Skip to content

Commit 5049a3c

Browse files
fix(rest, runtime): serve pending metadata drafts only to a caller with an authoring capability (#20373)
Fixes #20338 Clause-②: no ## What this changes A pending metadata draft is unpublished authoring work. Before this PR, only `GET /meta/_drafts` gated it: that door answers 403 to a caller without an authoring capability. Every other door that asks the protocol for draft content served it to any signed-in caller who could open the item. Now every such door asks the same predicate first, `isObjectSchemaMaskExempt` (a system context, or `studio.access`, `setup.access` or `manage_metadata`). This is not a second rule. | Door | Caller without the capability, before | Caller without the capability, after | |:--|:--|:--| | REST `GET /meta/:type/:name?state=draft` | 200, the pending draft row (pruned per caller) | the plain read's answer, byte for byte: the published item, or 404 `RESOURCE_NOT_FOUND` for a never-published item (never `NO_DRAFT`) | | REST `GET /meta/:type/:name?preview=draft` | 200, the draft overlaid on the active item | the plain read's answer, byte for byte | | REST `GET /meta/:type?preview=draft` | the draft overlay, with draft-only items listed | the plain list, byte for byte (the `api` served-set filter applies again) | | REST `POST /analytics/dataset/query` with `previewDrafts: true` or `?preview=draft` | the draft dataset definition, and the pending seed draft's rows | the published dataset over live rows; a draft-only `datasetName` gets the door's own 404 `NOT_FOUND` | | dispatcher `GET /meta/:type/:name?preview=draft` | the draft overlay | the dispatcher's plain read, byte for byte (404 for a never-published item) | | dispatcher `GET /meta/:type?preview=draft` | the draft overlay | the dispatcher's plain list, byte for byte | | REST and dispatcher `GET /meta/_drafts` | 403 | 403 (unchanged; it now calls the same helper) | **Why a fallback and not a 403.** Triage `5861173247` set the direction: 「A member without it reads the published version, or 404 for an app with no published version」. The shape matches what `?preview=draft` already does when no draft exists, since the protocol degrades to the published value. It also makes the answer independent of whether a draft exists: it is identical to a read that never named the switch, so it is not an oracle, and it tells the caller nothing about pending work. `/meta/_drafts` keeps its 403 because it lists drafts only and has no published answer to fall back to. **Builders are unchanged.** A caller with the capability reads exactly what they read before on every door. On `?state=draft`, whoever may save the app reads it whole (the ruling of `5861438413`), and everyone else who may read drafts reads it pruned per caller. Each door carries that control in the tests. **The mechanism.** - `rest-server.ts` has one module-level `mayReadPendingDrafts(caller)`, a bare delegation to `isObjectSchemaMaskExempt`. The runtime dispatcher has its own identical delegation in `domains/meta.ts`, which its `_drafts` door and both preview doors ask. That keeps `@objectstack/rest` free of a new export for a one-liner, and both delegations reach the one `@objectstack/metadata-core` predicate. - Every draft switch is declared together with its admission (`const previewDrafts = … && mayReadPendingDrafts(ctx)`), so no later branch can read the switch past the gate. - The item read used to parse `?state=` a second time in its uncached arm (`stateParam`). That re-derivation is gone: the request literal and the read policy both read the admitted `isDraftRead`. `readCtx` is now resolved above the two switches (memoised per request, not a new seam). - **For #20320's claimant:** the `GET /meta/:type` list door's gate is a single declaration at its entry. `previewDrafts` is now declared right after `listCtx` and `listOrganizationId`, before `listRequest`, and carries its admission. If you move the list handler's projections, keep that declaration ahead of every read of `previewDrafts`. ## Tests The fix was committed first (`8bc02e48b`). Pre-fix readings were taken at `149bf8a50`, where the tests were committed and the fix was not. - **New `packages/rest/src/meta-draft-read-builder-gate.test.ts`, real stack** (better-sqlite3 `:memory:`, real `sys_metadata*`, real `ObjectStackProtocolImplementation`, real routes). Per door: a member reads the published version; a member gets the absence answer for a draft-only item; every builder (Studio, Setup, `manage_metadata`) reads the draft (the control). It also pins that a member's `?state=draft` on an item with no draft is the published read, never `NO_DRAFT`, and one cross-door predicate cell: each caller is refused by `/meta/_drafts` exactly when the item, list and dataset doors serve them the published version. - Pre-fix: 15 member cells red, 6 builder controls green. - Post-fix: 21/21. - **New `packages/rest/src/meta-draft-read-door-census.test.ts`.** An AST census of every draft switch in `rest-server.ts`, keyed `route » text`. Each switch is ledgered as `read`, `listing` or `write`. A `read` must carry its admission in its own declaration, the `listing` must sit behind `if (!mayReadPendingDrafts(…))`, and the helper must stay a bare delegation. A new draft door reddens it. - **New `packages/runtime/src/domains/meta-draft-read-builder-gate.test.ts`.** The dispatcher's item and list doors: member cells, draft-only 404, builder controls, a protocol-handed-the-switch assertion, and two-transport parity (for 5 callers, the dispatcher's `_drafts` admission, its preview answers and `RestServer`'s item answer agree). It also carries the same AST census over `domains/meta.ts`. - Pre-fix: 7 red, 6 green. - Post-fix: 13/13. - **Existing pins moved, not weakened.** - The #20156 census gained a declared `readsDrafts` caller property, checked against `/meta/_drafts` beside `savesApps`. Its `?state=draft` cells for a caller who may not read drafts now require the plain read's answer byte for byte, with the protocol never asked for the draft row. - The #20290 real-stack "may not save" control moved from a capability-less member to a Studio builder, who may read drafts but may not save. - Five pins drove a draft switch as a capability-less caller to measure the draft branch itself: cache bypass ×2, forwarding ×2, and the drafts view's `api` exemption. Each now reads as `studio.access`. The served-only pin also asserts that the member's `?preview=draft` list is the filtered one. - **Package runs:** - `@objectstack/rest` full `vitest --project local` at `78c4bdfdc`: 211 files, 3831 passed, 2 skipped. - `@objectstack/runtime` full `vitest --project local` at `e2c5f126d` (runtime `src` is unchanged since `8bc02e48b`): 283 files, 4072 passed, 1 skipped. - Both `typecheck` scripts exit 0. `tsc -p tsconfig.test.json --listFiles` includes all three new test files. - `test:repo`: rest 8/8, runtime 69/69. - Final targeted run at `78c4bdfdc`: rest 8 files, 580 passed; runtime 3 files, 130 passed. **Ablations.** All four legs ran at `8bc02e48b` through `scripts/ablation-replace.mjs` wrap mode, inside a script with its own `EXIT`/`INT`/`TERM` trap that restores both files from `HEAD` by absolute path. Predictions were written before each run. Each subject is reached through source: rest tests import `./rest-server.js`, and runtime's vitest aliases `@objectstack/rest` to `../rest/src`, so no `dist/` is involved. | Leg | Mutation | Predicted red (rest / runtime) | Observed | |:--|:--|:--|:--| | A-rest | REST helper `return true;` (anchor 1→0, blob `99e5c3b059fc`→`b18e3688d418`) | 26 (new 15, census 1, #20156 census 10) / 2 | 26 / 2 | | B-rest | REST helper `return false;` (blob →`6d02fff57404`) | 32 (new 7, census 1, #20156 census 19, #20290 5) / 3 | 32 / 3 | | A-rt | runtime helper `return true;` (blob `14f515580af9`→`483b0036cb51`) | 0 / 6 | 0 / 6 | | B-rt | runtime helper `return false;` (blob →`7b409f440cc9`) | 0 / 6 | 0 / 6 | Each restore was proven: both blobs equal `HEAD` (`99e5c3b059fc`, `14f515580af9`) and `git diff HEAD` is empty. ## Gates At `78c4bdfdc`, `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 91 families from merge base `670471718`. All 91 exit 0. `--ran` reports: 91 derived, 91 run, 0 NOT-MEASURED, 0 UNRUN. - Three gates first refused with exit 3 (NOT MEASURED: unbuilt `client-react` and the full package tree): `check:skill-examples`, `check:dual-build-cjs-loads` and `check:type-check-debt`. All three exit 0 after the named builds. - `node scripts/check-issue-citations.mjs` first exited 2 on a docblock citation of a card that no longer resolves. It is now cited by its commit (`2a29caa`) and exits 0. - `pnpm lint` (`eslint . --no-inline-config`, whole repo, not narrowed): exit 0. - `node scripts/check-issue-citations.mjs --base origin/main` after merging `origin/main`: exit 0. ## Acceptance notes - **`/diff` still serves draft content to a member. Out of scope here by order; this PR does not gate it.** Measured at `8bc02e48b` on the real stack, as a member with no authoring capability, on a published app with a pending draft: `GET /meta/app/NAME/diff?from=0&to=2` answers 200, carrying the draft's label and a draft-only navigation entry. `sys_metadata_history` holds a full body for every draft save, and `/diff` reads versions without a state filter. A draft-only app answers 404 there, because `/diff` reads the current document first. `/history` answers 200 with events only, and no body text. Gating `/diff` would change ruling B's stored-version door for every member, including their view of published history, so it is reported to the seat rather than done here. - **Texts this makes true, left unedited (read-only packages):** - `metadata-core/src/object-schema-fls.ts` (the D4 docblock: 「draft/preview reads are admin-gated upstream already」); - `metadata-protocol/src/protocol.ts`, where `getMetaItem`'s preview branch says 「(admin-gated upstream)」 and `getMetaItems`'s says 「admin-gated upstream — see http-dispatcher」; - the `2a29caa` changeset's 「draft access stays admin-gated upstream」. All three are now true of every door that takes a draft switch. Only `/diff` above still serves draft bodies without the gate. - **A boundary of the ruled predicate, not a defect found:** a caller who holds only `manage_org_presentation` may save an org-scoped draft of an org-overridable type, but is not admitted to read drafts, as on `/meta/_drafts` before this PR. Their `?state=draft` now answers the published item. Measured holders: no default permission set, platform object or example grants `manage_org_presentation`. Whether an objectui editor reads `?state=draft` as such a caller is not measurable here: objectui is not in this container. - **Observation, not filed:** `plugin-security`'s `probeUnpublishedDraft` changes a refusal's wording when an object exists only as a draft. That reveals existence, never content. It is by design (its docblock), and no card or change is proposed. - `docs/ui/apps.mdx` gains one sentence stating who may read a pending app draft, and what anyone else is answered. - Commit trailers are the model-free pair the dispatch order names. --- _Generated by [Claude Code](https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 862b6ce commit 5049a3c

13 files changed

Lines changed: 1204 additions & 36 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/rest": patch
3+
"@objectstack/runtime": patch
4+
---
5+
6+
**Pending metadata drafts are now served only to a caller with an authoring capability — the check `GET /api/v1/meta/_drafts` already made.** A pending draft is unpublished authoring work. Until now, every door that reads one, other than `/meta/_drafts`, served it to any signed-in caller who could open the item. The draft access that the `previewDrafts` / `state` request declarations, ADR-0106 D4 and ADR-0037 described as admin-gated upstream is now gated.
7+
8+
Clause-②: no
9+
10+
- **The doors:** `GET /api/v1/meta/:type/:name?state=draft` and `?preview=draft`, `GET /api/v1/meta/:type?preview=draft`, and `POST /api/v1/analytics/dataset/query` with `previewDrafts: true` or `?preview=draft` on `RestServer`, plus the runtime dispatcher's `/meta` item and list `?preview=draft`.
11+
- **Who may read drafts:** a system context, or a caller holding `studio.access`, `setup.access` or `manage_metadata`. This is the same predicate `/meta/_drafts` asks, not a second rule.
12+
- **Everyone else gets the read as if the draft switch were absent.** They receive the published version, pruned for them as the plain read prunes it. For a name that has nothing published, they receive that door's own absence answer: `404` on the item read, `404 NOT_FOUND` for a dataset by name, and the item simply missing from a list. The answer is byte-identical to the plain read, so it does not reveal whether a draft exists. For example, `?state=draft` on an app with no pending draft answers such a caller with the published app, not `404 NO_DRAFT`. A dataset preview run by such a caller uses live rows, never a pending seed draft's rows.
13+
- **Unchanged:** callers with an authoring capability read exactly what they read before. Whoever may save an app reads its `?state=draft` whole, and everyone else pruned per caller. `/meta/_drafts` still answers `403` to a caller without the capability, because it lists drafts and has no published answer to fall back to. `/diff` and `/history` are not changed by this release.

‎content/docs/ui/apps.mdx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,12 @@ them, left out as the single-app read leaves them out, on every layer, in the
374374
draft, and on both sides of a diff. These doors apply no per-deployment gate:
375375
an entry whose `requiresService` names a service this deployment lacks is part
376376
of the stored version, for every caller. The exemption lifts no whole refusal:
377-
an app an author may not open is refused on these doors too.
377+
an app an author may not open is refused on these doors too. Reading a
378+
*pending* draft at all — `?state=draft`, and the `?preview=draft` preview —
379+
needs an authoring capability (`studio.access`, `setup.access` or
380+
`manage_metadata`: the check `GET /api/v1/meta/_drafts` makes). A caller
381+
without one is answered as if the parameter were absent: the published app,
382+
or `404` for an app that has never been published.
378383

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

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

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@
4444
* The exemption is a CALLER property, honoured by these four doors and by
4545
* no other: the rendered plain read (`?preview=draft` included) and
4646
* `/published` still prune for an author.
47+
*
48+
* [#20338] The draft door serves the draft only to a caller who may read
49+
* drafts at all — the authoring capability `GET /meta/_drafts` asks
50+
* (`readsDrafts` below). Anyone else is never handed one: `?state=draft`
51+
* answers them what the plain read answers them, byte for byte.
4752
* - **`/history` and `/audit` serve events, never a body**: they refuse where
4853
* the plain read refuses the item whole, and otherwise serve the events.
4954
* - **`/references`** is declared exempt: it serves the identities of OTHER
@@ -164,19 +169,27 @@ interface Caller {
164169
* call a caller an author the save door refuses, or the reverse.
165170
*/
166171
savesApps: boolean;
172+
/**
173+
* [#20338] Does `GET /meta/_drafts` admit this caller? DECLARED here and
174+
* checked against that door below: every draft door asks the predicate it
175+
* asks, so a caller it refuses is never served a draft by `?state=draft`.
176+
*/
177+
readsDrafts: boolean;
167178
}
168179
const CALLERS: Record<'reader' | 'non-reader' | 'author' | 'anonymous', Caller> = {
169180
reader: {
170181
ctx: { userId: 'u_reader', systemPermissions: ['finance.access', 'payroll.access', 'studio.access'] },
171182
holdings: ['crm_admin'],
172183
readableFields: ['amount', 'secret_margin'],
173184
savesApps: false,
185+
readsDrafts: true,
174186
},
175187
'non-reader': {
176188
ctx: { userId: 'u_member', systemPermissions: [] },
177189
holdings: [],
178190
readableFields: ['amount'],
179191
savesApps: false,
192+
readsDrafts: false,
180193
},
181194
// An author the plain read serves only PART of `crm`: they may write
182195
// metadata, but hold neither `finance.access` nor `crm_admin`. And no
@@ -187,8 +200,9 @@ const CALLERS: Record<'reader' | 'non-reader' | 'author' | 'anonymous', Caller>
187200
holdings: [],
188201
readableFields: ['amount'],
189202
savesApps: true,
203+
readsDrafts: true,
190204
},
191-
anonymous: { holdings: [], readableFields: [], savesApps: false },
205+
anonymous: { holdings: [], readableFields: [], savesApps: false, readsDrafts: false },
192206
};
193207
type CallerName = keyof typeof CALLERS;
194208

@@ -269,6 +283,8 @@ function setup(who: CallerName | Caller) {
269283
}),
270284
// The save door's protocol call — reached only past its admission.
271285
saveMetaItem: vi.fn(async ({ type, name }: any) => ({ success: true, type: singular(type), name })),
286+
// [#20338] `GET /meta/_drafts` — reached only past its admission.
287+
listDrafts: vi.fn(async () => ({ items: [] })),
272288
findReferencesToMeta: vi.fn(async () => ({ references: [] })),
273289
findData: vi.fn().mockResolvedValue([]),
274290
};
@@ -311,6 +327,15 @@ async function save(rest: any, type: string, name: string, item: any) {
311327
return res;
312328
}
313329

330+
/** [#20338] `GET /meta/_drafts` — the door whose admission IS `readsDrafts`. */
331+
async function listDrafts(rest: any) {
332+
const route = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === `${META}/_drafts`);
333+
if (!route) throw new Error(`GET ${META}/_drafts is not registered`);
334+
const res = makeRes();
335+
await route.handler({ method: 'GET', path: `${META}/_drafts`, params: {}, query: {}, body: {}, headers: {} }, res);
336+
return res;
337+
}
338+
314339
/** The ADR-0112 minimum: the status and the machine code, never the prose. */
315340
const envelope = (res: any) => ({ status: res.statusCode, code: res.body?.code ?? res.body?.error?.code });
316341
const text = (res: any): string => JSON.stringify(res.body ?? null);
@@ -581,6 +606,24 @@ describe(`[#20156 · #20290] ruling ${AUTHOR_EXEMPTION.ruling} — the author ex
581606
}
582607
}
583608
});
609+
610+
it('[#20338] `readsDrafts` is exactly who `GET /meta/_drafts` admits — the predicate every draft door asks', async () => {
611+
for (const callerName of Object.keys(CALLERS) as CallerName[]) {
612+
const { rest, protocol } = setup(callerName);
613+
const res = await listDrafts(rest);
614+
if (CALLERS[callerName].readsDrafts) {
615+
expect(res.statusCode, callerName).toBe(200);
616+
expect(protocol.listDrafts, callerName).toHaveBeenCalledTimes(1);
617+
} else {
618+
expect(envelope(res), callerName).toEqual(
619+
CALLERS[callerName].ctx
620+
? { status: 403, code: 'FORBIDDEN' }
621+
: { status: 401, code: 'UNAUTHENTICATED' },
622+
);
623+
expect(protocol.listDrafts, callerName).not.toHaveBeenCalled();
624+
}
625+
}
626+
});
584627
});
585628

586629
describe(`[#20156] every alternate door answers what the plain read answers, or refuses — save the author exemption of ruling ${AUTHOR_EXEMPTION.ruling}`, () => {
@@ -601,11 +644,22 @@ describe(`[#20156] every alternate door answers what the plain read answers, or
601644
const res = await drive(rest, door.suffix, subject.type, subject.name, door.query);
602645
const stored = find(subject.type, subject.name);
603646
if (door.serves === 'draft' && CALLERS[callerName].ctx) {
647+
const asked = protocol.getMetaItem.mock.calls.map(([r]: any[]) => r?.state);
648+
if (!CALLERS[callerName].readsDrafts) {
649+
// [#20338] A caller who may not read drafts is
650+
// never handed one: the protocol is not asked for
651+
// the draft row, and the door answers what the
652+
// plain read answers them, byte for byte.
653+
expect(asked).not.toContain('draft');
654+
expect(envelope(res)).toEqual(envelope(plain));
655+
expect(res.body).toEqual(plain.body);
656+
return;
657+
}
604658
// [#20290] The cell measured the draft branch: the
605659
// protocol was asked for the draft row. (An anonymous
606660
// caller is refused by the `/meta` auth gate before
607661
// any read, on every door alike.)
608-
expect(protocol.getMetaItem.mock.calls.map(([r]: any[]) => r?.state)).toContain('draft');
662+
expect(asked).toContain('draft');
609663
}
610664

611665
if (exempt) {
@@ -759,6 +813,9 @@ describe('[#20156] edges', () => {
759813
holdings: [],
760814
readableFields: ['amount'],
761815
savesApps: false,
816+
// [#20338] No authoring capability either, so `?state=draft`
817+
// serves them the published app — pruned, as below.
818+
readsDrafts: false,
762819
};
763820
const { rest, protocol } = setup(presenter);
764821

‎packages/rest/src/meta-draft-read-author-exemption.test.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,13 @@ const CALLERS = {
5757
financeAuthor: { userId: 'u_finance_author', systemPermissions: ['manage_metadata', 'finance.access'] },
5858
/** The card's author: may save the app, but holds no `finance.access`. */
5959
author: { userId: 'u_author', systemPermissions: ['manage_metadata'] },
60-
/** May open the app, may not save it. */
61-
member: { userId: 'u_member', systemPermissions: [] },
60+
/**
61+
* May open the app and read drafts (a Studio builder), may not save it.
62+
* [#20338] A caller who may not read drafts at all is never served one —
63+
* they read the published app (`meta-draft-read-builder-gate.test.ts`) — so
64+
* the "may not save" control is a caller who may still read the draft.
65+
*/
66+
builder: { userId: 'u_builder', systemPermissions: ['studio.access'] },
6267
} as const;
6368
type CallerName = keyof typeof CALLERS;
6469

@@ -189,14 +194,14 @@ describe('[#20290] the draft read serves an app author the stored draft whole',
189194

190195
it('a caller who may not save the app reads the draft pruned per caller (the control) — no per-deployment gate either, as on /layers', async () => {
191196
const { call } = await boot();
192-
const res = await call('member', 'GET', '', 'atlas', { state: 'draft' });
197+
const res = await call('builder', 'GET', '', 'atlas', { state: 'draft' });
193198

194199
expect(res.statusCode).toBe(200);
195200
expect(res.body?.item?.label).toBe('Atlas (draft)');
196201
expect(navIds(res.body?.item)).toEqual(['nav_leads', 'nav_org_directory']);
197202
expect(JSON.stringify(res.body)).not.toContain('nav_finance');
198203
// The same per-caller answer the stored-version doors give them.
199-
const layers = await call('member', 'GET', '/layers', 'atlas');
204+
const layers = await call('builder', 'GET', '/layers', 'atlas');
200205
expect(navIds(layers.body?.effective)).toEqual(['nav_leads', 'nav_org_directory']);
201206
}, 60_000);
202207

0 commit comments

Comments
 (0)