From e91220840e1998fa7a3e315b6885d03a4d498262 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 15:53:08 +0000 Subject: [PATCH 1/4] fix(plugin-auth): admit ObjectStack platform admins on /admin/impersonate-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 Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx --- .changeset/impersonate-user-platform-admin.md | 15 + .../src/admin-impersonate-endpoint.test.ts | 365 ++++++++++++++++++ .../src/admin-impersonate-endpoint.ts | 262 +++++++++++++ .../plugins/plugin-auth/src/auth-manager.ts | 75 +++- .../src/impersonation-bearer-rotation.test.ts | 6 +- 5 files changed, 721 insertions(+), 2 deletions(-) create mode 100644 .changeset/impersonate-user-platform-admin.md create mode 100644 packages/plugins/plugin-auth/src/admin-impersonate-endpoint.test.ts create mode 100644 packages/plugins/plugin-auth/src/admin-impersonate-endpoint.ts diff --git a/.changeset/impersonate-user-platform-admin.md b/.changeset/impersonate-user-platform-admin.md new file mode 100644 index 0000000000..6169aa62cf --- /dev/null +++ b/.changeset/impersonate-user-platform-admin.md @@ -0,0 +1,15 @@ +--- +"@objectstack/plugin-auth": patch +--- + +**Fix:** `POST /api/v1/auth/admin/impersonate-user` now admits ObjectStack **platform admins**. It previously refused every one of them with `403 YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS` — byte-identical to the refusal a plain member received — so the `sys_user` "Impersonate User" button was dead on every deployment (#9968). + +better-auth's `admin` plugin authorizes on the legacy `user.role === 'admin'` scalar that ADR-0068 D2 stopped synthesizing. ObjectStack's platform admin is a `sys_user_permission_set` row pointing at `admin_full_access` with `organization_id = null`, which the vendor cannot be pointed at, and re-synthesizing the scalar is permanently vetoed. + +**What an operator will now observe.** A platform admin who could not impersonate anyone can now impersonate a non-admin user, and the impersonation takes effect for cookie and bearer clients alike. Refusals are unchanged for everyone else: a signed-in non-platform-admin (including an organization owner or admin, who is **not** a platform admin under ADR-0068) still gets `403 YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS`, and an anonymous caller still gets `401` from better-auth's own `adminMiddleware`. + +**One refusal is newly reachable.** The vendor refuses to impersonate an admin-grade *target* by reading that same `role` scalar against `adminRoles: ['admin']` — a column nothing writes after ADR-0068 D2, so the guard was inert. It is now asked through the ADR-0068 predicate, so impersonating a **platform-admin target** is refused with `403 YOU_CANNOT_IMPERSONATE_ADMINS` where it previously succeeded. + +Implemented as a better-auth **plugin endpoint**, replacing the vendor endpoint in place on the `admin` plugin's own `endpoints` record — not a raw Hono mount. That keeps the signed-cookie contract with `/admin/stop-impersonating` and keeps the `/admin/impersonate-user` path-keyed rotation hook attached, so bearer-client impersonation does not regress to a silent 200 no-op. + +Every other better-auth-native `/admin/*` route still gates on the legacy scalar and still refuses platform admins — unchanged here. diff --git a/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.test.ts b/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.test.ts new file mode 100644 index 0000000000..d271969500 --- /dev/null +++ b/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.test.ts @@ -0,0 +1,365 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// `/admin/impersonate-user` refused EVERY ObjectStack platform admin. +// +// The vendor authorizes on the legacy `user.role === 'admin'` scalar that +// ADR-0068 D2 stopped synthesizing, so a seeded platform admin (`role: 'user'`, +// `positions: ['user','platform_admin']`) and a plain member received +// BYTE-IDENTICAL `403 YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS`. That identity +// is what makes a one-directional test useless here: "the platform admin is +// admitted" alone would also pass against a gate that admits everyone, and "the +// member is refused" alone was already green against the bug. Both directions +// are asserted, always as `status` AND `code` (ADR-0112) — a bare `.toThrow()` +// or a lone status cannot tell the ADR-0068 gate's answer from the vendor's. +// +// The third pin is the one a raw Hono mount would have silently broken: the +// #8243 rotation hook is keyed on the PATH `/admin/impersonate-user` in +// better-auth's global `hooks.after`. Re-implementing at the Hono layer +// shadows the path, detaches the hook, and re-opens #8243 — impersonation +// answers 200 while `bearer()` keeps converting the caller's token back into +// the ADMIN's session. Nothing about the endpoint's own response would change. +// So the rotation is re-asserted here THROUGH the new authorization path, with +// a caller who is a platform admin the ADR-0068 way. +// +// Real better-auth pipeline throughout: requests go in as `Request` objects +// through `AuthManager.handleRequest`, and "who is this now?" is asked at the +// seam the framework's data routes use — `auth.api.getSession({ headers })`, +// literally what `runtime/src/security/resolve-session-principal.ts` calls. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity'; +import { AuthManager } from './auth-manager'; +import { createMemoryEngine } from './impersonation-bearer-rotation.test'; +import { ADMIN_SESSION_RECOVERY_RESPONSE_HEADER } from './impersonation-bearer-rotation'; +import { USER_NOT_FOUND } from './admin-impersonate-endpoint'; + +const SECRET = 'test-secret-at-least-32-chars-long!!'; +const PASSWORD = 'S3cure!Passw0rd-9968'; +const BASE = 'http://localhost:3000/api/v1/auth'; +const PS_ADMIN = 'ps_admin_full_access'; + +const makeManager = (engine: any) => + new AuthManager({ + secret: SECRET, + baseUrl: 'http://localhost:3000', + dataEngine: engine, + plugins: { admin: true }, + } as any); + +const signUp = (manager: AuthManager, email: string, name: string) => + manager.handleRequest( + new Request(`${BASE}/sign-up/email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password: PASSWORD, name }), + }), + ); + +const signIn = (manager: AuthManager, email: string) => + manager.handleRequest( + new Request(`${BASE}/sign-in/email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password: PASSWORD }), + }), + ); + +const bearerFrom = (response: Response): string => { + const token = response.headers.get('set-auth-token'); + if (!token) throw new Error('no set-auth-token on the response'); + return token; +}; + +const userRows = (engine: any) => (engine.tables.get('sys_user') ?? []) as any[]; + +const userIdFor = (engine: any, email: string): string => { + const row = userRows(engine).find((r) => r.email === email); + if (!row) throw new Error(`no sys_user row for ${email}`); + return String(row.id); +}; + +/** + * Grant platform admin the ADR-0068 D2 way — a `sys_user_permission_set` row + * pointing at `admin_full_access` with `organization_id = null`. + * + * ⛔ Deliberately NOT `row.role = 'admin'`. Writing that scalar is what the + * 2026-08-18 ruling permanently vetoed, and a fixture that used it would be + * testing the vendor's gate rather than ObjectStack's — it passes with or + * without this card's change. + */ +const grantPlatformAdmin = async (engine: any, userId: string) => { + if (!(engine.tables.get('sys_permission_set') ?? []).some((r: any) => r.id === PS_ADMIN)) { + await engine.insert('sys_permission_set', { id: PS_ADMIN, name: ADMIN_FULL_ACCESS }); + } + await engine.insert('sys_user_permission_set', { + user_id: userId, + permission_set_id: PS_ADMIN, + organization_id: null, + }); +}; + +const impersonate = (manager: AuthManager, bearer: string | null, userId: string) => + manager.handleRequest( + new Request(`${BASE}/admin/impersonate-user`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(bearer ? { authorization: `Bearer ${bearer}` } : {}), + }, + body: JSON.stringify({ userId }), + }), + ); + +/** WHO does this bearer resolve to now? `null` for anonymous. */ +const principalFor = async (manager: AuthManager, bearer: string): Promise => { + const auth: any = await manager.getAuthInstance(); + const session = await auth.api + .getSession({ headers: new Headers({ authorization: `Bearer ${bearer}` }) }) + .catch(() => null); + const id = session?.user?.id ?? session?.session?.userId; + return typeof id === 'string' && id.length > 0 ? id : null; +}; + +/** + * An ObjectStack platform admin (ADR-0068 grant, legacy scalar untouched), a + * plain member, and a target — plus a bearer for each signed-in caller. + */ +const arrange = async () => { + const engine = createMemoryEngine(); + const manager = makeManager(engine); + + await signUp(manager, 'padmin@example.com', 'Platform Admin'); + await signUp(manager, 'member@example.com', 'Plain Member'); + await signUp(manager, 'target@example.com', 'Impersonation Target'); + + const adminId = userIdFor(engine, 'padmin@example.com'); + const memberId = userIdFor(engine, 'member@example.com'); + const targetId = userIdFor(engine, 'target@example.com'); + await grantPlatformAdmin(engine, adminId); + + // The premise this whole card rests on: the platform admin's legacy scalar is + // NOT 'admin'. If a future seed starts writing it, every assertion below goes + // green for the wrong reason, so it is asserted rather than assumed. + const adminRow = userRows(engine).find((r) => String(r.id) === adminId); + expect(adminRow.role ?? 'user').not.toBe('admin'); + + const adminBearer = bearerFrom(await signIn(manager, 'padmin@example.com')); + const memberBearer = bearerFrom(await signIn(manager, 'member@example.com')); + + return { engine, manager, adminId, memberId, targetId, adminBearer, memberBearer }; +}; + +const jsonOf = async (res: Response) => JSON.parse(await res.text()); + +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => vi.restoreAllMocks()); + +// ─────────────────────────────────────────────────────────────────────────── +describe('ADR-0068 D2 — /admin/impersonate-user admits the ObjectStack platform admin', () => { + it('a platform admin granted via admin_full_access is ADMITTED (not the legacy scalar)', async () => { + const { manager, adminBearer, targetId } = await arrange(); + + const res = await impersonate(manager, adminBearer, targetId); + + expect(res.status).toBe(200); + const body = await jsonOf(res); + expect(body?.user?.id).toBe(targetId); + expect(body?.session?.userId).toBe(targetId); + }); + + it('the impersonation actually takes effect — the next request IS the target', async () => { + const { manager, adminBearer, targetId } = await arrange(); + + const res = await impersonate(manager, adminBearer, targetId); + expect(res.status).toBe(200); + + // The impersonated token better-auth emitted, resolved through the same + // seam the data routes use. A 200 that did not change the principal is the + // #8243 shape and is NOT a pass. + const impersonatedBearer = bearerFrom(res); + expect(await principalFor(manager, impersonatedBearer)).toBe(targetId); + }); + + it('the impersonation session records who is behind it', async () => { + const { engine, manager, adminBearer, adminId, targetId } = await arrange(); + + expect((await impersonate(manager, adminBearer, targetId)).status).toBe(200); + + // Find the row by the field under test, not by `user_id`: signing UP already + // gave the target a session, so a `user_id` lookup returns that one first and + // reports `impersonated_by: undefined` whether or not impersonation worked. + const rows = (engine.tables.get('sys_session') ?? []) as any[]; + const impersonations = rows.filter((r) => r.impersonated_by); + expect(impersonations).toHaveLength(1); + expect(impersonations[0].user_id).toBe(targetId); + expect(impersonations[0].impersonated_by).toBe(adminId); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('the other direction — a non-entitled caller is still refused', () => { + it('a signed-in plain member is refused 403 YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS', async () => { + const { manager, memberBearer, targetId } = await arrange(); + + const res = await impersonate(manager, memberBearer, targetId); + + // status AND code (ADR-0112) — either alone cannot distinguish this gate's + // refusal from a validation error or from the vendor's own. + expect(res.status).toBe(403); + expect((await jsonOf(res)).code).toBe('YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS'); + }); + + it('the refused member changes nothing — no impersonation session exists', async () => { + const { engine, manager, memberBearer, targetId } = await arrange(); + + expect((await impersonate(manager, memberBearer, targetId)).status).toBe(403); + + const rows = (engine.tables.get('sys_session') ?? []) as any[]; + expect(rows.filter((r) => r.impersonated_by)).toHaveLength(0); + }); + + it('an anonymous caller is refused 401, distinctly from the 403 — identity error before capability error', async () => { + const { manager, targetId } = await arrange(); + + const res = await impersonate(manager, null, targetId); + + // 401, NOT the member's 403: the two refusals must stay distinguishable, or + // the dogfood sweep cannot tell "the payload never reached the gate" from + // "the gate said no". + expect(res.status).toBe(401); + + // ⚠️ Measured, and deliberately not dressed up: this refusal carries an + // EMPTY body — no ADR-0112 envelope, no `code`. It comes from the vendor's + // `adminMiddleware` (`APIError.fromStatus('UNAUTHORIZED')`), which runs + // before this endpoint's handler and is byte-identical on stock + // better-auth 1.7.1 for both `/admin/impersonate-user` and + // `/admin/set-role`. This card changes the AUTHORIZATION predicate, not the + // authentication middleware, so the shape is pinned as it is rather than + // asserted to be something it is not. + expect(await res.text()).toBe(''); + }); + + it('an org owner/admin who is NOT a platform admin is refused', async () => { + const { engine, manager, memberBearer, memberId, targetId } = await arrange(); + // Owning an organization is not platform admin (ADR-0068). The endpoint + // asks the narrow question, so this must stay a 403. + await engine.insert('sys_member', { + user_id: memberId, + organization_id: 'org_1', + role: 'owner', + }); + + const res = await impersonate(manager, memberBearer, targetId); + + expect(res.status).toBe(403); + expect((await jsonOf(res)).code).toBe('YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS'); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('the target guard means something again after ADR-0068 D2', () => { + it('a platform-admin TARGET cannot be impersonated — 403 YOU_CANNOT_IMPERSONATE_ADMINS', async () => { + const { engine, manager, adminBearer, targetId } = await arrange(); + // The vendor guards this by reading `targetUser.role` against + // adminRoles:['admin'] — a column nothing writes post-D2, so the vendor's + // guard is inert. Granted the ADR-0068 way, it must bite. + await grantPlatformAdmin(engine, targetId); + + const res = await impersonate(manager, adminBearer, targetId); + + expect(res.status).toBe(403); + expect((await jsonOf(res)).code).toBe('YOU_CANNOT_IMPERSONATE_ADMINS'); + }); + + it('a missing target is a 404, not a 403 — the two refusals stay distinguishable', async () => { + const { manager, adminBearer } = await arrange(); + + const res = await impersonate(manager, adminBearer, 'usr_does_not_exist'); + + expect(res.status).toBe(404); + expect((await jsonOf(res)).code).toBe('USER_NOT_FOUND'); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('#8243 — the rotation hook is STILL ATTACHED after the re-authorization', () => { + // This is the guard against the forbidden shape creeping back. A raw Hono + // mount shadowing `/admin/impersonate-user` detaches the path-keyed global + // `hooks.after`, and every assertion in the two suites above would still + // pass. These do not. + + it('the caller bearer the platform admin was holding no longer resolves to anyone', async () => { + const { manager, adminBearer, targetId } = await arrange(); + + expect((await impersonate(manager, adminBearer, targetId)).status).toBe(200); + + // Rotation invalidated it. Without the hook this would still be the admin — + // which is exactly the silent 200 no-op #8243 exists to make impossible. + expect(await principalFor(manager, adminBearer)).toBeNull(); + }); + + it('the admin-session recovery credential comes back on the response', async () => { + const { manager, adminBearer, targetId } = await arrange(); + + const res = await impersonate(manager, adminBearer, targetId); + + expect(res.status).toBe(200); + expect(res.headers.get(ADMIN_SESSION_RECOVERY_RESPONSE_HEADER)).toBeTruthy(); + expect( + (res.headers.get('access-control-expose-headers') ?? '') + .split(',') + .map((h) => h.trim().toLowerCase()), + ).toContain(ADMIN_SESSION_RECOVERY_RESPONSE_HEADER); + }); + + it('the admin session row the caller was holding is really gone', async () => { + const { engine, manager, adminBearer, adminId, targetId } = await arrange(); + const originalToken = adminBearer.split('.')[0]; + + expect((await impersonate(manager, adminBearer, targetId)).status).toBe(200); + + const rows = (engine.tables.get('sys_session') ?? []) as any[]; + expect(rows.some((r) => r.token === originalToken)).toBe(false); + // …and a ROTATED admin session took its place, so the admin can come back. + expect(rows.some((r) => r.user_id === adminId && r.token !== originalToken)).toBe(true); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('the restated vendor constant cannot drift', () => { + it('USER_NOT_FOUND still equals better-auth\'s own BASE_ERROR_CODES entry', async () => { + // `admin-impersonate-endpoint.ts` restates this constant instead of + // importing the `better-auth` ROOT entry, because pulling the root in there + // makes admin-plugin construction die under the suites that mock it. A + // restated constant is only safe while something proves it still matches. + const { BASE_ERROR_CODES } = await import('better-auth'); + const vendor = BASE_ERROR_CODES.USER_NOT_FOUND as { code: string; message: string }; + // Field by field: the vendor entry also carries a `toString`, so a whole- + // object `toEqual` compares a method we neither restate nor depend on. + expect(USER_NOT_FOUND.code).toBe(vendor.code); + expect(USER_NOT_FOUND.message).toBe(vendor.message); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('exactly one plugin claims the path', () => { + // The measured alternative — registering a SECOND better-auth plugin for + // `/admin/impersonate-user` — also serves, but makes 1.7.1's + // `checkEndpointConflicts` log `Endpoint path conflicts detected!` on every + // boot. Replacing the endpoint in place is what keeps that quiet, and this + // asserts the property rather than the intention. + it('boots without a better-auth endpoint-conflict error', async () => { + const errors: string[] = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + errors.push(args.map(String).join(' ')); + }); + + const { manager, adminBearer, targetId } = await arrange(); + expect((await impersonate(manager, adminBearer, targetId)).status).toBe(200); + + expect(errors.filter((e) => e.includes('Endpoint path conflicts detected'))).toHaveLength(0); + }); +}); diff --git a/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.ts b/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.ts new file mode 100644 index 0000000000..dcb647618f --- /dev/null +++ b/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.ts @@ -0,0 +1,262 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `POST /admin/impersonate-user`, re-authorized on the ADR-0068 D2 platform-admin + * predicate — as a better-auth **plugin endpoint**, never a raw Hono mount. + * + * ## Why this file is not `admin-ban-endpoints.ts` + * + * Every other ObjectStack `/admin/*` route is a raw Hono mount carrying + * `judgePlatformAdmin` (see `platform-admin-gate.ts`). Ban/unban re-implement + * that way cleanly because they are DATA WRITES: two `internalAdapter` calls + * each, nothing about the caller's own session changes. + * + * Impersonation is not a data write. It mints a session and rewrites cookies + * using helpers that exist ONLY inside a better-auth endpoint context — + * `ctx.getSignedCookie` / `ctx.setSignedCookie` against `ctx.context.secret`, + * `ctx.context.createAuthCookie`, `deleteSessionCookie(ctx)`, + * `setSessionCookie(ctx, …)`. A raw mount has none of them, so re-implementing + * there means hand-rolling better-auth's signed-cookie format. And the exact + * `admin_session` payload is a CONTRACT with `/admin/stop-impersonating`, which + * parses `adminCookie.split(':')` and answers 500 if the shape is off: a subtly + * wrong signature is either a broken exit path or a forgeable cookie. + * + * Second, independent reason — and the one no test would have caught. Shadowing + * a vendor path with a raw mount silently detaches every better-auth hook keyed + * on that path. `/admin/impersonate-user` carries one: + * `rotateCallerBearerOnImpersonation` (#8243) in `auth-manager.ts`'s global + * `hooks.after`. 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. A raw mount would reintroduce #8243 with nothing turning red. + * + * ⛔ A raw Hono mount for this route is FORBIDDEN (maintainer ruling, + * 2026-08-20). This module is the shape that ruling names. + * + * ## The measurement this shape rests on + * + * Whether better-auth 1.7.1 permits overriding a path another plugin registers + * was explicitly UNMEASURED when this was ruled. Measured now, on the installed + * `better-auth@1.7.1`: + * + * - `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. A second plugin + * registering `/admin/impersonate-user` therefore BOOTS, serves, and prints + * `Endpoint path conflicts detected!` on every start. + * - `getEndpoints` merges with `{...acc, ...plugin.endpoints}` (key-keyed), 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. + * + * So a second plugin works but is permanently noisy. This module takes the + * strictly better door the same measurement opens: it replaces the endpoint + * **on the admin plugin's own `endpoints` record**, so exactly ONE plugin ever + * registers the path. `checkEndpointConflicts` sees one entry, logs nothing, + * and the route is served by an endpoint built with `createAuthEndpoint` — + * inside the endpoint context, with every cookie helper and `$context` present, + * on the same path, so the #8243 hook still fires. + * + * ## What actually changed vs. the vendor handler + * + * The endpoint is rebuilt from the vendor endpoint's OWN `options` object + * (`method`, `body` schema, `use: [adminMiddleware, …]`, `metadata`), passed + * through untouched. So the request contract, the 401-for-anonymous, the + * OpenAPI entry and the body validation are the vendor's, and they cannot drift + * from it on a dependency bump — there is no second copy to drift. + * + * Only the AUTHORIZATION changes, in the two places the vendor asks it: + * + * 1. **Caller.** `hasPermission({ role: session.user.role, … })` → the ADR-0068 + * D2 predicate. The vendor's only two authorization inputs are a + * construction-time `adminUserIds` array and the persisted legacy `role` + * scalar; ObjectStack's platform admin is neither (it is a + * `sys_user_permission_set` row pointing at `admin_full_access` with + * `organization_id = null`), and ADR-0068 D2 forbids synthesizing the + * scalar the vendor can read. That mismatch is the whole defect: a platform + * admin and a plain member receive byte-identical + * `403 YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS`. + * + * 2. **Target.** The vendor refuses to impersonate an admin-grade target by + * reading `targetUser.role` against `adminRoles` (default `['admin']`). + * On any post-ADR-0068-D2 deployment NOTHING writes that scalar, so that + * guard is INERT — "you cannot impersonate admins" is currently a promise + * the code does not keep. It is re-asked here through the same ADR-0068 + * predicate, so it means something again. + * + * Direction matters and is asserted in both: the caller set only ever GROWS by + * platform admins (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), and the protected-target set only ever + * GROWS. Neither predicate admits anyone the vendor admitted and we now refuse, + * and neither refuses anyone the vendor admitted. + * + * ⛔ 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. + * Re-adding it would be adding a door nothing asked for. + * + * ## Refusal envelope — the vendor's, on purpose + * + * These 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. This route is a better-auth endpoint, not an ObjectStack raw mount, + * and the dogfood sweep distinguishes the two envelopes on purpose (see + * `admin-ban-endpoints.ts`). Keeping them also mints no new public error code, + * so nothing here reaches the spec error-code ledger. + */ + +import { isPlatformAdminUser } from './platform-admin-gate.js'; +import { IMPERSONATE_USER_PATH, ADMIN_SESSION_COOKIE_KEY } from './impersonation-bearer-rotation.js'; + +/** + * Answers ADR-0068 D2's "is this user id a platform admin?" — a + * `sys_user_permission_set` row pointing at `admin_full_access` with + * `organization_id = null`. + * + * Injected rather than imported so this module never reaches for a data engine + * itself. MUST fail CLOSED (resolve `false`) on any lookup error: it backs a + * security gate, and an unverifiable actor must never pass. + */ +export type PlatformAdminOracle = (userId: string) => Promise; + +/** The slice of better-auth's `admin` plugin this module rewrites. */ +export interface AdminPluginLike { + id: string; + endpoints: Record; + $ERROR_CODES?: Record; +} + +/** + * Impersonation session lifetime, seconds — the vendor's default for a plugin + * constructed WITHOUT `impersonationSessionDuration`, which is how + * `auth-manager.ts` constructs it (`admin({ schema })`). Pinned rather than + * inherited because the vendor never exposes the resolved option. + */ +export const IMPERSONATION_SESSION_SECONDS = 3600; + +/** + * better-auth's `BASE_ERROR_CODES.USER_NOT_FOUND`, restated locally. + * + * ⛔ NOT read from `import('better-auth')` — that is the package ROOT entry, and + * pulling it in here makes admin-plugin CONSTRUCTION depend on it. Several + * suites in this package mock the root (`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 then swallowed by + * `addOptionalPlugin`'s catch and silently disabled — a plugin lost to a + * constant. The subpath entries this module does import + * (`better-auth/api|cookies|db`) are not mocked anywhere and stay real. + * + * A restated constant is only safe if it cannot drift, so it does not rely on + * being remembered: `admin-impersonate-endpoint.test.ts` pins it equal to the + * vendor's own value, and a vendor rename turns that red. + */ +export const USER_NOT_FOUND = { code: 'USER_NOT_FOUND', message: 'User not found' } as const; + +/** + * Replace `admin`'s `/admin/impersonate-user` with the ADR-0068-authorized + * endpoint, in place, on the plugin's own `endpoints` record. + * + * Returns the SAME plugin object (mutated), so the plugin's id, schema, hooks, + * `$ERROR_CODES` and every other endpoint stay exactly as the vendor built + * them, and only one plugin ever claims the path. + * + * A vendor bump that renames or drops the endpoint leaves the plugin untouched + * and reports `false` — loudly handled by the caller — rather than silently + * adding a second endpoint nobody routes to. + */ +export async function applyPlatformAdminImpersonation( + plugin: AdminPluginLike, + isPlatformAdmin: PlatformAdminOracle, +): Promise { + const vendor = plugin?.endpoints?.impersonateUser; + if (!vendor || vendor.path !== IMPERSONATE_USER_PATH || !vendor.options) return false; + + const [{ createAuthEndpoint, APIError }, { deleteSessionCookie, setSessionCookie }, { parseUserOutput }] = + await Promise.all([ + import('better-auth/api'), + import('better-auth/cookies'), + import('better-auth/db'), + ]); + + const ERR = plugin.$ERROR_CODES ?? {}; + const notAllowed = ERR.YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS ?? { + code: 'YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS', + message: 'You are not allowed to impersonate users', + }; + const cannotImpersonateAdmins = ERR.YOU_CANNOT_IMPERSONATE_ADMINS ?? { + code: 'YOU_CANNOT_IMPERSONATE_ADMINS', + message: 'You cannot impersonate admins', + }; + const failedToCreate = ERR.FAILED_TO_CREATE_USER ?? { + code: 'FAILED_TO_CREATE_USER', + message: 'Failed to create user', + }; + + // The vendor's own options object — method, body schema, `use` + // (adminMiddleware: authoritative session or 401) and OpenAPI metadata — is + // handed straight back to `createAuthEndpoint`. Nothing about the request + // contract is retyped here, so nothing about it can drift. + plugin.endpoints.impersonateUser = createAuthEndpoint( + IMPERSONATE_USER_PATH, + vendor.options, + async (ctx: any) => { + const caller = ctx.context.session?.user; + const callerId = typeof caller?.id === 'string' ? caller.id : ''; + + // ── THE changed predicate (1/2): who may impersonate ───────────────── + // `isPlatformAdminUser` reads the session user we already hold — which + // inside an endpoint is the RAW row (better-auth's `adminMiddleware` + // re-reads the session from the database with the cookie cache disabled, + // so `customSession`'s derived `positions[]` / `isPlatformAdmin` are NOT + // on it). It therefore contributes only the legacy `role === 'admin'` + // back-compat reading, and the oracle does the real ADR-0068 lookup. + const callerAdmitted = + isPlatformAdminUser(caller) || (callerId ? await isPlatformAdmin(callerId) : false); + if (!callerAdmitted) throw APIError.from('FORBIDDEN', notAllowed); + + const targetUser = await ctx.context.internalAdapter.findUserById(ctx.body.userId); + if (!targetUser) throw APIError.from('NOT_FOUND', USER_NOT_FOUND); + + // ── THE changed predicate (2/2): who is protected FROM impersonation ── + // Same question the vendor asks against `adminRoles`, asked so it is not + // inert after ADR-0068 D2 stopped writing the scalar it read. + const targetId = typeof targetUser.id === 'string' ? targetUser.id : String(targetUser.id); + const targetProtected = + isPlatformAdminUser(targetUser) || (await isPlatformAdmin(targetId)); + if (targetProtected) throw APIError.from('FORBIDDEN', cannotImpersonateAdmins); + + // ── everything below is the vendor handler, unchanged ──────────────── + const session = await ctx.context.internalAdapter.createSession( + targetUser.id, + true, + { + impersonatedBy: callerId, + expiresAt: new Date(Date.now() + IMPERSONATION_SESSION_SECONDS * 1000), + }, + true, + ); + if (!session) throw APIError.from('INTERNAL_SERVER_ERROR', failedToCreate); + + const authCookies = ctx.context.authCookies; + deleteSessionCookie(ctx); + const dontRememberMeCookie = await ctx.getSignedCookie( + authCookies.dontRememberToken.name, + ctx.context.secret, + ); + const adminCookieProp = ctx.context.createAuthCookie(ADMIN_SESSION_COOKIE_KEY); + await ctx.setSignedCookie( + adminCookieProp.name, + `${ctx.context.session.session.token}:${dontRememberMeCookie || ''}`, + ctx.context.secret, + authCookies.sessionToken.attributes, + ); + await setSessionCookie(ctx, { session, user: targetUser }, true); + + return ctx.json({ + session, + user: parseUserOutput(ctx.context.options, targetUser), + }); + }, + ); + + return true; +} diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 29ecdc4224..24e74d27e8 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -36,6 +36,9 @@ import { rotateCallerBearerOnImpersonation, withBearerAdminSessionRecovery, } from './impersonation-bearer-rotation.js'; +import { + applyPlatformAdminImpersonation, +} from './admin-impersonate-endpoint.js'; import { invitationRoleCapFailure, isPlainMemberInvitation, @@ -2589,9 +2592,38 @@ export class AuthManager { // match ObjectStack's snake_case conventions (ban_reason, // ban_expires, impersonated_by). `role` and `banned` are already // snake_case-compatible. - return admin({ + const adminPlugin: any = admin({ schema: buildAdminPluginSchema(), }); + + // ADR-0068 D2 — re-authorize `/admin/impersonate-user` on ObjectStack's + // platform-admin predicate, IN PLACE on this plugin's own endpoints + // record. See `admin-impersonate-endpoint.ts` for the measurement that + // picked this shape over a second plugin (which boots and serves, but + // makes `checkEndpointConflicts` log an error on every start) and over + // a raw Hono mount (forbidden: hand-rolled signed cookies, and it would + // silently detach the #8243 rotation hook keyed on this path). + // + // Every OTHER better-auth-native `/admin/*` route still gates on the + // legacy scalar and still refuses platform admins — that is the parent + // card's remaining surface, deliberately untouched here. + const rewired = await applyPlatformAdminImpersonation( + adminPlugin, + (userId: string) => this.isPlatformAdminUserId(userId), + ); + if (!rewired) { + // The vendor renamed or dropped the endpoint. Say so loudly: the + // route then falls back to the vendor's own handler, which refuses + // every platform admin — a broken button, not an open door. + console.error( + '[AuthManager] better-auth\'s admin plugin no longer exposes a ' + + '`impersonateUser` endpoint at /admin/impersonate-user, so the ' + + 'ADR-0068 platform-admin authorization could NOT be applied. ' + + 'Impersonation will refuse every ObjectStack platform admin until ' + + 'admin-impersonate-endpoint.ts is updated for the new vendor shape.', + ); + } + return adminPlugin; }); } @@ -4319,6 +4351,47 @@ export class AuthManager { return null; } + /** + * ADR-0068 D2, asked on its own: is `userId` a PLATFORM admin — a + * `sys_user_permission_set` row pointing at the `admin_full_access` + * permission set with `organization_id = null` (seeded by + * `bootstrapPlatformAdmin`)? + * + * Deliberately NARROWER than {@link isOrgOrPlatformAdmin}: it does not admit + * organization owners/admins. Platform-admin routes must not be reachable by + * whoever happens to own an org (ADR-0068), so the two questions stay two + * methods. + * + * Reads through `withSystemReadContext` so the lookups are not themselves + * RLS-scoped to the acting — possibly non-privileged — user, and fails CLOSED + * (returns false) on any lookup error: this backs a security gate, and an + * unverifiable actor must never pass. + */ + private async isPlatformAdminUserId(userId: string): Promise { + if (!userId) return false; + const engine = this.getDataEngine(); + if (!engine) return false; + try { + const sys = withSystemReadContext(engine); + const links = await sys.find('sys_user_permission_set', { + where: { user_id: userId }, + limit: 50, + }); + const platformLinks = (Array.isArray(links) ? links : []).filter( + (l: any) => !l.organization_id, + ); + if (platformLinks.length === 0) return false; + const sets = await sys.find('sys_permission_set', { limit: 50 }); + const adminSet = (Array.isArray(sets) ? sets : []).find( + (r: any) => r.name === 'admin_full_access', + ); + if (!adminSet) return false; + return platformLinks.some((l: any) => l.permission_set_id === adminSet.id); + } catch { + return false; + } + } + /** * True when `userId` is a platform admin (a `sys_user_permission_set` row * pointing at `admin_full_access` with `organization_id = null`) OR an diff --git a/packages/plugins/plugin-auth/src/impersonation-bearer-rotation.test.ts b/packages/plugins/plugin-auth/src/impersonation-bearer-rotation.test.ts index c4efabc696..dad565c567 100644 --- a/packages/plugins/plugin-auth/src/impersonation-bearer-rotation.test.ts +++ b/packages/plugins/plugin-auth/src/impersonation-bearer-rotation.test.ts @@ -38,7 +38,11 @@ import { * is pinned to ObjectQL's own dispatch predicate. The delete path is genuinely * exercised here: rotation deletes the admin's original session row. */ -const createMemoryEngine = () => { +// Exported so the ADR-0068 impersonation-authorization suite +// (`admin-impersonate-endpoint.test.ts`) drives the SAME double rather than +// minting a second one: a new fake engine would be a new looseness risk and a +// new `check:engine-double-contract` ledger entry, for no added fidelity. +export const createMemoryEngine = () => { const tables = new Map(); const rows = (name: string) => { if (!tables.has(name)) tables.set(name, []); From 16f43973bad703f6793b116f3c74df8bc5c7b5f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 15:57:34 +0000 Subject: [PATCH 2/4] test(plugin-auth): record why the impersonation suite reuses the sibling 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 Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx --- .../src/admin-impersonate-endpoint.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.test.ts b/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.test.ts index d271969500..efde462447 100644 --- a/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.test.ts +++ b/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.test.ts @@ -29,6 +29,18 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity'; import { AuthManager } from './auth-manager'; +// ⚠️ Imported from a sibling TEST file on purpose, and the cost is measured, not +// missed: importing a `.test.ts` re-registers its `describe`s here, so that +// file's 10 rotation tests also execute inside this one. The alternatives are +// worse. Minting a second engine double is a second looseness risk plus two new +// `check:engine-double-contract` ledger entries, when this suite needs no +// fidelity that double does not already have. Extracting it to a plain `.ts` +// helper would REMOVE it from that gate's sight entirely — the gate discovers +// doubles by walking `*.test.ts` only (`scripts/check-engine-double-contract.mjs` +// `walk()`), so the pinned entries would go stale and the double would end up +// scanned by nobody. And a suite-free `*.test.ts` fixture file is not available +// either: this package runs a bare `vitest run`, with no `--passWithNoTests`. +// Ten duplicated hermetic tests is the cheapest of the four. import { createMemoryEngine } from './impersonation-bearer-rotation.test'; import { ADMIN_SESSION_RECOVERY_RESPONSE_HEADER } from './impersonation-bearer-rotation'; import { USER_NOT_FOUND } from './admin-impersonate-endpoint'; From 634f692119bdccda6bc438d313f3c1e580666742 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 04:18:57 +0000 Subject: [PATCH 3/4] chore(devx): declare the ruled vendorWire entry for admin-impersonate-endpoint.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 6abc4df03 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 Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx --- scripts/check-route-envelope.mjs | 39 ++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index 963403101a..086561e435 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -756,16 +756,37 @@ const PLUGIN_ROUTE_MODULES = { 'pre-auth bootstrap, ruled outside BaseResponseSchema by design (2026-08-17, #9389 option B): /bootstrap-status is polled by the Account SPA to choose between /login and first-run /setup, by a caller that has no credential to authenticate with yet. 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', }, - // ── Ruled vendor wire format (2026-08-21, #10554): no entries yet ──────── + // ── Ruled vendor wire format (2026-08-21, #10554) ──────────────────── // - // The state exists ahead of its first entry, deliberately: this machinery - // landed from `main` while the adjudicated body (better-auth's - // `{ session, user }` in plugin-auth's reimplemented impersonation handler) - // exists only on PR #10352's branch — and an entry for a file the walk - // cannot find is an ERROR here (the declared-but-not-found reconciliation - // in `audit()`), so the entry lands WITH the file, on that PR, under the - // ruling that authorized exactly that one entry. Adding any entry to this - // state is ⛔ MAINTAINER-ONLY (#8435) — see the header. + // The state's first and only entry, landing WITH the file it was ruled on + // (PR #10352) — the machinery arrived from `main` ahead of it because an + // entry for a file the walk cannot find is an ERROR here (the + // declared-but-not-found reconciliation in `audit()`). The ruling authorized + // exactly this one entry; adding or widening any other is + // ⛔ MAINTAINER-ONLY (#8435) — see the header. + + // ONE body — the success return of `POST /admin/impersonate-user`, at line + // 254: `ctx.json({ session, user })`. It is the ONLY body this file BUILDS + // that any counter here reads; the four refusals are + // `throw APIError.from(…)`, which this surface does not count — so the + // number is the whole visible departure, not a sample of it. + // + // The shape is not this repo's to change. The endpoint replaces + // better-auth's own handler in place on the `admin` plugin's `endpoints` + // record and passes the vendor's OpenAPI `metadata` through untouched — a + // published schema declaring exactly `{ session, user }` — so enveloping + // the body would contradict the schema this same endpoint serves, on top of + // breaking the vendor client that parses it and forking from a + // contract partner that lives entirely on the vendor's side. + 'packages/plugins/plugin-auth/src/admin-impersonate-endpoint.ts': { + unenveloped: 1, + vendorWire: + "better-auth's wire format, ruled outside BaseResponseSchema (2026-08-21, #10554 option A)", + note: + 'vendor: better-auth (1.7.1) — the body is byte-identical to that release\'s own handler return; ' + + 'reader: authClient.admin.impersonateUser — the vendor client that parses this shape; ' + + 'partner: /admin/stop-impersonating — the contract partner answering the same bare shape, entirely vendor-side', + }, }; const EXPRESS_RESPONSE_RECEIVERS = new Set(['res']); From 7a376ca54d75de9b715206800c962296c9456142 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 04:31:56 +0000 Subject: [PATCH 4/4] chore(runtime): classify the four better-auth impersonation codes as 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 Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx --- .../src/dispatcher-error-vocabulary.ts | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index bc74ad1191..cd15cec640 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -308,6 +308,103 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ '`deps.error(INTERNAL_ERROR_MESSAGE, 500)` — unconditionally, never `errorFromThrown` (#5085). ' + 'So the string never lands in an ADR-0112 `error.code`.', }, + // ── [#10352] better-auth's OWN vocabulary, now restamped in-repo ─────── + // + // These four became visible to this scan for the same reason the success + // body became visible to `check-route-envelope`: #9968 reimplements the + // vendor's `/admin/impersonate-user` handler in this repo — in place, on + // the `admin` plugin's own `endpoints` record — so ObjectStack's ADR-0068 + // platform admin can pass it. Codes that were previously RELAYED from + // inside `node_modules` are now stamped by a literal this repo builds. The + // refusal SET did not change: three of the four are read at runtime off + // `plugin.$ERROR_CODES` and the quoted literal beside each is only the + // fallback spelling for a vendor bump that drops the key. + // + // Why they cannot reach an ADR-0112 `error.code`, verified rather than + // inherited: they are thrown as better-auth's `APIError` from inside a + // better-auth ENDPOINT, and better-auth answers its own failures with a + // `Response` instead of throwing. `AuthManager.handleRequest` returns that + // `Response` untouched (it only LOGS `status >= 500`) and + // `domains/auth.ts` passes it on as `{ handled: true, result: response }`, + // so `errorFromThrown` is never reached. The other direction is closed by + // that same file: anything the auth service DOES throw is answered + // `deps.error(INTERNAL_ERROR_MESSAGE, 500)` with a status-derived code, + // unconditionally (#5085). Same route the IMPERSONATION_ROTATION_FAILED + // and YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_MEMBER rows already document. + // + // ⚠️ `door: 'none'` here records `none of the three ADR-0112 doors`, NOT + // `invisible`: each refusal IS served to the client, in the vendor's flat + // `{ message, code }` shape. That this endpoint's bodies are the vendor's + // wire rather than this repo's envelope is not inferred here — it is the + // 2026-08-21 maintainer ruling (#10554), carried in `check-route-envelope` + // as the `vendorWire` entry for this same file. + // + // ⛔ `pending-registration` would be FALSE for all four. That verdict says + // 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 the ledger member + // outliving its only producer. + { + code: 'YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS', + file: 'packages/plugins/plugin-auth/src/admin-impersonate-endpoint.ts', + shape: 'objlit', + door: 'none', + verdict: 'foreign-vocabulary', + why: + "better-auth 1.7.1's own admin-plugin vocabulary — verified in the installed vendor at " + + '`dist/plugins/admin/error-codes`, spelled there exactly as it is here — and read at runtime ' + + 'off `plugin.$ERROR_CODES`, never retyped. The caller-side refusal, raised ' + + "`APIError.from('FORBIDDEN', notAllowed)` inside a better-auth endpoint, so it leaves as the " + + "vendor's own `Response` and never as a throw this repo classifies. See the section note " + + 'above for the measured path and for why registering a vendor constant would be false.', + }, + { + code: 'YOU_CANNOT_IMPERSONATE_ADMINS', + file: 'packages/plugins/plugin-auth/src/admin-impersonate-endpoint.ts', + shape: 'objlit', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'The target-side twin of the row above, from the same better-auth 1.7.1 file ' + + "(`dist/plugins/admin/error-codes`), likewise read off `plugin.$ERROR_CODES` and raised " + + "`APIError.from('FORBIDDEN', cannotImpersonateAdmins)`. #9968 makes it reachable for the " + + "first time — the vendor gated it on the legacy `user.role` scalar nothing writes post " + + "ADR-0068 D2, so the vendor's own promise was inert — but reachable in the vendor's wire " + + "shape under the vendor's spelling, which changes nothing about whose vocabulary it is.", + }, + { + code: 'FAILED_TO_CREATE_USER', + file: 'packages/plugins/plugin-auth/src/admin-impersonate-endpoint.ts', + shape: 'objlit', + door: 'none', + verdict: 'foreign-vocabulary', + why: + "better-auth 1.7.1's BASE_ERROR_CODES member, surfaced through the `admin` plugin's " + + "`$ERROR_CODES` and raised `APIError.from('INTERNAL_SERVER_ERROR', failedToCreate)` when the " + + 'impersonation session cannot be minted. Same vendor, same endpoint and same wire as the two ' + + 'rows above.', + }, + { + code: 'USER_NOT_FOUND', + file: 'packages/plugins/plugin-auth/src/admin-impersonate-endpoint.ts', + shape: 'objlit', + door: 'none', + verdict: 'foreign-vocabulary', + why: + "better-auth 1.7.1's BASE_ERROR_CODES member, raised `APIError.from('NOT_FOUND', " + + 'USER_NOT_FOUND)`. The one of the four NOT read off `$ERROR_CODES`, for a reason written at ' + + 'its declaration site: it lives on the ROOT `better-auth` entry, and importing that entry ' + + 'makes admin-plugin CONSTRUCTION depend on a module several suites in this package ' + + "`vi.mock`, where vitest throws on a missing export and `addOptionalPlugin`'s catch then " + + 'swallows the whole `admin` plugin — measured. So it is restated as a local constant and ' + + "`admin-impersonate-endpoint.test.ts` pins it equal to the vendor's own " + + '`BASE_ERROR_CODES.USER_NOT_FOUND`. That pin is what keeps the restatement from drifting ' + + "into a code this repo owns by accident: it is still the vendor's string on the vendor's " + + 'wire, and a vendor rename turns the pin red rather than silently minting a local code.', + }, + { code: 'OS_METADATA_CONVERTED', file: 'packages/spec/src/conversions/apply.ts',