Skip to content

Commit c849af2

Browse files
fix(plugin-auth): the account lockout resolves the account the sign-in credential check resolves (#24843)
Security fix (maintainer-ordered; no public card; detail withheld under the disclosure ruling). Clause-②: no ## The invariant this restores ADR-0069 D2's account lockout counts every refused password sign-in against the account the credential check resolved, and enforces an existing lock on every sign-in attempt that resolves to that account, whatever spelling of the identifier the attempt used. The lock gate, the failed sign-in counter, the credential check and the `login_failed` / `account_locked` account events name one account. ## What changed **`@objectstack/plugin-auth`**, `auth-manager.ts`, at the `/sign-in/email` lockout seam: - A new private resolver, `findSignInAccount(engine, email, fields)`, is the one answer to "which account does this password sign-in name". It asks `sys_user` exactly what better-auth 1.7.3's `/sign-in/email` credential check asks: `internalAdapter.findUserByEmail(email.toLowerCase())`, an equality match on the lower-cased address, which is the spelling `internalAdapter.createUser` stores. - The lock gate (`assertAccountNotLocked`), the counter (`recordSignInOutcome`) and the account events (`resolveSignInAccountId`) all read the account through it. Each keeps its own failure posture: the gate fails open, and the counter and the ledger report a lost write at `error`, as before. - The lockout state keeps its shape. It stays on the account's own `sys_user` row (`failed_login_count`, `locked_until`), and every write to it is by the row's id, so one account holds one counter. There is nothing to migrate. - The gate's and the counter's query options lose their `as any` casts, so `scripts/query-options-erasure-baseline.json` records `auth-manager.ts` at 12, down from 14 (`pnpm check:query-options-erasure --update`). **Docs.** `content/docs/permissions/authentication.mdx` states that the password-stage counter and lock belong to the account the sign-in's credential check resolves the address to. **Changeset.** `.changeset/auth-lockout-resolved-account.md`, a `@objectstack/plugin-auth` patch. ## Tests `packages/plugins/plugin-auth/src/account-lockout-resolved-account.test.ts` (new) drives a real `AuthManager` and better-auth pipeline through `handleRequest`, over the in-memory engine this package's better-auth suites share (its equality is exact, as a SQL column's is by default). The threshold is 3, and the lock is read back from the `sys_user` row. It pins: - the spellings used are ones the credential check resolves to the account: a correct password under each signs in to that account's id; - refusals under different spellings of one address accumulate toward its one lock: three `login_failed` and then one `account_locked`, all naming the account, and the event's `locked_until` equals the stored lock; - a locked account refuses the correct password under every spelling, with 403 `ACCOUNT_LOCKED`; each refusal records `login_failed` with reason `ACCOUNT_LOCKED`, no session is minted, and the stored lock does not move; - a lock reached under one spelling refuses the correct password under another; - a successful sign-in under any spelling clears the counter; - an identifier that resolves to no account changes no `sys_user` row and writes no account event, and the identifier never reaches the audit sink. The existing lockout unit cases in `auth-manager.test.ts`, the account-event cases in `auth-account-event-audit.test.ts` and the durability cases in `durability-swallow-repair.test.ts` pass unchanged. **Ablation**, run once on the committed fix with `scripts/ablation-replace.mjs` in wrap mode. One mutation of `findSignInAccount`'s query turned 5 cases red, across the new file and `auth-account-event-audit.test.ts`. The leg was restored, with the blob equal to HEAD and an empty `git diff HEAD`. The mutation's detail is withheld under the disclosure ruling. ## Gates (local, at `94ea60bd5`, after merging `origin/main` at `c3a8e07b6`) - **Build.** The dependency closure and the package itself were built with `pnpm --filter '@objectstack/plugin-auth...' build`, which exited 0. `pnpm --filter @objectstack/spec check:generated` reported all 14 generated artifacts up to date. - **Typecheck.** `pnpm --filter @objectstack/plugin-auth typecheck` exited 0. It includes `tsconfig.examples.json` and `check:test-typecheck`, whose test layer compiles the new file with no new debt. - **Tests.** `pnpm --filter @objectstack/plugin-auth test` exited 0: 169 files, 3240 passed and 10 skipped. - **Gate families.** `node scripts/pm/dispatch-gates.mjs --commands` derived 109 families for this change set, and all were run at the final head. The `--ran` reconciliation, with every exit code recorded, reads: 109 derived, 108 run with exit 0, 1 NOT MEASURED, 0 unrun. - NOT MEASURED: `node scripts/check-plugin-teardown-shape.mjs --self-test` exited 3. Its positive control is pinned to a commit that this shallow checkout cannot reach. CI runs it. - The root `test:repo` task (the vitest `repo` project) was not run locally: the diff touches no test of that project, and CI's `Test Core` runs it. --- _Generated by [Claude Code](https://claude.ai/code/session_01KqkTQNLcxVc588eZpJmp1r)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f7546a4 commit c849af2

5 files changed

Lines changed: 306 additions & 27 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
fix(plugin-auth): the account lockout resolves the account the sign-in credential check resolves
6+
7+
Clause-②: no
8+
9+
- **One account per sign-in.** The ADR-0069 D2 lock gate, its failed sign-in counter and the `login_failed` / `account_locked` account events now share one resolver. It looks the account up exactly as better-auth's `/sign-in/email` credential check does, so all four name the same account for every identifier the credential check resolves to it.
10+
- **What that guarantees.** Every refused sign-in that resolves to an account counts toward that account's one lock. An existing lock refuses every sign-in that resolves to the account, including one with the correct password. A successful sign-in clears the counter, as before. An identifier that resolves to no account changes no account and writes no account event.
11+
- **Nothing to migrate.** The lockout state stays on the account's own `sys_user` row (`failed_login_count`, `locked_until`), written by its id, and `lockoutThreshold` / `lockoutDurationMinutes` keep their meaning.

‎content/docs/permissions/authentication.mdx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,13 @@ so a locked password stage and a locked second factor are independent states. Ea
564564
counter resets on a success at its own stage, and the admin **Unlock Account**
565565
action clears both at once.
566566

567+
The password-stage counter and lock belong to the **account**, not to the text
568+
typed into the sign-in form. Both live on the `sys_user` row that the sign-in's own
569+
credential check resolves the address to. So every refused sign-in on that account
570+
counts toward its one lock, whichever spelling of the address it used. A lock refuses
571+
every sign-in that resolves to the account, the correct password included, and a
572+
successful sign-in under any spelling clears the counter.
573+
567574
Setting `lockout_threshold` to `0` disables the **password-stage** lockout only.
568575
Two-factor verification keeps a built-in limit of 10 attempts per 15 minutes,
569576
because it is the last check before a session is issued. Two-factor verification
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// ADR-0069 D2 — the account lockout belongs to the ACCOUNT, never to the text
4+
// a sign-in typed.
5+
//
6+
// ## The invariant
7+
//
8+
// A password sign-in names an account through its credential check
9+
// (better-auth's `/sign-in/email`). The lockout's counter and its lock gate
10+
// must name that same account for every identifier the credential check
11+
// resolves to it:
12+
//
13+
// - every refused sign-in that resolves to the account counts toward its one
14+
// lock, whichever spelling of the address it carried;
15+
// - an existing lock refuses every sign-in that resolves to the account, the
16+
// correct password included;
17+
// - a successful sign-in under any spelling clears the counter, as before;
18+
// - an identifier that resolves to no account changes no account and writes
19+
// no account event.
20+
//
21+
// The account events (`login_failed` / `account_locked`) name the account the
22+
// same resolution names, so the ledger and the lock never disagree.
23+
//
24+
// ## Why the server here is the real one
25+
//
26+
// "Which account does this identifier name" is answered by the credential
27+
// check, so every case drives a real `AuthManager` through
28+
// `AuthManager.handleRequest` over the in-memory engine this package's
29+
// better-auth suites share, whose equality is exact — the comparison a SQL
30+
// column makes by default. The lock state is read back from the `sys_user`
31+
// row itself.
32+
33+
import { describe, it, expect, afterAll } from 'vitest';
34+
import { AuthManager } from './auth-manager';
35+
import { createMemoryEngine } from './impersonation-bearer-rotation.test';
36+
import { inviteForAudienceGate } from './audience-gate-test-support';
37+
import { memoizeScrypt } from './__tests__/scrypt-memo.testkit';
38+
import type { AuthSessionAuditEventInput } from './auth-session-audit';
39+
40+
const scrypt = memoizeScrypt();
41+
afterAll(scrypt.restore);
42+
43+
const SECRET = 'test-secret-at-least-32-chars-long!!';
44+
const BASE = 'http://localhost:3000/api/v1/auth';
45+
/** The address the account signed up with. */
46+
const EMAIL = 'holder.lockout-account@example.com';
47+
/**
48+
* Other spellings of the same address, each of which the credential check
49+
* resolves to the account above (a correct password under each signs in).
50+
*/
51+
const SPELLINGS = [
52+
'Holder.Lockout-Account@Example.com',
53+
'HOLDER.LOCKOUT-ACCOUNT@EXAMPLE.COM',
54+
] as const;
55+
const PASSWORD = 'Corr3ct!Horse-Battery';
56+
const WRONG_PASSWORD = 'Wr0ng!Guess-Zebra-7731';
57+
const THRESHOLD = 3;
58+
59+
function deployment() {
60+
const engine = createMemoryEngine();
61+
const events: AuthSessionAuditEventInput[] = [];
62+
const manager = new AuthManager({
63+
secret: SECRET,
64+
baseUrl: 'http://localhost:3000',
65+
dataEngine: engine,
66+
lockoutThreshold: THRESHOLD,
67+
lockoutDurationMinutes: 15,
68+
getAuditSink: () => ({
69+
recordAuthEvent: async (event: AuthSessionAuditEventInput) => {
70+
events.push(event);
71+
},
72+
}),
73+
} as any);
74+
return { engine, manager, events };
75+
}
76+
77+
const post = (manager: AuthManager, path: string, body: unknown) =>
78+
manager.handleRequest(
79+
new Request(`${BASE}${path}`, {
80+
method: 'POST',
81+
headers: { 'Content-Type': 'application/json' },
82+
body: JSON.stringify(body),
83+
}),
84+
);
85+
86+
async function signUp(manager: AuthManager): Promise<void> {
87+
inviteForAudienceGate(manager, EMAIL);
88+
const res = await post(manager, '/sign-up/email', { email: EMAIL, password: PASSWORD, name: 'Holder' });
89+
expect(res.status, await res.clone().text()).toBe(200);
90+
}
91+
92+
const signIn = (manager: AuthManager, email: string, password: string) =>
93+
post(manager, '/sign-in/email', { email, password });
94+
95+
/** The status and machine code a sign-in answered. */
96+
async function outcome(res: Response): Promise<{ status: number; code: string | undefined }> {
97+
const body = (await res.clone().json().catch(() => ({}))) as { code?: unknown };
98+
return { status: res.status, code: typeof body.code === 'string' ? body.code : undefined };
99+
}
100+
101+
const REFUSED = { status: 401, code: 'INVALID_EMAIL_OR_PASSWORD' };
102+
const LOCKED = { status: 403, code: 'ACCOUNT_LOCKED' };
103+
104+
function accountRow(engine: any): any {
105+
const row = ((engine.tables.get('sys_user') ?? []) as any[]).find((r) => r.email === EMAIL);
106+
if (!row) throw new Error(`no sys_user row for ${EMAIL}`);
107+
return row;
108+
}
109+
110+
const isLocked = (row: any) => !!row.locked_until && new Date(row.locked_until).getTime() > Date.now();
111+
112+
/** The account events, in order — `login` / `logout` filtered out. */
113+
const accountEvents = (events: AuthSessionAuditEventInput[]) =>
114+
events.filter((e) => e.action === 'login_failed' || e.action === 'account_locked');
115+
116+
describe('ADR-0069 D2: the lockout counts and holds the account the credential check resolves', () => {
117+
it('each spelling below is one the credential check resolves to the account', async () => {
118+
const { engine, manager } = deployment();
119+
await signUp(manager);
120+
for (const spelling of SPELLINGS) {
121+
const res = await signIn(manager, spelling, PASSWORD);
122+
expect(res.status, spelling).toBe(200);
123+
const body = (await res.json()) as { user?: { id?: string } };
124+
expect(body.user?.id, spelling).toBe(accountRow(engine).id);
125+
}
126+
});
127+
128+
it('refusals under different spellings of one address accumulate toward its one lock', async () => {
129+
const { engine, manager, events } = deployment();
130+
await signUp(manager);
131+
const userId = String(accountRow(engine).id);
132+
events.length = 0;
133+
134+
const attempts = [SPELLINGS[0], EMAIL, SPELLINGS[1]];
135+
for (const [i, spelling] of attempts.entries()) {
136+
expect(await outcome(await signIn(manager, spelling, WRONG_PASSWORD)), spelling).toEqual(REFUSED);
137+
expect(Number(accountRow(engine).failed_login_count), spelling).toBe(i + 1);
138+
}
139+
140+
expect(isLocked(accountRow(engine))).toBe(true);
141+
const recorded = accountEvents(events);
142+
expect(recorded.map((e) => e.action)).toEqual([
143+
'login_failed',
144+
'login_failed',
145+
'login_failed',
146+
'account_locked',
147+
]);
148+
expect(recorded.every((e) => e.userId === userId)).toBe(true);
149+
expect(recorded[3]!.context?.threshold).toBe(THRESHOLD);
150+
expect(new Date(String(recorded[3]!.context?.locked_until)).getTime())
151+
.toBe(new Date(accountRow(engine).locked_until).getTime());
152+
});
153+
154+
it('a locked account refuses the correct password under every spelling', async () => {
155+
const { engine, manager, events } = deployment();
156+
await signUp(manager);
157+
const userId = String(accountRow(engine).id);
158+
for (let i = 0; i < THRESHOLD; i++) {
159+
expect(await outcome(await signIn(manager, EMAIL, WRONG_PASSWORD))).toEqual(REFUSED);
160+
}
161+
expect(isLocked(accountRow(engine))).toBe(true);
162+
const lockedUntil = new Date(accountRow(engine).locked_until).getTime();
163+
events.length = 0;
164+
165+
for (const spelling of [EMAIL, ...SPELLINGS]) {
166+
expect(await outcome(await signIn(manager, spelling, PASSWORD)), spelling).toEqual(LOCKED);
167+
}
168+
169+
expect(events.some((e) => e.action === 'login'), 'no session was minted').toBe(false);
170+
expect(accountEvents(events)).toEqual(
171+
[EMAIL, ...SPELLINGS].map(() => expect.objectContaining({
172+
action: 'login_failed',
173+
userId,
174+
context: { endpoint: '/sign-in/email', reason: 'ACCOUNT_LOCKED' },
175+
})),
176+
);
177+
// The lock is the one the account already held: the refusals neither lift nor move it.
178+
expect(new Date(accountRow(engine).locked_until).getTime()).toBe(lockedUntil);
179+
});
180+
181+
it('a lock reached under one spelling refuses the correct password under another', async () => {
182+
const { engine, manager } = deployment();
183+
await signUp(manager);
184+
for (let i = 0; i < THRESHOLD; i++) {
185+
expect(await outcome(await signIn(manager, SPELLINGS[1], WRONG_PASSWORD))).toEqual(REFUSED);
186+
}
187+
expect(isLocked(accountRow(engine))).toBe(true);
188+
189+
expect(await outcome(await signIn(manager, EMAIL, PASSWORD))).toEqual(LOCKED);
190+
expect(await outcome(await signIn(manager, SPELLINGS[0], PASSWORD))).toEqual(LOCKED);
191+
});
192+
193+
it('a successful sign-in under any spelling clears the counter', async () => {
194+
const { engine, manager } = deployment();
195+
await signUp(manager);
196+
197+
for (const spelling of SPELLINGS) {
198+
for (let i = 0; i < THRESHOLD - 1; i++) {
199+
expect(await outcome(await signIn(manager, EMAIL, WRONG_PASSWORD))).toEqual(REFUSED);
200+
}
201+
expect(Number(accountRow(engine).failed_login_count)).toBe(THRESHOLD - 1);
202+
203+
expect((await signIn(manager, spelling, PASSWORD)).status, spelling).toBe(200);
204+
expect(Number(accountRow(engine).failed_login_count), spelling).toBe(0);
205+
expect(isLocked(accountRow(engine)), spelling).toBe(false);
206+
}
207+
});
208+
209+
it('an identifier that resolves to no account changes no account and writes no account event', async () => {
210+
const { engine, manager, events } = deployment();
211+
await signUp(manager);
212+
const before = structuredClone(engine.tables.get('sys_user'));
213+
events.length = 0;
214+
215+
const unknown = ['nobody.lockout-account@example.com', 'Nobody.Lockout-Account@Example.com'];
216+
for (let i = 0; i < THRESHOLD + 1; i++) {
217+
for (const identifier of unknown) {
218+
expect(await outcome(await signIn(manager, identifier, WRONG_PASSWORD)), identifier).toEqual(REFUSED);
219+
}
220+
}
221+
222+
expect(engine.tables.get('sys_user')).toEqual(before);
223+
expect(accountEvents(events)).toEqual([]);
224+
for (const identifier of unknown) expect(JSON.stringify(events)).not.toContain(identifier);
225+
// …and the real account still signs in.
226+
expect((await signIn(manager, EMAIL, PASSWORD)).status).toBe(200);
227+
});
228+
});

‎packages/plugins/plugin-auth/src/auth-manager.ts‎

Lines changed: 59 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9284,6 +9284,10 @@ export class AuthManager {
92849284
* (`lockoutThreshold <= 0`) or no data engine is wired. Fails OPEN on a
92859285
* lookup error: an infra hiccup must never block every login.
92869286
*
9287+
* The lock it enforces is the one held by the account the credential check
9288+
* resolves the identifier to ({@link findSignInAccount}), so it holds for
9289+
* every spelling of the address that signs in to that account.
9290+
*
92879291
* The refusal is a refused sign-in of the account it names, so — given the
92889292
* endpoint context `ctx` — it writes that account's `login_failed` row with
92899293
* the refusal's own code before throwing (`auth-session-audit.ts`).
@@ -9296,13 +9300,10 @@ export class AuthManager {
92969300
let locked = false;
92979301
let lockedUserId: string | undefined;
92989302
try {
9299-
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] };
9300-
const u = await engine.findOne('sys_user', {
9301-
where: { email }, fields: ['id', 'locked_until'], context: SYSTEM_CTX,
9302-
} as any);
9303-
const lu = u?.locked_until;
9303+
const u = await this.findSignInAccount(engine, email, ['locked_until']);
9304+
const lu = u?.locked_until as string | number | Date | null | undefined;
93049305
locked = !!(lu && new Date(lu).getTime() > Date.now());
9305-
if (locked && typeof u?.id === 'string' && u.id) lockedUserId = u.id;
9306+
if (locked && u) lockedUserId = u.id;
93069307
} catch {
93079308
return; // fail-open
93089309
}
@@ -9335,6 +9336,12 @@ export class AuthManager {
93359336
* history row on every login). No-op when lockout is disabled. Never throws —
93369337
* a counter write must not turn a valid login into an error.
93379338
*
9339+
* The counter it moves is the one held by the account the credential check
9340+
* resolves the identifier to ({@link findSignInAccount}) — the account the
9341+
* gate above enforces — so every refused attempt on that account counts
9342+
* toward one lock, and every success on it clears that lock, whatever
9343+
* spelling of the address the attempt carried.
9344+
*
93389345
* Resolves to the lock it stamped — the account, the expiry and the
93399346
* threshold — when THIS call wrote `locked_until` and the write landed, and
93409347
* to `undefined` otherwise. That is the lockout transition the
@@ -9351,12 +9358,8 @@ export class AuthManager {
93519358
if (!engine) return undefined;
93529359
try {
93539360
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] };
9354-
const u = await engine.findOne('sys_user', {
9355-
where: { email },
9356-
fields: ['id', 'failed_login_count', 'locked_until'],
9357-
context: SYSTEM_CTX,
9358-
} as any);
9359-
if (!u?.id) return undefined;
9361+
const u = await this.findSignInAccount(engine, email, ['failed_login_count', 'locked_until']);
9362+
if (!u) return undefined;
93609363
if (success) {
93619364
if ((Number(u.failed_login_count) || 0) !== 0 || u.locked_until) {
93629365
await engine.update(
@@ -9376,9 +9379,7 @@ export class AuthManager {
93769379
patch.locked_until = lockedUntil;
93779380
}
93789381
await engine.update('sys_user', patch, { context: SYSTEM_CTX } as any);
9379-
return lockedUntil && typeof u.id === 'string'
9380-
? { userId: u.id, lockedUntil, threshold }
9381-
: undefined;
9382+
return lockedUntil ? { userId: u.id, lockedUntil, threshold } : undefined;
93829383
} catch (e) {
93839384
// Never break the auth response. [#12981] But lockout accounting that
93849385
// silently stops accounting is brute-force protection that is OFF.
@@ -9443,22 +9444,15 @@ export class AuthManager {
94439444

94449445
/**
94459446
* The id of the account a password sign-in for `email` is checked against,
9446-
* or `undefined`. Resolved the way better-auth's `/sign-in/email` resolves
9447-
* it — the address lower-cased (`findUserByEmail(email.toLowerCase())`),
9448-
* matching the lower-cased identifier the adapter stores — so the
9449-
* `login_failed` row names the account the credential check actually judged.
9447+
* or `undefined` — {@link findSignInAccount}'s answer, so the `login_failed`
9448+
* row names the account the credential check actually judged and the lock
9449+
* gate and counter hold.
94509450
*/
94519451
private async resolveSignInAccountId(email: string): Promise<string | undefined> {
94529452
const engine = this.getDataEngine();
94539453
if (!engine) return undefined;
94549454
try {
9455-
const query: EngineQueryOptions = {
9456-
where: { email: email.toLowerCase() },
9457-
fields: ['id'],
9458-
context: { isSystem: true, positions: [], permissions: [] },
9459-
};
9460-
const u = await engine.findOne('sys_user', query);
9461-
return typeof u?.id === 'string' && u.id ? u.id : undefined;
9455+
return (await this.findSignInAccount(engine, email, []))?.id;
94629456
} catch (e) {
94639457
// The refusal stands either way; what is lost is its ledger row.
94649458
this.logDurabilityDegradation(
@@ -9473,6 +9467,45 @@ export class AuthManager {
94739467
}
94749468
}
94759469

9470+
/**
9471+
* ADR-0069 D2 — the ONE answer to "which account does this password sign-in
9472+
* name", read by the lock gate ({@link assertAccountNotLocked}), the counter
9473+
* ({@link recordSignInOutcome}) and the account events
9474+
* ({@link resolveSignInAccountId}): the `sys_user` row with `fields`
9475+
* (plus `id`), or `undefined` when the identifier names no account.
9476+
*
9477+
* It asks exactly what better-auth's `/sign-in/email` credential check asks
9478+
* (1.7.3: `internalAdapter.findUserByEmail(email.toLowerCase())`, an
9479+
* equality match of the `email` column against the lower-cased address,
9480+
* which is also the spelling `internalAdapter.createUser` stores), so the
9481+
* gate, the counter, the ledger and the credential check name one account
9482+
* for every identifier the credential check resolves to it.
9483+
*
9484+
* The lockout state lives on that row (`failed_login_count` /
9485+
* `locked_until`) and every write to it is by the row's `id`, so one account
9486+
* holds one counter. ⛔ Never resolve the account any other way on this
9487+
* path: the lockout holds exactly as far as it names the account the
9488+
* credential check names.
9489+
*
9490+
* Throws what the engine throws; each caller keeps its own failure posture
9491+
* (the gate fails open, the counter and the ledger report the lost write).
9492+
*/
9493+
private async findSignInAccount(
9494+
engine: IDataEngine,
9495+
email: string,
9496+
fields: readonly string[],
9497+
): Promise<({ id: string } & Record<string, unknown>) | undefined> {
9498+
const query: EngineQueryOptions = {
9499+
where: { email: email.toLowerCase() },
9500+
fields: ['id', ...fields],
9501+
context: { isSystem: true, positions: [], permissions: [] },
9502+
};
9503+
const row = await engine.findOne('sys_user', query);
9504+
return row && typeof row.id === 'string' && row.id
9505+
? (row as { id: string } & Record<string, unknown>)
9506+
: undefined;
9507+
}
9508+
94769509
/**
94779510
* ADR-0069 D2 (#3690) — project the operator's lockout settings onto
94789511
* better-auth's own `twoFactor({ accountLockout })` contract, so the second

0 commit comments

Comments
 (0)