Repository navigation
Commit 5049a3c
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
File tree
- .changeset
- content/docs/ui
- packages
- rest/src
- runtime/src/domains
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
374 | 374 | | |
375 | 375 | | |
376 | 376 | | |
377 | | - | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
378 | 383 | | |
379 | 384 | | |
380 | 385 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
44 | 44 | | |
45 | 45 | | |
46 | 46 | | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
47 | 52 | | |
48 | 53 | | |
49 | 54 | | |
| |||
164 | 169 | | |
165 | 170 | | |
166 | 171 | | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
167 | 178 | | |
168 | 179 | | |
169 | 180 | | |
170 | 181 | | |
171 | 182 | | |
172 | 183 | | |
173 | 184 | | |
| 185 | + | |
174 | 186 | | |
175 | 187 | | |
176 | 188 | | |
177 | 189 | | |
178 | 190 | | |
179 | 191 | | |
| 192 | + | |
180 | 193 | | |
181 | 194 | | |
182 | 195 | | |
| |||
187 | 200 | | |
188 | 201 | | |
189 | 202 | | |
| 203 | + | |
190 | 204 | | |
191 | | - | |
| 205 | + | |
192 | 206 | | |
193 | 207 | | |
194 | 208 | | |
| |||
269 | 283 | | |
270 | 284 | | |
271 | 285 | | |
| 286 | + | |
| 287 | + | |
272 | 288 | | |
273 | 289 | | |
274 | 290 | | |
| |||
311 | 327 | | |
312 | 328 | | |
313 | 329 | | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
314 | 339 | | |
315 | 340 | | |
316 | 341 | | |
| |||
581 | 606 | | |
582 | 607 | | |
583 | 608 | | |
| 609 | + | |
| 610 | + | |
| 611 | + | |
| 612 | + | |
| 613 | + | |
| 614 | + | |
| 615 | + | |
| 616 | + | |
| 617 | + | |
| 618 | + | |
| 619 | + | |
| 620 | + | |
| 621 | + | |
| 622 | + | |
| 623 | + | |
| 624 | + | |
| 625 | + | |
| 626 | + | |
584 | 627 | | |
585 | 628 | | |
586 | 629 | | |
| |||
601 | 644 | | |
602 | 645 | | |
603 | 646 | | |
| 647 | + | |
| 648 | + | |
| 649 | + | |
| 650 | + | |
| 651 | + | |
| 652 | + | |
| 653 | + | |
| 654 | + | |
| 655 | + | |
| 656 | + | |
| 657 | + | |
604 | 658 | | |
605 | 659 | | |
606 | 660 | | |
607 | 661 | | |
608 | | - | |
| 662 | + | |
609 | 663 | | |
610 | 664 | | |
611 | 665 | | |
| |||
759 | 813 | | |
760 | 814 | | |
761 | 815 | | |
| 816 | + | |
| 817 | + | |
| 818 | + | |
762 | 819 | | |
763 | 820 | | |
764 | 821 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
57 | 57 | | |
58 | 58 | | |
59 | 59 | | |
60 | | - | |
61 | | - | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
62 | 67 | | |
63 | 68 | | |
64 | 69 | | |
| |||
189 | 194 | | |
190 | 195 | | |
191 | 196 | | |
192 | | - | |
| 197 | + | |
193 | 198 | | |
194 | 199 | | |
195 | 200 | | |
196 | 201 | | |
197 | 202 | | |
198 | 203 | | |
199 | | - | |
| 204 | + | |
200 | 205 | | |
201 | 206 | | |
202 | 207 | | |
| |||
0 commit comments