Skip to content

Commit 93a6ba4

Browse files
refactor(plugin-auth): move the better-auth global after-hook into auth-manager-after-hook.ts (#25224 cut 12) (#25536)
<!-- 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 (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. This is the fourth cut it applies to. Cut 12 of the `auth-manager.ts` split: **the global after-hook of the better-auth instance**. It is the first of the three `createAuthInstance` slices the seat set (`hooks.after`, then `hooks.before`, then the rest of the method). #25224 stays open for the later cuts. This follows the amended claim `6374301142`, which amends `6372234293` on the file cut 11 left (#25524, landed note `6373433471`). The shape is cuts 1 to 11's, which is #23050's, applied to a closure instead of a method. The seat ruled that in scope at 19:56Z, the same call as #25223's `start()` R3, with the bars listed below. Branch: `claude/project-thread-lg3wrj`, the harness-pinned branch for this session, restarted from `origin/main` `1ff43b67ea` after #25524 merged. It names no issue, so the card relation is this body's first line. `auth-manager.ts` goes from 8,536 to 8,362 lines. The cut is +274 / −193 over 2 files: 467 changed lines, under the 3,000-line line. It is not a governed surface (`check-governed-merges.mjs --branch HEAD`: NOT governed). One commit: `d79f55f402` `refactor(plugin-auth): move the better-auth global after-hook into auth-manager-after-hook.ts`. ## The move | moved | base lines | proof lines | tokens | members it reads | |---|---|---:|---:|---:| | the `hooks.after` arrow body in `createAuthInstance` | `:2834`–`:3016` | 185 | 610 | 8 methods | The lines are base `1ff43b67ea`. The 185 proof lines are the body with its braces (`:2833`'s `{` to `:3017`'s `}`). - **What it is:** `createAuthInstance` (`:1699`, private, annotated `Promise<Auth<any>>`) builds one config literal for better-auth. Its `hooks.after` is `createAuthMiddleware(async (ctx: any) => {BODY})`: the after-hook every better-auth endpoint's response passes through. - **What BODY does, in order** (all of it moves, with its comments): the bearer rotation on impersonation, the echo of the installed session token, the session envelope completion, the two-factor re-enrolment reset, the sign-in outcome count with the refusal audit, the concurrent-session cap and the login stamp, the password-change stamp and history commit, the old-address notice on an e-mail change, the directory doors narrowed for an external caller, the invite-role refusal code, and the sign-up `disableSignUp` restore. - **Where it goes:** a new module, `packages/plugins/plugin-auth/src/auth-manager-after-hook.ts` (255 lines). BODY becomes the body of `export async function globalAfterHook(this: AfterHookHost, ctx: any)`, re-indented eight spaces (four levels) and otherwise unedited. The module header gives the reasoning, in the form the eleven earlier `auth-manager-*.ts` modules use. - **What stays:** `createAuthInstance` keeps its place, docblock and signature, and `hooks.after` keeps its place in the literal. Its arrow is now: ```ts // [#25224 cut 12] The body moved to `auth-manager-after-hook.ts`; this // arrow hands it the members it reads (`AfterHookHost`). after: createAuthMiddleware((ctx: any) => { const host: AfterHookHost = { auditRefusedSignIn: (...a) => this.auditRefusedSignIn(...a), // … one closure per member, alphabetical, 8 in all }; return globalAfterHook.call(host, ctx); }), ``` Nothing else in `createAuthInstance` changes. ## The seat's bars **Body.** - BODY is the same TypeScript token sequence at base and in `globalAfterHook`: 185 lines, 610 tokens. Every line is the base line minus exactly eight spaces, and the parameter list (`ctx: any`) is the same text. Neither side has a return annotation. - **`async` moves from the arrow to the function.** The head arrow is not `async`, and it returns `globalAfterHook.call(host, ctx)`, the function's own promise, unchanged. better-auth 1.7.12's `createAuthMiddleware` is better-call 1.4.4's `createMiddleware`, whose handler wrapper runs `const response = await _handler(internalContext);` inside its `try` (`dist/middleware.mjs:15`). So it awaits the same promise BODY settles, with the same value or rejection, and its `catch` sees the same error. The host literal and the `.call` cannot throw before that promise exists. - **No captured locals.** None of the locals `createAuthInstance` declares before the literal (`betterAuth`, `createAuthMiddleware`, `setShouldSkipSessionRefresh`, `plugins`, `passwordHasher`) is read in BODY. The binding proof below finds no identifier in BODY bound anywhere else in the method either. - **No `arguments`, `new.target` or `super`** (0 of each), where an arrow and a function differ. There is no bare `this`: BODY's only `this` uses are 9 member reads, `this.X`. **Receiver.** - BODY's `this` was the `AuthManager` instance, the arrow's lexical receiver. It is now the host. - `AfterHookHost` names exactly the 8 members BODY reads, no more, each typed as the class member (`AuthManager['x']`): `auditRefusedSignIn`, `enforceConcurrentCap`, `externalDirectoryDoorDeps`, `recordPasswordHistory`, `recordSignInOutcome`, `sendChangeEmailNotice`, `stampLastLogin` and `stampPasswordChangedAt`. - All 8 are methods, so each goes as a late-bound closure, `(...a) => this.x(...a)`. The method runs on the manager and is looked up when BODY calls it, so a spy or double set on the manager is still the one BODY reaches. - BODY reads no field, so there is no getter. No member BODY reads is a function-valued field, so there is nothing to disclose the way #25516 disclosed `resolveKernelService`. **Host lifetime.** The host is built inside the arrow, once per run of the hook: each time better-auth runs `hooks.after` on an endpoint's response. The seat allowed once per `createAuthInstance` call; this is more often, and it is what every method stub in this series does. Nothing BODY reads is captured early, because each closure looks its method up on the manager at call time. **Readers.** The decision letters are read at both ends below: 4 drop, as expected. No anchor entry is owed. Tenant census and symbol-anchor output are byte-identical at both ends, and the full plugin-auth suite passes at both ends. ## Imports and evaluation order Seven import lines leave `auth-manager.ts`, because BODY was their last reader there: - `narrowDirectoryForExternalCaller` (the line stays as `import { type ExternalDirectoryDoorDeps } from './external-directory-door.js'`); - `rotateCallerBearerOnImpersonation` (a three-line import block), `echoInstalledSessionToken`, `resetVerifiedOnTwoFactorReenrollment`, `codeInviteRoleNotFound`, `attachSessionToCredentialResponse` and `readRequestClientAddress`. `SIGN_IN_EMAIL_PATH` has other readers in `auth-manager.ts`, so both files import it. One import line is added after cut 11's, with a comment in the same form: `import { globalAfterHook, type AfterHookHost } from './auth-manager-after-hook.js'`. The new module imports the `AuthManager` type only. esbuild drops a type-only import, so a module whose last value import leaves `auth-manager.ts` is now first evaluated later, when the new module is reached. I read each top level: - `two-factor-rotated-token-echo`, `two-factor-reenrollment-verified-reset`, `invite-role-not-found-code`, `session-envelope-completion` and `external-directory-door` are now reached only through the new module. None of the five imports anything. Their top levels are literals, interfaces and function declarations (plus two arrow consts in `external-directory-door`), so there is no side effect to reorder. - `impersonation-bearer-rotation` is still reached earlier, through `admin-impersonate-endpoint.ts:137`. - `request-client-address` is now first reached through `auth-manager-request-routing.ts`, before the new module. Its top level is one `defineRequestState(() => undefined)` from `@better-auth/core/context`, which creates a frozen ref object over the per-request store and registers nothing globally, so the order cannot be observed. - The new module's own top level is imports, one interface and one function declaration. Its import of `auth-manager.ts` is type-only, so there is no runtime cycle. ## The decision-letter layer: −4, as expected `check-adr-anchors`, measured at both ends. The gate exits 0 at both. | point | anchored files | citations | files | decision letters | |---|---:|---:|---:|---:| | base `1ff43b67ea` | 83 | 52,270 | 6,675 | 1,971 | | head `d79f55f402` | 83 | 52,270 | 6,676 | 1,967 | - The four letters are in BODY's comments: ADR-0069 D2 (`:2882`, the account lockout counter), D7 (`:2904`, the login audit stamp) and D1 twice (`:2918` and `:2995`, the password-changed stamps). They leave `auth-manager.ts`, which is anchored, for a module that is not. Their citations still resolve, so the citation count does not move. - **No entry is owed.** The `auth-manager.ts` entry has two paragraphs: ADR-0108 (better-auth's organization roles map) and ADR-0093 D2 (`settleMembership` and its two seams, `user.create.after` and `session.create.before`). Neither describes `hooks.after`, and no other entry names it. So, per the seat's bar, no entry is added for the new module. ## Path readers No path reader needs migrating: none reads a moved line. - **The tenant-audit census.** No census site moves: BODY has no engine write, no `this.X.delete()` and no elevation read. `check-tenant-audit-census` exits 0 at both with no `--write`, and its output is byte-identical. The `--json` document has 238 sites at both and 0 per-site differences with line numbers ignored. 2 sites are in `auth-manager.ts` at both (each 174 lines earlier at head, the cut's net), and 0 are in the new module. Only `scannedSources` (743 → 744) and `measuredAt` differ, so neither census document is edited. - **`check-dispatcher-error-vocabulary.mjs`** and **`api-error-code.pin.test.ts`'s `authManagerFiles` floor**: BODY has no `new APIError(`. The gate reads 56 sites found and 56 classified at both; only its scanned-file count moves (3,420 → 3,421). - **`check-auth-mount-ledger.mjs`**: no base-path literal or declaration is in BODY. - **The plugin-factory scans** in `better-auth-schema-parity.test.ts` and `managed-extension-fields.test.ts` match `better-auth/plugins…` and `@better-auth/…` dynamic imports. BODY has no dynamic import, and both suites pass at head. - **`rate-limit-storage-isolation.test.ts`** walks the import graph from `rate-limit-storage.ts`, which reaches neither file. It passes at head. - **The system-context census** reads 149 elevation read sites at both, with byte-identical output. - **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, at base with BODY blanked, and at head. None of their symbols occurs as a token on a line of BODY. ## Proof Builds and suites ran through `os-verify-lock`, and every exit code was captured before any pipe. Base `1ff43b67ea`. A full `turbo run build` ran first. Then `plugin-auth` was built and its suite run at base, and built and run again at head, on the same dependency dists. **1. The moved text.** - **Token identity:** the base arrow body and the module 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): 185 lines and 610 tokens, each line the base line minus eight spaces. - **The head arrow's shape**, checked by the same run: not `async`, the same parameters, one host literal whose keys are exactly `AfterHookHost`'s and whose every value is `(...a) => this.<key>(...a)`, then `return globalAfterHook.call(host, ctx)`, and nothing else. - **Binding identity:** a second proof walks all 181 identifiers of BODY at base and in the module, in order, and resolves each with the TypeScript checker. All 181 resolve the same way at both ends: - 38 to the `ctx` parameter; - 56 to a declaration inside BODY, the same declaring identifier by position; - 9 to the receiver's members (`AuthManager` at base, `AfterHookHost` at head); - 8 to imports with the same specifier and imported name; - 70 to no declaration: the globals `Error` and `undefined`, and property names on untyped values. None resolves to a `createAuthInstance` local or to a top-level declaration of either file. - **Negative controls:** both proofs read the module text, and each control changes it in memory only; the file is untouched. - Token proof: `await rotateCallerBearerOnImpersonation(ctx);` changed to `void …` fails the token and line checks, and the run exits 1. - Binding proof: the module's `readRequestClientAddress` import pointed at another specifier gives 1 difference, and the run exits 1. - **An independent reading:** `git diff --color-moved=plain --color-moved-ws=allow-indentation-change 1ff43b6 d79f55f` over the source files marks 188 of the 193 deleted lines and 194 of the 274 added lines as moved. - The 5 unmoved deletions are the `external-directory-door` import line (now type-only), the three-line `rotateCallerBearerOnImpersonation` import block (now one line in the module), and the `after: createAuthMiddleware(async (ctx: any) => {` line (now without `async`). - The other added lines are the module header, the host interface and its docblock, the function signature, the arrow's host literal and `return`, and the two new import comments and lines. **2. `.d.ts` and exports (ruling `6348891160` items 1 and 2).** | file | base and head | raw diff lines | |---|---|---:| | `dist/index.d.ts` raw | `1b3a48357d137155de33104277667a922aab62df73b418b03894c56faaacd0ba` | 0 | | `dist/index.d.ts` normalised | `cee7303ccdfe72794efd588c85b628419d4253d730b5a0c5a3f1cdb8dddb4443` | | | `dist/index.d.mts` raw | `b5e8dc982b634f0a5108b1b533626a68767f2da5a56f72dba801059b149e2872` | 0 | | `dist/index.d.mts` normalised | `562d0a4ce656f97aeb937541482cdf531b61d7c75a005918784bf0a52d032dfc` | | - 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 byte-identical, so no union-order swap occurs: 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 createAuthInstance;` → `createAuthInstance;`, line 1712, a one-line change) hashes raw `35b18682171cd394898c9e56bc8d5272ccf20aeff0d1966fc568cb5e566a08df` and normalised `e69c1b0157b45e48bbc16bd64eb939abf9be33ccb31553485df93ef05aac6fc3`. 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). `globalAfterHook` and `AfterHookHost` are not among them, and `dist/index.d.ts` names neither. - **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. `tsup` bundles the new module into the root entry (`splitting: false`). **3. Tests and lint.** - `pnpm exec vitest run --maxWorkers=3` in `packages/plugins/plugin-auth`: base `Test Files 191 passed (191)`, `Tests 3607 passed | 10 skipped (3617)`, 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: byte-identical gate output, 238 sites at both, 0 per-site differences line-blind, 0 unresolved and 0 unledgered at both. - `scripts/measure-durability-swallow-family.mjs` (a measurement, not a gate): 77 members and 84 quiet rows at both. The members are the same with line numbers ignored, and the quiet rows are identical. Only the scanned file count moves (3,470 → 3,471, the new module). `tryStatements` (3,639), `droppedNotAwaited` (239) and every other statistic are the same: BODY's 4 `try` blocks move whole. - 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. Five print byte-identical output at both (`cmp` clean): - `check-adr-symbol-anchors`: 2,384 anchors across 142 records, 466 symbol (440 declaration, 26 literal); - `check-scripts-symbol-anchors`: 4,070 anchors; - `check-spec-docblock-symbol-anchors`: 5,076 anchors; - `check-system-context-census`: 149 elevation read sites; - `check-platform-checklist`: 16 areas, 329 items. The sixth, `check-adr-anchors`, differs only in the file count and the decision letters, as in the table above. **5. Gates.** - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 54 commands for the 2 paths. All 54 ran at head after the full build, and all 54 exited 0. - `--ran` over the record with each exit code: 54 derived, 54 run, 0 NOT-MEASURED, a derived zero. `--tier`: no path-derived mandate. - `check-governed-merges.mjs --branch HEAD`: NOT governed, 467 changed lines. - **Docs drift** (`node scripts/docs-audit/affected-docs.mjs --json 1ff43b6`, exit 0): 0 pages name something this change touched (19 anchors across `plugin-auth`), and 0 release-owned pages. So no doc edit is owed. - **Serial constraints** (in the claim): 11 PRs are open, and none touches `auth-manager.ts`, the new module's path, or a reader named above. The Version Packages PR #24876 touches `plugin-auth`'s `package.json`, its `CHANGELOG.md` and changesets only. #25530 adds one `scripts/adr-anchors/` entry for a `plugin-security` file, which moves `check-adr-anchors`' totals for whichever lands second, but not this cut's four letters. `origin/main` has moved 2 commits past this base, to `74496c64b0` (#25526 `plugin-approvals`, #25527 `service-settings`). Neither touches `plugin-auth` or `scripts/`. ## 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 0fab7ba commit 93a6ba4

2 files changed

Lines changed: 274 additions & 193 deletions

File tree

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25224 cut 12] The global after-hook of the better-auth instance
5+
* `AuthManager` builds — the one `hooks.after` middleware `createAuthInstance`
6+
* hands better-auth, which every endpoint's response passes through — moved
7+
* out of `auth-manager.ts` whole.
8+
*
9+
* A MOVE, not a redesign. The function below is the body of the arrow
10+
* `createAuthInstance` passes to `createAuthMiddleware` for `hooks.after`, as
11+
* it stood, re-indented four levels and otherwise unedited: the same bearer
12+
* rotation on impersonation, the same echo of the installed session token,
13+
* the same session envelope completion, the same two-factor re-enrolment
14+
* reset, the same sign-in outcome count, refusal audit, concurrent-session cap
15+
* and login stamp, the same password-change stamp and history commit, the
16+
* same old-address notice on an e-mail change, the same directory doors
17+
* narrowed for an external caller, the same invite-role refusal code, and the
18+
* same sign-up stamp and `disableSignUp` restore — in the same order.
19+
*
20+
* ## How the moved body reaches the class
21+
*
22+
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
23+
* header carries the full reasoning), which the eleven `auth-manager-*.ts`
24+
* modules split out before this one follow, applied to a closure instead of a
25+
* method. `createAuthInstance` keeps its place, its docblock and its
26+
* signature, and `hooks.after` keeps its place in the config literal: the
27+
* arrow there is now one literal of exactly the {@link AfterHookHost} members
28+
* the body reads, plus a `.call`. The `async` moved from that arrow to the
29+
* function below, and the arrow returns the function's own promise, so the
30+
* middleware awaits the same promise the body settles, with no extra wrapper.
31+
*
32+
* The body's `this` was the manager, the arrow's lexical receiver; it is now
33+
* that host. Every member it reads is a method, and each goes as a closure
34+
* that calls it on the manager at call time, so a test double or spy set on
35+
* the manager is still the one the body reaches. The arrow builds the host
36+
* each time better-auth runs the hook, as every method stub in this series
37+
* does, so nothing the body reads is captured early. The arrow read no local
38+
* of `createAuthInstance`, so the function takes the same one parameter.
39+
*
40+
* Nothing re-exports this module, so the published surface does not move.
41+
*/
42+
43+
import type { AuthManager } from './auth-manager.js';
44+
import { SIGN_IN_EMAIL_PATH } from './auth-session-audit.js';
45+
import { narrowDirectoryForExternalCaller } from './external-directory-door.js';
46+
import { rotateCallerBearerOnImpersonation } from './impersonation-bearer-rotation.js';
47+
import { codeInviteRoleNotFound } from './invite-role-not-found-code.js';
48+
import { readRequestClientAddress } from './request-client-address.js';
49+
import { attachSessionToCredentialResponse } from './session-envelope-completion.js';
50+
import { resetVerifiedOnTwoFactorReenrollment } from './two-factor-reenrollment-verified-reset.js';
51+
import { echoInstalledSessionToken } from './two-factor-rotated-token-echo.js';
52+
53+
/**
54+
* The `AuthManager` members the function below reads — every one of them, and
55+
* no other. Each is typed AS the class member (an indexed access on the
56+
* class), so a change to a member's signature on the class is a change here
57+
* too, and naming a member the class does not have is a compile error.
58+
*/
59+
export interface AfterHookHost {
60+
auditRefusedSignIn: AuthManager['auditRefusedSignIn'];
61+
enforceConcurrentCap: AuthManager['enforceConcurrentCap'];
62+
externalDirectoryDoorDeps: AuthManager['externalDirectoryDoorDeps'];
63+
recordPasswordHistory: AuthManager['recordPasswordHistory'];
64+
recordSignInOutcome: AuthManager['recordSignInOutcome'];
65+
sendChangeEmailNotice: AuthManager['sendChangeEmailNotice'];
66+
stampLastLogin: AuthManager['stampLastLogin'];
67+
stampPasswordChangedAt: AuthManager['stampPasswordChangedAt'];
68+
}
69+
70+
/** The body of `createAuthInstance`'s `hooks.after` arrow; the comments there and in it are its contract. */
71+
export async function globalAfterHook(this: AfterHookHost, ctx: any) {
72+
// ── #8243: impersonation must actually take effect for a bearer ──
73+
// FIRST among the after-hooks that touch the response, because the
74+
// bearer plugin's own after-hook runs behind this one and must see
75+
// what we stage: it re-reads `Access-Control-Expose-Headers` to add
76+
// `set-auth-token`, so the recovery header we expose here survives
77+
// only by being merged before it. See
78+
// `impersonation-bearer-rotation.ts` for the mechanism — the short
79+
// version is that `bearer()` converts the caller's token back into
80+
// the ADMIN's session cookie on every later request, so without
81+
// rotating that token, `/admin/impersonate-user` is a 200 no-op.
82+
await rotateCallerBearerOnImpersonation(ctx);
83+
84+
// ── #10701: a 2FA verification must echo the session it INSTALLED ─
85+
// On the enrolment lane `/two-factor/verify-totp` rotates the
86+
// caller's session and then echoes the PRE-rotation token — the row
87+
// it just deleted. A client that stores it (the console's
88+
// `auth-session-token` pattern) is holding a revoked credential, and
89+
// because `bearer()` overwrites the request cookie with whatever the
90+
// Authorization header carries, presenting it destroys the valid
91+
// rotated cookie too. See `two-factor-rotated-token-echo.ts`. This
92+
// corrects the echoed VALUE only; resolver precedence is untouched.
93+
await echoInstalledSessionToken(ctx);
94+
95+
// ── #17234: complete the SessionResponse envelope with the session
96+
// the route just committed ────────────────────────────────────────
97+
// `/sign-in/email` and `/sign-up/email` answer `{ token, user }`
98+
// (plus `redirect` on sign-in) with no `session` member anywhere in
99+
// the body or the headers, so `SessionResponseSchema` never parsed
100+
// either method's return value. The session is not absent — it is
101+
// the row `internalAdapter.createSession` already committed before
102+
// the endpoint returned — so this reads it back by the response's
103+
// own token, the same seam `/get-session` uses, and attaches it.
104+
// See `session-envelope-completion.ts` for the full measurement and
105+
// why this can never fabricate an id or an expiry.
106+
await attachSessionToCredentialResponse(ctx);
107+
108+
// ── #10700: `verified` must describe the secret stored beside it ──
109+
// A second `/two-factor/enable` on an already-confirmed account
110+
// rewrites the TOTP secret on the one `sys_two_factor` row the
111+
// account has and INHERITS `verified` from the enrollment before it,
112+
// so a secret nobody confirmed is honoured at the sign-in challenge.
113+
// better-auth already gates that challenge on the flag — a first
114+
// enrollment is inert until the session-lane verify flips it — so
115+
// restoring the flag restores the gate rather than adding a second
116+
// one. See `two-factor-reenrollment-verified-reset.ts`, whose header
117+
// also states what this deliberately does NOT do.
118+
await resetVerifiedOnTwoFactorReenrollment(ctx);
119+
120+
// ── ADR-0069 D2: account lockout (counter) ──────────────────
121+
// better-auth catches an INVALID_EMAIL_OR_PASSWORD APIError and runs
122+
// the after-hook with it on `ctx.context.returned`; a success leaves
123+
// the session payload there. Count failures, reset on success.
124+
if (ctx?.path === SIGN_IN_EMAIL_PATH) {
125+
const email = typeof ctx?.body?.email === 'string' ? ctx.body.email : '';
126+
if (email) {
127+
let succeeded = true;
128+
try {
129+
const { isAPIError } = await import('better-auth/api');
130+
succeeded = !isAPIError(ctx?.context?.returned);
131+
} catch {
132+
succeeded = !(ctx?.context?.returned instanceof Error);
133+
}
134+
const lockStamped = await this.recordSignInOutcome(email, succeeded);
135+
// A refused sign-in writes `login_failed`, and the one that
136+
// stamped the lockout also writes `account_locked`. A successful
137+
// one is recorded as `login` by the session hook, unchanged.
138+
if (!succeeded) await this.auditRefusedSignIn(ctx, email, lockStamped);
139+
if (succeeded) {
140+
const uid = ctx?.context?.returned?.user?.id;
141+
if (typeof uid === 'string') await this.enforceConcurrentCap(uid);
142+
// ADR-0069 D7 — login audit: stamp last_login_at/ip on success.
143+
// Independent of lockout config (recordSignInOutcome no-ops when
144+
// lockout is off). The IP is the address `handleRequest`
145+
// established for this request by the client-address trust rule,
146+
// the same address the D5 allow-list judged; none when it was
147+
// undetermined. ⛔ Never re-read from a header here.
148+
if (typeof uid === 'string') {
149+
await this.stampLastLogin(uid, await readRequestClientAddress());
150+
}
151+
}
152+
}
153+
return;
154+
}
155+
156+
// ── ADR-0069 D1: on a successful change/reset — stamp
157+
// password_changed_at (expiry) and commit password history.
158+
if (ctx?.path === '/change-password' || ctx?.path === '/reset-password') {
159+
let succeeded: boolean;
160+
try {
161+
const { isAPIError } = await import('better-auth/api');
162+
succeeded = !isAPIError(ctx?.context?.returned);
163+
} catch {
164+
succeeded = !(ctx?.context?.returned instanceof Error);
165+
}
166+
if (succeeded) {
167+
const stampId = ctx?.context?.__osPwChangeUserId;
168+
if (stampId) await this.stampPasswordChangedAt(stampId);
169+
const stash = ctx?.context?.__osPwHistory;
170+
if (stash?.userId) await this.recordPasswordHistory(stash.userId, stash.oldHash);
171+
}
172+
delete ctx.context.__osPwChangeUserId;
173+
delete ctx.context.__osPwHistory;
174+
return;
175+
}
176+
177+
// ── #8019: tell the OLD address, without gating on it ──────────────
178+
// Maintainer ruling 2026-08-12 — 「notify the OLD address — do not
179+
// gate on it」. See the `changeEmail` config site for why this cannot
180+
// be `sendChangeEmailConfirmation` (that option IS the gate).
181+
//
182+
// Why HERE is the whole design:
183+
// • after-hook ⇒ better-auth has already produced its response, so
184+
// nothing this code does can change the endpoint's outcome. The
185+
// "does not gate" property is structural, not a promise;
186+
// • gated on success ⇒ a refused request sends nothing;
187+
// • `sendChangeEmailNotice` swallows its own failures ⇒ a dead
188+
// mailbox, an unseeded template or a missing transport cannot
189+
// turn the notification into a block. That inversion — a security
190+
// notice that takes the flow down with it — is precisely the
191+
// failure mode the ruling exists to avoid.
192+
//
193+
// Sent at REQUEST time, which is also the strongest moment available:
194+
// the owner learns while the attacker still has to prove control of
195+
// the new mailbox, so the warning arrives inside the window where it
196+
// can still be acted on. It is also the only moment that exists
197+
// exactly once per attempt — the apply step (`GET /verify-email`)
198+
// does not carry the old address, and better-auth mints a fresh
199+
// session there when the link is opened without one, so an
200+
// apply-time notice would silently miss that path.
201+
if (ctx?.path === '/change-email') {
202+
const from = ctx?.context?.__osChangeEmailFrom;
203+
delete ctx.context.__osChangeEmailFrom;
204+
let succeeded: boolean;
205+
try {
206+
const { isAPIError } = await import('better-auth/api');
207+
succeeded = !isAPIError(ctx?.context?.returned);
208+
} catch {
209+
succeeded = !(ctx?.context?.returned instanceof Error);
210+
}
211+
const newEmail = typeof ctx?.body?.newEmail === 'string' ? ctx.body.newEmail : '';
212+
if (succeeded && from?.email && newEmail) {
213+
// #14319 — the notice goes to the account owner, who IS the
214+
// caller here, so the request rung applies.
215+
await this.sendChangeEmailNotice(from, newEmail, ctx);
216+
}
217+
return;
218+
}
219+
220+
// ── Ruling 6355206139 E2: the directory doors, narrowed for an external ──
221+
// list-members and get-full-organization check membership only, so an
222+
// `external` caller would read every fellow member (and every pending
223+
// invitation) there. See `external-directory-door.ts`.
224+
await narrowDirectoryForExternalCaller(ctx, this.externalDirectoryDoorDeps());
225+
226+
// ── #24750: invite-member's unknown-role refusal carries its code ──
227+
// better-auth throws it with only a message; the spec promises
228+
// `ROLE_NOT_FOUND` (see `invite-role-not-found-code.ts`). It throws
229+
// on a match, so it stays after every section above.
230+
await codeInviteRoleNotFound(ctx);
231+
232+
if (ctx?.path !== '/sign-up/email') return;
233+
// ADR-0069 D1 — stamp password_changed_at for a newly-created local
234+
// user (expiry clock starts at sign-up). Best-effort.
235+
{
236+
const newUserId = ctx?.context?.returned?.user?.id;
237+
let signupOk: boolean;
238+
try {
239+
const { isAPIError } = await import('better-auth/api');
240+
signupOk = !isAPIError(ctx?.context?.returned);
241+
} catch {
242+
signupOk = !(ctx?.context?.returned instanceof Error);
243+
}
244+
// [#25060] Not for an existing address answered as a success: that
245+
// answer's user id belongs to no row, and nothing was created.
246+
if (signupOk && typeof newUserId === 'string' && ctx?.context?.__osExistingAddressSignUp !== true) {
247+
await this.stampPasswordChangedAt(newUserId);
248+
}
249+
}
250+
const ep = ctx?.context?.options?.emailAndPassword;
251+
if (ep && ctx.context.__osDisableSignUpOrig !== undefined) {
252+
ep.disableSignUp = ctx.context.__osDisableSignUpOrig;
253+
delete ctx.context.__osDisableSignUpOrig;
254+
}
255+
}

0 commit comments

Comments
 (0)