@ultimat3/auth 23.0.0 → 25.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
@@ -5,14 +5,14 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
5
5
  | Rule | |
6
6
  |---|---|
7
7
  | Deps | `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/db`. No external deps. |
8
- | Never import | `@ultimat3/policy`, `@ultimat3/http` (tier 2 consumers), `@ultimat3/mail` (sideways) |
8
+ | Never import | `@ultimat3/policy`, `@ultimat3/http` (tier 2 consumers), `@ultimat3/mail` (tier 4, upward) |
9
9
  | Policy seam | `PolicyActorFields` in `policy-bridge.ts` mirrors policy's shape structurally |
10
10
  | Http seam | `RequestLike` / `CookieJar` in `session.ts` and `AuthRouteDescriptor` in `oauth-route.ts`; http binds to them, not the reverse |
11
11
  | Mail seam | injected `MailSender` port in `verify.ts`; the app wires `@ultimat3/mail`'s `send` |
12
12
  | Better Auth | binds through `AuthAdapter`. It is an adapter, never a dependency. |
13
13
  | Errors | `AuthError` from `errors.ts`; never `throw new Error` |
14
14
  | Time | take a `Clock`. No `Date.now()` anywhere in this package. |
15
- | Secrets | compare with `timingSafeEqual` (from `@ultimat3/core`, re-exported off `tokens.ts` — same implementation `@ultimat3/storage` uses); store `sha256Hex`. Never `===` on a secret. |
15
+ | Secrets | compare with `timingSafeEqual` (imported from `@ultimat3/core` — never re-exported here, same implementation `@ultimat3/storage` uses); store `sha256Hex`. Never `===` on a secret. |
16
16
 
17
17
  ## Non-negotiables
18
18
 
@@ -41,19 +41,29 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
41
41
  - **The limiter's table is bounded, and the eviction order is the guarantee.** Every bucket carries
42
42
  `forgetAtMs`; `policy.maxKeys` (`DEFAULT_MAX_AUTH_LIMIT_KEYS`) is the backstop, and a **live
43
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
48
- environment to guess a replica count.
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.
44
+ - **`AuthLimiter` is async on every member, declares the policy it enforces, and is a
45
+ RESERVATION.** `reserve(key)` counts the attempt in the step that admits it (refused, counting
46
+ nothing, while locked); `refund(reservation)` gives ONE back and lifts a lockout the window no
47
+ longer justifies; `recordSuccess(key)` clears the key. There is no check-then-record pair: N
48
+ concurrent guesses all passed the check. `assertAuthLimiterPolicy` (once, in `defineAuth`)
49
+ refuses a per-process limiter under `scope: 'shared'` (`X_AUTH_LIMITER_NOT_SHARED`) and different
50
+ `maxAttempts`/`windowMs`/`lockoutMs` (`X_AUTH_LIMITER_POLICY_MISMATCH`). `maxKeys` is not
51
+ compared. Nothing here reads the environment to guess a replica count.
52
+ - **Every credential check runs through `openLoginAttempt`** (`login-attempt.ts`): account → ip →
53
+ org reserved in that fixed order BEFORE the KDF or the TOTP compare. A failure calls nothing (the
54
+ reservations are the record); `release()` for no verdict (a shed KDF, a store that is down, a
55
+ bucket that refused) or a factor proven with another still owed; `succeed()` clears the account
56
+ and refunds the shared two.
57
+ - **`postgresAuthLimiter` is the shared limiter: ONE row per key in `x_auth_lockouts`, holding the
58
+ instants still inside the window** (`attempts_ms bigint[]` — a sliding window). The take is one
59
+ `insert … on conflict do update` reading the window off `x_auth_lockouts.<column>`, never a CTE
60
+ and never a second table: only the row the statement locks is re-read after a wait. `admitted`
61
+ is a column because `returning` cannot see the old row; no row back is never an admission. A
62
+ live lockout is never rewritten. `PgExecutor` (`@ultimat3/core`'s, re-exported) is structural:
63
+ the pool is the host's.
54
64
  - **`configureAuthLimiters` is the HOST's install point and takes a FACTORY**, called with the
55
65
  RESOLVED policy once per bucket; the comparison still runs on what comes back. Precedence:
56
- `config.limiter` → the installed factory → `createAuthLimiter`. `installedAuthLimiter` and
66
+ `config.limiter` → the installed factory → `memoryAuthLimiter`. `installedAuthLimiter` and
57
67
  `installedLimiterCount()` are NOT in `src/index.ts`. `purgeAuthLimits()` sweeps only the
58
68
  **widest** window among the built limiters (retained one per `windowMs` in a `Map`), on the
59
69
  limiter's own clock. `AuthLimiter.purgeExpired` is optional.
@@ -76,6 +86,9 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
76
86
  `alg: none` are refused in `decodeJwtHeader`. It checks `nbf` and `azp` too.
77
87
  - **`resolveGrants` is a seam, never a group-to-role table**, called on EVERY login. Absent leaves
78
88
  the row alone. A user created with no roles and no org logs a warning.
89
+ Its second argument (`OAuthGrantContext`, `oauth-grant-context.ts`, #670) is the token set, the
90
+ whole verified id-token payload and a LAZY, once-per-login, `sub`-checked `userinfo()` — read
91
+ through `oauth-profile.ts`'s `userinfoBody`, never a second userinfo reader.
79
92
  - **`verifySession` writes at most once per `idleSlideMs`** (default `idleTtlMs / 20`). Never cache
80
93
  the USER row — `authenticate` re-reads it every request so a revoked role bites on the next one.
81
94
  In `observed`, `null` is an ANSWER (clears the stored value) and `undefined` is silence.
@@ -90,22 +103,29 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
90
103
  - `takeVerification(purpose, identifier, tokenHash)` consumes **only on a hash match**, in one
91
104
  conditional statement (`consumed_at is null` on the UPDATE and its subselect,
92
105
  `order by created_at desc limit 1`); `consumeVerification` still compares in constant time.
106
+ `consumed_at` is the adapter's `Clock`, bound, never `now()`.
93
107
  - **Foreign text in a `cause:` goes through `renderCauseValue`, in a `fix:` through
94
108
  `renderFixLiteral`, and in a shell `fix:` through `renderFixShellArg`.** Foreign here:
95
- `providerDetail()`'s return, `claims.iss`, `accountLocked`'s `key`, and every OAuth endpoint URL
109
+ `providerDetail()`'s return, `claims.iss`, `accountLocked`'s `key` (an `org:` key is never
110
+ rendered at all — it was looked up from the caller's address), and every OAuth endpoint URL
96
111
  in a command position (`jwks.ts`, `oauth-profile.ts`, `oauth-exchange.ts`,
97
112
  `oauth-discovery.ts`) — the registry is filled by an issuer's own discovery document. A LINE
98
113
  that would not run degrades to prose: `jwks.ts`'s `readTheKeySet(tail)` decides with core's
99
114
  `isFixShellSafe`. `oauth-discovery.ts` still owes that repair (it keeps `auth` on
100
115
  `FIX_SHELL_ARG_PINS`). `${provider}` is registry-validated boot config, not foreign.
101
- - `readCookie` never throws on a malformed value (`decodeURIComponent('%')`).
116
+ - Cookies are read through `@ultimat3/core`'s `readCookie` (`readSessionCookie`, the oauth
117
+ handshake), which never throws on a malformed value (`decodeURIComponent('%')`).
118
+ They are WRITTEN through its `serializeSetCookie`, never a literal (`bun run set-cookie-literals`,
119
+ `X_SET_COOKIE_HAND_BUILT`); `cookie-golden.test.ts` pins the bytes.
102
120
  - A token endpoint's HTTP 200 is not success — read `error`.
103
121
  - Link by address only when the provider **and** the local account both verified it
104
122
  (`link: 'verified-email'`, default; `'never'` is the only other value).
105
123
  - **The OAuth route paths are not configurable.** `oauth-paths.ts` imports nothing and is read by
106
124
  both `oauth-errors.ts` and `oauth-route.ts`; every "start over" fix is `restartAt(provider)`.
107
125
  - The routes are **descriptors** (`AuthRouteDescriptor`), never mounted handlers.
108
- - The callback answers failure as **coded JSON**, and success redirects to a fixed `successPath` —
126
+ - The callback answers failure as **coded JSON** — a fixed body per code (`code`, `title`, `docs`,
127
+ one `cause`, `fix: x errors explain <CODE> --json`); the authored cause and fix are the
128
+ `auth.oauth.refused` log line — and success redirects to a fixed `successPath` —
109
129
  never `?next=` (`nextAfterSignIn` in `@ultimat3/http` is the one open-redirect check).
110
130
  - The handshake cookie is cleared on **every** callback outcome.
111
131
  - Refresh is **not implemented**, so the framework **stores no provider token**: `accountFor`
@@ -118,47 +138,70 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
118
138
  an uncoded adapter throw logs `auth.oauth.uncoded_failure`, a rejecting `OAuthFetch` logs
119
139
  `auth.oauth.token_fetch_failed`, and the `POST /token` body is read as
120
140
  `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.
141
+ - **`x_users.mfa_secret` is SEALED** (`mfa-secret.ts`, purpose `auth:x_users.mfa_secret`):
142
+ `saveTotpSecret` writes, `openTotpSecret` reads and REFUSES a value core's `isSealed` does not
143
+ recognise (`X_MFA_SECRET_UNSEALED`, fix `x auth seal-mfa --json`). No plaintext fallback, ever —
144
+ `sealMfaSecrets` is the one-shot — each write a compare-and-set (`replaceMfaSecret`), a row that
145
+ changed underneath is `skipped` — and `x doctor` counts what is left. Whether a value is sealed is
146
+ `isSealed`'s to say, never a `like` in an adapter (`listUsersWithMfaSecret` returns them all).
123
147
  - **`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`
148
+ - **A JWKS refresh is single-flighted through core's `singleFlight` with
149
+ `deadlineMs = timeoutMs * 2`**; eviction frees the key, never the work, and a `generationFence`
126
150
  generation check (read, never `guard`) stops a superseded refresh overwriting the cache.
127
151
  `schedule` is injectable.
128
152
  - **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.
153
+ limiter inert against stuffing. A password proven with a second factor still owed clears
154
+ NOTHING: `completeMfa` is what clears, or a repeat of the first leg wipes the wrong codes.
155
+ - **`MemoryAuthAdapter.createUser` and `updateUser` enforce `x_users.id`, `email` and `external_id`
156
+ uniqueness** (`authUniqueViolation`, `X_AUTH_WRITE_FAILED`), NULLS DISTINCT like Postgres;
157
+ `PostgresAuthAdapter` answers the same code through `builtin-adapter-violation.ts`.
158
+ **`MemoryAuthAdapter` takes a `Clock`** (default `systemClock`) and stamps every instant from it.
133
159
  `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.
137
- - An api key's scopes are the agent actor's scopes. Never union them with the owner's roles.
160
+ - **`listUsersByOrg` orders by byte**: `collate "C"` in Postgres, a code-point compare in memory —
161
+ never `localeCompare`, never UTF-16 `<` (`list-users-order.live.test.ts`, on an `en_US` db).
162
+ - Every `AuthAdapter` member is REQUIRED — a missing one is a compile error at `defineAuth`, and
163
+ no caller in this package refuses at runtime with `X_NOT_IMPLEMENTED`.
164
+ - **An api key is its owner's credential.** `verifyApiKey` re-reads the owner (after the secret
165
+ matched, never before) and refuses a missing or disabled one as the same `X_API_KEY_INVALID`; a
166
+ key with `userId === null` has no owner and is unchanged. `disableUser` revokes the user's live
167
+ keys beside their sessions, and logs before its first write.
168
+ - **A key's scopes only ever shrink** (`apiKeyScopes`, `policy-bridge.ts`): `*` and `<res>:*` are
169
+ refused at `issueApiKey` (`X_CONFIG_INVALID`) and dropped from a stored row; an OWNED key keeps
170
+ only what its owner's grants cover. Roles are policy's to expand, so the default grants are the
171
+ row's `permissions` + `scopes` (`directGrants`) and a role-based app passes
172
+ `apiKeyResolver(store, { grantsOf })`. Never union a key with the owner's roles.
138
173
  - **`apiKeyResolver(() => store)` is the ONE token → caller mapping** (`api-key-resolver.ts`):
139
174
  `verifyApiKey` → `apiKeyActor`, `X_API_KEY_INVALID` → `null`, everything else rethrown. Its
140
175
  `ApiKeyCaller` is structurally http's `BearerCaller` and mcp's `ResolvedToken` — declared here
141
176
  because tier 2 imports neither. The store is a thunk (a mount is declared before boot installs
142
- the database client). No second in-memory key store: `MemoryAdapter` IS an `ApiKeyStore`.
177
+ the database client). No second in-memory key store: `MemoryAuthAdapter` IS an `ApiKeyStore`.
143
178
  - Rotate the session id on any privilege change (`rotateSession`, called by `updatePrivileges` in
144
- `privileges.ts`), never patch the row.
179
+ `privileges.ts`), never patch the row. A `passwordHash` change also ends every OTHER session of
180
+ that user (`deleteOtherSessions`), or all of them when the caller passed no session of theirs.
181
+ **A new id, never a new lifetime**: rotation carries `createdAt` and `absoluteExpiresAt` over
182
+ (`createSession` clamps both, so they only shorten), and the rotated cookie's `Max-Age` is
183
+ `remainingMaxAgeSeconds` — `session-rotation.test.ts` pins both adapters.
145
184
  - **Every argon2 call goes through `kdfGate()`** — width 8, queue 64, `X_OVERLOADED` past it
146
- (borrowed from http, in `AUTH_BORROWED_ERROR_CODES`). The pool is core's `createFlightGate`, the
185
+ (borrowed from http, in `AUTH_BORROWED_ERROR_CODES`). The pool is core's `flightGate`, the
147
186
  refusal auth's own through core's `overflow:` seam. `configureKdfGate()` is the ONE install point
148
187
  and deliberately not a `defineAuth` key.
149
- - **MFA has a first leg and no second one, and the second is not a route you can just add.**
150
- `login()` / `completeOAuthLogin()` throw `X_MFA_REQUIRED` before any session exists. A
151
- `POST /auth/mfa/verify { userId, code }` would be unauthenticated by construction. The follow-up
152
- needs three things together: a **sealed pending-MFA credential** built like `sealHandshake`, the
153
- completion as an `AuthRouteDescriptor` with its path in `oauth-paths.ts`'s style, and
154
- `auth.limiter` around `verifyTotp`. `TotpReplayGuard` is the completion's.
188
+ - **MFA's second leg is `completeMfa(auth, challenge, code)`** (`mfa-challenge.ts`). `login()`
189
+ ends in `X_MFA_REQUIRED` whose `meta.challenge` is a `seal()` value (purpose
190
+ `auth:mfa-challenge`: user id, nonce, expiry on the server's clock, `MFA_CHALLENGE_TTL_MS`) —
191
+ `meta`, never `cause`, and never a bare user id: a second leg keyed by one is unauthenticated by
192
+ construction. The code is reserved against the same three buckets before it is compared, and a
193
+ spent step is refused through `auth.totpReplay` (checked and remembered with no `await`
194
+ between). It needs the app's master key (`X_SEAL_KEY_MISSING`). No route ships: the handler
195
+ decides how the challenge travels. Anything that is not six digits is a RECOVERY CODE: hashed
196
+ (`recoveryCodeHash`) and consumed by `adapter.consumeRecoveryCode` — one conditional UPDATE, so
197
+ single-use is storage's guarantee. There is no read-then-write redemption helper.
155
198
  - **`mfa.required` is the literal `false`, and `defineAuth` refuses a `true`** (`X_CONFIG_INVALID`):
156
199
  enforcing it would lock out every un-enrolled user. `mfa.issuer` is read by
157
200
  `enrolTotp(auth, { account })`.
158
201
  - **A TOTP secret that decodes to zero bytes is NO key**: `verifyTotp` returns the generic failure
159
202
  (`{ ok: false, step: null }`), `totpCode` and `enrolTotp` throw `X_MFA_SECRET_INVALID`. The secret
160
203
  never reaches `cause:`/`fix:`. No length floor beyond one byte.
161
- - **`createTotpReplayGuard`'s table is bounded and the eviction ORDER is the guarantee**: subjects
204
+ - **`memoryTotpReplayGuard`'s table is bounded and the eviction ORDER is the guarantee**: subjects
162
205
  whose steps are all below the drift floor are forgotten first; past `DEFAULT_MAX_TOTP_SUBJECTS`,
163
206
  evict by newest spent step ascending, least-recently-seen tie-break. `maxSubjects` is normalised
164
207
  first (`boundedSubjects`): anything not a positive finite integer takes the default.
@@ -173,8 +216,10 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
173
216
  | `policy-bridge.ts` | the one funnel: identity → `Actor`, all four `ActorKind`s |
174
217
  | `session.ts` | two expiries, rotation, revocation, device list, the cookie |
175
218
  | `adapter.ts` | the seam; `builtin-adapter.ts` (Postgres) + `memory-adapter.ts` |
176
- | `rate-limit.ts` | per-ip, per-account and per-org buckets, lockout, scope check, `loginFailed()` |
177
- | `rate-limit-postgres.ts` | the SHARED limiter: two tables, a row per failure, over a structural `PgExecutor` |
219
+ | `builtin-adapter-violation.ts` | a Postgres unique violation on an auth table → `authUniqueViolation` |
220
+ | `rate-limit.ts` | per-ip, per-account and per-org buckets as reservations, lockout, scope check, `loginFailed()` |
221
+ | `login-attempt.ts` | one attempt's reservations across the three buckets, taken before the check and settled after it |
222
+ | `rate-limit-postgres.ts` | the SHARED limiter: one table, one row per key, a one-statement take, over a structural `PgExecutor` |
178
223
  | `limiter-install.ts` | the host's one install point for that limiter — the factory, what it built, and the purge over it |
179
224
  | `oauth.ts` | `OAuthProvider`, PKCE, `beginOAuth`, the callback gate. No I/O, no env |
180
225
  | `oauth-builtins.ts` | the three shipped IdPs, as data. Imports only the type, so no cycle |
@@ -182,22 +227,25 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
182
227
  | `oauth-discovery.ts` | `/.well-known/openid-configuration` → an `OAuthProvider`. One `fetch` |
183
228
  | `jwks.ts` | `crypto.subtle` signature verification, cached by `kid`, one shared in-flight refresh. No dependency |
184
229
  | `workload.ts` | a workload JWT (K8s SA / SPIFFE / IMDS / RFC 8693) → a `ServiceIdentity` |
185
- | `revocation.ts` | per-user, per-org and before-an-instant sweeps; `disableUser` |
230
+ | `revocation.ts` | per-user, per-org and before-an-instant sweeps; `disableUser` (sessions and api keys) |
231
+ | `mfa-challenge.ts` | the sealed challenge `X_MFA_REQUIRED` carries, and `completeMfa` |
232
+ | `mfa-secret.ts` | the TOTP secret at rest: seal on write, open on read, the one-shot that seals older rows |
186
233
  | `directory.ts` | `describeUser` (allow-list projection), `listOrgUsers`, external-id lookup |
187
- | `privileges.ts` | `updatePrivileges` — the grant, and the rotation it requires |
234
+ | `privileges.ts` | `updatePrivileges` — the grant, the rotation it requires, and the sessions a changed password ends |
188
235
  | `oauth-cookie.ts` | the handshake's home between the two legs: seal, open, the cookie |
189
236
  | `oauth-exchange.ts` | `oauthCredentials` + the one POST to the token endpoint |
190
237
  | `id-token.ts` | id token → claims this handshake may believe |
191
238
  | `id-token-fixture.ts` | the one string-input JWT builder the OAuth tests share. Off `index.ts` |
192
239
  | `auth-fixture.ts` | the KDF parameters, the password and the `AuthError` catcher the three `auth*.test.ts` suites share. Off `index.ts` |
193
- | `oauth-profile.ts` | claims or userinfo → one `OAuthProfile` |
240
+ | `oauth-profile.ts` | claims or userinfo → one `OAuthProfile`; `userinfoBody`, the one userinfo reader |
241
+ | `oauth-grant-context.ts` | `resolveGrants`' second argument: tokens, every id-token claim, a lazy subject-checked `userinfo()` |
194
242
  | `oauth-login.ts` | profile → account link → session. `completeOAuthLogin` is the entry point |
195
243
  | `oauth-login-fixture.ts` | the adapter, clock and profile the three `oauth-login*` suites share. Off `index.ts` |
196
244
  | `oauth-paths.ts` | the one declaration of where the two routes live. Imports nothing |
197
245
  | `errors.ts` | the codes this package owns and borrows, their titles, the one `registerErrorCodes()` call, `AuthError`, and every non-OAuth factory |
198
- | `oauth-errors.ts` | the OAuth half of those factories, and `restartAt`. Split off at the 500-line ceiling; declares no code and registers nothing |
246
+ | `oauth-errors.ts` | the OAuth refusals more than one OAuth module raises, and `restartAt`. A refusal with one thrower lives beside it; declares no code and registers nothing |
199
247
  | `oauth-route.ts` | `oauthLogin(auth)` — the redirect out and the callback back |
200
- | `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 |
248
+ | `kdf-gate.ts` | the one bound on concurrent argon2 work, and the `X_OVERLOADED` past it — core's `flightGate` with auth's own refusal injected |
201
249
  | `email.ts` | `normaliseEmail` — the one normalisation an address gets before it is an identity key |
202
250
  | `json.ts` | reading untrusted JSON: `isRecord`, and a base64url JWT segment as an object or `null` |
203
251
  | `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 |
@@ -215,7 +263,8 @@ Gotchas:
215
263
  this package **owns**, unconditionally, and lists the borrowed two in `AUTH_BORROWED_ERROR_CODES`
216
264
  without a title. A `hasErrorCode()` guard would suppress the `X_ERROR_CODE_DUPLICATE` that is
217
265
  supposed to fire when two packages claim one code.
218
- - Tests run against `MemoryAdapter`; no ADAPTER test needs a database. The two exceptions are
266
+ - Tests run against `MemoryAuthAdapter`; the one ADAPTER test that boots a database is
267
+ `adapter-parity-identity.test.ts` (PGlite). The two other exceptions are
219
268
  `postgresAuthLimiter`'s, and they are exceptions in the shape the repo already has: the
220
269
  scripted-executor twin (`rate-limit-postgres.test.ts`) proves the protocol with no server,
221
270
  and `rate-limit-postgres.live.test.ts` is `describe.skip` without `TEST_DATABASE_URL` — the