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