Repository navigation
fix(rest): one per-caller read gate for GET /meta/:type/:name and every door beside it - #20190
Conversation
…nst the plain read's per-caller answer Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
…ead and every door beside it The layered view (/layers and ?layers=true), /published, /history, /audit and /diff served the document the plain read gates with none of its gates: a member the plain read refuses a permission-set-gated doc read its body from three doors, an anonymous caller read any doc or book through ?layers=true, and an app's requiredPermissions / docs-audience entries reached every member. metaItemReadGate is now the one spelling of the plain read's per-type gates (app nav filter, ADR-0057 D10 dashboard widget gate, ADR-0046 section 6.7 docs audience), asked by the plain read and every door. /published answers what the plain read answers; the stored-version doors run the per-caller arms and refuse a partly-withheld app rather than hand the designer a pruned version to save back; /history and /audit refuse where the plain read refuses the item whole. The ADR-0106 object mask now also covers /published and /diff. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
…e moved into metaItemReadGate Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
… on the alternate read doors Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
…s per-caller gate Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 35 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f61bb877bd9b1d3d0624771d62b06903874093b4 && git checkout f61bb877bd9b1d3d0624771d62b06903874093b4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1c8b320a894b31dcfc76c33c8e7a62b8986d893a 8116184fe60c57d304ef275f925da03c78ea9282 && git checkout -B drift-repro 1c8b320a894b31dcfc76c33c8e7a62b8986d893a && git merge --no-ff 8116184fe60c57d304ef275f925da03c78ea9282
node scripts/docs-audit/affected-docs.mjs --json 1c8b320a894b31dcfc76c33c8e7a62b8986d893a
|
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: Independence: INDEPENDENT AGENT (fed the cards, the rulings and the PR only; not the dispatch order or the seat's conclusions) VERDICT: FAIL
Generated by Claude Code |
Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
…n; cite landing commits for two cards that no longer resolve The refuse-on-partial app arm on /layers, ?layers=true and /diff drew a new permission boundary: it locked any author lacking one entry's permission, platform admins included, out of the designer's layered view. It is removed whole: those three doors serve the stored app exactly as before the gate, and the census pins those cells as a declared pending-decision exemption anchored to the card. Every other arm stands. The two citations the shared gate moved (the plural-spelling and folded-type cards) no longer resolve on the board; they now name the commits that landed them. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Delta of: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: Independence: INDEPENDENT AGENT (fed the card, the prior review, and the PR only; not the dispatch order or the seat's conclusions) VERDICT: FAIL
|
…rs to partial callers The layered view and /diff again refuse an app the plain read refuses whole (an app-level requiredPermissions the caller lacks; an unpublished app to a non-builder), with the plain read's own answer, and /diff judges the current document for app again. Only a caller the plain read would serve part of an app keeps the stored app unpruned, pending the maintainer's decision; the census exemption is narrowed to exactly those cells and its scope test fails if it widens to a whole-refusal cell or a fourth door. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
… count Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Delta of: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: Independence: INDEPENDENT AGENT (fed the card, the prior review, and the PR only; not the dispatch order or the seat's conclusions) VERDICT: PASS |
Part of #20156
Clause-②: no
What this changes
The plain single-item read
GET /meta/:type/:nameapplies a per-caller read gate. The read doors beside it served the same stored document with none of those gates. Every door now asks one function,RestServer#metaItemReadGate. The plain read's per-type gates (the app nav filter, the ADR-0057 D10 dashboard widget gate, the ADR-0046 §6.7 docs audience) moved into it unchanged, and the plain read calls it too. A door therefore cannot serve the document past a gate the plain read applies, except the partial app cells declared pending below. The census reads its door list off the route table, so a door registered later under/meta/:type/:name/...without a declared disposition fails.#20130 (the
approw of this family) was closed as a duplicate of this card by triage. Its app measurements and its owed runtime-dispatcher reading are covered here.Part of, notFixes: the card stays open for the maintainer's ruling on the partialappcells of the stored-version doors. See "Pending a decision" below.What each door answers now
/published/layers,?layers=truedoc/book: the plain read's refusal, judged on every layer.app: the plain read's refusal of an app refused whole (app-levelrequiredPermissions→403; unpublished to a non-builder →404); an app the caller may see only in part: unchanged, pending a decision.object: the ADR-0106 mask, as before.dashboard: stored, since per-deployment gates are not applied to an authoring view/difffieldsvalues masked; adoc/book/appwith nothing behind the name answers the plain read's404 RESOURCE_NOT_FOUND/history,/auditdoc,bookorappwhole; otherwise the events/referencesPending a decision: the partial
appcells of/layers,?layers=trueand/diffThese cells are the callers the plain read would serve only PART of an app: they may open it, but
requiredPermissionsor the docs audience withholds some of its entries from them. Pulling them to the plain read's answer draws a new permission boundary whichever way it is done. So they answer exactly what they answered before this PR: the stored app, unpruned. That keeps the entry-name exposure #20130 described on these three doors, which triage graded p2, for those callers only.An app the plain read refuses whole is not in the exemption. That covers an app-level
requiredPermissionsthe caller lacks, and an unpublished app to a non-builder (ADR-0045 §3). Those callers get the plain read's own refusal on these doors too:403 PERMISSION_DENIEDor the absence answer. It is the same refusal the plain read,/published,/historyand/auditgive.content/docs/plugins/adding-a-metadata-type.mdxdocuments Studio'sDesignerEditorWrapperloading?layers=trueand saving what it loaded. A pruned stored app saved back deletes the withheld entries.systemPermissionsis the fixedADMIN_FULL_ACCESS_CAPABILITIESlist, with no wildcard.filterAppForUserWithReasonhas no admin exemption. So the admin could not open the designer on any app with an entry permission they lack.The census pins exactly these cells (3: the partially-served non-reader × the three doors) to the pre-PR answer, each marked
PENDING-DECISION #20156. A separate scope test holds the exemption to theprunedplain-read answer on exactly those three literal doors, and to the exact cells that yields. Measured: widening the census predicate to a whole-refusal cell turns that test red. The options and measurements are on the card.A fifth gated type the doors skipped:
objectThe plain read also applies the ADR-0106 object-schema mask.
/layersalready applied it;/publishedand/diffdid not. ADR-0106 D5(4) prescribes routing such an outlet through the same projection, so both now mask./publishedmasks the document./diffmasks thefieldsvalues in every bucket, on both sides, following the redaction precedent one frame down: bodies are compared raw and the emitted values are projected.The dashboard widget gate is per deployment, not per caller
It asks which optional services this deployment registered and answers every caller alike; the plain read's own cache-exclusion comment says so.
/publishedapplies it, because it answers what the plain read answers. The stored-version doors do not: the designer would otherwise load, and save back, a dashboard minus a widget whose service is merely off here.Red before, green after
Census:
packages/rest/src/meta-alternate-door-read-gates.test.ts, 196 cases. It covers gated type (doc×2,book,app×3,dashboard,object, plus aviewcontrol) × door × caller (reader, non-reader, anonymous). Each cell asserts one of three things for that caller: the plain read's status and code, the declared per-door answer, or the pending-decision cell's pre-PR answer. The plain-read rows are asserted against the table with a cached protocol method present, so a gated type missing from the cache exclusion also reddens.origin/main'srest-server.tsthrough untracked temporary copies, deleted after. 35 red: 32 kept cells plus 3 edge pins. The kept cells are:docandbooknon-reader on all six doors;?layers=trueon both docs and the book (200 with the body, including theorgdoc the plain read answers 401);apppayrollandlaunchpadnon-reader on all six doors;appcrmon/published;dashboardandobjecton/published, andobjecton/diff.The 3 pending-decision cells pass on the pre-PR code, as they must, since they pin its answer.
8116184fe): 196/196.scripts/ablation-replace.mjs, restore proven by blob == HEAD andgit diff HEADempty each time; subjects imported relatively from source, nodist/):6783daa1→970dc972. 6 red: exactly thepayrollandlaunchpadnon-reader cells on/layers,?layers=trueand/diff.rest-server.tsat8116184feis blob6783daa1.refusedanswers. Test blobb1aaa717→d21265ab. The scope test that enumerates the exempt cells went red.Verification (at
8116184fe, merged withorigin/mainat1c8b320a8)pnpm --filter @objectstack/rest exec vitest run --project local: 198 files, 3535 passed, 1 skipped, 0 failed.pnpm --filter @objectstack/rest typecheck(tsc pluscheck:test-typecheck): exit 0.pnpm lint(repo-wide): exit 0.node scripts/check-issue-citations.mjs --base origin/main(the diff-scoped board check, run locally with the container's token): 32 citations judged, 31 resolve, 1 cross-repo, 0 findings. The two unresolvable citations the first review found now name their landing commits (83a3b1f2e,26f3588fb, each verified an ancestor oforigin/main) and say the original card no longer resolves.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 90 families from merge base1c8b320a8, at8116184fe. Run and reconciled with--ran: 88 exit 0, 2 NOT MEASURED (check:dual-build-cjs-loads,check:type-check-debt), both PREREQUISITE NOT MET on full-farm builds that CI performs.Changeset
.changeset/20156-alternate-door-read-gates.md, patch for@objectstack/rest: a security fix in a released package with no authorable surface removed. It states:dashboard's/publishednow applies the ADR-0057 D10 widget gate;appto while the decision is pending: only a caller the plain read serves part of the app, never one it refuses whole;appcells.Acceptance notes
handleMetadataRequest) is untouched. It answers/metaitem reads with no per-caller gate, its plain read included. [finding] the runtime dispatcher's /meta item reads apply NO per-caller read gate: through a catch-all host, GET /meta/doc/:name serves a permission-set-gated doc body to a non-holder, and /meta/app/:name serves requiredPermissions-gated entries #20193 remains open./diffof adoc,bookorappwhose current document is absent now answers404 RESOURCE_NOT_FOUND, the plain read's answer./historyand/auditof such an item still serve its events, which carry no body.tois the current version, the default. For an explicit historicalto, a gate input unchanged between the two versions is judged at its current value./meta/:type/:name/.... A literal-typed door (/meta/book/:name/tree,/meta/object/:name/state/:field, or a future/meta/app/:name/x) falls outside it. Both existing ones are outside this card's class, and/book/:name/treecarries its own §6.7 gate./referencesis declared exempt in the census, with its reason. It serves the type, name and label of OTHER items that reference this one, and applies none of those items' own gates. Not measured here, and outside this card's class.Number()query reads.Generated by Claude Code