Skip to content

fix(plugin-auth): the better-auth-native /admin/ routes refuse an anonymous caller with the ADR-0112 envelope - #10800

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-10349-admin-refusal-envelope
Aug 21, 2026
Merged

fix(plugin-auth): the better-auth-native /admin/ routes refuse an anonymous caller with the ADR-0112 envelope#10800
os-warren merged 3 commits into
mainfrom
claude/issue-10349-admin-refusal-envelope

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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 through judgePlatformAdmin with the ADR-0112 envelope and code: 'UNAUTHENTICATED' (platform-admin-gate.ts). The routes better-auth serves itself refuse through the vendor's adminMiddleware, which is getAuthoritativeSessionFromCtx(ctx) followed by APIError.fromStatus("UNAUTHORIZED") — read at better-auth/dist/plugins/admin/routes.mjs:17-21 on the installed 1.7.1, with no body argument.

Measured in this tree, anonymous, through AuthManager.handleRequest, before the change:

POST /admin/impersonate-user -> 401 ct="application/json" len=0 body=""
POST /admin/set-role -> 401 ct="application/json" len=0 body=""
POST /admin/revoke-user-sessions -> 401 ct="application/json" len=0 body=""
POST /admin/revoke-user-session -> 401 ct="application/json" len=0 body=""
POST /admin/list-user-sessions -> 401 ct="application/json" len=0 body=""
POST /admin/update-user -> 401 ct="application/json" len=0 body=""
GET /admin/list-users?limit=1 -> 401 ct="application/json" len=0 body=""
GET /admin/get-user?id=… -> 401 ct="application/json" len=0 body=""
POST /admin/has-permission -> 401 ct="application/json" len=0 body=""
POST /admin/stop-impersonating -> 401 ct="application/json" len=0 body=""

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 SyntaxError rather than an assertion diff. The content-type header meanwhile announces application/json, so a client that believes it and parses throws on the refusal instead of branching on it.

The change

AuthManager.handleRequest now passes the vendor response through envelopeVendorAdminRefusal (new module vendor-admin-refusal-envelope.ts) before returning it. Same tree, same run, after:

POST /admin/impersonate-user -> 401 ct="application/json" len=78
{"success":false,"error":{"code":"UNAUTHENTICATED","message":"Sign in first"}}

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 touches packages/spec not at all. The message comes from platform-admin-gate.ts, hoisted into a two-entry PLATFORM_ADMIN_REFUSAL_MESSAGES constant 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_PATH for the #8243 bearer recovery, and SESSION_ERASURE_PATHS for the #7724 atomic-erasure wrapper — both keyed off the existing private helper betterAuthEndpointPath(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/email answers 401 {"message":"Invalid email or password","code":"INVALID_EMAIL_OR_PASSWORD"}; /get-session 200; /sign-out 200 — 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

NarrowingWhyWhat would break it
Empty body onlyThe signed-in non-admin's 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
401 / 403 only/admin/oauth2/* is a bodyless 404 with oidcProvider off (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/ prefixOption C.a non-/admin/ path is returned unchanged — this is option C, not option B

Each 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

#DirectionWhereAssertion
anonymous → vendor-lane /admin/ → refusedvendor-admin-refusal-envelope.test.ts, real handleRequest on better-auth 1.7.1401and the full envelope with code: 'UNAUTHENTICATED' — status AND code, per ADR-0112
same, over the derived live route tableadmin-route-nonadmin-refusal.dogfood.test.ts, better-auth-gate buckettightened from [401, 403].includes(anon.status) to anon.status === 401andanon.code === 'UNAUTHENTICATED'
ObjectStack raw mounts unchangeddogfood objectstack-gate bucket (unedited) + the vendor lane and the ObjectStack lane now answer anonymous BYTE-IDENTICALLYstructural, too: the raw mounts sit ahead of the better-auth catch-all and never enter handleRequest at all
admission unchangedADMISSION is unchanged — a platform admin still gets 200 from /admin/impersonate-useran ADR-0068 D2 platform admin (grant via admin_full_access, legacy role scalar untouched) still gets 200 with the impersonated user — the one assertion that fails on an implementation that refuses everyone
non-/admin/ vendor routes unaffecteda non-/admin/ vendor refusal keeps the vendor's own body — option C holds at the seam/sign-in/email still answers the vendor's flat {message, code}, measured identical before and after

The 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) while packages/spec/dist did (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 through src/; only cross-package deps come from dist/. 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's exports, i.e. dist/, which is why it is run against a built closure, verified by finding envelopeVendorAdminRefusal in the built dist/index.mjs before running it.)

Leg A — seam removed (handleRequest returns 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:

FAIL src/admin-impersonate-endpoint.test.ts > an anonymous caller is refused 401 …
SyntaxError: Unexpected end of JSON input
FAIL src/vendor-admin-refusal-envelope.test.ts > every vendor /admin/ route refuses an anonymous caller 401 UNAUTHENTICATED
SyntaxError: Unexpected end of JSON input
Tests 2 failed | 44 passed (46)

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:

FAIL … > a non-/admin/ path is returned unchanged — this is option C, not option B
FAIL … > the namespace prefix is a namespace, not a route name
Tests 2 failed | 44 passed (46)

Restores: git hash-object back to 1044073db00341b2e02f2d91d162f2a72a4b78fd (auth-manager.ts) and 06ef895bf8586022a215d9cdb79530f6d3548708 (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 as minor rather than major (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 an application/json header 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, so os migrate meta has 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 that POST /api/v1/auth/admin/remove-user answers a signed-in plain member401 UNAUTHENTICATED, while set-role and update-user answer 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_USERS both with notransaction on 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 to adminMiddleware'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-user refuses a signed-in caller 401 UNAUTHENTICATED on 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 legacy role scalar ADR-0068 D2 stopped synthesizing).

#10776 — the break-glass last-local-credential guard is a better-auth hooks.before and therefore runs ahead ofadminMiddleware, so an anonymous POST /admin/remove-user naming the environment's last local-credential holder draws 409 LAST_LOCAL_CREDENTIAL instead of 401: a state oracle identifying the break-glass account to an unauthenticated caller. Distinct from #9654 (closed not_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 record
  • packages/plugins/plugin-auth/src/vendor-admin-refusal-envelope.test.ts — new; the pure pins plus the real-pipeline pins including admission
  • packages/plugins/plugin-auth/src/auth-manager.ts — the seam, one call
  • packages/plugins/plugin-auth/src/platform-admin-gate.ts — the two refusal messages hoisted to a shared constant; emitted bytes unchanged
  • packages/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 place
  • packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts — the better-auth-gate bucket 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/spec change, no new error code, nothing under content/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-authTest Files 64 passed (64), Tests 1373 passed (1373); tsc --noEmit clean.
  • @objectstack/dogfoodadmin-route-nonadmin-refusal.dogfood.test.tsTest 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 --noEmit clean.
  • Derived union: node scripts/pm/dispatch-gates.mjs with no path arguments, 13 families, all exit 0.
  • ⚠️The derivation named neither check:route-envelope nor check: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-test and both pass. The zero is trustworthy because the same run named 13 other families and placed both of these in --residue with the populations they do declare.
  • @objectstack/plugin-auth TEST_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 in auth-manager.test.ts and 18 in admin-user-endpoints.test.ts.

Generated by Claude Code

os-warrenand others added 2 commits August 21, 2026 10:48
…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
@github-actions

github-actionsBot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 8 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/contracts/auth-service.mdx(via handleRequest (symbol))
  • content/docs/permissions/authentication.mdx(via handleRequest (symbol))
What this run could not see
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 11 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 5f2e54cc66330cbc53a17f6e3746acdfcdc14704packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9ab4054389e6078d4c7945b42cc05c177c1d4ea0 — the merge of head 7bb7c15bacc5d42c0312eca99a553849ec4fd04b into base 5f2e54cc66330cbc53a17f6e3746acdfcdc14704, 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 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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 5f2e54cc66330cbc53a17f6e3746acdfcdc14704 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 32492126584 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Console Pin Gate — 失败步骤: Build the Console SPA at the pinned objectui SHA

    ✗ Build failed in 5.92s
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

跨 PR 相同签名(24h,按失败测试文件聚合):

  • ⚠️本次没有可用的聚合签名(日志里没有能解析出测试文件名的 FAIL 行)—— 这不是「没有同签名的其他 PR」,是这一轮没测到。跨 PR 聚合本次不可用,请手工比对其他 PR 的同类评论。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 16 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] Anonymous callers to every better-auth-native /admin/ route get a bodyless 401 — no envelope, no machine-readable code

2 participants

@os-warren@huangyiirene