@happyvertical/smrt-users 0.51.36 → 0.51.39

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/AGENTS.md CHANGED
@@ -12,6 +12,7 @@ are not prerequisites for unrelated user-package work.
12
12
  | `src/services/PermissionResolver.ts` | permission precedence, inherited memberships, guards, RLS, role seeding | [agents/permissions.md](agents/permissions.md) |
13
13
  | `src/services/OidcLoginService.ts`, collections and OIDC handlers | identity reconciliation, transaction boundaries, migration readiness | [agents/oidc-provisioning.md](agents/oidc-provisioning.md) |
14
14
  | `src/services/MobileAuthService.ts` | mobile handshake, bearer sessions, bootstrap extension boundary | [agents/mobile-auth.md](agents/mobile-auth.md) |
15
+ | `src/services/TerminalAuthService.ts`, `src/collections/CliAuthApproveLimitCollection.ts` | terminal approval concurrency, shared attempt budget, retry-safe database fixtures | [agents/terminal-auth-testing.md](agents/terminal-auth-testing.md) |
15
16
  | `src/retention.ts` | expired session/token/CLI-auth reaping and retention sweep wiring | [agents/retention.md](agents/retention.md) |
16
17
 
17
18
  ## Models and authority
@@ -0,0 +1,45 @@
1
+ # Mobile authentication
2
+
3
+ Read for `src/services/MobileAuthService.ts` and `/sveltekit` mobile handlers.
4
+
5
+ `createMobileAuthHandlers(options)` (from `/sveltekit`) returns the mountable
6
+ server side of the KMP mobile contract: `authStart` (`POST auth/start`,
7
+ server-brokered PKCE via `OidcLoginService`), `authComplete`
8
+ (`POST auth/complete`, code + echoed `state`/`codeVerifier` → bearer
9
+ session), `session.GET`/`session.DELETE` (bootstrap/logout), and
10
+ `guard`/`withSession` — the bearer middleware for app-owned mobile routes.
11
+ Core logic lives in `MobileAuthService` (framework-agnostic; exported from
12
+ the package root).
13
+
14
+ - **Bearer = session id** (same convention as `TerminalAuthService`); 401
15
+ bodies are `{ error, code }` and drive the mobile client's re-auth flow.
16
+ - **Stateless handshake**: the OAuth `state` is an HMAC-signed token
17
+ (secret: `stateSecret` ?? provider `clientSecret`) carrying
18
+ nonce/provider/createdAt — full ID-token nonce verification with no
19
+ server-side pending state. The `codeVerifier` never enters a URL: it is
20
+ client-held per the frozen contract.
21
+ - **Wire DTOs** come from `@happyvertical/smrt-mobile-contract`
22
+ (`MobileAuthStartRequest` etc.) — one owning package for the Kotlin,
23
+ Swift, and TypeScript shapes (compile-checked descriptors + parity test).
24
+ - **Tenant options** honor `Role.inheritsToDescendants` (#1867): direct
25
+ ACTIVE memberships plus descendants of flagged memberships (nearest
26
+ flagged ancestor labels the option; any direct row pins; inactive direct
27
+ rows exclude). Session binding defaults to the first DIRECT tenant —
28
+ override with `resolveTenantId`.
29
+ - **Hooks**: `resolveUser` (invite-gating; default provisions via
30
+ `getOrCreateFromOidc`), `resolveTenantId`, `buildExtras` (bootstrap
31
+ `extras`; model JSON must use `toPublicJSON({ permissions })` — #1822).
32
+ `buildExtras` is the only app-domain bootstrap extension point. Never emit
33
+ app fields such as `dashboard` at the response top level: they are outside
34
+ `MobileSessionBootstrap` and the Kotlin decoder ignores them.
35
+ - **Guard** wraps `withSessionPermissionContext`, so
36
+ `assertOperationPermission`, tenancy context, and Postgres RLS all see the
37
+ bearer caller; `OperationPermissionError` maps to 403 with a
38
+ machine-readable `reason`.
39
+ - **Uploads**: `resolveMobileUploadDedupKey` + the documented contract in
40
+ `docs/content/architecture/mobile-upload-contract.md` (`clientCaptureId`
41
+ field, `Idempotency-Key` header fallback); domain ingestion stays
42
+ app-side. Framework-model writes ride `sync/apply`, not this path.
43
+ - Configure `redirectUris` in production — RFC 8252 scheme rules always
44
+ apply, but the allow list is the defense against redirecting authorization
45
+ responses to attacker-controlled URIs.
@@ -0,0 +1,97 @@
1
+ # OIDC provisioning
2
+
3
+ Read for changes to `src/collections/UserCollection.ts`, `src/services/OidcLoginService.ts`,
4
+ OIDC handlers, or identity migrations. The canonical scenario matrix is
5
+ `packages/profiles/src/testing/oidcProvisioningDecisionMatrix.ts`.
6
+
7
+ - **OIDC `email_verified` is enforced.** `UserCollection.getOrCreateFromOidc`
8
+ refuses to provision a user when the IdP explicitly returns
9
+ `email_verified: false` (opt out with `{ allowUnverifiedEmail: true }`). An
10
+ absent claim makes no assertion and is not enforced.
11
+ - **RFC 9207 response issuer is exact.** OIDC callbacks validate a supplied
12
+ `iss` against discovered metadata with exact string comparison before trusting
13
+ a code or error. If metadata advertises issuer-response support, `iss` is
14
+ required.
15
+ - **Verified-email Profile reuse is fail-closed.** The typed canonical scenarios
16
+ live in
17
+ `packages/profiles/src/testing/oidcProvisioningDecisionMatrix.ts`; both
18
+ package suites execute that matrix and public docs reference it rather than
19
+ maintaining another behavioral table. Default provisioning reuses only one
20
+ unowned, global `Person`. Tenant-scoped, non-Person, duplicate-email,
21
+ and already-owned matches fail before User/session creation. An existing
22
+ issuer/subject link without a User must still be the unique global Person for
23
+ the current verified claim email; once owned, the stable issuer/subject link
24
+ reuses its canonical Person and owner.
25
+ Issuer and subject are opaque, case-sensitive identifiers; preserve their
26
+ exact value and use trim only to reject blank claims.
27
+ - **`resolveProfile` is the application reconciliation boundary.** The
28
+ SvelteKit handlers, `OidcLoginService`, and `getOrCreateFromOidc` accept the
29
+ same hook inside the provisioning transaction. The service/handler path
30
+ supplies protocol-validated claims; direct collection callers must validate
31
+ and trust their claim source before calling `getOrCreateFromOidc`.
32
+ Token/userinfo merging keeps `email` and `email_verified` paired to the same
33
+ claim source; verification is never borrowed across sources.
34
+ Resolver reads/writes use the supplied `db`, and the hook must be idempotent
35
+ because a concurrent unique-key conflict can retry it. `undefined` chooses
36
+ the secure default and `null` rejects, including exact issuer/subject reuse.
37
+ For a new identity, a supplied Profile is still validated as the unique,
38
+ unowned global Person for the verified email. For an exact existing identity,
39
+ it must be the already-linked Profile and cannot rebind identity authority;
40
+ stable-link owner and canonical-Person checks still apply. The hook receives a
41
+ separate frozen claims snapshot; internal retry and persistence state is not
42
+ exposed for mutation.
43
+ - **Owned first binding requires `authorizeProfileOwner`.** An invitation or
44
+ approval workflow may explicitly return both its pre-provisioned canonical
45
+ global `Person` and existing approved `User` from this transaction-bound
46
+ hook. `undefined` keeps the secure `profile_owned` default and `null`
47
+ rejects. SMRT treats only the selected IDs as input, reloads them in the
48
+ provisioning transaction, and requires `email_verified === true`, the unique
49
+ canonical global Person for the normalized claim email, exactly one owner,
50
+ that owner as the selected User, and the same normalized User email. The
51
+ hook runs before identity/User/session creation and may be retried, so use
52
+ only its supplied `db` and `users` handles and keep application authorization
53
+ idempotent. Never authorize from email matching alone. Existing exact
54
+ identities cannot be rebound; when `resolveProfile` is also present both
55
+ hooks must select the same Profile.
56
+ - **OIDC first login is atomic.** The Profile, `OidcIdentity`, and User are one
57
+ transaction. The database arbiters are `OidcIdentity.identityKey`,
58
+ private `oidc_profile_email_reservations.email_key`, `User.emailKey`, and the
59
+ unique `User.profileId`; local callbacks acquire exact issuer/subject and normalized
60
+ email locks in deterministic order so changed email claims also serialize.
61
+ SQLite and DuckDB callbacks additionally serialize every root-handle statement
62
+ the coordinator owns per database URL — `_smrt_backfills` initialization, the
63
+ transaction, and the post-commit rebind — because one adapter multiplexes a
64
+ single native connection and cannot safely overlap unrelated root
65
+ transactions. Never overlap two statements on one such handle, inside a
66
+ transaction or not: rebind and owner/email candidate reads are sequential,
67
+ never `Promise.all`. Owner-authorized DuckDB callbacks use that same
68
+ root-handle serialization;
69
+ PostgreSQL deadlock and serialization errors use a bounded transaction retry.
70
+ Newly provisioned Profiles use non-semantic per-profile slugs so equal IdP
71
+ display names cannot trigger a natural-key upsert;
72
+ run
73
+ `smrt db:status`, `smrt db:migrate`, then `smrt db:status` before deployment.
74
+ Stop or upgrade old writers first. Before migration, group
75
+ non-null `users.profile_id` values, then reconcile duplicates. After
76
+ migration, run public `backfillProfileEmailKeys(db)`,
77
+ `backfillUserEmailKeys(db)`, then `backfillLegacyUserProfiles(db)` from one
78
+ deploy process. The first two use the shared
79
+ TypeScript `normalizeIdentityEmail()` implementation transactionally and are
80
+ idempotent; the User backfill fails before writes if normalized duplicates
81
+ remain. The legacy Profile backfill transactionally creates canonical global
82
+ Persons for null/blank User links without creating OIDC identities or
83
+ inferring ownership; reconcile any same-email Profile or reservation before
84
+ retrying. Its marker records a completed pass but never skips newly imported
85
+ legacy Users on a later run. Every OIDC path requires the Profile email-key
86
+ readiness marker; creating a User or checking User email uniqueness
87
+ additionally requires the User marker. A stable issuer/subject with an
88
+ existing owning User skips only the User email-key lookup and marker. Full
89
+ scans remain in the explicit deploy step; guarded runtime paths use indexed
90
+ keys and validate only returned candidates. Multiple null links remain valid.
91
+ Legacy race keys backfill only after canonical validation. Pass a root
92
+ database on adapters such as DuckDB that cannot create nested savepoints.
93
+ Root adapters must expose `beginTransaction`; transaction-only handles are
94
+ ambiguous and fail closed before provisioning writes. Caller-owned
95
+ transactions never run `_smrt_backfills` DDL and require that table to
96
+ already exist; use the root database when initialization or recovery is
97
+ needed.
@@ -0,0 +1,186 @@
1
+ # Permissions and tenant inheritance
2
+
3
+ Read for `src/services/PermissionResolver.ts`, operation guards, role seeding,
4
+ RLS, and tenant-hierarchy changes. Integration examples live in README's
5
+ manifest-derived permission catalog and PostgreSQL RLS sections.
6
+
7
+ ## Membership selection
8
+
9
+ 1. A direct target-tenant membership pins resolution: active resolves normally;
10
+ pending/suspended returns empty. Never union direct and ancestor roles.
11
+ 2. Otherwise walk hierarchyPath nearest-first for the nearest active ancestor
12
+ membership whose role has inheritsToDescendants. Skip inactive/unflagged
13
+ ancestors; they neither grant nor block. No qualifying ancestor means empty.
14
+ 3. Run later layers against the target tenant. Only target-tenant groups apply;
15
+ membership overrides travel with the chosen ancestor membership.
16
+ inheritedFromTenantId reports that ancestor, null for direct resolution.
17
+
18
+ Verify hierarchyPath link-by-link against loaded parentTenantId rows before
19
+ trusting it: excess depth, self-reference, duplicates or inconsistent paths
20
+ fail closed. Bound traversal by MAX_TENANT_HIERARCHY_DEPTH. Tenant status is not
21
+ consulted, matching direct resolution. Long-lived user/tenant caches must
22
+ invalidate ancestor membership changes and inheritsToDescendants flips;
23
+ request-scoped caches do not survive those requests.
24
+
25
+ `loadSessionContext().tenantAuthorization` is authoritative for required-tenant
26
+ consumers; membership null can mean inherited authority.
27
+
28
+ 4. If no membership resolved at all, apply the opt-in ancestor-read policy
29
+ (smrt#2939): the user's ACTIVE memberships on VERIFIED DESCENDANTS of the
30
+ target, whose role slug is declared in `permissions.ancestorRead.roles` and
31
+ within `maxDepth` hops, contribute `<collection>.read` for declared
32
+ collections, intersected with BOTH that role's own catalog grants (so a
33
+ descendant-side tenant GRANT, group role, or membership GRANT cannot widen
34
+ it) AND the principal's effective permissions in that descendant (so its
35
+ DENY cascade and membership DENY apply). Only a SYSTEM
36
+ role (`tenantId` null, `isSystem: true`) matches a declared slug: slugs are
37
+ not unique across a hierarchy, so a tenant-scoped custom role of the same
38
+ name must not opt its tenant in. Off by default;
39
+ read-only; never lateral; never reached when a direct or inherited
40
+ membership already decided. The target tenant's DENY still subtracts.
41
+ `ancestorReadFromTenantIds` reports the contributing descendants. The grant
42
+ carries no membership, so `membershipId`/`tenantAuthorization.membershipId`
43
+ stay null and a consumer requiring a non-empty membership still refuses.
44
+
45
+ ## Permission precedence
46
+
47
+ Apply these layers in order, later layers overriding earlier ones:
48
+
49
+ 1. Ancestor TenantPermissionOverride cascade (GRANT adds, DENY removes).
50
+ 2. Selected membership role permissions.
51
+ 3. Target-tenant group-role permissions.
52
+ 4. Tenant cascade's net DENY removes role/group grants.
53
+ 5. Membership GRANT may re-add a tenant-denied slug.
54
+ 6. Membership DENY removes last and always wins.
55
+
56
+ The tenant block is the cascade's net result, not the union of historical DENYs:
57
+ a child's more-specific GRANT can override a parent DENY. Use a direct child
58
+ membership or tenant DENY to attenuate inherited role authority.
59
+ `getGroupIdsForTenant(userId, tenantId)` is required; getGroupIds is cross-tenant.
60
+
61
+ ## Hierarchies and seeding
62
+
63
+ - `Tenant.save()` derives hierarchyPath/hierarchyLevel from the REAL
64
+ parentTenantId chain on every save (never from an ancestor's stored path),
65
+ refuses missing parents, cycles and over-depth before writing, and
66
+ re-materializes descendants by raw column update (so STI subclass columns
67
+ are never clobbered). createChild/moveToParent/makeRoot are thin wrappers.
68
+ `materializeTenantHierarchy(db, { dryRun })` / `smrt
69
+ db:materialize-tenant-hierarchy` backfills legacy rows: idempotent, one
70
+ transaction, refuses a broken chain without writing (#3036). A NEW or
71
+ CHANGED parent link is authority-bearing, so `Tenant.save()` first loads the
72
+ parent through TenantCollection (the caller's tenancy scope) and refuses an
73
+ invisible one on every path — helpers, `create({ parentTenantId })`, or a
74
+ plain assignment + save; system context bypasses as usual, and an unchanged
75
+ link is never re-checked. getTree(rootId?)
76
+ returns UI structure. Maximum depth is 10.
77
+ - Tenant override cascade requires parent cascadePermissions and child
78
+ inheritPermissions. These flags do not gate the independent, per-role
79
+ inheritsToDescendants membership flow.
80
+ - `permissions.ancestorRead` in the `users` package config declares upward,
81
+ read-only visibility: `{ roles, collections, maxDepth? }`. Both lists are
82
+ required and non-empty and `maxDepth >= 1`, else the policy is OFF — there is
83
+ no partially valid declaration. `isAncestorReadableSlug()` is the single place
84
+ the read-only bound is enforced; only a two-segment `<collection>.<action>`
85
+ slug whose action normalizes to `read` on a declared collection passes.
86
+ `PermissionResolver.create(options, { ancestorReadPolicy })` binds one
87
+ resolver; `null` forces it off. It grants the OPERATION at the ancestor, never
88
+ row visibility — sibling rows stay scoped by the tenancy interceptor and RLS.
89
+ - seedSystemRoles({ inheritsToDescendants: ['owner', 'admin'] }) flags listed
90
+ slugs additively, never unflags omitted ones, and rejects unknown slugs.
91
+ Default seeds are exact-tenant.
92
+ - RolePermissionCollection.seedRolePermissions() or
93
+ seedSystemRoles({ seedPermissions: true }) maps owner/admin to all catalog
94
+ permissions, member to ordinary-resource read/create, viewer to read. Member
95
+ create excludes identity/RBAC/security resources and their joins/overrides.
96
+ Seeding is additive/idempotent; removal requires prune: true.
97
+ - When a package adds built-in self-personalization permissions after role
98
+ creation, explicitly call seedDefaultRolePersonalizationPermissions(). It
99
+ upgrades owner/admin/member/viewer idempotently and never grants custom roles.
100
+
101
+ ## Guards and RLS
102
+
103
+ PermissionCatalogService derives collection.action slugs from manifests,
104
+ including custom actions; list/get map to read. Guards derive a class's slug
105
+ through the same resolution (explicit `collection`, manifest entry, then its
106
+ STI base's), so an STI subtype resolves its STI base's collection
107
+ (`Network extends Tenant` -> `tenants.update`) in every build, including a
108
+ bundle decorated before its manifest loads (#3125). Hand-written mutations in form
109
+ handlers, endpoints, CLI and jobs use assertOperationPermission(). It requires
110
+ catalog presence then resolves permissions, throwing fail-closed by default.
111
+ Use onDeny: 'return', checkOperationPermission or hasOperationPermission only
112
+ when the caller handles a structured/boolean denial.
113
+
114
+ Generated SvelteKit write routes (POST/PUT/PATCH/DELETE CRUD and mutating
115
+ custom actions) enforce the same `collection.action` slug (custom actions use
116
+ `collection.<method>`; actions hosted on a collection class use the item collection, which the catalog now emits) after authentication: the slug must be in the request's
117
+ session permission snapshot (`locals.permissions`/`permissionSet`/
118
+ `smrtPermissions` or `locals.tenantContext.permissions`), otherwise 403. Only
119
+ `locals.tenantContext.superAdminBypass` skips it; `api.public: true` stays open.
120
+
121
+ Resource-anchored guards receive the resource tenant ID. When it differs from
122
+ the session tenant, omit the session membership so the resolver selects the
123
+ appropriate authority; mismatched supplied membership/tenant fails closed.
124
+ Inherited root-admin authority can then authorize descendant resources without
125
+ application-side membership fan-out.
126
+
127
+ Resolution cost must not scale with catalog size in objects (#3047): role →
128
+ permission ids and ids → slugs are `list({ select })` projection reads (the
129
+ collections' beforeList interceptors still run; no RolePermission/Permission
130
+ is hydrated), so an owner mapped to the whole catalog costs two queries and
131
+ no per-permission objects. Keep new resolver reads projections when only ids
132
+ or slugs are needed, and do not add public collection methods for them — each
133
+ becomes a custom-action slug in the manifest-derived catalog. The resolver's
134
+ collections never inherit `defaultListLimit`/`maxListLimit` from the caller's
135
+ options, and id-list reads chunk at `IN_LIST_CHUNK_SIZE` with an explicit
136
+ limit: a truncated authorization read would narrow the result silently.
137
+ `issue-3047-permission-resolution-cost-postgres.test.ts` pins the hydration
138
+ budget and a golden equivalence matrix recorded on the hydrating resolver.
139
+
140
+ The same rule holds for every framework-internal authorization read (#3048):
141
+ collections that back a decision — the resolver's, the ResourceGrant exact-tuple
142
+ read in `checkResourceOperationPermission()` (DENY wins only if every row is
143
+ read), and TenantService's owned-tenant quota count — are created through
144
+ `withoutListBounds()` (`src/services/authorization-read-options.ts`), which
145
+ strips only the caller's list bounds. Use it for any new decision read;
146
+ `issue-3048-authorization-list-bounds-postgres.test.ts` pins it.
147
+
148
+ PermissionResolver runs every resolution inside smrt-tenancy's system context
149
+ (#3036). Its reads are deliberately cross-tenant and framework-owned — the
150
+ ancestor TenantPermissionOverride batch, ancestor memberships for
151
+ inheritsToDescendants, descendant memberships for ancestor-read — and are keyed
152
+ by the explicit (userId, tenantId); with those classes registered tenant-scoped
153
+ (`autoFilter`) the consumer's filter otherwise threw TenantIsolationError or
154
+ silently narrowed them. It returns slugs and ids only, never rows, and the
155
+ caller's own reads stay filtered. Do not move a resolver read back under the
156
+ ambient tenant; the Postgres regression suite
157
+ `issue-3036-hierarchy-resolution-postgres.test.ts` pins this. Because nothing
158
+ backstops those reads any more, the tenant-override cascade verifies its chain
159
+ too: it uses hierarchyPath only when it agrees link-by-link with
160
+ parentTenantId, otherwise walks the real parent links (so a forged path cannot
161
+ cascade an unrelated tenant in, and a never-materialized row still gets its real
162
+ ancestors' DENYs — and GRANTs), and throws TenantHierarchyError on a broken
163
+ real chain: a per-request failure for that tenant's whole subtree, which
164
+ `smrt db:materialize-tenant-hierarchy --dry-run` reports ahead of time (see
165
+ README "Upgrading an existing fleet").
166
+ `getTenantInheritanceChain()` returns Tenant rows, so it is NOT run in system
167
+ context; it reports the same verified chain the cascade applies when the
168
+ caller can read every ancestor. Otherwise it never reports PARENT_NOT_FOUND
169
+ for a row the caller simply cannot see: an interceptor refusal surfaces as
170
+ TenantIsolationError, and a silently filtered row (e.g. RLS) ends the chain at
171
+ the nearest readable ancestor. Descendant path rewrites are raw column updates recorded through
172
+ `bumpChangeFeed`; `updated_at` is intentionally not bumped (derived columns;
173
+ every save recomputes them). They are not atomic with the moving row's save —
174
+ an interrupted subtree fails closed until re-saved or backfilled.
175
+
176
+ System context and super-admin bypass are honored. Pass
177
+ allowSuperAdminBypass: false for money or separation-of-duties operations that
178
+ require an explicit grant.
179
+
180
+ generatePostgresPermissionSql/applyPostgresPermissionPolicies enforce generated
181
+ RLS using smrt.permissions and smrt.tenant_id, installed with set_config by
182
+ withSessionPermissionContext. This bounds REST, MCP and in-process database
183
+ access once principal context is set. Child-tenant sessions receive resolved
184
+ inherited permissions automatically, but RLS row filtering remains bound to the
185
+ session tenant: a root session's app-level guard authorization does not itself
186
+ permit child rows through RLS. Preserve this distinction.
@@ -0,0 +1,52 @@
1
+ # smrt-users/credential retention
2
+
3
+ Module semantics for `src/retention.ts` and the three `deleteExpired()` paths
4
+ it drives. Package orientation, the permission model, and the security rules
5
+ that apply before editing anything live in [../AGENTS.md](../AGENTS.md) — read
6
+ that first.
7
+
8
+ ## Credential retention (#2375)
9
+
10
+ `SessionCollection.deleteExpired()`,
11
+ `UsersMagicLinkTokenCollection.deleteExpired()` and
12
+ `UsersCliAuthRequestCollection.deleteExpired()` all existed, and all waited for
13
+ an application to remember to call them. Expired credential rows are the worst
14
+ kind of unbounded growth: worthless the moment they expire, and exactly the
15
+ rows an attacker would like to still find in the table.
16
+
17
+ `src/retention.ts` contributes the three to the framework retention sweep in
18
+ `@happyvertical/smrt-core`, so `smrt db:prune` and a running jobs `TaskRunner`
19
+ reap them.
20
+
21
+ - **The entry point registers on import.** `src/index.ts` calls
22
+ `registerUserRetentionTasks()`, so any process that loaded this package
23
+ contributes the tasks — including `smrt db:prune`, which imports the package
24
+ optionally for exactly that reason. Registering is not scheduling: nothing is
25
+ deleted until something runs a sweep.
26
+ - **Task names** are `users-sessions`, `users-magic-link-tokens`,
27
+ `users-cli-auth-requests` — prefixed with the package's short name because
28
+ the retention registry is one process-global namespace. Opt one out with
29
+ `runRetentionSweep(db, { tasks: { 'users-sessions': false } })` or
30
+ `smrt db:prune --skip users-sessions`; `unregisterUserRetentionTasks()`
31
+ removes all three.
32
+ - **There is no retention window to configure.** An expired credential has
33
+ nothing worth retaining, so each task deletes only already-expired rows. An
34
+ application that keeps expired sessions for audit should opt the task out and
35
+ archive them itself.
36
+ - **All three are a single counted DELETE**, not a hydrate-and-delete loop.
37
+ They now run unattended on a timer, and a per-row delete that throws part-way
38
+ leaves the rest of the expired rows un-reaped — the #1400 reasoning that
39
+ already applied to sessions, now applied to the other two. Counting first
40
+ also gives a usable figure where `rowCount` is not populated, and is what
41
+ makes `{ dryRun }` preview the same predicate rather than an estimate. The
42
+ count and the delete are not one transaction, so the figure is approximate
43
+ under concurrent writers.
44
+ - **`expiresAt` is `@field({ indexed: true })`** on `Session`,
45
+ `UsersMagicLinkToken` and `UsersCliAuthRequest`: the prune predicate scans
46
+ that column on every pass.
47
+ - **CLI bearer handoff is single-use.** An approved request becomes `consumed`
48
+ and clears its `sessionId` when one poller wins the exchange. The CLI
49
+ retention task deletes pending requests past their TTL, requests already
50
+ marked `expired` by lazy expiry, and consumed history; it retains approved
51
+ requests until exchange so a near-expiry approval cannot orphan its bearer
52
+ session.
@@ -0,0 +1,32 @@
1
+ # Terminal approval storage and regression matrix — #3076
2
+
3
+ The failed-approval budget is shared storage, keyed by unique `user_id`.
4
+ Each UPSERT candidate uses a unique private slug, so concurrent first inserts do
5
+ not also collide on the inherited `(slug, context)` index. Existing rows require
6
+ no migration: their slug is not used for lookup or authorization. The guarded
7
+ UPSERT continues to reserve at most the configured count across service instances
8
+ and processes. Unknown database failures propagate; they are not authentication
9
+ successes or silently converted to a rate-limit response.
10
+
11
+ Risk: high (terminal authentication and shared rate-limit authority). No public
12
+ API, schema, session, tenant or approval contract changes.
13
+
14
+ | Behavior / trigger | Positive | Negative / failure | Actor / executor | Runtime / edge | Validation |
15
+ |---|---|---|---|---|---|
16
+ | First reservation candidates | Distinct private insert slugs, one user budget | Third reservation denied at limit2; old code deterministically repeats slug | Same user, real SQL observed without replacing executor | SQLite; independent PostgreSQL service connections | users `test`, `test:postgres` |
17
+ | Concurrent invalid approvals | Three attempts reach ordinary auth denial | Ten rejected, exactly seven typed rate limits; zero attacker sessions | One attacker/user+tenant, two instances, atomic UPSERT | SQLite and PostgreSQL18 | users `test`, `test:postgres` |
18
+ | Concurrent approval/exchange | Eight approvals share one session, one of eight exchanges succeeds | Seven exchanges expired, consumed row clears session ID | UUID user/tenant, owning services | PostgreSQL; SQLite existing lifecycle coverage | users `test`, `test:postgres` |
19
+ | Window/release | Successful attempts release their reservation | Old-window release cannot erase new-window failure; cleanup failure cannot reverse committed approval | Existing storage executor, no retry change | SQLite | users `test` |
20
+ | Fixture retry/reseed | Two complete fixture cycles use the same emails | No duplicate email from prior committed fixture rows | Test-owned UUIDs, explicit cleanup through public database interface | PostgreSQL; intentionally independent connections outside rollback | users `test:postgres` |
21
+
22
+ Before the repair, the candidate regression fails (one slug instead of two),
23
+ and the second PostgreSQL fixture cycle fails its fixed-email insert. Actual CI
24
+ also captured a PostgreSQL duplicate `(slug, context)` error during concurrent
25
+ first reservations. The fixture cleans only its own IDs in FK order, then rolls
26
+ back its isolated handle; it must not replace real connections with one shared
27
+ transaction, weaken count assertions, or disable retries.
28
+
29
+ Run documented users build, typecheck (including Svelte), test, test:postgres and
30
+ verify:pack gates. PostgreSQL runs use the canonical disposable-database wrapper.
31
+ Root knowledge checks follow all producers. No new dialect is introduced; the
32
+ terminal auth regression suite declares SQLite and PostgreSQL coverage.