@ultimat3/auth 8.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 CHANGED
@@ -48,7 +48,7 @@ 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. `defineAuth({ limiter })` is the one install point.
51
+ environment to guess a replica count.
52
52
  - **`postgresAuthLimiter` is the shared limiter, and a row per FAILURE is what makes it correct**
53
53
  (`As of 2026-08`). `assertAuthLimiterPolicy` refused a per-process limiter under
54
54
  `scope: 'shared'` and there was nothing else to pass, so the declaration was unsatisfiable while
@@ -71,6 +71,34 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
71
71
  declared structurally
72
72
  even though this package already depends on `@ultimat3/db`: the connection is the HOST's, so the
73
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
+
74
102
  - **`normaliseEmail` is the ONE normalisation, it lives ABOVE the `AuthAdapter` seam, and no
75
103
  adapter may fold case** (`As of 2026-08`). `MemoryAdapter` lowercased and trimmed on both
76
104
  `findUserByEmail` and `createUser`; `BuiltinAdapter` issues `where email = $1` against a plain
@@ -283,6 +311,7 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
283
311
  | `adapter.ts` | the seam; `builtin-adapter.ts` (Postgres) + `memory-adapter.ts` |
284
312
  | `rate-limit.ts` | per-ip, per-account and per-org buckets, lockout, scope check, `loginFailed()` |
285
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 |
286
315
  | `oauth.ts` | `OAuthProvider`, PKCE, `beginOAuth`, the callback gate. No I/O, no env |
287
316
  | `oauth-builtins.ts` | the three shipped IdPs, as data. Imports only the type, so no cycle |
288
317
  | `oauth-registry.ts` | the registry: `registerOAuthProvider`, `providerFor`, `oauthProviderIds` |
package/README.md CHANGED
@@ -152,8 +152,38 @@ defineAuth({
152
152
  Both limiters share one table: the keys are prefixed (`account:`, `ip:`, `org:`) and every limit
153
153
  travels as a statement parameter, so the tenant bucket's wider allowance cannot leak into the
154
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.
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.
157
187
 
158
188
  ## Providers are a registry, not a union
159
189
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/auth",
3
- "version": "8.0.0",
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": "8.0.0",
35
- "@ultimat3/db": "8.0.0",
36
- "@ultimat3/schema": "8.0.0"
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
- const limiter = config.limiter ?? createAuthLimiter(clock, rateLimit);
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. An INJECTED org limiter is still compared, because
180
- // then the app has made a claim about what it enforces and `Auth.orgRateLimit` reports it.
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 orgLimiter = config.orgLimiter ?? createAuthLimiter(clock, orgLimits);
183
- if (config.orgLimiter !== undefined) assertAuthLimiterPolicy(orgLimits, config.orgLimiter);
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,
@@ -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/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. */