Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-auth): the better-auth-native /admin/ routes refuse an anonymous caller with the ADR-0112 envelope - #10800
Conversation
…10349) The `/api/v1/auth/admin/` namespace answered the same question in two shapes. ObjectStack's raw mounts refuse an anonymous caller with the ADR-0112 envelope and `code: 'UNAUTHENTICATED'` (`platform-admin-gate.ts`); the routes better-auth serves itself refuse through the vendor's `adminMiddleware` (`APIError.fromStatus('UNAUTHORIZED')`, no body argument), which reaches the client as a 401 announcing `application/json` and carrying the EMPTY STRING. Measured on better-auth 1.7.1 through `AuthManager.handleRequest`, anonymous: ten vendor-lane routes answered `401 ct=application/json len=0 body=""`. `handleRequest` now gives those refusals the declared envelope at the one seam every vendor route passes through, with the code DERIVED from the status by ADR-0112's own `standardErrorCodeForHttpStatus` map — no new error code, no literal to drift. Statuses and admission are unchanged. Scope is the `/admin/` namespace (option C, not option B). The prefix test costs no new concept: `handleRequest` already discriminates on `betterAuthEndpointPath` twice, for `STOP_IMPERSONATING_PATH` and `SESSION_ERASURE_PATHS`. Three narrowings, each pinned: a refusal that already carried a body keeps it byte-for-byte; only 401/403 are named (a bodyless `/admin/oauth2/*` 404 and any semantic 4xx the vendor owns stay as they are); nothing outside `/admin/` is touched. The dogfood sweep's `better-auth-gate` bucket tightens from `[401, 403].includes(anon.status)` to the full ADR-0112 pin — status AND code — which is the fix's own falsifiable assertion. That bucket documented this gap instead of closing it, and nothing tracked closing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
…ucket exposed The `better-auth-gate` bucket's member-arm vocabulary check was guarded by `if (member.code !== undefined)`, and on every bodyless refusal the code WAS undefined — so for those routes the check had never executed. Giving the vendor lane an envelope made it executable, and it went red on the first run: `/admin/remove-user` answers a SIGNED-IN member `401 UNAUTHENTICATED`, while `set-role` and `update-user` answer the same bearer `403 YOU_ARE_NOT_ALLOWED_*`. Measured on the booted showcase stack and controlled hermetically: the same three fires against the in-memory engine give the member 403, both with no `transaction` on the engine and with a pass-through one. So it is the real erasure transaction (#7724, `SESSION_ERASURE_PATHS`) that the session re-read inside `adminMiddleware` does not survive. Filed as #10792. Recorded here as an ADDITIONAL accepted code for that one route, never as a pin — the same reasoning the platform-admin arm below already carries. Pinning today's 401 would turn the fix red, pinning the 403 is red today, and widening the vocabulary for every route would let the next one drift in silence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 11 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 9ab4054389e6078d4c7945b42cc05c177c1d4ea0 && git checkout 9ab4054389e6078d4c7945b42cc05c177c1d4ea0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5f2e54cc66330cbc53a17f6e3746acdfcdc14704 7bb7c15bacc5d42c0312eca99a553849ec4fd04b && git checkout -B drift-repro 5f2e54cc66330cbc53a17f6e3746acdfcdc14704 && git merge --no-ff 7bb7c15bacc5d42c0312eca99a553849ec4fd04b
node scripts/docs-audit/affected-docs.mjs --json 5f2e54cc66330cbc53a17f6e3746acdfcdc14704
|
Uh oh!
There was an error while loading. Please reload this page.
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32492126584 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Fixes#10349
The defect, reproduced rather than taken from the card
/api/v1/auth/admin/is served by two implementations and answered the same question in two shapes.ObjectStack's raw Hono mounts —
create-user,set-user-password,unlock-user,import-users,ban-user,unban-user,oauth2/toggle-disabled,sso/*— are registered ahead of better-auth's catch-all and refuse an anonymous caller throughjudgePlatformAdminwith the ADR-0112 envelope andcode: 'UNAUTHENTICATED'(platform-admin-gate.ts). The routes better-auth serves itself refuse through the vendor'sadminMiddleware, which isgetAuthoritativeSessionFromCtx(ctx)followed byAPIError.fromStatus("UNAUTHORIZED")— read atbetter-auth/dist/plugins/admin/routes.mjs:17-21on the installed 1.7.1, with no body argument.Measured in this tree, anonymous, through
AuthManager.handleRequest, before the change:Ten routes, not the two the card names. The body is the empty string, not an empty JSON object — the distinction decides whether normalizing means adding an envelope or rewriting one, and it is what the ablation below observes as a
SyntaxErrorrather than an assertion diff. Thecontent-typeheader meanwhile announcesapplication/json, so a client that believes it and parses throws on the refusal instead of branching on it.The change
AuthManager.handleRequestnow passes the vendor response throughenvelopeVendorAdminRefusal(new modulevendor-admin-refusal-envelope.ts) before returning it. Same tree, same run, after:The code is derived, not written down:
standardErrorCodeForHttpStatus(@objectstack/spec/api) is ADR-0112's own status-to-code map, so this PR registers no vocabulary, adds no code literal that can drift from the catalog, and touchespackages/specnot at all. The message comes fromplatform-admin-gate.ts, hoisted into a two-entryPLATFORM_ADMIN_REFUSAL_MESSAGESconstant whose values are the strings that module already emitted — so the vendor lane's anonymous refusal is now byte-identical to the ObjectStack lane's rather than a second string that happens to match today.judgePlatformAdmin's output bytes are unchanged.Option C stays option C — and the reason is a property of the seam
Triage scoped this to C and left one conditional upgrade: go to B only if the seam makes C and B the same diff. It does not.
handleRequestalready discriminates on the endpoint path, twice, before this change:STOP_IMPERSONATING_PATHfor the #8243 bearer recovery, andSESSION_ERASURE_PATHSfor the #7724 atomic-erasure wrapper — both keyed off the existing private helperbetterAuthEndpointPath(request), whose docblock says it exists so the sets keyed by it are "all talking about one thing". Restricting to/admin/therefore introduces no concept the seam did not have; it adds one more member to a discrimination this method was already built around. B would be removing a test, and would be a far larger contract claim (every better-auth route, including the sign-in and OAuth surfaces) landing on a card that says C.One honest caveat, reported rather than smoothed over: at the wire, C and B are currently hard to tell apart, because no bodyless refusal was measured outside
/admin/(POST /sign-in/emailanswers401 {"message":"Invalid email or password","code":"INVALID_EMAIL_OR_PASSWORD"};/get-session200;/sign-out200 — three routes, not an exhaustive enumeration). So the C-vs-B boundary is held by the unit-level identity pin on the pure function, not by an observable wire difference. The ablation section says which test actually fails when the prefix is widened.Three narrowings, each measured and each pinned
403 {"message":…,"code":"YOU_ARE_NOT_ALLOWED_TO_*"}is the vendor's own denial vocabulary and is what the dogfood sweep asserts on. Rewriting it is a second, larger contract change.a vendor refusal that DID say something is returned unchanged/admin/oauth2/*is a bodyless 404 withoidcProvideroff (measured,ct=null); a semantic 409/400 the vendor owns is not this seam's to name.a non-refusal status is returned unchanged, even bodyless under /admin//admin/prefixa non-/admin/ path is returned unchanged — this is option C, not option BEach is asserted by identity (
toBe(input)— the same object, not an equal one), so an implementation that rebuilt an "equivalent" response on those paths still fails.Pins, by direction
/admin/→ refusedvendor-admin-refusal-envelope.test.ts, realhandleRequeston better-auth 1.7.1401and the full envelope withcode: 'UNAUTHENTICATED'— status AND code, per ADR-0112admin-route-nonadmin-refusal.dogfood.test.ts,better-auth-gatebucket[401, 403].includes(anon.status)toanon.status === 401andanon.code === 'UNAUTHENTICATED'objectstack-gatebucket (unedited) +the vendor lane and the ObjectStack lane now answer anonymous BYTE-IDENTICALLYhandleRequestat allADMISSION is unchanged — a platform admin still gets 200 from /admin/impersonate-useradmin_full_access, legacyrolescalar untouched) still gets200with the impersonated user — the one assertion that fails on an implementation that refuses everyone/admin/vendor routes unaffecteda non-/admin/ vendor refusal keeps the vendor's own body — option C holds at the seam/sign-in/emailstill answers the vendor's flat{message, code}, measured identical before and afterThe dogfood tightening is the card's own point: that suite documented the asymmetry instead of closing it, and nothing tracked closing it. Leaving the bucket loose would have reproduced exactly the failure the card describes.
Ablation
Predicted signatures written down before mutating, both legs restored byte-identically.
Resolution, proved not asserted.
packages/plugins/plugin-auth/distdid not exist in this worktree at the time both legs ran (test -e→ 1) whilepackages/spec/distdid (test -e→ 0, the positive control for the same predicate) — and the package's 64-file / 1373-test suite ran green throughout. So the in-package tests resolve the subject throughsrc/; only cross-package deps come fromdist/. No rebuild is needed for either leg, and neither leg can be reading a stale artifact because there was no artifact to read. (The dogfood suite is the opposite case — it reaches plugin-auth through@objectstack/example-showcase'sexports, i.e.dist/, which is why it is run against a built closure, verified by findingenvelopeVendorAdminRefusalin the builtdist/index.mjsbefore running it.)Leg A — seam removed (
handleRequestreturns the vendor response untouched). Predicted: 2 failures, both a parse error rather than an assertion diff, because the pre-fix body is the empty string. Observed, exactly:The pure-function tests and the ADMISSION test stayed green, as predicted — they do not traverse the seam.
Leg B — the prefix widened to
/(the option-B shape). Predicted: the two pure-function pins fail; the seam's non-/admin/test stays green because/sign-in/email's 401 already carries a body and the empty-body narrowing keeps it untouched under either prefix. Observed, exactly:Restores:
git hash-objectback to1044073db00341b2e02f2d91d162f2a72a4b78fd(auth-manager.ts) and06ef895bf8586022a215d9cdb79530f6d3548708(vendor-admin-refusal-envelope.ts), tree clean.Changeset
@objectstack/plugin-auth: minor, declared**BREAKING**— this changes a public response shape. The repo ships breaking changes asminorrather thanmajor(scripts/check-changeset-no-major.mjs's own WHY block: the lockstep launch-window convention).ADR-0087 disposition:
not-required (no-migration-prescription). Nothing is removed, renamed or narrowed — a refusal that carried an empty body under anapplication/jsonheader now carries the declared envelope at the same status. A status branch keeps working unchanged; an envelope branch that only ever matched the ObjectStack lane now also matches the vendor lane. There is no old spelling to migrate off, soos migrate metahas nothing to rewrite and there is no ledger entry to make. The changeset body deliberately carries no rewrite prescription, which the gate refuses alongside that category.What the tightening found on its first run
Tightening the dogfood bucket did not just go green. It made a neighbouring assertion executable for the first time and that one went red immediately — which is the clearest evidence available that the bucket had been documenting rather than checking.
The member arm of the same bucket reads
if (member.code !== undefined) expect(member.code).toMatch(/^YOU_ARE_NOT_ALLOWED/). On every bodyless refusal the code wasundefined, so for those routes that check had never once executed. With the envelope supplying a code, it ran — and reported thatPOST /api/v1/auth/admin/remove-useranswers a signed-in plain member401 UNAUTHENTICATED, whileset-roleandupdate-useranswer the same bearer403 YOU_ARE_NOT_ALLOWED_*.Measured on the booted showcase stack, three sibling routes back to back, and controlled hermetically: the same fires against the in-memory engine give the member
403 YOU_ARE_NOT_ALLOWED_TO_DELETE_USERSboth with notransactionon the engine and with a pass-through one. So it is neither the wrapper's presence nor the break-glass before-hook — it is what a real transaction does toadminMiddleware's session re-read inside the #7724 erasure wrapper (SESSION_ERASURE_PATHS). Filed as #10792.This PR cannot have caused it: the normalizer rewrites an empty body and never a status, and the member's status was 401 before the change too — the old
[401, 403].includes(member.status)assertion passed either way.It is recorded in the suite as an additional accepted code for that one route, never as a pin, with the same reasoning the platform-admin arm below it already carries: pinning today's 401 would turn the fix red, pinning the 403 is red today, and widening the vocabulary for every route would let the next one drift in silence. Deleting that arm is part of closing #10792.
Out of scope, filed
#10792 —
/admin/remove-userrefuses a signed-in caller401 UNAUTHENTICATEDon a transaction-capable engine; the mechanism is caller-independent, so the route is likely dead on every real deployment. The platform-admin arm is explicitly marked not-measured in the issue, because on this stack no caller can pass that route anyway (the vendor gate reads the legacyrolescalar ADR-0068 D2 stopped synthesizing).#10776 — the break-glass last-local-credential guard is a better-auth
hooks.beforeand therefore runs ahead ofadminMiddleware, so an anonymousPOST /admin/remove-usernaming the environment's last local-credential holder draws409 LAST_LOCAL_CREDENTIALinstead of401: a state oracle identifying the break-glass account to an unauthenticated caller. Distinct from #9654 (closednot_planned), which recorded parameter-shape disclosure on the raw mounts. Not fixed here — different defect class, and this PR deliberately leaves that response untouched (409 is neither a refusal status this seam names nor an empty body).Files
packages/plugins/plugin-auth/src/vendor-admin-refusal-envelope.ts— new; the pure normalizer and its measurement recordpackages/plugins/plugin-auth/src/vendor-admin-refusal-envelope.test.ts— new; the pure pins plus the real-pipeline pins including admissionpackages/plugins/plugin-auth/src/auth-manager.ts— the seam, one callpackages/plugins/plugin-auth/src/platform-admin-gate.ts— the two refusal messages hoisted to a shared constant; emitted bytes unchangedpackages/plugins/plugin-auth/src/admin-impersonate-endpoint.test.ts— the pin that asserted an empty response body replaced by the envelope pin, with the history recorded in placepackages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts— thebetter-auth-gatebucket tightened, the header paragraph that described the gap rewritten to describe its closure, and the /admin/remove-user refuses a signed-in caller 401 UNAUTHENTICATED on a transaction-capable engine — the session lookup does not survive the #7724 erasure transaction #10792 member-arm exception recorded.changeset/admin-vendor-refusal-envelope.md⛔ No
packages/specchange, no new error code, nothing undercontent/docs/releases/.Verification
All local gates run on the final commit
be9b5a9fd, clean tree, exit codes captured before any pipe. Full table in the report comment on #10349.@objectstack/plugin-auth—Test Files 64 passed (64),Tests 1373 passed (1373);tsc --noEmitclean.@objectstack/dogfoodadmin-route-nonadmin-refusal.dogfood.test.ts—Test Files 1 passed (1),Tests 6 passed (6)against a built closure, i.e. the tightened bucket green over the derived live route table;tsc --noEmitclean.node scripts/pm/dispatch-gates.mjswith no path arguments, 13 families, all exit 0.check:route-envelopenorcheck:dispatcher-error-vocabulary— the known class [finding] Every PM dispatch list is short by the same ~5 changeset-triggered gate families — they are path-derivable, but the changeset does not exist yet when the list is derived #10309 shortfall, and it matters most on exactly this card, which changes a route envelope. Both were run explicitly with--self-testand both pass. The zero is trustworthy because the same run named 13 other families and placed both of these in--residuewith the populations they do declare.@objectstack/plugin-authTEST_DEBT is untouched: re-measured by hand at 97 against the recorded 109 (the gate fails only when actual exceeds recorded), with neither touched test file contributing a diagnostic — a zero backed by the same run reporting 43 inauth-manager.test.tsand 18 inadmin-user-endpoints.test.ts.Generated by Claude Code