Skip to content
Merged
117 changes: 117 additions & 0 deletions .changeset/rest-sub-configs-parsed-not-cast.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
---
'@objectstack/rest': minor
---

**BREAKING (accept-set tightening)**: `RestServer` now parses `config.crud`,
`config.metadata`, `config.batch` and `config.routes` against the schemas that
declare them (`CrudEndpointsConfigSchema`, `MetadataEndpointsConfigSchema`,
`BatchEndpointsConfigSchema`, `RouteGenerationConfigSchema` in
`@objectstack/spec/api`) at construction, instead of casting to them — and
builds the normalized config from the parsed output (#11984).

The constraints were always declared. `packages/spec/src/api/rest-server.zod.ts`
carries `batch.maxBatchSize: z.number().int().min(1).max(1000)`, the
`routes.nameTransform` and `crud.objectParamStyle` enums and
`metadata.cacheTtl: z.number().int()`, and nothing ran them: both hops into
`@objectstack/rest` are casts, the plugin declares no `configSchema`, and #11637
deliberately parsed `api` alone so that one narrowing went in front of contract
review rather than five. Measured on the pre-fix tree: `batch.maxBatchSize: 0`
constructed happily and became the live batch cap (`?? 200` does not fire — `0`
is not nullish), and `routes.nameTransform: 'snake_case'` sat in the normalized
config as if it were declared.

**Newly refused, all at `new RestServer(...)` / `createRestApiPlugin().start()`,
with a message naming the sub-object, the failing key(s) and the declaring
schema** (a construction-time refusal, not an HTTP envelope):

- `batch.maxBatchSize` outside `1..1000` or not an integer — `0`, `-5`,
`2000`, `2.5`. Refused with zod's own bound text (`expected number to be >=1`,
`<=1000`, `expected int`).
- `routes.nameTransform` outside `'none' | 'plural' | 'kebab-case' | 'camelCase'`.
- `crud.objectParamStyle` outside `'path' | 'query'`.
- `metadata.cacheTtl` that is not an integer (`2.5`, `'60'`).
- A declared key of any of the four written with the wrong type:
`crud.dataPrefix: 42`, `metadata.enableCache: 'yes'`,
`routes.includeObjects: 'account'`, `batch.defaultAtomic: 'yes'`, ...
- An explicit **`null`** at any declared key of the four —
`batch: { maxBatchSize: null }`, `metadata: { cacheTtl: null }`,
`crud: { dataPrefix: null }`. The cast-era `??` chain read `null` as absent
and applied the default; the parse refuses it (`batch.maxBatchSize: Invalid
input: expected number, received null`), because zod's `.default()` fills
`undefined` only.
- A sub-object that is not an object at all — `batch: 'x'`, `routes: []` —
refused at the sub-object root (`batch.(root): Invalid input: expected
object, received string`), where the cast admitted it unchanged and every key
read came back `undefined`, so every key silently took its default.
- `crud.patterns` keyed by an operation outside the CRUD vocabulary
(`patterns: { bogus: {...} }`), or a pattern whose `method` is not an HTTP
method, or a pattern missing its required `path` — `patterns` is an
enum-keyed `z.record`, which zod validates key by key, and
`CrudEndpointPatternSchema.path` is a plain `z.string()`.
- A **partial** `routes.overrides.<object>.operations`. That record is
`z.record(CrudOperation, z.boolean())` with a non-optional value, which zod 4
reads as exhaustive: all five operations must be present. The input TYPE
already demanded all five at typed authoring sites; this is the day the
runtime agrees with `tsc`. #14365 proposes `z.partialRecord` for this record;
when that lands the refusal reverses, and the §A pin for it in
`rest-sub-config-parse-not-cast.test.ts` is deleted with it.

**Deliberately NOT refused** — the narrowing is exactly what the schemas
declare, and no more:

- A **negative** `metadata.cacheTtl`. The card that filed this defect listed
"a negative TTL" among the values the parse would refuse; the schema declares
`.int()` only, with no lower bound, so `-1` and `0` stay accepted. A lower
bound is `packages/spec`'s to declare, and is filed separately.
- **Unknown keys inside a sub-object**: all four schemas are non-strict
`z.object()`s, so `batch: { bogus: 1 }` is stripped, not refused — as before,
where the cast simply never read it.
- The retired whole-config key `openApi31` (#4579). Its `retiredKey()`
tombstone lives on `RestServerConfigSchema`, and this seam parses the five
sub-objects rather than the whole config, so the tombstone stays unexecuted:
the key keeps the ignore posture #3963 chose for `api.requireAuth`, and
flipping it into a boot failure is a maintainer's decision, not this seam's.
- `api`: unchanged from #11637 / #12450 (validate-only, `requireAuth` still
`.omit()`ed).

**The parsed output is now consumed** for the four sub-objects — defaults come
from the schema and unknown keys are stripped — because the decision was
measured per sub-object rather than inherited from `api`: for each of the four,
every key `normalizeConfig` reads is one its schema declares (the key diff is
empty), and none carries a tombstone, so nothing a consumed parse could strip
is anything the runtime honours. The one honoured-but-undeclared key this
family ever had, `metadata.maskObjectFields`, gained its declared seat in
#11983 and is pinned to survive the parse. Defaults are unchanged
(`maxBatchSize` 200, `cacheTtl` 3600, `dataPrefix` `/data`, `prefix` `/meta`,
`nameTransform` `'none'`, every operation/endpoint switch on, masking on per
ADR-0106 D8), and a partial `crud.operations` / `batch.operations` /
`metadata.endpoints` still takes per-key defaults (ADR-0122 author state). The
seam is one table of declared sub-object schemas and one `parseDeclaredSubConfig`;
`api` runs through the same table with its `.omit()`.

**Migration.** Correct the offending key at its producer; the refusal names the
sub-object, the key, the declared rule and the schema that declares it. A
deployment that meant "no batch cap" wants `enableBatchEndpoint: false` or
`api.enableBatch: false` (the cap's range is the declared policy), and a
partial `routes.overrides.<object>.operations` wants all five operations
spelled out (until #14365 lands).

**In-repo blast radius, measured per sub-object on `origin/main` @ `08e49496f`.**
140 files construct a REST server (`new RestServer(` or
`createRestApiPlugin(`, 277 sites); across all of them, **zero** pass a
`crud` / `metadata` / `batch` / `routes` block carrying any key the four
schemas declare (the `routes: { data: '', ... }` fixtures are `discovery.routes`
payloads, and every `metadata: { ... }` inside those files is endpoint or plugin
metadata — verified by scanning each block for the schema's own keys; positive
control: `rest-server.ts`'s own `NormalizedRestServerConfig` and `normalizeConfig`
blocks hit).
Repo-wide value census of the constrained keys, every file type: `maxBatchSize`
24 lines, 3 out-of-range literals — two are `packages/spec`'s own schema tests
(`0`, `2000`, which never construct a server) and one is a different schema's
key (`tracing.test.ts`); `nameTransform` and `objectParamStyle` 6 lines each, 0
unknown values; `cacheTtl` 69 lines, 1 non-integer literal (`30.7` in
`packages/runtime`'s endpoint-policy tests — the declarative endpoint's
`cacheTtl`, a different schema). No fixture changes; no in-repo boot path is
affected.

<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed or renamed — no spec key, export or config field changes spelling, and the four schemas in `packages/spec` are untouched. What changes is that constraints already declared at those keys are finally executed at the consumption seam, so `objectstack migrate meta` has no mechanical rewrite to list: a config carrying `maxBatchSize: 0` or `nameTransform: 'snake_case'` states an intent (which cap, which transform did you mean?) that no conversion can decide for the author, and the refusal text names the fix at the call site. -->
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:1445`, `:1474`), and neither
cannot set it (`packages/rest/src/rest-server.ts:1522`, `:1551`), and neither
can an action body (`packages/runtime/src/domains/actions.ts:404`). 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:246` |
| 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:1477` |
| 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:1554` |

### 2. Write pipeline and data integrity

Expand Down Expand Up @@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 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:136` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4629`, `:5992`, `:6240`, `:6671`, `:6864` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4716`, `:6079`, `:6327`, `:6758`, `:6951` |
| 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:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:276`, `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:1516` (#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:286` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1445`, `:1474`; `domains/actions.ts:404` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1522`, `:1551`; `domains/actions.ts:404` |

---

Expand Down
Loading
Loading