@ultimat3/auth 10.0.0 → 11.1.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
@@ -98,6 +98,13 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
98
98
  a limiter built through the seam holds the clock its host handed it, and that is the clock every
99
99
  `at_ms` in those tables was written from. `AuthLimiter.purgeExpired` is OPTIONAL so
100
100
  `createAuthLimiter` can keep bounding itself; a limiter with no table declares nothing.
101
+ **What the seam RETAINS is one limiter per window, `As of 2026-08-23`** — a `Map` keyed by
102
+ `windowMs`, because the widest window is the only thing a purge reads. It was a list appended to
103
+ on every `installedAuthLimiter` call, two per `defineAuth`, trimmed by nothing: a process that
104
+ redefines auth (`x dev`'s reload, a test file, a host building one `Auth` per app) held every
105
+ limiter it ever built and the store behind each. `installedLimiterCount()` is the only
106
+ observation of that growth — a purge sweeps exactly one limiter however many are held — and it
107
+ is not exported from `src/index.ts`.
101
108
 
102
109
  - **`normaliseEmail` is the ONE normalisation, it lives ABOVE the `AuthAdapter` seam, and no
103
110
  adapter may fold case** (`As of 2026-08`). `MemoryAdapter` lowercased and trimmed on both
@@ -268,6 +275,17 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
268
275
  what `x new` scaffolds and what every test runs against, so the duplicate path was only ever
269
276
  exercised against the permissive half of the seam: two `register()` calls at one address made two
270
277
  rows, and the second was unreachable forever. `adapter-parity.test.ts` pins both halves.
278
+ **A NULL is not a value to either index, `As of 2026-08-23`**: a Postgres unique index is NULLS
279
+ DISTINCT, so `external_id` constrains only the rows that carry one — the check was
280
+ `!== undefined`, and `oauth-login.ts` binds `grants.externalId ?? null` for every first-time
281
+ OAuth user, so the SECOND such signup on a `MemoryAdapter` app failed with `X_AUTH_WRITE_FAILED`
282
+ against a constraint production does not have.
283
+ - **`MemoryAdapter` takes a `Clock`, and every instant it stamps comes from it** —
284
+ `new MemoryAdapter(clock)`, defaulting to `systemClock`, so the no-argument construction every
285
+ test already writes is unchanged. `takeVerification` stamped `consumedAt` with the record's
286
+ own `createdAt` — the moment the link was ISSUED — where `BuiltinAdapter` writes
287
+ `consumed_at = now()`, the moment it was REDEEMED: a redemption an hour later and one a second
288
+ later recorded the identical instant, and a frozen test clock could not move either.
271
289
  - The new `AuthAdapter` members are OPTIONAL (`findUserByExternalId`, `listUsersByOrg`,
272
290
  `deleteSessionsForUser`, `deleteSessionsForOrg`, `deleteSessionsCreatedBefore`). A required
273
291
  member is a breaking change to every third-party adapter; the callers throw
package/README.md CHANGED
@@ -445,7 +445,7 @@ an adapter implementation, not a dependency of this package.
445
445
  | Driver | Use |
446
446
  |---|---|
447
447
  | `BuiltinAdapter` | Postgres via `@ultimat3/db`; takes an injected `DbClient` |
448
- | `MemoryAdapter` | `x new` before a database exists, and every test in this package |
448
+ | `MemoryAdapter` | `x new` before a database exists, and every test in this package. `new MemoryAdapter(clock)` — `systemClock` by default — stamps every instant it writes |
449
449
  | your own | implement `AuthAdapter`; DDL in `tables.ts` shows what the columns mean |
450
450
 
451
451
  **An adapter stores and matches the address it is handed — it never folds case.** `x_users.email`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/auth",
3
- "version": "10.0.0",
3
+ "version": "11.1.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": "10.0.0",
35
- "@ultimat3/db": "10.0.0",
36
- "@ultimat3/schema": "10.0.0"
34
+ "@ultimat3/core": "11.1.0",
35
+ "@ultimat3/db": "11.1.0",
36
+ "@ultimat3/schema": "11.1.0"
37
37
  }
38
38
  }
@@ -24,8 +24,20 @@ import type { AuthLimiter, AuthRateLimitPolicy } from './rate-limit';
24
24
  export type AuthLimiterFactory = (policy: AuthRateLimitPolicy) => AuthLimiter;
25
25
 
26
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[] = [];
27
+ /**
28
+ * The limiters a purge can still reach, ONE per distinct window.
29
+ *
30
+ * A list appended to per call is a leak with no ceiling: `installedAuthLimiter` runs twice per
31
+ * `defineAuth` — the account/IP bucket and the tenant bucket — and nothing trimmed it, so a
32
+ * process that redefines auth (`x dev`'s reload, a test file, a host building one `Auth` per app)
33
+ * held every limiter it ever built, and the store behind each, for its whole life.
34
+ *
35
+ * Keyed by `windowMs` because that is the only thing `purgeAuthLimits` reads: every limiter here
36
+ * writes the same two tables, so one per window is all a sweep can distinguish. The FIRST of a
37
+ * window is kept, which is the limiter the old list already swept — a second install clears the
38
+ * map, so nothing here outlives the pool it was built on either way.
39
+ */
40
+ let built = new Map<number, AuthLimiter>();
29
41
 
30
42
  /**
31
43
  * The ONE install point, the same shape as `configureKdfGate` beside it: a host that owns the
@@ -37,13 +49,13 @@ let built: AuthLimiter[] = [];
37
49
  */
38
50
  export function configureAuthLimiters(next: AuthLimiterFactory): void {
39
51
  factory = next;
40
- built = [];
52
+ built = new Map();
41
53
  }
42
54
 
43
55
  /** Back to `createAuthLimiter`, the per-process default. A host that installs one calls this on stop. */
44
56
  export function resetAuthLimiters(): void {
45
57
  factory = undefined;
46
- built = [];
58
+ built = new Map();
47
59
  }
48
60
 
49
61
  /**
@@ -55,10 +67,20 @@ export function resetAuthLimiters(): void {
55
67
  export function installedAuthLimiter(policy: AuthRateLimitPolicy): AuthLimiter | undefined {
56
68
  if (factory === undefined) return undefined;
57
69
  const limiter = factory(policy);
58
- built.push(limiter);
70
+ if (!built.has(policy.windowMs)) built.set(policy.windowMs, limiter);
59
71
  return limiter;
60
72
  }
61
73
 
74
+ /**
75
+ * How many limiters a purge can still reach. Deliberately NOT exported from `src/index.ts`: it
76
+ * exists because unbounded retention has no other observation — `purgeAuthLimits` sweeps exactly
77
+ * one limiter however many were held, so no assertion about behaviour could see the growth. The
78
+ * same shape as `@ultimat3/realtime`'s `droppedChannelFrames`.
79
+ */
80
+ export function installedLimiterCount(): number {
81
+ return built.size;
82
+ }
83
+
62
84
  /** A limiter that keeps rows somebody else has to delete. The memory limiter sweeps itself. */
63
85
  type PurgingAuthLimiter = AuthLimiter & { purgeExpired(): Promise<number> };
64
86
 
@@ -82,7 +104,7 @@ const canPurge = (limiter: AuthLimiter): limiter is PurgingAuthLimiter =>
82
104
  */
83
105
  export async function purgeAuthLimits(): Promise<number> {
84
106
  let widest: PurgingAuthLimiter | undefined;
85
- for (const limiter of built) {
107
+ for (const limiter of built.values()) {
86
108
  if (!canPurge(limiter)) continue;
87
109
  if (widest === undefined || limiter.policy.windowMs > widest.policy.windowMs) widest = limiter;
88
110
  }
@@ -2,6 +2,7 @@
2
2
  // database exists and the one every test in this package runs against — the same interface
3
3
  // Postgres and Better Auth implement, so a flow that works here works there or the seam is wrong.
4
4
 
5
+ import { type Clock, systemClock } from '@ultimat3/core';
5
6
  import type {
6
7
  AuthAccount,
7
8
  AuthAdapter,
@@ -21,12 +22,22 @@ const verificationKey = (purpose: string, identifier: string): string => `${purp
21
22
 
22
23
  export class MemoryAdapter implements AuthAdapter {
23
24
  readonly name = 'memory';
25
+ readonly #clock: Clock;
24
26
  readonly #users = new Map<string, AuthUser>();
25
27
  readonly #sessions = new Map<string, AuthSession>();
26
28
  readonly #accounts = new Map<string, AuthAccount>();
27
29
  readonly #verifications = new Map<string, AuthVerification>();
28
30
  readonly #apiKeys = new Map<string, AuthApiKeyRecord>();
29
31
 
32
+ /**
33
+ * The clock every instant this adapter stamps comes from — one argument, because a stamp is a
34
+ * fact about WHEN a call happened and a test that cannot move it can only assert a range.
35
+ * Defaults to `systemClock`, so `new MemoryAdapter()` is what it always was.
36
+ */
37
+ constructor(clock: Clock = systemClock) {
38
+ this.#clock = clock;
39
+ }
40
+
30
41
  /**
31
42
  * Exact match, because `BuiltinAdapter` issues `where email = $1` against a plain `text ...
32
43
  * unique` column and nothing folds case there. Normalising here instead made this the ONE
@@ -59,7 +70,17 @@ export class MemoryAdapter implements AuthAdapter {
59
70
  if (existing.email === input.email) {
60
71
  throw authUniqueViolation('createUser', 'x_users', 'email');
61
72
  }
62
- if (input.externalId !== undefined && existing.externalId === input.externalId) {
73
+ // `!= null` in one predicate, spelled out: a Postgres unique index is NULLS DISTINCT, so
74
+ // `external_id text unique` constrains only the rows that CARRY a value and admits
75
+ // unlimited NULLs. `!== undefined` alone made a second account with no external id collide
76
+ // with the first — and `oauth-login.ts` hands over `grants.externalId ?? null` for every
77
+ // first-time OAuth user, so the second such signup failed against a constraint production
78
+ // does not have.
79
+ if (
80
+ input.externalId !== undefined &&
81
+ input.externalId !== null &&
82
+ existing.externalId === input.externalId
83
+ ) {
63
84
  throw authUniqueViolation('createUser', 'x_users', 'external_id');
64
85
  }
65
86
  }
@@ -216,7 +237,10 @@ export class MemoryAdapter implements AuthAdapter {
216
237
  // Before the write, never after: a wrong guess that consumed the row would be an
217
238
  // unauthenticated way to kill the victim's live link, which is the Postgres adapter's rule too.
218
239
  if (!timingSafeEqual(tokenHash, record.tokenHash)) return null;
219
- const consumed: AuthVerification = { ...record, consumedAt: new Date(record.createdAt) };
240
+ // The moment it was REDEEMED, which is what `consumed_at = now()` writes on the Postgres
241
+ // side. This was `new Date(record.createdAt)` — the moment it was ISSUED — so every window
242
+ // measured from the stamp read a redemption as having happened at issue time.
243
+ const consumed: AuthVerification = { ...record, consumedAt: this.#clock.now() };
220
244
  this.#verifications.set(key, consumed);
221
245
  return consumed;
222
246
  }