Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions .changeset/20295-rest-api-config-dead-keys-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
---
'@objectstack/spec': minor
'@objectstack/rest': minor
---

feat(spec,rest)!: retire `api.responseFormat` and `api.documentation.enabled` — parsed, defaulted, and read by nothing (#20295)

Clause-②: no (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).

Two keys of `RestServerConfig.api` (`RestApiConfigSchema`) were accepted, given
defaults and copied into the REST server's config by `normalizeConfig` — and no
site ever read them back. `responseFormat` (`envelope`, `includeMetadata`,
`includePagination`) toggled nothing: `envelope: false` unwrapped no response.
`documentation.enabled` was a second on/off switch for the OpenAPI document that
nothing consulted: `api.enableOpenApi` decides that mount. Measured before
removal, each against a lit control on the same instrument: no reader in this
repo's packages, and no author in objectui at its pinned commit or in cloud.
ADR-0049 enforce-or-remove; the verdict is RETIRE, by the maintainer's criterion —
mainstream data APIs keep a fixed response envelope that no administrator toggles
server-wide, and the OpenAPI switch already exists and is enforced.

```
FROM new RestServer(server, protocol, { api: { responseFormat: { envelope: false } } })
-> constructed; `envelope: false` changed nothing
TO -> throws: REST API configuration is invalid: `api` does not satisfy
`RestApiConfigSchema` …
- api.responseFormat: `api.responseFormat` was removed in @objectstack/spec 17.5.0
(ADR-0049 enforce-or-remove) — … Delete the key. Response shapes are fixed, …

FROM RestApiConfigSchema.parse({ documentation: { enabled: false, title: 'My API' } })
-> { documentation: { enabled: false, title: 'My API' }, … } // served the document anyway
TO -> ZodError { code: 'invalid_type', path: ['documentation', 'enabled'],
message: '`api.documentation.enabled` was removed in @objectstack/spec 17.5.0 (ADR-0049
enforce-or-remove) — … Delete the key; `api.enableOpenApi: false` is the switch …' }
```

**Fix.** `api.responseFormat` → delete the key; response shapes are fixed, so
there is nothing to configure. `api.documentation.enabled` → delete the key; to
serve no OpenAPI document, set `api.enableOpenApi: false` (it leaves
`GET /openapi.json` and `GET /docs` unmounted). `tsc` refuses both keys at the
authoring site (their input type is `never`).

**What does not change.** Every live key of the `api` block parses exactly as
before, including the rest of `documentation` (`title`, `description`,
`version`, `termsOfService`, `contact`, `license` — a separate decision). A
config without the two keys mounts the same REST surface: neither key ever
reached it. A `documentation` block no longer grows an `enabled: true` default.

### The retirement kit

- **Schema.** `RestApiConfigSchema` and its inline `documentation` object are
non-strict `z.object()`s, so each key is a `retiredKey()` tombstone carrying its
prescription (a bare deletion would have stripped it in silence).
`responseFormat` retires whole — its three members were its only members.
- **REST server.** `normalizeConfig` runs the tombstones (the `crud.patterns`
posture, not `requireAuth`'s warn-and-ignore), so a config carrying either key
now fails `RestServer` construction and the REST plugin's `start` with the
prescription, and the normalized config no longer carries or re-defaults them.
- **ADR-0087.** `RETIRED_KEYS_BY_MAJOR[18]` gains `api/RestApiConfig:responseFormat`
and `api/RestApiConfig:documentation.enabled`. No D2 conversion: a
`RestServerConfig` is plugin TS configuration, never a stack collection member or
a stored row. The family's D3 entry, `rest-api-config-dead-keys-retired`, carries
the prescription to `os migrate meta` and the upgrade guide.
- **Ledger and docs.** `liveness/rest_api.json` keeps both rows `dead` with a
REMOVED note (`responseFormat`'s three child rows collapse into its one row);
the generated `state-counts.md` moves `rest_api` from 14 to 12 dead; the
reference page for `rest-server` is regenerated.

<!-- adr-0087: registered rest-api-config-dead-keys-retired -->
18 changes: 5 additions & 13 deletions content/docs/references/api/rest-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -249,29 +249,21 @@ 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** | `{ enabled: boolean; title: string; description?: string; version?: string; … }` | optional | OpenAPI/Swagger documentation config |
| **responseFormat** | `{ envelope: boolean; includeMetadata: boolean; includePagination: boolean }` | optional | Response format options |
| **documentation** | `{ title: string; description?: string; version?: string; termsOfService?: string; … }` | optional | OpenAPI/Swagger documentation config |
| **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** | `boolean` | optional (default: `true`) | Enable API documentation |
| **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 | |

### Nested Shape: `RestApiConfig.responseFormat`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **envelope** | `boolean` | optional (default: `true`) | Wrap responses in standard envelope |
| **includeMetadata** | `boolean` | optional (default: `true`) | Include response metadata (timestamp, requestId) |
| **includePagination** | `boolean` | optional (default: `true`) | Include pagination info in list responses |


---

Expand Down Expand Up @@ -305,8 +297,8 @@ 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** | `{ enabled: boolean; title: string; description?: string; version?: string; … }` | optional | OpenAPI/Swagger documentation config |
| **responseFormat** | `{ envelope: boolean; includeMetadata: boolean; includePagination: boolean }` | optional | Response format options |
| **documentation** | `{ title: string; description?: string; version?: string; termsOfService?: string; … }` | optional | OpenAPI/Swagger documentation config |
| **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
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ directory rather than per file.
| Dir | Sites |
|---|---|
| `ai/` | 78 |
| `api/` | 432 |
| `api/` | 431 |
| `identity/` | 32 |
| `integration/` | 8 |
| `kernel/` | 247 |
Expand Down
170 changes: 170 additions & 0 deletions packages/rest/src/rest-api-config-dead-keys-refused.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#20295] The REST server REFUSES the retired `api.responseFormat` and
* `api.documentation.enabled` at construction — ADR-0049 enforce-or-remove.
*
* Before: both were parsed by `parseDeclaredApiConfig`, defaulted, and copied
* into `this.config.api` by `normalizeConfig`, where nothing read them back —
* `responseFormat.envelope: false` unwrapped no response and
* `documentation.enabled: false` turned no document off (`api.enableOpenApi`
* decides that mount). Now both are `retiredKey()` tombstones on
* `RestApiConfigSchema`, and this seam runs that schema, so the tombstone's
* refusal reaches the operator at boot — the `crud.patterns` posture
* (`rest-sub-config-parse-not-cast.test.ts` §E), NOT `requireAuth`'s
* warn-and-ignore `.omit()`.
*
* ⛔ 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
* REAL `RestServer` construction or the real plugin composition, so what it
* measures is whether the SERVER refuses. `refusal()` answers `''` when
* construction succeeds, and `''` contains no key name, so every `toContain`
* below is its own positive control.
*
* On the assertion set: this is a construction-time refusal, not an HTTP
* answer — nothing is mounted yet, so there is no ADR-0112 `code` / `status`
* envelope to assert. The strongest set this door has is: refused, the located
* key (`api.<path>`), the declaring schema's name, and the prescription.
*
* This file is one of the two the retirement's tree-scoped absence pin
* excludes by name: its JOB is to author the retired keys.
*/

import { describe, it, expect, vi } from 'vitest';
import { RestServer } from './rest-server.js';
import { createRestApiPlugin } from './rest-api-plugin.js';

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;
}

function makeProtocol() {
return {
getMetaItems: vi.fn(async ({ type }: { type: string }) => ({ type, items: [] })),
} as any;
}

/** Construct the real server with `api` as given — the seam under test. */
function construct(api: Record<string, unknown>) {
return new RestServer(makeServer(), makeProtocol(), { api } as any);
}

/** The construction refusal's message, or `''` when the server constructed. */
function refusal(api: Record<string, unknown>): string {
try {
construct(api);
return '';
} catch (err) {
return err instanceof Error ? err.message : String(err);
}
}

/** The normalized `api` block, read off the constructed server. */
const normalizedApi = (rest: unknown) =>
(rest as { config: { api: Record<string, unknown> } }).config.api;

/** The mounted `METHOD path` set of a constructed server. */
function mounted(api: Record<string, unknown>): string[] {
const rest = construct(api);
rest.registerRoutes();
return rest.getRoutes().map((r: any) => `${r.method} ${r.path}`).sort();
}

function bootCtx() {
const services: Record<string, unknown> = { 'http.server': makeServer(), protocol: makeProtocol() };
return {
registerService: vi.fn(),
getService: vi.fn((name: string) => {
if (name in services) return services[name];
throw new Error(`Service '${name}' not found`);
}),
getServices: vi.fn(() => new Map(Object.entries(services))),
hook: vi.fn(),
trigger: vi.fn().mockResolvedValue(undefined),
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
getKernel: vi.fn(),
} as any;
}

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;

describe('[#20295] RestServer construction refuses the retired `api` keys', () => {
it('refuses `api.responseFormat` — every former spelling, the old defaults and the empty block included', () => {
for (const responseFormat of [
{ envelope: false },
{ envelope: true, includeMetadata: true, includePagination: true },
{ includeMetadata: false },
{},
]) {
const label = JSON.stringify(responseFormat);
const message = refusal({ responseFormat });
expect(message, label).toContain(' - api.responseFormat: ');
expect(message, label).toContain('RestApiConfigSchema');
expect(message, label).toMatch(RESPONSE_FORMAT_PRESCRIPTION);
}
});

it('refuses `api.documentation.enabled` — both values — while its siblings in the block are untouched', () => {
for (const enabled of [false, true]) {
const message = refusal({ documentation: { title: 'My API', enabled } });
expect(message, String(enabled)).toContain(' - api.documentation.enabled: ');
expect(message, String(enabled)).toContain('RestApiConfigSchema');
expect(message, String(enabled)).toMatch(DOCS_ENABLED_PRESCRIPTION);
// Only the retired member is diagnosed — `title` is not named.
expect(message, String(enabled)).not.toContain('api.documentation.title');
}
});

it('a retired-key refusal never diagnoses `api.version` — a key this config did not write', () => {
const message = refusal({ responseFormat: { envelope: false } });
expect(message, 'positive control: the refusal is present').toContain('api.responseFormat');
expect(message).not.toContain('/api//');
expect(message).not.toContain('api.version');
});

it('the plugin path refuses the same keys (both cast hops)', async () => {
await expect(
createRestApiPlugin({ api: { api: { responseFormat: { envelope: false } } } } as never).start!(bootCtx()),
).rejects.toThrow(/api\.responseFormat.*was removed/s);
await expect(
createRestApiPlugin({ api: { api: { documentation: { enabled: false } } } } as never).start!(bootCtx()),
).rejects.toThrow(/api\.documentation\.enabled.*was removed/s);
});
});

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();
await expect(
createRestApiPlugin({ api: { api: { documentation: { title: 'My API' } } } } as never).start!(ctx),
).resolves.toBeUndefined();
expect(ctx.logger.error).not.toHaveBeenCalled();
});

it('normalizeConfig neither forwards nor re-defaults either retired key', () => {
const api = normalizedApi(construct({ documentation: { title: 'My API' } }));
expect(api).not.toHaveProperty('responseFormat');
expect(api.documentation, 'the live members pass through with their declared defaults only').toEqual({ title: 'My API' });
expect(api.documentation as object).not.toHaveProperty('enabled');
});

it('`api.enableOpenApi` is the OpenAPI switch the prescription names — it really unmounts the document', () => {
const on = mounted({});
const off = mounted({ enableOpenApi: false });
// Positive control: the default mounts both routes the switch owns.
expect(on).toContain('GET /api/v1/openapi.json');
expect(on).toContain('GET /api/v1/docs');
expect(on.filter((r) => !off.includes(r)).sort()).toEqual(['GET /api/v1/docs', 'GET /api/v1/openapi.json']);
});

it('a `documentation` block without `enabled` mounts exactly the default surface — the key never reached it', () => {
expect(mounted({ documentation: { title: 'Renamed', description: 'd' } })).toEqual(mounted({}));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
* 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` / `responseFormat` — live in
* reach `documentation` (whose retired `enabled` member, like the retired
* `responseFormat` block, is refused rather than defaulted since #20295) — live in
* `rest-config-parse-not-cast.test.ts` §D, which is deliberately unmocked.
*/

Expand Down
Loading
Loading