From c4f3d2fc661ad0baa3f9d9f0f5ccb48ca0cf3c03 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 09:20:40 +0000 Subject: [PATCH 1/2] fix(rest): say what openapi.json `info.version` carries, and drop the fallback that contradicted it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET {basePath}/openapi.json` built `info.version` as `this.config.api.version || enriched.info.version` under a comment promising "the runtime version so consumers don't pin to the spec package's compile-time version". Both halves were false. The served value has always been `api.version` — the API version identifier `normalizeConfig()` defaults to 'v1' and `getApiBasePath()` uses to build the mount. The runtime version never reached the field. OpenAPI 3.1 defines `info.version` as "the version of the OpenAPI document (which is distinct from the OpenAPI Specification version or the API implementation version)", so the runtime version is the one reading the field's own definition excludes. This is why the field does NOT inherit the ruling that settled `DiscoverySchema.version`, where "System Identity" means the serving artifact. The comment is corrected to say what the value is and where the runtime version is served instead (`/discovery`, `/health`). The `|| enriched.info.version` fallback is removed. It was reachable rather than dead: `normalizeConfig` defaults with `??` and `PluginRestApiSchema` declares a bare `z.string()`, so a configured `api.version: ''` arrives falsy — measured, that published `@objectstack/spec`'s compile-time version, the exact value the old comment said the line existed to keep off the wire. A falsy `api.version` now serves itself. Four pins in the route's test twin fix the meaning: the declared identifier is served and differs from the artifact's own version, a custom `api.version` is tracked, an `OS_RUNTIME_VERSION` stamp does not reach the field, and an empty `api.version` no longer falls back. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR --- ...openapi-info-version-is-the-api-version.md | 26 ++++++ packages/rest/src/rest-openapi-route.test.ts | 79 +++++++++++++++++++ packages/rest/src/rest-server.ts | 33 +++++++- 3 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 .changeset/openapi-info-version-is-the-api-version.md diff --git a/.changeset/openapi-info-version-is-the-api-version.md b/.changeset/openapi-info-version-is-the-api-version.md new file mode 100644 index 0000000000..9f5207f35a --- /dev/null +++ b/.changeset/openapi-info-version-is-the-api-version.md @@ -0,0 +1,26 @@ +--- +'@objectstack/rest': patch +--- + +`GET {basePath}/openapi.json` no longer falls back to the spec package's +compile-time version when `api.version` is configured empty + +The served `info.version` has always carried the API version identifier +(`api.version`, default `'v1'`), under a comment claiming it carried "the +runtime version so consumers don't pin to the spec package's compile-time +version". Both halves were false: the runtime version never reached the field, +and the `|| enriched.info.version` fallback published exactly the compile-time +version the comment said the line existed to avoid. + +The fallback was reachable rather than dead — `normalizeConfig` defaults with +`??` and `PluginRestApiSchema` declares a bare `z.string()`, so a configured +`api.version: ''` arrives falsy and the document advertised +`@objectstack/spec`'s package version. It now serves the configured value as +written, so a misconfigured deployment stays visibly misconfigured instead of +silently switching the field to a different kind of fact. Every non-empty +`api.version` — including the default — serves exactly what it served before. + +`info.version` is deliberately not the runtime version: OpenAPI 3.1 defines it +as "the version of the OpenAPI document (which is distinct from the OpenAPI +Specification version or the API implementation version)". Callers who want the +serving artifact read `{basePath}/discovery` or `/health`. diff --git a/packages/rest/src/rest-openapi-route.test.ts b/packages/rest/src/rest-openapi-route.test.ts index 59cabf3026..d1bd20ec6d 100644 --- a/packages/rest/src/rest-openapi-route.test.ts +++ b/packages/rest/src/rest-openapi-route.test.ts @@ -369,3 +369,82 @@ describe('#5588 — built-in routes come from rest, not from the static artifact } }); }); + +describe('GET /openapi.json — what `info.version` carries (#11546)', () => { + // The line under test used to read + // `version: this.config.api.version || enriched.info.version` + // under a comment promising "the runtime version so consumers don't pin to + // the spec package's compile-time version". Both halves were false, and + // nothing pinned either one, so the document could have drifted to any of + // three different facts without a test noticing. These four fix what the + // field means. + // + // OpenAPI 3.1, Info Object: `version` is "the version of the OpenAPI + // document (which is distinct from the OpenAPI Specification version or the + // API implementation version)". The runtime version is the implementation + // version, so it is the one value the field's own definition excludes — + // which is why this is NOT the shape #11292 settled for `/discovery`, where + // `DiscoverySchema.version` means the serving artifact by #10993. + + it('serves the declared API version identifier, not the artifact version', async () => { + const rest = makeRest(makeProtocol({ object: [], api: [] }).protocol); + const artifact = await (rest as any).loadOpenApiSpec(); + const { body } = await serveOpenApiFrom(rest); + + expect(body.info.version).toBe('v1'); + // The serve path deliberately overrides the producer here, so the pin is + // only meaningful while the two values actually differ — if they ever + // converge this assertion says so instead of passing vacuously. + expect( + artifact.info.version, + 'the artifact must carry a DIFFERENT version for the override pin above to mean anything', + ).not.toBe('v1'); + expect(body.info.version).not.toBe(artifact.info.version); + }); + + it('tracks a custom `api.version`, which is also the mount segment', async () => { + const rest = makeRest(makeProtocol({ object: [], api: [] }).protocol, { version: 'v9' }); + const { body } = await serveOpenApiFrom(rest, '/api/v9'); + expect(body.info.version).toBe('v9'); + }); + + it('is not the runtime version — an `OS_RUNTIME_VERSION` stamp does not reach it', async () => { + // The anti-regression pin for the direction this card did NOT take. Were + // the field re-pointed at `resolveDiscoveryVersion()`, the sentinel below + // would land in the served document and this goes red. + const SENTINEL = '9.9.9-openapi-info-version-sentinel'; + const old = process.env.OS_RUNTIME_VERSION; + process.env.OS_RUNTIME_VERSION = SENTINEL; + try { + const rest = makeRest(makeProtocol({ object: [], api: [] }).protocol); + const { body } = await serveOpenApiFrom(rest); + expect(body.info.version).toBe('v1'); + expect(JSON.stringify(body.info)).not.toContain(SENTINEL); + } finally { + if (old === undefined) delete process.env.OS_RUNTIME_VERSION; + else process.env.OS_RUNTIME_VERSION = old; + } + }); + + it('serves a falsy `api.version` as itself rather than falling back to the artifact', async () => { + // The removed `|| enriched.info.version` was reachable, not dead: + // `normalizeConfig` defaults with `??` and `PluginRestApiSchema` declares a + // bare `z.string()`, so `version: ''` arrives here falsy. Measured on the + // pre-fix code this served the spec package's compile-time version — the + // exact value the old comment said the line existed to keep off the wire. + // + // An empty version is a broken deployment either way (the mount doubles its + // slash, below). The point of the pin is that it stays visibly broken + // instead of quietly publishing a different kind of fact. + const rest = makeRest(makeProtocol({ object: [], api: [] }).protocol, { version: '' }); + expect( + (rest as any).getApiBasePath(), + 'this pin describes the empty-version mount — if normalization starts rejecting it, retire the pin', + ).toBe('/api/'); + + const artifact = await (rest as any).loadOpenApiSpec(); + const { body } = await serveOpenApiFrom(rest, '/api/'); + expect(body.info.version).toBe(''); + expect(body.info.version).not.toBe(artifact.info.version); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 95c26ffd28..705ff916a7 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -3619,12 +3619,39 @@ export class RestServer { 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. + // `info.version` carries the API version identifier this + // deployment declares (`api.version`, which `normalizeConfig` + // defaults to `'v1'`) — the same value that builds the default + // mount (`${basePath}/${version}` -> `/api/v1`), though a + // deployment that sets `apiPath` moves the mount without + // moving this. + // + // It is deliberately NOT the runtime version, and the comment + // this replaces ("surface the runtime version so consumers + // don't pin to the spec package's compile-time version") had it + // backwards in both halves. OpenAPI 3.1 defines the field as + // "the version of the OpenAPI document (which is distinct from + // the OpenAPI Specification version or the API implementation + // version)" — the runtime version IS the implementation + // version, the one reading the field's own definition rules + // out. That fact is not lost: `{basePath}/discovery` and + // `/health` both answer with it, derived from + // `OS_RUNTIME_VERSION` (#10993/#11235/#11292), so a caller who + // wants the serving artifact asks a producer that means it. + // + // No `|| enriched.info.version` fallback. It was reachable, + // not dead: `normalizeConfig` defaults with `??` and the + // schema declares a bare `z.string()`, so a configured empty + // version reaches here falsy — and firing the fallback + // published the spec package's compile-time version, the one + // value the old comment claimed this line existed to keep off + // the wire. A falsy `api.version` now serves itself, so a + // misconfigured deployment reads as misconfigured instead of + // silently switching this field to a different kind of fact. if (enriched.info) { enriched.info = { ...enriched.info, - version: this.config.api.version || enriched.info.version, + version: this.config.api.version, }; } From 5728f071a183909e74beab36da444f29bbaaa9bb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 10:31:04 +0000 Subject: [PATCH 2/2] docs(rest): correct the mechanism these comments give for the removed fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comments shipped in the previous commit named `PluginRestApiSchema`, which does not exist in this repo, and said it declares a bare `z.string()` that permits an empty `api.version`. Both halves are wrong, and on a card about a comment falsified by the line it introduces they are exactly the wrong thing to ship. Measured on origin/main: - The export at packages/spec/src/api/plugin-rest-api.zod.ts:625 is `RestApiPluginConfigSchema` (the file name transposed into the symbol). It is referenced nowhere outside its own file, its own test and the api-surface manifest, and it is not the type of anything on this path. - The schema that governs this field is `RestApiConfigSchema` (packages/spec/src/api/rest-server.zod.ts:48). `RestApiPluginConfig.api` is typed `RestServerConfig`, `RestServerConfigSchema.api` is `RestApiConfigSchema.optional()`, and `RestApiConfig = z.input` is what `normalizeConfig` casts to. - That schema does NOT permit an empty version. It declares `version: z.string().regex(/^[a-zA-Z0-9_\-\.]+$/).default('v1')`, and the `+` quantifier refuses `''`. So the fallback's reachability has the opposite cause from the one recorded: the contract forbids `''`, and it arrives anyway because nothing parses the config against the contract. Both hops in are casts (`config.api as any` in rest-api-plugin.ts:388, `as Partial` in rest-server.ts), the REST plugin declares no `configSchema` for the kernel's plugin-config validator, and the repo's only `RestApiConfigSchema.parse` call parses `{}` in a QA helper. The regex never executes on a deployment path, so `??` is the only guard and it does not catch the empty string. Comments and changeset prose only — no behaviour change, and the removal of the fallback stands unchanged on either account of why it could fire. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR --- ...openapi-info-version-is-the-api-version.md | 10 ++++--- packages/rest/src/rest-openapi-route.test.ts | 12 +++++---- packages/rest/src/rest-server.ts | 27 +++++++++++++------ 3 files changed, 32 insertions(+), 17 deletions(-) diff --git a/.changeset/openapi-info-version-is-the-api-version.md b/.changeset/openapi-info-version-is-the-api-version.md index 9f5207f35a..57868de115 100644 --- a/.changeset/openapi-info-version-is-the-api-version.md +++ b/.changeset/openapi-info-version-is-the-api-version.md @@ -12,10 +12,12 @@ version". Both halves were false: the runtime version never reached the field, and the `|| enriched.info.version` fallback published exactly the compile-time version the comment said the line existed to avoid. -The fallback was reachable rather than dead — `normalizeConfig` defaults with -`??` and `PluginRestApiSchema` declares a bare `z.string()`, so a configured -`api.version: ''` arrives falsy and the document advertised -`@objectstack/spec`'s package version. It now serves the configured value as +The fallback was reachable rather than dead, though not because the contract +permits it: `RestApiConfigSchema` declares +`version: z.string().regex(/^[a-zA-Z0-9_\-\.]+$/)`, which refuses `''`. Nothing +parses this config against that schema — both hops into the server are casts — +so `normalizeConfig`'s `??` is the only guard, it does not catch `''`, and the +document advertised `@objectstack/spec`'s package version. It now serves the configured value as written, so a misconfigured deployment stays visibly misconfigured instead of silently switching the field to a different kind of fact. Every non-empty `api.version` — including the default — serves exactly what it served before. diff --git a/packages/rest/src/rest-openapi-route.test.ts b/packages/rest/src/rest-openapi-route.test.ts index d1bd20ec6d..6957628657 100644 --- a/packages/rest/src/rest-openapi-route.test.ts +++ b/packages/rest/src/rest-openapi-route.test.ts @@ -427,11 +427,13 @@ describe('GET /openapi.json — what `info.version` carries (#11546)', () => { }); it('serves a falsy `api.version` as itself rather than falling back to the artifact', async () => { - // The removed `|| enriched.info.version` was reachable, not dead: - // `normalizeConfig` defaults with `??` and `PluginRestApiSchema` declares a - // bare `z.string()`, so `version: ''` arrives here falsy. Measured on the - // pre-fix code this served the spec package's compile-time version — the - // exact value the old comment said the line existed to keep off the wire. + // The removed `|| enriched.info.version` was reachable, not dead — though + // not because the contract permits `''`. `RestApiConfigSchema` refuses it + // (`z.string().regex(/^[a-zA-Z0-9_\-\.]+$/)`); nothing parses this config + // against that schema, so `??` is the only guard and `''` walks past it. + // Measured on the pre-fix code this served the spec package's compile-time + // version — the exact value the old comment said the line existed to keep + // off the wire. // // An empty version is a broken deployment either way (the mount doubles its // slash, below). The point of the pin is that it stays visibly broken diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 705ff916a7..a1e41dfee9 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -3640,14 +3640,25 @@ export class RestServer { // wants the serving artifact asks a producer that means it. // // No `|| enriched.info.version` fallback. It was reachable, - // not dead: `normalizeConfig` defaults with `??` and the - // schema declares a bare `z.string()`, so a configured empty - // version reaches here falsy — and firing the fallback - // published the spec package's compile-time version, the one - // value the old comment claimed this line existed to keep off - // the wire. A falsy `api.version` now serves itself, so a - // misconfigured deployment reads as misconfigured instead of - // silently switching this field to a different kind of fact. + // not dead — and NOT because the contract allows an empty + // version. `RestApiConfigSchema` declares + // `version: z.string().regex(/^[a-zA-Z0-9_\-\.]+$/)`, which + // refuses `''`. Nothing ever runs it: this config arrives + // through casts on both hops (`config.api as any` in + // `rest-api-plugin.ts`, then `as Partial` in + // `normalizeConfig` below), the plugin declares no + // `configSchema` for the kernel's validator to parse, and the + // repo's only `RestApiConfigSchema.parse` parses `{}` in a QA + // helper. So the regex never executes on a deployment path, + // `??` is the only guard left, and `''` walks past it — + // whereupon the fallback published the spec package's + // compile-time version, the one value the old comment claimed + // this line existed to keep off the wire. A falsy + // `api.version` now serves itself, so a misconfigured + // deployment reads as misconfigured instead of silently + // switching this field to a different kind of fact. The + // unenforced regex is a defect in its own right, filed + // separately rather than fixed here. if (enriched.info) { enriched.info = { ...enriched.info,