Skip to content

Commit 7d3c013

Browse files
refactor(plugin-auth): move AuthManager's request routing into auth-manager-request-routing.ts (#25224 cut 10) (#25508)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuMjbPDrsigsyqoKsV4WZQMz)_ Part of #25224 Clause-②: no Metadata-protocol: none Ruling-ref: 6348891160, 6364006498 The basis for the `Clause-②: no` line: this is a move. No accept set, route, refusal, code or answer changes, and the published surface is measured unchanged: - `@objectstack/plugin-auth`'s `dist/index.d.ts` and `dist/index.d.mts` are raw byte-identical to base at head (0 diff lines, so the union-order normalisation has nothing to do). The positive control is the one ruling `6348891160` asks for. - Its ESM and CJS entries export the same 159 names at base and at head. The `.d.ts` bar is ruling `6348891160` item 1 (on #25222, covering this card). Ruling `6364006498` is Sales's ruling A on this card, for bodies that read shared unexported helpers. This is the second cut it applies to. Cut 10 of the `auth-manager.ts` split: **request routing.** Three `AuthManager` method bodies (226 lines) move unedited, re-indented one level, into one new module, `packages/plugins/plugin-auth/src/auth-manager-request-routing.ts`. #25224 stays open for the later cuts. This follows the amended claim `6369962162`, which amends `6367866058` on the file cut 9 left (#25461, landed note `6369059246`). The shape is cuts 1 to 9's, which is #23050's. Branch: `claude/project-thread-lg3wrj`, the harness-pinned branch for this session, restarted from `origin/main` `05cc3ee953` after #25461 merged. It names no issue, so the card relation is this body's first line. `auth-manager.ts` goes from 8,711 to 8,521 lines. The cut is +361 / −233 over 2 files: 594 changed lines, under the 3,000-line line. It is not a governed surface (`check-governed-merges.mjs --branch HEAD`: 0 of the 2 paths hit the register). One commit, `27f04eaa1c`. ## The move | moved | base line | lines | members it reads | |---|---:|---:|---| | `handleRequest` (public) | `:6369` | 200 | `betterAuthEndpointPath`, `getOrCreateAuth`, `resolveClientAddress`, `runSubjectErasureAtomically` | | `ownsRoute` (public) | `:6739` | 14 | `betterAuthEndpointPath`, `getOrCreateAuth`, and `routeOwnership`, `routeOwnershipFor` (read and written) | | `betterAuthEndpointPath` (private) | `:6766` | 12 | `getBasePath` | Lines are base `05cc3ee953`. The members come from a walk of every `this.X` in each body with the TypeScript compiler API. No body has a bare `this`, `this[...]`, `arguments` or `super`, or a `this` inside a `function` or a nested class. The members a body assigns are `routeOwnership` and `routeOwnershipFor` (`ownsRoute`). - **`handleRequest`** is the one seam every better-auth request passes through on its way to the vendor handler and back. The client address is established once, then come the vendor header, the dev-only `Origin`, the admin-session recovery out of impersonation, the request-state and actor-attribution scopes, the SCIM scope, the atomic erasure, the two refusal envelopes and the server-error log. It reassigns its own `request` parameter, as before. Its two `await import(…)` calls (`@better-auth/core/context`, `./scim-connection-service.js`) and its two `console.error('[AuthManager] better-auth returned error:', …)` lines move with it, text unchanged. - **`ownsRoute`** answers whether better-auth routes a request at all, from a memoized ownership table keyed on the live instance. - **`betterAuthEndpointPath`** is the endpoint path both key on. **`isScimProtocolPath` is exported in place** (ruling `6364006498`, parameter 2): - `:1240` becomes `export function isScimProtocolPath(…)`. That is the only edit to text that stays. - It does not move. `docs/adr/0134-env-side-scim-provisioning.md:122` anchors it as `auth-manager.ts#isScimProtocolPath`, beside `#SCIM_PROTOCOL_PATH_PREFIX` (`:121`), which it reads and which stays too. - The new module imports it by value from `./auth-manager.js`, beside `type AuthManager`. The one read is inside `handleRequest`'s body, when a request is handled. The module's top level is imports, the host interface and three function declarations, so nothing reads the binding while the two modules are first evaluated. - `index.ts` lists `auth-manager.ts`'s published names one by one (cut 9) and does not list it, so it is not published. The head's ESM and CJS entries do not export it, and `dist/index.d.ts` does not name it. **Imports.** - Ten imported names leave `auth-manager.ts`, because their only readers were the moved bodies: `runWithAuthActorScope`, `SESSION_ERASURE_PATHS`, `envelopeVendorAdminRefusal`, `refuseAnonymousSession`, `buildBetterAuthRouteOwnership`, `ADMIN_SESSION_COOKIE_KEY`, `STOP_IMPERSONATING_PATH`, `withBearerAdminSessionRecovery`, `withVendorClientAddress` and `clientAddressStateEntries`. The lines that also carry a name that stays keep it (`setAuthActorResolver`, `type BetterAuthRouteOwnership`, `rotateCallerBearerOnImpersonation`, `vendorClientAddressOptions`, `readRequestClientAddress`). The three single-name lines go. - `type BetterAuthRouteOwnership` stays for the `routeOwnership` field. - `ORGANIZATION_DELETE_PATH` is imported by both files, because the global before-hook in `auth-manager.ts` (`:2379`) reads it too. **Evaluation order.** - `session-tombstone.js` is still first reached earlier, through `objectql-adapter.js` (`:70`). - `vendor-admin-refusal-envelope.js` and `anonymous-session-refusal.js` have no other importer, so they are now first reached through the new module, whose import line is `auth-manager.ts`'s last. Each one's top level is literal constants, one `new Set([401, 403])`, function declarations and two value imports: - `./platform-admin-gate.js`, which an earlier import line of `auth-manager.ts` already reaches (`admin-impersonate-endpoint.js`, `:95`, imports it by value); - `@objectstack/spec/api`, which nothing else in `plugin-auth` imports by value. So its first load moves later too: in the CJS bundle its first `require` goes from line 4,372 to 7,535, and ESM hoists it either way. - `@objectstack/spec` declares `"sideEffects": false`, and neither module does anything outside itself, so the later order cannot be observed. ## The shape - **The class keeps all three methods** at their places, with their names, signatures and docblocks: the published `.d.ts` lists each one. Each body is now a host literal of exactly the members that body reads, typed as a `Pick` of `RequestRoutingHost`, plus `.call`. - **`RequestRoutingHost`** lives in the new module. It names the 7 members the bodies read, each typed as the class member (`AuthManager['getBasePath']`, …). - **Every method goes as a late-bound closure** (`(...a) => this.m(...a)`), so a double or spy set on the manager is still the one a body reaches. `handleRequest` and `ownsRoute` ask `betterAuthEndpointPath` through the manager rather than calling the function directly. - **`routeOwnership` and `routeOwnershipFor` go as get/set pairs.** `ownsRoute` stores the table it builds, and the instance it built it from, on the manager. So the memo stays the manager's, and it is rebuilt when that instance is replaced, as before. The two private fields stay on the class. - **`handleRequest` and `ownsRoute` are no longer `async`.** Each returns the moved async function's own promise, so a caller awaits the same promise the body settles. Building the host cannot throw, so neither now throws synchronously where it used to reject. `betterAuthEndpointPath` was not `async` and stays so. - **The cost:** small host objects on every better-auth request. `handleRequest` builds one, and each of its two `betterAuthEndpointPath` calls through the manager builds one more, so three per request; `ownsRoute` builds two. - **Nothing re-exports the new module**, so the published surface does not move. ## The decision-letter layer drops by one, as agreed with the seat `handleRequest`'s first comment cites `ADR-0069 D7`. `auth-manager.ts` is an anchored file and the new module is not. So `check-adr-anchors`' decision-letter layer reads **1,978 at base `05cc3ee953` and 1,977 at head**, and the gate exits 0 at both. That is the one letter citation in the moved text. Its other ADR citation is `ADR-0112`, with no letter. No anchor entry is added for the new module. The `auth-manager.ts` entry has two paragraphs (ADR-0093 D2, the membership reconciler; ADR-0108, the organization roles), and neither describes a moved method. The new module's header cites no `ADR-NNNN`. So the number layer reads 52,223 citations at both, across 6,651 files at base and 6,652 at head (the new module). ## Path readers No path reader needs migrating: none reads a moved line. - **The tenant-audit census.** No census site moves: the moved text has no engine write, no `this.X.delete()` and no elevation read. `check-tenant-audit-census` exits 0 at head with no `--write`. There are 238 sites at base and at head, compared per site with line numbers ignored: 0 differences, 2 in `auth-manager.ts` at both, and 0 in the new module. So neither census document is edited. - **`check-dispatcher-error-vocabulary.mjs`** and **`api-error-code.pin.test.ts`'s `authManagerFiles` floor**: no `new APIError(` sits in the moved text. The gate reads 56 sites found and 56 classified at base and at head. - **The plugin-factory scans** in `better-auth-schema-parity.test.ts` and `managed-extension-fields.test.ts` match `await import('better-auth/plugins…')` and `await import('@better-auth/…')` plugin factories. The moved `await import('@better-auth/core/context')` is not a plugin factory, and both suites pass at head. - **`check-auth-mount-ledger.mjs`** and **`auth-manager-base-path.test.ts`**: no base-path literal or declaration sits in the moved text. `getBasePath`, `configuredBasePath` and `rootedBasePath` stay on the class. - **The system-context census** reads 148 elevation read sites at base and at head. - **Prose that names the moved methods** (`better-auth-route-ownership.ts:103`, `anonymous-session-refusal.ts:89`, `vendor-admin-refusal-envelope.ts:83`, comments in `auth-manager.ts`) names `AuthManager.betterAuthEndpointPath` or `handleRequest`, which stay on the class. - **Callers through the manager.** These suites drive the three methods through the class methods, and all pass at head: - `auth-catchall-fallthrough.test.ts` and `auth-catchall-yield.test.ts` (route ownership and the #4088 yield); - `auth-manager-base-path.test.ts` (the endpoint path under every configured spelling); - `oidc-discovery-sync-mount.test.ts`; - `scim-transaction-scope.test.ts` (the SCIM scope around the request). - **Every symbol anchor into the file.** Before the cut, every `auth-manager.ts#symbol` anchor in the tracked tree outside changelogs was mapped with `symbolSegmentResolution` from `scripts/symbol-anchors.mjs`. There are 29, and all 29 are `declaration`-class at base, with the moved bodies blanked, and at head. None names a moved method. Two name a token the moved text carries, and each resolves on the declaration that stays: - `#isScimProtocolPath` (ADR-0134 `:122`; the moved `handleRequest` calls it); - `#AuthManager` (ADR-0135 `:104`; the moved log text spells `[AuthManager]`). ## Proof Builds and suites ran through `os-verify-lock`, and every exit code was captured before any pipe. Base `05cc3ee953`, head `27f04eaa1c`. A full `turbo run build` ran first. Then `plugin-auth` was built and its suite run at base, and again at head, on the same dependency dists. **1. The moved text.** - For each of the 3 methods, the base class body and the new module's function body are the same TypeScript token sequence (scanner tokens, kind and text, trivia skipped, each template literal re-scanned at its substitutions as the parser does). - Every line is the base line minus exactly two leading spaces, and the parameters and return type are the same text. - Each class method keeps its docblock and signature, with `async` dropped from `handleRequest` and `ownsRoute`. - Result: 3 of 3 pass, 226 body lines. | moved | lines | tokens | |---|---:|---:| | `handleRequest` | 200 | 465 | | `ownsRoute` | 14 | 90 | | `betterAuthEndpointPath` | 12 | 86 | - **Negative control:** one token changed in the module (`'production'` → `'productio'` in `handleRequest`) makes that method fail (tokens and lines DIFFER), and the run exits 1. The module was restored afterwards, and `cmp` against the saved copy was clean. - **An independent reading:** `git diff --color-moved=plain --color-moved-ws=allow-indentation-change 05cc3ee 27f04ea` over the two source files marks 226 of the 233 deleted lines and 236 of the 361 added lines as moved. - The other 7 deleted lines are the two `async` signature lines, `function isScimProtocolPath(` (now `export function`), three one-line imports that keep a name that stays, and the `buildBetterAuthRouteOwnership,` line of a fourth. - The other added lines are the stubs' host literals, `RequestRoutingHost`, the module header, the per-function pointer docblocks and the import blocks. **2. `.d.ts` and exports (ruling `6348891160` items 1 and 2).** | file | base | head | raw diff lines | |---|---|---|---:| | `dist/index.d.ts` raw | `b85cb81c24e981304d02184a9ef1294be702aa7aa9df1d327f51e66be9dcfd33` | same | 0 | | `dist/index.d.ts` normalised | `c86ed39db60c3929cf287b59d6cc9aa1a2505ccbfbaa23a624af35a3fcd11abb` | same | | | `dist/index.d.mts` raw | `3561220e444c82fbf102db534ec2a274041db507ed9e38785c0993ca92b84f70` | same | 0 | | `dist/index.d.mts` normalised | `6165644e3dfd1d779991e9bd63ceac3b9ab3dc47d910776ac5a88b4293ef6de5` | same | | - The normaliser, verbatim (the ruling's one-liner): `node -e 'const s=require("fs").readFileSync(process.argv[1],"utf8");process.stdout.write(s.replace(/"[^"\n]*"(?:\s*\|\s*"[^"\n]*")+/g,r=>r.split(/\s*\|\s*/).sort().join(" | ")))' dist/index.d.ts | sha256sum`. - The raw files are already byte-identical, so no union-order swap occurs in this cut: items 1(b) and 1(d) hold with nothing to account for. - **Positive control:** the head `dist/index.d.ts` with one private member made public (`private betterAuthEndpointPath;` → `betterAuthEndpointPath;`, line 2999, a one-line change) hashes raw `b2c8e7ab8a562e54b2698e5eedf0fa8493704a880dd7e2f54ceac8c6963e1190` and normalised `67551d11baf0aa0d506ae5934c99f6148ab638c4cc856ab2e8ad918b28c38b56`. Both differ from the readings above. - **Export lists:** ESM `dist/index.mjs` and CJS `dist/index.js` each have 159 names at base and at head, the same list (the sha256 of the sorted names is `3abb126f5babb701…` on all four). `isScimProtocolPath`, `handleRequest`, `ownsRoute` and `betterAuthEndpointPath` are not among them, and `dist/index.d.ts` names `isScimProtocolPath` and `RequestRoutingHost` nowhere. - **The bundle:** 18 files in `dist/` at both, the same list. Only `index.js`, `index.mjs` and their two `.map` files differ, as a move of code inside the bundle must. **3. Tests and lint.** - `pnpm exec vitest run --maxWorkers=3` in `packages/plugins/plugin-auth`: base `Test Files 189 passed (189)`, `Tests 3580 passed | 10 skipped (3590)`, exit 0. Head: the same counts, exit 0. - `pnpm --filter @objectstack/plugin-auth typecheck` (both `tsc` projects and `check:test-typecheck`): exit 0. `pnpm --filter @objectstack/runtime typecheck`: exit 0. - `eslint --no-inline-config` (the root `lint` script's binary and flags) over the 2 source files: exit 0. **4. The censuses, the measurement and the anchors, at base and at head.** - The tenant-audit census, as above: 238 sites at both, 0 per-site differences, 0 unresolved and 0 unledgered at both, and every total the same. Only `scannedSources` (730 → 731, the new module) and `measuredAt` differ. - `scripts/measure-durability-swallow-family.mjs` (a measurement, not a gate): 74 members and 88 quiet rows at both, the same with line numbers ignored. Two statistics move: - the scanned file count, 3,454 → 3,455 (the new module); - the `droppedNotAwaited` diagnostic, 245 → 242 (`delete` 158 → 156, `update` 25 → 24). Measured with an instrumented scratch copy, never committed: at base, `ownsRoute`'s `try` (`:6742`) followed `this.getOrCreateAuth()` into its same-file body and met three un-awaited write-shaped calls there (`:5544`, `:5742`, `:6961`). In the new module that `try` cannot follow the call into another file, so it no longer meets them. They were dropped at base too, so no member moves. The other `try` that reaches the same three calls (`:5097` at base) still does. - These checkers were run by hand at base and at head, because `dispatch-gates` does not derive the ADR one. All six exit 0 at both: - `check-adr-symbol-anchors`: 2,384 anchors across 142 records, 466 symbol (440 declaration, 26 literal), at both; - `check-adr-anchors`: 81 anchored files; the decision-letter layer reads 1,978 and 1,977, as above; - `check-scripts-symbol-anchors`: 4,070 anchors at both; - `check-spec-docblock-symbol-anchors`: 5,075 anchors at both (both of these read in the same built tree); - `check-system-context-census`: 148 elevation read sites at both; - `check-platform-checklist`: 16 areas, 329 items at both. **5. Gates.** - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 53 commands for the 2 paths. All 53 ran at `27f04eaa1c` after the full build, and all 53 exited 0. - `--ran` over the record with each exit code: 53 derived, 53 run, 0 NOT-MEASURED, a derived zero. - `check-governed-merges.mjs --branch HEAD`: NOT governed, 594 changed lines. - **Docs drift** (`node scripts/docs-audit/affected-docs.mjs --json 05cc3ee`, exit 0): 2 pages, `content/docs/kernel/contracts/auth-service.mdx` and `content/docs/permissions/authentication.mdx`. Both name `handleRequest` as the `IAuthService` contract member, which the class method still is, with the same signature. So no doc edit is owed. 0 release-owned pages. - **Serial constraints** (in the claim): 19 PRs are open, and none touches this cut's files, the ten modules the new one imports, or a reader above. `origin/main` moved to `d44c8936b1` (#25455, the console pin) before the push, and `git merge-tree` of this branch onto it is clean. ## Changeset None: `skip-changeset`, per ruling `6348891160` item 4. The published declarations and export lists are unchanged, and the bundle's bytes move only because code moved inside it. --- _Generated by [Claude Code](https://claude.ai/code/session_01LSXBc2GCBwiAL7vV4kWkyr)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01LSXBc2GCBwiAL7vV4kWkyr Co-authored-by: Claude <noreply@anthropic.com>
1 parent fa06169 commit 7d3c013

2 files changed

Lines changed: 361 additions & 233 deletions

File tree

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25224 cut 10] The request routing of `AuthManager` — the one seam every
5+
* better-auth request passes through on its way to the vendor handler and
6+
* back, the question of whether better-auth routes a request at all, and the
7+
* endpoint path both of them key on — moved out of `auth-manager.ts` whole.
8+
*
9+
* A MOVE, not a redesign. Each function below is the body of the
10+
* `AuthManager` method of the same name as it stood, re-indented one level
11+
* and otherwise unedited: the same client address established once, the same
12+
* vendor header and dev-only `Origin`, the same admin-session recovery on the
13+
* way out of impersonation, the same request-state and actor-attribution
14+
* scopes, the same SCIM scope and atomic erasure, the same refusal envelopes
15+
* and the same server-error log, the same ownership table and its memo.
16+
*
17+
* ## How the moved bodies reach the class
18+
*
19+
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
20+
* header carries the full reasoning), which `auth-manager-actor-guards.ts`,
21+
* `auth-manager-sign-in-lockout.ts`, `auth-manager-password-session-policy.ts`,
22+
* `auth-manager-database-hooks.ts`, `auth-manager-email-sms-delivery.ts`,
23+
* `auth-manager-plugin-assembly.ts`, `auth-manager-database-secret.ts`,
24+
* `auth-manager-membership-admission.ts` and `auth-manager-mcp-access-token.ts`
25+
* follow, applied per method. The class keeps all three methods — the
26+
* published `dist/*.d.ts` lists each one — and each body is one literal of
27+
* exactly the {@link RequestRoutingHost} members that method reads, plus a
28+
* `.call`. `handleRequest` and `ownsRoute` are no longer `async`: each returns
29+
* the moved function's own promise, so a caller awaits the same promise the
30+
* body settles, with no extra wrapper.
31+
*
32+
* A method goes as a closure that calls it on the manager at call time, so a
33+
* test double or spy set on the manager is still the one a body reaches —
34+
* `betterAuthEndpointPath` included, which `handleRequest` and `ownsRoute` ask
35+
* through the manager rather than calling the function here directly.
36+
* `routeOwnership` and `routeOwnershipFor` go as get/set pairs, because
37+
* `ownsRoute` stores the table it builds, and the instance it built it from,
38+
* on the manager: the memo stays the one the manager holds, rebuilt when that
39+
* instance is replaced, as before.
40+
*
41+
* `isScimProtocolPath` stays: the SCIM provisioning decision record anchors
42+
* it, and the prefix it reads, on `auth-manager.ts`. So that file exports it
43+
* and this module imports it by VALUE, for one read inside `handleRequest`,
44+
* when a request is handled. `index.ts` lists `auth-manager.ts`'s published
45+
* names one by one, so that export is not published. The two modules import
46+
* each other, and that is safe because nothing reads the binding while they
47+
* are first evaluated: this module's top level is imports, an interface and
48+
* function declarations.
49+
* `ORGANIZATION_DELETE_PATH` is imported by both modules, because the
50+
* before-hook in `auth-manager.ts` reads it too.
51+
*
52+
* Nothing re-exports this module, so the published surface does not move.
53+
*/
54+
55+
import { refuseAnonymousSession } from './anonymous-session-refusal.js';
56+
import { runWithAuthActorScope } from './auth-actor-attribution.js';
57+
import { isScimProtocolPath, type AuthManager } from './auth-manager.js';
58+
import { buildBetterAuthRouteOwnership } from './better-auth-route-ownership.js';
59+
import { withVendorClientAddress } from './client-address-vendor.js';
60+
import {
61+
ADMIN_SESSION_COOKIE_KEY,
62+
STOP_IMPERSONATING_PATH,
63+
withBearerAdminSessionRecovery,
64+
} from './impersonation-bearer-rotation.js';
65+
import { ORGANIZATION_DELETE_PATH } from './organization-delete.js';
66+
import { clientAddressStateEntries } from './request-client-address.js';
67+
import { SESSION_ERASURE_PATHS } from './session-tombstone.js';
68+
import { envelopeVendorAdminRefusal } from './vendor-admin-refusal-envelope.js';
69+
70+
/**
71+
* The `AuthManager` members the functions below read — every one of them, and
72+
* no other; each function's `this` is the `Pick` of the members IT reads. Each
73+
* is typed AS the class member (an indexed access on the class), so a change
74+
* to a member's signature on the class is a change here too, and naming a
75+
* member the class does not have is a compile error. See the module header for
76+
* why `routeOwnership` and `routeOwnershipFor` are accessors.
77+
*/
78+
export interface RequestRoutingHost {
79+
routeOwnership: AuthManager['routeOwnership'];
80+
routeOwnershipFor: AuthManager['routeOwnershipFor'];
81+
betterAuthEndpointPath: AuthManager['betterAuthEndpointPath'];
82+
getBasePath: AuthManager['getBasePath'];
83+
getOrCreateAuth: AuthManager['getOrCreateAuth'];
84+
resolveClientAddress: AuthManager['resolveClientAddress'];
85+
runSubjectErasureAtomically: AuthManager['runSubjectErasureAtomically'];
86+
}
87+
88+
/** The body of `AuthManager#handleRequest`; the method's docblock there is its contract. */
89+
export async function handleRequest(this: Pick<RequestRoutingHost, 'betterAuthEndpointPath' | 'getOrCreateAuth' | 'resolveClientAddress' | 'runSubjectErasureAtomically'>, request: Request, transport?: { remoteAddress?: string }): Promise<Response> {
90+
// ADR-0069 D7 — the client address this request's sign-in audit stamps,
91+
// established ONCE, here, by the same trust rule the D5 allow-list judges
92+
// with (`resolveClientAddress`). Read before any rewrite below, and carried
93+
// to the after-hook in better-auth's request state (`request-client-address.ts`).
94+
const clientHeaders: Record<string, string> = {};
95+
request.headers.forEach((value, name) => {
96+
clientHeaders[name] = value;
97+
});
98+
const clientAddress = this.resolveClientAddress({
99+
headers: clientHeaders,
100+
...(transport?.remoteAddress ? { remoteAddress: transport.remoteAddress } : {}),
101+
});
102+
103+
// The vendored library's OWN client-address reader (session `ip_address`,
104+
// the sign-in audit fingerprint, its per-address rate limiter) reads only
105+
// the platform-owned header (`vendorClientAddressOptions`, composed in
106+
// `createAuthInstance`). Written HERE, from the address just established,
107+
// after dropping any copy the caller sent — so the library answers the same
108+
// address the D5 allow-list judged, or none. See `client-address-vendor.ts`.
109+
request = withVendorClientAddress(request, clientAddress);
110+
111+
// Dev DX: better-auth's CSRF protection rejects state-changing requests
112+
// (e.g. `/sign-in/email`) with a 403 when neither `Origin` nor `Referer`
113+
// is present. Browsers always send one; non-browser API clients (curl,
114+
// fetch from a script, integration tests) often don't, so they hit an
115+
// opaque 403 in local dev. A request with *no* Origin header is never a
116+
// browser-driven cross-site attack — CSRF is fundamentally cross-origin —
117+
// so in non-production we synthesize a same-origin `Origin` from the
118+
// request URL. It matches the dev localhost trusted-origins set, so the
119+
// CSRF check passes without weakening protection in production (gated on
120+
// NODE_ENV, the same dev signal used for the fallback auth secret above).
121+
if (
122+
process.env.NODE_ENV !== 'production' &&
123+
!request.headers.get('origin') &&
124+
!request.headers.get('referer')
125+
) {
126+
try {
127+
const headers = new Headers(request.headers);
128+
headers.set('origin', new URL(request.url).origin);
129+
request = new Request(request, { headers });
130+
} catch {
131+
/* malformed URL — leave the request untouched */
132+
}
133+
}
134+
135+
const auth = await this.getOrCreateAuth();
136+
137+
// [#8243] Let a bearer client carry the `admin_session` recovery credential
138+
// back out of impersonation. better-auth's `/admin/stop-impersonating`
139+
// resolves the admin through the `admin_session` COOKIE alone, so in a
140+
// cookie-blocked deployment — the exact context `bearer()` exists for — the
141+
// exit path is dead. We accept the credential on a header and write it into
142+
// the request's own `Cookie` before better-auth sees it; the vendor route
143+
// then runs completely unmodified, checking everything it always checked.
144+
//
145+
// The REQUEST seam, not a before-hook: `bearer()`'s before-hook rebuilds
146+
// the header set from `c.request.headers`, so a `Cookie` injected by any
147+
// hook is clobbered by whichever hook sorts after it. Written into the
148+
// request itself, `bearer()`'s parse-mutate-serialize keeps it.
149+
if (this.betterAuthEndpointPath(request) === STOP_IMPERSONATING_PATH) {
150+
try {
151+
const authContext: any = await (auth as any).$context;
152+
const adminCookieName: string | undefined =
153+
authContext?.createAuthCookie?.(ADMIN_SESSION_COOKIE_KEY)?.name;
154+
if (adminCookieName) {
155+
request = await withBearerAdminSessionRecovery(request, adminCookieName);
156+
}
157+
} catch {
158+
// Cookie name unresolvable (e.g. a dynamic-baseURL context we cannot
159+
// reach here) → leave the request alone. The vendor route then answers
160+
// exactly as it does today for a missing cookie: a loud failure, never
161+
// a silent wrong identity.
162+
}
163+
}
164+
165+
// better-auth's HTTP entrypoint (`createBetterAuth.handler`) wraps execution
166+
// in `runWithAdapter` but NOT `runWithRequestState`. Endpoints that read
167+
// request-state via `defineRequestState()` (e.g. `should-session-refresh`,
168+
// `oauth`) therefore throw "No request state found" when reached via HTTP.
169+
// The `customSession` plugin triggers this by invoking the inner
170+
// `getSession()` endpoint directly, bypassing `to-auth-endpoints`'
171+
// auto-wrap. We establish the ALS store here so all downstream endpoint
172+
// calls inherit a valid request-state WeakMap.
173+
const { runWithRequestState } = await import('@better-auth/core/context');
174+
// [#4586] Open the actor-attribution scope around the WHOLE request, so
175+
// every write better-auth makes on the way — adapter writes, the
176+
// membership reconciler in `user.create.after`, anything a plugin hook
177+
// triggers — can be credited to the human who made the request. Opening it
178+
// costs nothing: the scope starts empty, the before-hook drops a resolver
179+
// in, and the session is looked up only if some write asks. Attribution
180+
// only — the authorization subject of those writes is unchanged (system).
181+
// `await`, not a bare return: both scope helpers are generic over their
182+
// callback, so the composed call is typed `Promise< Promise< Response > >`.
183+
// The previous single call site flattened it with the `await` below.
184+
const runHandler = async (): Promise<Response> =>
185+
await runWithAuthActorScope(() =>
186+
runWithRequestState(
187+
new WeakMap<object, unknown>(clientAddressStateEntries(clientAddress)),
188+
() => auth.handler(request),
189+
),
190+
);
191+
192+
// [#7724] A subject-erasure request is ONE unit of work, and better-auth
193+
// does not treat it as one: `internalAdapter.deleteUser` deletes the
194+
// sessions, then the accounts, then the user, in three unrelated adapter
195+
// calls with no transaction. Measured 2026-08-20 against the installed
196+
// better-auth 1.7.1: `deleteUser` at `dist/db/internal-adapter.mjs:233-247`
197+
// is three separate `deleteManyWithHooks` / `deleteWithHooks` calls
198+
// (session `:235`, account `:239`, user `:243`), and the string
199+
// "transaction" does not occur anywhere in that file — a zero result that
200+
// means something because "deleteManyWithHooks" occurs 8 times in it, so
201+
// the search does reach the text. (The session call is now conditional on
202+
// `!secondaryStorage || storeSessionInDatabase`; ObjectStack wires no
203+
// `secondaryStorage` — deliberately, see `session-tombstone.ts` — so all
204+
// three fire here.) Anything
205+
// that refuses the LAST of those three leaves the first two committed: the
206+
// credential rows are gone, the `sys_user` row is not, and the deployment
207+
// is left with an identity that still occupies the org roster and can no
208+
// longer sign in. Nothing tells the operator, and there is no way back.
209+
const endpointPath = this.betterAuthEndpointPath(request);
210+
211+
// [#3653 / #14522] A SCIM protocol request (`/scim/v2/*`) runs inside
212+
// `scimRequestScope`, which is what makes the adapter's `transaction`
213+
// config open a REAL engine transaction around the vendor's provisioning
214+
// multi-writes (`objectql-adapter.ts`, the scoping note there). Opened
215+
// HERE, with `run(...)` around the whole request, for the same reason the
216+
// actor-attribution scope above is: `run` has a callback boundary that
217+
// every `als.run` the vendor performs underneath nests inside. The stamp
218+
// used to be an `enterWith` inside the SCIM plugin's `verifyBearerToken`
219+
// callback, and it never reached the writes — the vendor resumes the
220+
// endpoint handler from a continuation captured before the verifier ran
221+
// (measured on 1.7.2: zero `engine.transaction` calls across
222+
// `POST /Users` + `PATCH /Users/{id}`). Keyed on the endpoint path prefix
223+
// so it is exactly as narrow as before — SCIM protocol requests only; the
224+
// non-SCIM flows keep their sequential posture, which the scoping note
225+
// records as load-bearing. Pinned by `scim-transaction-scope.test.ts`.
226+
const runRequest = isScimProtocolPath(endpointPath)
227+
? async (): Promise<Response> => {
228+
const { scimRequestScope } = await import('./scim-connection-service.js');
229+
return scimRequestScope.run({ scim: true }, runHandler);
230+
}
231+
: runHandler;
232+
// [#24948] An organization delete is one unit of work on the same terms:
233+
// better-auth clears the caller's active organization and deletes the
234+
// members and invitations before the organization row, with no
235+
// transaction, and the engine's refusal on that last row used to leave the
236+
// organization standing with no owner.
237+
const vendorResponse =
238+
endpointPath !== undefined &&
239+
(SESSION_ERASURE_PATHS.has(endpointPath) || endpointPath === ORGANIZATION_DELETE_PATH)
240+
? await this.runSubjectErasureAtomically(runRequest)
241+
: await runRequest();
242+
243+
// [#10349] The better-auth-native `/admin/` routes refuse an anonymous
244+
// caller through the vendor's `adminMiddleware`
245+
// (`APIError.fromStatus('UNAUTHORIZED')`, no body argument), so the refusal
246+
// reaches the client as a 401 that announces `application/json` and carries
247+
// the EMPTY STRING — no envelope, nothing to branch on. The ObjectStack raw
248+
// `/admin/*` mounts answer the identical question with the ADR-0112
249+
// envelope and `code: 'UNAUTHENTICATED'` (`platform-admin-gate.ts`), and
250+
// which of the two a caller gets depends only on which implementation
251+
// happens to serve that route — an implementation detail, not a contract.
252+
//
253+
// This is the ONE seam every vendor route passes through, which is why the
254+
// normalization belongs here and not in ten routes we do not own. It is
255+
// scoped to the `/admin/` NAMESPACE (option C): the prefix test costs no new
256+
// concept, because this method already discriminates on `endpointPath` twice
257+
// above — `STOP_IMPERSONATING_PATH` and `SESSION_ERASURE_PATHS`.
258+
//
259+
// Status and admission are untouched; see the module header for the three
260+
// narrowings and the measurement behind each.
261+
const enveloped = await envelopeVendorAdminRefusal(endpointPath, vendorResponse);
262+
263+
// [#17238] And the anonymous `/get-session` answer becomes the same
264+
// declared refusal. better-auth serves `200` + the literal JSON `null`
265+
// when no session backs the request, which is a value no
266+
// `SessionResponse` can express — so `ObjectStackClient.auth.me()`, which
267+
// declares `Promise<SessionResponse>`, resolved OUTSIDE its own type on
268+
// the most ordinary call a logged-out caller makes. Ruled by the director
269+
// seat (batch #117 item 4): the code moves, the published schema does not.
270+
//
271+
// Same seam and same reason as the `/admin/` normalization above — this is
272+
// the ONE place every vendor route passes through — but ⚠️ NOT the same
273+
// kind of change: that one is forbidden to move admission and this one IS
274+
// the admission move (`200` -> `401`). `anonymous-session-refusal.ts`
275+
// carries the three narrowings that keep every other answer identical.
276+
const response = await refuseAnonymousSession(endpointPath, enveloped);
277+
278+
if (response.status >= 500) {
279+
try {
280+
const body = await response.clone().text();
281+
console.error('[AuthManager] better-auth returned error:', response.status, body);
282+
} catch {
283+
console.error('[AuthManager] better-auth returned error:', response.status, '(unable to read body)');
284+
}
285+
}
286+
287+
return response;
288+
}
289+
290+
/** The body of `AuthManager#ownsRoute`; the method's docblock there is its contract. */
291+
export async function ownsRoute(this: Pick<RequestRoutingHost, 'betterAuthEndpointPath' | 'getOrCreateAuth' | 'routeOwnership' | 'routeOwnershipFor'>, request: Request): Promise<boolean> {
292+
const endpointPath = this.betterAuthEndpointPath(request);
293+
if (endpointPath === undefined) return false;
294+
try {
295+
const auth = await this.getOrCreateAuth();
296+
if (this.routeOwnershipFor !== auth || !this.routeOwnership) {
297+
this.routeOwnership = buildBetterAuthRouteOwnership((auth as any)?.api);
298+
this.routeOwnershipFor = auth;
299+
}
300+
return this.routeOwnership.owns(request.method, endpointPath);
301+
} catch {
302+
return false;
303+
}
304+
}
305+
306+
/** The body of `AuthManager#betterAuthEndpointPath`; the method's docblock there is its contract. */
307+
export function betterAuthEndpointPath(this: Pick<RequestRoutingHost, 'getBasePath'>, request: Request): string | undefined {
308+
let pathname: string;
309+
try {
310+
pathname = new URL(request.url).pathname;
311+
} catch {
312+
return undefined;
313+
}
314+
const base = this.getBasePath();
315+
if (!pathname.startsWith(base)) return undefined;
316+
const endpoint = pathname.slice(base.length).replace(/\/+$/, '');
317+
return endpoint.startsWith('/') ? endpoint : undefined;
318+
}

0 commit comments

Comments
 (0)