@ultimat3/auth 20.2.1 → 22.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
@@ -16,468 +16,149 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
16
16
 
17
17
  ## Non-negotiables
18
18
 
19
- - **A policy NUMBER is screened at `defineAuth`, `As of 2026-08-26`** (`policy-numbers.ts`,
20
- `X_CONFIG_INVALID`). `session.absoluteTtlMs`, `session.idleTtlMs`, `session.idleSlideMs`,
21
- `password.minLength`, the two argon2 costs and every `rateLimit` number. Measured with `NaN` —
22
- what `Number(process.env.SESSION_TTL_MS)` answers when the variable is unset, and not nullish, so
23
- the spread over the defaults keeps it: `now >= NaN` is false, so a session idle since 2000
24
- reported `absoluteExpired: false` AND `idleExpired: false`; `password.length < NaN` is false and
25
- the two-distinct-characters rule is guarded by `length > 0`, so the EMPTY password was accepted.
26
- A rule whose comparison is false for every input is not a loose rule, it is no rule.
27
-
28
- - **Every RUNTIME number this package bounds anything with is screened too, `As of 2026-08-26`** —
29
- `assertFiniteAuthCount` in `policy-numbers.ts`, one file for both halves. `jwks.ttlMs`
30
- (`now >= fetchedAt + NaN` is false, so a provider's key rotation is never refetched and every
31
- login against the new `kid` fails until the process restarts), the three OAuth legs' `timeoutMs`
32
- (`AbortSignal.timeout(NaN)` THROWS, and it is screened OUTSIDE each leg's `try` — inside it, the
33
- catch rendered an app config typo as the identity provider being unreachable), the limiter's
34
- `maxKeys` (`Math.max(1, Math.floor(NaN))` is `NaN`, so the sweep bounding a table half of whose
35
- keys are attacker-chosen never ran) and `mfa.drift`. **`drift` is the one that hangs**: it is a
36
- LOOP BOUND, not a comparison — `for (offset = -Infinity; offset <= Infinity; offset += 1)` never
37
- terminates, measured, synchronously, on the login path — and `NaN` makes the loop never run at
38
- all, so every correct code is rejected as if it were wrong. Editing `verifyTotp` with the screen
39
- removed WEDGES `mfa.test.ts` rather than failing it; mutate with the `NaN` case.
40
-
41
- **That sentence was false when it was written and four more sites closed it, `As of 2026-08-26`.**
42
- It named the options `defineAuth` resolves and the ones a *service* call takes, and missed every
43
- number a **cookie** or a **mailed link** is bounded by. `oauth.handshake.ttlMs` is the one that
44
- matters: `openHandshake`'s `now - issuedAt > NaN` is false, so a year-old sealed handshake — the
45
- state, nonce and PKCE verifier that ARE the callback leg's CSRF defence — opened and returned its
46
- verifier, while `handshakeCookie` wrote `Max-Age=NaN`, which is not `delta-seconds`, so the
47
- browser dropped the attribute and kept the cookie for the whole session. One number, both ends of
48
- the deadline, off together and silently. `session.cookie.maxAgeSeconds` is the same shape beside
49
- a `policy.absoluteTtlMs` that WAS screened; `kdf.maxConcurrent`/`kdf.maxQueued` are the second
50
- thing that WEDGES after `mfa.drift` — core asks `active < maxConcurrent` then
51
- `waiters.length >= maxQueued`, both false for `NaN`, so every `hashPassword` on the box parks in
52
- an unbounded queue nothing releases and login stops answering rather than shedding.
53
-
54
- - **A number is screened ABOVE the write it feeds, not beside the arithmetic** (`As of
55
- 2026-08-26`). `issueVerification` read `input.ttlMs ?? DEFAULT_VERIFICATION_TTL_MS[purpose]`
56
- straight into `new Date(now + ttl)` and threw a bare `RangeError` — `toISOString()` refuses an
57
- Invalid Date — on the line AFTER `putVerification` had already resolved. Two faults from one
58
- number: an uncoded throw out of the package, and a durable row whose expiry `consumeVerification`
59
- compares as `now >= NaN`, false for ever, i.e. a password-reset link that never expires. The
60
- write also upserts on `(purpose, identifier)`, so the failing call had destroyed whatever live
61
- token that address held on its way out. `verify.test.ts` asserts the ordering directly (no row
62
- written, no mail sent, the earlier token still redeemable) — moving the screen below the store
63
- call fails those three and leaves the coded-refusal case green.
64
-
65
- - **The minimum is per option, and zero is usually legitimate.** `assertFiniteAuthCount`'s `min` is
66
- `0 | 1` because only the call site knows what zero MEANS, and picking `1` for tidiness is how a
67
- screen breaks a working deployment while every test still passes. `maxAgeSeconds: 0` is how a
68
- cookie is expired — it is what sign-out emits — and `{ maxConcurrent: 0, maxQueued: 0 }` is a
69
- gate that refuses every hash, which is how `password.test.ts` proves the unreadable-hash path
70
- burns the same KDF a wrong password does. Both take `min: 0`. A TTL takes `min: 1`, because a
71
- handshake or a mailed link that is already expired when it is issued is not a configuration.
72
-
19
+ - **Every number this package bounds anything with is screened** (`policy-numbers.ts`): policy
20
+ numbers at `defineAuth` (`X_CONFIG_INVALID` — `session.absoluteTtlMs`, `idleTtlMs`,
21
+ `idleSlideMs`, `password.minLength`, the two argon2 costs, every `rateLimit` number), and every
22
+ runtime one through `assertFiniteAuthCount` — `jwks.ttlMs`, the three OAuth legs' `timeoutMs`
23
+ (screened OUTSIDE each leg's `try`), the limiter's `maxKeys`, `mfa.drift`,
24
+ `oauth.handshake.ttlMs`, `session.cookie.maxAgeSeconds`, `kdf.maxConcurrent`/`maxQueued`. A `NaN`
25
+ makes every comparison false: no expiry, the empty password accepted, a year-old handshake
26
+ opened. **`mfa.drift` and the KDF gate are the two that HANG** — mutate `verifyTotp` with the
27
+ `NaN` case, never by deleting the screen (that wedges `mfa.test.ts`).
28
+ - **A number is screened ABOVE the write it feeds.** `issueVerification` screens its TTL before
29
+ `putVerification` (which upserts on `(purpose, identifier)`); `verify.test.ts` asserts no row
30
+ written, no mail sent, the earlier token still redeemable.
31
+ - **The minimum is per option; zero is often legitimate.** `assertFiniteAuthCount`'s `min` is
32
+ `0 | 1`: `maxAgeSeconds: 0` is sign-out, `{ maxConcurrent: 0, maxQueued: 0 }` is how
33
+ `password.test.ts` proves an unreadable hash burns the KDF. A TTL takes `min: 1`.
73
34
  - Every credential failure throws `loginFailed()` — one code, one cause, one fix. Adding a
74
35
  parameter to it re-opens account enumeration.
75
- - **A stored hash Bun cannot read is the generic failure, and it burns the same KDF** (`As of
76
- 2026-08`). `Bun.password.verify` THROWS rather than answering `false` on an unsupported algorithm
77
- (a Django `pbkdf2_sha256$…` row: `UnsupportedAlgorithm`) or a malformed PHC string
78
- (`InvalidEncoding`), so `verifyPassword` catching nothing was two faults at once: a bare `Error`
79
- out of `login()` — a 500 where `loginFailed()`'s `X_UNAUTHENTICATED` belongs — and an enumeration
80
- oracle on exactly the rows that have not migrated off the legacy scheme, which is the normal
81
- state of a table mid-migration. `verifyAgainst` (`password.ts`) answers `null` there, and `null`
82
- joins the no-user branch, `''` with it. Nothing is logged: the algorithm of an unreadable hash is
83
- the same oracle one layer down. An `AuthError` out of the gate (`X_OVERLOADED`) is **re-thrown**,
84
- never folded into the failure — a shed is load, not a verdict. Supported-but-old stays a verdict:
85
- bcrypt verifies natively and `needsRehash` flags it, which is the rehash-on-login lever a legacy
86
- migration rewrites rows with, and `password.test.ts` pins both halves.
87
- - The limiter's table is **bounded**, and the eviction order is part of the guarantee. `ipKey`
88
- mints one entry per source address, so half the keys are attacker-chosen and a spray from an
89
- IPv6 /64 is a fresh key per attempt. Every bucket carries `forgetAtMs` — window emptied *and*
90
- lockout expired, the instant it answers exactly as a missing one — and the sweep drops those
91
- for free. `policy.maxKeys` (`DEFAULT_MAX_AUTH_LIMIT_KEYS`) is the backstop, and a **live
92
- lockout outranks its own deadline** in the comparator: without that rank a spray recorded a
93
- second later sorts ahead of the account it just locked, and filling the table becomes a way to
94
- buy attempts back. Never reduce that sort to recency.
95
- - **`AuthLimiter` is async on every member, and it declares the policy it enforces.** A
96
- synchronous signature is one no shared implementation can satisfy — a lockout that holds across
97
- replicas is a network round trip — so the interface the comment always promised was unreachable
98
- by construction. `defineAuth` resolves the app's declaration and compares it against
99
- `limiter.policy`, once, in `assertAuthLimiterPolicy`: a per-process limiter under
100
- `scope: 'shared'` is `X_AUTH_LIMITER_NOT_SHARED`, and different `maxAttempts`/`windowMs`/
101
- `lockoutMs` is `X_AUTH_LIMITER_POLICY_MISMATCH` — both at boot, never at the first spray.
102
- `maxKeys` is **not** compared: it bounds one process' table, so a shared limiter has no opinion
103
- on it. The point is that `Auth.rateLimit` is what an operator reads as "what this deployment
104
- enforces", so an injected limiter may not quietly enforce something else. Nothing here reads the
36
+ - **A stored hash Bun cannot read is the generic failure, and it burns the same KDF.**
37
+ `verifyAgainst` (`password.ts`) answers `null` for `Bun.password.verify`'s throw (unsupported
38
+ algorithm, malformed PHC) and `''`, joining the no-user branch; nothing is logged. An
39
+ `X_OVERLOADED` from the gate is RE-THROWN (a shed is load, not a verdict). Supported-but-old
40
+ (bcrypt) stays a verdict and `needsRehash` flags it. `password.test.ts` pins both halves.
41
+ - **The limiter's table is bounded, and the eviction order is the guarantee.** Every bucket carries
42
+ `forgetAtMs`; `policy.maxKeys` (`DEFAULT_MAX_AUTH_LIMIT_KEYS`) is the backstop, and a **live
43
+ lockout outranks its own deadline** in the comparator. Never reduce that sort to recency.
44
+ - **`AuthLimiter` is async on every member and declares the policy it enforces.**
45
+ `assertAuthLimiterPolicy` (once, in `defineAuth`) refuses a per-process limiter under
46
+ `scope: 'shared'` (`X_AUTH_LIMITER_NOT_SHARED`) and different `maxAttempts`/`windowMs`/
47
+ `lockoutMs` (`X_AUTH_LIMITER_POLICY_MISMATCH`). `maxKeys` is not compared. Nothing here reads the
105
48
  environment to guess a replica count.
106
- - **`postgresAuthLimiter` is the shared limiter, and a row per FAILURE is what makes it correct**
107
- (`As of 2026-08`). `assertAuthLimiterPolicy` refused a per-process limiter under
108
- `scope: 'shared'` and there was nothing else to pass, so the declaration was unsatisfiable while
109
- `x new` scaffolds `replicas: 2`. A counter column plus a window end would have been a FIXED
110
- window — `maxAttempts` at the end of one window and `maxAttempts` again at the start of the next,
111
- twice the declared allowance under the same declared numbers, on the credential path — so
112
- failures are rows and the count is `at_ms > now - windowMs`. The insert and the count are two
113
- statements, never one CTE: every CTE in a statement reads that statement's snapshot and cannot
114
- see the row being written beside it, so the lockout would fire one attempt late. **Two statements
115
- is also why the insert takes `pg_advisory_xact_lock` on the key** — `PgExecutor` accepts a
116
- transaction handle, so two OUTER transactions each counted committed rows plus their own, both
117
- read one short of `maxAttempts`, and both committed: three failures and an open account. The lock
118
- parks the second transaction's insert until the first commits, so its count runs against a
119
- snapshot that holds the first's row. Autocommit pays one no-op and no extra round trip; the
120
- guarantee is READ COMMITTED, because a snapshot-isolated outer transaction pins its count at
121
- transaction start and no lock can undo that. `auth.ts` records account → ip → org in that fixed
122
- order, which is what keeps two concurrent sign-ins from taking two of these locks in opposite
123
- ones. `greatest` on the lockout upsert EXTENDS and never shortens — two replicas do not share a
124
- clock, and the one that lags must not be able to bring a live lockout forward. `PgExecutor` is
125
- declared structurally
126
- even though this package already depends on `@ultimat3/db`: the connection is the HOST's, so the
127
- limiter takes the pool the boot opened rather than opening a second one.
128
- - **`configureAuthLimiters` is the HOST's install point and it takes a FACTORY, not a limiter**
129
- (`As of 2026-08`). `defineAuth({ limiter })` is still the app's, and it still wins; what was
130
- missing is that `postgresAuthLimiter` shipped with **nowhere a host could install it from**.
131
- `defineAuth` is the APP's call and the app does not know which pool this process opened —
132
- `@ultimat3/cli`'s `startServices` resolves that long before `loadApp` imports a single app
133
- module — so a scaffolded app got a per-POD lockout while `x new` scaffolds `replicas: 2` and
134
- `docker/helm` runs three: `maxAttempts × N` guesses per account, and a lockout one replica
135
- established invisible to the rest.
136
-
137
- A **factory** because the boot cannot know the app's numbers. `assertAuthLimiterPolicy` compares
138
- what a limiter enforces against what the app declared, so a limiter built at boot on
139
- `DEFAULT_AUTH_RATE_LIMIT` is `X_AUTH_LIMITER_POLICY_MISMATCH` for every app that tuned one. The
140
- factory is called with the RESOLVED policy, once per bucket, so the two halves cannot disagree —
141
- and the comparison still runs on what comes back, so a factory that ignores its argument is
142
- refused exactly as an injected limiter is. Precedence: `config.limiter` → the installed factory →
143
- `createAuthLimiter`. `installedAuthLimiter` is deliberately NOT in `src/index.ts`, for the reason
144
- `registerJob` is not in `@ultimat3/jobs`': a second caller building limiters out of band is a
145
- second answer to where failures are counted.
146
-
147
- `purgeAuthLimits()` is the other half, and it exists because `PostgresAuthLimiter.purgeExpired()`
148
- had **no caller anywhere** — every failure row and every dead lockout was kept forever. It sweeps
149
- only the **widest** window among the limiters the factory built: they all write the same two
150
- tables, so a sweep measured on a narrower window deletes failures a wider limiter is still
151
- counting, which is a sprayer buying attempts back from the cleanup job. No `nowMs` argument —
152
- a limiter built through the seam holds the clock its host handed it, and that is the clock every
153
- `at_ms` in those tables was written from. `AuthLimiter.purgeExpired` is OPTIONAL so
154
- `createAuthLimiter` can keep bounding itself; a limiter with no table declares nothing.
155
- **What the seam RETAINS is one limiter per window, `As of 2026-08-23`** — a `Map` keyed by
156
- `windowMs`, because the widest window is the only thing a purge reads. It was a list appended to
157
- on every `installedAuthLimiter` call, two per `defineAuth`, trimmed by nothing: a process that
158
- redefines auth (`x dev`'s reload, a test file, a host building one `Auth` per app) held every
159
- limiter it ever built and the store behind each. `installedLimiterCount()` is the only
160
- observation of that growth — a purge sweeps exactly one limiter however many are held — and it
161
- is not exported from `src/index.ts`.
162
-
163
- - **`normaliseEmail` is the ONE normalisation, it lives ABOVE the `AuthAdapter` seam, and no
164
- adapter may fold case** (`As of 2026-08`). `MemoryAdapter` lowercased and trimmed on both
165
- `findUserByEmail` and `createUser`; `BuiltinAdapter` issues `where email = $1` against a plain
166
- case-sensitive `text ... unique`. Two adapters, two answers to "does this account exist" — and
167
- `oauth-login.ts`'s `resolveUser` normalised nothing at all, carrying the provider's display
168
- casing straight through. So a provider sending `Ada@Example.com` linked the existing account
169
- under `x dev` and minted a SECOND one in production, at an address `login()` (which lowercases)
170
- could then never reach; a later `register()` at the lowercase spelling made a third. Every door
171
- now normalises before the adapter sees the address — `register`, `login`, `profileEmail` in
172
- `oauth-login.ts`, and `accountKey`, which must key the same way or one address buys a fresh
173
- lockout budget per spelling. `adapter-parity.test.ts` pins both adapters in one test, the shape
174
- `jobs/driver-parity.test.ts` established; `MemoryAdapter` normalising again is a failing test.
175
- Trim and lowercase only: stripping a `+tag` or a gmail dot MERGES two addresses a person kept
176
- apart, which is takeover between colleagues at one domain.
177
- - **`json.ts` owns `isRecord` and `decodeJwtSegment`, and this package holds no second copy.**
178
- `isRecord` was declared six times here and the base64url-JSON-payload decode three
179
- (`jwks.decodeJwtHeader`, `id-token.decodeSegment`, `workload.verifyWorkloadToken`). All six
180
- agreed that an array is not a record, which is the fact that matters: on a decoded JWT payload
181
- the check is what gates every claim read after it, and `JSON.parse('[]')` narrows to
182
- `Record<string, unknown>` without it. One declaration means one place for that to be true.
183
- `decodeJwtSegment` answers `null` for all three failures — not base64url, not JSON, not an
184
- object — because each caller has its own coded refusal to raise.
49
+ - **`postgresAuthLimiter` is the shared limiter, a row per FAILURE** (sliding window: the count is
50
+ `at_ms > now - windowMs`). The insert and the count are two statements, never one CTE, and the
51
+ insert takes `pg_advisory_xact_lock` on the key so two outer transactions cannot both read one
52
+ short (guarantee is READ COMMITTED). `auth.ts` records account → ip → org in that fixed order.
53
+ `greatest` on the lockout upsert only extends. `PgExecutor` is structural: the pool is the host's.
54
+ - **`configureAuthLimiters` is the HOST's install point and takes a FACTORY**, called with the
55
+ RESOLVED policy once per bucket; the comparison still runs on what comes back. Precedence:
56
+ `config.limiter` → the installed factory → `createAuthLimiter`. `installedAuthLimiter` and
57
+ `installedLimiterCount()` are NOT in `src/index.ts`. `purgeAuthLimits()` sweeps only the
58
+ **widest** window among the built limiters (retained one per `windowMs` in a `Map`), on the
59
+ limiter's own clock. `AuthLimiter.purgeExpired` is optional.
60
+ - **`normaliseEmail` is the ONE normalisation, it lives ABOVE the `AuthAdapter` seam, and no adapter
61
+ may fold case.** Every door now normalises before the adapter sees the address — `register`,
62
+ `login`, `profileEmail` in `oauth-login.ts`, `accountKey`, and `issueVerification`/
63
+ `consumeVerification`. Trim and lowercase only; never strip a `+tag` or a gmail dot.
64
+ `adapter-parity.test.ts` pins both adapters in one test.
65
+ - **`json.ts` owns `isRecord` and `decodeJwtSegment`; no second copy.** An array is not a record.
66
+ `decodeJwtSegment` answers `null` for all three failures; each caller raises its own refusal.
185
67
  - Absolute and idle expiry are two separate computations in `sessionExpiry()`. Do not fold them.
186
- - PKCE is not provider-dependent. `OAuthProvider.usesPkce` is the literal `true`, not `boolean`,
187
- so `usesPkce: false` is a type error rather than a comment — and there is no
188
- `if (provider.usesPkce)` branch left anywhere for it to have been false in. It stays the literal
189
- now that `registerOAuthProvider` is open to any app: the mechanism has to survive the opening.
190
- - **Providers are a registry, `OAuthProviderId` is `string`.** A closed union of three consumer
191
- IdPs made an enterprise OP *unrepresentable* — a type constraint has no runtime escape, so the
192
- only ways out were forking the package or bypassing OAuth entirely and losing PKCE, the sealed
193
- handshake, issuer pinning and account linking with it. The three built-ins seed the registry
194
- through the same `registerOAuthProvider()` an app calls, so there is still one way to do it.
195
- `providerFor(id)` throws `X_OAUTH_PROVIDER_UNKNOWN` and never answers `undefined`; a second claim
196
- on one id is `X_OAUTH_PROVIDER_DUPLICATE` at boot, never a silent replacement.
197
- - **`oauthProviderUnknown(provider, supported)` scopes its list to its reader.** The route passes
198
- `BUILTIN_OAUTH_PROVIDER_IDS` — an anonymous stranger typed that URL, and the registry now holds
199
- whatever internal OP this deployment registered. `providerFor()` passes `oauthProviderIds()` —
200
- its reader is a developer with a stack trace, and the full list is what makes the fix runnable.
201
- Neither ever passes `defineAuth({ providers })`. One code, two audiences, one sentence that stays
202
- executable either way because it names `registerOAuthProvider` before it names the list.
68
+ - PKCE is not provider-dependent: `OAuthProvider.usesPkce` is the literal `true`.
69
+ - **Providers are a registry, `OAuthProviderId` is `string`.** The three built-ins seed it through
70
+ `registerOAuthProvider()`, the call an app makes. `providerFor(id)` throws
71
+ `X_OAUTH_PROVIDER_UNKNOWN`, never `undefined`; a second claim is `X_OAUTH_PROVIDER_DUPLICATE`.
72
+ - **`oauthProviderUnknown(provider, supported)` scopes its list to its reader**: the route passes
73
+ `BUILTIN_OAUTH_PROVIDER_IDS` (an anonymous caller), `providerFor()` passes `oauthProviderIds()`.
203
74
  - **`verifyIdToken({ keys })` is required, with no default.** `'token-endpoint-tls'` is the OIDC
204
- Core 3.1.3.7 exemption stated out loud, and `exchangeOAuthCode` is the only shipped caller
205
- entitled to it. Anything else — IdP-initiated login, `form_post`, back-channel logout, token
206
- exchange — passes a `JwksKeySource` and gets the signature checked. A default is exactly what
207
- would let a second door inherit "unverified" from the first. `HS256` and `alg: none` are refused
208
- in `decodeJwtHeader` before a key is ever looked up.
209
- - **`resolveGrants` is a seam, never a group-to-role table.** It is called on EVERY login, not only
210
- at creation, or "remove them from the group in the IdP" is a no-op forever. Absent means the app
211
- has no opinion and the stored row is left alone; a seam returning the stored answer writes
212
- nothing. Creating a user with no roles and no org logs a warning — that account can do nothing.
213
- - **`verifySession` writes at most once per `idleSlideMs`** (default `idleTtlMs / 20`). Throttling
214
- the SESSION write is safe; caching the USER row is not — `authenticate` re-reads it on every
215
- request, and that is what makes a revoked role take effect on the next one with no token-expiry
216
- lag. Do not cache it.
217
-
218
- **In `verifySession`'s `observed`, `null` is an ANSWER and `undefined` is silence**
219
- (`As of 2026-09-06`). `observed?.ip ?? session.ip` collapsed the two, so an explicit
220
- `{ ip: null }` — this request had no client address — could not clear a stale stored one: the
221
- address from an earlier request was written forward on every verify, and the device list a user
222
- checks for a session they do not recognise showed it as current. An observation that names
223
- neither field still costs no write, because a change nobody reported would turn every
224
- authenticated request back into a write on the hottest table there is.
225
- - A user's `scopes` column reaches `Actor.scopes`. Hardcoding `[]` there made a scope something no
226
- human could hold, so `hasScope(actor, 'tenancy:cross')` was satisfiable only by minting a
227
- `serviceActor` inside the handler — which discards the operator's identity and makes the sweep
228
- unattributable, the exact property the required reason string exists to preserve.
75
+ Core 3.1.3.7 exemption and `exchangeOAuthCode` is its only entitled caller. `HS256` and
76
+ `alg: none` are refused in `decodeJwtHeader`. It checks `nbf` and `azp` too.
77
+ - **`resolveGrants` is a seam, never a group-to-role table**, called on EVERY login. Absent leaves
78
+ the row alone. A user created with no roles and no org logs a warning.
79
+ - **`verifySession` writes at most once per `idleSlideMs`** (default `idleTtlMs / 20`). Never cache
80
+ the USER row — `authenticate` re-reads it every request so a revoked role bites on the next one.
81
+ In `observed`, `null` is an ANSWER (clears the stored value) and `undefined` is silence.
82
+ - A user's `scopes` column reaches `Actor.scopes`.
229
83
  - Every revocation takes a `reason` and logs `auth.revocation` before it runs.
230
- `deleteSessionsForOrg` joins through `x_users`; `x_sessions` does **not** gain an `org_id`,
231
- because a denormalised membership goes stale the moment somebody moves org and the 03:00 sweep
232
- then leaves live exactly the sessions it was run to kill.
233
- - The code flow carries `nonce` inside the id token, not on the redirect. `assertOAuthCallback`
234
- checks an echoed one when present and never requires it; `verifyIdToken` is the real gate.
235
- - The handshake crosses two requests, so it is sealed (`sealHandshake`), never handed over in a
236
- variable. `openHandshake` takes the provider as an argument for the reason `decodeCursor` takes
237
- a scope: an optional check is one a call site forgets. Expiry is the server's clock, not `Max-Age`.
238
- - One handshake cookie **per provider** (`handshakeCookieName`), never one shared slot. Two tabs
239
- are two handshakes in one jar, and a shared name makes the second redirect overwrite the first.
240
- `clearHandshakeCookie(provider)` for the same reason: clearing all of them cancels the other tab.
241
- - `takeVerification(purpose, identifier, tokenHash)` consumes the row **only on a hash match**,
242
- in one conditional statement. The hash is an argument to the consume, not a comparison after it:
243
- a store that consumes first lets `{identifier:'victim@…', token:'x'}` kill the victim's live
244
- link, one request per address, unauthenticated. `consumeVerification` still compares in constant
245
- time on the row it gets back — the seam is an app's to implement, and one that ignores the
246
- argument would otherwise redeem any token. The Postgres statement carries `consumed_at is null`
247
- on the UPDATE **and** in its subselect (single-use under two racing redemptions), and
248
- `order by created_at desc limit 1` so it can only ever consume one row.
249
- - **Foreign text reaching a `cause:` goes through `renderCauseValue`, and a `fix:` through
250
- `renderFixLiteral`.** Not for throw-safety — these values are `string` by type, so
251
- `bun run error-render` (which only sees `unknown`/`any`) will never catch one — but because a
252
- newline writes a second log line an operator reads as genuine. Three values in this package are
253
- foreign and all three are rendered at their source: `providerDetail()`'s return (a REMOTE
254
- server's bytes, rendered there rather than at `oauthExchangeFailed` so the prose details this
255
- package authors stay unquoted), `claims.iss` in `id-token.ts` (a field of the JWT the caller
256
- presented), and `accountLocked`'s `key` (built by `ipKey` from a caller-supplied address).
257
- `${provider}` is NOT in that set — it is registry-validated on every shipped path, so it is boot
258
- config like `clientIdEnv`, not request data. Swept whole `As of 2026-08`.
259
- **`UltimateError`'s constructor now escapes every line-bearing field as well** (`As of
260
- 2026-08-20`), so a line break cannot leave this package whether or not a call site remembers.
261
- These three renders stay and are not a second path: `renderCauseValue` also QUOTES, which is what
262
- makes a forged `iss` legible as a value rather than as prose — the constructor is a floor under
263
- every field, not a replacement for saying which value is foreign.
264
-
265
- **A `fix:` a SHELL reads takes `renderFixShellArg` instead, `As of 2026-09`.**
266
- `oauth-discovery.ts` composed `curl -sS -m 5 ${url}` from `discoveryUrl(input.issuer)` at four
267
- sites, and `new URL()` keeps `$`, `(`, `)` and backticks in a path — so an issuer one boot
268
- mistake away from hostile wrote the command substitution into the line a reader pastes.
269
- `renderFixLiteral` cannot stand in for it: it answers DOUBLE quotes, in which `$(…)` is still
270
- live. The two are used side by side in that file — the shell renderer for the four `curl`/prose
271
- lines, `renderFixLiteral` for the one that is a `registerOAuthProvider({ id: … })` call, where
272
- quoting IS the escape and a bare `'${id}'` broke on an apostrophe.
273
-
274
- **Every OAuth endpoint in a command position is screened, not just the discovery URL** (`As of
275
- 2026-09-06`). `jwks.ts` (three lines, one `probe` const), `oauth-profile.ts`'s userinfo probe and
276
- `oauth-exchange.ts`'s token probe each composed `curl … ${url}` from `providerFor(id)`, and that
277
- registry is filled by `discoverOAuthProvider` — the ISSUER's own document — as well as by the
278
- app. `oauth-exchange.ts`'s own comment claimed `url` was "a framework constant"; it has not been
279
- one since `registerOAuthProvider` opened, and the comment says so now. The value still rides in
280
- the `cause` on the two legs whose detail names the host; `X_OAUTH_EXCHANGE_FAILED` carries no
281
- `meta` passthrough.
282
-
283
- **A screened value is half the contract; the LINE has to run** (`As of 2026-09-06`). A
284
- placeholder is honest text and is not a command — `curl -sS -m 5 <the provider jwks_uri>` is read
285
- by a shell as a redirection from a file called `the`, so the one instruction the reader was
286
- handed fails, which is axiom 4 inverted. `jwks.ts`'s three lines share one `readTheKeySet(tail)`:
287
- `isFixShellSafe` (core, the predicate `renderFixShellArg` is itself built on) decides, and the
288
- whole fix degrades to PROSE naming the discovery document when the URI does not travel.
289
- `oauth-discovery.ts` still owes the same repair and is what keeps `auth` on
290
- `FIX_SHELL_ARG_PINS`.
291
- - `readCookie` never throws on a malformed value. The `Cookie:` header is attacker-controlled and
292
- `decodeURIComponent('%')` is a bare `URIError`, which would escape every coded path in this
293
- package — the raw value goes to the signature or hash check, which is the readable refusal.
294
- - A token endpoint's HTTP 200 is not success — GitHub reports a dead code that way. Read `error`.
295
- - Link by address only when the provider **and** the local account both verified it. That is
296
- `link: 'verified-email'`, the default; `'never'` is the only other value and there is
297
- deliberately no "link on any provider address" — it is account takeover at a sloppy
298
- provider, so it is unrepresentable rather than discouraged. An app that wants it wraps
299
- `signInWithOAuth`.
300
- - **The OAuth route paths are not configurable.** `oauth-paths.ts` imports nothing and is
301
- read by both `oauth-errors.ts` and `oauth-route.ts`, so a `fix:` line naming
302
- `GET /auth/oauth/<provider>` cannot outlive the route again — which is exactly what it did
303
- through 1.2.0, when the library functions shipped with no route to mount them in. Every
304
- "start over" fix is built from `restartAt(provider)`.
305
- - The routes are **descriptors** (`mcpHttpRoute()`'s category), never mounted handlers:
306
- `@ultimat3/http` is tier 2 like this package and `defineRoute` is tier 4 and renders a
307
- page, so neither is importable here. A bare `Request` in, a `Response` out.
308
- - The callback answers failure as **coded JSON**, and the success hop redirects to a fixed
309
- `successPath` — never `?next=`. `nextAfterSignIn` in `@ultimat3/http` is the one
310
- implementation of the open-redirect check and a second copy is one that drifts.
311
- - The handshake cookie is cleared on **every** callback outcome, success and failure alike:
312
- the code it authorised is spent either way.
313
- - Refresh is **not implemented**, and the framework therefore **stores no provider token** (`As of
314
- 2026-08-23`). `accountFor` (`oauth-login.ts`) writes `accessToken: null, refreshToken: null`:
315
- nothing in this package ever read either column back — `oauth-profile.ts` uses the token from the
316
- exchange, in flight — while `tables.ts`'s header promised "no column holds a plaintext secret".
317
- Declared and never wired, the deletion this repo already ran for `jobs.driver`, except this one
318
- turned a database dump into a set of usable third-party credentials. The COLUMNS and the
319
- `AuthAccount` fields both stay: the type is the documented adapter seam, and an app that
320
- deliberately stores tokens implements `linkAccount` itself. `expiresAt` is still written.
321
- - **`defineAuth({ providers })` defaults to `[]`**, never the live registry (`As of 2026-08-23`,
322
- BREAKING). It was `oauthProviderIds()`, so nothing was ever "left out" and the uniform-404 the
323
- option exists for could not fire — while a registry any dependency writes into decided which
324
- login endpoints the app served. Name what you enabled.
325
- - **A provider with no credentials answers the SAME 404 an unknown one does.** Past `assertEnabled`,
326
- `oauthCredentials` threw `X_ENV_MISSING` and the route published a 500 carrying the app's own env
327
- var names, so `500 = registered here, 404 = not` re-opened the oracle one request later.
328
- `credentialsFor` (`oauth-route.ts`) catches it on BOTH legs, logs the real cause under
329
- `auth.oauth.credentials_missing`, and re-throws `oauthProviderUnknown`.
330
- - **Nothing this package does not own reaches a published `cause:`.** Both legs of the OAuth flow
331
- are anonymous by definition and `publicBody` serialises `cause`. Two paths carried an internal
332
- message into it and both now log instead: an uncoded throw from an adapter (`uncoded()` in
333
- `oauth-route.ts` → `auth.oauth.uncoded_failure`) and a rejecting `OAuthFetch`
334
- (`postForm` → `auth.oauth.token_fetch_failed`). The `POST /token` leg also reads its response
335
- body as `providerDetail(response, 'coded-only')` — that request carries `client_secret`, and an
336
- echoing endpoint put 38 of 42 characters of it into a 502 body. `error`/`error_description` still
337
- come through; userinfo, discovery and jwks keep the raw fallback, because their requests carry
338
- no secret.
339
- - **`x_users.mfa_secret` is a PLAINTEXT secret and `tables.ts` now says so, column by column.** A
340
- TOTP seed is symmetric, so a digest cannot verify a code; encrypting it needs a key-management
341
- seam this package does not have. **Deferred deliberately** — `mfa.ts`'s "a database dump is not a
342
- permanent MFA bypass" is true of the recovery CODES and not of the seed.
343
- - **`issueVerification`/`consumeVerification` normalise too** — the fourth identity door.
344
- `putVerification` upserts on `(purpose, identifier)` and `adapter.ts` promises a new token
345
- invalidates the previous one; that promise was per SPELLING, so N live reset tokens for one
346
- address could be held at once by varying the case, and each is a password.
347
- - **`providerJwks` memoises only the DEFAULT client.** The memo was keyed on the provider id alone
348
- and silently discarded a later caller's options, so an app pinning a corporate egress proxy got
349
- it only if it called first — the `jobs.driver` shape, with a network path as the substituted
350
- value. A caller that supplies options gets its own client.
351
- - **A wedged JWKS refresh no longer holds the shared slot for ever** (`As of 2026-08-23`).
352
- `createJwksClient` single-flights its refresh through `@ultimat3/core`'s `createSingleFlight`,
353
- with `deadlineMs = timeoutMs * 2` — derived, never a second invented number: `timeoutMs` bounds
354
- the network leg (`AbortSignal.timeout`) and everything after it is local work on the same budget,
355
- so doubling never evicts a slow-but-healthy refresh. It matters because `AbortSignal.timeout`
356
- bounds only the DEFAULT transport and `options.fetch` is the app's — a `fetch` that ignores its
357
- signal used to pin the slot for the life of the process, and every later `keyFor` joined a
358
- promise nothing would resolve. **Eviction frees the KEY, never the work**: the wedged refresh
359
- runs on and its own callers keep their promise, so the worst case is one duplicate JWKS fetch and
360
- never a failed verification. `schedule` is injectable so no test waits a deadline out.
361
- Its consequence is the second half and is not optional: two refreshes can now overlap and both
362
- end by installing what they read, so a `createFence` generation check is what stops the one the
363
- client gave up on from dropping a pre-rotation key set on top of the live one. Read
364
- (`fence.generation() === issued`), never `guard` — a superseded refresh is still the honest
365
- answer for the callers holding it, and only the shared cache is fenced.
366
- - **`verifyIdToken` checks `nbf` and `azp`.** `workload.ts` imports `ID_TOKEN_CLOCK_SKEW_MS` from
367
- `id-token.ts` and then enforced a bound `id-token.ts` did not: an `nbf` ten years out verified.
368
- `azp` is OIDC Core 3.1.3.7 step 5 — with more than one audience, `aud` naming this client says
369
- only that the token MENTIONS it. Both only narrow what is accepted.
370
- - **A success clears the ACCOUNT bucket and nothing else.** `recordSuccess(ipKey(ip))` used to run
371
- on every login and deleted the whole address bucket, which made it inert against the attack it
372
- exists for: a stuffing run never spends `maxAttempts` guesses on one account, so
373
- `4 wrong + 1 login to an account the attacker owns, repeat` never locked. Measured: 5 guesses to
374
- `X_ACCOUNT_LOCKED` without the reset, 160 and unlocked with it. The cost is a shared NAT
375
- accumulating failures, which is what `windowMs` bounds and what `X_ACCOUNT_LOCKED`'s `fix:`
376
- already names the manual escape for.
377
- - **`MemoryAdapter.createUser` enforces the two UNIQUE constraints `BuiltinAdapter` leans on** —
378
- `x_users.email` and `x_users.external_id`, `authUniqueViolation` (`X_AUTH_WRITE_FAILED`). It is
379
- what `x new` scaffolds and what every test runs against, so the duplicate path was only ever
380
- exercised against the permissive half of the seam: two `register()` calls at one address made two
381
- rows, and the second was unreachable forever. `adapter-parity.test.ts` pins both halves.
382
- **A NULL is not a value to either index, `As of 2026-08-23`**: a Postgres unique index is NULLS
383
- DISTINCT, so `external_id` constrains only the rows that carry one — the check was
384
- `!== undefined`, and `oauth-login.ts` binds `grants.externalId ?? null` for every first-time
385
- OAuth user, so the SECOND such signup on a `MemoryAdapter` app failed with `X_AUTH_WRITE_FAILED`
386
- against a constraint production does not have.
387
- - **`MemoryAdapter` takes a `Clock`, and every instant it stamps comes from it** —
388
- `new MemoryAdapter(clock)`, defaulting to `systemClock`, so the no-argument construction every
389
- test already writes is unchanged. `takeVerification` stamped `consumedAt` with the record's
390
- own `createdAt` — the moment the link was ISSUED — where `BuiltinAdapter` writes
391
- `consumed_at = now()`, the moment it was REDEEMED: a redemption an hour later and one a second
392
- later recorded the identical instant, and a frozen test clock could not move either.
393
- - The new `AuthAdapter` members are OPTIONAL (`findUserByExternalId`, `listUsersByOrg`,
394
- `deleteSessionsForUser`, `deleteSessionsForOrg`, `deleteSessionsCreatedBefore`). A required
395
- member is a breaking change to every third-party adapter; the callers throw
396
- `X_NOT_IMPLEMENTED` naming the method instead.
84
+ `deleteSessionsForOrg` joins through `x_users`; `x_sessions` does **not** gain an `org_id`.
85
+ - The code flow carries `nonce` inside the id token. `assertOAuthCallback` checks an echoed one when
86
+ present and never requires it; `verifyIdToken` is the real gate.
87
+ - The handshake is sealed (`sealHandshake`); `openHandshake` takes the provider as an argument.
88
+ Expiry is the server's clock, not `Max-Age`. One handshake cookie **per provider**
89
+ (`handshakeCookieName`), and `clearHandshakeCookie(provider)`.
90
+ - `takeVerification(purpose, identifier, tokenHash)` consumes **only on a hash match**, in one
91
+ conditional statement (`consumed_at is null` on the UPDATE and its subselect,
92
+ `order by created_at desc limit 1`); `consumeVerification` still compares in constant time.
93
+ - **Foreign text in a `cause:` goes through `renderCauseValue`, in a `fix:` through
94
+ `renderFixLiteral`, and in a shell `fix:` through `renderFixShellArg`.** Foreign here:
95
+ `providerDetail()`'s return, `claims.iss`, `accountLocked`'s `key`, and every OAuth endpoint URL
96
+ in a command position (`jwks.ts`, `oauth-profile.ts`, `oauth-exchange.ts`,
97
+ `oauth-discovery.ts`) — the registry is filled by an issuer's own discovery document. A LINE
98
+ that would not run degrades to prose: `jwks.ts`'s `readTheKeySet(tail)` decides with core's
99
+ `isFixShellSafe`. `oauth-discovery.ts` still owes that repair (it keeps `auth` on
100
+ `FIX_SHELL_ARG_PINS`). `${provider}` is registry-validated boot config, not foreign.
101
+ - `readCookie` never throws on a malformed value (`decodeURIComponent('%')`).
102
+ - A token endpoint's HTTP 200 is not success — read `error`.
103
+ - Link by address only when the provider **and** the local account both verified it
104
+ (`link: 'verified-email'`, default; `'never'` is the only other value).
105
+ - **The OAuth route paths are not configurable.** `oauth-paths.ts` imports nothing and is read by
106
+ both `oauth-errors.ts` and `oauth-route.ts`; every "start over" fix is `restartAt(provider)`.
107
+ - The routes are **descriptors** (`AuthRouteDescriptor`), never mounted handlers.
108
+ - The callback answers failure as **coded JSON**, and success redirects to a fixed `successPath` —
109
+ never `?next=` (`nextAfterSignIn` in `@ultimat3/http` is the one open-redirect check).
110
+ - The handshake cookie is cleared on **every** callback outcome.
111
+ - Refresh is **not implemented**, so the framework **stores no provider token**: `accountFor`
112
+ (`oauth-login.ts`) writes `accessToken: null, refreshToken: null`. The columns and `AuthAccount`
113
+ fields stay; an app that stores tokens implements `linkAccount` itself.
114
+ - **`defineAuth({ providers })` defaults to `[]`** — name what you enabled.
115
+ - **A provider with no credentials answers the SAME 404 an unknown one does**: `credentialsFor`
116
+ (`oauth-route.ts`) logs `auth.oauth.credentials_missing` and re-throws `oauthProviderUnknown`.
117
+ - **Nothing this package does not own reaches a published `cause:`** on the anonymous OAuth legs:
118
+ an uncoded adapter throw logs `auth.oauth.uncoded_failure`, a rejecting `OAuthFetch` logs
119
+ `auth.oauth.token_fetch_failed`, and the `POST /token` body is read as
120
+ `providerDetail(response, 'coded-only')` (that request carries `client_secret`).
121
+ - **`x_users.mfa_secret` is a PLAINTEXT secret** and `tables.ts` says so; encrypting it needs a
122
+ key-management seam this package does not have. Deferred deliberately.
123
+ - **`providerJwks` memoises only the DEFAULT client**; a caller supplying options gets its own.
124
+ - **A JWKS refresh is single-flighted through core's `createSingleFlight` with
125
+ `deadlineMs = timeoutMs * 2`**; eviction frees the key, never the work, and a `createFence`
126
+ generation check (read, never `guard`) stops a superseded refresh overwriting the cache.
127
+ `schedule` is injectable.
128
+ - **A success clears the ACCOUNT bucket and nothing else** — clearing the address bucket made the
129
+ limiter inert against stuffing.
130
+ - **`MemoryAdapter.createUser` enforces `x_users.email` and `x_users.external_id` uniqueness**
131
+ (`authUniqueViolation`, `X_AUTH_WRITE_FAILED`), NULLS DISTINCT like Postgres.
132
+ **`MemoryAdapter` takes a `Clock`** (default `systemClock`) and stamps every instant from it.
133
+ `adapter-parity.test.ts` pins both.
134
+ - The newer `AuthAdapter` members are OPTIONAL (`findUserByExternalId`, `listUsersByOrg`,
135
+ `deleteSessionsForUser`, `deleteSessionsForOrg`, `deleteSessionsCreatedBefore`); callers throw
136
+ `X_NOT_IMPLEMENTED` naming the method.
397
137
  - An api key's scopes are the agent actor's scopes. Never union them with the owner's roles.
398
- - Rotate the session id on any privilege change (`rotateSession`), never patch the row.
399
- `updatePrivileges` in `privileges.ts` is the caller that makes that rule exist — it had none
400
- until 1.3.0, and `SessionPolicy.rotateOnPrivilegeChange` was a flag nothing read.
401
- - **Every argon2 call goes through `kdfGate()`, and that is the only thing bounding its memory.**
402
- 19 MiB of arena per hash at the OWASP floor, and both existing gates are per-SOURCE (`ipKey(ip)`,
403
- 5 attempts; `@ultimat3/http`'s `auth` bucket, 10 per `route|ip:`) so a spray rotating an IPv6 /64
404
- mints a fresh key every attempt — and both cap ATTEMPTS, not concurrent WORK. The only backstop
405
- left was `http.maxInflight` (1000), about 19 GB of arenas queued. `kdf-gate.ts` bounds the width
406
- (8) and the waiting queue (64) and refuses past it with `X_OVERLOADED`, borrowed from http and
407
- listed in `AUTH_BORROWED_ERROR_CODES` — this package cannot import http, and a shed is a shed
408
- whichever layer performs it. **The pool itself is `@ultimat3/core`'s `createFlightGate` since
409
- 2026-08-23** — the same hand-over-on-release rule, the same numbers — and the refusal stays this
410
- package's through core's `overflow:` seam, so `kdfOverloaded` is still what a caller catches and
411
- core's `X_FLIGHT_GATE_OVERLOADED` never leaves auth. No `subject:` is passed: it feeds only
412
- `gateOverloaded`'s prose, which this gate never reaches. `configureKdfGate()` is the ONE install
413
- point and is deliberately not a `defineAuth` key: the ceiling is a property of the machine, not
414
- of the app's auth policy.
415
- - **MFA has a first leg and no second one, and the second one is not a route you can just add.**
416
- `login()` and `completeOAuthLogin()` throw `X_MFA_REQUIRED` before any session exists; nothing is
417
- written, so the only value handed over is a user id in `meta`. A `POST /auth/mfa/verify
418
- { userId, code }` built on that is **unauthenticated by construction** — nothing binds it to a
419
- completed first factor, so it converts MFA from a second factor into the only factor. That is why
420
- the `fix:` now tells an app author to finish the flow itself (`verifyTotp` → `createSession({
421
- mfaSatisfied: true })`) rather than naming a route, and why no route was added under a bug fix.
422
- The framework's own second leg needs three things landing together, and fewer is worse than none:
423
- a **sealed pending-MFA credential** built like `sealHandshake` (`oauth-cookie.ts`) — server-clock
424
- expiry, one cookie, bound to the user id the first factor proved and to nothing the client says;
425
- the completion shipped as an `AuthRouteDescriptor` the way `oauthLogin()` was (`oauth-route.ts`),
426
- with its path declared in `oauth-paths.ts`'s style so the `fix:` and the mount cannot drift; and
427
- `auth.limiter` around `verifyTotp` — today it is wired only into `login`, so a six-digit code
428
- would be the one credential in this package with no lockout. `TotpReplayGuard` is already built
429
- and must be the completion's, not a second one.
430
- - **`mfa.required` is the literal `false`, and `defineAuth` refuses a `true` that reaches it
431
- anyway** (`X_CONFIG_INVALID`, borrowed from core), `As of 2026-08`. It resolved onto the frozen
432
- `Auth` and **nothing read it** — `login()` and `signInWithOAuth()` branch on `user.mfaSecret`
433
- alone and mint `mfaSatisfied: true` otherwise, so `required: true` handed a user who had never
434
- enrolled a fully-privileged session while reading as "this deployment requires a second factor".
435
- Enforcing it was the tempting fix and is a **lockout**: `actorFromUser` degrades a session only
436
- when `mfaSecret !== null`, so an un-enrolled user has no half-authenticated actor to enrol
437
- through, this package ships no enrolment route to send them to, and `mfaRequired()`'s own `fix:`
438
- (`verifyTotp({ secret: user.mfaSecret, … })`) cannot be followed with a null secret — a dead
439
- `fix:`, the defect `oauth-paths.ts` exists to stop. So the unenforceable declaration is refused
440
- where it is written, exactly as `assertAuthLimiterPolicy` refuses a per-process limiter under a
441
- fleet-wide lockout. The literal type is the build error and the runtime check is for the JS
442
- caller and the JSON config the type cannot reach, the split `invariantColumns()`'s Proxy keeps.
443
- `mfa.issuer` is the half that stayed, and it now has a reader: `enrolTotp(auth, { account })`
444
- takes the `Auth` every other entry point takes, so the product name an authenticator app shows
445
- is declared once at `defineAuth` instead of restated at every enrolment.
446
- - **A TOTP secret that decodes to zero bytes is not a weak key, it is NO key** (`As of 2026-08`).
447
- `base32Decode` answers `new Uint8Array(0)` for any character outside the alphabet and for `''`,
448
- and `new Bun.CryptoHasher('sha1', new Uint8Array(0))` is a perfectly valid HMAC — so `totpCode`
449
- returned a six-digit code derived from nothing, every malformed secret in the table shared that
450
- one stream, and `verifyTotp` accepted a code an attacker computes without knowing any secret.
451
- Reachable: `enrolTotp(auth, { account, secret })` takes an imported secret, `builtin-adapter.ts`
452
- maps the column straight through, and a `mfa_secret text not null default ''` column is not
453
- `null`, so `login()` still demanded a second factor and then accepted the empty-key code. The
454
- file's own header comment asserted the opposite ("fails the decode closed") for as long as it was
455
- wrong. Three answers now, one per caller: `verifyTotp` returns `{ ok: false, step: null }` — the
456
- **generic failure**, the same rule `verifyAgainst` follows for a stored hash Bun cannot read, so
457
- a broken row is neither a 500 nor an oracle; `totpCode` throws `X_MFA_SECRET_INVALID`, because
458
- there is no code an unreadable secret is entitled to; `enrolTotp` throws it too, so the value
459
- never reaches the table. The secret never reaches `cause:` or `fix:` — it is a credential and
460
- both are logged. No minimum LENGTH is enforced beyond one byte: 10-byte secrets are what several
461
- authenticator apps issue, so a 16-byte floor would refuse real enrolments to close nothing the
462
- zero-byte rule leaves open.
463
- - **`createTotpReplayGuard`'s table is bounded, and the eviction ORDER is the guarantee.** It
464
- pruned steps inside one subject's `Set` and never revisited a subject who stopped signing in, so
465
- the map carried one permanent entry per user for the life of the process. Evicting a subject
466
- makes a step they have already spent replayable again, so the order cannot be recency of
467
- insertion: a subject whose every step has fallen below the drift floor is **forgotten** (nothing
468
- outside ±drift is ever offered to `verifyTotp`, so that entry answers exactly as a missing one),
469
- and only if that is not enough does `DEFAULT_MAX_TOTP_SUBJECTS` evict live state, sorted by
470
- newest spent step ascending with a least-recently-seen tie-break — the subject who just proved a
471
- code is always the last one out. Same shape as the limiter's "a live lockout outranks its own
472
- deadline". `remember` re-files its subject so the map's iteration order IS that tie-break.
473
- `maxSubjects` is **normalised before any of that arithmetic runs** (`boundedSubjects`): the cap
474
- ran on the caller's number unchecked, so `Infinity` left the table exactly as unbounded as it was
475
- before it was capped, and `NaN` — every comparison against which is false — made the eviction
476
- loop's `used.size <= evictTo` never true, emptying the table on the first sweep and handing back
477
- a replay of the code the subject had just spent. Anything that is not a positive finite integer
478
- takes `DEFAULT_MAX_TOTP_SUBJECTS`; a fraction still floors.
479
- - SAML is out of scope permanently: XML-DSig canonicalisation has no Bun native and would need a
480
- real dependency. Put an OIDC-speaking bridge in front and register that.
138
+ - Rotate the session id on any privilege change (`rotateSession`, called by `updatePrivileges` in
139
+ `privileges.ts`), never patch the row.
140
+ - **Every argon2 call goes through `kdfGate()`** — width 8, queue 64, `X_OVERLOADED` past it
141
+ (borrowed from http, in `AUTH_BORROWED_ERROR_CODES`). The pool is core's `createFlightGate`, the
142
+ refusal auth's own through core's `overflow:` seam. `configureKdfGate()` is the ONE install point
143
+ and deliberately not a `defineAuth` key.
144
+ - **MFA has a first leg and no second one, and the second is not a route you can just add.**
145
+ `login()` / `completeOAuthLogin()` throw `X_MFA_REQUIRED` before any session exists. A
146
+ `POST /auth/mfa/verify { userId, code }` would be unauthenticated by construction. The follow-up
147
+ needs three things together: a **sealed pending-MFA credential** built like `sealHandshake`, the
148
+ completion as an `AuthRouteDescriptor` with its path in `oauth-paths.ts`'s style, and
149
+ `auth.limiter` around `verifyTotp`. `TotpReplayGuard` is the completion's.
150
+ - **`mfa.required` is the literal `false`, and `defineAuth` refuses a `true`** (`X_CONFIG_INVALID`):
151
+ enforcing it would lock out every un-enrolled user. `mfa.issuer` is read by
152
+ `enrolTotp(auth, { account })`.
153
+ - **A TOTP secret that decodes to zero bytes is NO key**: `verifyTotp` returns the generic failure
154
+ (`{ ok: false, step: null }`), `totpCode` and `enrolTotp` throw `X_MFA_SECRET_INVALID`. The secret
155
+ never reaches `cause:`/`fix:`. No length floor beyond one byte.
156
+ - **`createTotpReplayGuard`'s table is bounded and the eviction ORDER is the guarantee**: subjects
157
+ whose steps are all below the drift floor are forgotten first; past `DEFAULT_MAX_TOTP_SUBJECTS`,
158
+ evict by newest spent step ascending, least-recently-seen tie-break. `maxSubjects` is normalised
159
+ first (`boundedSubjects`): anything not a positive finite integer takes the default.
160
+ - SAML is out of scope permanently (XML-DSig canonicalisation has no Bun native). Put an
161
+ OIDC-speaking bridge in front and register that.
481
162
 
482
163
  ## Files
483
164
 
@@ -514,6 +195,8 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
514
195
  | `kdf-gate.ts` | the one bound on concurrent argon2 work, and the `X_OVERLOADED` past it — core's `createFlightGate` with auth's own refusal injected |
515
196
  | `email.ts` | `normaliseEmail` — the one normalisation an address gets before it is an identity key |
516
197
  | `json.ts` | reading untrusted JSON: `isRecord`, and a base64url JWT segment as an object or `null` |
198
+ | `sign-out.ts` | `signOutHeaders({ session? })` — the expired session cookie plus `Clear-Site-Data: "cache", "storage"` (`SIGN_OUT_CLEAR_SITE_DATA`), never `"cookies"`. A PWA's offline cache does not survive a sign-out, by decision (a cached private page IS the previous principal's data); the page boot's rescope wipe is the second line. Secure contexts only |
199
+ | `client-scope.ts` | `clientScopeOf(actor)` — the OPAQUE per-principal id for `<meta name="ultimate-scope">`: keyed SHA-256 (`SESSION_SECRET`) over kind, id and impersonator, 32 hex; `''` for anonymous. Without `SESSION_SECRET`, a random per-process key and one `auth.client_scope.process_key` warning (rescopes, never leaks) |
517
200
 
518
201
  ```bash
519
202
  bun test packages/auth
@@ -533,3 +216,5 @@ Gotchas:
533
216
  and `rate-limit-postgres.live.test.ts` is `describe.skip` without `TEST_DATABASE_URL` — the
534
217
  same pairing `@ultimat3/http`'s rate-limit store and `@ultimat3/realtime`'s Postgres files
535
218
  use. A limiter whose statements were never executed is a credential control nobody has run.
219
+
220
+ Why each rule above is shaped the way it is: [`docs/history/auth.md`](../../docs/history/auth.md).
package/README.md CHANGED
@@ -460,16 +460,15 @@ still satisfies the interface. Calling one an adapter has not implemented is `X_
460
460
  with the method named, not a silent no-op.
461
461
 
462
462
  `x_users` gained two columns in 1.3.0 — `scopes text[]` and `external_id text unique` — plus an
463
- `org_id` index. `X_USERS_MIGRATION_1_3` is those statements for an app already on 1.2; both
464
- columns are additive with a default, so the migration takes no table rewrite.
463
+ `org_id` index.
465
464
 
466
- **Nothing wires those statements into a migration for you, `As of 2026-08`.** `AUTH_TABLES` is the
467
- DDL as plain strings (`AUTH_TABLE_NAMES` is what they create, and `X_USERS_TABLE`,
468
- `X_SESSIONS_TABLE`, `X_ACCOUNTS_TABLE`, `X_API_KEYS_TABLE`, `X_VERIFICATIONS_TABLE` are the
469
- individual ones). `x db gen <name>` diffs `describeEntities()` against the newest migration's
470
- snapshot — these tables are not `entity()` declarations, so nothing outside `tables.ts` reads the
471
- constant and the command cannot see them. Paste each statement into its own file under
472
- `packages/db/migrations/`, one statement per migration, then `x db migrate`.
465
+ **The framework applies the whole schema at boot, upgrades included — `As of 2026-09-23`
466
+ (22.0.0).** `AUTH_TABLES` (with `AUTH_TABLE_NAMES`) is a row of `@ultimat3/cli`'s
467
+ `FRAMEWORK_SCHEMA`, which every boot applies in foreign-key order: `create table if not exists`,
468
+ then `add column if not exists` for what an older table lacks, so a database created on 1.2 is
469
+ brought up to date with no hand-run migration. There is nothing to paste: the per-table `X_*_TABLE`
470
+ constants and `X_USERS_MIGRATION_1_3` are gone, and a hand-written migration that pasted them
471
+ should be deleted. They are still not `entity()` declarations, so `x db gen` does not see them.
473
472
 
474
473
  ## Sessions are not a write path
475
474
 
@@ -501,6 +500,36 @@ row would not be, and nothing does.
501
500
  | `__Host-` + `Path=/` + no `Domain` | a sibling subdomain overwriting it (session fixation) |
502
501
  | `Max-Age` | a client keeping it past the server's absolute ceiling |
503
502
 
503
+ ## Signing out
504
+
505
+ `logout(auth, token)` ends the session row. The **response** does the rest: append
506
+ `signOutHeaders()` to it.
507
+
508
+ ```ts
509
+ import type { Auth } from '@ultimat3/auth';
510
+ import { logout, signOutHeaders } from '@ultimat3/auth';
511
+
512
+ declare const auth: Auth; // your defineAuth(...)
513
+ declare const token: string; // the session token the request carried
514
+ declare const ctx: { readonly headers: Headers }; // the request context
515
+
516
+ await logout(auth, token);
517
+ for (const [name, value] of signOutHeaders({ session: auth.sessions.policy })) {
518
+ ctx.headers.append(name, value);
519
+ }
520
+ ```
521
+
522
+ | Header | Why |
523
+ |---|---|
524
+ | `set-cookie` expiring the session cookie | only when `session` is passed — an app with its own cookie expires that itself |
525
+ | `clear-site-data: "cache", "storage"` (`SIGN_OUT_CLEAR_SITE_DATA`) | drops everything the previous principal left on the origin: the page store's IndexedDB, local storage, the service worker and its cached private pages. **Never `"cookies"`** — the same response sets the cookie that marks the browser signed out |
526
+
527
+ The browser acts on `Clear-Site-Data` only in a secure context (HTTPS, or `localhost`), which is
528
+ every deployed app and every `x dev`. The cost is one worker reinstall and re-precache per
529
+ sign-out: a PWA's offline cache does not outlive the person it was cached for. The realtime side of
530
+ the same fence is in [`packages/realtime/README.md`](../realtime/README.md) (a principal change wipes
531
+ the previous scope's persisted rows and queued writes).
532
+
504
533
  ## OAuth — log in with GitHub
505
534
 
506
535
  `oauthLogin(auth)` **is** the flow. Two route descriptors, mounted at two fixed paths, composing
@@ -657,3 +686,12 @@ An api key's scopes become **exactly** the agent actor's scopes — never the ow
657
686
  bun test packages/auth
658
687
  bun run --filter @ultimat3/auth typecheck
659
688
  ```
689
+
690
+ ### Error classes
691
+
692
+ Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
693
+ a job boundary the class is gone and the `code` is what survives — match on that.
694
+
695
+ | Class | Code | Declared in |
696
+ |---|---|---|
697
+ | `AuthError` | any `AuthErrorCode` — `AUTH_ERROR_CODES` | `src/errors.ts` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/auth",
3
- "version": "20.2.1",
3
+ "version": "22.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": "20.2.1",
35
- "@ultimat3/db": "20.2.1",
36
- "@ultimat3/schema": "20.2.1"
34
+ "@ultimat3/core": "22.0.0",
35
+ "@ultimat3/db": "22.0.0",
36
+ "@ultimat3/schema": "22.0.0"
37
37
  }
38
38
  }
package/src/api-keys.ts CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { type Clock, randomHex, systemClock } from '@ultimat3/core';
8
8
  import type { ApiKeyStore, AuthApiKeyRecord } from './adapter';
9
- import { apiKeyInvalid } from './errors';
9
+ import { apiKeyEnvInvalid, apiKeyInvalid } from './errors';
10
10
  import type { PolicyActor } from './policy-bridge';
11
11
  import { actorFromApiKey } from './policy-bridge';
12
12
  import { randomToken, sha256Hex, timingSafeEqual } from './tokens';
@@ -58,7 +58,11 @@ export interface IssuedApiKey {
58
58
  readonly record: AuthApiKeyRecord;
59
59
  }
60
60
 
61
+ /** What `parseApiKey` can split back out: no `_`, and nothing a log would render two ways. */
62
+ const API_KEY_ENV = /^[a-z0-9-]+$/;
63
+
61
64
  export function issueApiKey(input: IssueApiKeyInput): IssuedApiKey {
65
+ if (!API_KEY_ENV.test(input.env)) throw apiKeyEnvInvalid(input.env);
62
66
  const clock = input.clock ?? systemClock;
63
67
  // Hex, not base64url: the id sits between two `_` delimiters and must not contain one.
64
68
  const id = randomHex(8);
@@ -265,8 +265,13 @@ export class BuiltinAdapter implements AuthAdapter {
265
265
  return rows.map(toSession);
266
266
  }
267
267
 
268
+ /**
269
+ * On a pair that is already linked the row keeps its `id`, `user_id` and `created_at` and only
270
+ * the tokens move — so the answer is the row `returning *` reports, never the object handed in,
271
+ * which named a user the row does not belong to.
272
+ */
268
273
  async linkAccount(account: AuthAccount): Promise<AuthAccount> {
269
- await this.#db.execute(sql`
274
+ const row = await this.#db.one<Row>(sql`
270
275
  insert into x_accounts (id, user_id, provider, provider_account_id, access_token,
271
276
  refresh_token, expires_at, created_at)
272
277
  values (${account.id}, ${account.userId}, ${account.provider}, ${account.providerAccountId},
@@ -274,8 +279,9 @@ export class BuiltinAdapter implements AuthAdapter {
274
279
  ${account.createdAt})
275
280
  on conflict (provider, provider_account_id) do update
276
281
  set access_token = excluded.access_token, refresh_token = excluded.refresh_token,
277
- expires_at = excluded.expires_at`);
278
- return account;
282
+ expires_at = excluded.expires_at
283
+ returning *`);
284
+ return row === null ? account : toAccount(row);
279
285
  }
280
286
 
281
287
  async findAccount(provider: string, providerAccountId: string): Promise<AuthAccount | null> {
@@ -346,7 +352,7 @@ export class BuiltinAdapter implements AuthAdapter {
346
352
  async listApiKeys(ownerId: string): Promise<readonly AuthApiKeyRecord[]> {
347
353
  const rows = await this.#db.query<Row>(sql`
348
354
  select * from x_api_keys where user_id = ${ownerId} or org_id = ${ownerId}
349
- order by created_at desc`);
355
+ order by created_at desc, id desc`);
350
356
  return rows.map(toApiKey);
351
357
  }
352
358
 
@@ -0,0 +1,68 @@
1
+ // The browser page's client scope id: an OPAQUE name for the principal a per-request document was
2
+ // rendered for, carried in `<meta name="ultimate-scope">` so the page's one client store fences
3
+ // itself per principal (plan 101, decision 8). Keyed SHA-256 over the principal, never the id:
4
+ // the HTML is cacheable by a browser and readable by every script on the page.
5
+
6
+ import type { Actor } from '@ultimat3/core';
7
+ import { logger } from '@ultimat3/core';
8
+ import { clientScopeSecretShort } from './errors';
9
+
10
+ /** 128 bits: a scope only has to differ between principals, never to authenticate anyone. */
11
+ const SCOPE_HEX_CHARS = 32;
12
+
13
+ /** `wiki/Configuration.md`'s floor for `SESSION_SECRET`, the same one the oauth handshake reads. */
14
+ const MIN_SECRET_LENGTH = 32;
15
+
16
+ export interface ClientScopeOptions {
17
+ /** Defaults to `SESSION_SECRET`. */
18
+ readonly secret?: string | undefined;
19
+ readonly env?: Readonly<Record<string, string | undefined>> | undefined;
20
+ }
21
+
22
+ /** Minted on first need when no `SESSION_SECRET` exists: stable per process, never per request. */
23
+ let processKey: string | undefined;
24
+
25
+ /**
26
+ * `''` for the anonymous page — the empty scope core reads as "nobody". Otherwise the principal
27
+ * AND who is really acting, so an admin impersonating a user is not the user's own scope: their
28
+ * stores must never share records, on the page or on disk.
29
+ */
30
+ export function clientScopeOf(actor: Actor, options: ClientScopeOptions = {}): string {
31
+ if (actor.kind === 'anonymous') return '';
32
+ const material = JSON.stringify([
33
+ actor.kind,
34
+ actor.id,
35
+ actor.onBehalfOf?.actorKind ?? null,
36
+ actor.onBehalfOf?.actorId ?? null,
37
+ ]);
38
+ return new Bun.CryptoHasher('sha256', scopeKey(options))
39
+ .update(material)
40
+ .digest('hex')
41
+ .slice(0, SCOPE_HEX_CHARS);
42
+ }
43
+
44
+ function scopeKey(options: ClientScopeOptions): string {
45
+ if (options.secret !== undefined) {
46
+ // The same floor as the env path below: an explicit key is a caller's value, so a short one is
47
+ // refused rather than silently swapped for the process key.
48
+ if (options.secret.length < MIN_SECRET_LENGTH) {
49
+ throw clientScopeSecretShort(options.secret.length, MIN_SECRET_LENGTH);
50
+ }
51
+ return options.secret;
52
+ }
53
+ const configured = (options.env ?? Bun.env)['SESSION_SECRET']?.trim() ?? '';
54
+ if (configured.length >= MIN_SECRET_LENGTH) return configured;
55
+ if (processKey === undefined) {
56
+ // `SESSION_SECRET` is optional in the env schema, so its absence cannot refuse a render. A
57
+ // random per-process key keeps the id opaque; what it costs is stability across replicas and
58
+ // restarts — a principal's page then rescopes, which clears its store, never leaks it.
59
+ processKey = Array.from(crypto.getRandomValues(new Uint8Array(32)), (byte) =>
60
+ byte.toString(16).padStart(2, '0'),
61
+ ).join('');
62
+ logger.warn('auth.client_scope.process_key', {
63
+ reason: 'SESSION_SECRET unset or shorter than 32 chars',
64
+ fix: 'export SESSION_SECRET="$(openssl rand -hex 32)"',
65
+ });
66
+ }
67
+ return processKey;
68
+ }
package/src/errors.ts CHANGED
@@ -241,6 +241,34 @@ export const authPolicyNumberInvalid = (
241
241
  meta: { option: key, value: String(value) },
242
242
  });
243
243
 
244
+ /**
245
+ * An explicit key for `clientScopeOf` under the floor `SESSION_SECRET` is held to. The env path
246
+ * already refused a short secret; the option skipped the screen, so `{ secret: 'x' }` keyed every
247
+ * page's scope id with one character. `X_CONFIG_INVALID`, borrowed like the two above.
248
+ */
249
+ export const clientScopeSecretShort = (length: number, minLength: number): AuthError =>
250
+ new AuthError({
251
+ code: 'X_CONFIG_INVALID',
252
+ cause: `clientScopeOf({ secret }) was passed a key of ${String(length)} characters and at least ${String(minLength)} are required, so the scope id would be keyed by a guessable secret`,
253
+ fix: 'omit secret so clientScopeOf reads SESSION_SECRET, or pass one generated with: openssl rand -hex 32',
254
+ meta: { option: 'secret', minLength },
255
+ });
256
+
257
+ /**
258
+ * An api key `env` the parser cannot read back. `parseApiKey` splits `ult_<env>_<id>_<secret>` on
259
+ * `_`, so `issueApiKey({ env: 'live_eu' })` minted a key its own verifier refused. Refused at
260
+ * issue — `X_CONFIG_INVALID`, borrowed like the three above — never at first use.
261
+ */
262
+ export const apiKeyEnvInvalid = (env: string): AuthError =>
263
+ new AuthError({
264
+ code: 'X_CONFIG_INVALID',
265
+ cause: env.includes('_')
266
+ ? `api key env ${renderCauseValue(env)} contains "_", which parseApiKey splits on`
267
+ : `api key env ${renderCauseValue(env)} is not lowercase letters, digits and "-", the only spelling that survives parseApiKey and reads the same in every log`,
268
+ fix: "pass env matching ^[a-z0-9-]+$ to issueApiKey (e.g. 'live-eu')",
269
+ meta: { option: 'env' },
270
+ });
271
+
244
272
  export const passwordWeak = (reasons: readonly string[]): AuthError =>
245
273
  new AuthError({
246
274
  code: 'X_PASSWORD_WEAK',
package/src/index.ts CHANGED
@@ -20,9 +20,7 @@ export type {
20
20
  export type { ApiKeySummary, IssueApiKeyInput, IssuedApiKey, ParsedApiKey } from './api-keys';
21
21
  export {
22
22
  API_KEY_NAMESPACE,
23
- API_KEY_PREFIX_SEGMENTS,
24
23
  apiKeyActor,
25
- apiKeyPrefix,
26
24
  describeApiKey,
27
25
  issueApiKey,
28
26
  parseApiKey,
@@ -52,6 +50,9 @@ export {
52
50
  } from './auth';
53
51
 
54
52
  export { BuiltinAdapter } from './builtin-adapter';
53
+ /** The opaque per-principal id a per-request document hands the page's client store. */
54
+ export type { ClientScopeOptions } from './client-scope';
55
+ export { clientScopeOf } from './client-scope';
55
56
  export type { AuthUserSummary } from './directory';
56
57
  export { describeUser, findUserByExternalId, listOrgUsers } from './directory';
57
58
  // The one normalisation an address gets before it is an identity key. Public because an app
@@ -97,7 +98,6 @@ export type {
97
98
  } from './jwks';
98
99
  export {
99
100
  createJwksClient,
100
- DEFAULT_JWKS_TTL_MS,
101
101
  decodeJwtHeader,
102
102
  providerJwks,
103
103
  verifyJwtSignature,
@@ -106,9 +106,7 @@ export type { KdfGate, KdfLimits } from './kdf-gate';
106
106
  export {
107
107
  configureKdfGate,
108
108
  createKdfGate,
109
- DEFAULT_KDF_LIMITS,
110
109
  kdfGate,
111
- resetKdfGate,
112
110
  } from './kdf-gate';
113
111
  export type { AuthLimiterFactory } from './limiter-install';
114
112
  // `installedAuthLimiter` is deliberately absent: `defineAuth` is the one reader, and a second
@@ -148,7 +146,7 @@ export type {
148
146
  OAuthProviderId,
149
147
  PkcePair,
150
148
  } from './oauth';
151
- export { assertOAuthCallback, beginOAuth, createPkce, pkceChallenge } from './oauth';
149
+ export { assertOAuthCallback, beginOAuth, createPkce } from './oauth';
152
150
  export {
153
151
  APPLE_PROVIDER,
154
152
  BUILTIN_OAUTH_PROVIDER_IDS,
@@ -163,7 +161,6 @@ export {
163
161
  handshakeCookie,
164
162
  handshakeCookieName,
165
163
  handshakeSecret,
166
- OAUTH_HANDSHAKE_COOKIE_PREFIX,
167
164
  openHandshake,
168
165
  readHandshakeCookie,
169
166
  sealHandshake,
@@ -229,7 +226,6 @@ export {
229
226
  DEFAULT_PASSWORD_POLICY,
230
227
  hashPassword,
231
228
  needsRehash,
232
- parseHashParams,
233
229
  verifyPassword,
234
230
  } from './password';
235
231
  export type {
@@ -260,7 +256,6 @@ export {
260
256
  DEFAULT_MAX_AUTH_LIMIT_KEYS,
261
257
  ipKey,
262
258
  loginFailed,
263
- ORG_ATTEMPT_FACTOR,
264
259
  orgKey,
265
260
  orgRateLimit,
266
261
  } from './rate-limit';
@@ -271,14 +266,7 @@ export type {
271
266
  } from './rate-limit-postgres';
272
267
  export {
273
268
  postgresAuthLimiter,
274
- SQL_AUTH_FORGET_KEY,
275
- SQL_AUTH_KEY_LOCK,
276
269
  SQL_AUTH_LIMIT_TABLES,
277
- SQL_AUTH_LOCK,
278
- SQL_AUTH_LOCKED_UNTIL,
279
- SQL_AUTH_PURGE,
280
- SQL_AUTH_RECORD_FAILURE,
281
- SQL_AUTH_RESET,
282
270
  } from './rate-limit-postgres';
283
271
  export type { DisabledUser } from './revocation';
284
272
  export {
@@ -303,10 +291,8 @@ export {
303
291
  clearSessionCookie,
304
292
  createSession,
305
293
  DEFAULT_SESSION_POLICY,
306
- IDLE_SLIDE_DIVISOR,
307
294
  idleSlideMs,
308
295
  listDevices,
309
- parseSessionToken,
310
296
  readCookie,
311
297
  readSessionCookie,
312
298
  revokeOtherSessions,
@@ -316,21 +302,15 @@ export {
316
302
  sessionExpiry,
317
303
  verifySession,
318
304
  } from './session';
305
+ /** A sign-out response's headers: the session cookie expired, and `Clear-Site-Data`. */
306
+ export type { SignOutHeadersOptions } from './sign-out';
307
+ export { SIGN_OUT_CLEAR_SITE_DATA, signOutHeaders } from './sign-out';
319
308
 
320
309
  export {
321
310
  AUTH_TABLE_NAMES,
322
311
  AUTH_TABLES,
323
- X_ACCOUNTS_TABLE,
324
- X_API_KEYS_TABLE,
325
- X_SESSIONS_TABLE,
326
- X_USERS_MIGRATION_1_3,
327
- X_USERS_TABLE,
328
- X_VERIFICATIONS_TABLE,
329
312
  } from './tables';
330
313
  export {
331
- base64Url,
332
- base64UrlBytes,
333
- matchesHash,
334
314
  randomToken,
335
315
  sha256Hex,
336
316
  timingSafeEqual,
@@ -209,9 +209,21 @@ export class MemoryAdapter implements AuthAdapter {
209
209
  .sort((a, b) => b.lastSeenAt.getTime() - a.lastSeenAt.getTime());
210
210
  }
211
211
 
212
+ /** Postgres's `on conflict … do update`: the owner, id and created_at stay; the tokens move. */
212
213
  async linkAccount(account: AuthAccount): Promise<AuthAccount> {
213
- this.#accounts.set(`${account.provider}:${account.providerAccountId}`, account);
214
- return account;
214
+ const key = `${account.provider}:${account.providerAccountId}`;
215
+ const existing = this.#accounts.get(key);
216
+ const stored =
217
+ existing === undefined
218
+ ? account
219
+ : {
220
+ ...existing,
221
+ accessToken: account.accessToken,
222
+ refreshToken: account.refreshToken,
223
+ expiresAt: account.expiresAt,
224
+ };
225
+ this.#accounts.set(key, stored);
226
+ return stored;
215
227
  }
216
228
 
217
229
  async findAccount(provider: string, providerAccountId: string): Promise<AuthAccount | null> {
@@ -255,9 +267,13 @@ export class MemoryAdapter implements AuthAdapter {
255
267
  }
256
268
 
257
269
  async listApiKeys(ownerId: string): Promise<readonly AuthApiKeyRecord[]> {
258
- return [...this.#apiKeys.values()].filter(
259
- (key) => key.userId === ownerId || key.orgId === ownerId,
260
- );
270
+ // `order by created_at desc, id desc`, the builtin adapter's statement.
271
+ return [...this.#apiKeys.values()]
272
+ .filter((key) => key.userId === ownerId || key.orgId === ownerId)
273
+ .sort(
274
+ (a, b) =>
275
+ b.createdAt.getTime() - a.createdAt.getTime() || (a.id < b.id ? 1 : a.id > b.id ? -1 : 0),
276
+ );
261
277
  }
262
278
 
263
279
  async touchApiKey(id: string, at: Date): Promise<void> {
@@ -133,7 +133,13 @@ export async function providerDetail(
133
133
  try {
134
134
  const parsed: unknown = JSON.parse(text);
135
135
  if (isRecord(parsed)) {
136
- const description = parsed['error_description'] ?? parsed['error'] ?? parsed['message'];
136
+ // `message` is not an OAuth error field (RFC 6749 §5.2 defines `error` and
137
+ // `error_description`), and a gateway that echoes the request body puts `client_secret` in
138
+ // it — so a `coded-only` leg never reads it.
139
+ const description =
140
+ parsed['error_description'] ??
141
+ parsed['error'] ??
142
+ (echo === 'coded-only' ? undefined : parsed['message']);
137
143
  if (typeof description === 'string' && description !== '') {
138
144
  return renderCauseValue(description);
139
145
  }
@@ -79,13 +79,21 @@ async function getJson(
79
79
  // so the rejection is whatever a driver or a proxy threw — and `instanceof` runs the value's
80
80
  // own `getPrototypeOf` trap, which would replace this coded refusal with a bare `TypeError`
81
81
  // raised from inside the catch that exists to raise it. Same rule as `@ultimat3/cache`.
82
- const reason = renderThrowable(error);
82
+ //
83
+ // LOGGED, never published: `oauth-route.ts` serves this cause to whoever typed the callback
84
+ // URL, and a rejection from an injected fetch is a server's internals — a pool DSN, an
85
+ // internal address. The same split `oauth-exchange.ts` makes on the token leg.
86
+ logger.error('auth.oauth.userinfo_fetch_failed', {
87
+ provider,
88
+ url,
89
+ detail: renderThrowable(error),
90
+ });
83
91
  throw oauthExchangeFailed({
84
92
  provider,
85
93
  stage: 'userinfo',
86
94
  detail:
87
- `${reason} — nothing left this host for ${url} (egress, DNS or TLS); restart the ` +
88
- 'flow once it does',
95
+ `nothing left this host for ${url} (egress, DNS or TLS); the reason is in this process ` +
96
+ 'log under auth.oauth.userinfo_fetch_failed. Restart the flow once it does',
89
97
  // The endpoint reaches the registry from `discoverOAuthProvider` — the OP's own document —
90
98
  // so it is REMOTE text, and this line is a command a reader pastes: `$(…)` and a backtick
91
99
  // substitute before `curl` is reached. `renderFixShellArg` passes an ordinary endpoint
@@ -0,0 +1,44 @@
1
+ // What a sign-out RESPONSE carries, beside ending the session row (`logout`): the session cookie
2
+ // expired, and `Clear-Site-Data`, so the browser drops everything the previous principal left on
3
+ // the origin — the page store's IndexedDB, local storage, the service worker and its cached pages.
4
+
5
+ import type { SessionPolicy } from './session';
6
+ import { clearSessionCookie } from './session';
7
+
8
+ /**
9
+ * `"cache", "storage"` and never `"cookies"`: the response itself sets the cookie that marks the
10
+ * browser signed out, and `"cookies"` would clear that too. `"storage"` unregisters the service
11
+ * worker and empties Cache Storage — which is the point, since a cached private page is the previous
12
+ * principal's data — so the next load reinstalls the worker and re-precaches. That cost is paid once
13
+ * per sign-out, and it is the trade the framework makes: a PWA's offline cache does not outlive the
14
+ * person it was cached for.
15
+ */
16
+ export const SIGN_OUT_CLEAR_SITE_DATA = '"cache", "storage"';
17
+
18
+ export interface SignOutHeadersOptions {
19
+ /**
20
+ * The framework session's policy, when the app signs out of `@ultimat3/auth`'s own session: the
21
+ * cookie is then expired here. Absent for an app with its own cookie (it sets that itself).
22
+ */
23
+ readonly session?: SessionPolicy | undefined;
24
+ readonly cookieName?: string | undefined;
25
+ }
26
+
27
+ /**
28
+ * The headers a sign-out response appends, in order: `for (const [name, value] of
29
+ * signOutHeaders()) ctx.headers.append(name, value)`. The browser acts on them only for a secure
30
+ * context (HTTPS, or localhost) — which is every deployed app and every `x dev`.
31
+ */
32
+ export function signOutHeaders(options: SignOutHeadersOptions = {}): readonly [string, string][] {
33
+ return [
34
+ ...(options.session === undefined
35
+ ? []
36
+ : [
37
+ ['set-cookie', clearSessionCookie(options.session, options.cookieName)] as [
38
+ string,
39
+ string,
40
+ ],
41
+ ]),
42
+ ['clear-site-data', SIGN_OUT_CLEAR_SITE_DATA],
43
+ ];
44
+ }
package/src/tables.ts CHANGED
@@ -1,6 +1,7 @@
1
- // Single responsibility: the DDL `BuiltinAdapter` expects. Exported as plain strings so an app
2
- // can paste them into a migration and read EXACTLY what auth stores — verifiable by reading,
3
- // never by trusting.
1
+ // Single responsibility: the DDL `BuiltinAdapter` expects, as `AUTH_TABLES` — applied at every boot
2
+ // by `@ultimat3/cli`'s `FRAMEWORK_SCHEMA`, upgrades of an older table included. Plain strings, so
3
+ // what auth stores is verifiable by reading, never by trusting. There is nothing to paste into a
4
+ // migration any more: the per-table exports and `X_USERS_MIGRATION_1_3` are gone (22.0.0).
4
5
  //
5
6
  // What is at rest, stated column by column rather than as a claim:
6
7
  //
@@ -45,16 +46,18 @@ export const X_USERS_TABLE = `create table if not exists x_users (
45
46
  create index if not exists x_users_org_id_idx on x_users (org_id)`;
46
47
 
47
48
  /**
48
- * The two columns `x_users` gained in 1.3.0, as the statements an app already running 1.2 runs
49
- * once. Both are additive and both have a default, so the migration is not a rewrite and takes no
50
- * exclusive lock beyond the catalog update.
49
+ * What `x_users` gained in 1.3.0, applied at EVERY boot right after the create, so a table made
50
+ * before 1.3 is brought up to date with no hand-run migration — this was `X_USERS_MIGRATION_1_3`,
51
+ * exported for an app to run and applied by nothing. Idempotent (`if not exists`) and additive:
52
+ * both columns have a default or are nullable, so on a live table it is a catalog update, not a
53
+ * rewrite, and a second boot does nothing.
51
54
  */
52
- export const X_USERS_MIGRATION_1_3: readonly string[] = Object.freeze([
55
+ const X_USERS_UPGRADE = [
53
56
  `alter table x_users add column if not exists scopes text[] not null default '{}'`,
54
57
  'alter table x_users add column if not exists external_id text',
55
58
  'create unique index if not exists x_users_external_id_key on x_users (external_id)',
56
59
  'create index if not exists x_users_org_id_idx on x_users (org_id)',
57
- ]);
60
+ ].join(';\n');
58
61
 
59
62
  // `id` is the public half of the cookie; `token_hash` is sha256 of the secret half.
60
63
  export const X_SESSIONS_TABLE = `create table if not exists x_sessions (
@@ -114,6 +117,7 @@ export const X_API_KEYS_TABLE = `create table if not exists x_api_keys (
114
117
  /** Ordered by foreign-key dependency — run them top to bottom. */
115
118
  export const AUTH_TABLES: readonly string[] = Object.freeze([
116
119
  X_USERS_TABLE,
120
+ X_USERS_UPGRADE,
117
121
  X_SESSIONS_TABLE,
118
122
  X_ACCOUNTS_TABLE,
119
123
  X_VERIFICATIONS_TABLE,