Uh oh!
There was an error while loading. Please reload this page.
Measure the /meta write doors against an anonymous principal — refusal is enforced, now pinned end-to-end - #11472
Merged
Conversation
… 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
Contributor
📓 Docs Drift CheckNothing 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
Coarse fallback — 2 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
os-zhuang
marked this pull request as ready for review
August 24, 2026 00:54
Uh oh!
There was an error while loading. Please reload this page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.tsalready asserts a 401 on all six doors — but it constructs aRestServerover avi.fn()transport and callsroute.handler(req, res)directly. That proves the handler wrapper refuses a context it is handed; it cannot prove the composed app routes a realPUT /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.tsboots the real showcase and drives real HTTP — but its entire/metaface was one anonymousGET.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 — norequireAuthoverride), 2026-08-23. Three principals, identical URL, method and body across all three legs.Reading it
ANONYMOUS_DENY_BODYenvelope. The persistence read-back after LEG 1 is404 RESOURCE_NOT_FOUND— nothing was written, so this is not a gate that answers 401 after the save.PUTbody 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-storedreturns a real scan report, the reset reports "no overlay found", publish/rollback reportNO_DRAFT/VERSION_NOT_FOUND)./metapublish and rollback doors carry nomanage_metadatagate, so the authoring capability the PUT/DELETE doors enforce is reachable around #8919'smanage_metadatalayer). 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.enableProjectScopingdefaults false), so/environments/:id/meta/*answersENDPOINT_NOT_FOUNDrather than existing ungated. Worth recording; not a hole here.Conclusion: objectstack-ai/objectui#5828 is benign by construction on the server side. An
@anonmetadata seed written by a guest / previewMode boot cannot reach the metadata store — it is refused at the REST/metaumbrella before the protocol layer is touched.What this PR changes
Test-only. Two files, both in
packages/qa/dogfood/test/:showcase-anonymous-deny-surfaces.dogfood.test.ts— the pin. Adds the six write doors to the/metasection:.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;404 RESOURCE_NOT_FOUND;DENIED_SEAMSenvelope-family table. This is the anti-vacuity half: if a/metawrite route were ever unregistered, the hono catch-all would hand it toruntime/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.tseligibility rule 2 forbids/metawrites). Every door names a non-existent object/view, mirroring the discipline the/packagesblock already states for its:id— the auth floor is the first thing a door meets, so a refusal must not need a real target.authz-conformance.matrix.ts— anoteon the existinganonymous-deny-metarow 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 inpackages/rest/.No
needs:contract-reviewNo 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-changesetapplied.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.tscould not be touched, so the ablation targets the shared decision the umbrella calls,shouldDenyAnonymousinpackages/core/src/security/anonymous-deny.ts, made to returnfalseunconditionally. Mutation confirmed on disk before building (injected-marker count1, and the originalif (input.userId || input.isSystem) return false;guard still present at count1— so the ablation added a bypass rather than silently replacing the anchor).@objectstack/corerebuilt, andnode scripts/ablation-dist-preflight.mjs @objectstack/core ABLATION_11373_DENY_DISABLEDexited 0 confirming it reacheddist/— necessary because dogfood resolves@objectstack/corethrough itsexportstodist, notsrc.All twelve new anonymous cases went red — the six
is denied (401)cases and the six envelope-family rows, e.g.:The restore leg was run and verified, both directions: marker count back to
0, rebuilt,--absentpreflight exit 0, suite back to 44 passed. The mutating script carriedtrap restore EXIT INT TERMso 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_metadatacapability gate refuses them too, because an anonymous context carries nosystemPermissions. 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.
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 0pnpm lint(full repo,eslint . --no-inline-config)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 declaredpnpm check:engine-double-contractOK — 390 pinned, 133 in the DEBT ledger, 2 exemptpnpm check:where-matcher288 matcher(s) discovered ... none newpnpm check:query-options-erasurepnpm check:published-filespnpm check:slot-lookuppnpm check:type-source-resolutionpnpm check:type-check-coveragenode scripts/check-ci-filter-parity.mjsnode scripts/check-plugin-teardown-shape.mjsnode scripts/docs-audit/check-affected-docs.mjspnpm --filter @objectstack/spec run check:{empty-state,liveness,strictness-ledger,variant-docs}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-measurewas not run locally — it requires the whole workspace closure built and re-runstscper 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