From 4fc25214ae87131e88a991f05594d3b81a06a9f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 15:10:42 +0000 Subject: [PATCH 1/2] fix(metadata-protocol): run the ADR-0121 endpoint publish gates on `api` drafts in publishPackageDrafts (#5206 step 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `protocol.publishPackageDrafts` — the real entry point behind Studio's "publish everything" (ADR-0033 / ADR-0067 D2) — had exactly one type-aware pre-flight: the object namespace-prefix rule, gated on `d.type === 'object'`. An `api` draft was therefore promoted draft→active having met no gate at all, the same shape #5189 closed on `MetadataManager.publishPackage`, one path over. The gate reuses `validateApiEndpointDeclarations` (public from `@objectstack/spec/api` since #5203) verbatim — the same function the stack schema runs, the same function `publishPackage` runs, and the same `firstFailure` the endpoint matcher's load-time backstop runs. Refusal messages are the gate's own; nothing here restates a criterion for what is servable. Unlike `publishPackage`, this path already resolves the package's declared `manifest.namespace` for the object-prefix rule, so the FULL gate runs, namespace included (ADR-0121 D1/D2). Failure posture mirrors the pre-existing namespace-prefix rule exactly: a pre-flight refusal found before anything is promoted, whole batch withheld (`success: false`, `publishedCount: 0`, `published: []`), one entry per offending item in `failed[]`. Both violation classes now come back in one report instead of one class per publish attempt. The #5203 load-time backstop is untouched — this is the earlier door, not a replacement for the last one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7 --- .changeset/publish-drafts-endpoint-gate.md | 56 +++ ...tocol-publish-drafts-endpoint-gate.test.ts | 442 ++++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 214 ++++++++- 3 files changed, 701 insertions(+), 11 deletions(-) create mode 100644 .changeset/publish-drafts-endpoint-gate.md create mode 100644 packages/metadata-protocol/src/protocol-publish-drafts-endpoint-gate.test.ts diff --git a/.changeset/publish-drafts-endpoint-gate.md b/.changeset/publish-drafts-endpoint-gate.md new file mode 100644 index 0000000000..9e77ff079e --- /dev/null +++ b/.changeset/publish-drafts-endpoint-gate.md @@ -0,0 +1,56 @@ +--- +"@objectstack/metadata-protocol": patch +--- + +fix(metadata-protocol): `publishPackageDrafts` 现在对 `api` draft 跑 ADR-0121 端点发布门 (#5206 step 2) + +`protocol.publishPackageDrafts` 是 Studio「全部发布」的真实入径(ADR-0033 / +ADR-0067 D2)。在此之前,它唯一的按类型前置检查是对象命名空间前缀 +(`validateObjectNamespacePrefix`,仅 `d.type === 'object'`),于是一条 `api` +draft **不经任何一道门**就被提升为 `active` —— 与 #5189 在 +`MetadataManager.publishPackage` 上修掉的是同一形状、另一条路。 + +安全后果早已被 PR #5203 的装载期兜底挡住:端点匹配器在建索引时用同一个 +`firstFailure` 重判每一条存量条目,没过门的被排除出索引并 `error` 点名。所以 +这次修的是**拒绝得太晚**:ADR-0121 的原文是「publish 拒绝」,作者应当在 +publish 当场拿到点名 key 的处方,而不是到装载期日志里才发现自己的端点在答 +404。 + +**判据只有一份。** 本改动调用 `@objectstack/spec/api` 导出的 +`validateApiEndpointDeclarations`(#5203 公开)—— 就是 stack schema 跑的那个 +函数、`publishPackage` 跑的那个函数、装载期兜底跑的那个 `firstFailure`。拒绝 +文案直接用门函数自己的消息(已包含端点名、越界的 key 和改法),本包不复述任何 +一条「什么算可服务」的规则。 + +与 `publishPackage` 不同,这条路**有身份**:包的 `manifest.namespace` 本来就 +为对象前缀规则读过了,所以这里跑的是**全量门**,命名空间门(ADR-0121 D1/D2) +包含在内。命名空间门**不**以「包声明了 namespace」为条件 —— 门函数自己的前置 +判据(声明了 `apis:` 的 stack 必须显式声明 `manifest.namespace`)本身就是一条 +判据,对「压根没有 namespace」的包跳过它,等于给最不可能过编译期的那批包留一 +个洞。对象前缀规则对无 namespace 的包网开一面,是因为一个裸对象名只是命名气味; +一个无命名空间的端点是一个**无主 URL**。 + +**行为变化(用户可见)**: + +- 一条 `api` draft 若违反端点门(最典型:ADR-0121 D6 —— `authRequired: false` + 却没有 `rateLimit.enabled: true` 的预算),`publishPackageDrafts` 现在返回 + `success: false` / `publishedCount: 0`,该条目进入 `failed[]`,`code` + 为 `ENDPOINT_GATE`;body 连 `ApiEndpointSchema` 都不满足的,`code` 为 + `ENDPOINT_SCHEMA`(解析是判定的前置,不是第六道门 —— 判不了的形状也服务不 + 了)。 +- **失败粒度沿用既有语义,未发明新的批次语义**:与命名空间前缀违规完全一致, + 这是一次**提升任何东西之前**的前置拒绝,整批不落地(`published: []`),同批 + 的健康 draft 保持 draft 态。这既是 ADR-0067 D2 的「一次 commit 不能落一半」, + 也是 #5189 在另一条路上的同一姿势(`itemsPublished: 0`)。两类违规现在合并 + 在**同一份报告**里返回,作者一次往返就能看全。 +- 判定范围是**本批被提升的 draft**,与紧邻它的对象前缀规则一致。与同包已 + `active` 的端点撞车不在此拦截 —— 匹配器对全库重复声明有确定性裁决并 `error` + 点名(`buildEndpointIndex`);把范围扩到整包 active 集合意味着「因为你没在发 + 布的东西而拒绝这次发布」,那是另一份契约,不是一个 bug 修复。 + +装载期兜底(#5203)原样保留,未移除也未削弱:publish 是**更早**的那道门,不是 +最后那道门的替代品。 + +`api` 进 `DEFAULT_METADATA_TYPE_REGISTRY` / `BUILTIN_METADATA_TYPE_SCHEMAS` +(即 Studio 直写路径的 422)是 #5206 的第 1 步,拆在子单 #5271(spec 车道); +本改动**不依赖**它落地。 diff --git a/packages/metadata-protocol/src/protocol-publish-drafts-endpoint-gate.test.ts b/packages/metadata-protocol/src/protocol-publish-drafts-endpoint-gate.test.ts new file mode 100644 index 0000000000..06151dac45 --- /dev/null +++ b/packages/metadata-protocol/src/protocol-publish-drafts-endpoint-gate.test.ts @@ -0,0 +1,442 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; +import { identityFreeEndpointGateFailure, ApiEndpointSchema } from '@objectstack/spec/api'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +/** + * Regression for #5206 step 2 (engine half) — `publishPackageDrafts` runs the + * ADR-0121 / #5040 E7 endpoint publish gates on `api` drafts. + * + * ## What was open + * + * `protocol.publishPackageDrafts` is the REAL entry point behind Studio's + * "publish everything" (ADR-0033 / ADR-0067 D2). Its only type-aware pre-flight + * was the object namespace-prefix rule (`validateObjectNamespacePrefix`, gated + * on `d.type === 'object'`), so an `api` draft was promoted draft→active having + * met no gate at all — the same shape #5189 closed on + * `MetadataManager.publishPackage`, one path over. + * + * The SECURITY consequence was already contained by PR #5203: the endpoint + * matcher re-judges every stored item at index-build time with the same + * `firstFailure`, so an anonymous zero-quota endpoint that skipped publish is + * EXCLUDED from the index and named at `error` level. What was still broken is + * WHEN the author is told: ADR-0121 says publish refuses, and the author is + * owed a prescription naming the offending key at publish time — not a line in + * a boot log. These tests pin the earlier door; the last one is asserted to + * still be there (`identityFreeEndpointGateFailure`, bottom of the file). + * + * ## Failure granularity — mirrored, not invented + * + * The pre-existing posture for a namespace-prefix violation is a WHOLE-BATCH + * refusal found before anything is promoted: `success: false`, + * `publishedCount: 0`, `published: []`, every violation in `failed[]`. The + * endpoint gate joins that same pre-flight and behaves identically (ADR-0067 + * D2's "a commit cannot half-land", and what #5189 does on the sibling path + * with `itemsPublished: 0`). Tests below assert the healthy siblings of a bad + * `api` draft stay drafts. + */ + +interface Row { + id: string; + type: string; + name: string; + organization_id: string | null; + package_id: string | null; + state: string; + metadata: string; + checksum?: string; + version?: number; + updated_at?: string; + created_at?: string; +} + +interface HistoryRow { + id: string; + event_seq: number; + name: string; + type: string; + version: number; + operation_type: string; + metadata: string | null; + checksum: string | null; + previous_checksum: string | null; + change_note?: string | null; + source?: string | null; + organization_id: string | null; + recorded_by?: string | null; + recorded_at: string; +} + +// Overlay rows are keyed by the ADR-0048 key (type, name, org, state, package). +function keyOf(w: Record) { + return `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; +} + +/** Does row `r` satisfy `where` (top-level eq + `$or` + `organization_id IS NULL`)? */ +function matchesMetadataWhere(r: Row, where: Record): boolean { + for (const [k, v] of Object.entries(where)) { + if (k === '$or') { + const clauses = v as Array>; + if (!clauses.some((c) => matchesMetadataWhere(r, c))) return false; + continue; + } + if (v === undefined) continue; + if ((r as any)[k] !== v) return false; + } + return true; +} + +/** + * @param namespace the package's declared `manifest.namespace`, or `undefined` + * to model a package that declares none (`getPackage` → undefined). + */ +function makeStubEngine(namespace?: string) { + const rows = new Map(); + const historyRows: HistoryRow[] = []; + let nextId = 0; + + const findRow = (w: Record): { key: string; row: Row } | null => { + if (w.id !== undefined) { + for (const [k, r] of rows) if (r.id === w.id) return { key: k, row: r }; + return null; + } + if (w.package_id !== undefined) { + const k = keyOf(w); + const r = rows.get(k); + return r ? { key: k, row: r } : null; + } + for (const [k, r] of rows) if (matchesMetadataWhere(r, w)) return { key: k, row: r }; + return null; + }; + + const matchesHistory = (h: HistoryRow, w: Record): boolean => { + if (w.organization_id !== undefined && h.organization_id !== w.organization_id) return false; + if (w.type !== undefined && h.type !== w.type) return false; + if (w.name !== undefined && h.name !== w.name) return false; + if (w.version !== undefined && h.version !== w.version) return false; + if (w.operation_type !== undefined && h.operation_type !== w.operation_type) return false; + return true; + }; + + const engine: any = { + async findOne(table: string, opts: { where: Record }) { + if (table === 'sys_metadata_history') { + return historyRows.find((h) => matchesHistory(h, opts.where)) ?? null; + } + return findRow(opts.where)?.row ?? null; + }, + async find(table: string, opts: { where: Record }) { + if (table === 'sys_metadata_history') { + return historyRows.filter((h) => matchesHistory(h, opts.where)); + } + return Array.from(rows.values()).filter((r) => matchesMetadataWhere(r, opts.where)); + }, + async insert(table: string, data: Record) { + if (table === 'sys_metadata_audit') return { id: 'audit_skip' }; + if (table === 'sys_metadata_history') { + nextId += 1; + const h: HistoryRow = { id: `h_${nextId}`, ...(data as any) }; + historyRows.push(h); + return { id: h.id }; + } + nextId += 1; + const row = { id: `r_${nextId}`, ...(data as any) } as Row; + rows.set(keyOf(data), row); + return { id: row.id }; + }, + async update(_t: string, data: Record, opts: { where: Record }) { + const found = findRow(opts.where); + if (!found) return { id: null }; + const merged = { ...found.row, ...(data as any) }; + rows.delete(found.key); + rows.set(keyOf(merged), merged); + return { id: found.row.id }; + }, + async delete(_t: string, opts: { where: Record }) { + const found = findRow(opts.where); + if (!found) return { deleted: 0 }; + rows.delete(found.key); + return { deleted: 1 }; + }, + async transaction(cb: (ctx: any) => Promise): Promise { + return cb(undefined); + }, + registry: { + registerItem: () => {}, + registerObject: () => {}, + getPackage: () => + namespace === undefined ? undefined : { manifest: { id: 'app.showcase', namespace } }, + }, + }; + return { engine, rows }; +} + +const PKG = 'app.showcase'; + +/** A fully legal endpoint under the `showcase` carve-out (ADR-0121 D1). */ +const validEndpoint = (over: Record = {}) => ({ + name: 'list_things', + path: '/api/v1/apps/showcase/things', + method: 'GET', + type: 'object_operation', + target: 'showcase_thing', + objectParams: { object: 'showcase_thing', operation: 'find' }, + ...over, +}); + +const objectBody = (name: string) => ({ + name, + label: 'Thing', + fields: { title: { type: 'text', label: 'Title' } }, +}); + +/** Save one `api` draft the way the ungated Studio direct-write path does. */ +async function saveApiDraft( + protocol: ObjectStackProtocolImplementation, + name: string, + item: unknown, +): Promise { + await protocol.saveMetaItem({ type: 'api', name, item: item as any, packageId: PKG, mode: 'draft' }); +} + +const draftRows = (rows: Map) => Array.from(rows.values()).filter((r) => r.state === 'draft'); +const activeRows = (rows: Map) => Array.from(rows.values()).filter((r) => r.state === 'active'); + +describe('publishPackageDrafts — the ADR-0121 endpoint publish gate (#5206 step 2)', () => { + it('refuses an anonymous, unmetered `api` draft (D6) and NAMES the key to fix', async () => { + const { engine, rows } = makeStubEngine('showcase'); + const protocol = new ObjectStackProtocolImplementation(engine); + + // The exact shape ADR-0121 D6 exists to prevent, and the one the + // runtime honours faithfully: anonymous + no armed budget. + await saveApiDraft(protocol, 'open_things', validEndpoint({ name: 'open_things', authRequired: false })); + + // Before this fix: { success: true, publishedCount: 1 } — promoted + // straight to active, ungated. + const res = await protocol.publishPackageDrafts({ packageId: PKG }); + + expect(res).toMatchObject({ success: false, publishedCount: 0, failedCount: 1 }); + expect(res.published).toEqual([]); + expect(res.failed).toHaveLength(1); + expect(res.failed[0]).toMatchObject({ type: 'api', name: 'open_things', code: 'ENDPOINT_GATE' }); + // The gate's OWN message — named endpoint, named key, prescription. + expect(res.failed[0].error).toContain("Endpoint 'open_things'"); + expect(res.failed[0].error).toContain('`authRequired: false` without an ARMED rate limit'); + expect(res.failed[0].error).toContain('rateLimit: { enabled: true'); + + // Nothing was promoted: the draft is still a draft. + expect(activeRows(rows)).toHaveLength(0); + expect(draftRows(rows).map((r) => r.name)).toEqual(['open_things']); + }); + + it('a `rateLimit` that is present but NOT armed is still refused (D6 is not a presence check)', async () => { + const { engine } = makeStubEngine('showcase'); + const protocol = new ObjectStackProtocolImplementation(engine); + + await saveApiDraft( + protocol, + 'open_things', + validEndpoint({ + name: 'open_things', + authRequired: false, + // `enabled` defaults to false → a budget that meters nothing. + rateLimit: { windowMs: 60000, maxRequests: 100 }, + }), + ); + + const res = await protocol.publishPackageDrafts({ packageId: PKG }); + expect(res).toMatchObject({ success: false, publishedCount: 0, failedCount: 1 }); + expect(res.failed[0].error).toContain('`enabled` is not `true`'); + }); + + it('publishes a valid `api` draft (the gate refuses shapes, not the type)', async () => { + const { engine, rows } = makeStubEngine('showcase'); + const protocol = new ObjectStackProtocolImplementation(engine); + + await saveApiDraft(protocol, 'list_things', validEndpoint()); + + const res = await protocol.publishPackageDrafts({ packageId: PKG }); + + expect(res.failed).toEqual([]); + expect(res).toMatchObject({ success: true, publishedCount: 1, failedCount: 0 }); + expect(res.published.map((p) => p.name)).toEqual(['list_things']); + expect(draftRows(rows)).toHaveLength(0); + expect(activeRows(rows).map((r) => r.name)).toEqual(['list_things']); + }); + + it('an anonymous endpoint WITH an armed budget publishes (D6 is satisfiable, not a ban)', async () => { + const { engine, rows } = makeStubEngine('showcase'); + const protocol = new ObjectStackProtocolImplementation(engine); + + await saveApiDraft( + protocol, + 'open_things', + validEndpoint({ + name: 'open_things', + authRequired: false, + rateLimit: { enabled: true, windowMs: 60000, maxRequests: 100 }, + }), + ); + + const res = await protocol.publishPackageDrafts({ packageId: PKG }); + expect(res).toMatchObject({ success: true, publishedCount: 1, failedCount: 0 }); + expect(activeRows(rows).map((r) => r.name)).toEqual(['open_things']); + }); + + it('refuses a path outside the stack carve-out, and a duplicate METHOD+path claim', async () => { + const { engine } = makeStubEngine('showcase'); + const protocol = new ObjectStackProtocolImplementation(engine); + + await saveApiDraft( + protocol, + 'stray', + validEndpoint({ name: 'stray', path: '/api/v1/things' }), + ); + const strayRes = await protocol.publishPackageDrafts({ packageId: PKG }); + expect(strayRes).toMatchObject({ success: false, publishedCount: 0, failedCount: 1 }); + expect(strayRes.failed[0].error).toContain("not inside this stack's endpoint carve-out"); + expect(strayRes.failed[0].error).toContain('/api/v1/apps/showcase/'); + + // Two drafts in ONE batch claiming the same METHOD + normalized path. + const dup = makeStubEngine('showcase'); + const dupProtocol = new ObjectStackProtocolImplementation(dup.engine); + await saveApiDraft(dupProtocol, 'a_things', validEndpoint({ name: 'a_things' })); + await saveApiDraft( + dupProtocol, + 'b_things', + validEndpoint({ name: 'b_things', path: '/api/v1/apps/showcase/things/' }), + ); + const dupRes = await dupProtocol.publishPackageDrafts({ packageId: PKG }); + expect(dupRes).toMatchObject({ success: false, publishedCount: 0 }); + expect(dupRes.failed.some((f) => /already claimed by endpoint/.test(f.error))).toBe(true); + expect(dup.rows.size).toBe(2); + expect(activeRows(dup.rows)).toHaveLength(0); + }); + + it('refuses an `api` draft whose body does not even satisfy ApiEndpointSchema', async () => { + const { engine, rows } = makeStubEngine('showcase'); + const protocol = new ObjectStackProtocolImplementation(engine); + + // `api` has no entry in BUILTIN_METADATA_TYPE_SCHEMAS (that half is + // #5271, the spec lane), so the direct-write path stores arbitrary JSON + // verbatim. Parsing is the gate's precondition: an unparseable + // declaration cannot be judged and could never be served either. + await saveApiDraft(protocol, 'garbage', { name: 'garbage', totally: 'not an endpoint' }); + + const res = await protocol.publishPackageDrafts({ packageId: PKG }); + expect(res).toMatchObject({ success: false, publishedCount: 0 }); + expect(res.failed[0]).toMatchObject({ type: 'api', name: 'garbage', code: 'ENDPOINT_SCHEMA' }); + expect(res.failed[0].error).toContain('does not satisfy ApiEndpointSchema'); + expect(activeRows(rows)).toHaveLength(0); + }); + + it('refuses when the package declares NO namespace — the D2 precondition, reported once', async () => { + // `getPackage` → undefined: the object namespace-prefix rule + // grandfathers this (a bare object name is a naming smell), but an + // endpoint with no namespace is an UNOWNABLE URL, so the endpoint gate + // runs unconditionally and its own precondition fires. + const { engine, rows } = makeStubEngine(undefined); + const protocol = new ObjectStackProtocolImplementation(engine); + + await saveApiDraft(protocol, 'list_things', validEndpoint()); + await saveApiDraft(protocol, 'other_things', validEndpoint({ name: 'other_things', path: '/api/v1/apps/showcase/others' })); + + const res = await protocol.publishPackageDrafts({ packageId: PKG }); + + expect(res).toMatchObject({ success: false, publishedCount: 0 }); + // ONE report, not one per endpoint — the missing namespace is a + // property of the package, so it is unattributed (`name: ''`). + expect(res.failed).toHaveLength(1); + expect(res.failed[0]).toMatchObject({ type: 'api', name: '', code: 'ENDPOINT_GATE' }); + expect(res.failed[0].error).toContain('MUST declare an explicit `manifest.namespace`'); + // …plus THIS path's remedy (where to set it from here). + expect(res.failed[0].error).toContain('From `publishPackageDrafts` specifically'); + expect(activeRows(rows)).toHaveLength(0); + }); + + it('fails the WHOLE batch — healthy siblings of a bad `api` draft stay drafts', async () => { + const { engine, rows } = makeStubEngine('showcase'); + const protocol = new ObjectStackProtocolImplementation(engine); + + await protocol.saveMetaItem({ + type: 'object', + name: 'showcase_thing', + item: objectBody('showcase_thing'), + packageId: PKG, + mode: 'draft', + }); + await saveApiDraft(protocol, 'list_things', validEndpoint()); + await saveApiDraft(protocol, 'open_things', validEndpoint({ name: 'open_things', path: '/api/v1/apps/showcase/open', authRequired: false })); + + const res = await protocol.publishPackageDrafts({ packageId: PKG }); + + // Mirrors the namespace-prefix posture exactly: pre-flight refusal, + // nothing promoted — NOT "publish the two good ones". + expect(res).toMatchObject({ success: false, publishedCount: 0, failedCount: 1 }); + expect(res.published).toEqual([]); + expect(res.failed.map((f) => f.name)).toEqual(['open_things']); + expect(draftRows(rows).map((r) => r.name).sort()).toEqual(['list_things', 'open_things', 'showcase_thing']); + expect(activeRows(rows)).toHaveLength(0); + }); + + it('reports object namespace-prefix AND endpoint violations in ONE report', async () => { + const { engine } = makeStubEngine('showcase'); + const protocol = new ObjectStackProtocolImplementation(engine); + + await protocol.saveMetaItem({ + type: 'object', + name: 'thing', // missing the `showcase_` prefix + item: objectBody('thing'), + packageId: PKG, + mode: 'draft', + }); + await saveApiDraft(protocol, 'open_things', validEndpoint({ name: 'open_things', authRequired: false })); + + const res = await protocol.publishPackageDrafts({ packageId: PKG }); + + expect(res).toMatchObject({ success: false, publishedCount: 0, failedCount: 2 }); + expect(res.failed.map((f) => f.code).sort()).toEqual(['ENDPOINT_GATE', 'NAMESPACE_PREFIX']); + }); + + it('leaves non-`api` drafts alone — no endpoint gate, no behaviour change', async () => { + const { engine, rows } = makeStubEngine('showcase'); + const protocol = new ObjectStackProtocolImplementation(engine); + + await protocol.saveMetaItem({ + type: 'object', + name: 'showcase_thing', + item: objectBody('showcase_thing'), + packageId: PKG, + mode: 'draft', + }); + + const res = await protocol.publishPackageDrafts({ packageId: PKG }); + expect(res).toMatchObject({ success: true, publishedCount: 1, failedCount: 0 }); + expect(activeRows(rows).map((r) => r.name)).toEqual(['showcase_thing']); + }); +}); + +describe('the #5203 load-time backstop is still the LAST door, not a replaced one', () => { + /** + * The publish gate added by #5206 step 2 is the EARLIER door. This asserts + * the pairing rather than the matcher's wiring (which + * `packages/metadata`'s own suite owns): the very body publish now refuses + * is the body `identityFreeEndpointGateFailure` — the function + * `buildEndpointIndex` calls on every stored item — also refuses. If a + * future change relaxed one side, exactly one of these two expectations + * would flip, and a silently-served anonymous endpoint is what that would + * mean. + */ + it('the same D6 body publish refuses is the one the index-build backstop excludes', () => { + const parsed = ApiEndpointSchema.parse(validEndpoint({ name: 'open_things', authRequired: false })); + const failure = identityFreeEndpointGateFailure(parsed); + expect(failure).toBeDefined(); + expect(failure!.message).toContain('`authRequired: false` without an ARMED rate limit'); + }); + + it('and the body publish accepts is one the backstop lets into the index', () => { + const parsed = ApiEndpointSchema.parse(validEndpoint()); + expect(identityFreeEndpointGateFailure(parsed)).toBeUndefined(); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index c1c28940ec..f79fe0fc8c 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -43,6 +43,15 @@ import { } from '@objectstack/spec/kernel'; import { validateObjectNamespacePrefix, deriveNamespaceFromPackageId } from '@objectstack/spec/kernel'; import { stripReadDecorations } from '@objectstack/spec/kernel'; +// [#5206 step 2, #5040 E7 / ADR-0121] The endpoint publish gates, reused +// verbatim — see `gateApiDraftsForPublish`. `validateApiEndpointDeclarations` +// is the ONE judge of what is servable; this module calls it, it never restates +// a criterion. +import { + ApiEndpointSchema, + validateApiEndpointDeclarations, + type ApiEndpoint, +} from '@objectstack/spec/api'; import { z } from 'zod'; import { computeMetadataDiagnostics, @@ -117,6 +126,23 @@ function canonicalizeMetaRequestType(request: T): T return type === request.type ? request : { ...request, type }; } +/** + * [#5206 step 2] Where an author of THIS path sets the namespace the ADR-0121 + * D2 gate demands. + * + * The criterion itself is the endpoint gate's — this sentence only answers the + * follow-up question "so where do I put it, from here?", which differs per + * publish path (`MetadataManager.publishPackage` has its own, #5189). It is + * appended to the gate's own message, never in place of it. + */ +const PUBLISH_DRAFTS_NAMESPACE_REMEDY = + "From `publishPackageDrafts` specifically: the namespace is read from the package's registered " + + '`manifest.namespace`, so declare it on the manifest and re-install/update the package ' + + '(`installPackage` derives a default from the package id for Studio-authored packages, so an ' + + 'absent one means the package is not in the runtime registry or its manifest predates that). ' + + 'Alternatively publish the endpoints as part of a stack artifact (`defineStack` → compile → ' + + 'artifact ingest), which carries the manifest and runs these same gates at parse time.'; + /** * [#3770] One-shot flag for the "engine has no schema registry" warning emitted * by {@link ObjectStackProtocolImplementation.assertObjectRegistered}. The @@ -8115,6 +8141,140 @@ export class ObjectStackProtocolImplementation implements return { drafts }; } + /** + * [#5206 step 2, #5189, #5040 E7 / ADR-0121] Run the endpoint publish gates + * over a batch's `api` drafts and report every failure as a publish-blocking + * violation. + * + * ## Why it exists on THIS path + * + * E7 (#5111) hung the per-endpoint gates on `ObjectStackDefinitionSchema`, + * so every path that parses a STACK is covered. `publishPackageDrafts` + * parses no stack: it reads `sys_metadata` draft rows and promotes them. + * #5189 closed the same hole on `MetadataManager.publishPackage`; this is + * the other door, and the one Studio's "publish everything" button actually + * goes through (ADR-0033 / ADR-0067 D2). Until now the only type-aware + * pre-flight here was the object namespace-prefix check, so an `api` draft + * — anonymous, unmetered, whatever — became `active` unjudged. + * + * ## One judge, never a second criteria set + * + * Everything this method decides comes from + * {@link validateApiEndpointDeclarations}: the same function the stack + * schema runs, the same function `publishPackage` runs, and (minus the two + * identity-bearing gates) the same `firstFailure` the endpoint matcher's + * load-time backstop runs. The messages are the gate's own — they already + * name the endpoint, the offending key and the fix, which is the whole + * point of refusing HERE instead of in a boot log. Nothing in this file + * restates a rule about what is servable. + * + * ## Why it can run the FULL gate, namespace included + * + * Unlike `publishPackage` — which indexes by `packageId`, holds no manifest + * and must be handed a `namespace` — this path already resolves the + * package's declared `manifest.namespace` for the object-prefix rule. So + * the namespace gate (ADR-0121 D1/D2) is judgeable here and is judged. + * Deliberately NOT conditional on the namespace being present: the gate's + * own precondition ("a stack that declares `apis:` MUST declare an explicit + * `manifest.namespace`") is a criterion, and skipping it when the answer is + * "there is none" would reopen the hole for exactly the packages least + * likely to have been through a stack compile. The object-prefix rule above + * grandfathers namespace-less packages because a bare object name is a + * naming smell; a namespace-less endpoint is an unownable URL. + * + * ## Boundaries, stated rather than silently assumed + * + * - Judged over the drafts BEING PROMOTED, mirroring the object-prefix rule + * directly above it. A draft that collides with an already-`active` + * endpoint of the same package is therefore not caught here; the matcher + * resolves store-wide duplicate claims deterministically and names the + * loser at `error` level (`buildEndpointIndex`). Widening this to the + * package's whole active set would mean refusing a publish over something + * the author is not publishing — a different contract, not a bug fix. + * - A draft row that vanished between `listDrafts` and here is skipped, not + * invented into a gate failure: the promote loop reports the real + * `no_draft` for it. + * - A store read that FAILS propagates. "Could not read the draft" must + * never be answered with "the gate passed" (ADR-0110 D3's distinction, + * same reason the matcher refuses to turn an outage into a 404). + * + * @param drafts the batch's draft headers, as `listDrafts` returned them. + * @param namespace the package's declared `manifest.namespace`, or + * `undefined` when it declares none (the gate reports that itself). + * @returns one entry per violation, `[]` when the batch has no `api` + * drafts (a package without endpoints is untouched by this pass). + */ + private async gateApiDraftsForPublish( + drafts: ReadonlyArray<{ type: string; name: string; organizationId: string | null }>, + namespace: string | undefined, + ): Promise> { + const apiDrafts = drafts.filter((d) => canonicalMetaType(d.type) === 'api'); + if (apiDrafts.length === 0) return []; + + const violations: Array<{ type: string; name: string; error: string; code: string }> = []; + /** Parsed endpoints, index-aligned with {@link gatedNames}. */ + const endpoints: ApiEndpoint[] = []; + const gatedNames: string[] = []; + + for (const d of apiDrafts) { + const draftOrgId = d.organizationId ?? null; + const draftRepo = this.getOverlayRepo(draftOrgId); + const ref = { type: 'api', name: d.name, org: draftOrgId ?? 'env' } as unknown as Parameters[0]; + const draft = await draftRepo.get(ref, { state: 'draft' }); + if (!draft) continue; // raced away — the promote loop reports `no_draft` + + // Parsing is the gate's PRECONDITION, not a sixth gate: an + // `ApiEndpoint` is what `validateApiEndpointDeclarations` judges. + // Refusing an unparseable draft here is the same ruling #5189 made + // on the sibling path — a shape that cannot be gated could not be + // served either (the matcher's own loud skip refuses it at load), + // so publishing it would mint a route that answers 404 forever. + const parsed = ApiEndpointSchema.safeParse(draft.body); + if (!parsed.success) { + for (const issue of parsed.error.issues) { + violations.push({ + type: 'api', + name: d.name, + error: + `api draft '${d.name}' does not satisfy ApiEndpointSchema and cannot be published: ` + + `${issue.message} (at ${issue.path.join('.') || ''}). An endpoint that does ` + + `not parse cannot be gated (ADR-0121) and would be EXCLUDED from endpoint ` + + `matching at load anyway, so its declared route would answer 404.`, + code: 'ENDPOINT_SCHEMA', + }); + } + continue; + } + endpoints.push(parsed.data); + gatedNames.push(d.name); + } + + for (const issue of validateApiEndpointDeclarations(endpoints, { namespace })) { + // The gate reports per-endpoint issues at `['apis', , …]` and + // the namespace PRECONDITION once at `['apis']` — the latter is a + // property of the package, not of any one endpoint, so it is + // reported once, unattributed, with this path's own remedy appended. + const index = typeof issue.path[1] === 'number' ? issue.path[1] : undefined; + if (index === undefined) { + violations.push({ + type: 'api', + name: '', + error: `${issue.message} ${PUBLISH_DRAFTS_NAMESPACE_REMEDY}`, + code: 'ENDPOINT_GATE', + }); + continue; + } + violations.push({ + type: 'api', + name: gatedNames[index] ?? '', + error: issue.message, + code: 'ENDPOINT_GATE', + }); + } + + return violations; + } + /** * Publish every pending DRAFT bound to a package in one shot (ADR-0033) — * the "publish whole app" action. Promotes each draft→active by reusing the @@ -8184,24 +8344,56 @@ export class ObjectStackProtocolImplementation implements // install time (`installPackage`), so real Studio packages always have // one by the time they publish. const pkgNamespace = this.engine?.registry?.getPackage?.(request.packageId)?.manifest?.namespace; + /** + * Every PRE-FLIGHT refusal of this batch, in one report. + * + * The namespace-prefix rule established the posture and the + * ADR-0121 endpoint gates below join it: a violation is found BEFORE + * anything is promoted, and it fails the WHOLE batch + * (`publishedCount: 0`, `published: []`) rather than publishing the + * healthy siblings around it. That is not a choice made here — it is + * ADR-0067 D2's turn-atomicity ("a commit cannot half-land") read + * backwards onto the pre-flight, and it is what `MetadataManager. + * publishPackage` does with the same gates on the other path (#5189): + * one `validationErrors` list, `itemsPublished: 0`. + * + * Both gates run before the report is returned, so an author fixing a + * package sees the object-name violations AND the endpoint violations + * in one round trip instead of one class per publish attempt. + */ + const preflightViolations: Array<{ type: string; name: string; error: string; code: string }> = []; if (pkgNamespace) { - const nsViolations: Array<{ type: string; name: string; error: string; code: string }> = []; for (const d of drafts) { if (d.type !== 'object') continue; const err = validateObjectNamespacePrefix(d.name, pkgNamespace); - if (err) nsViolations.push({ type: d.type, name: d.name, error: err, code: 'NAMESPACE_PREFIX' }); - } - if (nsViolations.length > 0) { - return { - success: false, - publishedCount: 0, - failedCount: nsViolations.length, - published: [], - failed: nsViolations, - }; + if (err) preflightViolations.push({ type: d.type, name: d.name, error: err, code: 'NAMESPACE_PREFIX' }); } } + // [#5206 step 2, #5040 E7 / ADR-0121] The endpoint publish gates on the + // OTHER publish path. `MetadataManager.publishPackage` gained them in + // #5189; this function — the real entry point behind Studio's "publish + // everything" (ADR-0033 / ADR-0067 D2) — promoted `api` drafts to + // active without meeting a single gate. The security consequence is + // already caught one layer down (PR #5203 re-judges every stored item + // at index-build time and EXCLUDES the ones that never passed), so what + // this closes is the LATENESS: ADR-0121 says publish refuses, and an + // author is owed a prescription naming the offending key here, not an + // `error` line in a boot log they never read. The load-time backstop + // stays exactly where it is — this is the earlier door, not a + // replacement for the last one. + preflightViolations.push(...(await this.gateApiDraftsForPublish(drafts, pkgNamespace))); + + if (preflightViolations.length > 0) { + return { + success: false, + publishedCount: 0, + failedCount: preflightViolations.length, + published: [], + failed: preflightViolations, + }; + } + const published: Array<{ type: string; name: string; version: string }> = []; const failed: Array<{ type: string; name: string; error: string; code?: string; issues?: Array<{ path: string; message: string; code?: string }> }> = []; From 789a3084d06cd3104c7ac563d72f640f73ec9387 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 15:53:57 +0000 Subject: [PATCH 2/2] chore(scripts): record the #5206 endpoint-gate test's engine double as measured DEBT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:engine-double-contract` flagged the fake engine in protocol-publish-drafts-endpoint-gate.test.ts as unpinned. The prescribed fix — a devDependency on @objectstack/objectql so the fake's delete can route through assertEngineDeleteDispatch — was run to completion rather than assumed: @objectstack/objectql depends on @objectstack/metadata-protocol in `dependencies`, so the reverse edge makes turbo refuse the build graph (`Cyclic dependency detected: @objectstack/objectql#build, @objectstack/metadata-protocol#build`). The edge and lockfile were reverted. That is the same cycle, measured the same way, as the #4867 and #4981 entries already in this ledger, whose `closes` both point at #4987 (sink the predicate into a package both sides already depend on). This is the sixth metadata-protocol file to hit a route those entries record as closed. The entry is DEBT, not EXEMPT. It records that the double is structurally looser than the contract; it does not claim the looseness is currently harmless. The probe finding — delete is exercised, and only as a scalar `{ where: { id } }` — is recorded in `why` as a fact about this file, which is explicitly not an argument about the contract, per this ledger's own rule. Purely additive: one entry, no existing entry reordered, reworded or lowered. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7 --- scripts/engine-double-contract.baseline.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scripts/engine-double-contract.baseline.json b/scripts/engine-double-contract.baseline.json index 552f8a4739..7923b8372d 100644 --- a/scripts/engine-double-contract.baseline.json +++ b/scripts/engine-double-contract.baseline.json @@ -24,6 +24,13 @@ "than EXEMPT." ], "entries": [ + { + "file": "packages/metadata-protocol/src/protocol-publish-drafts-endpoint-gate.test.ts", + "unguarded": 1, + "kind": "DEBT", + "why": "MEASURED (#5206): the devDependency route this ledger's other metadata-protocol entries prescribe is CYCLIC, re-measured on this branch rather than cited. @objectstack/objectql depends on @objectstack/metadata-protocol in `dependencies` (`workspace:*`), so the reverse edge was added, `pnpm install` run, and turbo refused the graph outright — `WARNING Circular package dependency detected: @objectstack/objectql, @objectstack/metadata-protocol` / `x Cyclic dependency detected: @objectstack/objectql#build, @objectstack/metadata-protocol#build` (turbo 2.10.7, `turbo run build --filter=@objectstack/metadata-protocol --dry`) — then the edge and lockfile were reverted. Same cycle, same measurement method as the #4867 and #4981 entries below; this is the sixth metadata-protocol file to hit the route those entries already recorded as closed. The fake's delete was probed by replacing it with a throw: it IS exercised (the promote drops the published draft row) and only ever as `{ where: { id } }`, a scalar by-id delete routed through SysMetadataRepository.delete, so removing the method to escape the scan was not available either. But that is an argument about this file, not about the contract, so the entry stays DEBT rather than EXEMPT per this ledger's own rule.", + "closes": "sink assertEngineDeleteDispatch into a package BOTH sides already depend on — tracked as #4987 — then open the fake's delete with it; the devDependency route is closed by the cycle above, for this file and for the five sibling metadata-protocol entries alike" + }, { "file": "packages/metadata-protocol/src/protocol-publish-drafts-org-scope.test.ts", "unguarded": 1,