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
105 changes: 105 additions & 0 deletions .changeset/metadata-endpoints-switch-radius-maintenance-key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
---
"@objectstack/spec": minor
"@objectstack/rest": minor
---

feat(spec): every `metadata.endpoints.*` switch gates exactly the face its name states, and the whole-store operations get their own key `maintenance` (#15542, #15854)

`RestServerConfig.metadata.endpoints` declared three switches, each `describe()` naming
exactly one route, and each gated a different set. The mismatch ran in **both**
directions at once:

- **`items`** — declared "GET /meta/:type - List items of type" — also gated the
whole-store family: the cross-type spec-validation sweep `GET /meta/diagnostics`, the
draft list `GET /meta/_drafts`, and the **`POST /meta/_migrate-stored` write door**.
An operator who switched off a listing read they considered chatty silently unmounted
a migration door.
- **`item`** — declared "GET /meta/:type/:name - Get specific item" — gated four
*reads* (`/:type/:name`, `/references`, `/layers`, `/book/:name/tree`) and left the
per-item **writes** `PUT` and `DELETE /meta/:type/:name` plus the whole history family
(`/history`, `/audit`, `/diff`, `/published`, `/publish`, `/rollback`) answering to
`api.enableMetadata` alone. An operator who closed the per-item surface left its
writes mounted.

Neither is a liveness defect — all three keys were genuinely read — which is why no
ADR-0049 census could ever flag them: what drifted was each key's **radius** against its
own documentation.

**One principle now holds across the block: a switch gates exactly the face its name
states, reads and writes alike.**

| key | mounts it gates (default prefix `/meta`) |
|---|---|
| `types` | `GET /meta`, `GET /meta/types` — one handler, two paths (unchanged) |
| `items` | `GET /meta/:type` — and nothing else |
| `item` | `GET` / `PUT` / `DELETE /meta/:type/:name`, `/references`, `/layers`, `/history`, `/audit`, `/diff`, `/published`, `/publish`, `/rollback`, and `GET /meta/book/:name/tree` |
| `maintenance` | **new** — `GET /meta/diagnostics`, `GET /meta/_drafts`, `POST /meta/_migrate-stored` |

All four `describe()` strings are rewritten to enumerate what they gate, so the
generated reference page is the radius rather than a sample of it.
`api.enableMetadata` remains the master switch above all four, and
`GET /meta/object/:name/state/:field` — the object FSM read, addressed by object name
rather than by `:type/:name` — deliberately stays under that master switch alone.

**BREAKING** — for a programmatic embedder that authors `RestServerConfig.metadata.endpoints`,
the mounted route table moves for two of the four keys, in opposite directions:

- **`items: false` now removes one route instead of four.** An embedder relying on it to
close `/diagnostics`, `/_drafts` and the `POST /_migrate-stored` door **regains all
three** unless it also sets `maintenance: false`. That is a write door coming back, so
it is the half to read twice. One line restores the old table:
`endpoints: { items: false, maintenance: false }`.
- **`item: false` now removes twelve routes instead of four.** An embedder relying on it
to close only the per-item *reads* while keeping `PUT`, `DELETE` and the history family
mounted **loses those eight**. There is no key that restores them — the per-item face is
one face by this ruling — so an embedder that wants the writes keeps `item` on and
closes the surface at `api.enableMetadata` or at the object's own `enable.apiMethods`.
- **The exported type `MetadataEndpointsConfigParsed` narrows: `endpoints` gains a
REQUIRED member `maintenance: boolean`.** `maintenance` is `z.boolean().default(true)`,
so it is optional on the way *in* and always present on the way *out* — and
`MetadataEndpointsConfigParsed` is `z.infer<typeof MetadataEndpointsConfigSchema>`, the
OUTPUT side. Any code that builds one of these objects by hand — a test fixture, a
helper returning the parsed shape, a `satisfies MetadataEndpointsConfigParsed` literal —
stops compiling with `TS2741: Property 'maintenance' is missing`. This one IS
compiler-carried (the ADR-0087 D8 class), which is the good case: the break is loud, it
lands at build time, and no runtime behaviour depends on the author noticing a
changelog. Add `maintenance: true` to restore the previous mounts, or `false` to keep
the whole-store family closed. In-repo consumers of the type: none — the narrowing was
measured against a probe compiled from the rebuilt declaration, not assumed.

Priced and accepted rather than deferred: `RestServerConfig` is reachable from **no
shipped boot path** today (`os serve` fixes the config and the dev plugin passes none,
#15543), so the measured population of affected authors is **zero** and the blast radius
is programmatic embedders only. That is precisely why this lands now — once a boot path
starts authoring the config, the same change becomes a behaviour change on live
operators.

**ADR-0087 disposition: a D3 semantic migration, no D2 conversion.** No
authored key changes shape or spelling — `items: false` still parses to `items: false`,
`maintenance` is additive with `.default(true)`, and nothing is retired (`endpoints.schema`
stays the #14691 tombstone it already was). There is nothing for the conversion layer to
convert: a `RestServerConfig` is plugin TS configuration, never a stack collection member
and never a `sys_metadata` row (the `RestServerConfig.openApi31` precedent, #4579), so no
rehydration seam sees it. What changes is a mounted route table at construction time.

Nor is the RADIUS change compiler-carried on the AUTHORED side — and that is the half a
D3 is owed for. Every authored key is an optional boolean, so `{ items: false }` still
compiles and still parses and simply mounts a different table: the author is told
nothing. (The parsed-type narrowing in the third BREAKING bullet above *is*
compiler-carried, but it catches only code that hand-builds the OUTPUT type — it cannot
reach the embedder who authored `{ items: false }` and now silently gets three routes
back.) So for the change that actually moves the route table, both channels that would
otherwise reach a consumer are blind, which is precisely the residue D3 exists for — the prescription is registered as
`metadata-endpoints-switch-radius-repartitioned` so `objectstack migrate meta` hands
it to an upgrading embedder instead of leaving it as prose in a changelog.

<!-- adr-0087: registered metadata-endpoints-switch-radius-repartitioned -->

`@objectstack/rest` is versioned alongside rather than as a passive consumer: it is where
the gates live, so the route-table change is observable there and not only in the
declaration.

Every key's radius is pinned route by route, in both directions, in
`packages/rest/src/rest-config-mount-table.pin.test.ts` — the #15544 shape, which asserts
each route is **absent from the mounted table** when its switch is off rather than what
the switch normalizes to. A gate that grows or loses a route reddens there.
8 changes: 4 additions & 4 deletions content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ not on any flag.
## How the flag is set

`isSystem` is **server-constructed and never client-supplied**. Inbound HTTP
cannot set it (`packages/rest/src/rest-server.ts:1739`, `:1768`), and neither
cannot set it (`packages/rest/src/rest-server.ts:1749`, `:1778`), and neither
can an action body (`packages/runtime/src/domains/actions.ts:414`). It is
written by internal callers only, as an option on the engine call:

Expand Down Expand Up @@ -103,7 +103,7 @@ that silently does not happen.
| 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:250` |
| 15 | **Read-audit rows are not written** | plugin-audit | Lose: the "a person opened this record" trail. `sudo()` keeps the caller's `userId`, so this flag is the only thing separating a human read from a platform one | `read-audit.ts:556` |
| 16 | Approval snapshot payload redaction skipped | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel. Lose: field-visibility redaction over approval payloads | `payload-redaction-middleware.ts:115` |
| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1771` |
| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1781` |

### 2. Write pipeline and data integrity

Expand Down Expand Up @@ -158,7 +158,7 @@ The largest single consumer — **17 of the 105 sites**.
|:--|:---|:---|:---|:---|
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:138` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5319`, `:6766`, `:7014`, `:7445`, `:7638` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5411`, `:6868`, `:7116`, `:7547`, `:7740` |
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:552`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
Expand Down Expand Up @@ -199,7 +199,7 @@ assuming `isSystem` covers it is a documented source of bugs.
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1590` (#3493 / #6640) |
| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` |
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:299` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1739`, `:1768`; `domains/actions.ts:414` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1749`, `:1778`; `domains/actions.ts:414` |

---

Expand Down
13 changes: 7 additions & 6 deletions content/docs/references/api/rest-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -175,16 +175,17 @@ const result = BatchEndpointsConfigSchema.parse(data);
| **enableCache** | `boolean` | optional (default: `true`) | Enable HTTP cache headers (ETag, Last-Modified) |
| **cacheTtl** | `never` | optional | [REMOVED] `metadata.cacheTtl` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: `metadata.enableCache` selects the protocol's `getMetaItemCached` read path, which takes no TTL, and no Cache-Control / ETag header was ever built from this value. Delete the key; `metadata.enableCache` is the live switch, and a declarative `api` endpoint's `cacheTtl` is the key that does reach the wire. |
| **maskObjectFields** | `boolean` | optional (default: `true`) | [ADR-0106 D8] Mask served object schemas to the caller's readable fields |
| **endpoints** | `{ types: boolean; items: boolean; item: boolean }` | optional | Enable/disable specific endpoints |
| **endpoints** | `{ types: boolean; items: boolean; item: boolean; maintenance: boolean }` | optional | Enable/disable specific endpoints |

### Nested Shape: `MetadataEndpointsConfig.endpoints`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **types** | `boolean` | optional (default: `true`) | GET /meta - List all metadata types |
| **items** | `boolean` | optional (default: `true`) | GET /meta/:type - List items of type |
| **item** | `boolean` | optional (default: `true`) | GET /meta/:type/:name - Get specific item |
| **schema** | `never` | optional | [REMOVED] `metadata.endpoints.schema` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — it gated a route that does not exist: the REST server mounts no `GET /meta/:type/:name/schema`, so `false` removed nothing and `true` added nothing. Delete the key; `endpoints.types` / `items` / `item` are the switches that gate real mounts. |
| **types** | `boolean` | optional (default: `true`) | Mount the metadata type list — `GET /meta` and `GET /meta/types` (one handler, two paths) |
| **items** | `boolean` | optional (default: `true`) | Mount the per-type item list — `GET /meta/:type`, and nothing else |
| **item** | `boolean` | optional (default: `true`) | Mount the whole per-item face — `GET`, `PUT` and `DELETE /meta/:type/:name`, its `/references` and `/layers` reads, the history family (`/history`, `/audit`, `/diff`, `/published`, `/publish`, `/rollback`) and `GET /meta/book/:name/tree` |
| **maintenance** | `boolean` | optional (default: `true`) | Mount the whole-store maintenance operations — `GET /meta/diagnostics`, `GET /meta/_drafts` and the `POST /meta/_migrate-stored` write door |
| **schema** | `never` | optional | [REMOVED] `metadata.endpoints.schema` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — it gated a route that does not exist: the REST server mounts no `GET /meta/:type/:name/schema`, so `false` removed nothing and `true` added nothing. Delete the key; `endpoints.types` / `items` / `item` / `maintenance` are the switches that gate real mounts. |


---
Expand Down Expand Up @@ -284,7 +285,7 @@ const result = BatchEndpointsConfigSchema.parse(data);
| **enableCache** | `boolean` | optional (default: `true`) | Enable HTTP cache headers (ETag, Last-Modified) |
| **cacheTtl** | `never` | optional | [REMOVED] `metadata.cacheTtl` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: `metadata.enableCache` selects the protocol's `getMetaItemCached` read path, which takes no TTL, and no Cache-Control / ETag header was ever built from this value. Delete the key; `metadata.enableCache` is the live switch, and a declarative `api` endpoint's `cacheTtl` is the key that does reach the wire. |
| **maskObjectFields** | `boolean` | optional (default: `true`) | [ADR-0106 D8] Mask served object schemas to the caller's readable fields |
| **endpoints** | `{ types: boolean; items: boolean; item: boolean }` | optional | Enable/disable specific endpoints |
| **endpoints** | `{ types: boolean; items: boolean; item: boolean; maintenance: boolean }` | optional | Enable/disable specific endpoints |

### Nested Shape: `RestServerConfig.batch`

Expand Down
Loading
Loading