@ultimat3/auth 22.15.0 → 24.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,7 +5,7 @@ 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` |
@@ -41,16 +41,26 @@ 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
66
  `config.limiter` → the installed factory → `createAuthLimiter`. `installedAuthLimiter` and
@@ -90,22 +100,27 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
90
100
  - `takeVerification(purpose, identifier, tokenHash)` consumes **only on a hash match**, in one
91
101
  conditional statement (`consumed_at is null` on the UPDATE and its subselect,
92
102
  `order by created_at desc limit 1`); `consumeVerification` still compares in constant time.
103
+ `consumed_at` is the adapter's `Clock`, bound, never `now()`.
93
104
  - **Foreign text in a `cause:` goes through `renderCauseValue`, in a `fix:` through
94
105
  `renderFixLiteral`, and in a shell `fix:` through `renderFixShellArg`.** Foreign here:
95
- `providerDetail()`'s return, `claims.iss`, `accountLocked`'s `key`, and every OAuth endpoint URL
106
+ `providerDetail()`'s return, `claims.iss`, `accountLocked`'s `key` (an `org:` key is never
107
+ rendered at all — it was looked up from the caller's address), and every OAuth endpoint URL
96
108
  in a command position (`jwks.ts`, `oauth-profile.ts`, `oauth-exchange.ts`,
97
109
  `oauth-discovery.ts`) — the registry is filled by an issuer's own discovery document. A LINE
98
110
  that would not run degrades to prose: `jwks.ts`'s `readTheKeySet(tail)` decides with core's
99
111
  `isFixShellSafe`. `oauth-discovery.ts` still owes that repair (it keeps `auth` on
100
112
  `FIX_SHELL_ARG_PINS`). `${provider}` is registry-validated boot config, not foreign.
101
- - `readCookie` never throws on a malformed value (`decodeURIComponent('%')`).
113
+ - Cookies are read through `@ultimat3/core`'s `readCookie` (`readSessionCookie`, the oauth
114
+ handshake), which never throws on a malformed value (`decodeURIComponent('%')`).
102
115
  - A token endpoint's HTTP 200 is not success — read `error`.
103
116
  - Link by address only when the provider **and** the local account both verified it
104
117
  (`link: 'verified-email'`, default; `'never'` is the only other value).
105
118
  - **The OAuth route paths are not configurable.** `oauth-paths.ts` imports nothing and is read by
106
119
  both `oauth-errors.ts` and `oauth-route.ts`; every "start over" fix is `restartAt(provider)`.
107
120
  - The routes are **descriptors** (`AuthRouteDescriptor`), never mounted handlers.
108
- - The callback answers failure as **coded JSON**, and success redirects to a fixed `successPath` —
121
+ - The callback answers failure as **coded JSON** — a fixed body per code (`code`, `title`, `docs`,
122
+ one `cause`, `fix: x errors explain <CODE> --json`); the authored cause and fix are the
123
+ `auth.oauth.refused` log line — and success redirects to a fixed `successPath` —
109
124
  never `?next=` (`nextAfterSignIn` in `@ultimat3/http` is the one open-redirect check).
110
125
  - The handshake cookie is cleared on **every** callback outcome.
111
126
  - Refresh is **not implemented**, so the framework **stores no provider token**: `accountFor`
@@ -118,35 +133,58 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
118
133
  an uncoded adapter throw logs `auth.oauth.uncoded_failure`, a rejecting `OAuthFetch` logs
119
134
  `auth.oauth.token_fetch_failed`, and the `POST /token` body is read as
120
135
  `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.
136
+ - **`x_users.mfa_secret` is SEALED** (`mfa-secret.ts`, purpose `auth:x_users.mfa_secret`):
137
+ `saveTotpSecret` writes, `openTotpSecret` reads and REFUSES a value core's `isSealed` does not
138
+ recognise (`X_MFA_SECRET_UNSEALED`, fix `x auth seal-mfa --json`). No plaintext fallback, ever —
139
+ `sealMfaSecrets` is the one-shot — each write a compare-and-set (`replaceMfaSecret`), a row that
140
+ changed underneath is `skipped` — and `x doctor` counts what is left. Whether a value is sealed is
141
+ `isSealed`'s to say, never a `like` in an adapter (`listUsersWithMfaSecret` returns them all).
123
142
  - **`providerJwks` memoises only the DEFAULT client**; a caller supplying options gets its own.
124
143
  - **A JWKS refresh is single-flighted through core's `createSingleFlight` with
125
144
  `deadlineMs = timeoutMs * 2`**; eviction frees the key, never the work, and a `createFence`
126
145
  generation check (read, never `guard`) stops a superseded refresh overwriting the cache.
127
146
  `schedule` is injectable.
128
147
  - **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.
148
+ limiter inert against stuffing. A password proven with a second factor still owed clears
149
+ NOTHING: `completeMfa` is what clears, or a repeat of the first leg wipes the wrong codes.
150
+ - **`MemoryAdapter.createUser` and `updateUser` enforce `x_users.id`, `email` and `external_id`
151
+ uniqueness** (`authUniqueViolation`, `X_AUTH_WRITE_FAILED`), NULLS DISTINCT like Postgres;
152
+ `BuiltinAdapter` answers the same code through `builtin-adapter-violation.ts`.
132
153
  **`MemoryAdapter` takes a `Clock`** (default `systemClock`) and stamps every instant from it.
133
154
  `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.
155
+ - Every `AuthAdapter` member is REQUIRED — a missing one is a compile error at `defineAuth`, and
156
+ no caller in this package refuses at runtime with `X_NOT_IMPLEMENTED`.
157
+ - **An api key is its owner's credential.** `verifyApiKey` re-reads the owner (after the secret
158
+ matched, never before) and refuses a missing or disabled one as the same `X_API_KEY_INVALID`; a
159
+ key with `userId === null` has no owner and is unchanged. `disableUser` revokes the user's live
160
+ keys beside their sessions, and logs before its first write.
161
+ - **A key's scopes only ever shrink** (`apiKeyScopes`, `policy-bridge.ts`): `*` and `<res>:*` are
162
+ refused at `issueApiKey` (`X_CONFIG_INVALID`) and dropped from a stored row; an OWNED key keeps
163
+ only what its owner's grants cover. Roles are policy's to expand, so the default grants are the
164
+ row's `permissions` + `scopes` (`directGrants`) and a role-based app passes
165
+ `apiKeyResolver(store, { grantsOf })`. Never union a key with the owner's roles.
166
+ - **`apiKeyResolver(() => store)` is the ONE token → caller mapping** (`api-key-resolver.ts`):
167
+ `verifyApiKey` → `apiKeyActor`, `X_API_KEY_INVALID` → `null`, everything else rethrown. Its
168
+ `ApiKeyCaller` is structurally http's `BearerCaller` and mcp's `ResolvedToken` — declared here
169
+ because tier 2 imports neither. The store is a thunk (a mount is declared before boot installs
170
+ the database client). No second in-memory key store: `MemoryAdapter` IS an `ApiKeyStore`.
138
171
  - Rotate the session id on any privilege change (`rotateSession`, called by `updatePrivileges` in
139
- `privileges.ts`), never patch the row.
172
+ `privileges.ts`), never patch the row. A `passwordHash` change also ends every OTHER session of
173
+ that user (`deleteOtherSessions`), or all of them when the caller passed no session of theirs.
140
174
  - **Every argon2 call goes through `kdfGate()`** — width 8, queue 64, `X_OVERLOADED` past it
141
175
  (borrowed from http, in `AUTH_BORROWED_ERROR_CODES`). The pool is core's `createFlightGate`, the
142
176
  refusal auth's own through core's `overflow:` seam. `configureKdfGate()` is the ONE install point
143
177
  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.
178
+ - **MFA's second leg is `completeMfa(auth, challenge, code)`** (`mfa-challenge.ts`). `login()`
179
+ ends in `X_MFA_REQUIRED` whose `meta.challenge` is a `seal()` value (purpose
180
+ `auth:mfa-challenge`: user id, nonce, expiry on the server's clock, `MFA_CHALLENGE_TTL_MS`) —
181
+ `meta`, never `cause`, and never a bare user id: a second leg keyed by one is unauthenticated by
182
+ construction. The code is reserved against the same three buckets before it is compared, and a
183
+ spent step is refused through `auth.totpReplay` (checked and remembered with no `await`
184
+ between). It needs the app's master key (`X_SEAL_KEY_MISSING`). No route ships: the handler
185
+ decides how the challenge travels. Anything that is not six digits is a RECOVERY CODE: hashed
186
+ (`recoveryCodeHash`) and consumed by `adapter.consumeRecoveryCode` — one conditional UPDATE, so
187
+ single-use is storage's guarantee. There is no read-then-write redemption helper.
150
188
  - **`mfa.required` is the literal `false`, and `defineAuth` refuses a `true`** (`X_CONFIG_INVALID`):
151
189
  enforcing it would lock out every un-enrolled user. `mfa.issuer` is read by
152
190
  `enrolTotp(auth, { account })`.
@@ -168,8 +206,10 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
168
206
  | `policy-bridge.ts` | the one funnel: identity → `Actor`, all four `ActorKind`s |
169
207
  | `session.ts` | two expiries, rotation, revocation, device list, the cookie |
170
208
  | `adapter.ts` | the seam; `builtin-adapter.ts` (Postgres) + `memory-adapter.ts` |
171
- | `rate-limit.ts` | per-ip, per-account and per-org buckets, lockout, scope check, `loginFailed()` |
172
- | `rate-limit-postgres.ts` | the SHARED limiter: two tables, a row per failure, over a structural `PgExecutor` |
209
+ | `builtin-adapter-violation.ts` | a Postgres unique violation on an auth table → `authUniqueViolation` |
210
+ | `rate-limit.ts` | per-ip, per-account and per-org buckets as reservations, lockout, scope check, `loginFailed()` |
211
+ | `login-attempt.ts` | one attempt's reservations across the three buckets, taken before the check and settled after it |
212
+ | `rate-limit-postgres.ts` | the SHARED limiter: one table, one row per key, a one-statement take, over a structural `PgExecutor` |
173
213
  | `limiter-install.ts` | the host's one install point for that limiter — the factory, what it built, and the purge over it |
174
214
  | `oauth.ts` | `OAuthProvider`, PKCE, `beginOAuth`, the callback gate. No I/O, no env |
175
215
  | `oauth-builtins.ts` | the three shipped IdPs, as data. Imports only the type, so no cycle |
@@ -177,9 +217,11 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
177
217
  | `oauth-discovery.ts` | `/.well-known/openid-configuration` → an `OAuthProvider`. One `fetch` |
178
218
  | `jwks.ts` | `crypto.subtle` signature verification, cached by `kid`, one shared in-flight refresh. No dependency |
179
219
  | `workload.ts` | a workload JWT (K8s SA / SPIFFE / IMDS / RFC 8693) → a `ServiceIdentity` |
180
- | `revocation.ts` | per-user, per-org and before-an-instant sweeps; `disableUser` |
220
+ | `revocation.ts` | per-user, per-org and before-an-instant sweeps; `disableUser` (sessions and api keys) |
221
+ | `mfa-challenge.ts` | the sealed challenge `X_MFA_REQUIRED` carries, and `completeMfa` |
222
+ | `mfa-secret.ts` | the TOTP secret at rest: seal on write, open on read, the one-shot that seals older rows |
181
223
  | `directory.ts` | `describeUser` (allow-list projection), `listOrgUsers`, external-id lookup |
182
- | `privileges.ts` | `updatePrivileges` — the grant, and the rotation it requires |
224
+ | `privileges.ts` | `updatePrivileges` — the grant, the rotation it requires, and the sessions a changed password ends |
183
225
  | `oauth-cookie.ts` | the handshake's home between the two legs: seal, open, the cookie |
184
226
  | `oauth-exchange.ts` | `oauthCredentials` + the one POST to the token endpoint |
185
227
  | `id-token.ts` | id token → claims this handshake may believe |
@@ -190,7 +232,7 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
190
232
  | `oauth-login-fixture.ts` | the adapter, clock and profile the three `oauth-login*` suites share. Off `index.ts` |
191
233
  | `oauth-paths.ts` | the one declaration of where the two routes live. Imports nothing |
192
234
  | `errors.ts` | the codes this package owns and borrows, their titles, the one `registerErrorCodes()` call, `AuthError`, and every non-OAuth factory |
193
- | `oauth-errors.ts` | the OAuth half of those factories, and `restartAt`. Split off at the 500-line ceiling; declares no code and registers nothing |
235
+ | `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 |
194
236
  | `oauth-route.ts` | `oauthLogin(auth)` — the redirect out and the callback back |
195
237
  | `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 |
196
238
  | `email.ts` | `normaliseEmail` — the one normalisation an address gets before it is an identity key |
@@ -210,7 +252,8 @@ Gotchas:
210
252
  this package **owns**, unconditionally, and lists the borrowed two in `AUTH_BORROWED_ERROR_CODES`
211
253
  without a title. A `hasErrorCode()` guard would suppress the `X_ERROR_CODE_DUPLICATE` that is
212
254
  supposed to fire when two packages claim one code.
213
- - Tests run against `MemoryAdapter`; no ADAPTER test needs a database. The two exceptions are
255
+ - Tests run against `MemoryAdapter`; the one ADAPTER test that boots a database is
256
+ `adapter-parity-identity.test.ts` (PGlite). The two other exceptions are
214
257
  `postgresAuthLimiter`'s, and they are exceptions in the shape the repo already has: the
215
258
  scripted-executor twin (`rate-limit-postgres.test.ts`) proves the protocol with no server,
216
259
  and `rate-limit-postgres.live.test.ts` is `describe.skip` without `TEST_DATABASE_URL` — the
package/README.md CHANGED
@@ -40,6 +40,9 @@ const { start, callback } = oauthLogin(auth);
40
40
  - The lockout counts attempts against one identity, so it has to be **one** count. `AuthLimiter`
41
41
  is async on every member and declares the policy it enforces; `defineAuth` refuses a limiter
42
42
  that disagrees with the app's declaration.
43
+ - An attempt is **reserved** before the KDF, never checked and recorded after it: `reserve(key)`
44
+ counts the guess in the step that admits it, so a concurrent burst spends the allowance as it
45
+ arrives. `refund(reservation)` gives one back on success; a failure calls nothing.
43
46
 
44
47
  ## The lockout across replicas
45
48
 
@@ -70,8 +73,10 @@ refuses any pairing that would make it a lie:
70
73
  `account:<email>` and `ip:<addr>` were the only key shapes, so one tenant's misconfigured
71
74
  integration hammering login from 400 addresses was capped by neither — each IP bucket allowed its
72
75
  own quota, the account buckets protected individuals, and the shared limiter saturated behind
73
- them. `orgKey(orgId)` is the third shape, checked in `login()` once the address resolves to an org
74
- and still before the KDF runs.
76
+ them. `orgKey(orgId)` is the third shape, reserved in `login()` once the address resolves to an org
77
+ and still before the KDF runs. A locked tenant answers `X_ACCOUNT_LOCKED` with no org id in it —
78
+ the id was looked up from the address the caller typed — and an attempt the tenant cap turns away
79
+ is not counted against the account or the address.
75
80
 
76
81
  `rateLimit.orgMaxAttempts` is its own number, defaulting to `maxAttempts * 20`: a whole tenant
77
82
  sharing five attempts is a denial of service against that tenant. A success on one member's
@@ -103,14 +108,18 @@ exemption here — the token arrived in a header.
103
108
 
104
109
  **mTLS is out of scope.** TLS termination is the mesh's job ([axiom
105
110
  7](../../docs/idea/README.md)); the framework's part is reading a trusted
106
- `x-forwarded-client-cert` through `@ultimat3/http`'s trusted-proxy seam, which that package owns.
111
+ `x-forwarded-client-cert` through `@ultimat3/http`'s trusted-proxy seam, which that package owns —
112
+ and only once the app declares `configureHttp({ trustClientCertHeader: true })`: trusting the
113
+ proxy's `x-forwarded-for` is not a promise that it strips a certificate header the client sent.
107
114
 
108
115
  `maxKeys` is not compared — it bounds one process' table, not a limit. A custom limiter therefore
109
116
  does **not** own its own configuration: the policy stays the app's single statement of the limits,
110
117
  and the boot check is what keeps it true.
111
118
 
112
- **A shared limiter ships, `As of 2026-08`** — `postgresAuthLimiter({ executor, clock, policy })`,
113
- two tables, a row per failure so the window still SLIDES across replicas. Until it landed,
119
+ **A shared limiter ships, `As of 2026-10`** — `postgresAuthLimiter({ executor, clock, policy })`,
120
+ one table (`x_auth_lockouts`), one row per key holding the instants still inside the window, so
121
+ the window still SLIDES across replicas and a take is ONE statement: forty concurrent guesses
122
+ against `maxAttempts: 5` are admitted five times. Until it landed,
114
123
  `scope: 'shared'` was a declaration nothing in the framework could satisfy while `x new` scaffolded
115
124
  `replicas: 2` — `maxAttempts × 2` guesses per account. `executor` is a `PgExecutor`, anything
116
125
  speaking `query(text, values)`; **never `Bun.sql`**, whose `.query` is `undefined`.
@@ -152,7 +161,17 @@ defineAuth({
152
161
  Both limiters share one table: the keys are prefixed (`account:`, `ip:`, `org:`) and every limit
153
162
  travels as a statement parameter, so the tenant bucket's wider allowance cannot leak into the
154
163
  account bucket's. It reports `maxKeys: undefined` — there is no in-process table to bound — and
155
- neither table forgets on its own.
164
+ the table does not forget on its own.
165
+
166
+ A limiter of your own implements the same five members:
167
+
168
+ | Member | Contract |
169
+ |---|---|
170
+ | `reserve(key)` | count one attempt atomically; `X_ACCOUNT_LOCKED`, counting nothing, while the key is locked; the attempt that fills the window is admitted and starts the lockout. Answers `{ key, atMs }` |
171
+ | `refund(reservation)` | remove that ONE attempt, and lift a lockout the window no longer justifies |
172
+ | `recordSuccess(key)` | clear the key — its window and its lockout |
173
+ | `lockedUntil(key)` / `reset()` | the live deadline or `null`; drop everything |
174
+ | `purgeExpired?()` | only for a limiter backed by a table |
156
175
 
157
176
  **An app does not have to write any of that, `As of 2026-08-22`.** The boot fills a seam and every
158
177
  `defineAuth` in the process picks it up:
@@ -178,10 +197,10 @@ Precedence is `defineAuth({ limiter })` → the installed factory → `createAut
178
197
  `resetAuthLimiters()` puts the per-process default back. `@ultimat3/cli`'s `startServices` calls it
179
198
  on every boot, so a scaffolded app gets a fleet-wide lockout with nothing to remember.
180
199
 
181
- Neither table forgets on its own, and `purgeAuthLimits()` is the framework's reader for that:
182
- it drops failures past the window and lockouts that have expired, measured against the clock the
183
- host handed the limiter, and it sweeps only the WIDEST window installed — a sweep on a narrower
184
- one deletes failures another limiter is still counting, which hands a sprayer its attempts back.
200
+ The table does not forget on its own, and `purgeAuthLimits()` is the framework's reader for that:
201
+ it drops every key whose window has emptied and whose lockout has expired, measured against the
202
+ clock the host handed the limiter, and it sweeps only the WIDEST window installed — a sweep on a
203
+ narrower one deletes attempts another limiter is still counting, which hands a sprayer them back.
185
204
  `@ultimat3/jobs`' `purge()` job is what calls it hourly; `x dev` and every role container declare
186
205
  that sweep at boot.
187
206
 
@@ -222,12 +241,13 @@ registerOAuthProvider(await discoverOAuthProvider({ id: 'bigco-sso', issuer: 'ht
222
241
  | `registerOAuthProvider(provider)` | the frozen provider; `X_OAUTH_PROVIDER_DUPLICATE` on a second claim of one id |
223
242
  | `providerFor(id)` | the provider, or throws `X_OAUTH_PROVIDER_UNKNOWN` — never `undefined` |
224
243
  | `hasOAuthProvider(id)` | whether the id is registered |
225
- | `BUILTIN_OAUTH_PROVIDER_IDS` | the three shipped ids — the only list an **anonymous** refusal names |
244
+ | `BUILTIN_OAUTH_PROVIDER_IDS` | the three shipped ids — the only list an **anonymous** refusal is built from, and it is named in the `auth.oauth.refused` log line, never in the response body |
226
245
  | `oauthProviderIds()` | every registered id, live. **NOT** what `defineAuth({ providers })` defaults to — that is `[]`, so an app names what it enabled |
227
246
 
228
247
  `discoverOAuthProvider` refuses a document with no `jwks_uri`: without a key set there is nothing
229
248
  to check an id token's signature against, and the token-endpoint TLS exemption below is a thing a
230
- caller declares, not one a provider inherits by omission.
249
+ caller declares, not one a provider inherits by omission. It refuses a document whose `issuer` is
250
+ not the one asked for, too (OIDC Discovery §4.3).
231
251
 
232
252
  **SAML is out of scope and will stay out of scope.** XML-DSig canonicalisation has no Bun native
233
253
  and implementing it would mean a real dependency in the primitive vocabulary, which
@@ -284,41 +304,44 @@ rather than never. A seam that returns the stored answer writes nothing.
284
304
 
285
305
  ## MFA — TOTP and recovery codes
286
306
 
287
- Pure functions over a secret and a clock. This package mints, checks and de-duplicates a code;
288
- **it persists nothing** — the secret, the recovery-code hashes and the spent steps are the app's
289
- rows, because `AuthAdapter` has no MFA member and adding one would break every third-party
290
- adapter.
307
+ TOTP is pure functions over a secret and a clock; what is STORED goes through two writers and one
308
+ reader, `As of 2026-10`: the secret is sealed at rest, and a recovery code is consumed atomically.
291
309
 
292
310
  ```ts
293
311
  import type { Auth } from '@ultimat3/auth';
294
- import { createTotpReplayGuard, enrolTotp, generateRecoveryCodes, verifyTotp } from '@ultimat3/auth';
295
- import { systemClock } from '@ultimat3/core';
312
+ import { enrolTotp, generateRecoveryCodes, saveTotpSecret } from '@ultimat3/auth';
296
313
 
297
314
  declare const auth: Auth; // the `defineAuth` at the top — `enrolTotp` reads `auth.mfa.issuer`
315
+ declare const userId: string;
298
316
 
299
317
  const enrolment = enrolTotp(auth, { account: 'ada@example.com' }); // issuer: auth.mfa.issuer
300
- // enrolment.uri -> otpauth://… the QR code
301
- // enrolment.secret -> base32, store it against the user
302
-
303
- const recovery = generateRecoveryCodes(); // { codes, hashes } — show `codes` ONCE, store `hashes`
304
- const guard = createTotpReplayGuard();
305
-
306
- export function secondFactorHolds(userId: string, secret: string, code: string): boolean {
307
- const at = systemClock.now();
308
- const { ok, step } = verifyTotp({ secret, code, at });
309
- if (!ok || step === null || guard.isUsed(userId, step)) return false;
310
- guard.remember(userId, step, at);
311
- return true;
312
- }
318
+ // enrolment.uri -> otpauth://… the QR code
319
+ await saveTotpSecret(auth, userId, enrolment.secret); // sealed, onto x_users
320
+
321
+ const recovery = generateRecoveryCodes(); // { codes, hashes } — show `codes` ONCE
322
+ await auth.adapter.updateUser(userId, { recoveryCodeHashes: recovery.hashes });
313
323
  ```
314
324
 
325
+ Checking a code at sign-in is `completeMfa` (below) — it opens the secret, meters the guess and
326
+ refuses a spent code; an app does not call `verifyTotp` on a stored secret itself.
327
+
328
+ **The secret at rest is sealed** under the app's master key (`purpose: 'auth:x_users.mfa_secret'`).
329
+ A TOTP seed is symmetric, so it cannot be a digest — and in the clear, a dump of `x_users` was
330
+ every enrolled user's second factor.
331
+
332
+ | | |
333
+ |---|---|
334
+ | write | `saveTotpSecret(auth, userId, secret)` — refuses a secret nothing can derive a code from (`X_MFA_SECRET_INVALID`), needs the master key (`X_SEAL_KEY_MISSING`) |
335
+ | read | `openTotpSecret(stored)` — **refuses a value that is not sealed** (`X_MFA_SECRET_UNSEALED`, fix `x auth seal-mfa --json`). There is no plaintext fallback, by decision: a fallback kept "for now" is a plaintext column forever |
336
+ | rows from before sealing | `x auth seal-mfa` once, or `sealMfaSecrets({ adapter })` on an adapter of your own — idempotent, answers `{ sealed, alreadySealed, skipped }`; each write is a compare-and-set (`adapter.replaceMfaSecret`), so a re-enrolment mid-run is `skipped`, never overwritten. `x doctor` reports how many are left |
337
+
315
338
  | Call | Answers |
316
339
  |---|---|
317
340
  | `enrolTotp(auth, { account, issuer?, secret? })` | `{ secret, uri, digits, periodSeconds }` — `issuer` omitted is `auth.mfa.issuer`, `secret` omitted mints one |
318
341
  | `verifyTotp({ secret, code, at, drift?, usedSteps? })` | `{ ok, step }`. `step` is the window the code belonged to, `null` on no match |
319
342
  | `createTotpReplayGuard(drift?, maxSubjects?)` | the in-process `{ isUsed, remember, size }`; a fleet passes a Redis-backed pair of the same two methods |
320
343
  | `generateRecoveryCodes(count = 10)` | `{ codes, hashes }`. `codes` is shown once and is never re-derivable |
321
- | `redeemRecoveryCode(code, hashes)` | the **remaining** hashes, or `null`. Persisting that array is what makes a code single-use |
344
+ | `recoveryCodeHash(code)` | what a code is stored and looked up as — dashes, spaces and case removed first. `auth.adapter.consumeRecoveryCode(userId, hash)` removes it in ONE statement and answers whether it was there, so two requests carrying one code cannot both redeem it |
322
345
  | `totpStep(at, stepSeconds?)` / `totpCode(secret, step, digits?)` | the RFC 6238 halves, for a test that has to mint a valid code. `totpCode` throws `X_MFA_SECRET_INVALID` on a secret that decodes to zero bytes |
323
346
 
324
347
  **A secret the decoder cannot read verifies nothing, `As of 2026-08`.** `base32Decode` answers
@@ -360,11 +383,49 @@ at `login()` instead is a lockout — `actorFromUser` degrades only a user who H
360
383
  package ships no enrolment route to send the rest to. Gate it in your own sign-in handler —
361
384
  `if (user.mfaSecret === null)` send them to `enrolTotp` before you call `createSession`.
362
385
 
363
- **The second leg of login is the app's, `As of 2026-08`.** `login()` and `completeOAuthLogin()`
364
- throw `X_MFA_REQUIRED` before any session exists; finishing the flow is `verifyTotp` followed by
365
- `createSession({ mfaSatisfied: true })` in the app's own route. The framework ships no
366
- `POST /auth/mfa/verify`, deliberately — see [`CLAUDE.md`](CLAUDE.md) for the three things that
367
- would have to land together, and why fewer is worse than none.
386
+ **The second leg of login is `completeMfa`, `As of 2026-10`.** `login()` and
387
+ `completeOAuthLogin()` throw `X_MFA_REQUIRED` before any session exists, and the error's
388
+ `meta.challenge` is what carries the proven first leg to the second: a value sealed under the
389
+ app's master key (`x secrets init`; `X_SEAL_KEY_MISSING` without one), naming the user, good for
390
+ `MFA_CHALLENGE_TTL_MS` (five minutes) on the server's clock. It is `meta`, never `cause` — the
391
+ problem document publishes `cause` — so the sign-in handler decides how it travels.
392
+
393
+ ```ts
394
+ import { type Auth, completeMfa, login } from '@ultimat3/auth';
395
+ import { isUltimateError } from '@ultimat3/core';
396
+
397
+ declare const auth: Auth;
398
+
399
+ export async function signIn(email: string, password: string, ip: string) {
400
+ try {
401
+ return { session: await login(auth, { email, password, ip }) };
402
+ } catch (error) {
403
+ if (!isUltimateError(error) || error.code !== 'X_MFA_REQUIRED') throw error;
404
+ return { challenge: String(error.meta?.['challenge']) }; // a cookie, or your own body
405
+ }
406
+ }
407
+
408
+ export const secondFactor = (challenge: string, code: string, ip: string) =>
409
+ completeMfa(auth, challenge, code, { ip }); // what `login()` answers: actor, session, cookie
410
+ ```
411
+
412
+ | `completeMfa` is given | Answer |
413
+ |---|---|
414
+ | a live challenge and the current code, or an unused recovery code | `{ actor, session, token, cookie }`, `mfaSatisfied: true` |
415
+ | a wrong code, or one already spent | `X_UNAUTHENTICATED`; the guess stays counted |
416
+ | a value this app did not seal, an expired one, or one for an account now disabled or un-enrolled | `X_UNAUTHENTICATED`, one refusal for all of them |
417
+ | a guess past `rateLimit.maxAttempts` | `X_ACCOUNT_LOCKED` — the same account, address and tenant buckets a password guess spends |
418
+
419
+ A code guess is reserved before it is compared, so six digits are worth `maxAttempts` tries per
420
+ window. Repeating the first leg does not buy them back: a proven password with a second factor
421
+ still owed clears nothing — `completeMfa` is what clears the account's window. A spent step is
422
+ refused through `auth.totpReplay` (`defineAuth({ totpReplay })`, in-process by default). No route
423
+ ships.
424
+
425
+ `code` is either the six digits or a **recovery code**: anything that is not six digits is hashed
426
+ and consumed through the adapter, metered exactly as a wrong digit string is, and gone after one
427
+ use. A stored secret that is not sealed is `X_MFA_SECRET_UNSEALED` (HTTP 500) before any guess is
428
+ counted — the deployment's fault, never the caller's.
368
429
 
369
430
  ## Email verification and password reset
370
431
 
@@ -422,9 +483,9 @@ unauthenticated POST with any token cannot kill the victim's live link.
422
483
  | `revokeUserSessions(auth, userId, reason)` | one person, their current session included |
423
484
  | `revokeOrgSessions(auth, orgId, reason)` | one tenant, at 03:00, without touching another |
424
485
  | `revokeSessionsCreatedBefore(auth, at, reason)` | everything minted under a rotated secret |
425
- | `disableUser(auth, userId, reason)` | stamps `disabledAt` **and** kills the sessions |
486
+ | `disableUser(auth, userId, reason)` | stamps `disabledAt`, kills the sessions **and** revokes the user's live api keys — `{ user, sessionsRevoked, apiKeysRevoked }`. Re-enabling restores neither |
426
487
  | `listOrgUsers(auth, orgId, { role })` | the quarterly access review, as safe summaries |
427
- | `updatePrivileges(auth, userId, patch, session?)` | the grant, plus the session rotation it requires |
488
+ | `updatePrivileges(auth, userId, patch, session?)` | the grant, plus the session rotation it requires. A `passwordHash` change ends every OTHER session of that user (`sessionsRevoked`) — all of them when no session of theirs was passed, as in a reset |
428
489
 
429
490
  `reason` is a required argument on every revocation, for the reason `crossTenant()` requires one:
430
491
  an incident review asks who killed these sessions and why, and a `delete` with no line answers
@@ -444,7 +505,7 @@ an adapter implementation, not a dependency of this package.
444
505
 
445
506
  | Driver | Use |
446
507
  |---|---|
447
- | `BuiltinAdapter` | Postgres via `@ultimat3/db`; takes an injected `DbClient` |
508
+ | `BuiltinAdapter` | Postgres via `@ultimat3/db`. `new BuiltinAdapter(client?, clock?)` — the process client and `systemClock` by default; the clock stamps `x_verifications.consumed_at` |
448
509
  | `MemoryAdapter` | `x new` before a database exists, and every test in this package. `new MemoryAdapter(clock)` — `systemClock` by default — stamps every instant it writes |
449
510
  | your own | implement `AuthAdapter`; DDL in `tables.ts` shows what the columns mean |
450
511
 
@@ -454,10 +515,8 @@ would not. Normalisation happens once, above the seam, in `normaliseEmail` (`ema
454
515
  lowercase, nothing else. Call it before `findUserByEmail`/`createUser` in any login route of your
455
516
  own, and key any bucket of your own with it — `accountKey` does.
456
517
 
457
- The seam's newer members — `findUserByExternalId`, `listUsersByOrg`, `deleteSessionsForUser`,
458
- `deleteSessionsForOrg`, `deleteSessionsCreatedBefore` — are **optional**, so a 1.2-era adapter
459
- still satisfies the interface. Calling one an adapter has not implemented is `X_NOT_IMPLEMENTED`
460
- with the method named, not a silent no-op.
518
+ Every member of the seam is required: an adapter missing one is a compile error, never a runtime
519
+ refusal.
461
520
 
462
521
  `x_users` gained two columns in 1.3.0 — `scopes text[]` and `external_id text unique` — plus an
463
522
  `org_id` index.
@@ -553,7 +612,7 @@ Bun.serve({
553
612
  |---|---|---|
554
613
  | path | `/auth/oauth/:provider` | `/auth/oauth/:provider/callback` |
555
614
  | success | `302` to the provider, `Set-Cookie: __Host-x_oauth_<provider>` | `303` to `successPath`, `Set-Cookie: __Host-x_session` **and** the handshake cleared |
556
- | failure | the coded JSON body, status per code | the same, handshake cleared either way |
615
+ | failure | coded JSON, status per code: `code` + `title` + `docs`, one fixed `cause`, `fix: x errors explain <CODE> --json` | the same, handshake cleared either way |
557
616
 
558
617
  A **descriptor**, never a mounted handler — the same category as `mcpHttpRoute()`. `@ultimat3/http`
559
618
  is tier 2 like this package, so auth may not import it, and `defineRoute` is tier 4 and describes
@@ -577,8 +636,12 @@ mounting API to call today; do not write one, and do not read this section as pr
577
636
  at `GET /auth/oauth/<provider>`; it now quotes `oauthStartPath()`, the same declaration the mount
578
637
  reads. A movable base path is that sentence going stale again.
579
638
 
580
- **Failure is JSON, not a redirect carrying `?error=`.** The callback is the one request whose
581
- failure a developer must read, and there is no `?next=` on the success hop either: an
639
+ `oauthLogin` needs an origin to build the provider's `redirect_uri` from: `baseUrl`, or `APP_URL`.
640
+ With neither, the start leg refuses with `X_ENV_MISSING` rather than trust a request header.
641
+
642
+ **Failure is JSON, not a redirect carrying `?error=`.** The body is the same for every refusal of
643
+ one code; the authored cause and fix are the `auth.oauth.refused` log line, where only an operator
644
+ reads them. The callback is the one request whose failure a developer must read, and there is no `?next=` on the success hop either: an
582
645
  attacker-supplied return target on the endpoint that hands out a session is the classic open
583
646
  redirect, and `nextAfterSignIn` in `@ultimat3/http` is the one implementation of that check.
584
647
 
@@ -656,12 +719,74 @@ JWT signed with the `.p8` key, which Apple expires every six months.
656
719
  looked up by the non-secret id.
657
720
 
658
721
  ```ts
722
+ import { type Auth, apiKeyActor, issueApiKey, verifyApiKey } from '@ultimat3/auth';
723
+
724
+ declare const auth: Auth;
725
+ declare const orgId: string;
726
+
659
727
  const { plaintext, record } = issueApiKey({ env: 'prod', scopes: ['post:publish'], orgId });
660
728
  await auth.adapter.putApiKey(record);
661
- const actor = apiKeyActor(await verifyApiKey(auth.adapter, plaintext)); // kind: 'agent'
729
+ export const actor = apiKeyActor(await verifyApiKey(auth.adapter, plaintext)); // kind: 'agent'
730
+ ```
731
+
732
+ `verifyApiKey` answers `{ record, owner }`. A key is its owner's credential, `As of 2026-10`:
733
+
734
+ | The key | What verification does |
735
+ |---|---|
736
+ | names no `userId` | nothing more — a key no user owns is cut by its scopes and its org alone |
737
+ | names a `userId` | re-reads that user from the store. Missing or disabled is the same `X_API_KEY_INVALID` every other rejection is, on the very next use; `disableUser` also revokes the key outright |
738
+
739
+ A key's scopes only ever shrink on the way to the actor — never the owning user's roles, never a
740
+ default set:
741
+
742
+ - `*` and `<resource>:*` are refused at `issueApiKey` (`X_CONFIG_INVALID`), and dropped from a row
743
+ that already holds one.
744
+ - A key a user owns keeps only the scopes that owner's grants cover. This package can read the
745
+ row's `permissions` and `scopes` (`directGrants`) and cannot expand a role — that is
746
+ `@ultimat3/policy`'s — so an app whose users hold roles says what they grant:
747
+
748
+ ```ts
749
+ import { type ApiKeyVerifyStore, apiKeyResolver, directGrants } from '@ultimat3/auth';
750
+ import { expandRoles } from '@ultimat3/policy';
751
+
752
+ declare const keys: ApiKeyVerifyStore;
753
+
754
+ export const resolveKey = apiKeyResolver(() => keys, {
755
+ grantsOf: (owner) => [...directGrants(owner), ...expandRoles(owner.roles)],
756
+ });
662
757
  ```
663
758
 
664
- An api key's scopes become **exactly** the agent actor's scopes — never the owning user's roles.
759
+ Without `grantsOf`, a key owned by a user who holds only roles resolves with no scopes at all.
760
+
761
+ ### The resolver a mount takes
762
+
763
+ `apiKeyResolver(() => store)` is that last line as the `resolveToken` a bearer mount
764
+ (`defineApi({ http: { mounts } })`) and an MCP endpoint (`defineAppMcp`) both take — one resolver
765
+ for both doors.
766
+
767
+ ```ts
768
+ import type { ApiKeyVerifyStore } from '@ultimat3/auth';
769
+ import { apiKeyResolver, BuiltinAdapter } from '@ultimat3/auth';
770
+
771
+ let keys: ApiKeyVerifyStore | undefined;
772
+
773
+ /** `resolveToken: resolveKey` — on the mount, and on `defineAppMcp()`. */
774
+ export const resolveKey = apiKeyResolver(() => {
775
+ keys ??= new BuiltinAdapter();
776
+ return keys;
777
+ });
778
+ ```
779
+
780
+ | Presented | Answer |
781
+ |---|---|
782
+ | a live key | `{ actor, scopes }` — the agent actor for the key's org, and the scopes that actor carries as a `Set` |
783
+ | malformed, unknown, wrong secret, revoked, expired, or owned by a user who is gone or disabled | `null`, all alike: the mount answers one indistinguishable 401 |
784
+ | a store that FAILS | the throw, untouched — a database that is down is not a wrong key |
785
+
786
+ The store is a THUNK, read when a token is presented: a mount is declared when its module is
787
+ evaluated, and `new BuiltinAdapter()` takes the process's database client, which boot installs
788
+ later. In a test the store is `new MemoryAdapter()` — every `AuthAdapter` is an
789
+ `ApiKeyVerifyStore` (an `ApiKeyStore` that can also `findUserById`).
665
790
 
666
791
  ## Errors
667
792
 
@@ -670,6 +795,7 @@ An api key's scopes become **exactly** the agent actor's scopes — never the ow
670
795
  | `X_UNAUTHENTICATED` | no actor, unknown session, or any failed credential path |
671
796
  | `X_SESSION_EXPIRED` | idle or absolute expiry, named in `cause` |
672
797
  | `X_MFA_REQUIRED` | password proven, second factor outstanding |
798
+ | `X_MFA_SECRET_UNSEALED` | a stored `mfa_secret` is not sealed; it is refused, never read. `x auth seal-mfa --json` |
673
799
  | `X_OAUTH_STATE_INVALID` | state, nonce or PKCE verifier did not match |
674
800
  | `X_OAUTH_EXCHANGE_FAILED` | the provider refused the exchange, or returned no usable identity |
675
801
  | `X_OAUTH_TOKEN_INVALID` | the id token failed its signature, issuer, audience or expiry check, or no key in the published set matched its `kid` |
@@ -677,10 +803,10 @@ An api key's scopes become **exactly** the agent actor's scopes — never the ow
677
803
  | `X_OAUTH_PROVIDER_DUPLICATE` | two `registerOAuthProvider` calls claimed one id — at boot, never at a login |
678
804
  | `X_OAUTH_DENIED` | the user pressed Cancel, or the provider declined — `403`, never a `502` |
679
805
  | `X_PASSWORD_WEAK` | strength check rejected the password |
680
- | `X_ACCOUNT_LOCKED` | the per-ip, per-account or per-org bucket is inside its lockout |
681
- | `X_API_KEY_INVALID` | key unknown, revoked, expired or wrong |
806
+ | `X_ACCOUNT_LOCKED` | the per-ip, per-account or per-org bucket is inside its lockout. The cause names the caller's own address or email, never an org |
807
+ | `X_API_KEY_INVALID` | key unknown, revoked, expired, wrong, or owned by a user who is gone or disabled |
682
808
  | `X_ENV_MISSING` | `oauthCredentials()` found no client id or secret for an enabled provider |
683
- | `X_NOT_IMPLEMENTED` | an `AuthAdapter` has not implemented an optional seam member (`revokeOrgSessions`, `listOrgUsers`, …), or lost a write it accepted — `emailVerifiedNotStored(provider, userId)` when `updateUser` drops the OAuth verified stamp |
809
+ | `X_AUTH_WRITE_FAILED` | an `AuthAdapter` lost a write it accepted — `emailVerifiedNotStored(provider, userId)` when `updateUser` drops the OAuth verified stamp |
684
810
 
685
811
  ```bash
686
812
  bun test packages/auth
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/auth",
3
- "version": "22.15.0",
3
+ "version": "24.0.0",
4
4
  "description": "Sessions, passwords, OAuth, MFA and api keys — resolved to one Actor",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  "files": [
20
20
  "src",
21
21
  "!src/**/*.test.ts",
22
+ "!src/**/*-fixture.ts",
22
23
  "CLAUDE.md",
23
24
  "README.md",
24
25
  "LICENSE"
@@ -31,8 +32,8 @@
31
32
  "test": "bun test"
32
33
  },
33
34
  "dependencies": {
34
- "@ultimat3/core": "22.15.0",
35
- "@ultimat3/db": "22.15.0",
36
- "@ultimat3/schema": "22.15.0"
35
+ "@ultimat3/core": "24.0.0",
36
+ "@ultimat3/db": "24.0.0",
37
+ "@ultimat3/schema": "24.0.0"
37
38
  }
38
39
  }