Skip to content
Merged
19 changes: 19 additions & 0 deletions .changeset/rest-server-config-embedder-only-reachability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@objectstack/spec": patch
---

`RestServerConfig` now documents its own reachability: the `crud` / `metadata` / `batch` blocks are embedder-only, and the schema says so instead of implying a deployment posture nobody can author.

`RestServerConfig` is the argument a host passes when it constructs the REST server, and there is exactly one door: `createRestApiPlugin({ api })`, whose `start()` is the only non-test site that reaches `new RestServer(...)`. No shipped boot path opens it with a config of its own — `os serve` reads the stack config's own top-level `api:` block and forwards exactly two keys out of it (`api.enableProjectScoping`, `api.projectResolution`), and the dev plugin passes none at all. So on a CLI-started deployment every other key is whatever its `.default()` says, and until now the schema did not say so anywhere an operator would look.

- **The file header gains a `WHO CAN WRITE THIS CONFIG` section**, which is the part that reaches the generated reference page, and the `crud` / `metadata` / `batch` sub-schemas each gain a `Reachability: EMBEDDER-ONLY` line. The three keys' entries on the parent `RestServerConfig` table say it too, so the fact survives into `content/docs/references/api/rest-server.mdx` rather than living only in the TS source.
- **One documented carve-out, and it is the security-relevant key.** `RestServer.normalizeConfig` folds the environment into the *effective* value of `metadata.maskObjectFields`: `OS_ALLOW_UNMASKED_OBJECT_METADATA` turns the ADR-0106 D8 mask off whatever the key says. That env var is the only thing outside an embedder's argument that moves any value in this schema, and it is now stated on the key, on the sub-schema and in the file header.
- **`metadata.maskObjectFields`'s docblock is corrected.** It said `false` "opts this server out and serves the full schema to every authenticated caller" and offered the env var as a "deployment-wide counterpart", as if a deployment could pick either. Only an embedder can write the key; a deployment reaches the mask only through the env var.
- **`api.enableSearch` is corrected the same way.** Its docblock called it a "Deployment-wide switch" and its `describe()` a "deployment-wide search opt-out"; `os serve` does not forward it either, so it is embedder-only like the rest of the block apart from the two project-scoping keys.
- **The liveness ledger answers the ADR-0049 question in writing.** Every `live` row in `liveness/crud_endpoints.json`, `metadata_endpoints.json` and `batch_endpoints.json` gains a `REACHABILITY` sentence, and each file's `_note` carries the measurement once. `status` and `verifiedAt` are untouched on purpose: `live` answers who *reads* a key, reachability answers who can *set* it, and adding the second re-verified no call graph.

⚠️ **A correction to the record this change is built on.** An earlier draft of these sentences named a second door, `createHonoServerPlugin({ restConfig })`. No such function exists — a definition probe returns zero across the tree, against a positive control that finds `createRestApiPlugin` at `packages/rest/src/rest-api-plugin.ts:115`. `HonoServerPlugin` is a class that declares a `restConfig?: RestServerConfig` option whose single reader takes `api.basePath` for the SPA fallback; it never constructs a REST server, so it is not a door onto any of these keys. The claim was inherited from prose that was already in the tree, and on a card whose whole subject is a declared posture nobody can reach, publishing a declared door that does not exist would have been the same defect one level up. Every place this change touches now says the corrected thing.

⚠️ **`batch.maxBatchSize` really does describe itself as deployment policy — in another package.** The phrase does not occur in `packages/spec/src/api/rest-server.zod.ts`, but it exists verbatim in the REST server: *"The cap is deployment policy — `RestServerConfig.batch.maxBatchSize` (1..1000, default 200)"* at `packages/rest/src/rest-server.ts:2071`. Same defect class, different package, and not touched here — it is owed to a follow-up in `packages/rest`.

No behaviour changes and no schema shape changes — no key, default, bound or refusal moves, so the accept set is byte-identical. This is prose plus ledger rows, and the regenerated `content/docs/references/api/rest-server.mdx` that follows from the `describe()` edits.
50 changes: 45 additions & 5 deletions content/docs/references/api/rest-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,46 @@ Architecture alignment:
- Microsoft Dynamics: Web API with entity operations
- Strapi: Auto-generated REST endpoints

WHO CAN WRITE THIS CONFIG (#15543) — read this before planning a deployment
around any key below. A `RestServerConfig` is the ARGUMENT a host passes when
it constructs the server: never a stack collection member and never a stored
metadata row.

There is exactly ONE door, and it is programmatic:
`createRestApiPlugin({ api })` (`packages/rest/src/rest-api-plugin.ts`),
whose `start()` is the only non-test site that reaches `new RestServer(...)`.
⚠️ `HonoServerPlugin` (`packages/plugins/plugin-hono-server`) declares a
`restConfig?: RestServerConfig` option, but it is NOT a second door: its one
reader takes `api.basePath` for the SPA fallback, and that plugin never
constructs a REST server, so nothing below `api.basePath` reaches anything.

No shipped boot path opens that door with a config of its own. `os serve`
(`packages/cli/src/commands/serve.ts`) reads the stack config's own top-level
`api:` block and forwards exactly two keys out of it —
`api.enableProjectScoping` and `api.projectResolution` — into a fixed
argument, through an `as any` cast; the dev plugin
(`packages/plugins/plugin-dev/src/dev-plugin.ts`) calls
`createRestApiPlugin()` with no config at all. So the CLI does read a config
file — it just forwards those two keys and nothing else.

⇒ On a CLI-started deployment every OTHER key here is EMBEDDER-ONLY: the
whole of `crud`, `metadata` and `batch`, and the rest of `api`. Its value is
whatever the `.default()` below says, and no flag, config file or CLI option
moves it.

⚠️ ONE CARVE-OUT, and it is the security-relevant key: the REST server's
`normalizeConfig` folds the environment into the effective value of
`metadata.maskObjectFields` — `OS_ALLOW_UNMASKED_OBJECT_METADATA` turns the
ADR-0106 D8 mask OFF for a deployment that cannot otherwise reach the key.
That env var is the only thing outside an embedder's argument that changes
any value here. See `maskObjectFields` below.

This is the recorded posture, not a gap awaiting a fix: the keys keep their
runtime reads and their embedder consumer, and the reachability answer
ADR-0049 asks for is written per key in the liveness ledger
(`packages/spec/liveness/crud_endpoints.json`, `metadata_endpoints.json`,
`batch_endpoints.json`).

<Callout type="info">
**Source:** `packages/spec/src/api/rest-server.zod.ts`
</Callout>
Expand Down Expand Up @@ -205,7 +245,7 @@ const result = BatchEndpointsConfigSchema.parse(data);
| **enableBatch** | `boolean` | optional (default: `true`) | Enable batch operation endpoints |
| **enableDiscovery** | `boolean` | optional (default: `true`) | Enable API discovery endpoint |
| **enableOpenApi** | `boolean` | optional (default: `true`) | Enable OpenAPI 3.1 spec & docs viewer endpoints |
| **enableSearch** | `boolean` | optional (default: `true`) | Enable structured search endpoints (deployment-wide search opt-out) |
| **enableSearch** | `boolean` | optional (default: `true`) | Enable structured search endpoints (server-wide search opt-out; embedder-only, not settable from `os serve`) |
| **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. |
Expand Down Expand Up @@ -242,9 +282,9 @@ const result = BatchEndpointsConfigSchema.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **api** | `{ version: string; basePath: string; apiPath?: string; enableCrud: boolean; … }` | optional | REST API configuration |
| **crud** | `{ operations?: object; dataPrefix: string }` | optional | CRUD endpoints configuration |
| **metadata** | `{ prefix: string; enableCache: boolean; maskObjectFields: boolean; endpoints?: object }` | optional | Metadata endpoints configuration |
| **batch** | `{ maxBatchSize: integer; enableBatchEndpoint: boolean; operations?: object }` | optional | Batch endpoints configuration |
| **crud** | `{ operations?: object; dataPrefix: string }` | optional | CRUD endpoints configuration (embedder-only: written by a host that constructs this config, never by `os serve` or the dev plugin) |
| **metadata** | `{ prefix: string; enableCache: boolean; maskObjectFields: boolean; endpoints?: object }` | optional | Metadata endpoints configuration (embedder-only: written by a host that constructs this config, never by `os serve` or the dev plugin) |
| **batch** | `{ maxBatchSize: integer; enableBatchEndpoint: boolean; operations?: object }` | optional | Batch endpoints configuration (embedder-only: written by a host that constructs this config, never by `os serve` or the dev plugin) |
| **routes** | `{ }` | optional | Route generation configuration |
| **openApi31** | `never` | optional | [REMOVED] `RestServerConfig.openApi31` was removed in @objectstack/spec 17 (ADR-0049) — no runtime ever read it: the REST server forwards only `api`/`crud`/`metadata`/`batch`/`routes`, and the served /openapi.json is the pre-generated contract enriched with the live server URL and the registered objects, so webhook/callback definitions declared here never appeared in it. Delete the key. Config-driven OpenAPI 3.1 webhooks/callbacks documentation is a new capability and must arrive via the enforce route of ADR-0049 (a new ADR), not by re-declaring the key; for a real outbound webhook use `Webhook` from `@objectstack/spec/automation`. |

Expand All @@ -261,7 +301,7 @@ const result = BatchEndpointsConfigSchema.parse(data);
| **enableBatch** | `boolean` | optional (default: `true`) | Enable batch operation endpoints |
| **enableDiscovery** | `boolean` | optional (default: `true`) | Enable API discovery endpoint |
| **enableOpenApi** | `boolean` | optional (default: `true`) | Enable OpenAPI 3.1 spec & docs viewer endpoints |
| **enableSearch** | `boolean` | optional (default: `true`) | Enable structured search endpoints (deployment-wide search opt-out) |
| **enableSearch** | `boolean` | optional (default: `true`) | Enable structured search endpoints (server-wide search opt-out; embedder-only, not settable from `os serve`) |
| **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. |
Expand Down
2 changes: 1 addition & 1 deletion docs/qa/platform-checklist/FOLLOW-UPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -568,7 +568,7 @@ card filed against it*. Recorded, not acted on — the channel question is #1173
| # | finding | evidence | captured in | handling |
|---|---|---|---|---|
| E1 | **`metadata.endpoints.items` gates four routes, three of which its declared meaning does not cover** — its `describe()` says "GET /meta/:type — List items of type", and it also gates `GET {prefix}/diagnostics`, `GET {prefix}/_drafts` and the **`POST {prefix}/_migrate-stored` write door**. An operator switching off a listing read silently disarms a migration door and the cross-type spec-validation sweep. `endpoints.item` is milder but the same shape: it also takes `{prefix}/book/:name/tree`. | `packages/rest/src/rest-server.ts#registerMetadataEndpointsInner` (four `endpoints.items` gates, four `endpoints.item` gates) vs `packages/spec/src/api/rest-server.zod.ts#MetadataEndpointsConfigSchema` (one route named per switch) | api-backend.rest-metadata-config-contract (a clause requires the run to ENUMERATE each switch's real radius) | design/docs — filed as #15542, **RULED and closed**: every `endpoints.*` switch now gates exactly the face its name states. The whole-store family (`/diagnostics`, `/_drafts`, `POST {prefix}/_migrate-stored`) moved to a new key `maintenance`; `items` is down to its one declared mount; `item` gained the per-item `PUT`/`DELETE` and the history family it never gated (the converse mismatch, filed as #15854 and landed in the same PR); all four `describe()` strings now enumerate their mounts. ⛔ The checklist clause is **kept**, not retired — the run still ENUMERATES each switch's real radius from a route-table diff, and `areas/api-backend.json` revision 2 carries the new expected sets. |
| E2 | **No shipped boot path authors `RestServerConfig` at all.** `os serve` constructs the REST plugin with a fixed config (only `enableProjectScoping` / `projectResolution` are threaded) and the dev plugin calls `createRestApiPlugin()` with none, so `crud` / `metadata` / `batch` / `routes` are reachable only from embedder code (`createRestApiPlugin({ api })`, `createHonoServerPlugin({ restConfig })`). A deployment cannot set `batch.maxBatchSize`, move `crud.dataPrefix`, or opt out of ADR-0106 D8 masking without embedding. | `packages/cli/src/commands/serve.ts` (the fixed construction) · `packages/plugins/plugin-dev/src/dev-plugin.ts` (no config) | the three config items' `knownGaps` — every non-default clause is scored `oracle: test` in a harness, and the run record must say so instead of claiming a reconfigured deployment | capability gap — filed as #15543 |
| E2 | **No shipped boot path authors `RestServerConfig` at all.** `os serve` constructs the REST plugin with a fixed config (only `enableProjectScoping` / `projectResolution` are threaded) and the dev plugin calls `createRestApiPlugin()` with none, so `crud` / `metadata` / `batch` / `routes` are reachable only from embedder code — through the one real door, `createRestApiPlugin({ api })`. ⚠️ **This row used to name a second door, `createHonoServerPlugin({ restConfig })`; no such function exists** (definition probe: zero across the tree, against a positive control that finds `createRestApiPlugin` at `packages/rest/src/rest-api-plugin.ts#createRestApiPlugin`). `HonoServerPlugin` is a class whose `restConfig` option has one reader taking `api.basePath` for the SPA fallback, and it never constructs a REST server. Corrected 2026-09-08 with #15543. A deployment cannot set `batch.maxBatchSize`, move `crud.dataPrefix`, or opt out of ADR-0106 D8 masking without embedding. | `packages/cli/src/commands/serve.ts` (the fixed construction) · `packages/plugins/plugin-dev/src/dev-plugin.ts` (no config) | the three config items' `knownGaps` — every non-default clause is scored `oracle: test` in a harness, and the run record must say so instead of claiming a reconfigured deployment | capability gap — filed as #15543, **RULED 2026-09-07** (director seat, summon #17, decision batch #2, maintainer verbatim 「同意」): the keys **stay** and keep their runtime reads; threading a config through is not taken (a new authorable surface for no measured demand, and blocked on #15542) and retiring them is refused (they have an embedder consumer). ⇒ **This is a recorded posture, not an open gap**, so the harness scoring in the three items' `knownGaps` is the permanent method, not a workaround pending a fix. The reachability answer is now written down in two places instead of being re-derived per run: the schema docblocks (`packages/spec/src/api/rest-server.zod.ts` → WHO CAN WRITE THIS CONFIG, plus a per-sub-object line on `crud` / `metadata` / `batch`) and a per-key REACHABILITY row in `packages/spec/liveness/{crud,metadata,batch}_endpoints.json`. ⚠️ Re-derived while closing this, and the correction is about WHERE, not whether. The card and the ruling cite `batch.maxBatchSize`'s docblock as calling the cap *"deployment policy"* and attribute it to `packages/spec/src/api/rest-server.zod.ts`; the phrase **does not occur in that file** (control: `maxBatchSize` occurs there 3 times, so the zero is a real zero). It **does exist**, verbatim, in the REST server: *"The cap is deployment policy — `RestServerConfig.batch.maxBatchSize` (1..1000, default 200)"* at `packages/rest/src/rest-server.ts#enforceBatchSize`. ⇒ **The ruling misattributed the sentence to the wrong file; it did not invent it.** That carrier is real, is the same defect class, and is **owed to a successor card in `domain:rest`** — this PR does not touch `packages/rest`. Inside `packages/spec/src/api/rest-server.zod.ts` the population is: `metadata.maskObjectFields`'s `false` *"opts this server out"* (the ruling's one correctly-placed citation), plus a carrier the card missed entirely, `api.enableSearch` (*"Deployment-wide switch"* in its docblock and *"deployment-wide search opt-out"* in its `describe()`), which `os serve` does not forward either. Both are repaired here. |
| E3 | **The MOUNT half of every sub-config switch is unpinned.** `packages/rest/src/rest-sub-config-parse-not-cast.test.ts` pins what a switch normalizes to, and `rest-batch-size-cap.test.ts` pins the cap's effect; nothing asserts that a `false` switch removes its route from the table `getRoutes()` returns. The declared-not-enforced direction — a switch that normalizes correctly and gates nothing — is exactly what no current test would catch. ⚠️ The card said **nine** switches; re-measured on `cc5b3dd0c27` the mount-gating population is **nineteen** — the twelve sub-config switches the card enumerates (its own list adds to twelve, not nine) plus the seven `api.enable*` gates in `registerRoutes`, which are the same seam and were equally unpinned. | the two test files above; the gates live in `registerCrudEndpoints` / `registerBatchEndpoints` / `registerMetadataEndpointsInner` / `registerRoutes` | the three config items (the mount clauses, each with the gap named in `knownGaps`) | test gap — filed as #15544, **closed by `packages/rest/src/rest-config-mount-table.pin.test.ts`**: all nineteen gates pinned as a set difference against the all-true baseline, each with its presence twin (**twenty** since #15542 added `metadata.endpoints.maintenance`; the pin's §0 count moves deliberately with each switch added or retired). ⚠️ The three config items' `knownGaps` still say the harness is the only observation — stale in the good direction, refresh pending (`areas/api-backend.json` was held by another branch when this landed); `rest-metadata-config-contract`'s `automated.ref` now names this pin, the other two are still owed. |

### 10c. Checked and CLEAN (so the next sweep does not re-derive)
Expand Down
Loading
Loading