@ultimat3/auth 7.0.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -49,6 +49,28 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
49
49
  on it. The point is that `Auth.rateLimit` is what an operator reads as "what this deployment
50
50
  enforces", so an injected limiter may not quietly enforce something else. Nothing here reads the
51
51
  environment to guess a replica count. `defineAuth({ limiter })` is the one install point.
52
+ - **`postgresAuthLimiter` is the shared limiter, and a row per FAILURE is what makes it correct**
53
+ (`As of 2026-08`). `assertAuthLimiterPolicy` refused a per-process limiter under
54
+ `scope: 'shared'` and there was nothing else to pass, so the declaration was unsatisfiable while
55
+ `x new` scaffolds `replicas: 2`. A counter column plus a window end would have been a FIXED
56
+ window — `maxAttempts` at the end of one window and `maxAttempts` again at the start of the next,
57
+ twice the declared allowance under the same declared numbers, on the credential path — so
58
+ failures are rows and the count is `at_ms > now - windowMs`. The insert and the count are two
59
+ statements, never one CTE: every CTE in a statement reads that statement's snapshot and cannot
60
+ see the row being written beside it, so the lockout would fire one attempt late. **Two statements
61
+ is also why the insert takes `pg_advisory_xact_lock` on the key** — `PgExecutor` accepts a
62
+ transaction handle, so two OUTER transactions each counted committed rows plus their own, both
63
+ read one short of `maxAttempts`, and both committed: three failures and an open account. The lock
64
+ parks the second transaction's insert until the first commits, so its count runs against a
65
+ snapshot that holds the first's row. Autocommit pays one no-op and no extra round trip; the
66
+ guarantee is READ COMMITTED, because a snapshot-isolated outer transaction pins its count at
67
+ transaction start and no lock can undo that. `auth.ts` records account → ip → org in that fixed
68
+ order, which is what keeps two concurrent sign-ins from taking two of these locks in opposite
69
+ ones. `greatest` on the lockout upsert EXTENDS and never shortens — two replicas do not share a
70
+ clock, and the one that lags must not be able to bring a live lockout forward. `PgExecutor` is
71
+ declared structurally
72
+ even though this package already depends on `@ultimat3/db`: the connection is the HOST's, so the
73
+ limiter takes the pool the boot opened rather than opening a second one.
52
74
  - **`normaliseEmail` is the ONE normalisation, it lives ABOVE the `AuthAdapter` seam, and no
53
75
  adapter may fold case** (`As of 2026-08`). `MemoryAdapter` lowercased and trimmed on both
54
76
  `findUserByEmail` and `createUser`; `BuiltinAdapter` issues `where email = $1` against a plain
@@ -260,6 +282,7 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
260
282
  | `session.ts` | two expiries, rotation, revocation, device list, the cookie |
261
283
  | `adapter.ts` | the seam; `builtin-adapter.ts` (Postgres) + `memory-adapter.ts` |
262
284
  | `rate-limit.ts` | per-ip, per-account and per-org buckets, lockout, scope check, `loginFailed()` |
285
+ | `rate-limit-postgres.ts` | the SHARED limiter: two tables, a row per failure, over a structural `PgExecutor` |
263
286
  | `oauth.ts` | `OAuthProvider`, PKCE, `beginOAuth`, the callback gate. No I/O, no env |
264
287
  | `oauth-builtins.ts` | the three shipped IdPs, as data. Imports only the type, so no cycle |
265
288
  | `oauth-registry.ts` | the registry: `registerOAuthProvider`, `providerFor`, `oauthProviderIds` |
@@ -294,4 +317,9 @@ Gotchas:
294
317
  this package **owns**, unconditionally, and lists the borrowed two in `AUTH_BORROWED_ERROR_CODES`
295
318
  without a title. A `hasErrorCode()` guard would suppress the `X_ERROR_CODE_DUPLICATE` that is
296
319
  supposed to fire when two packages claim one code.
297
- - Tests run against `MemoryAdapter`; nothing in this package needs a database.
320
+ - Tests run against `MemoryAdapter`; no ADAPTER test needs a database. The two exceptions are
321
+ `postgresAuthLimiter`'s, and they are exceptions in the shape the repo already has: the
322
+ scripted-executor twin (`rate-limit-postgres.test.ts`) proves the protocol with no server,
323
+ and `rate-limit-postgres.live.test.ts` is `describe.skip` without `TEST_DATABASE_URL` — the
324
+ same pairing `@ultimat3/http`'s rate-limit store and `@ultimat3/realtime`'s Postgres files
325
+ use. A limiter whose statements were never executed is a credential control nobody has run.
package/README.md CHANGED
@@ -107,8 +107,53 @@ exemption here — the token arrived in a header.
107
107
 
108
108
  `maxKeys` is not compared — it bounds one process' table, not a limit. A custom limiter therefore
109
109
  does **not** own its own configuration: the policy stays the app's single statement of the limits,
110
- and the boot check is what keeps it true. **No shared limiter ships yet, `As of 2026-08`** —
111
- `createAuthLimiter` is the only implementation in the framework.
110
+ and the boot check is what keeps it true.
111
+
112
+ **A shared limiter ships, `As of 2026-08`** — `postgresAuthLimiter({ executor, clock, policy })`,
113
+ two tables, a row per failure so the window still SLIDES across replicas. Until it landed,
114
+ `scope: 'shared'` was a declaration nothing in the framework could satisfy while `x new` scaffolded
115
+ `replicas: 2` — `maxAttempts × 2` guesses per account. `executor` is a `PgExecutor`, anything
116
+ speaking `query(text, values)`; **never `Bun.sql`**, whose `.query` is `undefined`.
117
+
118
+ ```ts
119
+ import {
120
+ type AuthAdapter,
121
+ type AuthRateLimitPolicy,
122
+ DEFAULT_AUTH_RATE_LIMIT,
123
+ defineAuth,
124
+ orgRateLimit,
125
+ type PgExecutor,
126
+ postgresAuthLimiter,
127
+ } from '@ultimat3/auth';
128
+ import { type Clock, systemClock } from '@ultimat3/core';
129
+ import { db, type SqlFragment } from '@ultimat3/db';
130
+
131
+ declare const adapter: AuthAdapter;
132
+ const clock: Clock = systemClock;
133
+
134
+ // The client this process already opened, wrapped in one line.
135
+ const client = db();
136
+ const executor: PgExecutor = {
137
+ query: <R>(text: string, values: readonly unknown[]): Promise<readonly R[]> =>
138
+ client.query<R>({ text, values } satisfies SqlFragment),
139
+ };
140
+
141
+ const rateLimit: AuthRateLimitPolicy = { ...DEFAULT_AUTH_RATE_LIMIT, scope: 'shared' };
142
+
143
+ defineAuth({
144
+ adapter,
145
+ clock,
146
+ rateLimit,
147
+ limiter: postgresAuthLimiter({ executor, clock, policy: rateLimit }),
148
+ orgLimiter: postgresAuthLimiter({ executor, clock, policy: orgRateLimit(rateLimit) }),
149
+ });
150
+ ```
151
+
152
+ Both limiters share one table: the keys are prefixed (`account:`, `ip:`, `org:`) and every limit
153
+ travels as a statement parameter, so the tenant bucket's wider allowance cannot leak into the
154
+ account bucket's. It reports `maxKeys: undefined` — there is no in-process table to bound — and
155
+ neither table forgets on its own: `limiter.purgeExpired()` from a `task` drops failures past the
156
+ window and lockouts that have expired, both measured against the injected clock.
112
157
 
113
158
  ## Providers are a registry, not a union
114
159
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/auth",
3
- "version": "7.0.0",
3
+ "version": "8.0.0",
4
4
  "description": "Sessions, passwords, OAuth, MFA and api keys — resolved to one Actor",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,8 +31,8 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "7.0.0",
35
- "@ultimat3/db": "7.0.0",
36
- "@ultimat3/schema": "7.0.0"
34
+ "@ultimat3/core": "8.0.0",
35
+ "@ultimat3/db": "8.0.0",
36
+ "@ultimat3/schema": "8.0.0"
37
37
  }
38
38
  }
package/src/index.ts CHANGED
@@ -255,6 +255,22 @@ export {
255
255
  orgKey,
256
256
  orgRateLimit,
257
257
  } from './rate-limit';
258
+ export type {
259
+ PgExecutor,
260
+ PostgresAuthLimiter,
261
+ PostgresAuthLimiterOptions,
262
+ } from './rate-limit-postgres';
263
+ export {
264
+ postgresAuthLimiter,
265
+ SQL_AUTH_FORGET_KEY,
266
+ SQL_AUTH_KEY_LOCK,
267
+ SQL_AUTH_LIMIT_TABLES,
268
+ SQL_AUTH_LOCK,
269
+ SQL_AUTH_LOCKED_UNTIL,
270
+ SQL_AUTH_PURGE,
271
+ SQL_AUTH_RECORD_FAILURE,
272
+ SQL_AUTH_RESET,
273
+ } from './rate-limit-postgres';
258
274
  export type { DisabledUser } from './revocation';
259
275
  export {
260
276
  disableUser,
package/src/jwks.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  // no credential, so a signature check has to exist before those doors are opened.
8
8
 
9
9
  import type { Clock } from '@ultimat3/core';
10
- import { systemClock } from '@ultimat3/core';
10
+ import { renderThrowable, systemClock } from '@ultimat3/core';
11
11
  import { oauthExchangeFailed, oauthTokenInvalid } from './errors';
12
12
  import { decodeJwtSegment, isRecord } from './json';
13
13
  import type { OAuthProvider } from './oauth';
@@ -115,8 +115,9 @@ export function createJwksClient(options: JwksClientOptions): JwksKeySource {
115
115
  throw oauthExchangeFailed({
116
116
  provider: options.provider,
117
117
  stage: 'jwks',
118
- detail:
119
- error instanceof Error ? error.message : 'the request failed before a response arrived',
118
+ // `renderThrowable`: this catch is the last frame that can still answer with a code, and
119
+ // the `kid` that got here came out of an attacker-supplied JWT header.
120
+ detail: renderThrowable(error),
120
121
  fix: `curl -sS -m 5 ${options.jwksUri}`,
121
122
  });
122
123
  }
@@ -3,6 +3,7 @@
3
3
  // boot, no dependency — an enterprise IdP is then three lines instead of a hand-copied table of
4
4
  // four endpoints that nobody re-checks when the vendor moves one.
5
5
 
6
+ import { renderThrowable } from '@ultimat3/core';
6
7
  import { oauthExchangeFailed } from './errors';
7
8
  import { isRecord } from './json';
8
9
  import type { OAuthProvider } from './oauth';
@@ -61,8 +62,9 @@ export async function discoverOAuthProvider(
61
62
  throw oauthExchangeFailed({
62
63
  provider: input.id,
63
64
  stage: 'discovery',
64
- detail:
65
- error instanceof Error ? error.message : 'the request failed before a response arrived',
65
+ // `renderThrowable`: an injected `fetch` may reject with anything, `instanceof` throws on a
66
+ // value that traps `getPrototypeOf`, and a bare `TypeError` here is an uncoded crash.
67
+ detail: renderThrowable(error),
66
68
  fix: `curl -sS -m 5 ${url}`,
67
69
  });
68
70
  }
@@ -4,7 +4,7 @@
4
4
  // token is verified here, so no caller downstream can forget to.
5
5
 
6
6
  import type { Clock } from '@ultimat3/core';
7
- import { EnvMissingError, renderCauseValue, systemClock } from '@ultimat3/core';
7
+ import { EnvMissingError, renderCauseValue, renderThrowable, systemClock } from '@ultimat3/core';
8
8
  import { oauthExchangeFailed, restartAt } from './errors';
9
9
  import { type IdTokenClaims, verifyIdToken } from './id-token';
10
10
  import { isRecord } from './json';
@@ -143,7 +143,10 @@ async function postForm(
143
143
  signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
144
144
  });
145
145
  } catch (error) {
146
- const reason = error instanceof Error ? error.message : 'the request failed before a response';
146
+ // `renderThrowable`, never `error.message` behind an `instanceof`: the rejection comes from
147
+ // an injected `fetch`, and `instanceof` itself throws on a value whose `getPrototypeOf` trap
148
+ // does — losing the one refusal that tells a caller the code is already spent.
149
+ const reason = renderThrowable(error);
147
150
  throw oauthExchangeFailed({
148
151
  provider,
149
152
  stage: 'token',
@@ -3,7 +3,7 @@
3
3
  // when there is not. `emailVerified` is carried honestly rather than assumed — it is what
4
4
  // decides whether this login may attach itself to an existing account by address.
5
5
 
6
- import { logger } from '@ultimat3/core';
6
+ import { logger, renderThrowable } from '@ultimat3/core';
7
7
  import { oauthExchangeFailed, restartAt } from './errors';
8
8
  import { idTokenEmailVerified, isVerifiedFlag } from './id-token';
9
9
  import { isRecord } from './json';
@@ -61,7 +61,11 @@ async function getJson(
61
61
  signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
62
62
  });
63
63
  } catch (error) {
64
- const reason = error instanceof Error ? error.message : 'the request failed before a response';
64
+ // `renderThrowable`, never `error.message` behind an `instanceof`: `fetch` is INJECTED here,
65
+ // so the rejection is whatever a driver or a proxy threw — and `instanceof` runs the value's
66
+ // own `getPrototypeOf` trap, which would replace this coded refusal with a bare `TypeError`
67
+ // raised from inside the catch that exists to raise it. Same rule as `@ultimat3/cache`.
68
+ const reason = renderThrowable(error);
65
69
  throw oauthExchangeFailed({
66
70
  provider,
67
71
  stage: 'userinfo',
@@ -0,0 +1,234 @@
1
+ // The shared credential limiter: two Postgres tables, so N replicas count one spray once and a
2
+ // lockout one pod established is visible to the rest. Without it `rateLimit.scope: 'shared'` is a
3
+ // declaration nothing can satisfy, while `x new` scaffolds `replicas: 2` — which is
4
+ // `maxAttempts × 2` guesses per account.
5
+ import type { Clock } from '@ultimat3/core';
6
+ import { accountLocked } from './errors';
7
+ import type { AuthLimiter, AuthRateLimitPolicy } from './rate-limit';
8
+
9
+ /**
10
+ * The one thing this limiter needs from the DB layer, declared structurally rather than imported.
11
+ * `@ultimat3/action`'s `idempotency-postgres.ts` and `@ultimat3/http`'s rate-limit store declare
12
+ * the same shape for the same reason: the connection belongs to the HOST, not to any of them, so
13
+ * a limiter takes the pool the boot already opened instead of opening a second one against a URL
14
+ * that was resolved once.
15
+ *
16
+ * **`Bun.sql` does not satisfy it** — `Bun.sql.query` is `undefined`; it is a tagged template
17
+ * whose positional form is `unsafe`. `@ultimat3/db`'s `DbClient.query({ text, values })` does,
18
+ * wrapped in one line, and so does a transaction handle.
19
+ */
20
+ export interface PgExecutor {
21
+ query<R>(sql: string, params: readonly unknown[]): Promise<readonly R[]>;
22
+ }
23
+
24
+ /**
25
+ * Applied by the boot, never by an app migration — the rule `SQL_IDEMPOTENCY_TABLE` follows, so
26
+ * `x dev`, the container's `web` role and `ROLE=migrate` all install it.
27
+ *
28
+ * A row per FAILURE rather than a counter per key, because the window this package enforces is a
29
+ * SLIDING one: a counter plus a window end is a fixed window, which admits `maxAttempts` at the
30
+ * end of one window and `maxAttempts` again at the start of the next — twice the declared
31
+ * allowance, under the same declared numbers, on the credential path.
32
+ */
33
+ export const SQL_AUTH_LIMIT_TABLES = `
34
+ create table if not exists x_auth_failures (
35
+ key text not null,
36
+ at_ms bigint not null
37
+ );
38
+
39
+ create index if not exists x_auth_failures_key_idx on x_auth_failures (key, at_ms);
40
+
41
+ create table if not exists x_auth_lockouts (
42
+ key text primary key,
43
+ locked_until_ms bigint not null
44
+ );
45
+
46
+ create index if not exists x_auth_lockouts_until_idx on x_auth_lockouts (locked_until_ms);
47
+ `;
48
+
49
+ /**
50
+ * The per-key serializer, spelled from documented functions only: `md5` and a hex bit-string cast,
51
+ * never `hashtext`, which is an internal with no compatibility promise.
52
+ */
53
+ export const SQL_AUTH_KEY_LOCK = "pg_advisory_xact_lock(('x' || md5($1))::bit(64)::bigint)";
54
+
55
+ /**
56
+ * `$1` key, `$2` nowMs — and a **transaction-scoped advisory lock on the key**, taken in the same
57
+ * statement, before the row lands.
58
+ *
59
+ * `PgExecutor` accepts a transaction handle (see its doc comment), and the insert and the count
60
+ * below are two statements. Without this lock two OUTER transactions recording a failure for one
61
+ * account each counted only what had COMMITTED plus their own row: with `maxAttempts: 3` and one
62
+ * failure already committed, both read two, neither locked, and both committed — three failures
63
+ * and an open account. The lock makes the second transaction wait at the insert until the first
64
+ * commits, so its count is taken against a snapshot that already holds the first's row.
65
+ *
66
+ * Autocommit is unaffected: the lock is taken and released inside the statement's own implicit
67
+ * transaction, which is one extra no-op per failure and no extra round trip. The guarantee is
68
+ * READ COMMITTED, which is what `withTransaction` opens with no `isolation:` — a caller that opts
69
+ * into `'repeatable read'` or `'serializable'` pins the count's snapshot at transaction start, and
70
+ * no lock can make a statement see a commit its own snapshot precedes.
71
+ *
72
+ * `recordFailure` always locks account → ip → org (`auth.ts`), one fixed order, so two concurrent
73
+ * sign-ins cannot take two of these locks in opposite orders and deadlock.
74
+ */
75
+ export const SQL_AUTH_RECORD_FAILURE = `
76
+ with locked as (select ${SQL_AUTH_KEY_LOCK})
77
+ insert into x_auth_failures (key, at_ms)
78
+ select $1, $2::bigint from locked
79
+ `;
80
+
81
+ /**
82
+ * `$1` key, `$2` nowMs, `$3` windowMs, `$4` lockoutMs, `$5` maxAttempts.
83
+ *
84
+ * A SECOND statement, deliberately, and not a CTE beside the insert above: every CTE in one
85
+ * statement reads that statement's snapshot, so a `count(*)` sharing it cannot see the failure
86
+ * being inserted beside it and the lock would fire one attempt late. Run afterwards, the count
87
+ * sees this caller's own row and every other replica's that has committed — and, because the
88
+ * insert holds `SQL_AUTH_KEY_LOCK`, every row a concurrent transaction on this key committed too.
89
+ *
90
+ * `greatest` on conflict EXTENDS a live lockout and never shortens one — a spray arriving during
91
+ * a lockout must not be able to reset it to a nearer deadline.
92
+ */
93
+ export const SQL_AUTH_LOCK = `
94
+ insert into x_auth_lockouts (key, locked_until_ms)
95
+ select $1, $2::bigint + $4::bigint
96
+ from x_auth_failures
97
+ where key = $1 and at_ms > $2::bigint - $3::bigint
98
+ having count(*) >= $5::bigint
99
+ on conflict (key) do update
100
+ set locked_until_ms = greatest(x_auth_lockouts.locked_until_ms, excluded.locked_until_ms)
101
+ returning locked_until_ms
102
+ `;
103
+
104
+ /** `$2` is the caller's clock: an expired lockout answers exactly as a missing one. */
105
+ export const SQL_AUTH_LOCKED_UNTIL = `
106
+ select locked_until_ms from x_auth_lockouts where key = $1 and locked_until_ms > $2::bigint
107
+ `;
108
+
109
+ /** A success clears the window AND the lockout: one round trip, because both must go together. */
110
+ export const SQL_AUTH_FORGET_KEY = `
111
+ with cleared as (delete from x_auth_failures where key = $1 returning key)
112
+ delete from x_auth_lockouts where key = $1
113
+ `;
114
+
115
+ export const SQL_AUTH_RESET = `
116
+ with cleared as (delete from x_auth_failures returning key)
117
+ delete from x_auth_lockouts
118
+ `;
119
+
120
+ /**
121
+ * `$1` nowMs, `$2` windowMs — the CALLER's clock, never `now()`. Every instant in these tables is
122
+ * written from the caller's clock, so a purge measuring against the SERVER's would delete rows by
123
+ * the offset between the two: failures that are still inside the window, and lockouts that are
124
+ * still live. The second one hands a sprayer its account back.
125
+ */
126
+ export const SQL_AUTH_PURGE = `
127
+ with dropped_failures as (
128
+ delete from x_auth_failures where at_ms <= $1::bigint - $2::bigint returning key
129
+ ), dropped_lockouts as (
130
+ delete from x_auth_lockouts where locked_until_ms <= $1::bigint returning key
131
+ )
132
+ select (select count(*) from dropped_failures) + (select count(*) from dropped_lockouts) as removed
133
+ `;
134
+
135
+ export interface PostgresAuthLimiterOptions {
136
+ readonly executor: PgExecutor;
137
+ /** No `Date.now()` in this package: every instant written and compared comes from here. */
138
+ readonly clock: Clock;
139
+ /**
140
+ * The limits to enforce. `defineAuth` compares what this limiter REPORTS against what the app
141
+ * declared, so the two must be the same object — `postgresAuthLimiter({ policy: auth.rateLimit })`
142
+ * for the account and IP buckets, and `orgRateLimit(policy)` for the tenant one.
143
+ */
144
+ readonly policy: AuthRateLimitPolicy;
145
+ }
146
+
147
+ export interface PostgresAuthLimiter extends AuthLimiter {
148
+ /**
149
+ * Drop every failure past the window and every expired lockout, and answer how many rows went.
150
+ * Neither table bounds itself — `ipKey` mints one key per source address, so a spray from an
151
+ * IPv6 /64 is a row per attempt — and Postgres forgets nothing on its own. An app runs this
152
+ * from a `task`; a row this deletes answers exactly as a missing one, so it changes no decision.
153
+ */
154
+ purgeExpired(): Promise<number>;
155
+ }
156
+
157
+ interface LockRow {
158
+ /** `bigint`, which every Postgres client hands back as a string. */
159
+ readonly locked_until_ms: number | string;
160
+ }
161
+
162
+ /**
163
+ * **Install it at `defineAuth`, beside the declaration it satisfies.** Two limiters, one table:
164
+ * the keys are prefixed (`account:`, `ip:`, `org:`) and every limit travels as a parameter, so
165
+ * the tenant bucket's wider allowance cannot leak into the account bucket's.
166
+ *
167
+ * ```ts
168
+ * const client = db();
169
+ * const executor = { query: (text, values) => client.query({ text, values }) };
170
+ * const rateLimit = { ...DEFAULT_AUTH_RATE_LIMIT, scope: 'shared' } as const;
171
+ * defineAuth({
172
+ * rateLimit,
173
+ * limiter: postgresAuthLimiter({ executor, clock, policy: rateLimit }),
174
+ * orgLimiter: postgresAuthLimiter({ executor, clock, policy: orgRateLimit(rateLimit) }),
175
+ * });
176
+ * ```
177
+ */
178
+ export function postgresAuthLimiter(options: PostgresAuthLimiterOptions): PostgresAuthLimiter {
179
+ const exec = options.executor;
180
+ const clock = options.clock;
181
+ const policy = options.policy;
182
+ const nowMs = (): number => clock.now().getTime();
183
+
184
+ const lockedUntilMs = async (key: string): Promise<number | null> => {
185
+ const rows = await exec.query<LockRow>(SQL_AUTH_LOCKED_UNTIL, [key, nowMs()]);
186
+ const row = rows[0];
187
+ return row === undefined ? null : Number(row.locked_until_ms);
188
+ };
189
+
190
+ return {
191
+ // `maxKeys` is dropped, not passed through: it bounds ONE process' table, and reporting a
192
+ // bound this limiter does not enforce is the thing `assertAuthLimiterPolicy` exists to catch.
193
+ policy: { ...policy, maxKeys: undefined, scope: 'shared' },
194
+
195
+ async assertAllowed(key): Promise<void> {
196
+ const until = await lockedUntilMs(key);
197
+ if (until === null) return;
198
+ throw accountLocked(key, Math.ceil((until - nowMs()) / 1000));
199
+ },
200
+
201
+ async recordFailure(key): Promise<void> {
202
+ const at = nowMs();
203
+ await exec.query(SQL_AUTH_RECORD_FAILURE, [key, at]);
204
+ await exec.query(SQL_AUTH_LOCK, [
205
+ key,
206
+ at,
207
+ policy.windowMs,
208
+ policy.lockoutMs,
209
+ policy.maxAttempts,
210
+ ]);
211
+ },
212
+
213
+ async recordSuccess(key): Promise<void> {
214
+ await exec.query(SQL_AUTH_FORGET_KEY, [key]);
215
+ },
216
+
217
+ async lockedUntil(key): Promise<Date | null> {
218
+ const until = await lockedUntilMs(key);
219
+ return until === null ? null : new Date(until);
220
+ },
221
+
222
+ async reset(): Promise<void> {
223
+ await exec.query(SQL_AUTH_RESET, []);
224
+ },
225
+
226
+ async purgeExpired(): Promise<number> {
227
+ const rows = await exec.query<{ readonly removed: number | string }>(SQL_AUTH_PURGE, [
228
+ nowMs(),
229
+ policy.windowMs,
230
+ ]);
231
+ return Number(rows[0]?.removed ?? 0);
232
+ },
233
+ };
234
+ }