Uh oh!
There was an error while loading. Please reload this page.
fix(meta): gate _drafts outlets as authoring surfaces (ADR-0106 D5(4), #6599) - #7907
Conversation
…#6599) GET /api/v1/meta/_drafts (rest) and GET /metadata/_drafts (runtime) returned protocol.listDrafts() verbatim, so a pending object draft leaked every hidden field's full definition to any authenticated caller — the one ADR-0106 outlet the #3682 sweep left uncovered. Per the #6599 ruling, _drafts is an authoring surface: both faces now gate on the same D4 exemption predicate the mask uses (isObjectSchemaMaskExempt: studio.access / setup.access / manage_metadata / isSystem) and 403 everyone else, before the protocol is resolved. Consumer census confirmed every caller is a builder/admin console surface; no legitimate non-author caller exists. Both faces pinned in the shared ADR-0106 case table (meta-object-fls.test.ts in rest and runtime), driven by the same case list so the transports cannot diverge silently. Refusal envelopes follow each transport's precedent: REST FORBIDDEN, runtime PERMISSION_DENIED. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B3Kurx8qufrDzNjk4rag7V
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 24 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also reference the affected code. These are read-only:
|
hotlong
commented
Aug 12, 2026
CI red diagnosed and being fixed —
Everything else in that log is an Cause: that gate compiles the ⛔ For the record, since the gate's own message offers it: the ledger is not being raised. Raising TEST_DEBT to buy a test is what lets the next real regression land silently (issue #6376), and the review that accepted this PR would be worth less than the paperwork. The production diff and its coverage are unchanged and still ACCEPTED — only the test layer's types are being corrected. Generated by Claude Code |
…ate coverage CI's `TypeScript Type Check` (check:type-check-debt) compiles the test layer that the package-level `typecheck` script excludes, so 5 type errors in the new ADR-0106 D5(4) blocks pushed both ledger entries above their recorded counts (@objectstack/runtime 227 -> 231, @objectstack/rest 155 -> 156). Fixed at the source rather than by raising the ledger (#6376 — the ratchet only shrinks), with every assertion preserved: - rest/meta-object-fls.test.ts: `.at(-1)` is not in this package's configured lib; index the last `res.json` call directly. - runtime/domains/meta-object-fls.test.ts: `HttpDispatcherResult.response` is optional, so narrow it once in `runDraftsExit` and throw when absent — an unhandled `_drafts` must not read as "no disclosure". Both entries measured back at their recorded numbers: rest 155, runtime 227. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B3Kurx8qufrDzNjk4rag7V
Uh oh!
There was an error while loading. Please reload this page.
Fixes#6599
What this closes
GET /api/v1/meta/_drafts(packages/rest/src/rest-server.ts) andGET /metadata/_drafts(packages/runtime/src/domains/meta.ts) both calledprotocol.listDrafts()and returned the result verbatim — the one ADR-0106schema-serving outlet the #3682 D5(4) sweep left uncovered. A pending object
draft carries its full
fieldsmap, so an authenticated caller with no readaccess to a field still learned it existed, along with its label, type,
picklist options, formula and
requiredPermissions— the exact disclosureADR-0106 closes on every other
/metaexit.Route (a), per the #6599 ruling — an authoring gate, not a field projection
Both faces now gate per caller on the same D4 exemption predicate the mask
uses —
isObjectSchemaMaskExemptfrom@objectstack/metadata-core(
studio.access/setup.access/manage_metadata, orisSystem) — andanswer 403 to everyone else, rather than masking the draft field-by-field.
cannot be used to probe kernel support (the same posture as the neighbouring
_migrate-storedroute).per-field masking is not consent to expose pending drafts to non-authors.
FORBIDDEN; the runtime dispatcher answersPERMISSION_DENIED(derived fromthe 403 status by
standardErrorCodeForHttpStatus, matching_migrate-storednext door).
Premise verification (the deciding check)
Route (a) 403s non-authors, which is correct only if no legitimate non-author
caller reads these routes. Consumer census of
.listDrafts(/_draftsacrossobjectstack,objectuiandcloud:metadata-admin (PackagesPage, inspectors), the preview/publish panels
(DraftChangesPanel, DraftPreviewBar, draftStatus): every one is a
builder/admin authoring affordance, and each tolerates a 403 gracefully
(
.catch(() => [])/client.listDrafts?.({})). The HomePage banner isexplicitly the "publish your AI-drafted metadata" affordance — for a
non-author it degrades to "not shown", the correct behaviour.
readDraftFirstusesctx.protocol.getMetaItem(...)server-side; it is not an HTTP
_draftsconsumer.res.ok; not a production non-author.No legitimate non-author production caller exists; authors' access is unchanged.
Disclosure confirmed still reproducing on origin/main before the fix (both
outlets unmasked, no gate).
Coverage — both faces, one shared case table
Both outlets are two faces of one contract, so a new describe block in each
package's shared ADR-0106 case table drives the SAME
OBJECT_SCHEMA_MASK_CASESlist through the drafts face:
packages/rest/src/meta-object-fls.test.tspackages/runtime/src/domains/meta-object-fls.test.tsThe verdict is derived, not hand-tabulated —
isObjectSchemaMaskExempt(context)decides allow-vs-refuse for every case — sothe two transports cannot diverge on "who is an author" without a red test, and
a new exemption principal added to the shared table flows into the gate
automatically. Two consequences are pinned:
unrestricted-caller/byte-identical(readable everywhere, no authoring capability) is a 403 here — the gate is
stricter than the mask, which is the point of route (a) — and
masking-disabled/D8is still a 403. Refusal cases assert the ADR-0112 envelopecode AND status, and that the sensitive field never appears in the refusal
body. Three pre-existing
http-dispatcher.test.tsdrafts plumbing cases weregiven an author context (their intent is plumbing, not authz) and a gate-first
403 case added beside them.
Verification
pnpm --filter @objectstack/rest test— 92 files, 1489 passedpnpm --filter @objectstack/runtime test(FLS + http-dispatcher) — 138 files, 2109 passedpnpm --filter @objectstack/rest --filter @objectstack/runtime typecheck— cleancheck:authz-resolver,check:meta-type-normalized,check:route-envelope(rest-server stays at the pinned
siblingCode 77— the new 403 is the conformantnested
error: { code, message }shape, not thesiblingCodedialect),check:nul-bytes— all greenorigin/main(sits on landed fix(rest): exempt a request from the ADR-0069 gate only when it carries a real path (#7432) #7836; region-disjoint, no conflicts)Generated by Claude Code