Skip to content

fix(plugin-auth): admit ObjectStack platform admins on /admin/impersonate-user (better-auth plugin endpoint, not a raw mount) - #10352

Merged
os-warren merged 6 commits into
mainfrom
claude/issue-9968-admin-route-platform-admin
Aug 21, 2026
Merged

fix(plugin-auth): admit ObjectStack platform admins on /admin/impersonate-user (better-auth plugin endpoint, not a raw mount)#10352
os-warren merged 6 commits into
mainfrom
claude/issue-9968-admin-route-platform-admin

Conversation

@os-warren

@os-warren os-warren commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Part of #9968

Do not auto-merge. This PR is developed one tier below the contract-review gate under the maintainer's 2026-08-20 fable-exhaustion authorization, whose compensating control is that triage reviews before merge. It must not be flipped ready and must not have auto-merge armed until that review happens.

⚠️ Public-surface narrowing — read this first (Clause-② card)

This card was ruled in two halves. Only the impersonation half ships here. The half that narrows the public surface — retiring the set_user_role action — is NOT in this PR, because it trips a red line the dispatch drew (see The set_user_role half 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:

Caller Target Before After
ObjectStack platform admin (ADR-0068) ordinary user 403 YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS 200
ObjectStack platform admin another platform admin 403 YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS 403 YOU_CANNOT_IMPERSONATE_ADMINS
plain member / org owner / org admin anyone 403 YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS unchanged
anonymous anyone 401 (bodyless) unchanged
caller whose legacy user.role === 'admin' ordinary user 200 unchanged

The 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 admin plugin authorizes every /admin/* route through hasPermission({ userId, role, options, permissions }), whose only two authorization inputs are a construction-time adminUserIds array and the persisted legacy user.role scalar. ADR-0068 D2 stopped synthesizing that scalar, and re-synthesizing it is permanently vetoed (maintainer ruling, 2026-08-18). ObjectStack's platform admin is a sys_user_permission_set row pointing at admin_full_access with organization_id = null — neither input.

Measured on the installed better-auth@1.7.1, a signed-in caller whose sys_user.role is 'user':

/admin/impersonate-user -> 403 {"message":"You are not allowed to impersonate users","code":"YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS"}
/admin/set-role         -> 403 {"message":"You are not allowed to change users role","code":"YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE"}

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 checkEndpointConflicts permits 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 iterating options.plugins[].endpoints, and on a duplicate path+method calls logger.error(...). It does not throw.
  • getEndpoints merges plugin endpoints with {...acc, ...plugin.endpoints} (keyed by endpoint key), and better-call's router calls rou3 addRoute per 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-user boots, serves the override (200, override marker in the body) — and logs Endpoint 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 admin plugin's own endpoints record, 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:

  1. The handler is not a data write. It mints a session and rewrites cookies via helpers that exist only inside a better-auth endpoint context, and the admin_session payload is a contract with /admin/stop-impersonating, which parses adminCookie.split(':') and 500s if the shape is off. A hand-rolled signature is either a broken exit path or a forgeable cookie.
  2. Shadowing the path detaches every better-auth hook keyed on it — including 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 options object (method, body schema, use: [adminMiddleware, …], OpenAPI metadata), 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:

  1. Caller. hasPermission({ role: session.user.role, … }) → the ADR-0068 D2 predicate. The legacy role === 'admin' reading is retained exactly as platform-admin-gate.ts retains it, so a deployment still carrying the pre-D2 scalar is not locked out. The caller set only ever grows.
  2. Target. The vendor refuses to impersonate an admin-grade target by reading targetUser.role against adminRoles: ['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-admins escape is deliberately not carried over: ObjectStack constructs admin({ 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_CODES rather than retyped — so no new public error code is minted and nothing here reaches the spec error-code ledger.

The set_user_role half is blocked — measured, not assumed

Premise confirmed. The vendor's /admin/set-role handler's only effect is one write: internalAdapter.updateUser(userId, { role: parseRoles(ctx.body.role) }). Nothing else — no session revocation, no second write. The roles vocabulary check is skipped because ObjectStack passes no roles option. And that scalar is folded straight back into identity: customSession seeds positions[] from storedRole.split(','), and isPlatformAdminUser accepts role === 'admin'. So typing admin into 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 from sys_user.object.ts turns feature-gate-guard.test.ts red —

FAIL src/feature-gate-guard.test.ts > feature-gate completeness guard (#2874)
  > forward: every registered gated input carries the matching predicate
  > sys_user.actions.set_user_role is gated on admin
FAIL src/feature-gate-guard.test.ts > ... > reverse: ... > finds the gated surface (guards the walker itself)

The forward failure is repairable only by removing 'sys_user.actions.set_user_role' from PUBLIC_AUTH_FEATURES.admin.gatedInputs in packages/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-object back to 79adba85e…, matching origin/main).

Per the dispatch's explicit red line — and because #10159 and #10025 are already parked on the packages/spec ownership question — that half stops here and returns to the maintainer. The exact remaining work is one array-line deletion in public-auth-features.ts, the action block in sys-user.object.ts, a regeneration of the four *.objects.generated.ts translation files, the adminActions list and feature-gate matrix row in platform-objects.test.ts, the >= 38 walker floor in feature-gate-guard.test.ts, and the stale sys_user.role field description ("Set via the Set Platform Role action.").

Consumer sweep for set_user_role (repo-wide git grep, plus objectui), so the reviewer is not asked to trust an unqualified zero:

  • Declaration: packages/platform-objects/src/identity/sys-user.object.ts
  • Generated translations: en / es-ES / ja-JP / zh-CN .objects.generated.ts
  • Tests: platform-objects.test.ts (×2), feature-gate-guard.test.ts (via the registry)
  • Spec registry: packages/spec/src/kernel/public-auth-features.ts ← the blocker
  • Comments / docs only: sys-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.json
  • Route ledger: auth-route-ledger.ts lists POST /api/v1/auth/admin/set-role — the vendor route stays mounted regardless; retiring the action does not touch it
  • objectui: zero. Counter-checked — objectui does name sibling sys_user actions by name (create_user, set_user_password in useConsoleActionRuntime.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, status AND code on every refusal (ADR-0112), in admin-impersonate-endpoint.test.ts. Platform admin granted the ADR-0068 way (sys_user_permission_setadmin_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_by recorded. Other direction: plain member 403 YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS with no session written, anonymous 401, and an org owner — not a platform admin under ADR-0068 — still 403.

#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-token recovery 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 applyPlatformAdminImpersonation to a no-op (reproducing today's main) → predicted 8 red / 6 green, measured 9 red / 15 green. One honest correction: boots without a better-auth endpoint-conflict error was predicted green but is red, because it carries a 200 precondition — my prediction missed that, and the deviation is reported rather than smoothed. The reds are the right ones, including the one that proves code-plus-status is load-bearing: a platform-admin TARGET cannot be impersonated keeps status 403 and fails only on the code (YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS instead of YOU_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.ts reaches its subject by relative import (./auth-manager, ./admin-impersonate-endpoint, ./impersonation-bearer-rotation), so vitest transforms the package's TypeScript source and dist/ is never on the resolution path; the dist-preflight discipline governs subjects reached through a dependency's exports map, 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 = dcb647618f87c48e7eddb0b5ac7c2d3fc4948edc both 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 at 16f43973b:

pnpm --filter @objectstack/plugin-auth test        Test Files 61 passed (61) · Tests 1359 passed (1359)
pnpm --filter @objectstack/plugin-auth typecheck   tsc --noEmit — exit 0

check:changeset-gate-self-tests   EXIT=0     check:objectui-changeset        EXIT=0
check:slot-lookup                 EXIT=0     check:test-source-alias         EXIT=0
check:type-source-resolution      EXIT=0     check-adr-0087-registration     EXIT=0
check-changeset-no-major          EXIT=0     check-empty-changeset           EXIT=0
check:query-options-erasure       EXIT=0     check:cross-package-test-inputs EXIT=0
docs-audit/check-affected-docs    EXIT=0

check-engine-double-contract: OK — 331 pinned, 133 in the DEBT ledger, 2 exempt.
check-nul-bytes: OK (scanned 6093 text file(s) … no raw ASCII control bytes).
where-matcher conformance holds: 265 matcher(s) discovered … 0 silently-wrong … none new.
check-type-check-coverage: OK — 64/77 workspace packages type-checked …
check-type-check-coverage --re-measure: OK — 33 ledger entr(ies) re-measured in 237.2s,
  1924 raw tsc error(s) total, none above its recorded number.
  surplus: none — every entry sits exactly at its measurement, so any new error is red.

@objectstack/plugin-auth holds at 109 — separately confirmed by a test-inclusive tsc --noEmit over the package: exactly 109, zero of them in the files this PR adds. The check-type-check-coverage.mjs ledger is untouched, and the :2766 self-test fixture was not touched.

Notes for the reviewer

  • USER_NOT_FOUND is restated as a local constant rather than imported from the better-auth root entry. That is not a style choice: several suites in this package vi.mock('better-auth', …) to capture the betterAuth() config, and vitest throws on a missing export from a mocked module — measured, the whole admin plugin was swallowed by addOptionalPlugin'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 own BASE_ERROR_CODES.USER_NOT_FOUND.
  • If a future vendor bump renames or drops the endpoint, applyPlatformAdminImpersonation returns false and auth-manager.ts logs loudly. The route then falls back to the vendor handler, which refuses every platform admin — a broken button, never an open door.
  • The new suite imports the engine double from a sibling .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 bare vitest run cannot 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 401 from every better-auth-native /admin/ route).

#9652 and #9969 are 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

claude added 2 commits August 20, 2026 15:53
…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
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-auth, @objectstack/runtime, touching 13 documentable anchor(s).

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

  • content/docs/api/error-catalog.mdx (via sys_permission_set (literal))
  • content/docs/automation/approvals.mdx (via admin_full_access (literal))
  • content/docs/data-modeling/objects.mdx (via sys_user_permission_set (literal))
  • content/docs/kernel/contracts/auth-service.mdx (via AuthManager (symbol))
  • content/docs/kernel/services-checklist.mdx (via AuthManager (symbol))
  • content/docs/permissions/authentication.mdx (via AuthManager (symbol), USER_NOT_FOUND (symbol))
  • content/docs/permissions/authorization.mdx (via admin_full_access (literal), sys_permission_set (literal), sys_user_permission_set (literal))
  • content/docs/permissions/delegated-administration.mdx (via sys_permission_set (literal), sys_user_permission_set (literal))
  • content/docs/permissions/permission-sets.mdx (via admin_full_access (literal), sys_permission_set (literal), sys_user_permission_set (literal))
  • content/docs/permissions/permissions-matrix.mdx (via admin_full_access (literal))
  • content/docs/permissions/sharing-rules.mdx (via admin_full_access (literal))
  • content/docs/ui/audience-based-interfaces.mdx (via admin_full_access (literal))

7 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via sys_user_permission_set (literal))
  • content/docs/releases/v12.mdx (via sys_permission_set (literal))
  • content/docs/releases/v13.mdx (via sys_permission_set (literal), sys_user_permission_set (literal))
  • content/docs/releases/v14.mdx (via admin_full_access (literal), sys_user_permission_set (literal))
  • content/docs/releases/v15.mdx (via sys_permission_set (literal))
  • content/docs/releases/v16.mdx (via admin_full_access (literal), sys_user_permission_set (literal))
  • content/docs/releases/v17.mdx (via admin_full_access (literal), sys_permission_set (literal), sys_user_permission_set (literal))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 30 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 4330b7db7e1c0dbed6fea8941dffa2c27603e302packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3df4978c8611bc5c6ec50fd31093f8e0dec735f9 — the merge of head 7a376ca54d75de9b715206800c962296c9456142 into base 4330b7db7e1c0dbed6fea8941dffa2c27603e302, 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 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

⚠️ 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 4330b7db7e1c0dbed6fea8941dffa2c27603e302 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator Author

正文顶部那条 ⛔「Do not auto-merge」已被解除 —— 记录授权来源,不修改正文

本 PR 正文开头写着:

Do not auto-merge. … It must not be flipped ready and must not have auto-merge armed until that review happens.

那条约束当时是对的,现在已经满足。 我在 2026-08-21T01:00 翻了 ready 并武装 auto-merge,依据是:

⚠️不修改正文:那段话记录的是开发在当时掌握的事实,抹掉它等于伪造历史。它作为「当时的正确判断」保留,这条评论作为「该条件已满足」的凭证。

合并顺序(不变)

本 PR 先于 PR #10390 落地。两者都新增同名 isPlatformAdminUserId,但本 PR 保留 isOrgOrPlatformAdmin(docblock 还指向它),而 #10390 删除该方法。所以后落地的是 #10390,由它做「保留一份 + 修正悬空 {@link}」的人工解决。⛔ 该解决不交给 auto-merge。

一条可能出现、且不是本 PR 的 CI 红

本 PR 的 PR 侧 CI 跑于 16:12–16:26早于 #10449 出现(19:38)。合并队列用最新 main 重跑时会第一次遇到:

✗ check-query-options-erasure-ratchet
  • packages/spec/src/migrations/registry.ts:0:0 — Parsing error: Maximum call stack size exceeded

那是 #10449(P0,devx 车道已认领):该门禁在进程内调用 ESLint,绕开了 pnpm lint CLI 上的 --stack-size=4000红在 main 的测量群体上、与本 diff 无关。⛔ 不为它改代码,重跑至多一次。


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

⛔ 升级维护者:check:route-envelope 红,而三条出路里没有一条是作者可以诚实走的

CI Lint & Repo Gates 红在 check:route-envelope(run 32440334009)。我派开发去修,指示他若唯一诚实的声明是 exempt(MAINTAINER-ONLY)就停手上报。他停了,一个字节都没改git status 干净于 4b5d5605)。以下是实测,不是推理。

① 门禁反对的不是我们以为的东西

我在派发指令里、以及本 PR 正文里,都以为争点是拒绝信封(刻意保留 better-auth 扁平 { message, code })。错了。 用门禁自己的扫描器 scanHonoRouteSource 实测本文件:

bodies: 1, reads: 0
unenveloped: 1        site: admin-impersonate-endpoint.ts:254
errorWithoutMessage: 0, errorCodeNotString: 0, strayKeys: 0, stringError: 0, siblingCode: 0

拒绝是 throw APIError.from(...),而每个计数器读的都是交给 .json(…) 的对象字面量 —— 零条拒绝被计数。唯一被计数的是成功体

return ctx.json({ session, user: parseUserOutput(ctx.context.options, targetUser) });

② 为什么 {}ratchet 都是假话

{} 假:门禁实测仍红(unenveloped: found 1, declared 0)。

ratchet 假 —— 它断言「这些将来会被转成信封」,而三条事实说明它永远不会:

  • 该端点自己发布的 OpenAPI schema 声明的正是这个 body,且本 PR 原样透传 vendor.options(含 metadata.openapi):responses.200 … schema = { session: $ref Session, user: $ref User }。封装它 = 端点提供一个与它自己发布的 schema 矛盾的响应。
  • 契约伙伴够不着/admin/stop-impersonating 返回同样的裸 { session, user }整个住在 vendor 里,本仓库无法转换。只封装一半是新的不一致,不是进展。
  • 仓库已经承认这一类不是漂移PLUGIN_ROUTE_MODULESauth-plugin.ts 的条目原话 —— "The rest of this file (~46 bodies) is better-auth's own wire format, relayed rather than built, and stays invisible to these counters by design." surface 2 把这一类明确命名为 kind 3"A foreign wire format a client library requires")。本 body 正是该类;它之所以变得可见,只是因为 handler 被在仓内重新实现成了对象字面量,而不再是转发。

开发还逐一验证过没有任何在途卡可以真实充当 ratchet 目标:#9559 是 surface 1 的目标(ObjectStack 自己的 sendOk/sendError,够不到 vendor 端点);#10349拒绝体(无体 401),即使按它的选项 B,200 的 { session, user } 也纹丝不动。写上任一个都是假陈述。

exempt 不只是 MAINTAINER-ONLY —— 它连现有被裁类都不属于

现有 exempt#9389(2026-08-17)裁定的封闭列表,边界是 pre-auth discovery/bootstrap。而本 body 服务于已认证的 ObjectStack 平台管理员 —— 正好相反。所以它不是「像那一类所以加进去」,诚实的声明需要一个新的被裁类(vendor 端点 / vendor wire format / vendor 发布的 OpenAPI),而 surface 3 目前没有这个状态。新建或放宽被裁边界是门禁自己的 RATCHET_AUTHORITY_MARKER 标注的 ⛔ MAINTAINER-ONLY。

④ 两个可行但被拒绝的做法 —— 尤其是第二个

  • 把 body 封装成信封(门禁说这个作者无需批准)。拒绝:会破坏 authClient.admin.impersonateUser、与透传的 OpenAPI 矛盾、与 /admin/stop-impersonating 分叉,且属于派发明令禁止的功能代码变更。
  • ⚠️ 把字面量提升成 const 再传标识符 —— 计数器只读字面量,这样门禁立刻变绿,wire 一个字节不变,而且技术上不算撒谎。作为规避拒绝,理由是开发自己写的:"this repo builds this body now, and hiding it from the auditor is the exact state the gate exists to prevent."

我认可这个拒绝。⚠️ 这条路存在、有效、且几乎不会被任何人发现 —— 值得单独记下来,因为下一个碰到同类红的人会重新发现它

需要的裁决(两条形状,均属维护者)

⛔ 在此之前 PR #10352 无法变绿,因而无法合并PR #10390(卡 #10009)也随之继续压着 —— 它按既定顺序必须在本 PR 之后落地。


Generated by Claude Code

os-elon commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

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 exempt); a note is mandatory, naming the vendor, the vendor client reader, and the contract-partner endpoint(s); adding or widening an entry is ⛔ MAINTAINER-ONLY (same marker discipline as exempt-widening); and the const-hoisting evasion documented in the escalation is to be named as a forbidden move in the gate's own prose. Option (B) — widening #9389's exempt grammar — was considered and rejected: it would turn a single ruled boundary into a growable bag of classes.

Implementation is dispatched as #10554 (branch claude/route-envelope-decision-y3itw6, fable tier). Once the machinery lands on main, this PR's remaining step is: merge main, then declare admin-impersonate-endpoint.ts under the new state with the required note (vendor: better-auth; reader: authClient.admin.impersonateUser; contract partner: /admin/stop-impersonating) — the declaration itself will read ⛔ MAINTAINER-ONLY on first entry, and this ruling is the authorization for exactly that one entry. No functional code change to this PR is required or authorized by the ruling.

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

os-elon pushed a commit that referenced this pull request Aug 21, 2026
… 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
claude added 3 commits August 21, 2026 04:14
…-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

Copy link
Copy Markdown
Collaborator Author

os-dev-report

check-dispatcher-error-vocabulary fixed at 7a376ca54. No escalation needed — the vocabulary already has an honest limb for this population, and pending-registration was rejected as false.

Verdict chosen: foreign-vocabulary, door none

The verdict vocabulary the contract file actually supports is five members: pending-registration, sandbox-authored, foreign-vocabulary, boot-refusal, runtime-pinned. foreign-vocabulary is the vendor limb, and this table already uses it twice for better-auth codes in this same packageIMPERSONATION_ROTATION_FAILED (impersonation-bearer-rotation.ts) and YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_MEMBER (auth-manager.ts), the latter explicitly "the vendor's YOU_ARE_NOT_ALLOWED_TO_* family, thrown as new APIError('FORBIDDEN', { message, code })". So four new rows are author-side work on a settled limb, not a new one.

Vendor ownership verified, not assumed — all four found in the installed better-auth 1.7.1:

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

@os-warren
os-warren added this pull request to the merge queue Aug 21, 2026
Merged via the queue into main with commit 5b0af2b Aug 21, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-9968-admin-route-platform-admin branch August 21, 2026 04:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants