@ultimat3/auth 7.0.0 → 9.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 +59 -2
- package/README.md +77 -2
- package/package.json +4 -4
- package/src/auth.ts +18 -5
- package/src/index.ts +20 -0
- package/src/jwks.ts +4 -3
- package/src/limiter-install.ts +90 -0
- package/src/oauth-discovery.ts +4 -2
- package/src/oauth-exchange.ts +5 -2
- package/src/oauth-profile.ts +6 -2
- package/src/rate-limit-postgres.ts +234 -0
- package/src/rate-limit.ts +7 -0
package/CLAUDE.md
CHANGED
|
@@ -48,7 +48,57 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
48
48
|
`maxKeys` is **not** compared: it bounds one process' table, so a shared limiter has no opinion
|
|
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
|
-
environment to guess a replica count.
|
|
51
|
+
environment to guess a replica count.
|
|
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.
|
|
74
|
+
- **`configureAuthLimiters` is the HOST's install point and it takes a FACTORY, not a limiter**
|
|
75
|
+
(`As of 2026-08`). `defineAuth({ limiter })` is still the app's, and it still wins; what was
|
|
76
|
+
missing is that `postgresAuthLimiter` shipped with **nowhere a host could install it from**.
|
|
77
|
+
`defineAuth` is the APP's call and the app does not know which pool this process opened —
|
|
78
|
+
`@ultimat3/cli`'s `startServices` resolves that long before `loadApp` imports a single app
|
|
79
|
+
module — so a scaffolded app got a per-POD lockout while `x new` scaffolds `replicas: 2` and
|
|
80
|
+
`docker/helm` runs three: `maxAttempts × N` guesses per account, and a lockout one replica
|
|
81
|
+
established invisible to the rest.
|
|
82
|
+
|
|
83
|
+
A **factory** because the boot cannot know the app's numbers. `assertAuthLimiterPolicy` compares
|
|
84
|
+
what a limiter enforces against what the app declared, so a limiter built at boot on
|
|
85
|
+
`DEFAULT_AUTH_RATE_LIMIT` is `X_AUTH_LIMITER_POLICY_MISMATCH` for every app that tuned one. The
|
|
86
|
+
factory is called with the RESOLVED policy, once per bucket, so the two halves cannot disagree —
|
|
87
|
+
and the comparison still runs on what comes back, so a factory that ignores its argument is
|
|
88
|
+
refused exactly as an injected limiter is. Precedence: `config.limiter` → the installed factory →
|
|
89
|
+
`createAuthLimiter`. `installedAuthLimiter` is deliberately NOT in `src/index.ts`, for the reason
|
|
90
|
+
`registerJob` is not in `@ultimat3/jobs`': a second caller building limiters out of band is a
|
|
91
|
+
second answer to where failures are counted.
|
|
92
|
+
|
|
93
|
+
`purgeAuthLimits()` is the other half, and it exists because `PostgresAuthLimiter.purgeExpired()`
|
|
94
|
+
had **no caller anywhere** — every failure row and every dead lockout was kept forever. It sweeps
|
|
95
|
+
only the **widest** window among the limiters the factory built: they all write the same two
|
|
96
|
+
tables, so a sweep measured on a narrower window deletes failures a wider limiter is still
|
|
97
|
+
counting, which is a sprayer buying attempts back from the cleanup job. No `nowMs` argument —
|
|
98
|
+
a limiter built through the seam holds the clock its host handed it, and that is the clock every
|
|
99
|
+
`at_ms` in those tables was written from. `AuthLimiter.purgeExpired` is OPTIONAL so
|
|
100
|
+
`createAuthLimiter` can keep bounding itself; a limiter with no table declares nothing.
|
|
101
|
+
|
|
52
102
|
- **`normaliseEmail` is the ONE normalisation, it lives ABOVE the `AuthAdapter` seam, and no
|
|
53
103
|
adapter may fold case** (`As of 2026-08`). `MemoryAdapter` lowercased and trimmed on both
|
|
54
104
|
`findUserByEmail` and `createUser`; `BuiltinAdapter` issues `where email = $1` against a plain
|
|
@@ -260,6 +310,8 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
260
310
|
| `session.ts` | two expiries, rotation, revocation, device list, the cookie |
|
|
261
311
|
| `adapter.ts` | the seam; `builtin-adapter.ts` (Postgres) + `memory-adapter.ts` |
|
|
262
312
|
| `rate-limit.ts` | per-ip, per-account and per-org buckets, lockout, scope check, `loginFailed()` |
|
|
313
|
+
| `rate-limit-postgres.ts` | the SHARED limiter: two tables, a row per failure, over a structural `PgExecutor` |
|
|
314
|
+
| `limiter-install.ts` | the host's one install point for that limiter — the factory, what it built, and the purge over it |
|
|
263
315
|
| `oauth.ts` | `OAuthProvider`, PKCE, `beginOAuth`, the callback gate. No I/O, no env |
|
|
264
316
|
| `oauth-builtins.ts` | the three shipped IdPs, as data. Imports only the type, so no cycle |
|
|
265
317
|
| `oauth-registry.ts` | the registry: `registerOAuthProvider`, `providerFor`, `oauthProviderIds` |
|
|
@@ -294,4 +346,9 @@ Gotchas:
|
|
|
294
346
|
this package **owns**, unconditionally, and lists the borrowed two in `AUTH_BORROWED_ERROR_CODES`
|
|
295
347
|
without a title. A `hasErrorCode()` guard would suppress the `X_ERROR_CODE_DUPLICATE` that is
|
|
296
348
|
supposed to fire when two packages claim one code.
|
|
297
|
-
- Tests run against `MemoryAdapter`;
|
|
349
|
+
- Tests run against `MemoryAdapter`; no ADAPTER test needs a database. The two exceptions are
|
|
350
|
+
`postgresAuthLimiter`'s, and they are exceptions in the shape the repo already has: the
|
|
351
|
+
scripted-executor twin (`rate-limit-postgres.test.ts`) proves the protocol with no server,
|
|
352
|
+
and `rate-limit-postgres.live.test.ts` is `describe.skip` without `TEST_DATABASE_URL` — the
|
|
353
|
+
same pairing `@ultimat3/http`'s rate-limit store and `@ultimat3/realtime`'s Postgres files
|
|
354
|
+
use. A limiter whose statements were never executed is a credential control nobody has run.
|
package/README.md
CHANGED
|
@@ -107,8 +107,83 @@ 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.
|
|
111
|
-
|
|
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.
|
|
156
|
+
|
|
157
|
+
**An app does not have to write any of that, `As of 2026-08-22`.** The boot fills a seam and every
|
|
158
|
+
`defineAuth` in the process picks it up:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
import { configureAuthLimiters, type PgExecutor, postgresAuthLimiter } from '@ultimat3/auth';
|
|
162
|
+
import type { Clock } from '@ultimat3/core';
|
|
163
|
+
|
|
164
|
+
declare const bootExecutor: PgExecutor;
|
|
165
|
+
declare const bootClock: Clock;
|
|
166
|
+
|
|
167
|
+
// In the HOST, before the app's modules import.
|
|
168
|
+
configureAuthLimiters((policy) =>
|
|
169
|
+
postgresAuthLimiter({ executor: bootExecutor, clock: bootClock, policy }),
|
|
170
|
+
);
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
A **factory** and not a limiter, because the host runs before the app: `defineAuth` compares what a
|
|
174
|
+
limiter enforces against what the app declared, so a limiter built at boot on the framework
|
|
175
|
+
defaults would be `X_AUTH_LIMITER_POLICY_MISMATCH` for every app that tuned its numbers. The
|
|
176
|
+
factory is called once per bucket, with the resolved policy, so the two halves cannot disagree.
|
|
177
|
+
Precedence is `defineAuth({ limiter })` → the installed factory → `createAuthLimiter`, and
|
|
178
|
+
`resetAuthLimiters()` puts the per-process default back. `@ultimat3/cli`'s `startServices` calls it
|
|
179
|
+
on every boot, so a scaffolded app gets a fleet-wide lockout with nothing to remember.
|
|
180
|
+
|
|
181
|
+
Neither table forgets on its own, and `purgeAuthLimits()` is the framework's reader for that:
|
|
182
|
+
it drops failures past the window and lockouts that have expired, measured against the clock the
|
|
183
|
+
host handed the limiter, and it sweeps only the WIDEST window installed — a sweep on a narrower
|
|
184
|
+
one deletes failures another limiter is still counting, which hands a sprayer its attempts back.
|
|
185
|
+
`@ultimat3/jobs`' `purge()` job is what calls it hourly; `x dev` and every role container declare
|
|
186
|
+
that sweep at boot.
|
|
112
187
|
|
|
113
188
|
## Providers are a registry, not a union
|
|
114
189
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/auth",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "9.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": "
|
|
35
|
-
"@ultimat3/db": "
|
|
36
|
-
"@ultimat3/schema": "
|
|
34
|
+
"@ultimat3/core": "9.0.0",
|
|
35
|
+
"@ultimat3/db": "9.0.0",
|
|
36
|
+
"@ultimat3/schema": "9.0.0"
|
|
37
37
|
}
|
|
38
38
|
}
|
package/src/auth.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { t } from '@ultimat3/schema';
|
|
|
8
8
|
import type { AuthAdapter, AuthSession, AuthUser } from './adapter';
|
|
9
9
|
import { normaliseEmail } from './email';
|
|
10
10
|
import { mfaRequired, mfaRequiredUnenforceable, sessionUnknown } from './errors';
|
|
11
|
+
import { installedAuthLimiter } from './limiter-install';
|
|
11
12
|
import type { OAuthProviderId } from './oauth';
|
|
12
13
|
import { oauthProviderIds } from './oauth-registry';
|
|
13
14
|
import {
|
|
@@ -171,16 +172,28 @@ export function defineAuth(config: AuthConfigInput): Auth {
|
|
|
171
172
|
const session: SessionPolicy = { ...DEFAULT_SESSION_POLICY, ...config.session };
|
|
172
173
|
const password: PasswordPolicy = { ...DEFAULT_PASSWORD_POLICY, ...config.password };
|
|
173
174
|
const rateLimit: AuthRateLimitPolicy = { ...DEFAULT_AUTH_RATE_LIMIT, ...config.rateLimit };
|
|
174
|
-
|
|
175
|
+
// Three answers in precedence order, and the middle one is why the seam exists: what this call
|
|
176
|
+
// passed, then what the HOST installed (`configureAuthLimiters`, filled by the boot that owns
|
|
177
|
+
// the database connection), then one process' worth of state. Without the middle arm a
|
|
178
|
+
// scaffolded app had to remember to build a shared limiter itself, which is the opposite of
|
|
179
|
+
// what this framework promises — and `x new` scaffolds two replicas.
|
|
180
|
+
const limiter =
|
|
181
|
+
config.limiter ?? installedAuthLimiter(rateLimit) ?? createAuthLimiter(clock, rateLimit);
|
|
175
182
|
assertAuthLimiterPolicy(rateLimit, limiter);
|
|
176
183
|
// The tenant bucket is a noisy-neighbour cap, not a credential-guessing allowance, so an app
|
|
177
184
|
// that declares `scope: 'shared'` for its LOCKOUT is not also required to ship a shared limiter
|
|
178
185
|
// for this one — per replica it approximates to `orgMaxAttempts × replicas`, which is a
|
|
179
|
-
// throughput ceiling and discloses nothing.
|
|
180
|
-
//
|
|
186
|
+
// throughput ceiling and discloses nothing. Only the LOCAL fallback is exempt, and exempting it
|
|
187
|
+
// is what buys that: `createAuthLimiter` always reports `'process'`, which is the one arm the
|
|
188
|
+
// scope check would refuse. A limiter somebody else supplied — injected by the app or built by
|
|
189
|
+
// the host's factory — is compared exactly as the general bucket's is, because a factory that
|
|
190
|
+
// ignores the policy it was handed otherwise enforces numbers the app never declared while
|
|
191
|
+
// `Auth.orgRateLimit` reports the app's. That asymmetry was the whole defect: the same factory
|
|
192
|
+
// is refused for one bucket and trusted for the other.
|
|
181
193
|
const orgLimits = orgRateLimit(rateLimit);
|
|
182
|
-
const
|
|
183
|
-
|
|
194
|
+
const suppliedOrgLimiter = config.orgLimiter ?? installedAuthLimiter(orgLimits);
|
|
195
|
+
const orgLimiter = suppliedOrgLimiter ?? createAuthLimiter(clock, orgLimits);
|
|
196
|
+
if (suppliedOrgLimiter !== undefined) assertAuthLimiterPolicy(orgLimits, suppliedOrgLimiter);
|
|
184
197
|
// Read through a widened local on purpose: the field's type is the literal `false`, so this
|
|
185
198
|
// branch is unreachable from TypeScript and reachable from every JS caller and every config
|
|
186
199
|
// parsed out of JSON — the same split `invariantColumns()` keeps its Proxy behind a compile
|
package/src/index.ts
CHANGED
|
@@ -120,6 +120,10 @@ export {
|
|
|
120
120
|
kdfGate,
|
|
121
121
|
resetKdfGate,
|
|
122
122
|
} from './kdf-gate';
|
|
123
|
+
export type { AuthLimiterFactory } from './limiter-install';
|
|
124
|
+
// `installedAuthLimiter` is deliberately absent: `defineAuth` is the one reader, and a second
|
|
125
|
+
// caller building limiters out of band would be a second answer to where failures are counted.
|
|
126
|
+
export { configureAuthLimiters, purgeAuthLimits, resetAuthLimiters } from './limiter-install';
|
|
123
127
|
export { MemoryAdapter } from './memory-adapter';
|
|
124
128
|
export type {
|
|
125
129
|
EnrolTotpInput,
|
|
@@ -255,6 +259,22 @@ export {
|
|
|
255
259
|
orgKey,
|
|
256
260
|
orgRateLimit,
|
|
257
261
|
} from './rate-limit';
|
|
262
|
+
export type {
|
|
263
|
+
PgExecutor,
|
|
264
|
+
PostgresAuthLimiter,
|
|
265
|
+
PostgresAuthLimiterOptions,
|
|
266
|
+
} from './rate-limit-postgres';
|
|
267
|
+
export {
|
|
268
|
+
postgresAuthLimiter,
|
|
269
|
+
SQL_AUTH_FORGET_KEY,
|
|
270
|
+
SQL_AUTH_KEY_LOCK,
|
|
271
|
+
SQL_AUTH_LIMIT_TABLES,
|
|
272
|
+
SQL_AUTH_LOCK,
|
|
273
|
+
SQL_AUTH_LOCKED_UNTIL,
|
|
274
|
+
SQL_AUTH_PURGE,
|
|
275
|
+
SQL_AUTH_RECORD_FAILURE,
|
|
276
|
+
SQL_AUTH_RESET,
|
|
277
|
+
} from './rate-limit-postgres';
|
|
258
278
|
export type { DisabledUser } from './revocation';
|
|
259
279
|
export {
|
|
260
280
|
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
|
-
|
|
119
|
-
|
|
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
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Single responsibility: the ONE ambient install point for where failed credential attempts are
|
|
2
|
+
// counted, plus the purge over whatever it built.
|
|
3
|
+
//
|
|
4
|
+
// WHY a seam and not a `defineAuth` argument: `defineAuth` is the APP's call, and the app is not
|
|
5
|
+
// the thing that knows which database this process opened. A host boot resolves the pool long
|
|
6
|
+
// before it imports app modules (`@ultimat3/cli`'s `startServices` runs before `loadApp`), so
|
|
7
|
+
// until this existed `postgresAuthLimiter` shipped with nowhere to be installed from — account
|
|
8
|
+
// lockouts stayed per-pod while the shipped chart runs three `web` replicas, and an attacker got
|
|
9
|
+
// N x the lockout budget by spreading a spray across them.
|
|
10
|
+
//
|
|
11
|
+
// WHY a FACTORY and not a limiter: `defineAuth` compares what a limiter reports against what the
|
|
12
|
+
// app declared (`assertAuthLimiterPolicy`), and the boot cannot know the app's `maxAttempts`,
|
|
13
|
+
// `windowMs` or `lockoutMs` — it has not imported the app yet. Handing over a built limiter would
|
|
14
|
+
// make every app that tunes its own numbers fail at boot with `X_AUTH_LIMITER_POLICY_MISMATCH`.
|
|
15
|
+
// The factory is called WITH the resolved policy, so the two halves cannot disagree.
|
|
16
|
+
|
|
17
|
+
import type { AuthLimiter, AuthRateLimitPolicy } from './rate-limit';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build a limiter enforcing exactly `policy`. Called once per bucket — the account/IP bucket and
|
|
21
|
+
* the tenant bucket are separate instances over one store, because they enforce different
|
|
22
|
+
* `maxAttempts` and their keys are prefix-disjoint (`account:` / `ip:` / `org:`).
|
|
23
|
+
*/
|
|
24
|
+
export type AuthLimiterFactory = (policy: AuthRateLimitPolicy) => AuthLimiter;
|
|
25
|
+
|
|
26
|
+
let factory: AuthLimiterFactory | undefined;
|
|
27
|
+
/** Every limiter this process built through the factory above, so a purge can reach them. */
|
|
28
|
+
let built: AuthLimiter[] = [];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The ONE install point, the same shape as `configureKdfGate` beside it: a host that owns the
|
|
32
|
+
* database connection says where failed attempts are counted, and every `defineAuth` in the
|
|
33
|
+
* process picks it up without the app declaring anything.
|
|
34
|
+
*
|
|
35
|
+
* A second install replaces the first and forgets what the first built — a limiter over a pool
|
|
36
|
+
* the previous boot has closed is not something a purge should still be sweeping through.
|
|
37
|
+
*/
|
|
38
|
+
export function configureAuthLimiters(next: AuthLimiterFactory): void {
|
|
39
|
+
factory = next;
|
|
40
|
+
built = [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Back to `createAuthLimiter`, the per-process default. A host that installs one calls this on stop. */
|
|
44
|
+
export function resetAuthLimiters(): void {
|
|
45
|
+
factory = undefined;
|
|
46
|
+
built = [];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `defineAuth`'s reader, and deliberately NOT exported from `src/index.ts`: a second caller
|
|
51
|
+
* building limiters out of band would be a second answer to "where are failures counted", which
|
|
52
|
+
* is the ambiguity axiom 1 refuses. `undefined` means no host installed one, and the caller falls
|
|
53
|
+
* back to the in-memory limiter.
|
|
54
|
+
*/
|
|
55
|
+
export function installedAuthLimiter(policy: AuthRateLimitPolicy): AuthLimiter | undefined {
|
|
56
|
+
if (factory === undefined) return undefined;
|
|
57
|
+
const limiter = factory(policy);
|
|
58
|
+
built.push(limiter);
|
|
59
|
+
return limiter;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A limiter that keeps rows somebody else has to delete. The memory limiter sweeps itself. */
|
|
63
|
+
type PurgingAuthLimiter = AuthLimiter & { purgeExpired(): Promise<number> };
|
|
64
|
+
|
|
65
|
+
const canPurge = (limiter: AuthLimiter): limiter is PurgingAuthLimiter =>
|
|
66
|
+
typeof limiter.purgeExpired === 'function';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Drop every failure past the window and every expired lockout the installed limiters left
|
|
70
|
+
* behind, and answer how many rows went. `0` when nothing was installed, or when what was
|
|
71
|
+
* installed keeps no rows.
|
|
72
|
+
*
|
|
73
|
+
* The WIDEST window wins, and only that limiter is swept. Every limiter here writes to the same
|
|
74
|
+
* two tables, so sweeping the narrow one would delete failures the wide one is still counting —
|
|
75
|
+
* which is a sprayer buying attempts back from the cleanup job. The same defect the http store's
|
|
76
|
+
* `purgeExpired(nowMs)` exists to prevent, one level up.
|
|
77
|
+
*
|
|
78
|
+
* No `nowMs` argument, unlike `postgresRateLimitStore.purgeExpired`: a limiter built through this
|
|
79
|
+
* seam already holds the clock its host handed it, and that is the clock every `at_ms` in those
|
|
80
|
+
* tables was written from. A second clock at the call site is exactly the mismatch that reads a
|
|
81
|
+
* frozen test clock as a 20,000,000-second refill.
|
|
82
|
+
*/
|
|
83
|
+
export async function purgeAuthLimits(): Promise<number> {
|
|
84
|
+
let widest: PurgingAuthLimiter | undefined;
|
|
85
|
+
for (const limiter of built) {
|
|
86
|
+
if (!canPurge(limiter)) continue;
|
|
87
|
+
if (widest === undefined || limiter.policy.windowMs > widest.policy.windowMs) widest = limiter;
|
|
88
|
+
}
|
|
89
|
+
return widest === undefined ? 0 : await widest.purgeExpired();
|
|
90
|
+
}
|
package/src/oauth-discovery.ts
CHANGED
|
@@ -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
|
-
|
|
65
|
-
|
|
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
|
}
|
package/src/oauth-exchange.ts
CHANGED
|
@@ -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
|
-
|
|
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',
|
package/src/oauth-profile.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/rate-limit.ts
CHANGED
|
@@ -85,6 +85,13 @@ export interface AuthLimiter {
|
|
|
85
85
|
recordSuccess(key: string): Promise<void>;
|
|
86
86
|
lockedUntil(key: string): Promise<Date | null>;
|
|
87
87
|
reset(): Promise<void>;
|
|
88
|
+
/**
|
|
89
|
+
* Drop every expired row this limiter is keeping, and answer how many went. Optional because a
|
|
90
|
+
* limiter that bounds itself has nothing to sweep — `createAuthLimiter` evicts on write, so it
|
|
91
|
+
* omits this and `purgeAuthLimits()` skips it. A limiter backed by a table declares it, and
|
|
92
|
+
* that is what makes the framework's purge job able to reach one without knowing it is Postgres.
|
|
93
|
+
*/
|
|
94
|
+
purgeExpired?(): Promise<number>;
|
|
88
95
|
}
|
|
89
96
|
|
|
90
97
|
/** What `createAuthLimiter` returns: the interface, plus the bound it keeps, observable. */
|