@ultimat3/auth 1.2.0 → 3.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 ADDED
@@ -0,0 +1,275 @@
1
+ # @ultimat3/auth — agent notes
2
+
3
+ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3/policy`'s job.
4
+
5
+ | Rule | |
6
+ |---|---|
7
+ | Deps | `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/db`. No external deps. |
8
+ | Never import | `@ultimat3/policy`, `@ultimat3/http` (tier 2 consumers), `@ultimat3/mail` (sideways) |
9
+ | Policy seam | `PolicyActorFields` in `policy-bridge.ts` mirrors policy's shape structurally |
10
+ | Http seam | `RequestLike` / `CookieJar` in `session.ts` and `AuthRouteDescriptor` in `oauth-route.ts`; http binds to them, not the reverse |
11
+ | Mail seam | injected `MailSender` port in `verify.ts`; the app wires `@ultimat3/mail`'s `send` |
12
+ | Better Auth | binds through `AuthAdapter`. It is an adapter, never a dependency. |
13
+ | Errors | `AuthError` from `errors.ts`; never `throw new Error` |
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. |
16
+
17
+ ## Non-negotiables
18
+
19
+ - Every credential failure throws `loginFailed()` — one code, one cause, one fix. Adding a
20
+ parameter to it re-opens account enumeration.
21
+ - **A stored hash Bun cannot read is the generic failure, and it burns the same KDF** (`As of
22
+ 2026-08`). `Bun.password.verify` THROWS rather than answering `false` on an unsupported algorithm
23
+ (a Django `pbkdf2_sha256$…` row: `UnsupportedAlgorithm`) or a malformed PHC string
24
+ (`InvalidEncoding`), so `verifyPassword` catching nothing was two faults at once: a bare `Error`
25
+ out of `login()` — a 500 where `loginFailed()`'s `X_UNAUTHENTICATED` belongs — and an enumeration
26
+ oracle on exactly the rows that have not migrated off the legacy scheme, which is the normal
27
+ state of a table mid-migration. `verifyAgainst` (`password.ts`) answers `null` there, and `null`
28
+ joins the no-user branch, `''` with it. Nothing is logged: the algorithm of an unreadable hash is
29
+ the same oracle one layer down. An `AuthError` out of the gate (`X_OVERLOADED`) is **re-thrown**,
30
+ never folded into the failure — a shed is load, not a verdict. Supported-but-old stays a verdict:
31
+ bcrypt verifies natively and `needsRehash` flags it, which is the rehash-on-login lever a legacy
32
+ migration rewrites rows with, and `password.test.ts` pins both halves.
33
+ - The limiter's table is **bounded**, and the eviction order is part of the guarantee. `ipKey`
34
+ mints one entry per source address, so half the keys are attacker-chosen and a spray from an
35
+ IPv6 /64 is a fresh key per attempt. Every bucket carries `forgetAtMs` — window emptied *and*
36
+ lockout expired, the instant it answers exactly as a missing one — and the sweep drops those
37
+ for free. `policy.maxKeys` (`DEFAULT_MAX_AUTH_LIMIT_KEYS`) is the backstop, and a **live
38
+ lockout outranks its own deadline** in the comparator: without that rank a spray recorded a
39
+ second later sorts ahead of the account it just locked, and filling the table becomes a way to
40
+ buy attempts back. Never reduce that sort to recency.
41
+ - **`AuthLimiter` is async on every member, and it declares the policy it enforces.** A
42
+ synchronous signature is one no shared implementation can satisfy — a lockout that holds across
43
+ replicas is a network round trip — so the interface the comment always promised was unreachable
44
+ by construction. `defineAuth` resolves the app's declaration and compares it against
45
+ `limiter.policy`, once, in `assertAuthLimiterPolicy`: a per-process limiter under
46
+ `scope: 'shared'` is `X_AUTH_LIMITER_NOT_SHARED`, and different `maxAttempts`/`windowMs`/
47
+ `lockoutMs` is `X_AUTH_LIMITER_POLICY_MISMATCH` — both at boot, never at the first spray.
48
+ `maxKeys` is **not** compared: it bounds one process' table, so a shared limiter has no opinion
49
+ on it. The point is that `Auth.rateLimit` is what an operator reads as "what this deployment
50
+ enforces", so an injected limiter may not quietly enforce something else. Nothing here reads the
51
+ environment to guess a replica count. `defineAuth({ limiter })` is the one install point.
52
+ - **`normaliseEmail` is the ONE normalisation, it lives ABOVE the `AuthAdapter` seam, and no
53
+ adapter may fold case** (`As of 2026-08`). `MemoryAdapter` lowercased and trimmed on both
54
+ `findUserByEmail` and `createUser`; `BuiltinAdapter` issues `where email = $1` against a plain
55
+ case-sensitive `text ... unique`. Two adapters, two answers to "does this account exist" — and
56
+ `oauth-login.ts`'s `resolveUser` normalised nothing at all, carrying the provider's display
57
+ casing straight through. So a provider sending `Ada@Example.com` linked the existing account
58
+ under `x dev` and minted a SECOND one in production, at an address `login()` (which lowercases)
59
+ could then never reach; a later `register()` at the lowercase spelling made a third. Every door
60
+ now normalises before the adapter sees the address — `register`, `login`, `profileEmail` in
61
+ `oauth-login.ts`, and `accountKey`, which must key the same way or one address buys a fresh
62
+ lockout budget per spelling. `adapter-parity.test.ts` pins both adapters in one test, the shape
63
+ `jobs/driver-parity.test.ts` established; `MemoryAdapter` normalising again is a failing test.
64
+ Trim and lowercase only: stripping a `+tag` or a gmail dot MERGES two addresses a person kept
65
+ apart, which is takeover between colleagues at one domain.
66
+ - **`json.ts` owns `isRecord` and `decodeJwtSegment`, and this package holds no second copy.**
67
+ `isRecord` was declared six times here and the base64url-JSON-payload decode three
68
+ (`jwks.decodeJwtHeader`, `id-token.decodeSegment`, `workload.verifyWorkloadToken`). All six
69
+ agreed that an array is not a record, which is the fact that matters: on a decoded JWT payload
70
+ the check is what gates every claim read after it, and `JSON.parse('[]')` narrows to
71
+ `Record<string, unknown>` without it. One declaration means one place for that to be true.
72
+ `decodeJwtSegment` answers `null` for all three failures — not base64url, not JSON, not an
73
+ object — because each caller has its own coded refusal to raise.
74
+ - Absolute and idle expiry are two separate computations in `sessionExpiry()`. Do not fold them.
75
+ - PKCE is not provider-dependent. `OAuthProvider.usesPkce` is the literal `true`, not `boolean`,
76
+ so `usesPkce: false` is a type error rather than a comment — and there is no
77
+ `if (provider.usesPkce)` branch left anywhere for it to have been false in. It stays the literal
78
+ now that `registerOAuthProvider` is open to any app: the mechanism has to survive the opening.
79
+ - **Providers are a registry, `OAuthProviderId` is `string`.** A closed union of three consumer
80
+ IdPs made an enterprise OP *unrepresentable* — a type constraint has no runtime escape, so the
81
+ only ways out were forking the package or bypassing OAuth entirely and losing PKCE, the sealed
82
+ handshake, issuer pinning and account linking with it. The three built-ins seed the registry
83
+ through the same `registerOAuthProvider()` an app calls, so there is still one way to do it.
84
+ `providerFor(id)` throws `X_OAUTH_PROVIDER_UNKNOWN` and never answers `undefined`; a second claim
85
+ on one id is `X_OAUTH_PROVIDER_DUPLICATE` at boot, never a silent replacement.
86
+ - **`oauthProviderUnknown(provider, supported)` scopes its list to its reader.** The route passes
87
+ `BUILTIN_OAUTH_PROVIDER_IDS` — an anonymous stranger typed that URL, and the registry now holds
88
+ whatever internal OP this deployment registered. `providerFor()` passes `oauthProviderIds()` —
89
+ its reader is a developer with a stack trace, and the full list is what makes the fix runnable.
90
+ Neither ever passes `defineAuth({ providers })`. One code, two audiences, one sentence that stays
91
+ executable either way because it names `registerOAuthProvider` before it names the list.
92
+ - **`verifyIdToken({ keys })` is required, with no default.** `'token-endpoint-tls'` is the OIDC
93
+ Core 3.1.3.7 exemption stated out loud, and `exchangeOAuthCode` is the only shipped caller
94
+ entitled to it. Anything else — IdP-initiated login, `form_post`, back-channel logout, token
95
+ exchange — passes a `JwksKeySource` and gets the signature checked. A default is exactly what
96
+ would let a second door inherit "unverified" from the first. `HS256` and `alg: none` are refused
97
+ in `decodeJwtHeader` before a key is ever looked up.
98
+ - **`resolveGrants` is a seam, never a group-to-role table.** It is called on EVERY login, not only
99
+ at creation, or "remove them from the group in the IdP" is a no-op forever. Absent means the app
100
+ has no opinion and the stored row is left alone; a seam returning the stored answer writes
101
+ nothing. Creating a user with no roles and no org logs a warning — that account can do nothing.
102
+ - **`verifySession` writes at most once per `idleSlideMs`** (default `idleTtlMs / 20`). Throttling
103
+ the SESSION write is safe; caching the USER row is not — `authenticate` re-reads it on every
104
+ request, and that is what makes a revoked role take effect on the next one with no token-expiry
105
+ lag. Do not cache it.
106
+ - A user's `scopes` column reaches `Actor.scopes`. Hardcoding `[]` there made a scope something no
107
+ human could hold, so `hasScope(actor, 'tenancy:cross')` was satisfiable only by minting a
108
+ `serviceActor` inside the handler — which discards the operator's identity and makes the sweep
109
+ unattributable, the exact property the required reason string exists to preserve.
110
+ - Every revocation takes a `reason` and logs `auth.revocation` before it runs.
111
+ `deleteSessionsForOrg` joins through `x_users`; `x_sessions` does **not** gain an `org_id`,
112
+ because a denormalised membership goes stale the moment somebody moves org and the 03:00 sweep
113
+ then leaves live exactly the sessions it was run to kill.
114
+ - The code flow carries `nonce` inside the id token, not on the redirect. `assertOAuthCallback`
115
+ checks an echoed one when present and never requires it; `verifyIdToken` is the real gate.
116
+ - The handshake crosses two requests, so it is sealed (`sealHandshake`), never handed over in a
117
+ variable. `openHandshake` takes the provider as an argument for the reason `decodeCursor` takes
118
+ a scope: an optional check is one a call site forgets. Expiry is the server's clock, not `Max-Age`.
119
+ - One handshake cookie **per provider** (`handshakeCookieName`), never one shared slot. Two tabs
120
+ are two handshakes in one jar, and a shared name makes the second redirect overwrite the first.
121
+ `clearHandshakeCookie(provider)` for the same reason: clearing all of them cancels the other tab.
122
+ - `takeVerification(purpose, identifier, tokenHash)` consumes the row **only on a hash match**,
123
+ in one conditional statement. The hash is an argument to the consume, not a comparison after it:
124
+ a store that consumes first lets `{identifier:'victim@…', token:'x'}` kill the victim's live
125
+ link, one request per address, unauthenticated. `consumeVerification` still compares in constant
126
+ time on the row it gets back — the seam is an app's to implement, and one that ignores the
127
+ argument would otherwise redeem any token. The Postgres statement carries `consumed_at is null`
128
+ on the UPDATE **and** in its subselect (single-use under two racing redemptions), and
129
+ `order by created_at desc limit 1` so it can only ever consume one row.
130
+ - **Foreign text reaching a `cause:` goes through `renderCauseValue`, and a `fix:` through
131
+ `renderFixLiteral`.** Not for throw-safety — these values are `string` by type, so
132
+ `bun run error-render` (which only sees `unknown`/`any`) will never catch one — but because a
133
+ newline writes a second log line an operator reads as genuine. Three values in this package are
134
+ foreign and all three are rendered at their source: `providerDetail()`'s return (a REMOTE
135
+ server's bytes, rendered there rather than at `oauthExchangeFailed` so the prose details this
136
+ package authors stay unquoted), `claims.iss` in `id-token.ts` (a field of the JWT the caller
137
+ presented), and `accountLocked`'s `key` (built by `ipKey` from a caller-supplied address).
138
+ `${provider}` is NOT in that set — it is registry-validated on every shipped path, so it is boot
139
+ config like `clientIdEnv`, not request data. Swept whole `As of 2026-08`.
140
+ - `readCookie` never throws on a malformed value. The `Cookie:` header is attacker-controlled and
141
+ `decodeURIComponent('%')` is a bare `URIError`, which would escape every coded path in this
142
+ package — the raw value goes to the signature or hash check, which is the readable refusal.
143
+ - A token endpoint's HTTP 200 is not success — GitHub reports a dead code that way. Read `error`.
144
+ - Link by address only when the provider **and** the local account both verified it. That is
145
+ `link: 'verified-email'`, the default; `'never'` is the only other value and there is
146
+ deliberately no "link on any provider address" — it is account takeover at a sloppy
147
+ provider, so it is unrepresentable rather than discouraged. An app that wants it wraps
148
+ `signInWithOAuth`.
149
+ - **The OAuth route paths are not configurable.** `oauth-paths.ts` imports nothing and is
150
+ read by both `errors.ts` and `oauth-route.ts`, so a `fix:` line naming
151
+ `GET /auth/oauth/<provider>` cannot outlive the route again — which is exactly what it did
152
+ through 1.2.0, when the library functions shipped with no route to mount them in. Every
153
+ "start over" fix is built from `restartAt(provider)`.
154
+ - The routes are **descriptors** (`mcpHttpRoute()`'s category), never mounted handlers:
155
+ `@ultimat3/http` is tier 2 like this package and `defineRoute` is tier 4 and renders a
156
+ page, so neither is importable here. A bare `Request` in, a `Response` out.
157
+ - The callback answers failure as **coded JSON**, and the success hop redirects to a fixed
158
+ `successPath` — never `?next=`. `nextAfterSignIn` in `@ultimat3/http` is the one
159
+ implementation of the open-redirect check and a second copy is one that drifts.
160
+ - The handshake cookie is cleared on **every** callback outcome, success and failure alike:
161
+ the code it authorised is spent either way.
162
+ - Refresh is **not implemented**. `AuthAccount` persists `refreshToken` and `expiresAt`, and
163
+ nothing reads them yet — the session is the framework's own credential and does not depend
164
+ on the provider token.
165
+ - The new `AuthAdapter` members are OPTIONAL (`findUserByExternalId`, `listUsersByOrg`,
166
+ `deleteSessionsForUser`, `deleteSessionsForOrg`, `deleteSessionsCreatedBefore`). A required
167
+ member is a breaking change to every third-party adapter; the callers throw
168
+ `X_NOT_IMPLEMENTED` naming the method instead.
169
+ - An api key's scopes are the agent actor's scopes. Never union them with the owner's roles.
170
+ - Rotate the session id on any privilege change (`rotateSession`), never patch the row.
171
+ `updatePrivileges` in `privileges.ts` is the caller that makes that rule exist — it had none
172
+ until 1.3.0, and `SessionPolicy.rotateOnPrivilegeChange` was a flag nothing read.
173
+ - **Every argon2 call goes through `kdfGate()`, and that is the only thing bounding its memory.**
174
+ 19 MiB of arena per hash at the OWASP floor, and both existing gates are per-SOURCE (`ipKey(ip)`,
175
+ 5 attempts; `@ultimat3/http`'s `auth` bucket, 10 per `route|ip:`) so a spray rotating an IPv6 /64
176
+ mints a fresh key every attempt — and both cap ATTEMPTS, not concurrent WORK. The only backstop
177
+ left was `http.maxInflight` (1000), about 19 GB of arenas queued. `kdf-gate.ts` bounds the width
178
+ (8) and the waiting queue (64) and refuses past it with `X_OVERLOADED`, borrowed from http and
179
+ listed in `AUTH_BORROWED_ERROR_CODES` — this package cannot import http, and a shed is a shed
180
+ whichever layer performs it. `configureKdfGate()` is the ONE install point and is deliberately
181
+ not a `defineAuth` key: the ceiling is a property of the machine, not of the app's auth policy.
182
+ - **MFA has a first leg and no second one, and the second one is not a route you can just add.**
183
+ `login()` and `completeOAuthLogin()` throw `X_MFA_REQUIRED` before any session exists; nothing is
184
+ written, so the only value handed over is a user id in `meta`. A `POST /auth/mfa/verify
185
+ { userId, code }` built on that is **unauthenticated by construction** — nothing binds it to a
186
+ completed first factor, so it converts MFA from a second factor into the only factor. That is why
187
+ the `fix:` now tells an app author to finish the flow itself (`verifyTotp` → `createSession({
188
+ mfaSatisfied: true })`) rather than naming a route, and why no route was added under a bug fix.
189
+ The framework's own second leg needs three things landing together, and fewer is worse than none:
190
+ a **sealed pending-MFA credential** built like `sealHandshake` (`oauth-cookie.ts`) — server-clock
191
+ expiry, one cookie, bound to the user id the first factor proved and to nothing the client says;
192
+ the completion shipped as an `AuthRouteDescriptor` the way `oauthLogin()` was (`oauth-route.ts`),
193
+ with its path declared in `oauth-paths.ts`'s style so the `fix:` and the mount cannot drift; and
194
+ `auth.limiter` around `verifyTotp` — today it is wired only into `login`, so a six-digit code
195
+ would be the one credential in this package with no lockout. `TotpReplayGuard` is already built
196
+ and must be the completion's, not a second one.
197
+ - **`mfa.required` is the literal `false`, and `defineAuth` refuses a `true` that reaches it
198
+ anyway** (`X_CONFIG_INVALID`, borrowed from core), `As of 2026-08`. It resolved onto the frozen
199
+ `Auth` and **nothing read it** — `login()` and `signInWithOAuth()` branch on `user.mfaSecret`
200
+ alone and mint `mfaSatisfied: true` otherwise, so `required: true` handed a user who had never
201
+ enrolled a fully-privileged session while reading as "this deployment requires a second factor".
202
+ Enforcing it was the tempting fix and is a **lockout**: `actorFromUser` degrades a session only
203
+ when `mfaSecret !== null`, so an un-enrolled user has no half-authenticated actor to enrol
204
+ through, this package ships no enrolment route to send them to, and `mfaRequired()`'s own `fix:`
205
+ (`verifyTotp({ secret: user.mfaSecret, … })`) cannot be followed with a null secret — a dead
206
+ `fix:`, the defect `oauth-paths.ts` exists to stop. So the unenforceable declaration is refused
207
+ where it is written, exactly as `assertAuthLimiterPolicy` refuses a per-process limiter under a
208
+ fleet-wide lockout. The literal type is the build error and the runtime check is for the JS
209
+ caller and the JSON config the type cannot reach, the split `invariantColumns()`'s Proxy keeps.
210
+ `mfa.issuer` is the half that stayed, and it now has a reader: `enrolTotp(auth, { account })`
211
+ takes the `Auth` every other entry point takes, so the product name an authenticator app shows
212
+ is declared once at `defineAuth` instead of restated at every enrolment.
213
+ - **`createTotpReplayGuard`'s table is bounded, and the eviction ORDER is the guarantee.** It
214
+ pruned steps inside one subject's `Set` and never revisited a subject who stopped signing in, so
215
+ the map carried one permanent entry per user for the life of the process. Evicting a subject
216
+ makes a step they have already spent replayable again, so the order cannot be recency of
217
+ insertion: a subject whose every step has fallen below the drift floor is **forgotten** (nothing
218
+ outside ±drift is ever offered to `verifyTotp`, so that entry answers exactly as a missing one),
219
+ and only if that is not enough does `DEFAULT_MAX_TOTP_SUBJECTS` evict live state, sorted by
220
+ newest spent step ascending with a least-recently-seen tie-break — the subject who just proved a
221
+ code is always the last one out. Same shape as the limiter's "a live lockout outranks its own
222
+ deadline". `remember` re-files its subject so the map's iteration order IS that tie-break.
223
+ `maxSubjects` is **normalised before any of that arithmetic runs** (`boundedSubjects`): the cap
224
+ ran on the caller's number unchecked, so `Infinity` left the table exactly as unbounded as it was
225
+ before it was capped, and `NaN` — every comparison against which is false — made the eviction
226
+ loop's `used.size <= evictTo` never true, emptying the table on the first sweep and handing back
227
+ a replay of the code the subject had just spent. Anything that is not a positive finite integer
228
+ takes `DEFAULT_MAX_TOTP_SUBJECTS`; a fraction still floors.
229
+ - SAML is out of scope permanently: XML-DSig canonicalisation has no Bun native and would need a
230
+ real dependency. Put an OIDC-speaking bridge in front and register that.
231
+
232
+ ## Files
233
+
234
+ | File | Job |
235
+ |---|---|
236
+ | `auth.ts` | `defineAuth`, entity schemas, `login`/`register`/`authenticate`/`logout` |
237
+ | `policy-bridge.ts` | the one funnel: identity → `Actor`, all four `ActorKind`s |
238
+ | `session.ts` | two expiries, rotation, revocation, device list, the cookie |
239
+ | `adapter.ts` | the seam; `builtin-adapter.ts` (Postgres) + `memory-adapter.ts` |
240
+ | `rate-limit.ts` | per-ip, per-account and per-org buckets, lockout, scope check, `loginFailed()` |
241
+ | `oauth.ts` | `OAuthProvider`, PKCE, `beginOAuth`, the callback gate. No I/O, no env |
242
+ | `oauth-builtins.ts` | the three shipped IdPs, as data. Imports only the type, so no cycle |
243
+ | `oauth-registry.ts` | the registry: `registerOAuthProvider`, `providerFor`, `oauthProviderIds` |
244
+ | `oauth-discovery.ts` | `/.well-known/openid-configuration` → an `OAuthProvider`. One `fetch` |
245
+ | `jwks.ts` | `crypto.subtle` signature verification, cached by `kid`. No dependency |
246
+ | `workload.ts` | a workload JWT (K8s SA / SPIFFE / IMDS / RFC 8693) → a `ServiceIdentity` |
247
+ | `revocation.ts` | per-user, per-org and before-an-instant sweeps; `disableUser` |
248
+ | `directory.ts` | `describeUser` (allow-list projection), `listOrgUsers`, external-id lookup |
249
+ | `privileges.ts` | `updatePrivileges` — the grant, and the rotation it requires |
250
+ | `oauth-cookie.ts` | the handshake's home between the two legs: seal, open, the cookie |
251
+ | `oauth-exchange.ts` | `oauthCredentials` + the one POST to the token endpoint |
252
+ | `id-token.ts` | id token → claims this handshake may believe |
253
+ | `id-token-fixture.ts` | the one string-input JWT builder the OAuth tests share. Off `index.ts` |
254
+ | `oauth-profile.ts` | claims or userinfo → one `OAuthProfile` |
255
+ | `oauth-login.ts` | profile → account link → session. `completeOAuthLogin` is the entry point |
256
+ | `oauth-login-fixture.ts` | the adapter, clock and profile the three `oauth-login*` suites share. Off `index.ts` |
257
+ | `oauth-paths.ts` | the one declaration of where the two routes live. Imports nothing |
258
+ | `oauth-route.ts` | `oauthLogin(auth)` — the redirect out and the callback back |
259
+ | `kdf-gate.ts` | the one bound on concurrent argon2 work, and the `X_OVERLOADED` past it |
260
+ | `email.ts` | `normaliseEmail` — the one normalisation an address gets before it is an identity key |
261
+ | `json.ts` | reading untrusted JSON: `isRecord`, and a base64url JWT segment as an object or `null` |
262
+
263
+ ```bash
264
+ bun test packages/auth
265
+ bun run --filter @ultimat3/auth typecheck
266
+ ```
267
+
268
+ Gotchas:
269
+ - `exactOptionalPropertyTypes` — declare optional fields as `x?: T | undefined`.
270
+ - `noUncheckedIndexedAccess` — index a `Record` into a local before narrowing it.
271
+ - `X_NOT_IMPLEMENTED` is core's, `X_FORBIDDEN` is policy's. `errors.ts` registers only the codes
272
+ this package **owns**, unconditionally, and lists the borrowed two in `AUTH_BORROWED_ERROR_CODES`
273
+ without a title. A `hasErrorCode()` guard would suppress the `X_ERROR_CODE_DUPLICATE` that is
274
+ supposed to fire when two packages claim one code.
275
+ - Tests run against `MemoryAdapter`; nothing in this package needs a database.