@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 +18 -0
- package/README.md +1 -1
- package/package.json +4 -4
- package/src/limiter-install.ts +28 -6
- package/src/memory-adapter.ts +26 -2
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": "
|
|
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": "
|
|
35
|
-
"@ultimat3/db": "
|
|
36
|
-
"@ultimat3/schema": "
|
|
34
|
+
"@ultimat3/core": "11.1.0",
|
|
35
|
+
"@ultimat3/db": "11.1.0",
|
|
36
|
+
"@ultimat3/schema": "11.1.0"
|
|
37
37
|
}
|
|
38
38
|
}
|
package/src/limiter-install.ts
CHANGED
|
@@ -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
|
-
/**
|
|
28
|
-
|
|
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.
|
|
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
|
}
|
package/src/memory-adapter.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|