Skip to content

Measure the /meta write doors against an anonymous principal — refusal is enforced, now pinned end-to-end - #11472

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-11373-meta-anon-write-measure
Aug 24, 2026
Merged

Measure the /meta write doors against an anonymous principal — refusal is enforced, now pinned end-to-end#11472
os-zhuang merged 1 commit into
mainfrom
claude/issue-11373-meta-anon-write-measure

Conversation

@claude

@claudeclaudeBot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Fixes#11373

Server-side half of objectstack-ai/objectui#5828. This is a measurement card, and the measurement came back clean: the guard is already there. No production code changes — the deliverable is the reading plus the pin that was missing.

The question

Do the /meta/* metadata WRITE routes refuse an unauthenticated principal server-side?

Yes — measured, on a real running mount, all six doors.

Why this was not already answered

It was not that the refusal was unpinned. It was pinned in the wrong layer.

  • packages/rest/src/meta-write-door-capability-enumeration.test.ts already asserts a 401 on all six doors — but it constructs a RestServer over a vi.fn() transport and calls route.handler(req, res) directly. That proves the handler wrapper refuses a context it is handed; it cannot prove the composed app routes a real PUT /api/v1/meta/... into that wrapper at all. Those are different claims, and only the second one is what a guest browser meets.
  • packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts boots the real showcase and drives real HTTP — but its entire /meta face was one anonymous GET.

So the write half had never been driven anonymously through a real mount.

The measurement (verbatim)

Booted showcase (bootStack(showcaseStack), in-process Hono app, platform-default security — no requireAuth override), 2026-08-23. Three principals, identical URL, method and body across all three legs.

--- LEG 1: anonymous (no credential of any kind) ---
POST /meta/_migrate-stored
anon -> 401 {"error":"UNAUTHENTICATED","code":"UNAUTHENTICATED","message":"Authentication is required to access this endpoint."}
PUT /meta/object/:name (save)
anon -> 401 {"error":"UNAUTHENTICATED","code":"UNAUTHENTICATED","message":"Authentication is required to access this endpoint."}
DELETE /meta/object/:name (reset)
anon -> 401 {"error":"UNAUTHENTICATED","code":"UNAUTHENTICATED","message":"Authentication is required to access this endpoint."}
POST /meta/object/:name/publish
anon -> 401 {"error":"UNAUTHENTICATED","code":"UNAUTHENTICATED","message":"Authentication is required to access this endpoint."}
POST /meta/object/:name/rollback
anon -> 401 {"error":"UNAUTHENTICATED","code":"UNAUTHENTICATED","message":"Authentication is required to access this endpoint."}
PUT /meta/object/views/:name (compound)
anon -> 401 {"error":"UNAUTHENTICATED","code":"UNAUTHENTICATED","message":"Authentication is required to access this endpoint."}
--- PERSISTENCE after LEG 1: GET /meta/object/anon_probe_obj as ADMIN ---
-> 404 {"error":"Metadata item object/anon_probe_obj not found","code":"RESOURCE_NOT_FOUND"}
--- LEG 2: authenticated member (fresh sign-up, no authoring capability) ---
POST /meta/_migrate-stored
member -> 403 {"error":{"code":"FORBIDDEN","message":"Rewriting stored metadata requires the `manage_metadata` capability."}}
PUT /meta/object/:name (save)
member -> 403 {"error":{"code":"FORBIDDEN","message":"Saving a metadata item requires the `manage_metadata` capability."}}
DELETE /meta/object/:name (reset)
member -> 403 {"error":{"code":"FORBIDDEN","message":"Resetting a metadata item requires the `manage_metadata` capability."}}
POST /meta/object/:name/publish
member -> 403 {"error":{"code":"FORBIDDEN","message":"Publishing a metadata item requires the `manage_metadata` capability."}}
POST /meta/object/:name/rollback
member -> 403 {"error":{"code":"FORBIDDEN","message":"Rolling back a metadata item requires the `manage_metadata` capability."}}
PUT /meta/object/views/:name (compound)
member -> 403 {"error":{"code":"FORBIDDEN","message":"Saving a metadata item requires the `manage_metadata` capability."}}
--- LEG 3: dev admin (reverse check — same URL, same method, same body) ---
POST /meta/_migrate-stored
admin -> 200 {"apply":false,"protocol":"17.0.0","scanned":0,"canonical":0,"pending":0,"rewritten":0,"skipped":0,"failed":0,"rows":[]}
PUT /meta/object/:name (save)
admin -> 200 {"success":true,"version":"sha256:1e426f9c1e76a0ea5a8f38f98645cc0f0fe9ef7f38411ea3c68bb704580f7f31","seq":1,"state":"active","message":"Saved object 'anon_probe_obj' (env-wide, state=active) [seq=1]"}
DELETE /meta/object/:name (reset)
admin -> 200 {"success":true,"reset":false,"message":"No customization overlay found for object/showcase_task — already at artifact default."}
POST /meta/object/:name/publish
admin -> 404 {"error":"[no_draft] No pending draft exists for object/showcase_task — nothing to publish.","code":"NO_DRAFT"}
POST /meta/object/:name/rollback
admin -> 404 {"error":"[version_not_found] No history row at version 1 for object/showcase_task.","code":"VERSION_NOT_FOUND"}
PUT /meta/object/views/:name (compound)
admin -> 422 {"error":"[invalid_metadata] object/views/anon_probe_view failed spec validation: 1 issue — <root> [unrecognized_keys]","code":"INVALID_METADATA","issues":[{"path":"","message":"Unrecognized key(s) on this object: `objec
--- PERSISTENCE after LEG 3: GET /meta/object/anon_probe_obj as ADMIN ---
-> 200 {"type":"object","name":"anon_probe_obj","sortability":{"fields":{"organization_id":{"sortable":true},"created_at":{"sortable":true},...
--- control: anonymous GET /meta/types ---
-> 401 {"error":"UNAUTHENTICATED","code":"UNAUTHENTICATED","message":"Authentication is required to access this endpoint."}
--- scoped mount: anonymous PUT /environments/default/meta/object/anon_probe_obj ---
-> 404 {"success":false,"error":{"code":"ENDPOINT_NOT_FOUND","message":"Not found"}}

Reading it

  • Anonymous is refused on every door, before any write, in the rest-flat ANONYMOUS_DENY_BODY envelope. The persistence read-back after LEG 1 is 404 RESOURCE_NOT_FOUND — nothing was written, so this is not a gate that answers 401 after the save.
  • The reverse-check holds. The same PUT body that got 401 anonymously persisted for the admin ({"success":true,...,"seq":1}) and the read-back flipped 404 → 200. The probe is not broken: the URL is right, the method routes, the mount is up, and the door really does write when authorized. The other five doors likewise reach their handler bodies and answer domain-level results (_migrate-stored returns a real scan report, the reset reports "no overlay found", publish/rollback report NO_DRAFT/VERSION_NOT_FOUND).
  • The member leg is the middle rung: authenticated but capability-less gets 403 from a different gate (The REST /meta publish and rollback doors carry no manage_metadata gate, so the authoring capability the PUT/DELETE doors enforce is reachable around #8919's manage_metadata layer). 401 for anon and 403 for a member on the same URL in the same process is what makes the 401 attributable to the auth floor.
  • The environment-scoped mount is not served on this boot (enableProjectScoping defaults false), so /environments/:id/meta/* answers ENDPOINT_NOT_FOUND rather than existing ungated. Worth recording; not a hole here.

Conclusion: objectstack-ai/objectui#5828 is benign by construction on the server side. An @anon metadata seed written by a guest / previewMode boot cannot reach the metadata store — it is refused at the REST /meta umbrella before the protocol layer is touched.

What this PR changes

Test-only. Two files, both in packages/qa/dogfood/test/:

  1. showcase-anonymous-deny-surfaces.dogfood.test.ts — the pin. Adds the six write doors to the /meta section:

    • anonymous → 401 on each door;
    • an authenticated member → .not.toBe(401)and.not.toBe(404). Deliberately not.toBe(403): a member's exact status is the capability gate's business, and this file's own header warns against making it red for another proof's reasons. The .not.toBe(404) states the door's reachability directly instead of inferring it;
    • a persistence case that fires every anonymous write door and then reads back 404 RESOURCE_NOT_FOUND;
    • the six doors joined to the existing DENIED_SEAMS envelope-family table. This is the anti-vacuity half: if a /meta write route were ever unregistered, the hono catch-all would hand it to runtime/domains/meta.ts, whose own anonymous gate also answers 401 — in the dispatcher-wrapper envelope. Same status, different producer. Classifying the family is what tells a silently relocated door apart from a gated one.

    Both new legs are non-mutating, so the file stays eligible for the worker-shared showcase stack (shared-showcase.ts eligibility rule 2 forbids /meta writes). Every door names a non-existent object/view, mirroring the discipline the /packages block already states for its :id — the auth floor is the first thing a door meets, so a refusal must not need a real target.

  2. authz-conformance.matrix.ts — a note on the existing anonymous-deny-meta row recording that the row's cited proof drove only a read for most of its life, and what the write doors now measure.

Hard serial fence: respected

The card fenced packages/rest/src/rest-server.ts (held by in-flight #11095 / PR #11392). No edit was made to it, and none was needed — the guard it owns is present and working. This PR touches no file in packages/rest/.

No needs:contract-review

No accept-set changed and no security boundary moved. This PR adds evidence for a boundary that already holds.

No changeset

Test-only; no user-visible behaviour changes. skip-changeset applied.

Verification

All runs below are on the final commit e29fbd89.

Ablation — the new pin is proven to be able to fail. The fenced rest-server.ts could not be touched, so the ablation targets the shared decision the umbrella calls, shouldDenyAnonymous in packages/core/src/security/anonymous-deny.ts, made to return false unconditionally. Mutation confirmed on disk before building (injected-marker count 1, and the original if (input.userId || input.isSystem) return false; guard still present at count 1 — so the ablation added a bypass rather than silently replacing the anchor). @objectstack/core rebuilt, and node scripts/ablation-dist-preflight.mjs @objectstack/core ABLATION_11373_DENY_DISABLED exited 0 confirming it reached dist/ — necessary because dogfood resolves @objectstack/core through its exports to dist, not src.

[ablate] on-disk injected-marker count: 1
[ablate] on-disk original-guard count : 1
[ablate] core build exit=0
[ablate] preflight(present) exit=0
[ablate] ABLATED TEST exit=1
Test Files 1 failed (1)
Tests 30 failed | 14 passed (44)
[ablate] TRAP/restore leg
[ablate] restored injected-marker count: 0
[ablate] restore build exit=0
[ablate] preflight(--absent) exit=0
[ablate] RESTORED TEST exit=0
Test Files 1 passed (1)
Tests 44 passed (44)

All twelve new anonymous cases went red — the six is denied (401) cases and the six envelope-family rows, e.g.:

× anonymous PUT /meta/:type/:name (save) is denied (401) — the metadata plane is not anonymously writable
AssertionError: anon PUT /meta/:type/:name (save): anonymous must be denied with the shared status: expected 403 to be 401

The restore leg was run and verified, both directions: marker count back to 0, rebuilt, --absent preflight exit 0, suite back to 44 passed. The mutating script carried trap restore EXIT INT TERM so a foreground-cap SIGTERM could not leave the tree mutated.

A secondary finding worth recording: with the anonymous-deny floor removed entirely, the anonymous requests became 403, not 200 — the manage_metadata capability gate refuses them too, because an anonymous context carries no systemPermissions. The metadata write plane is defence-in-depth, not a single gate. (This is also why the persistence case stayed green under ablation: it discriminates "a gate that answers after the write", not the floor specifically.)

Suites and gates — all exit codes captured before any pipe; each gate's own verdict line quoted.

runresult
vitest run --project shared-showcase test/showcase-anonymous-deny-surfaces.dogfood.test.tsTest Files 1 passed (1) · Tests 44 passed (44)
vitest run --project isolated test/authz-conformance.test.tsTest Files 1 passed (1) · Tests 27 passed (27)
pnpm --filter @objectstack/dogfood typechecktsc --noEmit, clean, exit 0
pnpm lint (full repo, eslint . --no-inline-config)exit 0, no findings — not narrowed
pnpm check:nul-bytesOK (scanned 6421 text file(s) ... no raw ASCII control bytes)
pnpm check:test-source-aliasOK — 72 packages with tests scanned; 61 registered as still resolving a workspace dep through dist/
pnpm check:cross-package-test-inputsOK: 14 package(s) read outside themselves, all declared
pnpm check:engine-double-contractOK — 390 pinned, 133 in the DEBT ledger, 2 exempt
pnpm check:where-matcher288 matcher(s) discovered ... none new
pnpm check:query-options-erasureexit 0
pnpm check:published-filesexit 0
pnpm check:slot-lookupexit 0
pnpm check:type-source-resolutionexit 0
pnpm check:type-check-coverageexit 0
node scripts/check-ci-filter-parity.mjsexit 0
node scripts/check-plugin-teardown-shape.mjsexit 0
node scripts/docs-audit/check-affected-docs.mjsexit 0
pnpm --filter @objectstack/spec run check:{empty-state,liveness,strictness-ledger,variant-docs}all exit 0

Gate families were re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no hand-built path list) after the change was final.

Declared narrowing:pnpm check:type-check-debt --re-measure was not run locally — it requires the whole workspace closure built and re-runs tsc per ledger entry. CI runs it. Nothing else was narrowed; the repo-wide lint was run in full.

Downstream

objectstack-ai/objectui#5828 is parked on this reading and can be closed as measured-shut on the server side. Note that objectui is not reachable from this session's repo allowlist, so #5828 itself was read only through this card's summary of it and has not been updated here.


Generated by Claude Code

… mount
objectui#5828 observed that a guest / previewMode boot writes an `@anon`
metadata seed and could not decide from the client side whether the server
refuses it. Nothing in this repo answered that end-to-end: the `/meta` face of
`showcase-anonymous-deny-surfaces.dogfood.test.ts` was a single anonymous GET,
and the write half was pinned only in
`packages/rest/src/meta-write-door-capability-enumeration.test.ts`, which
invokes `route.handler` over a `vi.fn()` transport — that proves the handler
wrapper refuses a context it is handed, not that the composed app routes a real
`PUT /api/v1/meta/...` into the guarded registrar at all.
Measured, no fix needed. All six mutating `/meta` doors, driven as HTTP on the
booted showcase: anonymous -> 401 UNAUTHENTICATED in the rest-flat envelope,
nothing persisted; an authenticated member -> 403 (a different gate); the dev
admin, same URL/method/body -> the door runs, the PUT persisted, and the
read-back flipped 404 -> 200. So the 401 is the auth floor and not a broken
probe, and objectui#5828 is benign by construction server-side.
The pin adds the anonymous and member legs (both non-mutating, so the file stays
eligible for the shared showcase) and classifies each door's 401 envelope
family — the anti-vacuity half: an unregistered `/meta` write route would fall
to the hono catch-all, whose `runtime/domains/meta.ts` gate answers the same 401
in the DISPATCHER wrapper, so only the family tells a relocated door apart.
No changeset: test-only, no product behaviour changes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)

Coarse fallback — 2 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 8542bd457765444626023db6ffeb6f8105f7f83epackageMentionDocs.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gatetests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Measure whether /meta/* write routes refuse an unauthenticated (@anon) principal — server-side half of objectui#5828

2 participants

@os-zhuang@claude