From 5f8391b51a1a5c028485bfdbeb1bcb2017c99cc2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 07:28:05 +0000 Subject: [PATCH 1/2] feat(rest): declared endpoints join the OpenAPI enrichment pipeline (#5040 E6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET {basePath}/openapi.json` has one owner and a real boot proved it is `packages/rest` (#5078: a 355KB OpenAPI 3.1 document with a Host-injected `servers[0]`, 199 paths after `{object}` expansion and two `x-template` markers — every one a rest-server fingerprint). So the documentation face for `apis:` endpoints joins that pipeline instead of a `generateOpenApi` on a metadata service, which would have been the second owner ADR-0076 forbids. Each declaration contributes its literal `path`, the lower-cased method as the Operation key, `operationId` = `name`, and the only two documentation fields the frozen vocabulary carries (`summary` / `description`, omitted when absent rather than replaced by a generated stand-in). Everything else emitted is a fact about how the executor treats the declaration, each cited to its authority: the `query.id` parameter for object_operation get/update/delete, 201 for create and 200 otherwise, and 501 for script / proxy / an object_operation missing its `objectParams`. No invented request or response schemas — the shipped document's `components.schemas` is empty (#5168), so an emitted `$ref` would dangle like the six built-in ones already do. `authRequired` is materialised by the parse, so authenticated operations point at a security scheme read off the document itself (not a scheme name hard-coded in rest, which would be a second place to keep right) and `authRequired: false` emits an explicit `security: []`. Items failing `ApiEndpointSchema` are skipped loudly and named; a duplicate method+path is resolved by lexicographically-first `name`, the endpoint matcher's own rule, so the document cannot name an endpoint the runtime does not run; a declaration never displaces a built-in path+method. Live behaviour is unchanged: publish still rejects a non-empty `apis:` until the E7 flip, so the enumeration is empty and the enricher returns its input document by reference — pinned by tests rather than argued. Refs #5093, #5040 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EYGdmvWP1ieZSLqvAW6uyd --- .changeset/openapi-declared-endpoints.md | 16 + packages/rest/src/openapi-endpoints.test.ts | 312 ++++++++++++++++ packages/rest/src/openapi-endpoints.ts | 374 +++++++++++++++++++ packages/rest/src/rest-openapi-route.test.ts | 125 +++++++ packages/rest/src/rest-server.ts | 52 ++- 5 files changed, 876 insertions(+), 3 deletions(-) create mode 100644 .changeset/openapi-declared-endpoints.md create mode 100644 packages/rest/src/openapi-endpoints.test.ts create mode 100644 packages/rest/src/openapi-endpoints.ts create mode 100644 packages/rest/src/rest-openapi-route.test.ts diff --git a/.changeset/openapi-declared-endpoints.md b/.changeset/openapi-declared-endpoints.md new file mode 100644 index 0000000000..71635ac189 --- /dev/null +++ b/.changeset/openapi-declared-endpoints.md @@ -0,0 +1,16 @@ +--- +"@objectstack/rest": minor +"@objectstack/runtime": patch +--- + +**声明式端点进 OpenAPI 文档;`/openapi.json` 的影子属主摘除(#5040 E6,并入 #5078)** + +`GET {basePath}/openapi.json` 只有一个属主,而且实测坐实是 `packages/rest`(#5078:真实 boot 拿到 355KB 的 OpenAPI 3.1 文档,`servers[0]` 按 Host 注入、`{object}` 展开出 199 条 paths、两条 `x-template` —— 三个指纹全部是 rest-server 的行为)。因此 `apis:` 端点的文档面加入 **rest-server 既有的 enrichment 管线**(与 `{object}` 展开同根、同一次请求、同样 best-effort),而**不是**在某个 metadata service 上实现 `generateOpenApi` —— 那会造出 ADR-0076 第 1 条明令禁止的第二属主。E1 的契约成员因此已剔除。 + +每条声明贡献一个 path 条目:`path` 原样、`method` 小写作为 Operation 键、`operationId` = `name`,以及词表**真正带有**的两个文档字段 `summary` / `description`(缺省即缺省,不生成替身)。除此之外只写「执行器会怎么对待这条声明」的事实,逐条注明出处:`object_operation` 的 `get`/`update`/`delete` 记录 id 取 `query.id`(词表无路径模板语法)、`create` 答 201 其余 200、`script` / `proxy` 与缺 `objectParams` 的 `object_operation` 答 **501**。不编造任何 request/response schema —— 出厂文档的 `components.schemas` 是空的,凭空写 `$ref` 只会得到悬空引用。 + +`authRequired` 由 schema parse 物化(缺省即 `true`),为 true 的条目引用**从文档自身读出**的 security 方案(不在 rest 里硬写方案名,否则就是第二处需要保持正确的地方),为 false 的条目写显式 `security: []` —— 这是 review 时一眼能看见的那个形状。不满足 `ApiEndpointSchema` 的存量条目**响亮跳过**并点名(与端点匹配器的装载门同一姿态);同 `method+path` 撞车时按「`name` 字典序在前者胜」裁决,与匹配器**同一条规则**,否则文档会指认一个运行时并不执行的端点;撞上内建路径时内建保留,声明被略过并报错。 + +同时摘除 `http-dispatcher.ts` 里的 `generateOpenApi` 探测死分支:该方法在本仓与两个兄弟仓**零实现**,且 boot 实测**没有任何路由**把 `/openapi.json` 送进 `dispatch()` —— 双重死。`route-ledger.ts` 里对应的行与 `LEGACY_CHAIN_PREFIXES` 条目一并移除(原注记「falls through when metadata service lacks a generator」把「从来没有」写成了「有时没有」,正是 #5078 立单的失准点;把 prefix 留在一张自述为「if-chain 分支」的清单里,会在同一个 PR 里再造一次同样的谎)。该路由的唯一台账行在 `packages/rest/src/rest-route-ledger.ts`,一直是准的。 + +**现网行为零变更**:publish / validate 对非空 `apis:` 仍然硬拒(E7 前不撤),所以今天枚举出的是空集,enrichment 原样返回同一个文档对象 —— 服务出去的字节与本次改动前逐字节相同,并有测试钉住。 diff --git a/packages/rest/src/openapi-endpoints.test.ts b/packages/rest/src/openapi-endpoints.test.ts new file mode 100644 index 0000000000..908ac50ae4 --- /dev/null +++ b/packages/rest/src/openapi-endpoints.test.ts @@ -0,0 +1,312 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #5040 E6 — declared endpoints in the OpenAPI document. + * + * Two jobs, and the second one is the load-bearing one TODAY: + * + * 1. the positive shapes, driven straight through the pure enrichment with + * parsed declarations (publish still refuses to let any of them exist, so + * there is no boot that could exercise them end to end yet); + * 2. the empty-set invariant — with no declarations the document must come + * back not merely equivalent but IDENTICAL, because that is the entire + * live-behaviour claim this change makes until the E7 flip. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ApiEndpointSchema, type ApiEndpoint } from '@objectstack/spec/api'; +import { + buildEndpointOperation, + enrichOpenApiWithEndpoints, + resolveSecurityRequirement, + selectDocumentableEndpoints, +} from './openapi-endpoints'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** A document shaped like the one `@objectstack/spec/openapi.json` ships. */ +function baseDoc() { + return { + openapi: '3.1.0', + info: { title: 'ObjectStack API', version: '17.0.0' }, + servers: [{ url: 'http://localhost:3000' }], + paths: { + '/api/{object}': { get: { operationId: 'listRecords' }, post: { operationId: 'createRecord' } }, + '/api/meta': { get: { operationId: 'getMeta' } }, + }, + components: { schemas: {}, securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer' } } }, + security: [{ bearerAuth: [] }], + }; +} + +/** Parse a declaration the way the store's readers do — defaults materialised. */ +function endpoint(input: Record): ApiEndpoint { + return ApiEndpointSchema.parse(input); +} + +const OBJECT_FIND = { + name: 'list_tasks', + path: '/api/v1/apps/showcase/tasks', + method: 'GET', + type: 'object_operation', + target: 'showcase_task', + objectParams: { object: 'showcase_task', operation: 'find' }, +}; + +function collectingLogger() { + return { error: vi.fn() }; +} + +// --------------------------------------------------------------------------- +// The invariant this change stands on +// --------------------------------------------------------------------------- + +describe('empty set — the served document does not move (#5093)', () => { + it('returns the SAME document object when no api items are declared', () => { + const doc = baseDoc(); + expect(enrichOpenApiWithEndpoints(doc, [])).toBe(doc); + }); + + it('serialises byte-identically to the un-enriched document', () => { + const before = JSON.stringify(baseDoc()); + const after = JSON.stringify(enrichOpenApiWithEndpoints(baseDoc(), [])); + expect(after).toBe(before); + }); + + it('is byte-identical when every declared item is unparseable, too', () => { + // The degenerate middle case: items exist, none survive the schema. The + // document must land exactly where the empty case lands rather than + // sprouting an empty `paths` rewrite. + const logger = collectingLogger(); + const doc = baseDoc(); + const out = enrichOpenApiWithEndpoints(doc, [{ nope: true }, null], logger); + expect(out).toBe(doc); + expect(JSON.stringify(out)).toBe(JSON.stringify(baseDoc())); + }); + + it('leaves the base document untouched when endpoints ARE added', () => { + // The enricher must copy, never mutate: the base spec is cached across + // requests, so a mutation would leak one request's endpoints into every + // later response. + const doc = baseDoc(); + const snapshot = JSON.stringify(doc); + const out = enrichOpenApiWithEndpoints(doc, [OBJECT_FIND]); + expect(out).not.toBe(doc); + expect(JSON.stringify(doc)).toBe(snapshot); + expect(Object.keys((out as any).paths)).toContain('/api/v1/apps/showcase/tasks'); + }); +}); + +// --------------------------------------------------------------------------- +// Entry shape, per endpoint type +// --------------------------------------------------------------------------- + +describe('path entries', () => { + it('emits the literal path and the lower-cased method', () => { + const out = enrichOpenApiWithEndpoints(baseDoc(), [ + { ...OBJECT_FIND, method: 'DELETE', objectParams: { object: 'showcase_task', operation: 'delete' } }, + ]) as any; + const item = out.paths['/api/v1/apps/showcase/tasks']; + expect(Object.keys(item)).toEqual(['delete']); + expect(item.delete.operationId).toBe('list_tasks'); + }); + + it('carries `summary` / `description` when declared and omits them when not', () => { + const documented = buildEndpointOperation( + endpoint({ ...OBJECT_FIND, summary: 'List tasks', description: 'Open tasks for the caller.' }), + undefined, + ); + expect(documented.summary).toBe('List tasks'); + expect(documented.description).toBe('Open tasks for the caller.'); + + const bare = buildEndpointOperation(endpoint(OBJECT_FIND), undefined); + expect(bare).not.toHaveProperty('summary'); + expect(bare).not.toHaveProperty('description'); + }); + + it('object_operation find: no id parameter, no body, 200', () => { + const op = buildEndpointOperation(endpoint(OBJECT_FIND), undefined); + expect(op).not.toHaveProperty('parameters'); + expect(op).not.toHaveProperty('requestBody'); + expect(Object.keys(op.responses as object)).toContain('200'); + }); + + it.each(['get', 'update', 'delete'] as const)( + 'object_operation %s: documents the required `id` query parameter', + (operation) => { + const op = buildEndpointOperation( + endpoint({ ...OBJECT_FIND, method: 'POST', objectParams: { object: 'showcase_task', operation } }), + undefined, + ); + const params = op.parameters as Array>; + expect(params).toHaveLength(1); + expect(params[0]).toMatchObject({ name: 'id', in: 'query', required: true }); + }, + ); + + it('object_operation create: request body plus a 201', () => { + const op = buildEndpointOperation( + endpoint({ ...OBJECT_FIND, method: 'POST', objectParams: { object: 'showcase_task', operation: 'create' } }), + undefined, + ); + expect(op.requestBody).toEqual({ + required: true, + content: { 'application/json': { schema: { type: 'object' } } }, + }); + expect(Object.keys(op.responses as object)).toContain('201'); + }); + + it('never invents a response schema — only descriptions', () => { + // The shipped document has ZERO component schemas (#5168), so any `$ref` + // this module emitted would dangle. Descriptions are the honest maximum. + const op = buildEndpointOperation( + endpoint({ ...OBJECT_FIND, method: 'POST', objectParams: { object: 'showcase_task', operation: 'create' } }), + undefined, + ); + for (const response of Object.values(op.responses as Record)) { + expect(Object.keys(response)).toEqual(['description']); + } + }); + + it('flow: the body is the flow input', () => { + const op = buildEndpointOperation( + endpoint({ name: 'purge', path: '/api/v1/apps/showcase/purge', method: 'POST', type: 'flow', target: 'janitor' }), + undefined, + ); + expect(op.requestBody).toBeDefined(); + expect(Object.keys(op.responses as object)).toContain('200'); + }); + + it('omits the request body on methods that do not carry one', () => { + const op = buildEndpointOperation( + endpoint({ name: 'purge', path: '/api/v1/apps/showcase/purge', method: 'GET', type: 'flow', target: 'janitor' }), + undefined, + ); + expect(op).not.toHaveProperty('requestBody'); + }); + + it.each(['script', 'proxy'] as const)('%s is documented as 501, not as working', (type) => { + // Declared ≠ enforced is the defect this program removes; a document that + // advertised these as live endpoints would re-create it in the one artifact + // consumers generate clients from. + const op = buildEndpointOperation( + endpoint({ name: 'x', path: '/api/v1/apps/showcase/x', method: 'POST', type, target: 'whatever' }), + undefined, + ); + const responses = op.responses as Record; + expect(Object.keys(responses)).toContain('501'); + expect(responses['501'].description).toMatch(/does not execute/); + expect(responses).not.toHaveProperty('200'); + }); + + it('an object_operation missing objectParams is documented as 501', () => { + const op = buildEndpointOperation( + endpoint({ name: 'x', path: '/api/v1/apps/showcase/x', method: 'GET', type: 'object_operation', target: 't' }), + undefined, + ); + expect(Object.keys(op.responses as object)).toContain('501'); + }); +}); + +// --------------------------------------------------------------------------- +// authRequired → security +// --------------------------------------------------------------------------- + +describe('authRequired → security', () => { + it('reads the requirement off the document rather than hard-coding a scheme', () => { + expect(resolveSecurityRequirement(baseDoc())).toEqual([{ bearerAuth: [] }]); + // No document-level default → fall back to the first declared scheme. + expect( + resolveSecurityRequirement({ components: { securitySchemes: { apiKey: {}, bearerAuth: {} } } } as any), + ).toEqual([{ apiKey: [] }]); + // Nothing declared → say nothing. + expect(resolveSecurityRequirement({} as any)).toBeUndefined(); + }); + + it('authRequired defaults to true and points at the document scheme', () => { + const parsed = endpoint(OBJECT_FIND); + expect(parsed.authRequired).toBe(true); + const out = enrichOpenApiWithEndpoints(baseDoc(), [OBJECT_FIND]) as any; + const op = out.paths['/api/v1/apps/showcase/tasks'].get; + expect(op.security).toEqual([{ bearerAuth: [] }]); + expect(op.responses).toHaveProperty('401'); + }); + + it('authRequired: false emits an explicit empty security list', () => { + const out = enrichOpenApiWithEndpoints(baseDoc(), [{ ...OBJECT_FIND, authRequired: false }]) as any; + const op = out.paths['/api/v1/apps/showcase/tasks'].get; + expect(op.security).toEqual([]); + expect(op.responses).not.toHaveProperty('401'); + }); + + it('does not share security-requirement objects with the base document', () => { + const doc = baseDoc(); + const out = enrichOpenApiWithEndpoints(doc, [OBJECT_FIND]) as any; + expect(out.paths['/api/v1/apps/showcase/tasks'].get.security[0]).not.toBe(doc.security[0]); + }); +}); + +// --------------------------------------------------------------------------- +// Loud skips +// --------------------------------------------------------------------------- + +describe('invalid and conflicting declarations are skipped loudly', () => { + it('drops an item that fails ApiEndpointSchema and names it', () => { + const logger = collectingLogger(); + const kept = selectDocumentableEndpoints( + [{ name: 'Bad Name', path: 'no-leading-slash', method: 'GET', type: 'flow', target: 't' }, OBJECT_FIND], + logger, + ); + expect(kept.map((e) => e.name)).toEqual(['list_tasks']); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error.mock.calls[0][0]).toContain("'Bad Name'"); + expect(logger.error.mock.calls[0][0]).toContain('OMITTED'); + }); + + it('resolves a duplicate route claim the way the endpoint matcher does', () => { + // `buildEndpointIndex` keeps the lexicographically-first `name`. If this + // module picked differently the document would name an endpoint the + // runtime does not run — a lie with a straight face. + const logger = collectingLogger(); + const kept = selectDocumentableEndpoints( + [ + { ...OBJECT_FIND, name: 'zeta' }, + { ...OBJECT_FIND, name: 'alpha' }, + ], + logger, + ); + expect(kept.map((e) => e.name)).toEqual(['alpha']); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error.mock.calls[0][0]).toContain('duplicate endpoint claim'); + }); + + it('treats a trailing slash as the same route the matcher treats it as', () => { + const kept = selectDocumentableEndpoints([ + { ...OBJECT_FIND, name: 'alpha', path: '/api/v1/apps/showcase/tasks' }, + { ...OBJECT_FIND, name: 'beta', path: '/api/v1/apps/showcase/tasks/' }, + ]); + expect(kept).toHaveLength(1); + }); + + it('never displaces a built-in path+method (ADR-0076)', () => { + const logger = collectingLogger(); + const doc = baseDoc(); + const out = enrichOpenApiWithEndpoints(doc, [{ ...OBJECT_FIND, path: '/api/meta', method: 'GET' }], logger) as any; + // The built-in operation is untouched, and since that was the only + // declaration nothing was added — same document, byte for byte. + expect(out).toBe(doc); + expect(out.paths['/api/meta'].get.operationId).toBe('getMeta'); + expect(JSON.stringify(out)).toBe(JSON.stringify(baseDoc())); + expect(logger.error.mock.calls[0][0]).toContain('one owner'); + }); + + it('merges a new method into a path a built-in already describes', () => { + const out = enrichOpenApiWithEndpoints(baseDoc(), [ + { ...OBJECT_FIND, path: '/api/meta', method: 'POST', objectParams: { object: 'x', operation: 'create' } }, + ]) as any; + expect(out.paths['/api/meta'].get.operationId).toBe('getMeta'); + expect(out.paths['/api/meta'].post.operationId).toBe('list_tasks'); + }); +}); diff --git a/packages/rest/src/openapi-endpoints.ts b/packages/rest/src/openapi-endpoints.ts new file mode 100644 index 0000000000..a016f10a4a --- /dev/null +++ b/packages/rest/src/openapi-endpoints.ts @@ -0,0 +1,374 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * DECLARED ENDPOINTS → OpenAPI path entries (#5040 E6, closing #5078). + * + * ## Why this lives in `packages/rest` + * + * `GET {basePath}/openapi.json` has exactly ONE owner, and a real boot proved + * which: `rest-server.ts` answers it with a 355KB enriched document (Host- + * injected `servers[0]`, 199 paths after `{object}` expansion, two `x-template` + * fingerprints — all three are rest-server behaviours). The dispatcher carried + * a `metaSvc.generateOpenApi` probe for the same path that was DOUBLY dead: + * no implementation of the method has ever existed in this repo or its two + * siblings, and no route delivered `/openapi.json` into `dispatch()` in the + * first place. #5078 recorded both facts; the E6 design amendment on #5040 drew + * the ADR-0076 conclusion — the documentation face joins the owner that already + * serves the route, and the shadow branch is deleted rather than implemented. + * Implementing `generateOpenApi` on a metadata service would have created the + * second owner that ADR-0076 §1 exists to forbid. + * + * So: this module extends the SAME enrichment pipeline that expands `{object}` + * placeholders. Same request, same document, same best-effort posture. + * + * ## What it will and will not say + * + * The `ApiEndpointSchema` vocabulary is FROZEN (#5040 §0) and carries exactly + * two documentation fields — `summary` and `description`. Everything else this + * module emits is either the declaration read back verbatim (`path`, `method`, + * `name`→`operationId`) or a fact about how the executor will treat that + * declaration, mirrored from `endpoint-executor.ts` with the line cited. There + * is no third category: no invented request/response schemas, no guessed + * summaries, no `x-` extensions. A published OpenAPI document is a contract + * consumers generate clients from — a fabricated schema in it is worse than an + * absent one, because absence is visible and fiction is not. + * + * ## The mirrored facts, and why they are mirrored rather than imported + * + * The execution semantics live in `@objectstack/runtime` (`planEndpointTarget`, + * `executeObjectOperation`) and the documentation face lives here, in a package + * that does not — and per the layering should not — depend on the runtime. The + * three facts restated below are each one line, each cited to its authority, + * and each covered by a test that fails if this file drifts: + * + * 1. `object_operation` takes its record id from `query.id` for `get` / + * `update` / `delete` (`endpoint-executor.ts` `requireRecordId`) — the + * frozen vocabulary has no path-template syntax, so a declared endpoint + * cannot express `/{id}`. + * 2. `create` answers 201, everything else 200 (`successAnswer` call sites). + * 3. `script` / `proxy`, and an `object_operation` missing its `objectParams`, + * answer 501 (`planEndpointTarget`'s `unsupported` arm). Documenting those + * as if they worked is the declared≠enforced defect this program exists to + * remove, so they are documented as what they are. + * + * A shared, spec-level description of an endpoint's HTTP contract would delete + * the mirror; it is not built here because Prime Directive #2 keeps logic out + * of `packages/spec` and the frozen vocabulary is not this unit's to widen. + * + * ## Today it emits nothing + * + * Publish/validate still rejects a non-empty `apis:` until the E7 flip, so the + * enumeration yields an empty set and {@link enrichOpenApiWithEndpoints} + * returns its input document BY REFERENCE — the served bytes are identical to + * before this change. That invariant is pinned by a test rather than argued. + */ + +import { ApiEndpointSchema, type ApiEndpoint } from '@objectstack/spec/api'; + +/** The slice of an OpenAPI document this module reads and rewrites. */ +export interface OpenApiDocumentLike { + paths?: Record; + security?: unknown; + [key: string]: unknown; +} + +/** + * Where a rejected declaration is reported. + * + * Same posture as the endpoint matcher's load gate (`buildEndpointIndex`): a + * stored item that fails `ApiEndpointSchema` is skipped LOUDLY and named, never + * documented half-parsed. A document that describes a declaration the matcher + * refuses to serve is the same class of lie as the ledger note #5078 was filed + * about. + */ +export interface EndpointDocLogger { + error(message: string, meta?: unknown): void; +} + +const DEFAULT_LOGGER: EndpointDocLogger = { + error: (message, meta) => + meta === undefined + ? (globalThis as { console?: Console }).console?.error(message) + : (globalThis as { console?: Console }).console?.error(message, meta), +}; + +/** + * Trim exactly one trailing slash, never from a lone `/`. + * + * Mirrors `normalizeEndpointPath` (`@objectstack/metadata` endpoint-matcher), + * restated rather than imported so the HTTP face does not take a runtime + * dependency on the metadata engine for one line. It matters that the two + * agree: the matcher treats `/x` and `/x/` as one route, so a document that + * listed both would advertise a route nobody serves. + */ +function normalizeDeclaredPath(path: string): string { + const raw = String(path ?? ''); + if (raw.length > 1 && raw.endsWith('/')) return raw.slice(0, -1); + return raw; +} + +/** OpenAPI Path Item keys are the lower-cased method names. */ +function operationKey(method: string): string { + return String(method ?? '').toLowerCase(); +} + +/** + * The security requirement an authenticated endpoint points at. + * + * Read OFF THE DOCUMENT (its own `security`, else its first declared scheme) + * instead of hard-coding a scheme name here. The document is generated from + * `packages/spec`; naming `bearerAuth` in this file would create a second + * place that has to be right, and it would go stale silently — the document + * would still parse, the operation would just point at a scheme that no longer + * exists. Returns `undefined` when the document declares no security at all, + * in which case authenticated operations simply say nothing (and inherit + * whatever the document-level default is, per the OpenAPI object model). + */ +export function resolveSecurityRequirement(doc: OpenApiDocumentLike): unknown[] | undefined { + const docSecurity = doc.security; + if (Array.isArray(docSecurity) && docSecurity.length > 0) { + return docSecurity.map((req) => (req && typeof req === 'object' ? { ...(req as object) } : req)); + } + const schemes = (doc.components as { securitySchemes?: Record } | undefined)?.securitySchemes; + const first = schemes && typeof schemes === 'object' ? Object.keys(schemes)[0] : undefined; + return first ? [{ [first]: [] }] : undefined; +} + +/** How the executor will treat this declaration — the mirrored facts, in one place. */ +interface EndpointHttpFacts { + /** Status the executor answers on success (or 501 when it cannot execute). */ + successStatus: number; + /** `true` when the executor reads a record id off `query.id`. */ + requiresRecordId: boolean; + /** `true` when the executor reads the request body. */ + readsBody: boolean; + /** Set when the runtime refuses to execute the declaration at all. */ + unsupportedReason?: string; +} + +/** + * Mirror of `planEndpointTarget` + the `object_operation` operation table + * (`endpoint-executor.ts`), reduced to the four facts a path entry needs. + */ +function httpFactsFor(endpoint: ApiEndpoint): EndpointHttpFacts { + if (endpoint.type === 'object_operation') { + const object = endpoint.objectParams?.object; + const operation = endpoint.objectParams?.operation; + if (!object || !operation) { + return { + successStatus: 501, + requiresRecordId: false, + readsBody: false, + unsupportedReason: + "declares type 'object_operation' without both `objectParams.object` and `objectParams.operation`", + }; + } + return { + successStatus: operation === 'create' ? 201 : 200, + requiresRecordId: operation === 'get' || operation === 'update' || operation === 'delete', + readsBody: operation === 'create' || operation === 'update', + }; + } + + if (endpoint.type === 'flow') { + if (!endpoint.target) { + return { + successStatus: 501, + requiresRecordId: false, + readsBody: false, + unsupportedReason: "declares type 'flow' but names no target flow", + }; + } + // The request body is the flow input, exactly as on + // `POST /automation/:name/trigger`. + return { successStatus: 200, requiresRecordId: false, readsBody: true }; + } + + return { + successStatus: 501, + requiresRecordId: false, + readsBody: false, + unsupportedReason: `declares type '${endpoint.type}', which this runtime does not execute in 17.x`, + }; +} + +/** Methods that carry a request body in HTTP, and therefore in the document. */ +const BODY_METHODS = new Set(['POST', 'PUT', 'PATCH']); + +/** + * One OpenAPI Operation Object for one declaration. + * + * Exported for the tests that assert the shape per endpoint type; the enricher + * below is the only production caller. + */ +export function buildEndpointOperation( + endpoint: ApiEndpoint, + securityRequirement: unknown[] | undefined, +): Record { + const facts = httpFactsFor(endpoint); + const operation: Record = { operationId: endpoint.name }; + + // `summary` / `description` are the ONLY documentation the frozen vocabulary + // carries. Absent means absent — no derived stand-in, because a generated + // sentence reads exactly like an authored one and cannot be told apart later. + if (endpoint.summary) operation.summary = endpoint.summary; + if (endpoint.description) operation.description = endpoint.description; + + if (facts.requiresRecordId) { + operation.parameters = [ + { + name: 'id', + in: 'query', + required: true, + schema: { type: 'string' }, + description: + 'Record id. Declared endpoints take it from the query string — the endpoint vocabulary defines no path templates.', + }, + ]; + } + + if (facts.readsBody && BODY_METHODS.has(endpoint.method)) { + // Free-form object, deliberately: the executor forwards the body (through + // `inputMapping`, when declared) to the same pipeline the built-in route + // uses, and this document has no per-object schemas to point at — its + // `components.schemas` is in fact EMPTY today (#5168), so a `$ref` emitted + // here would dangle exactly as the six built-in ones already do. An empty + // `type: object` says "a JSON object, shape not described here", which is + // true; naming fields we have not derived would not be. + operation.requestBody = { + required: true, + content: { 'application/json': { schema: { type: 'object' } } }, + }; + } + + const responses: Record = {}; + if (facts.unsupportedReason) { + responses['501'] = { + description: `Not implemented — this endpoint ${facts.unsupportedReason}.`, + }; + } else { + responses[String(facts.successStatus)] = { + description: facts.successStatus === 201 ? 'Created' : 'Success', + }; + } + if (endpoint.authRequired) responses['401'] = { description: 'Unauthorized' }; + operation.responses = responses; + + // `authRequired` is `true` by default and materialised by the parse above, so + // there is no "unset" state to document. An explicit `security: []` is the + // OpenAPI way to say "this operation takes no credentials" — the one shape a + // reviewer should be able to spot in a diff. + if (endpoint.authRequired) { + if (securityRequirement) operation.security = securityRequirement; + } else { + operation.security = []; + } + + return operation; +} + +/** + * Parse the stored `api` items, dropping (loudly) everything that is not a + * valid declaration, and resolve duplicate route claims the way the matcher + * does. + * + * Exported for tests. The duplicate rule — lexicographically-first `name` keeps + * the route — is `buildEndpointIndex`'s, restated here for the same reason it + * was chosen there: it is total, deterministic across nodes and boots, and it + * must produce the SAME winner, or the document would name an endpoint the + * runtime does not run. + */ +export function selectDocumentableEndpoints( + items: readonly unknown[], + logger: EndpointDocLogger = DEFAULT_LOGGER, +): ApiEndpoint[] { + const byRoute = new Map(); + + for (const item of items) { + const parsed = ApiEndpointSchema.safeParse(item); + if (!parsed.success) { + const declaredName = + item && typeof item === 'object' && typeof (item as { name?: unknown }).name === 'string' + ? (item as { name: string }).name + : ''; + logger.error( + `[REST] stored api item '${declaredName}' does not satisfy ApiEndpointSchema — it is OMITTED ` + + `from the OpenAPI document. The endpoint matcher excludes it too, so the route it declares ` + + `answers 404; fix the declaration rather than the document.`, + { issues: parsed.error.issues }, + ); + continue; + } + + const endpoint = parsed.data; + const key = `${endpoint.method.toUpperCase()} ${normalizeDeclaredPath(endpoint.path)}`; + const incumbent = byRoute.get(key); + if (!incumbent) { + byRoute.set(key, endpoint); + continue; + } + + const challengerWins = endpoint.name < incumbent.name; + if (challengerWins) byRoute.set(key, endpoint); + const winner = challengerWins ? endpoint : incumbent; + const loser = challengerWins ? incumbent : endpoint; + logger.error( + `[REST] duplicate endpoint claim on '${key}': api items '${incumbent.name}' and '${endpoint.name}' ` + + `both declare it. '${winner.name}' is documented and '${loser.name}' is OMITTED — the rule is ` + + `lexicographically-first \`name\` wins, the same one the endpoint matcher applies, so the ` + + `document and the runtime name the same owner.`, + { key, documented: winner.name, omitted: loser.name }, + ); + } + + return [...byRoute.values()]; +} + +/** + * Fold declared endpoints into an OpenAPI document's `paths`. + * + * Returns `doc` ITSELF when there is nothing to add — that is what makes the + * empty-set case byte-identical rather than merely equivalent, and it is the + * state of the world until the E7 flip lets a non-empty `apis:` publish. + * + * A declaration never displaces a built-in: if the document already describes + * the same path+method, the built-in keeps it and the declaration is reported. + * ADR-0076's rule is one route, one owner; where the two could disagree, the + * package that actually mounts the route wins, in the document as on the wire. + */ +export function enrichOpenApiWithEndpoints( + doc: T, + items: readonly unknown[], + logger: EndpointDocLogger = DEFAULT_LOGGER, +): T { + if (!Array.isArray(items) || items.length === 0) return doc; + + const endpoints = selectDocumentableEndpoints(items, logger); + if (endpoints.length === 0) return doc; + + const securityRequirement = resolveSecurityRequirement(doc); + const paths: Record = { ...(doc.paths ?? {}) }; + let added = 0; + + for (const endpoint of endpoints) { + const path = normalizeDeclaredPath(endpoint.path); + const key = operationKey(endpoint.method); + const existing = paths[path] as Record | undefined; + + if (existing && typeof existing === 'object' && key in existing) { + logger.error( + `[REST] declared endpoint '${endpoint.name}' claims '${endpoint.method} ${path}', which this ` + + `document already describes as a built-in route. The built-in KEEPS it and the declaration is ` + + `OMITTED from the document — a path has one owner (ADR-0076), and the owner is whoever mounts it.`, + { endpoint: endpoint.name, method: endpoint.method, path }, + ); + continue; + } + + paths[path] = { ...(existing ?? {}), [key]: buildEndpointOperation(endpoint, securityRequirement) }; + added += 1; + } + + if (added === 0) return doc; + return { ...doc, paths }; +} diff --git a/packages/rest/src/rest-openapi-route.test.ts b/packages/rest/src/rest-openapi-route.test.ts new file mode 100644 index 0000000000..33488b2e13 --- /dev/null +++ b/packages/rest/src/rest-openapi-route.test.ts @@ -0,0 +1,125 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `GET {basePath}/openapi.json` at the route level (#5040 E6, #5078). + * + * The pure enrichment is unit-tested in `openapi-endpoints.test.ts`; what this + * file covers is the part only the server can answer — that the handler really + * asks the protocol for `api` items alongside `object` items, and that with the + * empty set the world's response is unchanged. + * + * This route has ONE owner. #5078 established it with a real boot after a + * shadow `generateOpenApi` branch in the dispatcher had spent months looking + * like a second one; these assertions are the cheap standing version of that + * boot, so the ownership claim stops depending on somebody re-running it. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server'; + +function makeServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn(), close: vi.fn(), + } as any; +} + +/** + * A protocol that records which metadata types were enumerated and answers + * each from `items`, in the `{ type, items }` envelope `getMetaItems` declares. + */ +function makeProtocol(items: Record) { + const asked: string[] = []; + const protocol: any = { + getMetaItems: vi.fn(async ({ type }: { type: string }) => { + asked.push(type); + return { type, items: items[type] ?? [] }; + }), + }; + return { protocol, asked }; +} + +/** Drive the registered `GET {base}/openapi.json` handler and read the body. */ +async function serveOpenApi(protocol: any) { + const rest = new RestServer(makeServer(), protocol, { api: { requireAuth: false, version: 'v1' } } as any); + (rest as any).registerOpenApiEndpoints('/api/v1'); + const entry = (rest as any).routeManager.get('GET', '/api/v1/openapi.json'); + expect(entry, 'the openapi.json route must be registered by this package').toBeDefined(); + + let status = 200; + let body: any; + const res: any = { + status: (c: number) => { status = c; return res; }, + json: (b: any) => { body = b; }, + setHeader: () => {}, + send: () => {}, + }; + await entry.handler({ headers: { host: 'example.test' }, params: {}, path: '/api/v1/openapi.json' }, res); + return { status, body }; +} + +const TASKS_ENDPOINT = { + name: 'list_tasks', + path: '/api/v1/apps/showcase/tasks', + method: 'GET', + type: 'object_operation', + target: 'showcase_task', + objectParams: { object: 'showcase_task', operation: 'find' }, + summary: 'List showcase tasks', +}; + +describe('GET /api/v1/openapi.json — endpoint enrichment', () => { + it('enumerates `api` items alongside `object` items', async () => { + const { protocol, asked } = makeProtocol({ object: [], api: [] }); + await serveOpenApi(protocol); + expect(asked).toContain('object'); + expect(asked).toContain('api'); + }); + + it('serves a document identical to the pre-#5093 one while no endpoint is declared', async () => { + // The load-bearing invariant: publish rejects a non-empty `apis:` until the + // E7 flip, so this is the ONLY state that exists in production today, and + // the change is required to be invisible in it. Compared against the same + // handler fed a protocol with no `api` capability at all — i.e. the world + // exactly as it was before the enrichment step existed. + const withEmptyApis = await serveOpenApi(makeProtocol({ object: [], api: [] }).protocol); + const withoutApiSupport = await serveOpenApi({ getMetaItems: vi.fn(async () => ({ type: 'object', items: [] })) }); + + expect(withEmptyApis.status).toBe(200); + expect(JSON.stringify(withEmptyApis.body)).toBe(JSON.stringify(withoutApiSupport.body)); + }); + + it('adds one path entry per declared endpoint', async () => { + const { protocol } = makeProtocol({ object: [], api: [TASKS_ENDPOINT] }); + const { body } = await serveOpenApi(protocol); + const item = body.paths['/api/v1/apps/showcase/tasks']; + expect(item).toBeDefined(); + expect(item.get.operationId).toBe('list_tasks'); + expect(item.get.summary).toBe('List showcase tasks'); + // `authRequired` defaults to true, so it points at the document's scheme. + expect(item.get.security).toEqual(body.security); + }); + + it('still serves the document when the api enumeration throws', async () => { + // A metadata store outage must cost the endpoint section, never the + // document — the base spec and the object expansion are independent of it. + const protocol: any = { + getMetaItems: vi.fn(async ({ type }: { type: string }) => { + if (type === 'api') throw new Error('store unavailable'); + return { type, items: [] }; + }), + }; + const { status, body } = await serveOpenApi(protocol); + expect(status).toBe(200); + expect(body.openapi).toBe('3.1.0'); + }); + + it('keeps serving `{object}` expansion unchanged alongside the new step', async () => { + const { protocol } = makeProtocol({ object: [{ name: 'showcase_task' }], api: [] }); + const { body } = await serveOpenApi(protocol); + const expanded = Object.keys(body.paths).filter((p) => p.includes('showcase_task')); + expect(expanded.length).toBeGreaterThan(0); + // The template row survives, marked, exactly as before. + expect(body.paths['/api/{object}']['x-template']).toBe(true); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index f55dbaac58..b64d0899ae 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -43,6 +43,7 @@ import { } from './export-format.js'; import { runImport } from './import-runner.js'; import { prepareImportRequest, isMetaEnvelope } from './import-prepare.js'; +import { enrichOpenApiWithEndpoints } from './openapi-endpoints.js'; // Node-safe logger — avoids importing 'console' which is absent from ES2020 lib typings. const logError = (...args: unknown[]) => (globalThis as any).console?.error(...args); @@ -2528,6 +2529,14 @@ export class RestServer { * - paths — `{object}` placeholders expanded into * one concrete path per registered object * from the protocol's discovery metadata + * - paths — one entry per declared `api` endpoint + * (#5040 E6, see openapi-endpoints.ts) + * + * This package is the SOLE owner of the route (ADR-0076, proven by the + * real boot in #5078), which is why the endpoint documentation joins this + * pipeline instead of a `generateOpenApi` on some metadata service — that + * would have been the second owner ADR-0076 forbids. The dispatcher's + * probe for such a method was deleted in the same change. * * The base spec is loaded lazily from @objectstack/spec/openapi.json * (shipped pre-generated by spec's build pipeline) so we don't pay @@ -2550,7 +2559,7 @@ export class RestServer { // Clone shallowly so per-request mutations (server URL, // expanded paths) don't bleed into the cached base spec. - const enriched: any = { ...spec, servers: [...(spec.servers ?? [])] }; + let enriched: any = { ...spec, servers: [...(spec.servers ?? [])] }; // 1) Override servers[0] with the actual request origin so // "Try it" works straight from the docs viewer. @@ -2565,12 +2574,22 @@ export class RestServer { ]; } + // Metadata-driven enrichment (steps 2 and 3) reads through one + // resolved protocol, but each step carries its own `try`: they + // describe different surfaces, and a failure to enumerate one + // must not silently blank the other. + let protocol: RestProtocol | undefined; + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + protocol = await this.resolveProtocol(environmentId, req); + } catch { + // Enrichment is best-effort — never fail the spec serve. + } + // 2) Expand `{object}` path placeholders into concrete // routes for every registered data object. Falls back // silently if discovery isn't available. try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const protocol = await this.resolveProtocol(environmentId, req); const items = await protocol?.getMetaItems?.({ type: 'object' }).catch(() => null) as any; const objects: string[] = Array.isArray(items?.items) ? items.items.map((i: any) => i?.name).filter(Boolean) @@ -2598,6 +2617,33 @@ export class RestServer { // Enrichment is best-effort — never fail the spec serve. } + // 3) Fold in the endpoints declared as `api` metadata (#5040 + // E6). Same enumeration root as the `{object}` expansion + // above — this document is the ONE documentation face for + // `/openapi.json`, which this package alone serves (#5078, + // ADR-0076): declared endpoints join it here rather than + // growing a second generator somewhere else. + // + // Until the E7 flip a non-empty `apis:` cannot publish, so + // the enumeration is empty and `enrichOpenApiWithEndpoints` + // hands `enriched` straight back — the served bytes today + // are exactly the ones served before this change. + try { + const apiResult = await protocol?.getMetaItems?.({ type: 'api' }); + const apiItems: unknown[] = Array.isArray((apiResult as any)?.items) + ? (apiResult as any).items + : Array.isArray(apiResult) ? apiResult as unknown[] : []; + enriched = enrichOpenApiWithEndpoints(enriched, apiItems, { + error: (message: string, meta?: unknown) => + meta === undefined ? logError(message) : logError(message, meta), + }); + } catch (err: any) { + // A store that cannot be read must not take the document + // down with it — but say so, because a silently endpoint- + // less document looks exactly like a correct one. + logError('[REST] openapi.json endpoint enrichment skipped:', err?.message ?? err); + } + // Surface the runtime version so consumers don't pin to // the spec package's compile-time version. if (enriched.info) { From b77363dcdbf9c136c9edf6cb50db4a19a0152bb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 07:28:25 +0000 Subject: [PATCH 2/2] refactor(runtime): drop the doubly-dead `/openapi.json` branch and its ledger row (#5078) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dispatcher carried a `GET /openapi.json` branch that duck-typed a `generateOpenApi` method on the metadata service. No implementation has ever provided it — not `MetadataManager`, not `NodeMetadataManager`, not a plugin, not either sibling repo; the only other repo-wide hits for the name are an unrelated boolean config key and its tests. The `if` was constant-false on every request ever served. It was dead a second way, which is what settles the disposition rather than merely arguing it: the real boot in #5078 showed nothing routes `/openapi.json` into `dispatch()` at all. `packages/rest` owns and answers the path end to end, and there is no ownership contest to resolve — only a shadow to remove (ADR-0076 "one route, one owner": a second implementation of a path another package already serves is code `grep` finds and the runtime never runs). The ledger moves with it. The row's note said the path "falls through when metadata service lacks a generator", which reads as sometimes-yes; it was always, so the note was the machine-readable surface ADR-0076 §4 forbids — that inaccuracy is exactly what #5078 was filed about. The row is removed rather than reworded: this ledger enumerates the routes THIS package's dispatcher serves, and it now serves none there. `/openapi.json` leaves `LEGACY_CHAIN_PREFIXES` for the same reason — that list means "branches of the `dispatch()` if-chain", and keeping a prefix in it for a branch that no longer exists would tell the same class of lie in the very change that stops one. Cited comments stay at both sites pointing at the real owner and its truthful row in `packages/rest/src/rest-route-ledger.ts`. Refs #5093, #5040 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EYGdmvWP1ieZSLqvAW6uyd --- packages/runtime/src/http-dispatcher.ts | 38 +++++++++++++++++-------- packages/runtime/src/route-ledger.ts | 24 ++++++++++++++-- 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 41c9ee9676..dfaf9ff429 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -1658,18 +1658,32 @@ export class HttpDispatcher { // /share-links moved to the domain registry (D11 step ③). - // OpenAPI Specification - if (cleanPath === '/openapi.json' && method === 'GET') { - try { - const metaSvc = await this.resolveService('metadata', context.environmentId); - if (metaSvc && typeof (metaSvc as any).generateOpenApi === 'function') { - const result = await (metaSvc as any).generateOpenApi({}); - return { handled: true, response: this.success(result) }; - } - } catch (e) { - // If not implemented, fall through or return 404 - } - } + // OpenAPI specification — REMOVED in #5093 (#5078), NOT relocated. + // + // A `GET /openapi.json` branch used to sit here. It resolved the + // metadata service and duck-typed a `generateOpenApi` method that no + // implementation has ever provided — not `MetadataManager`, not + // `NodeMetadataManager`, not a plugin, not either sibling repo. The + // only other repo-wide hits for the name are an unrelated boolean + // config key (`api/ApiDocumentationConfig.generateOpenApi`) and its + // tests. So the `if` was constant-false on every request ever served. + // + // It was dead a second way too, which is what settles the disposition: + // a real boot (#5078 — showcase, 47 plugins, RestAPI and Dispatcher + // co-mounted) showed NOTHING routes `/openapi.json` into `dispatch()` + // at all. `packages/rest` owns and answers it — `rest-server.ts` + // returned a 355KB OpenAPI 3.1 document with a Host-injected + // `servers[0]`, 199 expanded paths and two `x-template` markers, every + // one of them a rest-server fingerprint. + // + // Deleted rather than implemented, per ADR-0076 "one route, one owner": + // a second implementation of a path another package already serves is + // code `grep` finds and the runtime never runs — the exact input that + // makes the next reader reason confidently from dead code. The declared + // endpoints' `summary`/`description` reach the document through the + // owner's own enrichment pipeline instead (#5040 E6, + // `packages/rest/src/openapi-endpoints.ts`). Do not re-add a branch + // here; the ledger row went with it. // 2. Metadata-declared custom endpoints (`apis:`) — REMOVED in #4936. // diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 7ef4ee187e..3a5b57f439 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -102,7 +102,17 @@ export const LEGACY_CHAIN_PREFIXES = [ '/mcp/skill', '/mcp', '/actions', - '/openapi.json', + // `/openapi.json` was REMOVED in #5093 (closing #5078). It was pinned here + // for a `dispatch()` branch that duck-typed a `generateOpenApi` method + // nothing implements, over a path nothing routes into `dispatch()` — dead + // twice over, and deleted with the branch. The route is real and healthy; + // `packages/rest` owns it end to end (`rest-server.ts`, and the row in + // `rest-route-ledger.ts` that describes it truthfully). Leaving the prefix + // pinned here would have made THIS list say "a branch of the if-chain" + // about something that is not one — the same way the row's note said the + // route "falls through when metadata service lacks a generator" when in + // fact no generator has ever existed. That is the failure #5078 was filed + // about; a list is not allowed to lie in the same PR that stops one. // `/__api-endpoint` (the `handleApiEndpoint` catch-all for metadata-declared // `apis:`) was REMOVED in #4936. It never named a mounted route: the branch // it stood for resolved a `matchEndpoint` method no implementation in this @@ -292,7 +302,17 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ }, // ── misc legacy ─────────────────────────────────────────────────────────── - { route: 'GET /openapi.json', domain: '/openapi.json', disposition: 'server-only', note: 'docs tooling; falls through when metadata service lacks a generator' }, + // `GET /openapi.json` removed in #5093 (closing #5078). This ledger + // enumerates the routes THIS package's dispatcher serves, and after the dead + // `generateOpenApi` branch was deleted it serves none at that path. The route + // itself is alive and owned by `packages/rest` — see the `GET + // /api/v1/openapi.json` row in `packages/rest/src/rest-route-ledger.ts`, + // which is the one place that describes it. The removed note here claimed the + // path "falls through when metadata service lacks a generator", implying a + // generator sometimes exists; none ever has, in this repo or its two + // siblings, so the row was 100% fall-through wearing a conditional. Per + // ADR-0076 §4 a machine-readable surface must not lie, and per §1 a path has + // one owner: do not re-add a row (or a dispatcher branch) for this path. // `* (unmatched)` / `/__api-endpoint` removed in #4936 — see LEGACY_CHAIN_PREFIXES // above. It was the ledger's only row for a surface nothing served; an // unmatched path now falls to the semantic 404 with no pretence otherwise.