Skip to content

Commit 9dc1f42

Browse files
refactor(plugin-auth): move AuthManager's sign-in lockout path into auth-manager-sign-in-lockout.ts (#25224 cut 2) (#25307)
<!-- 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 The basis for the `Clause-②: no` line: this is a move. No accept set, export, route, refusal or answer changes. The `.d.ts` bar is ruling `6348891160` item 1 (on #25222, covering this card): `@objectstack/plugin-auth`'s published `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), with the ruling's positive control, and its entry exports the same 155 names under ESM and CJS (measured below). Cut 2 of the `auth-manager.ts` split: **the sign-in lockout path (ADR-0069 D2) and the last-login stamp (D7).** Seven `AuthManager` method bodies, 173 lines, move unedited (re-indented one level) into one new module, `packages/plugins/plugin-auth/src/auth-manager-sign-in-lockout.ts`. #25224 stays open for the later cuts. This follows the card's claim `6351729156` (amending `6349004758` for cut 2, on the file cut 1 left: PR #25273, landed note `6351216603`). The shape is cut 1's, which is #23050's (landed note `6136102437`). Branch: `claude/project-thread-lg3wrj`, the harness-pinned branch for this session, restarted from `origin/main` after #25273 merged. It names no issue, so the card relation is this body's first line. `auth-manager.ts` goes from 10,004 to 9,897 lines. The cut is +340 / −181 over 5 files (521 changed lines, under the 3,000-line line; two of the files are the regenerated tenant-audit census). Not a governed surface (`check-governed-merges.mjs --branch HEAD`: 0 of the 5 paths hit the register). ## Why this seam The first reading took the whole ADR-0069 enforcement tail (16 methods, base `:8852`–`:9951`: password policy, lockout, session controls). Five of those methods (`assertPasswordNotReused`, `recordPasswordHistory`, `unlockUser`, `enforceSessionControls`, `enforceConcurrentCap`) hold 6 of the file's 12 query-options `any`-erasure sites (measured with ESLint itself: the file linted at a path the baseline does not list reports exactly 12, the baseline's count). `scripts/query-options-erasure-baseline.json` grandfathers erasures per file and only ever loses keys, and the `query-options/no-any-erasure` rule blocks every file it does not list, so a move cannot carry a site into a new file without `pnpm lint` and the ratchet going red. Those five stay in `auth-manager.ts`. The fork was ruled on the card (`6351993113`, letter A, acknowledged in `6352024441`): after this cut merges, a separate sweep PR drops the file's 12 counted assertions in place (all 12, in nine methods; this paragraph's six are the ones the first reading reached) and ratchets the baseline down, and the nine methods move after it. This cut is the sign-in path's coherent remainder, and its bodies hold none (the new module lints with 0 sites; `auth-manager.ts` keeps its 12, so the baseline holds untouched). ## What moved | method | base body | lines | members it reads | |---|---|---:|---| | `assertAccountNotLocked` | `:9438`–`:9472` | 35 | `config`, `findSignInAccount`, `getDataEngine` | | `recordSignInOutcome` | `:9497`–`:9542` | 46 | `config`, `findSignInAccount`, `getDataEngine`, `logDurabilityDegradation` | | `auditRefusedSignIn` | `:9561`–`:9586` | 26 | `config`, `resolveSignInAccountId` | | `resolveSignInAccountId` | `:9594`–`:9611` | 18 | `findSignInAccount`, `getDataEngine`, `logDurabilityDegradation` | | `findSignInAccount` | `:9641`–`:9651` | 11 | none | | `resolveTwoFactorAccountLockout` | `:9682`–`:9691` | 10 | `config` | | `stampLastLogin` | `:9721`–`:9747` | 27 | `getDataEngine`, `logDurabilityDegradation` | Lines are base `49185cdb8a`. The members are a walk of every `this.X` in each body with the TypeScript compiler API: no bare `this`, no `this[...]`, no `arguments`, no `super`, no assignment to a `this` member. No module-level declaration moves. The imports that only the moved bodies used left with them (`accountLockedEventFor`, `refusalCodeOf`, `signInClientFingerprint` and `signInFailureEventFor` from `auth-session-audit.js`, and the `EngineQueryOptions` type); the new module imports each from the module `auth-manager.ts` imported it from. ## The shape - **The class keeps every method**, at its place, with its name, signature and docblock: the published `.d.ts` lists each one. Its body is now a host literal of exactly the members that body reads, typed as a `Pick` of `SignInLockoutHost`, plus `.call`. `findSignInAccount` reads no member and calls its function directly. - **`SignInLockoutHost`** (in the new module) names the five members the bodies read, each typed as the class member (`AuthManager['getDataEngine']`, …). The new module imports `AuthManager` as a type only, so there is no runtime cycle. - **Methods go as late-bound closures** (`(...a) => this.getDataEngine(...a)`), so a test double or spy set on the manager is still the one a body reaches. That includes `findSignInAccount` and `resolveSignInAccountId`, which bodies here ask through the manager. - **`config` goes as a getter**: the class reassigns it after construction (`setBaseUrl`, `applyConfigPatch`), and the bodies read it after an `await`. - **Six of the methods 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 no method throws synchronously where it used to reject. `resolveTwoFactorAccountLockout` was synchronous and stays so. - **Nothing re-exports the new module**, so the published surface does not move. ## Readers re-pointed in the same PR | reader | what it read | base | head | |---|---|---|---| | `api-error-code.pin.test.ts` | the sum of `new APIError(` sites over the files `AuthManager`'s bodies live in (floor 24) | 18 + 9 = 27 | 17 + 9 + 1 = 27: the `ACCOUNT_LOCKED` throw of `assertAccountNotLocked` moved, so the module joins the list | | the tenant-audit census (`check-tenant-audit-census.mjs`, its committed page and counts file) | every engine write call site, keyed by file | `auth-manager.ts` `update` `sys_user`: 8 | `auth-manager.ts`: 5, `auth-manager-sign-in-lockout.ts`: 3; regenerated with `node scripts/tenant-audit-census.mjs --write`, and the page's two hand-written erased-receiver counts follow (41 → 44); see below | Without the pin edit the sum read 26 and the floor held in silence, which is the drift the card forbids. Measured as reading no moved line: the plugin-factory scans in `better-auth-schema-parity.test.ts` and `managed-extension-fields.test.ts` (the module imports no factory), `pnpm check:dispatcher-error-vocabulary` (exit 0; no row names a stamp in a moved body), the query-options erasure ratchet (`62 unswept non-test site(s) in 16 file(s), none new`, key set verified against `49185cd`), the ADR-0093/0108 anchor of `auth-manager.ts` (names `settleMembership` and the roles map, neither moved), and no `docs/qa/platform-checklist` anchor or docs page names a moved method. ## The census reading The census moves in one way a reviewer should see. It reads 236 engine write sites at base and at head, and every site keeps its object, tenancy, options and elevation reading (compared per site, with the new module's file name mapped back to `auth-manager.ts`). What changes is how it reaches three of them: the `engine.update('sys_user', ...)` calls of `recordSignInOutcome` (2, base `:9508` and `:9524`) and `stampLastLogin` (1, base `:9730`). - In the class, their receiver is `const engine = this.getDataEngine()`. The census reads that through the method's declared return type in the same file (`getDataEngine(): IDataEngine | undefined`), so the three counted as "receiver carried a readable engine type". - In the module, the same text runs on a `this` typed as `Pick<SignInLockoutHost, ...>`, and the census does not follow a function's `this` parameter. It places the three by their literal object name, `sys_user`, a declared object, so none of them is lost or excused by a ledger row, and the run reports 0 unresolved and 0 unledgered receivers at base and at head. - The generated table moves `readable engine type` 195 → 192 and `placed by the object NAME` 31 → 34. The two hand-written counts the gate checks on the page (`PROSE_COUNTS`) are that sum, so they move 41 → 44. The corpus-scale block (not enforced) is re-measured too: 706 sources, 1 more than base for the new module. Keeping the three typed would mean editing the moved bodies (an annotation on `engine`) or teaching the census to read `this` parameters. The first is not a move and the second is a census logic change, so neither belongs in this PR. Later cuts that move engine writes on this host shape will show the same effect. ## Proof Builds and suites ran through `os-verify-lock`, every exit code captured before any pipe. Base `49185cdb8a`; head `75983184cc` (the move `c204bd096c`, the pin reader `4ee263903a`, the census `f898b1e719`, then a merge of `origin/main` at `5801a4f2c9`, whose five commits touch no `plugin-auth` file and auto-merged the census page's prose). Proofs 1 to 3 were taken at `4ee263903a`, whose `plugin-auth` source is the head's, and re-read at `75983184cc` after a full `turbo run build`: the suite gave the same counts and both `.d.ts` files the same raw hashes. **1. The moved text.** For each of the seven 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, the parameters and return type are the same text, and the class method keeps its docblock and signature with `async` dropped: 7 of 7 pass, 173 body lines. An independent reading: `git diff --color-moved=plain --color-moved-ws=allow-indentation-change 49185cd 4ee2639` over the two files marks 163 of the 169 deleted lines and 177 of the 327 added lines as moved. The 6 other deleted lines are the six `private async …(` signature lines (their methods stay, without `async`); the 150 other added lines are the seven class methods' new signature lines and host literals, `SignInLockoutHost`, the module header, the per-function pointer docblocks and the new import. **2. `.d.ts` and exports (ruling `6348891160` items 1 and 2).** Built through `turbo run build` at base and `pnpm run build` of the package at head: | file | raw base | raw head | raw diff lines | normalised base | normalised head | |---|---|---|---:|---|---| | `dist/index.d.ts` | `462694a9be58c0ed9b0bc7541828e62911a2a2c874a27d5e6cea9740ae734d4b` | `462694a9be58c0ed9b0bc7541828e62911a2a2c874a27d5e6cea9740ae734d4b` | 0 | `05e3e4dc5f32e40f6d16b2f48a4e132b4315a9c98b9478941b0351154e0610df` | `05e3e4dc5f32e40f6d16b2f48a4e132b4315a9c98b9478941b0351154e0610df` | | `dist/index.d.mts` | `ea7e59d064510a044cf630f63b2c9f4b5cd41dd22d32b326267abfd870272fdb` | `ea7e59d064510a044cf630f63b2c9f4b5cd41dd22d32b326267abfd870272fdb` | 0 | `80fc0552ec518819842bad5e3fab8cc24075c382a5693594a1428b9e73fd6e48` | `80fc0552ec518819842bad5e3fab8cc24075c382a5693594a1428b9e73fd6e48` | - 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 assertAccountNotLocked;` → `assertAccountNotLocked;`, line 4015, a one-line change) hashes raw `5e437fc5736b14b2261fbf4976a1e7c5d47d3fadd275566e4ce8af718aabdcee` and normalised `ec108851cd97419575810eec1cbc85dad9a983519eeb3ee90ef07d1646f52dd1`, both different from the readings above. - **Export lists:** ESM `dist/index.mjs` 155 names and CJS `dist/index.js` 155 names at base and at head, the same list (sha256 of the sorted names `ed3ac8b902031d30…` on all four). The bundle's bytes differ (`index.mjs`), as a move of code inside it must. **3. Tests.** `pnpm exec vitest run --maxWorkers=3` in `packages/plugins/plugin-auth`, at head and at base in the same container: `Test Files 185 passed (185)`, `Tests 3493 passed | 10 skipped (3503)` on both, VERDICT command-exit 0 on both, and the same at `75983184cc`. `pnpm --filter @objectstack/plugin-auth typecheck` (both `tsc` projects and `check:test-typecheck`): exit 0. `eslint --no-inline-config` (the root `lint` script's flags) over the 3 files: exit 0. **4. Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 89 commands for the 5 paths (59 for the three source paths, 30 more for the two census documents); all 89 ran at `f898b1e719` and again at `75983184cc` after the full build, each exit code captured before any pipe, all 89 exit 0 both times. `--ran`: 89 derived, 89 run, 0 NOT-MEASURED, 0 UNRUN. At `4ee263903a` (before the census commit) the derivation was 59 and the only two that exited 1 were `node scripts/check-tenant-audit-census.mjs` and its `--self-test` (the per-file drift and the two prose counts above); after `node scripts/tenant-audit-census.mjs --write` and the two prose edits both exit 0, and a further `--write` changes only the not-compared `Measured on … at` stamp. Measured at base and head: the census reads 236 sites with 0 unresolved and 0 unledgered receivers on both, and every site's object, tenancy, options and elevation reading is the same. `check-governed-merges.mjs --branch HEAD`: NOT governed, 521 changed lines. Docs drift (`node scripts/docs-audit/affected-docs.mjs --json 49185cd`, exit 0): 6 pages, each reached through the `AuthManager` class name, the `durationSeconds` name (a field of the class, not a moved line), or a literal the moved bodies carry unchanged (`ACCOUNT_LOCKED`, `locked_until`, `login_failed`, `last_login_ip`); no page restates a rule this move changes, so no page edit is owed beyond the census's own two documents. ## Changeset None: `skip-changeset`, per ruling `6348891160` item 4. The published declarations and export lists are unchanged; 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 d196748 commit 9dc1f42

5 files changed

Lines changed: 338 additions & 179 deletions

File tree

‎content/docs/permissions/tenant-audit-census.mdx‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ what moved this page's population from 225 to 227; nothing about the two sites
9393
changed, only whether this instrument could see them.
9494

9595
**The expensive failure direction is a keyword.** Sites whose receiver the author
96-
typed `any` have no type to read, and there are 41 of them — just under a fifth
96+
typed `any` have no type to read, and there are 44 of them — just under a fifth
9797
of the population, concentrated in exactly the seed and bootstrap paths this
9898
control exists for. Scoring an unreadable receiver as "not an engine" would have
9999
dropped every one of them silently, with a clean exit and a smaller number that
@@ -210,7 +210,7 @@ be stated is what this instrument counts, which is written above and re-runnable
210210
at any commit.
211211

212212
Two structural facts do plausibly widen this reading against any hand or regex
213-
one, and both are counted in the generated tables below: the 41 sites reached
213+
one, and both are counted in the generated tables below: the 44 sites reached
214214
through an erased (`any`) receiver, and the 51 that name their object through a
215215
`const` rather than inline. An instrument that read either the way a person does
216216
would report a smaller number and would not say so.
@@ -249,8 +249,8 @@ cannot read, and they are neither in nor out.
249249

250250
| how the instrument reached the site | count |
251251
| :--- | ---: |
252-
| receiver carried a readable engine type | 196 |
253-
| receiver erased, placed by the object NAME | 31 |
252+
| receiver carried a readable engine type | 193 |
253+
| receiver erased, placed by the object NAME | 34 |
254254
| receiver erased, placed by an `object: string` PARAMETER | 5 |
255255
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |
256256

@@ -307,11 +307,11 @@ holds still. They are required to be HERE and to say WHEN they were true;
307307
their values are not compared. The reasoning, and the measurement behind it,
308308
are in `scripts/check-tenant-audit-census.mjs`.
309309

310-
Measured on 2026-10-22 at `4d4e2390c`.
310+
Measured on 2026-10-22 at `75983184c`.
311311

312312
| corpus scale (not enforced) | count |
313313
| :--- | ---: |
314-
| tracked non-test sources scanned | 706 |
314+
| tracked non-test sources scanned | 708 |
315315
| engine-shaped types recognised | 87 |
316316
| declared objects in the registry | 117 |
317317
| same-named calls subtracted as non-engine | 171 |

‎docs/audits/2026-08-tenant-audit-write-call-sites.counts.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,11 @@ holds still. They are required to be HERE and to say WHEN they were true;
9090
their values are not compared. The reasoning, and the measurement behind it,
9191
are in `scripts/check-tenant-audit-census.mjs`.
9292

93-
Measured on 2026-10-22 at `4d4e2390c`.
93+
Measured on 2026-10-22 at `75983184c`.
9494

9595
| corpus scale (not enforced) | count |
9696
| :--- | ---: |
97-
| tracked non-test sources scanned | 706 |
97+
| tracked non-test sources scanned | 708 |
9898
| engine-shaped types recognised | 87 |
9999
| declared objects in the registry | 117 |
100100
| same-named calls subtracted as non-engine | 171 |
@@ -130,10 +130,11 @@ Measured on 2026-10-22 at `4d4e2390c`.
130130
| `packages/plugins/plugin-auth/src/admin-user-endpoints.ts` | `update` | `sys_user` | enabled | elevated | 1 |
131131
| `packages/plugins/plugin-auth/src/adopt-membership.ts` | `update` | `SystemObjectName.MEMBER` | undecidable | PROVABLY NONE | 1 |
132132
| `packages/plugins/plugin-auth/src/audience-gate-test-support.ts` | `insert` | `sys_invitation` | enabled | elevated | 1 |
133+
| `packages/plugins/plugin-auth/src/auth-manager-sign-in-lockout.ts` | `update` | `sys_user` | enabled | options unreadable | 3 |
133134
| `packages/plugins/plugin-auth/src/auth-manager.ts` | `update` | `sys_account` | enabled | options unreadable | 1 |
134135
| `packages/plugins/plugin-auth/src/auth-manager.ts` | `update` | `sys_session` | enabled | options unreadable | 3 |
135136
| `packages/plugins/plugin-auth/src/auth-manager.ts` | `update` | `sys_two_factor` | enabled | options unreadable | 1 |
136-
| `packages/plugins/plugin-auth/src/auth-manager.ts` | `update` | `sys_user` | enabled | options unreadable | 8 |
137+
| `packages/plugins/plugin-auth/src/auth-manager.ts` | `update` | `sys_user` | enabled | options unreadable | 5 |
137138
| `packages/plugins/plugin-auth/src/auth-manager.ts` | `insert` | `sys_user_permission_set` | enabled | elevated | 1 |
138139
| `packages/plugins/plugin-auth/src/auth-manager.ts` | `delete` | `sys_verification` | enabled | elevated | 1 |
139140
| `packages/plugins/plugin-auth/src/auth-manager.ts` | `insert` | `sys_verification` | enabled | elevated | 1 |

‎packages/plugins/plugin-auth/src/api-error-code.pin.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ describe('every new APIError( in plugin-auth/src carries an ADR-0112 code (#2424
175175
// throw does not have to edit this line; only losing them does.
176176
// [#25224] `AuthManager`'s throws are counted over the files its methods'
177177
// bodies live in: `auth-manager.ts` and the bodies moved out of it whole.
178-
const authManagerFiles = ['auth-manager.ts', 'auth-manager-actor-guards.ts'];
178+
const authManagerFiles = ['auth-manager.ts', 'auth-manager-actor-guards.ts', 'auth-manager-sign-in-lockout.ts'];
179179
expect(sites.length).toBeGreaterThanOrEqual(30);
180180
expect(new Set(sites.map((s) => s.file)).size).toBeGreaterThanOrEqual(4);
181181
expect(perFile.filter((f) => authManagerFiles.includes(f.file)).reduce((n, f) => n + f.sites.length, 0)).toBeGreaterThanOrEqual(24);
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25224 cut 2] The sign-in lockout path of `AuthManager` (ADR-0069 D2) —
5+
* the locked-account gate before the credential check, the failure counter
6+
* and lock stamp after it, the `login_failed` row of a refused attempt, the
7+
* account a sign-in identifier resolves to, the second factor's lockout
8+
* projection — and the last-login stamp the same after-hook writes (D7),
9+
* moved out of `auth-manager.ts` whole.
10+
*
11+
* A MOVE, not a redesign. Each function below is the body of the
12+
* `AuthManager` method of the same name as it stood, re-indented one level
13+
* and otherwise unedited: the same reads and writes, the same refusal with
14+
* the same code, the same audit rows, failing open exactly where they did.
15+
*
16+
* ## How the moved bodies reach the class
17+
*
18+
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
19+
* header carries the full reasoning) and `auth-manager-actor-guards.ts`
20+
* follows, applied per method. The class keeps every method — the published
21+
* `dist/*.d.ts` lists each one's name, docblock and position — and each body
22+
* is one literal of exactly the {@link SignInLockoutHost} members that method
23+
* reads, plus a `.call`. The methods are no longer `async`: each returns the
24+
* moved function's own promise, so a caller awaits the same promise the body
25+
* settles, with no extra wrapper. A method is handed over as a closure that
26+
* calls it on the manager at call time, so a test double or spy set on the
27+
* manager is still the one the body reaches — `findSignInAccount` and
28+
* `resolveSignInAccountId` included, which bodies here ask through the
29+
* manager rather than calling the function here directly.
30+
*
31+
* The one field, `config`, goes as a GETTER: the class reassigns it after
32+
* construction (`setBaseUrl` and `applyConfigPatch`), and the bodies read it
33+
* after an `await`, so a value copied when the method was called could part
34+
* from the field the body used to read.
35+
*
36+
* Nothing re-exports this module, so the published surface does not move.
37+
*/
38+
39+
import type { AuthManager } from './auth-manager.js';
40+
import type { IDataEngine } from '@objectstack/core';
41+
import type { EngineQueryOptions } from '@objectstack/spec/data';
42+
import {
43+
accountLockedEventFor,
44+
emitAuthSessionAuditEvent,
45+
refusalCodeOf,
46+
signInClientFingerprint,
47+
signInFailureEventFor,
48+
SIGN_IN_EMAIL_PATH,
49+
} from './auth-session-audit.js';
50+
51+
/**
52+
* The `AuthManager` members the functions below read — every one of them, and
53+
* no other; each function's `this` is the `Pick` of the members IT reads. Each
54+
* is typed AS the class member (an indexed access on the class), so a change
55+
* to a member's signature on the class is a change here too, and naming a
56+
* member the class does not have is a compile error. See the module header for
57+
* why `config` is a getter.
58+
*/
59+
export interface SignInLockoutHost {
60+
readonly config: AuthManager['config'];
61+
findSignInAccount: AuthManager['findSignInAccount'];
62+
getDataEngine: AuthManager['getDataEngine'];
63+
logDurabilityDegradation: AuthManager['logDurabilityDegradation'];
64+
resolveSignInAccountId: AuthManager['resolveSignInAccountId'];
65+
}
66+
67+
/** The body of `AuthManager#assertAccountNotLocked`; the method's docblock there is its contract. */
68+
export async function assertAccountNotLocked(this: Pick<SignInLockoutHost, 'config' | 'findSignInAccount' | 'getDataEngine'>, email: string, ctx?: unknown): Promise<void> {
69+
const threshold = Number(this.config.lockoutThreshold) || 0;
70+
if (threshold <= 0) return;
71+
const engine = this.getDataEngine();
72+
if (!engine) return;
73+
let locked = false;
74+
let lockedUserId: string | undefined;
75+
try {
76+
const u = await this.findSignInAccount(engine, email, ['locked_until']);
77+
const lu = u?.locked_until as string | number | Date | null | undefined;
78+
locked = !!(lu && new Date(lu).getTime() > Date.now());
79+
if (locked && u) lockedUserId = u.id;
80+
} catch {
81+
return; // fail-open
82+
}
83+
if (locked) {
84+
if (ctx !== undefined && lockedUserId) {
85+
await emitAuthSessionAuditEvent(
86+
this.config.getAuditSink?.(),
87+
signInFailureEventFor(lockedUserId, {
88+
path: SIGN_IN_EMAIL_PATH,
89+
reason: 'ACCOUNT_LOCKED',
90+
fingerprint: signInClientFingerprint(ctx),
91+
}),
92+
);
93+
}
94+
const { APIError } = await import('better-auth/api');
95+
throw new APIError('FORBIDDEN', {
96+
message:
97+
'This account is temporarily locked after too many failed sign-in ' +
98+
'attempts. Try again later or ask an administrator to unlock it.',
99+
code: 'ACCOUNT_LOCKED',
100+
});
101+
}
102+
}
103+
104+
/** The body of `AuthManager#recordSignInOutcome`; the method's docblock there is its contract. */
105+
export async function recordSignInOutcome(
106+
this: Pick<SignInLockoutHost, 'config' | 'findSignInAccount' | 'getDataEngine' | 'logDurabilityDegradation'>,
107+
email: string,
108+
success: boolean,
109+
): Promise<{ userId: string; lockedUntil: Date; threshold: number } | undefined> {
110+
const threshold = Number(this.config.lockoutThreshold) || 0;
111+
if (threshold <= 0) return undefined;
112+
const engine = this.getDataEngine();
113+
if (!engine) return undefined;
114+
try {
115+
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] };
116+
const u = await this.findSignInAccount(engine, email, ['failed_login_count', 'locked_until']);
117+
if (!u) return undefined;
118+
if (success) {
119+
if ((Number(u.failed_login_count) || 0) !== 0 || u.locked_until) {
120+
await engine.update(
121+
'sys_user',
122+
{ id: u.id, failed_login_count: 0, locked_until: null },
123+
{ context: SYSTEM_CTX } as any,
124+
);
125+
}
126+
return undefined;
127+
}
128+
const next = (Number(u.failed_login_count) || 0) + 1;
129+
const patch: Record<string, unknown> = { id: u.id, failed_login_count: next };
130+
let lockedUntil: Date | undefined;
131+
if (next >= threshold) {
132+
const mins = Number(this.config.lockoutDurationMinutes) || 15;
133+
lockedUntil = new Date(Date.now() + mins * 60_000);
134+
patch.locked_until = lockedUntil;
135+
}
136+
await engine.update('sys_user', patch, { context: SYSTEM_CTX } as any);
137+
return lockedUntil ? { userId: u.id, lockedUntil, threshold } : undefined;
138+
} catch (e) {
139+
// Never break the auth response. [#12981] But lockout accounting that
140+
// silently stops accounting is brute-force protection that is OFF.
141+
this.logDurabilityDegradation(
142+
'AuthManager: the sign-in lockout counter was NOT written — the auth response is '
143+
+ 'unaffected, so nothing looks broken. This attempt was not counted, which means '
144+
+ 'consecutive failures do not accumulate and the account is never locked: '
145+
+ '`lockoutThreshold` (ADR-0069 D2) is configured and, for this identity, not enforced. '
146+
+ 'A brute-force run against this account meets no limit while this persists, and the '
147+
+ 'only notice is this line. Remedy: make the sys_user update land — check the write '
148+
+ 'permission on `failed_login_count` / `locked_until` and driver connectivity.',
149+
e,
150+
{ object: 'sys_user', threshold, success },
151+
);
152+
return undefined;
153+
}
154+
}
155+
156+
/** The body of `AuthManager#auditRefusedSignIn`; the method's docblock there is its contract. */
157+
export async function auditRefusedSignIn(
158+
this: Pick<SignInLockoutHost, 'config' | 'resolveSignInAccountId'>,
159+
ctx: any,
160+
email: string,
161+
lockStamped: { userId: string; lockedUntil: Date; threshold: number } | undefined,
162+
): Promise<void> {
163+
const sink = this.config.getAuditSink?.();
164+
if (!sink) return;
165+
const userId = lockStamped?.userId ?? (await this.resolveSignInAccountId(email));
166+
if (!userId) return;
167+
const fingerprint = signInClientFingerprint(ctx);
168+
await emitAuthSessionAuditEvent(
169+
sink,
170+
signInFailureEventFor(userId, {
171+
path: SIGN_IN_EMAIL_PATH,
172+
reason: refusalCodeOf(ctx?.context?.returned),
173+
fingerprint,
174+
}),
175+
);
176+
if (lockStamped) {
177+
await emitAuthSessionAuditEvent(
178+
sink,
179+
accountLockedEventFor(lockStamped.userId, {
180+
path: SIGN_IN_EMAIL_PATH,
181+
lockedUntil: lockStamped.lockedUntil,
182+
threshold: lockStamped.threshold,
183+
fingerprint,
184+
}),
185+
);
186+
}
187+
}
188+
189+
/** The body of `AuthManager#resolveSignInAccountId`; the method's docblock there is its contract. */
190+
export async function resolveSignInAccountId(this: Pick<SignInLockoutHost, 'findSignInAccount' | 'getDataEngine' | 'logDurabilityDegradation'>, email: string): Promise<string | undefined> {
191+
const engine = this.getDataEngine();
192+
if (!engine) return undefined;
193+
try {
194+
return (await this.findSignInAccount(engine, email, []))?.id;
195+
} catch (e) {
196+
// The refusal stands either way; what is lost is its ledger row.
197+
this.logDurabilityDegradation(
198+
'AuthManager: a refused sign-in was NOT recorded in sys_audit_log — the account it named '
199+
+ 'could not be resolved, so its `login_failed` row was not written. The refusal itself was '
200+
+ 'answered normally, so nothing looks broken, and the Auth preset of Setup → Audit Log is '
201+
+ 'missing this attempt. Remedy: make the sys_user read land — check driver connectivity.',
202+
e,
203+
{ object: 'sys_user', action: 'login_failed' },
204+
);
205+
return undefined;
206+
}
207+
}
208+
209+
/** The body of `AuthManager#findSignInAccount`; the method's docblock there is its contract. */
210+
export async function findSignInAccount(
211+
engine: IDataEngine,
212+
email: string,
213+
fields: readonly string[],
214+
): Promise<({ id: string } & Record<string, unknown>) | undefined> {
215+
const query: EngineQueryOptions = {
216+
where: { email: email.toLowerCase() },
217+
fields: ['id', ...fields],
218+
context: { isSystem: true, positions: [], permissions: [] },
219+
};
220+
const row = await engine.findOne('sys_user', query);
221+
return row && typeof row.id === 'string' && row.id
222+
? (row as { id: string } & Record<string, unknown>)
223+
: undefined;
224+
}
225+
226+
/** The body of `AuthManager#resolveTwoFactorAccountLockout`; the method's docblock there is its contract. */
227+
export function resolveTwoFactorAccountLockout(this: Pick<SignInLockoutHost, 'config'>): { enabled: boolean; maxFailedAttempts: number; durationSeconds: number } | undefined {
228+
const threshold = Number(this.config.lockoutThreshold) || 0;
229+
if (threshold <= 0) return undefined;
230+
const minutes = Number(this.config.lockoutDurationMinutes) || 15;
231+
return {
232+
enabled: true,
233+
maxFailedAttempts: threshold,
234+
durationSeconds: minutes * 60,
235+
};
236+
}
237+
238+
/** The body of `AuthManager#stampLastLogin`; the method's docblock there is its contract. */
239+
export async function stampLastLogin(this: Pick<SignInLockoutHost, 'getDataEngine' | 'logDurabilityDegradation'>, userId: string, ip: string | undefined): Promise<void> {
240+
const engine = this.getDataEngine();
241+
if (!engine || !userId) return;
242+
try {
243+
const SYSTEM_CTX = { isSystem: true, attributedUserId: userId, positions: [], permissions: [] };
244+
const patch: Record<string, unknown> = { id: userId, last_login_at: new Date() };
245+
// Cap to the column width (IPv6 textual max 45) — a malformed/oversized
246+
// forwarded header must not blow up the write.
247+
if (ip) patch.last_login_ip = ip.slice(0, 45);
248+
await engine.update('sys_user', patch, { context: SYSTEM_CTX } as any);
249+
} catch (e) {
250+
// Never break the auth response. [#12981] But this write is the ONLY
251+
// trace the sign-in leaves in the compliance ledger.
252+
this.logDurabilityDegradation(
253+
'AuthManager: the last-login stamp was NOT written — the sign-in succeeded and the user '
254+
+ 'holds a valid session, so nothing looks wrong. `last_login_at` / `last_login_ip` keep '
255+
+ 'their previous values, and because this update is what plugin-audit turns into the '
256+
+ 'change-trail row, this sign-in leaves NO trace in the compliance ledger at '
257+
+ 'all: "when did this account last sign in, and from where" now answers with a stale '
258+
+ 'row, and a login from a new address is exactly what that trail is read for. Remedy: '
259+
+ 'make the sys_user update land; the next sign-in that lands re-stamps it, but this one '
260+
+ 'is not recoverable.',
261+
e,
262+
{ object: 'sys_user', fields: ['last_login_at', 'last_login_ip'] },
263+
);
264+
}
265+
}

0 commit comments

Comments
 (0)