Skip to content

Commit 7fa3e3e

Browse files
objectstack-fleet[bot]claudehotlong
authored
fix(rest): /meta/:type/:name/diff and /history are authoring doors, refused as /meta/_drafts refuses (#20378) (#20440)
Fixes #20378 Clause-②: no `GET /api/v1/meta/:type/:name/diff` and `GET /api/v1/meta/:type/:name/history` are now authoring doors. A caller that `mayReadPendingDrafts` does not admit is refused as `GET /api/v1/meta/_drafts` refuses: `403`, code `FORBIDDEN`, in the same nested `error` envelope. The decision is made on the caller before the protocol is resolved, before the query is parsed, and before any item, event or version is read. This executes ruling `5865708652` on the card (letter B, the maintainer's 「同意」 through the director seat). That ruling narrows item 2 of ruling B on #20156 (`5856774816`) for these two doors only. ## Why Both doors read `sys_metadata_history`, the authoring commit log (ADR-0067). A draft save appends a row there exactly as an active save does, and nothing on the row says which kind it was. A member with no authoring capability who could open an item could therefore read two things: - its pending draft through `/diff`, by naming the draft save's version in `from`/`to`, or through the default range once a draft is pending; - its draft-save events through `/history`. ADR-0106 D4 says 「draft/preview reads are admin-gated upstream already」. The version store has no published-only answer to fall back to, so these two doors take the `/meta/_drafts` shape (refuse). They do not take the draft switches' shape (answer as if the switch were absent). ## What changed - **`packages/rest/src/rest-server.ts`: a guard at the head of each handler.** Each door resolves its caller once (`resolveExecCtx`, memoised per request) and asks `mayReadPendingDrafts`. That is the one predicate `/meta/_drafts` and every draft switch already ask, so there is no second rule. The org partition further down reuses the same resolved caller. Callers it admits read exactly what they read before, including the `#20156` per-caller gate and the author exemption on `/diff`. - **What the refusal carries.** It has no item name, version or event. Its message names the door ("version history" or "stored versions"), never drafts, so the answer is the same for a published item, a draft-only item and a name with nothing behind it. The door is not an existence oracle. - **Unchanged:** `/layers`, `?layers=true` and `/audit`. - **`packages/rest/src/meta-history-diff-authoring-door.test.ts` (new).** It boots the real stack as `meta-draft-read-builder-gate.test.ts` does: better-sqlite3 in memory, the real `sys_metadata*` objects, a real `ObjectStackProtocolImplementation` and the real routes. The only stubs are `resolveExecCtx` and the `tenancy` service probe. It pins four things: - For `app` and `view` on both doors, a member without an authoring capability gets `403 FORBIDDEN`. The envelope keys equal those of the member's own `/meta/_drafts` answer. The answers for a published item, a draft-only item and a missing name are byte-identical. No protocol read is reached: spies on `getMetaItem`, `getMetaItemLayered`, `historyMetaItem` and `diffMetaItem` stay uncalled, and a builder call on the same door proves the spies are live. - A draft-save range, the default range and an unparseable bound all get the member the same refusal. The unparseable bound is answered `400` to a builder, which shows the member's refusal is decided before the query parse. - Every builder (`studio.access`, `setup.access`, `manage_metadata`) reads both doors as before, with author-whole versus pruned on `/diff` for `app`. - The lit control: the member still reads `/layers` and `?layers=true`, and gets the active row pruned as the plain read prunes it. - **`packages/rest/src/meta-alternate-door-read-gates.test.ts`, the `#20156` census.** - The `/diff` and `/history` rows gain `authoring: true`. For a caller that `/meta/_drafts` refuses, each census cell asserts `403 FORBIDDEN`, asserts that no secret appears in the answer, and asserts that `getMetaItem`, `diffMetaItem` and `historyMetaItem` were never called. - The presenter edge now expects the refusal on `/diff`. - The two edges that ask what an admitted caller sees on `/diff` and `/history` now drive the admitted `reader` caller. - The `/layers` and `?layers=true` rows are untouched. - **Docs.** In `content/docs/api/client-sdk.mdx`, one line beside the `client.meta.diffItem` example states the authoring-only rule. In `content/docs/ui/apps.mdx`, the paragraph that told every other caller they read `/diff` pruned now states that `/diff` and `/history` need the capability outright. - **Changeset:** `@objectstack/rest` `patch`, `Clause-②: no`. It pulls the declared contract (ADR-0106 D4) back in. ## Takeover of pushed work A previous dev pushed this branch (`5a09278bad` the guard, `1f4a1faf06` the pins, docs and changeset, `24c384d8d3` a merge). Its session ended before a PR or report. Every hunk was re-read against the card and the ruling. All were kept but one: - `meta-history-diff-authoring-door.test.ts` had a TS2345. The inferred union of the three `/diff` query literals is not assignable to the helper's `Record` type, and `check:test-typecheck` was red on a file its ledger does not cover. `db05a9e270` fixes it. `origin/main` was merged twice: `f4c601e889`, then `c0675573df`. The second merge carries the landing of #20404 on the same file. That PR moved the list chain, `isPublicAudienceRead` and the item read, and touched neither handler here. After the merge both guards still sit at the head of their handlers, and the branch's delta against `main` is the same six files. The runtime dispatcher still serves neither `/history` nor `/diff`. ## Verification (all at head `c0675573df`) - Build: `pnpm --workspace-concurrency=2 --filter '@objectstack/rest^...' build` exited 0. `pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2` exited 0 with 71/71 tasks, which the whole-tree gates need. - `pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2`: 215 files passed; 3904 tests passed and 26 skipped. - `pnpm --filter @objectstack/rest exec vitest run --project repo --maxWorkers=2`: 1 file passed, 8 tests passed. - `pnpm --filter @objectstack/rest typecheck` exited 0: `check:test-typecheck: OK`, with 0 files in the debt ledger. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 91 commands. All 91 were run and each exited 0. `--ran` reports: `91 derived, 91 run, 0 NOT-MEASURED, 0 UNRUN`. - `pnpm lint`, the whole repository, exited 0 in 37s. - `node scripts/check-issue-citations.mjs --base origin/main` exited 0: 7 citations, all resolve. **Ablation, per door.** The source is imported relatively (`./rest-server.js`), so no `dist` sits between the mutation and the tests. Each run went through `scripts/ablation-replace.mjs`, whose anchor must hit (1 to 0). The mutation replaced the door's guard with `if (false && !mayReadPendingDrafts(...))` and then ran both test files. The restore was proven: the blob is `e0f7a215dbe0`, equal to `HEAD`, and `git diff HEAD` is empty. | guard removed | red | green | |:--|:--|:--| | `/diff` | 14 of 310, every one a `/diff` refusal pin: the 9 census cells for a caller that may not read drafts, the presenter edge, the member pins for `app` and `view`, the range pin, and the one-predicate pin | every builder pin, every `/history` pin, both `/layers` controls | | `/history` | 13 of 310, every one a `/history` refusal pin: 9 census cells, the member pins for `app` and `view`, the `limit` pin, and the one-predicate pin | every builder pin, every `/diff` pin, both `/layers` controls | ## Acceptance notes - **The refusal message.** It is the one byte-level difference from `/meta/_drafts`. The status, the code and the envelope's key set are identical and pinned. The message names the door rather than drafts, because a refusal worded about drafts would read as "this item has one". It is not pinned, since no consumer parses it. - **`/audit` is outside this change.** The ruling names `/diff` and `/history` only. `/audit` serves `sys_metadata_audit` rows (actor, time, operation, outcome, no bodies). Whether a draft save writes an audit row that a member then reads was not measured; this note is read at source only. Carrier: none. - **objectui at the pin `f8a9d0fb05`.** - `/history` is consumed by `MetadataResourceHistoryPage`, on the metadata-designer routes, an authoring surface. - `MetadataClient.diff` has no caller. - A caller without an authoring capability who opens that route now receives the `403`. - **The sibling card is separate.** The mislabelled default `/diff` range (#20397) is not addressed here and proceeds unchanged. ## Declared narrowing: the verify lock `scripts/pm/os-verify-lock.sh` printed this for every build, test and ablation above (this host is macOS): **Declared narrowing — verification ran UNLOCKED.** `scripts/pm/os-verify-lock.sh` could not take the shared verify lock on this host: no usable `flock`. The shared verify lock is declared Linux-only (`flock` is util-linux, and a stock macOS does not ship it), so the command below was run directly, without the lock — a declared narrowing, not a silent one. No serialization guarantee held for this run, nor for any sibling agent in this container while it ran. pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2 The same disclosure was printed for each of the other wrapped commands: the two builds, the `repo` project run, the typecheck and the two ablation runs. --- _Generated by [Claude Code](https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com>
1 parent 6e3e546 commit 7fa3e3e

6 files changed

Lines changed: 529 additions & 14 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
**`GET /api/v1/meta/:type/:name/diff` and `GET /api/v1/meta/:type/:name/history` are now authoring doors: a caller without an authoring capability is refused, as `GET /api/v1/meta/_drafts` refuses.** Before this release, any signed-in caller who could open an item could read its version diff and its change history. Both doors read the metadata version log, which records a draft save exactly as it records a published save. So a member could read an item's unpublished draft through `/diff`, either by naming the draft save's version in `from`/`to` or through the default range once a draft was pending. Through `/history`, the same member could read the draft-save events. This follows the maintainer's ruling on #20378 (letter B, comment 5865708652), which pulls both doors back into the declared contract: draft and preview reads are admin-gated upstream (ADR-0106 D4). It narrows the earlier ruling that let every caller who may open an app read `/diff` pruned, for these two doors only.
6+
7+
Clause-②: no
8+
9+
- **Who may read them:** a system context, or a caller holding `studio.access`, `setup.access` or `manage_metadata`. This is the predicate `/meta/_drafts` and every draft switch already ask, not a second rule.
10+
- **Everyone else:** `403` with code `FORBIDDEN`, in the same nested `error` envelope `/meta/_drafts` answers. The refusal is decided on the caller before the query is parsed and before any item or version is read. So it is the same answer for an item that exists, one that does not, and one that exists only as a draft, and it carries no item name, version or event. The message names the door, not drafts.
11+
- **Unchanged:** callers with an authoring capability read both doors exactly as before, per-caller pruning included: on `/diff`, whoever may save an app reads both sides whole, and any other admitted caller reads them pruned. `/layers` and the deprecated `?layers=true` read the active row, so they keep answering every caller who may open the app with the pruned plain-read answer. `/audit` is unchanged.
12+
13+
A client that read `/diff` or `/history` as a member now receives `403 FORBIDDEN`. To read them, call as a caller holding one of the three capabilities above.

‎content/docs/api/client-sdk.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ const view = await client.meta.getItem('view', 'crm_lead.pipeline');
227227
// Per-item draft lifecycle (ADR-0033)
228228
await client.meta.publishItem('object', 'account', { message: 'go live' });
229229
await client.meta.rollbackItem('object', 'account', 3);
230+
// Authoring-only, like getHistory and listDrafts: without studio.access, setup.access or manage_metadata → 403 FORBIDDEN
230231
const diff = await client.meta.diffItem('object', 'account', { from: 2, to: 5 });
231232

232233
// Introspection & governance

‎content/docs/ui/apps.mdx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,12 @@ an app an author may not open is refused on these doors too. Reading a
379379
needs an authoring capability (`studio.access`, `setup.access` or
380380
`manage_metadata`: the check `GET /api/v1/meta/_drafts` makes). A caller
381381
without one is answered as if the parameter were absent: the published app,
382-
or `404` for an app that has never been published.
382+
or `404` for an app that has never been published. `/diff`, and the change log
383+
`/history` beside it, need that capability outright: both read the version log,
384+
which records a draft save like any other, so they have no published-only
385+
answer to fall back to. A caller without one is refused with `403`, as
386+
`GET /api/v1/meta/_drafts` refuses, before anything is read — the same answer
387+
whether or not the app exists.
383388

384389
`visible` did **not** move server-side with them, and that asymmetry is
385390
deliberate: CEL is evaluated in the browser because server-side evaluation needs

‎packages/rest/src/meta-alternate-door-read-gates.test.ts‎

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,15 @@
5151
* answers them what the plain read answers them, byte for byte.
5252
* - **`/history` and `/audit` serve events, never a body**: they refuse where
5353
* the plain read refuses the item whole, and otherwise serve the events.
54+
*
55+
* [#20378] **`/diff` and `/history` are AUTHORING doors** (ruling
56+
* 5865708652, letter B, which narrows ruling 5856774816 item 2 for these
57+
* two doors only). Both read `sys_metadata_history`, where a draft save is
58+
* recorded exactly as an active save, so a caller who may not read drafts
59+
* (`readsDrafts` below) is refused them exactly as `GET /meta/_drafts`
60+
* refuses — 403 `FORBIDDEN`, before any read. Everything this census says
61+
* about them holds for the callers they admit. `/layers` and
62+
* `?layers=true` keep the pruned plain-read answer for everyone.
5463
* - **`/references`** is declared exempt: it serves the identities of OTHER
5564
* items that point at this one, never a member of this item's document.
5665
*
@@ -348,7 +357,12 @@ type DoorKind = 'document' | 'stored' | 'events' | 'exempt';
348357
* side, a `diff` of two versions, or the pending `draft` in the plain read's
349358
* envelope (its `item`).
350359
*/
351-
interface Door { kind: DoorKind; suffix: string; query?: Record<string, string>; reason?: string; serves?: 'layers' | 'diff' | 'draft' }
360+
/**
361+
* `authoring` — [#20378] ruling 5865708652: the door refuses a caller who may
362+
* not read drafts (`readsDrafts`) with the `GET /meta/_drafts` 403, before any
363+
* read; the rest of its row holds for the callers it admits.
364+
*/
365+
interface Door { kind: DoorKind; suffix: string; query?: Record<string, string>; reason?: string; serves?: 'layers' | 'diff' | 'draft'; authoring?: true }
352366

353367
const DOORS: Record<string, Door> = {
354368
'?layers=true': { kind: 'stored', suffix: '', query: { layers: 'true' }, serves: 'layers' },
@@ -357,8 +371,8 @@ const DOORS: Record<string, Door> = {
357371
// version — not the rendered world, which is `?preview=draft`.
358372
'?state=draft': { kind: 'stored', suffix: '', query: { state: 'draft' }, serves: 'draft' },
359373
'/published': { kind: 'document', suffix: '/published' },
360-
'/diff': { kind: 'stored', suffix: '/diff', serves: 'diff' },
361-
'/history': { kind: 'events', suffix: '/history' },
374+
'/diff': { kind: 'stored', suffix: '/diff', serves: 'diff', authoring: true },
375+
'/history': { kind: 'events', suffix: '/history', authoring: true },
362376
'/audit': { kind: 'events', suffix: '/audit' },
363377
'/references': {
364378
kind: 'exempt',
@@ -643,6 +657,20 @@ describe(`[#20156] every alternate door answers what the plain read answers, or
643657
protocol.getMetaItem.mockClear();
644658
const res = await drive(rest, door.suffix, subject.type, subject.name, door.query);
645659
const stored = find(subject.type, subject.name);
660+
if (door.authoring && CALLERS[callerName].ctx && !CALLERS[callerName].readsDrafts) {
661+
// [#20378] ruling 5865708652: an authoring door
662+
// refuses a caller who may not read drafts exactly
663+
// as `GET /meta/_drafts` does, whatever the plain
664+
// read answers them — and before any read. (An
665+
// anonymous caller is refused by the `/meta` auth
666+
// gate first, as on every door.)
667+
expect(envelope(res)).toEqual({ status: 403, code: 'FORBIDDEN' });
668+
for (const s of subject.secrets) expect(text(res)).not.toContain(s);
669+
expect(protocol.getMetaItem).not.toHaveBeenCalled();
670+
expect(protocol.diffMetaItem).not.toHaveBeenCalled();
671+
expect(protocol.historyMetaItem).not.toHaveBeenCalled();
672+
return;
673+
}
646674
if (door.serves === 'draft' && CALLERS[callerName].ctx) {
647675
const asked = protocol.getMetaItem.mock.calls.map(([r]: any[]) => r?.state);
648676
if (!CALLERS[callerName].readsDrafts) {
@@ -827,13 +855,20 @@ describe('[#20156] edges', () => {
827855
const app = await save(rest, 'app', 'crm', clone(CRM_APP));
828856
expect(envelope(app)).toEqual({ status: 403, code: 'FORBIDDEN' });
829857
expect(protocol.saveMetaItem).toHaveBeenCalledTimes(1);
830-
// ...so every stored-version door serves them the plain read's pruned app.
858+
// ...so every stored-version door serves them the plain read's pruned app
859+
// — save `/diff`, an authoring door that refuses them outright
860+
// ([#20378] ruling 5865708652: they may not read drafts either).
831861
const plain = await drive(rest, '', 'app', 'crm');
832862
const expected = navIds(plainItem(plain));
833863
expect(expected).not.toEqual(navIds(CRM_APP));
834864
for (const doorName of AUTHOR_EXEMPTION.doors) {
835865
const door = DOORS[doorName];
836866
const res = await drive(rest, door.suffix, 'app', 'crm', door.query);
867+
if (door.authoring) {
868+
expect(envelope(res), doorName).toEqual({ status: 403, code: 'FORBIDDEN' });
869+
for (const s of ['nav_finance_ledger', 'nav_admin_runbook']) expect(text(res), doorName).not.toContain(s);
870+
continue;
871+
}
837872
expect(res.statusCode, doorName).toBe(200);
838873
for (const s of ['nav_finance_ledger', 'nav_admin_runbook']) expect(text(res), doorName).not.toContain(s);
839874
if (door.serves === 'diff') {
@@ -848,8 +883,12 @@ describe('[#20156] edges', () => {
848883
}
849884
});
850885

886+
// [#20378] Both edges below drive an ADMITTED caller: a caller who may not
887+
// read drafts is refused `/diff` and `/history` before any read (the
888+
// census rows above), so the question these edges ask is only open for one
889+
// who may.
851890
it('a gated type with nothing behind the name: /diff answers the plain read\'s absence, /history its events', async () => {
852-
const { rest, protocol } = setup('non-reader');
891+
const { rest, protocol } = setup('reader');
853892
protocol.getMetaItem.mockImplementation(async ({ type, name }: any) => ({ type: singular(type), name, item: undefined }));
854893
const diff = await drive(rest, '/diff', 'doc', 'crm_admin_runbook');
855894
const history = await drive(rest, '/history', 'doc', 'crm_admin_runbook');
@@ -861,7 +900,7 @@ describe('[#20156] edges', () => {
861900
});
862901

863902
it('a type no per-caller gate judges costs its event and diff doors no extra read', async () => {
864-
const { rest, protocol } = setup('non-reader');
903+
const { rest, protocol } = setup('reader');
865904
for (const suffix of ['/history', '/audit', '/diff']) {
866905
protocol.getMetaItem.mockClear();
867906
const res = await drive(rest, suffix, 'view', 'all_leads');

0 commit comments

Comments
 (0)