Skip to content
Merged
91 changes: 91 additions & 0 deletions .changeset/20294-openapi-info-publisher-overlay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
---
'@objectstack/spec': minor
'@objectstack/rest': minor
---

feat(spec,rest)!: the served OpenAPI `info` carries the publisher's `api.documentation` identity; `api.documentation.version` retired (#20294)

Clause-②: yes (narrowing)

**BREAKING** — shipped as `minor` under the launch-window convention
(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by
this banner, the `(narrowing)` arm above and the ADR-0087 disposition below,
never by the level). The breaking half is one key: `api.documentation.version`.

`RestServerConfig.api.documentation` (`RestApiConfigSchema`) declared nine
members, and `RestServer` parsed them, copied them into its config — and never
read them back. Measured before this change, with every member authored: both
doors that serve the OpenAPI document (`{apiPath}/openapi.json` and its
environment-scoped twin) answered the bundled artifact's `info` unchanged, 0 of 9
honoured. ADR-0049 enforce-or-remove, split by who owns each field:

- **Enforced — the publisher's identity.** `title`, `description`,
`termsOfService`, `contact` (`name` / `url` / `email`) and `license` (`name` /
`url`) now overlay the served `info` on both doors. A member you leave unset
keeps the bundled value, and a config with nothing authored — no block,
`documentation: {}` — serves `info` byte-identical to
`@objectstack/spec/openapi.json`, exactly as before. `contact` and `license`
replace the bundled object **whole**: `license: { name: 'MIT' }` serves
`{ name: 'MIT' }` with no URL, never MIT at the bundled Apache-2.0 URL, and a
partial `contact` never keeps ObjectStack's name or URL.
- **Retired — `documentation.version`.** The served `info.version` is the
protocol version, the version of the `@objectstack/spec` package that generated
the document, with no configured override: an earlier ruling made it equal the
published artifact's so an integrator can read which protocol version they are
talking to. A publisher-set version would give the field a third meaning, so
the key is now refused.

```
FROM new RestServer(server, protocol, { api: { documentation: { title: 'Acme Orders API', version: '2.3.0' } } })
-> constructed; GET /api/v1/openapi.json served info.title 'ObjectStack REST API'
and info.version = the spec version — both authored values ignored
TO -> throws: REST API configuration is invalid: `api` does not satisfy
`RestApiConfigSchema` …
- api.documentation.version: `api.documentation.version` was removed in
@objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — … Delete the key. To publish
your app's own release number, write it into `api.documentation.description`, …

FROM new RestServer(server, protocol, { api: { documentation: { title: 'Acme Orders API' } } })
-> GET /api/v1/openapi.json: info.title 'ObjectStack REST API'
TO -> GET /api/v1/openapi.json: info.title 'Acme Orders API' (and on the environment-scoped door)

FROM RestApiConfigSchema.parse({ documentation: { description: 'd' } }).documentation
-> { title: 'ObjectStack API', description: 'd' } // a default no document ever served
TO -> { description: 'd' }
```

**Fix.** `api.documentation.version` → delete the key. The served
`info.version` is always the protocol version; to publish your app's own release
number, write it into `api.documentation.description`. `tsc` refuses the key at
the authoring site (its input type is `never`), and `RestServer` construction and
the REST plugin's `start` refuse it with that prescription.

**What else changes.** `documentation.title` is `.optional()` instead of
`.default('ObjectStack API')`: that default was materialized into every present
block and never served, so the parsed block now carries exactly what was
authored (the parsed `title` is typed `string | undefined` now). `api.version` (the route identifier) and the runtime version still
never reach `info.version`. A host that authors none of these keys — every
CLI-started deployment, since `os serve` forwards only `enableProjectScoping`
and `projectResolution` — serves the same document as before.

### The kit

- **Schema.** The eight identity members carry describes naming the served
`info` field; `version` is a `retiredKey()` tombstone inside the live
`documentation` block (a non-strict `z.object()`, so a bare deletion would have
stripped it in silence), next to the `enabled` tombstone.
- **REST server.** `registerOpenApiEndpoints` builds `info` through a pure
helper that returns a NEW object — the cached artifact's own `info` is never
written — and the same handler serves both doors.
- **ADR-0087.** `RETIRED_KEYS_BY_MAJOR[18]` gains
`api/RestApiConfig:documentation.version`; the D3 entry
`rest-api-documentation-version-retired` carries the prescription to
`os migrate meta` and the upgrade guide. No D2 conversion: a `RestServerConfig`
is plugin TS configuration, never a stack collection member or a stored row.
- **Ledger and docs.** `liveness/rest_api.json`: the eight identity leaves and
the `contact` / `license` containers flip to `live` with the overlay as
evidence; the `version` row stays `dead` with a REMOVED note. The generated
`state-counts.md` moves `rest_api` from 12 live / 12 dead to 20 / 4; the
`rest-server` reference page is regenerated.

<!-- adr-0087: registered rest-api-documentation-version-retired -->
16 changes: 8 additions & 8 deletions content/docs/references/api/rest-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -250,20 +250,20 @@ const result = BatchEndpointsConfigSchema.parse(data);
| **enableProjectScoping** | `boolean` | optional (default: `false`) | Enable project-scoped routing for data/meta/AI APIs |
| **projectResolution** | `Enum<'required' \| 'optional' \| 'auto'>` | optional (default: `"auto"`) | Project ID resolution strategy |
| **requireAuth** | `never` | optional | [REMOVED] `api.requireAuth` was removed in @objectstack/spec 17. Anonymous access to object data is now always denied — auth is a kernel concern, not a deployment posture. Delete the key. To publish something publicly, declare it: a public form view (`sharing.allowAnonymous`), a share link, or `book.audience: 'public'` — each derives its own narrow authorization instead of opening the whole data plane. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **documentation** | `{ title: string; description?: string; version?: string; termsOfService?: string; … }` | optional | OpenAPI/Swagger documentation config |
| **documentation** | `{ title?: string; description?: string; termsOfService?: string; contact?: object; … }` | optional | Publisher identity of the served OpenAPI document: each member set here overlays its `info` on both /openapi.json doors, and nothing set serves the bundled `info` unchanged. `info.version` is always the protocol version |
| **responseFormat** | `never` | optional | [REMOVED] `api.responseFormat` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever read it: `envelope`, `includeMetadata` and `includePagination` were parsed, defaulted and copied into the REST server's config and never consulted, so `envelope: false` unwrapped no response. Delete the key. Response shapes are fixed, not a server-wide option: each route answers in the response schema `@objectstack/spec/api` declares for it, which is what the client SDK parses and the served /openapi.json describes, so no configuration changes them. |

### Nested Shape: `RestApiConfig.documentation`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **enabled** | `never` | optional | [REMOVED] `api.documentation.enabled` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever read it: whether the server publishes its OpenAPI document is decided by the sibling `api.enableOpenApi` at the mount, so `enabled: false` turned nothing off. Delete the key; `api.enableOpenApi: false` is the switch that leaves the `/openapi.json` document and its `/docs` viewer unmounted. |
| **title** | `string` | optional (default: `"ObjectStack API"`) | API documentation title |
| **description** | `string` | optional | API description |
| **version** | `string` | optional | Documentation version |
| **termsOfService** | `string` | optional | Terms of service URL |
| **contact** | `{ name?: string; url?: string; email?: string }` | optional | |
| **license** | `{ name: string; url?: string }` | optional | |
| **title** | `string` | optional | Title of the served OpenAPI document (`info.title`); unset keeps the bundled title |
| **description** | `string` | optional | Description of the served OpenAPI document (`info.description`); unset keeps the bundled description. Your app's own release number belongs here |
| **version** | `never` | optional | [REMOVED] `api.documentation.version` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever read it, and the served OpenAPI document's `info.version` has one source: the protocol version, i.e. the version of the `@objectstack/spec` package that generated the document, which no deployment configuration overrides. Delete the key. To publish your app's own release number, write it into `api.documentation.description`, which the served `info.description` carries. |
| **termsOfService** | `string` | optional | Terms-of-service URL of the served OpenAPI document (`info.termsOfService`); unset serves none |
| **contact** | `{ name?: string; url?: string; email?: string }` | optional | Contact of the served OpenAPI document; replaces the bundled `info.contact` whole, so a member left out is absent rather than inherited. Unset keeps the bundled contact |
| **license** | `{ name: string; url?: string }` | optional | License of the served OpenAPI document; replaces the bundled `info.license` whole, so a license without `url` serves no URL. Unset keeps the bundled license |


---
Expand Down Expand Up @@ -298,7 +298,7 @@ const result = BatchEndpointsConfigSchema.parse(data);
| **enableProjectScoping** | `boolean` | optional (default: `false`) | Enable project-scoped routing for data/meta/AI APIs |
| **projectResolution** | `Enum<'required' \| 'optional' \| 'auto'>` | optional (default: `"auto"`) | Project ID resolution strategy |
| **requireAuth** | `never` | optional | [REMOVED] `api.requireAuth` was removed in @objectstack/spec 17. Anonymous access to object data is now always denied — auth is a kernel concern, not a deployment posture. Delete the key. To publish something publicly, declare it: a public form view (`sharing.allowAnonymous`), a share link, or `book.audience: 'public'` — each derives its own narrow authorization instead of opening the whole data plane. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **documentation** | `{ title: string; description?: string; version?: string; termsOfService?: string; … }` | optional | OpenAPI/Swagger documentation config |
| **documentation** | `{ title?: string; description?: string; termsOfService?: string; contact?: object; … }` | optional | Publisher identity of the served OpenAPI document: each member set here overlays its `info` on both /openapi.json doors, and nothing set serves the bundled `info` unchanged. `info.version` is always the protocol version |
| **responseFormat** | `never` | optional | [REMOVED] `api.responseFormat` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever read it: `envelope`, `includeMetadata` and `includePagination` were parsed, defaulted and copied into the REST server's config and never consulted, so `envelope: false` unwrapped no response. Delete the key. Response shapes are fixed, not a server-wide option: each route answers in the response schema `@objectstack/spec/api` declares for it, which is what the client SDK parses and the served /openapi.json describes, so no configuration changes them. |

### Nested Shape: `RestServerConfig.crud`
Expand Down
40 changes: 40 additions & 0 deletions packages/rest/src/rest-api-config-dead-keys-refused.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
* (`rest-sub-config-parse-not-cast.test.ts` §E), NOT `requireAuth`'s
* warn-and-ignore `.omit()`.
*
* [#20294] `api.documentation.version` joined them (ruling B on #20359): the
* block's identity members are enforced now — they overlay the served OpenAPI
* `info` — and `version` is the one member retired, because the served
* `info.version` is the protocol version (#11646).
*
* ⛔ ANTI-VACUITY — the same rule as `rest-config-parse-not-cast.test.ts`: a pin
* asking the SCHEMA whether it refuses is `packages/spec`'s job
* (`rest-api-config-dead-keys-retirement.test.ts`). Every case below drives the
Expand Down Expand Up @@ -94,6 +99,8 @@ const RESPONSE_FORMAT_PRESCRIPTION =
/`api\.responseFormat` was removed in @objectstack\/spec 17\.5\.0.*Delete the key\..*Response shapes are fixed/s;
const DOCS_ENABLED_PRESCRIPTION =
/`api\.documentation\.enabled` was removed in @objectstack\/spec 17\.5\.0.*Delete the key; `api\.enableOpenApi: false` is the switch/s;
const DOCS_VERSION_PRESCRIPTION =
/`api\.documentation\.version` was removed in @objectstack\/spec 17\.5\.0.*`info\.version` has one source: the protocol version.*Delete the key\. To publish your app's own release number, write it into `api\.documentation\.description`/s;

describe('[#20295] RestServer construction refuses the retired `api` keys', () => {
it('refuses `api.responseFormat` — every former spelling, the old defaults and the empty block included', () => {
Expand Down Expand Up @@ -139,6 +146,39 @@ describe('[#20295] RestServer construction refuses the retired `api` keys', () =
});
});

describe('[#20294] RestServer construction refuses the retired `api.documentation.version`', () => {
// Ruling B on #20359: the identity members of `documentation` are enforced
// (they overlay the served `info` — `rest-openapi-info-overlay.test.ts`),
// `version` is retired because the served `info.version` is the protocol
// version (#11646). An authored one used to be accepted and ignored.
it('refuses it — with the enforced siblings beside it undiagnosed', () => {
const message = refusal({ documentation: { title: 'Acme Orders API', description: 'd', version: '2.3.0' } });
expect(message).toContain(' - api.documentation.version: ');
expect(message).toContain('RestApiConfigSchema');
expect(message).toMatch(DOCS_VERSION_PRESCRIPTION);
// Only the retired member is diagnosed — no issue line locates an
// enforced sibling (the prescription itself NAMES
// `api.documentation.description`, as the place a release number
// goes, so the check is on the located-issue line, not the word).
expect(message).not.toContain(' - api.documentation.title: ');
expect(message).not.toContain(' - api.documentation.description: ');
// Not the route identifier either: `api.version` is a different key.
expect(message).not.toContain(' - api.version: ');
});

it('the plugin path refuses it too', async () => {
await expect(
createRestApiPlugin({ api: { api: { documentation: { version: '2.3.0' } } } } as never).start!(bootCtx()),
).rejects.toThrow(/api\.documentation\.version.*was removed/s);
});

it('CONTROL: the same block without `version` constructs, and the enforced members pass through', () => {
expect(refusal({ documentation: { title: 'Acme Orders API', description: 'd' } })).toBe('');
const api = normalizedApi(construct({ documentation: { title: 'Acme Orders API', description: 'd' } }));
expect(api.documentation).toEqual({ title: 'Acme Orders API', description: 'd' });
});
});

describe('[#20295] CONTROL: without the retired keys, the server is what it was', () => {
it('the plugin path still boots (the ctx is not what refuses)', async () => {
const ctx = bootCtx();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,11 @@
* ⚠️ This file mocks `@objectstack/spec/api` module-wide, so the schema it
* drives is NOT the shipped one. The complementary pins that need the REAL
* schema — that the shipped defaults are the schema's, that `requireAuth`
* keeps its warn-and-ignore posture, and that the parse's inner defaults now
* reach `documentation` (whose retired `enabled` member, like the retired
* `responseFormat` block, is refused rather than defaulted since #20295) — live in
* keeps its warn-and-ignore posture, and that `documentation` arrives exactly
* as the parse outputs it (with no inner default left since #20294 made
* `title` optional; its retired `enabled` and `version` members, like the
* retired `responseFormat` block, are refused rather than defaulted since
* #20295 and #20294) — live in
* `rest-config-parse-not-cast.test.ts` §D, which is deliberately unmocked.
*/

Expand Down
25 changes: 18 additions & 7 deletions packages/rest/src/rest-config-parse-not-cast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,22 +334,33 @@ describe('[#14366] §D the `api` sub-object consumes the parsed output', () => {
expect(normalizedApi(rest).enableSearch).toBe(false);
});

it('THE BOUNDED DELTA: an authored `documentation` now carries its own declared inner defaults', () => {
it('THE BOUNDED DELTA: an authored `documentation` arrives as the parse outputs it', () => {
// The single measured behaviour change of #14366, pinned rather than
// left to be rediscovered. The deleted `??` chain copied this object
// through untouched (`documentation: api.documentation`), so a partial
// one stayed partial; the parse fills the inner `.default()`s.
// `documentation` has ZERO read sites outside this block (the #14369
// census), so nothing observes it today — which is exactly why it needs
// a pin: an unobserved change is the kind that gets reverted by
// accident.
// one stayed partial; the parse fills the inner `.default()`s — of
// which, since #20294 (`title` → `.optional()`) and #20295 (`enabled`
// retired), there are none, so a partial block stays partial again,
// now by the schema's word rather than by a cast. Since #20294 this
// block HAS a reader — `registerOpenApiEndpoints` overlays the served
// `info` from it — which is why an invented member here would be
// observable, and why the pin stays.
const doc = normalizedApi(construct({ documentation: { description: 'd' } }))
.documentation as Record<string, unknown>;
expect(doc).toEqual(
(declaredApi().parse({ documentation: { description: 'd' } }) as { documentation: unknown }).documentation,
);
expect(doc.description, 'the authored key survives').toBe('d');
expect(doc.title, 'and the declared inner default arrives with it').toBe('ObjectStack API');
// [#20294] REVERSED by design, not by regression: `documentation.title`
// is `.optional()` now, not `.default('ObjectStack API')`. That default
// was never served, and the served `info` is overlaid from this block
// (`registerOpenApiEndpoints`), so materializing it would retitle the
// document of a host that wrote only `description`. The block now
// carries exactly what was authored — its declared inner defaults are
// none — and an unset title keeps the bundled one
// (`rest-openapi-info-overlay.test.ts`).
expect(doc, 'no title is invented for a host that did not write one').not.toHaveProperty('title');
expect(doc, 'the block is exactly what was authored').toEqual({ description: 'd' });
// [#20295] REVERSED by design, not by regression: `documentation.enabled`
// is a retired tombstone, so the parse no longer materializes its old
// `.default(true)` — the block carries only its live members.
Expand Down
Loading
Loading