Skip to content

feat(security): REST 401 anonymous-deny body carries code UNAUTHENTICATED alongside error/message (#9487) - #9824

Merged
huangyiirene merged 1 commit into
mainfrom
claude/issue-9487-401-unauthenticated-code-key
Aug 19, 2026
Merged

feat(security): REST 401 anonymous-deny body carries code UNAUTHENTICATED alongside error/message (#9487)#9824
huangyiirene merged 1 commit into
mainfrom
claude/issue-9487-401-unauthenticated-code-key

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Part of #9487 — delivers the maintainer-ruled additive fix for the dispatched surface. One additional producer was found outside that surface and is filed as sub-issue #9823, so this PR deliberately uses no closing keyword: the PM decides whether the parent closes on this landing or on #9823.

What changed (ruling: additive only — no key removed, no key moved)

  • packages/core/src/security/anonymous-deny.tsANONYMOUS_DENY_BODY gains code: ANONYMOUS_DENY_CODE. The wire effect surfaces through @objectstack/rest's enforceAuth, which writes this constant verbatim on every /data, /meta and /reports 401 (all 69 call sites). error keeps carrying the same code value it always has; docstring updated to match, including an explicit note that this does NOT settle ADR-0112 D5 ([tracking] Envelope-position convergence line for packages/rest's flat dialect — the live ratchet owner #9559 stays open — both envelope families unchanged in kind).
  • packages/core/src/security/anonymous-deny.test.ts — the strict pin updated to the new exact three-key shape (strict toEqual, not loosened).
  • packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts — the strict wire pin updated to the exact three-key shape. The two-family classifier (isRestFlatDeny / isDispatcherWrapperDeny) is byte-untouched: it judges family on the discriminating keys and tolerates the additive key — verified on the wire, not assumed (below). The toEqual(ANONYMOUS_DENY_BODY) verbatim pin follows the constant by construction and needed no edit.
  • .changeset/anonymous-deny-401-code-key.md@objectstack/core minor. Not breaking; no ADR-0087 marker required (check:adr-0087-registration green).

The ledger half of the ruling — measured already satisfied, no spec edit needed

The ruling asks that the 401 family be declared like every other family. Measured on main: UNAUTHENTICATED is already a tier-1 StandardErrorCode member (packages/spec/src/api/errors.zod.ts:69) with HttpStatusErrorCodeMap 401 mapped to it (:174) — the same tier as PERMISSION_DENIED / OBJECT_NOT_FOUND / RECORD_NOT_FOUND; the card's other named codes (VALIDATION_FAILED, BATCH_NOT_ATOMIC, UNSUPPORTED_QUERY_PARAM) are tier-2 ledger rows only because they are not standard members. A tier-2 ledger row for UNAUTHENTICATED would be refused by the ledger's own admission gate ("no registered code shadows the standard catalog", packages/spec/src/api/error-code-ledger.test.ts:38-42). So packages/spec/** is untouched and the domain:spec boundary was never breached.

Measured benefit (the card's cost, removed)

packages/client/src/index.ts:4958-4961 reads asSemanticCode(errorBody?.code) ?? asSemanticCode(errorBody?.error?.code). Harness against built dist (stub server answering the built constant; client resolved via package exports): before — err.code === undefined; after — err.code === 'UNAUTHENTICATED', err.httpStatus === 401, with no SDK change. objectui's four UNAUTHENTICATED readers (error-message.ts:212, MarketplacePackagePage.tsx:338, tests) all key on code and start receiving it; none asserts absence.

Verification (all at b0d5633c5, the head this PR ships)

  • @objectstack/core tests: 35 files / 857 passed. @objectstack/rest full suite: 129 files / 2105 passed. @objectstack/dogfood full suite: 118 files passed / 1 skipped, 866 tests passed / 3 skipped; the anonymous-deny file alone re-run: 1 file / 25 passed against a real booted showcase — the additive body classified rest-flat, no third dialect, wire-verbatim pin green. @objectstack/dogfood typecheck green.
  • Reverse verification from the committed state, both legs rebuilt and dist-proven (scripts/ablation-dist-preflight.mjs, value-unique markers, --absent on the ablation leg): reverting the constant turns the core pin RED (1 failed / 7 passed, missing code) and the SDK harness back to err.code === undefined; restore leg returns 8/8 and PASS. First marker attempt was rejected by the preflight itself (the marker also matched a pre-existing docstring surviving in .d.ts) — remeasured with value-unique markers.
  • Gate union derived by node scripts/pm/dispatch-gates.mjs from the real changeset (15 path-matched + 5 convention-triggered), all green, plus the tool's known gaps run explicitly: pnpm lint and pnpm check:slot-lookup green, check:nul-bytes green, check:type-check-debt re-measure green on the built closure (turbo 70/70).

Scope discipline

  • Consumer sweep (with controls; intolerant count beside tolerant): exactly 2 strict whole-body pins existed and both are updated here; 1 verbatim pin follows the constant; the 2 tolerant top-level body.error readers (rest-meta-auth.test.ts:49, security-routes.test.ts:99) stay green by construction; all nested error.code readers are unaffected.
  • Producer sweep: the deny message has exactly two non-test spellings repo-wide. This PR fixes the constant; the inline copy in packages/runtime/src/dispatcher-plugin.ts:213-221 (service-declared endpoint routes) is Endpoint-route 401 in dispatcher-plugin mountRouteOnServer writes an inline flat body with no code key — the one producer #9487's constant change does not reach #9823 — out of the dispatched surface, reported rather than silently expanded into.
  • packages/rest, packages/client, packages/spec: zero edits needed, zero made.

Generated by Claude Code

… alongside error/message (#9487)
Maintainer-ruled additive change: every other REST error family answers
{ error, code } with the machine code in code; the 401 family was the one
outlier. ANONYMOUS_DENY_BODY gains code: ANONYMOUS_DENY_CODE — no key
removed or moved, so no existing reader breaks. The two strict pins that
asserted the old two-key shape (core anonymous-deny.test.ts, dogfood
showcase-anonymous-deny-surfaces) are updated to the new exact shape, not
loosened. The dogfood two-family classifier is untouched: isRestFlatDeny
judges family on discriminating keys and tolerates the additive code key
(verified against a real booted showcase, 25/25 green).
Does not settle ADR-0112 D5 (flat vs nested convergence, #9559): both
declared envelope families are unchanged in kind.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WeN7F6jQFpcqW2BN56RdPa
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅

What this run could not see

Coarse fallback — 23 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 9ff11921a26f44a62ca834651adaa54105468536packageMentionDocs.

Which tree this was computed on

This run read content/docs from bd1544abb973fb6e2254be4a81e2748702de7089 — the merge of head b0d5633c5a6be8874e4fbfe37201f2368f0b37a0 into base 9ff11921a26f44a62ca834651adaa54105468536, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin bd1544abb973fb6e2254be4a81e2748702de7089 && git checkout bd1544abb973fb6e2254be4a81e2748702de7089
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9ff11921a26f44a62ca834651adaa54105468536 b0d5633c5a6be8874e4fbfe37201f2368f0b37a0 && git checkout -B drift-repro 9ff11921a26f44a62ca834651adaa54105468536 && git merge --no-ff b0d5633c5a6be8874e4fbfe37201f2368f0b37a0
node scripts/docs-audit/affected-docs.mjs --json 9ff11921a26f44a62ca834651adaa54105468536

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

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

Labels

documentationImprovements or additions to documentationsize/steststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@huangyiirene@claude