Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
aacda8d
fix(rest): consume the parsed `api` sub-config instead of discarding it
claude Sep 5, 2026
c038409
test(rest): pin that the `api` defaults follow `RestApiConfigSchema`
claude Sep 5, 2026
3926655
chore(changeset): rest api config defaults now come from the schema
claude Sep 5, 2026
7222e7f
docs(permissions): re-anchor the system-context citations after the l…
claude Sep 5, 2026
920260e
docs(rest): retire the two comments that still called `api` validate-…
claude Sep 5, 2026
d51d455
docs(changeset): state the subtractive half of the `api` parse delta
claude Sep 5, 2026
b294219
Merge remote-tracking branch 'origin/main' into claude/issue-14366-co…
claude Sep 5, 2026
91fe238
Merge remote-tracking branch 'origin/main' into claude/issue-14366-co…
claude Sep 5, 2026
569df2f
docs(permissions): re-anchor the system-context census after merging …
claude Sep 5, 2026
5f5cf23
Merge remote-tracking branch 'origin/main' into claude/issue-14366-co…
claude Sep 5, 2026
232da30
docs(permissions): re-anchor the system-context census after the seco…
claude Sep 5, 2026
d2674b3
Merge branch 'main' into claude/issue-14366-consume-api-parse
claude Sep 5, 2026
dd0dd4b
docs(permissions): re-anchor the system-context census after the main…
claude Sep 5, 2026
cb8854e
Merge branch 'main' into claude/issue-14366-consume-api-parse
claude Sep 5, 2026
72da90c
docs(permissions): re-anchor the system-context census after the seco…
claude Sep 5, 2026
002e297
Merge remote-tracking branch 'origin/main' into claude/issue-14366-co…
claude Sep 5, 2026
005ff89
docs(permissions): re-anchor the system-context census after the thir…
claude Sep 5, 2026
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
11 changes: 11 additions & 0 deletions .changeset/rest-api-config-consumes-parse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@objectstack/rest": patch
---

The REST server's `api` configuration defaults now come from `RestApiConfigSchema` alone, instead of being restated in `packages/rest`.

`RestServer.normalizeConfig` already parsed `config.api` against `RestApiConfigSchema` — and then discarded the result, rebuilding the block from a `??` chain over the raw input. That chain restated the schema's eleven top-level `z.default(...)`s as eleven literals in a second package. They agreed key for key, and nothing measured that they would keep agreeing: changing a default in `@objectstack/spec` silently failed to propagate, because `api.enableUi ?? true` answers `true` for an absent key whatever the schema declares. Consuming the parse deletes the duplicate and makes the schema authoritative.

The parse itself is unchanged, so **nothing new is accepted or refused**: the same schema, with the same `.omit({ requireAuth: true })`, already ran at construction. `api.requireAuth` keeps its retired warn-and-ignore posture (`@objectstack/rest`'s plugin reads it off the raw config, so the warning is untouched), and every authored value still wins over the default.

One bounded behaviour change, for a caller who writes `api.documentation` or `api.responseFormat` — and it runs in two directions, not one. **Filled in:** those objects now arrive carrying their own declared inner defaults — `documentation.enabled` / `.title`, and `responseFormat.envelope` / `.includeMetadata` / `.includePagination`. **Stripped:** inner keys the schema does not declare no longer survive, at either depth — an authored `documentation.logo`, a `documentation.contact.phone` or a `documentation.license.spdxId` inside the nested objects, a `responseFormat.extra` — where the `??` chain passed the authored object through by reference and kept every key on it. Both halves are the same parse: `documentation` / `responseFormat` (and their `contact` / `license`) are non-strict `z.object()`s, which fill in their `.default()`s and drop what they do not name — dropped silently, so this is a strip and not a new refusal. An object left unwritten stays absent, and nothing in the platform reads either key today: the normalized block is `private` to `RestServer`, which reads only scalars off it (`apiPath` / `basePath` / `version` in `getApiBasePath`, the `enable*` flags, `projectResolution`), and the repo has no other read site for either key — so no consumer observes either half.
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:1553`, `:1582`), and neither
cannot set it (`packages/rest/src/rest-server.ts:1565`, `:1594`), 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:1585` |
| 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:1597` |

### 2. Write pipeline and data integrity

Expand Down Expand Up @@ -158,7 +158,7 @@ The largest single consumer — **17 of the 106 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:5083`, `:6509`, `:6757`, `:7188`, `:7381` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5127`, `:6553`, `:6801`, `:7232`, `:7425` |
| 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:422`, `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:1581` (#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:1553`, `:1582`; `domains/actions.ts:414` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1565`, `:1594`; `domains/actions.ts:414` |

---

Expand Down
153 changes: 153 additions & 0 deletions packages/rest/src/rest-api-config-defaults-follow-spec.pin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14366] `RestApiConfigSchema` is the SINGLE SOURCE of the `api` sub-object's
* defaults — `RestServer.normalizeConfig` follows a change to a
* `z.default(...)` in `packages/spec` rather than restating it.
*
* ⛔ ANTI-VACUITY, and this file exists because the ordinary spelling of this
* pin is vacuous. A test that asserts today's VALUES — `version === 'v1'`,
* `enableProjectScoping === false` — passes just as well with the deleted `??`
* chain still in place, because the chain's literals and the schema's defaults
* agreed key for key on the day the chain was written. That agreement is the
* whole defect: two sources that happen to match, with nothing measuring that
* they keep matching. Asserting the matched value measures neither source.
*
* So this file MOVES the schema and asks where the server lands. The mock
* below re-declares five `z.default(...)`s to values that differ from both the
* real schema's and the deleted chain's literals, then drives a REAL
* `RestServer` construction and reads the normalized config back:
*
* key real default deleted `??` literal mutated to
* version 'v1' 'v1' 'v9-mutated'
* basePath '/api' '/api' '/mutated'
* enableUi true true false
* enableProjectScoping false false true
* projectResolution 'auto' 'auto' 'required'
*
* Pre-change tree: all five answer the `??` literal, because the chain read the
* RAW input (`api.version ?? 'v1'`) and an absent key is nullish whatever the
* schema says — the parsed output was discarded. Post-change: all five answer
* the mutated default. That gap is what makes each case below a measurement of
* the propagation rather than of a coincidence. Measured, both directions, in
* this change's own reverse verification.
*
* ⚠️ 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` / `responseFormat` — live in
* `rest-config-parse-not-cast.test.ts` §D, which is deliberately unmocked.
*/

import { describe, it, expect, vi } from 'vitest';

vi.mock('@objectstack/spec/api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@objectstack/spec/api')>();
const { z } = await import('zod');
return {
...actual,
// `.extend()` on the real schema, not a hand-built stand-in: every
// other key — and the `requireAuth` tombstone the seam `.omit()`s —
// must survive, or this would measure a shape change rather than a
// default change. Only the five defaults move.
RestApiConfigSchema: (actual.RestApiConfigSchema as any).extend({
version: z.string().regex(/^[a-zA-Z0-9_\-\.]+$/).default('v9-mutated'),
basePath: z.string().default('/mutated'),
enableUi: z.boolean().default(false),
enableProjectScoping: z.boolean().default(true),
projectResolution: z.enum(['required', 'optional', 'auto']).default('required'),
}),
};
});

const { RestServer } = await import('./rest-server.js');
// The MOCKED schema, imported through the same specifier the seam uses, so the
// control case below reads the very object the server was handed.
const { RestApiConfigSchema } = await import('@objectstack/spec/api');

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 normalized `api` block, read off the constructed server. */
function normalizedApi(rest: unknown): Record<string, unknown> {
return (rest as { config: { api: Record<string, unknown> } }).config.api;
}

describe('[#14366] normalizeConfig follows the SCHEMA default, not a local literal', () => {
it('CONTROL: the mock really did move the schema', () => {
// Not decoration. Every assertion below is "the server answers X"; if
// the mock silently failed to apply, the real default would be `'v1'`
// and a `not.toBe('v1')` style pin could pass for the wrong reason.
// This proves the premise the rest of the file rests on.
const parsed = (RestApiConfigSchema as any).omit({ requireAuth: true }).parse({});
expect(parsed.version, 'the mocked schema must carry the mutated default').toBe('v9-mutated');
expect(parsed.basePath).toBe('/mutated');
expect(parsed.enableUi).toBe(false);
expect(parsed.enableProjectScoping).toBe(true);
expect(parsed.projectResolution).toBe('required');
});

it('a moved `version` default reaches the normalized config', () => {
// Pre-change: `'v1'` — `api.version ?? 'v1'` never consulted the schema.
expect(normalizedApi(construct()).version).toBe('v9-mutated');
});

it('a moved `basePath` default reaches the normalized config', () => {
expect(normalizedApi(construct()).basePath).toBe('/mutated');
});

it('a moved BOOLEAN default reaches it too — the `??` chain could not express this', () => {
// The sharpest of the five. `api.enableUi ?? true` yields `true` for an
// absent key no matter what the schema declares, so a spec change from
// `default(true)` to `default(false)` was UNREPRESENTABLE downstream:
// silently dropped, with every test still green. This is the drift the
// card was filed about, stated as an executable case.
expect(normalizedApi(construct()).enableUi).toBe(false);
expect(normalizedApi(construct()).enableProjectScoping).toBe(true);
});

it('a moved ENUM default reaches it', () => {
expect(normalizedApi(construct()).projectResolution).toBe('required');
});

it('the moved defaults reach the MOUNT, not just the config object', () => {
// Read through the behaviour, not only the structure: a default that
// landed in the normalized config but was not threaded would still be
// a half-fix. `getApiBasePath()` composes `${basePath}/${version}`.
expect(construct().getApiBasePath()).toBe('/mutated/v9-mutated');
});

it('an AUTHORED value still wins over the schema default — the change is defaults only', () => {
// The bound. Consuming the parse must not start overriding what the
// caller wrote; zod `.default()` applies to `undefined` alone.
const rest = construct({ version: 'v3', basePath: '/authored', enableUi: true });
expect(normalizedApi(rest).version).toBe('v3');
expect(normalizedApi(rest).basePath).toBe('/authored');
expect(normalizedApi(rest).enableUi).toBe(true);
expect(rest.getApiBasePath()).toBe('/authored/v3');
});

it('an authored FALSE still survives — `??` and the parse agree here, and must keep agreeing', () => {
// `false` is not nullish, so the deleted chain honoured it too. Kept as
// a regression guard: the failure this pin guards against is a future
// author "simplifying" the parse into a truthiness check.
const rest = construct({ enableProjectScoping: false });
expect(normalizedApi(rest).enableProjectScoping).toBe(false);
});
});
Loading
Loading