fix(plugin-auth): admit ObjectStack platform admins on /admin/impersonate-user (better-auth plugin endpoint, not a raw mount) - #10352
Conversation
…nate-user better-auth's admin plugin authorizes on the legacy `user.role === 'admin'` scalar that ADR-0068 D2 stopped synthesizing, so a platform admin and a plain member received byte-identical 403 YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS and the sys_user "Impersonate User" button was dead everywhere. Re-authorize the route as a better-auth PLUGIN ENDPOINT, replacing the vendor endpoint in place on the admin plugin's own `endpoints` record, rebuilt from the vendor's own options object so only the authorization predicate changes. A raw Hono mount is forbidden: it means hand-rolled signed cookies against the `admin_session` contract with /admin/stop-impersonating, and it would silently detach the path-keyed #8243 rotation hook. Measured on better-auth 1.7.1: `checkEndpointConflicts` only logs, so a second plugin would boot and serve but print an endpoint-conflict error on every start; replacing in place keeps exactly one plugin on the path. The vendor's admin-TARGET guard read the same dead scalar and was inert; it is re-asked through the ADR-0068 predicate so it means something again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
…ing engine double Importing a sibling `.test.ts` re-registers its suites here. That cost is real and is now written down alongside the three worse alternatives, so the next reader does not "fix" it into a new engine double, a ledger-invisible helper, or a suite-free fixture file this package's bare `vitest run` cannot load. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
📓 Docs Drift CheckThis PR changes 2 package(s): 12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 30 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 3df4978c8611bc5c6ec50fd31093f8e0dec735f9 && git checkout 3df4978c8611bc5c6ec50fd31093f8e0dec735f9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4330b7db7e1c0dbed6fea8941dffa2c27603e302 7a376ca54d75de9b715206800c962296c9456142 && git checkout -B drift-repro 4330b7db7e1c0dbed6fea8941dffa2c27603e302 && git merge --no-ff 7a376ca54d75de9b715206800c962296c9456142
node scripts/docs-audit/affected-docs.mjs --json 4330b7db7e1c0dbed6fea8941dffa2c27603e302
|
正文顶部那条 ⛔「Do not auto-merge」已被解除 —— 记录授权来源,不修改正文本 PR 正文开头写着:
那条约束当时是对的,现在已经满足。 我在
合并顺序(不变)本 PR 先于 PR #10390 落地。两者都新增同名 一条可能出现、且不是本 PR 的 CI 红本 PR 的 PR 侧 CI 跑于 那是 #10449(P0,devx 车道已认领):该门禁在进程内调用 ESLint,绕开了 Generated by Claude Code |
⛔ 升级维护者:
|
Maintainer ruling received — the escalation above is resolved as option (A)Ruled live in the PM session, 2026-08-21 (verbatim, untranslated): 「A:新增 vendor 状态」 — surface 3 gains a fourth state for vendor-endpoint wire format, the counterpart of surface 2's already-adjudicated kind 3. Execution clauses attached to the ruling: the state stays counted (closed at exactly N bodies, like this surface's Implementation is dispatched as #10554 (branch The dev's refusal to take the const-hoisting route, and the byte-clean stop-and-report, were the correct calls — that discipline is what produced a clean fork for the maintainer instead of a silently green gate. Generated by Claude Code |
… surface-3 grammar (#10554) Maintainer ruling 2026-08-21 (#10554, option A of the PR #10352 escalation): surface 3 gains a fourth state, vendorWire, for a body this repo builds whose shape is a vendor's wire format required by that vendor's client library — surface 2's kind 3, met as a built literal instead of a relay. - stays counted like this surface's exempt: closed at exactly N bodies - note mandatory, labelled vendor: / reader: / partner: - pairwise exclusive with ratchet and exempt - widening/adding is ⛔ MAINTAINER-ONLY (#8435 marker discipline) - the const-hoisting evasion is named in the gate's prose as a forbidden move - self-test: accept-with-note, reject-without-note, reject beside ratchet and exempt, authority marker on widening, shrink direction, empty declaration Machinery + prose + self-test only: zero entries on main — the adjudicated entry lands with its file on PR #10352, under the ruling that authorized it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XLSPyJTC8HwTFr1i6hj3rK
…in-route-platform-admin
…-endpoint.ts The 2026-08-21 maintainer ruling (#10554, option A) added a fourth state to check-route-envelope's surface-3 grammar for a body this repo BUILDS whose shape is a vendor's wire format. The machinery landed on `main` in 6abc4df with no entries, deliberately: an entry for a file the walk cannot find is an error, so the entry lands with the file, here. The one counted body is the success return of POST /admin/impersonate-user, `ctx.json({ session, user })`. The four refusals are `throw APIError.from(…)`, which no counter on this surface reads, so `unenveloped: 1` is the whole visible departure. The body is byte-identical to better-auth 1.7.1's own handler return, and this endpoint republishes the vendor's OpenAPI metadata untouched — a schema declaring exactly `{ session, user }` — so enveloping it would contradict the schema the same endpoint serves. The note names the three machine-checked parties the ruling mandates: vendor, reader and partner. The const-hoist evasion the ruling named is not used: the body literal stays at the call site, visible to every counter. node scripts/check-route-envelope.mjs --self-test ✓ check-route-envelope self-test passed node scripts/check-route-envelope.mjs ✓ Plugin-mounted Hono routes — 12 module(s) audited, 166 hand-built body/bodies (count reported, NOT pinned): 8 conformant, 0 ratcheted, 3 exempt, 1 vendor-wire Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
…foreign-vocabulary `check-dispatcher-error-vocabulary` reported four unclassified-site findings in the new `admin-impersonate-endpoint.ts`: FAILED_TO_CREATE_USER, USER_NOT_FOUND, YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS and YOU_CANNOT_IMPERSONATE_ADMINS. All four are better-auth 1.7.1's OWN constants -- verified in the installed vendor at `dist/plugins/admin/error-codes` and BASE_ERROR_CODES -- and three are read at runtime off `plugin.$ERROR_CODES`. They became visible to this scan only because #9968 reimplements the vendor's handler in-repo, so codes that used to be relayed from node_modules are now stamped by a literal this repo builds. The verdict is `foreign-vocabulary`, door `none`, which is the limb this table already uses twice for better-auth codes in this same package (IMPERSONATION_ROTATION_FAILED, YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_MEMBER). Re-verified rather than inherited: the refusals are `APIError` thrown inside a better-auth endpoint, better-auth answers its own failures with a `Response`, `AuthManager.handleRequest` returns it untouched (logging only >= 500) and `domains/auth.ts` passes it on as `{ handled: true, result: response }`, so `errorFromThrown` is never reached; anything the auth service does throw is answered `deps.error(INTERNAL_ERROR_MESSAGE, 500)` with a status-derived code (#5085). The 2026-08-21 ruling (#10554) already recorded this same file's bodies as the vendor's wire via `check-route-envelope`'s `vendorWire` entry. `pending-registration` was considered and rejected as FALSE: it asserts the code belongs in #8846's ObjectStack ledger batch. These are the vendor's strings; registering them would promote a vendor spelling into the platform vocabulary and leave a ledger member outliving its producer on the next bump. The rows add zero to PENDING_LEDGER_REGISTRATION, which still holds only `owd_widening_forbidden`. No evasion: no rename, no indirection, no hoist -- every literal stays at its call site, visible to the scanner. node scripts/check-dispatcher-error-vocabulary.mjs check-dispatcher-error-vocabulary: OK -- 21 unregistered code-stamping site(s), all classified; 1 awaiting a ledger entry (#8846). node scripts/check-dispatcher-error-vocabulary.mjs --self-test check-dispatcher-error-vocabulary --self-test: 8 shapes + 102 assertions OK (vocabulary + #9098 door typing) node scripts/check-nul-bytes.mjs check-nul-bytes: OK (scanned 6193 text file(s) ... no raw ASCII control bytes). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
|
os-dev-report
Verdict chosen:
|
| code | vendor location | vendor spelling |
|---|---|---|
YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS |
dist/plugins/admin/error-codes |
"You are not allowed to impersonate users" |
YOU_CANNOT_IMPERSONATE_ADMINS |
dist/plugins/admin/error-codes |
"You cannot impersonate admins" |
FAILED_TO_CREATE_USER |
BASE_ERROR_CODES |
"Failed to create user" |
USER_NOT_FOUND |
BASE_ERROR_CODES |
"User not found" |
Reachability re-verified rather than inherited. Both directions are closed: the refusals are APIError thrown inside a better-auth endpoint, and better-auth answers its own failures with a Response instead of throwing — AuthManager.handleRequest returns it untouched (it only logs status >= 500) and domains/auth.ts passes it on as { handled: true, result: response }, so errorFromThrown is never reached; and anything the auth service does throw is answered deps.error(INTERNAL_ERROR_MESSAGE, 500) with a status-derived code, unconditionally (#5085).
door: 'none' is recorded in the rows as meaning none of the three ADR-0112 doors, not invisible — each refusal is served, in the vendor's flat { message, code } shape. That this endpoint's bodies are the vendor's wire is not inferred here: it is the 2026-08-21 ruling (#10554), already carried as the vendorWire entry for this same file in check-route-envelope.
Why pending-registration would have been false
That verdict states the code "belongs in #8846's ledger batch" — i.e. that ObjectStack owns it. These are better-auth's constants. Registering them would promote a vendor spelling into the platform vocabulary every consumer branches on (what the SANDBOX_AUTHORED_LIMB note refuses for DUPLICATE, for the same reason), and a vendor rename would leave a ledger member outliving its only producer. It would also contradict this PR's own body, which states no new public error code is minted. The four rows add zero to PENDING_LEDGER_REGISTRATION, which still holds only owd_widening_forbidden.
No evasion was used: no rename, no indirection, no const hoist — every literal stays at its call site, visible to the scanner. Diff is one file, +97/-0.
Evidence — exit codes captured before any pipe, verdict lines quoted
Before (at 634f69211), EXIT=1:
check-dispatcher-error-vocabulary: 4 finding(s)
scope: … 21 unregistered code-stamping site(s) found; 17 classified.
After (at 7a376ca54), GATE_EXIT=0:
check-dispatcher-error-vocabulary: OK — 21 unregistered code-stamping site(s), all classified; 1 awaiting a ledger entry (#8846).
scope: 1870 non-test source files under packages/; 291 registered codes (241 ledger + 50 standard); 21 unregistered code-stamping site(s) found; 21 classified.
SELFTEST_EXIT=0:
check-dispatcher-error-vocabulary --self-test: 8 shapes + 102 assertions OK (vocabulary + #9098 door typing)
check-nul-bytes, EXIT=0: OK (scanned 6193 text file(s) … no raw ASCII control bytes).
Reverse verification that the typecheck is load-bearing (so the verdict/door/shape literals are real union members, not accepted silently): a standalone tsc --noEmit --strict over the file is EXIT=0; mutating one row's verdict to 'vendor-owned-ABLATION' gives EXIT=2 with error TS2322: Type '"vendor-owned-ABLATION"' is not assignable to type 'CodeVerdict'. Restored from the commit byte-for-byte — git hash-object = cd15cec6400683a64e13fe15eb7fdd98b830c277 = the committed blob, ablation marker absent, tree clean.
Reported, not acted on — dispatch-gates residue (class #10309)
No — the PM-derived union did not name it. Measured on the PR's diff at the failing head 634f69211 (6 paths), node scripts/pm/dispatch-gates.mjs --residue places it in the Silent bucket ("source names paths, none of which cover yours — the weakest verdict", 69 families):
- pnpm check:dispatcher-error-vocabulary [lint.yml] names: packages/spec/src/api/error-code-ledger.zod.ts,
packages/spec/src/api/errors.zod.ts, packages/runtime/src/dispatcher-error-vocabulary.ts, …
The mechanism is precise and matches the class: the gate declares its INPUTS (the spec ledger + its own declaration table) but its actual population is every source file under packages/ that stamps a code — its own scope line reports 1870 of them. A PR that adds a new stamping site, which is exactly what this one did, moves the gate without touching any declared path, so the derivation scores it silent and the union misses it. The residue text names the escape itself: a gate whose population is broader reaches it by declaring the subtree spelling. Declaring packages/** for this family would close it.
Post-fix the derivation does name it — but only trivially, matched via packages/runtime/src/dispatcher-error-vocabulary.ts ⇢ gate source 'packages/runtime/src/dispatcher-error-vocabulary.ts', i.e. because my own commit edited the declaration file. That is the repair being visible to the deriver, not the failure having been derivable.
Untouched / noted
Feature code, tests, changeset, PR body, packages/spec/** and content/docs/releases/** were not touched; no new PR; draft state not flipped (PR remains draft: false, as I found it). Recorded for triage without acting on it, per shared-identity discipline: the PR body carries "⛔ Do not auto-merge … must not be flipped ready and must not have auto-merge armed until that review happens", while the PR is currently ready with auto-merge armed. That state was set by another actor and is the maintainer's to reconcile, not mine to revert.
Generated by Claude Code
Generated by Claude Code
Part of #9968
This card was ruled in two halves. Only the impersonation half ships here. The half that narrows the public surface — retiring the
set_user_roleaction — is NOT in this PR, because it trips a red line the dispatch drew (see Theset_user_rolehalf is blocked, below). Nothing is removed from the public surface by this PR.One refusal does become newly reachable, which is the closest thing here to a narrowing and is called out rather than buried:
403 YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS200403 YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS403 YOU_CANNOT_IMPERSONATE_ADMINS403 YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS401(bodyless)user.role === 'admin'200The middle row is the only case where a caller who could previously have succeeded now cannot — and on any post-ADR-0068-D2 deployment it was already unreachable, because reaching it required the caller to pass the vendor's gate, which no platform admin does. It is listed because the rule is new even where the outcome is not.
The defect
better-auth's
adminplugin authorizes every/admin/*route throughhasPermission({ userId, role, options, permissions }), whose only two authorization inputs are a construction-timeadminUserIdsarray and the persisted legacyuser.rolescalar. ADR-0068 D2 stopped synthesizing that scalar, and re-synthesizing it is permanently vetoed (maintainer ruling, 2026-08-18). ObjectStack's platform admin is asys_user_permission_setrow pointing atadmin_full_accesswithorganization_id = null— neither input.Measured on the installed
better-auth@1.7.1, a signed-in caller whosesys_user.roleis'user':A platform admin and a plain member receive byte-identical refusals, so the
sys_user"Impersonate User" button is dead on every deployment. Fails closed.The probe the ruling required, and its answer
The ruling made this measure-first: probe whether 1.7.1's
checkEndpointConflictspermits overriding a path another plugin registers, and stop and report if it fails. It did not fail.checkEndpointConflicts(dist/api/index.mjs) builds its registry by iteratingoptions.plugins[].endpoints, and on a duplicate path+method callslogger.error(...). It does not throw.getEndpointsmerges plugin endpoints with{...acc, ...plugin.endpoints}(keyed by endpoint key), andbetter-call's router calls rou3addRouteper endpoint in object order, where a later entry for the same method+path replaces the earlier one.Measured end to end against a real
betterAuth()instance: a second plugin registering/admin/impersonate-userboots, serves the override (200, override marker in the body) — and logsEndpoint path conflicts detected!on every start.So the override is permitted, and this PR takes the strictly better door the same measurement opens: replace the endpoint in place on the
adminplugin's ownendpointsrecord, so exactly one plugin ever registers the path. No conflict log, same endpoint context, same path.The shape — plugin endpoint, never a raw Hono mount
⛔ A raw Hono mount is forbidden by the ruling, for two independent reasons this implementation respects:
admin_sessionpayload is a contract with/admin/stop-impersonating, which parsesadminCookie.split(':')and 500s if the shape is off. A hand-rolled signature is either a broken exit path or a forgeable cookie.rotateCallerBearerOnImpersonation(better-auth bearer plugin lets a bearer session silently shadow an impersonation the server just created — /admin/impersonate-user returns 200 and is a no-op for any bearer client #8243). Without it,bearer()converts the caller's token back into the admin's session on every later request and impersonation is a silent 200 no-op. Nothing about the endpoint's own response would change, so no existing test would have noticed.The replacement endpoint is rebuilt from the vendor endpoint's own
optionsobject (method,bodyschema,use: [adminMiddleware, …], OpenAPImetadata), passed through untouched. The request contract, the 401-for-anonymous and the body validation are the vendor's and cannot drift from it on a dependency bump, because there is no second copy to drift.What actually changed
Only the authorization, in the two places the vendor asks it:
hasPermission({ role: session.user.role, … })→ the ADR-0068 D2 predicate. The legacyrole === 'admin'reading is retained exactly asplatform-admin-gate.tsretains it, so a deployment still carrying the pre-D2 scalar is not locked out. The caller set only ever grows.targetUser.roleagainstadminRoles: ['admin']— a column nothing writes post-D2, so that guard is inert: "you cannot impersonate admins" is currently a promise the code does not keep. It is re-asked through the same ADR-0068 predicate. The protected-target set only ever grows.The vendor's
allowImpersonatingAdmins/impersonate-adminsescape is deliberately not carried over: ObjectStack constructsadmin({ schema })and configures neither, and the escape's own check reads the same dead scalar.Refusals keep better-auth's flat
{ message, code }shape and the vendor's own code constants, read off the plugin's$ERROR_CODESrather than retyped — so no new public error code is minted and nothing here reaches the spec error-code ledger.The
set_user_rolehalf is blocked — measured, not assumedPremise confirmed. The vendor's
/admin/set-rolehandler's only effect is one write:internalAdapter.updateUser(userId, { role: parseRoles(ctx.body.role) }). Nothing else — no session revocation, no second write. Therolesvocabulary check is skipped because ObjectStack passes norolesoption. And that scalar is folded straight back into identity:customSessionseedspositions[]fromstoredRole.split(','), andisPlatformAdminUseracceptsrole === 'admin'. So typingadmininto that text box is a supported, gated, one-user-at-a-time resurrection of the vetoed dual identity. Retirement is the right call.But retiring it requires editing
packages/spec/**, and this lane has zero ownership there. Measured, not reasoned: removing the action fromsys_user.object.tsturnsfeature-gate-guard.test.tsred —The forward failure is repairable only by removing
'sys_user.actions.set_user_role'fromPUBLIC_AUTH_FEATURES.admin.gatedInputsinpackages/spec/src/kernel/public-auth-features.ts. The guard reads that registry from@objectstack/spec/kernel; there is no in-package repair. The measurement was taken on a throwaway edit and reverted byte-for-byte (git hash-objectback to79adba85e…, matchingorigin/main).Per the dispatch's explicit red line — and because #10159 and #10025 are already parked on the
packages/specownership question — that half stops here and returns to the maintainer. The exact remaining work is one array-line deletion inpublic-auth-features.ts, the action block insys-user.object.ts, a regeneration of the four*.objects.generated.tstranslation files, theadminActionslist and feature-gate matrix row inplatform-objects.test.ts, the>= 38walker floor infeature-gate-guard.test.ts, and the stalesys_user.rolefield description ("Set via the Set Platform Role action.").Consumer sweep for
set_user_role(repo-widegit grep, plusobjectui), so the reviewer is not asked to trust an unqualified zero:packages/platform-objects/src/identity/sys-user.object.tsen/es-ES/ja-JP/zh-CN.objects.generated.tsplatform-objects.test.ts(×2),feature-gate-guard.test.ts(via the registry)packages/spec/src/kernel/public-auth-features.ts← the blockersys-user.page.ts,packages/cli/src/commands/serve.ts,content/docs/references/system/auth-config.mdx,docs/adr/0092-*.md,docs/qa/platform-checklist/areas/identity-auth.jsonauth-route-ledger.tslistsPOST /api/v1/auth/admin/set-role— the vendor route stays mounted regardless; retiring the action does not touch itobjectuidoes name siblingsys_useractions by name (create_user,set_user_passwordinuseConsoleActionRuntime.test.tsx,ActionParamDialog.tsx,useObjectLabel-actionResultDialog.test.tsx), so the search machinery demonstrably works and the zero is real.No live consumer would break — the console renders whatever the object declares, and no code anywhere calls the action by name.
Verification
Two-directional pins,
statusANDcodeon every refusal (ADR-0112), inadmin-impersonate-endpoint.test.ts. Platform admin granted the ADR-0068 way (sys_user_permission_set→admin_full_access), with the legacy scalar asserted not to be'admin'so the suite cannot pass for the wrong reason: admitted, impersonation takes effect at the seam the data routes use (auth.api.getSession),impersonated_byrecorded. Other direction: plain member403 YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERSwith no session written, anonymous401, and an org owner — not a platform admin under ADR-0068 — still403.#8243 hook pin (the guard against the forbidden shape creeping back): after the platform admin impersonates, the caller's bearer resolves to nobody, the
set-admin-session-tokenrecovery credential comes back and is CORS-exposed, and the original admin session row is gone with a rotated one in its place.Ablation. Predicted signature recorded first, then measured: neuter
applyPlatformAdminImpersonationto a no-op (reproducing today'smain) → predicted 8 red / 6 green, measured 9 red / 15 green. One honest correction:boots without a better-auth endpoint-conflict errorwas predicted green but is red, because it carries a200precondition — my prediction missed that, and the deviation is reported rather than smoothed. The reds are the right ones, including the one that provescode-plus-statusis load-bearing:a platform-admin TARGET cannot be impersonatedkeeps status403and fails only on the code (YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERSinstead ofYOU_CANNOT_IMPERSONATE_ADMINS) — a status-only assertion would have stayed green. The refusal pins (member 403, anonymous 401, org-owner 403) stay green under ablation, which is exactly why a one-directional pin would have been worthless here.Rebuild statement, argued from the files rather than asserted: no rebuild is required for this ablation.
admin-impersonate-endpoint.test.tsreaches its subject by relative import (./auth-manager,./admin-impersonate-endpoint,./impersonation-bearer-rotation), so vitest transforms the package's TypeScript source anddist/is never on the resolution path; the dist-preflight discipline governs subjects reached through a dependency'sexportsmap, which this is not. The claim is falsifiable by the run itself — an ablation that failed to reach the running code would have left all tests green, the exact false-green signature. Restored byte-for-byte:git hash-object=dcb647618f87c48e7eddb0b5ac7c2d3fc4948edcboth before mutation and after restore, equal to the committed blob, ablation marker absent.Commands, exit codes captured before any pipe, each quoted from the gate's own verdict line. Gate union re-derived after the final commit with
node scripts/pm/dispatch-gates.mjs(no paths passed) on a clean worktree at16f43973b:@objectstack/plugin-authholds at 109 — separately confirmed by a test-inclusivetsc --noEmitover the package: exactly 109, zero of them in the files this PR adds. Thecheck-type-check-coverage.mjsledger is untouched, and the:2766self-test fixture was not touched.Notes for the reviewer
USER_NOT_FOUNDis restated as a local constant rather than imported from thebetter-authroot entry. That is not a style choice: several suites in this packagevi.mock('better-auth', …)to capture thebetterAuth()config, and vitest throws on a missing export from a mocked module — measured, the wholeadminplugin was swallowed byaddOptionalPlugin's catch and silently disabled. A restated constant is only safe while something proves it still matches, so a pin asserts it equals the vendor's ownBASE_ERROR_CODES.USER_NOT_FOUND.applyPlatformAdminImpersonationreturnsfalseandauth-manager.tslogs loudly. The route then falls back to the vendor handler, which refuses every platform admin — a broken button, never an open door..test.ts, which re-registers that file's 10 tests here. The cost was measured and the three alternatives are worse; the reasoning is written at the import site so nobody "fixes" it into a new engine double, a gate-invisible helper, or a suite-free fixture file this package's barevitest runcannot load.Out-of-scope findings filed unassigned: #10348 (three spellings of the ADR-0068 platform-admin id read, one skipping the system read context) and #10349 (bodyless
401from every better-auth-native/admin/route).#9652and#9969are not addressed here — they depend on this card, and every other better-auth-native/admin/*route still gates on the legacy scalar, deliberately untouched.🤖 Generated with Claude Code
https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
Generated by Claude Code