@happyvertical/smrt-users 0.45.0 → 0.45.2

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
@@ -1,346 +1,78 @@
1
1
  # @happyvertical/smrt-users
2
2
 
3
- Multi-tenant user management with RBAC, hierarchical tenants, session handling, and SvelteKit integration.
3
+ Multi-tenant identity, RBAC, hierarchical tenants, sessions, and SvelteKit auth.
4
4
 
5
5
  ## Modules
6
6
 
7
+ Read only the module relevant to the change; protocol and provisioning details
8
+ are not prerequisites for unrelated user-package work.
9
+
7
10
  | Module | Scope | Module doc |
8
11
  |---|---|---|
9
- | `src/retention.ts` | expired session/token/CLI-auth reaping and its wiring into the framework retention sweep | [agents/retention.md](agents/retention.md) |
10
-
11
- ## Models (14)
12
-
13
- | Model | Key Pattern |
14
- |-------|-------------|
15
- | User | Auth identity. `profileId` is a unique cross-package reference to smrt-profiles (one User per non-null Profile). Email auto-lowercased; readonly nullable unique `emailKey` is derived on save for durable normalized uniqueness. |
16
- | AccessRequest | "Request access / waitlist" record captured before a `User` exists. CLOSED generated surface (`api`/`mcp`/`cli` = `[]`) — all access via `AccessRequestService`. Email normalized + indexed; JSON `requestContext` (NOT `context` — reserved for slug scoping). |
17
- | Tenant | **STI** + hierarchical parent-child. `hierarchyPath` (materialized path), `hierarchyLevel`. Max depth 10. |
18
- | Session | Server-side. Secure UUID. TTL in **seconds** (not ms). Status auto-updates to EXPIRED on access. |
19
- | MagicLinkToken | Single-use email login token. Backed by `MagicLinkService`. |
20
- | Role | `tenantId = null` system role (available to all tenants). `isSystem: true` blocks deletion. `inheritsToDescendants: true` (default false) opts the role's membership authority into descendant tenants. |
21
- | Permission | Slug format: `resource.action`. Parsed by PermissionResolver. |
22
- | Membership | User + Tenant + Role junction. UNIQUE(userId, tenantId). |
23
- | Group | Team within a tenant. Multiple roles via GroupRole. |
24
- | GroupMember, GroupRole, RolePermission | Join tables. |
25
- | MembershipOverride | Per-user permission grant/deny. **DENY always wins.** |
26
- | TenantPermissionOverride | Tenant-level cascade overrides. Effect: INHERIT/GRANT/DENY. |
27
-
28
- ## Permission Resolution Precedence (broad specific, most-specific wins)
29
-
30
- `PermissionResolver.resolvePermissions` builds the effective set in this order;
31
- each later layer overrides earlier ones:
32
-
33
- 1. **Tenant-inherited** — walk ancestors, apply each `TenantPermissionOverride`
34
- down the cascade (GRANT adds, DENY removes within the hierarchy)
35
- 2. **Membership role** — base permissions from the user's role in the tenant
36
- 3. **Group roles** permissions from all groups the user belongs to **in that tenant**
37
- 4. **Tenant-level DENY** *(removes; overrides role/group grants, tenant-wide)* a
38
- `TenantPermissionOverride` with effect `DENY` is a HARD, tenant-wide block: it
39
- subtracts the DENY'd slug even if a role or group granted it (steps 2–3). It
40
- sits just **above** the per-user membership overrides and **below** role/group.
41
- 5. **Membership GRANT override** *(re-adds; most specific)* a per-user GRANT can
42
- re-add a slug a tenant DENY'd in step 4, because it is more specific.
43
- 6. **Membership DENY override** *(absolute; always wins)* — a per-user DENY removes
44
- the slug last and is never overridden.
45
-
46
- So a permission a role grants but the tenant DENYs is **removed**, unless that
47
- exact user also has a membership-GRANT override for it. A membership-DENY always
48
- wins. Tenant-DENY of an inherited/cascade grant still blocks it (unchanged).
49
- The hard block reflects the tenant cascade's **net** resolution, not an
50
- unconditional union of every DENY in the chain so a more-specific tenant GRANT
51
- (e.g. a child sub-tenant re-granting a permission its parent DENYs) still wins.
52
-
53
- ### Membership selection hierarchical inheritance (opt-in, #1866)
54
-
55
- Which membership feeds step 2 above:
56
-
57
- 1. **A direct membership row in the target tenant always pins resolution to
58
- itself** active rows resolve normally; an inactive (pending/suspended)
59
- row resolves to the **empty set**. A direct row therefore *attenuates*
60
- rather than unions: "viewer here, despite being network admin" gives
61
- viewer, and a suspension in the child is effective even for users holding
62
- inheritable authority on an ancestor.
63
- 2. **No direct row** → the resolver walks the tenant's ancestors (nearest
64
- first, from `hierarchyPath`) and resolves through the **nearest ACTIVE
65
- ancestor membership whose role has `inheritsToDescendants: true`**.
66
- Unflagged or inactive ancestor memberships are skipped (they neither confer
67
- nor block); there is **no union across the chain** — the nearest flagged
68
- membership alone is used. To attenuate a specific descendant, create a
69
- direct membership there or use a tenant-level DENY.
70
- 3. **No qualifying ancestor** → empty set (byte-identical to the
71
- pre-inheritance resolver; with no role flagged, nothing changes).
72
-
73
- `loadSessionContext()` exposes `tenantAuthorization`; required-tenant consumers
74
- must validate it because `membership: null` can mean inherited authority.
75
-
76
- All later layers run unchanged against the **target** tenant: the tenant
77
- cascade and tenant-DENY hard block come from the target tenant (a child can
78
- carve authority out of an inherited role), group roles stay exact-tenant (only
79
- target-tenant groups contribute; ancestor groups never flow down), and
80
- membership GRANT/DENY overrides travel with the ancestor membership used.
81
- `PermissionResolutionResult.inheritedFromTenantId` reports the ancestor tenant
82
- when inheritance was used (`null` for direct resolution).
83
-
84
- Safety: resolution is bounded by `MAX_TENANT_HIERARCHY_DEPTH`; malformed
85
- `hierarchyPath` values fail closed to the empty set — too deep,
86
- self-referential, duplicate ancestors, or inconsistent with the actual
87
- `parentTenantId` chain (the path is verified link-by-link against the loaded
88
- ancestor rows before it is trusted as an authorization source). Tenant `status` is not consulted (parity with direct
89
- resolution). Caching: a long-lived `(user, tenant)` permission cache must also
90
- invalidate on ancestor-membership changes and on `Role.inheritsToDescendants`
91
- flips — request-scoped caches (the common pattern) are unaffected.
92
-
93
- Flag roles at seed time with
94
- `seedSystemRoles({ inheritsToDescendants: ['owner', 'admin'] })` (additive:
95
- listed slugs are flagged, omitted slugs are never unflagged; unknown slugs
96
- throw). The default seed leaves every role exact-tenant.
97
-
98
- ## Operation Permission Guards
99
-
100
- SMRT derives a fine-grained, per-`<collection>.<action>` permission catalog **from the
101
- manifest** (`PermissionCatalogService`, including custom `@smrt` actions — not just CRUD)
102
- and enforces it two ways: app-side via `assertOperationPermission()` / `PermissionResolver`,
103
- and at the database via generated **Postgres RLS** policies
104
- (`generatePostgresPermissionSql()` / `applyPostgresPermissionPolicies()`) that read the
105
- session-injected `smrt.permissions` / `smrt.tenant_id` (set by `withSessionPermissionContext`
106
- via `set_config`). Because the RLS teeth are at the data layer, enforcement is door-agnostic
107
- — REST, MCP, and in-process callers are all bounded once the principal's context is set.
108
- Setup: README → *Manifest-derived permission catalog* and *Postgres RLS enforcement*.
109
-
110
- - Use `assertOperationPermission()` for hand-written mutations in SvelteKit form
111
- actions, custom endpoints, CLI scripts, and jobs. It derives the same
112
- `<collection>.<action>` slugs as `PermissionCatalogService` (`list`/`get` →
113
- `read`), requires the slug to exist in the catalog, then resolves permissions
114
- through `PermissionResolver`.
115
- - `assertOperationPermission()` throws fail-closed by default. Use
116
- `{ onDeny: 'return' }`, `checkOperationPermission()`, or
117
- `hasOperationPermission()` when a structured/boolean result is needed.
118
- - **Resource-tenant calling convention**: for resource-anchored authorization,
119
- pass the **resource's** tenant id — `tenantId: resourceTenantId` — not the
120
- session's current tenant. With `Role.inheritsToDescendants` flagged, a
121
- root-tenant admin then passes for any descendant resource with no app-side
122
- authority logic (and no membership fan-out), while per-child delegation and
123
- DENY precedence keep working. Do NOT pass a session-scoped `membership`
124
- alongside a different resource `tenantId` — a membership/tenant mismatch
125
- fails closed by design; omit `membership` and let the resolver look it up.
126
- - System context and super-admin bypass context are honored for parity with
127
- Postgres RLS. Pass `{ allowSuperAdminBypass: false }` on money-class or
128
- separation-of-duties operations that must require an explicit permission grant.
129
- - **Postgres RLS and membership inheritance**: RLS policies check the
130
- session-injected `smrt.permissions` list (resolved app-side by
131
- `PermissionResolver`), so a session whose `smrt.tenant_id` IS the child
132
- tenant gets inherited authority in RLS automatically. But RLS row filtering
133
- stays bound to the session's tenant setting — a root-tenant session acting
134
- on child-tenant rows is authorized by the app-level guard (resource-tenant
135
- convention above), not by RLS. This is a documented divergence, mirroring
136
- how #1829 handled bypass parity.
137
- - Seed role mappings with `RolePermissionCollection.seedRolePermissions()` or
138
- `RoleCollection.seedSystemRoles({ seedPermissions: true })`. The default
139
- matrix maps owner/admin to all catalog permissions, member to read/create for
140
- ordinary app resources, and viewer to read-only. Member create grants
141
- intentionally exclude users/RBAC authority and security resources (`users`,
142
- `tenants`, `roles`, `permissions`, memberships, groups, sessions, magic-link
143
- tokens, and related join/override tables). Re-seeding is additive and
144
- idempotent; pruning stale mappings requires `{ prune: true }`. When a
145
- contributing package registers a new built-in self-personalization
146
- permission after system roles already exist, call the explicit,
147
- idempotent `RolePermissionCollection.seedDefaultRolePersonalizationPermissions()`
148
- upgrade helper; it targets owner/admin/member/viewer only and never grants
149
- custom roles.
150
-
151
- **Critical**: `getGroupIdsForTenant(userId, tenantId)` (joins with groups table to scope by tenant). Never use `getGroupIds()` — it's cross-tenant.
152
-
153
- ## Hierarchical Tenants
154
-
155
- - `TenantCollection.createChild()` auto-calculates hierarchy fields, enforces depth limit
156
- - `moveToParent()` updates tenant + ALL descendants' paths/levels
157
- - `cascadePermissions` (parent pushes down) + `inheritPermissions` (child accepts) — both must be true
158
- - `getTree(rootId?)` returns nested structure for UI
159
- - Two independent downward flows: the `TenantPermissionOverride` **cascade**
160
- (tenant-level permission config, flags above) and **membership-role
161
- inheritance** (`Role.inheritsToDescendants`, per-role opt-in — see
162
- "Membership selection" above). The cascade flags do not gate membership
163
- inheritance.
164
-
165
- ## SvelteKit Integration
166
-
167
- ```typescript
168
- // hooks.server.ts
169
- export const handle = createSessionHandler({ db, ttl: 604800, skipPaths: ['/api/public'] });
170
- // Populates event.locals: { user, membership, permissions: string[], tenantId, sessionId }
171
-
172
- // +page.server.ts
173
- await createSessionCookie(event, userId, tenantId, { db });
174
- await destroySessionCookie(event, { db });
175
- await switchSessionTenant(event, tenantId, { db });
12
+ | `src/services/PermissionResolver.ts` | permission precedence, inherited memberships, guards, RLS, role seeding | [agents/permissions.md](agents/permissions.md) |
13
+ | `src/services/OidcLoginService.ts`, collections and OIDC handlers | identity reconciliation, transaction boundaries, migration readiness | [agents/oidc-provisioning.md](agents/oidc-provisioning.md) |
14
+ | `src/services/MobileAuthService.ts` | mobile handshake, bearer sessions, bootstrap extension boundary | [agents/mobile-auth.md](agents/mobile-auth.md) |
15
+ | `src/retention.ts` | expired session/token/CLI-auth reaping and retention sweep wiring | [agents/retention.md](agents/retention.md) |
16
+
17
+ ## Models and authority
18
+
19
+ - Users are global; tenant access comes through Membership (unique user/tenant).
20
+ User email is normalized and globally unique through readonly nullable
21
+ `emailKey`. `profileId` is a unique cross-package reference: at most one User
22
+ owns each non-null Profile.
23
+ - Tenant uses STI and a materialized hierarchy, maximum depth 10.
24
+ `createChild()` calculates paths; `moveToParent()` updates all descendants.
25
+ - Role with `tenantId = null` is available to all tenants; `isSystem` prevents
26
+ deletion. `inheritsToDescendants` is opt-in. Seed owner/admin/member/viewer
27
+ through `RoleCollection.seedSystemRoles()` at application initialization.
28
+ - Group roles apply only in their own tenant. Use `getGroupIdsForTenant()`,
29
+ never cross-tenant `getGroupIds()`, for authorization.
30
+ - Membership DENY always wins. Direct inactive membership blocks inherited
31
+ authority; a direct active membership pins resolution instead of unioning it
32
+ with ancestors. Read the permissions module before changing these rules.
33
+ - AccessRequest has no generated API/MCP/CLI operations; access it through
34
+ `AccessRequestService`. Its JSON field is `requestContext`, not reserved
35
+ slug-scoping `context`.
36
+
37
+ ## Security boundaries
38
+
39
+ - Generated REST/MCP operations on identity/RBAC models are list/get only.
40
+ Route authentication does not authorize authority mutations, and these models
41
+ are not tenant-scoped. Use permission-gated services or explicitly checked
42
+ consumer handlers. CLI remains a local-operator surface. Preserve the
43
+ registry assertions in `security-audit-1400.test.ts`.
44
+ - Resource guards take the resource tenant, not the session tenant. Omit a
45
+ session membership when targeting a different tenant; mismatches fail closed.
46
+ `loadSessionContext().tenantAuthorization` must be checked by required-tenant
47
+ consumers: null membership can represent inherited authority.
48
+ - Sessions use secure UUIDs; TTL is seconds (default seven days). Access marks
49
+ expired sessions EXPIRED. Magic-link tokens are single use.
50
+ - Tenant switching verifies active membership before writing and rotates the
51
+ session ID for non-null targets, revoking the old session. Persist the returned
52
+ `SwitchTenantResult.sessionId`; `switchSessionTenant()` updates the cookie and
53
+ preserves its security settings. Failed switches mutate nothing; null clears
54
+ do not rotate. `SessionCollection.setSessionTenant()` is unguarded and must
55
+ never receive an untrusted tenant ID.
56
+ - OIDC provisioning is atomic and fail-closed. Preserve exact issuer/subject
57
+ identifiers, claim-source email verification, unique global Person ownership,
58
+ and transaction-bound reconciliation. Read the OIDC module and canonical
59
+ scenario matrix before changing any provisioning path.
60
+
61
+ ## Entry points and validation
62
+
63
+ `src/sveltekit/` owns `createSessionHandler`, `createSessionCookie`,
64
+ `destroySessionCookie`, and `switchSessionTenant`. Use README integration examples
65
+ rather than copying application setup into these instructions.
66
+
67
+ From the repository root:
68
+
69
+ ```bash
70
+ pnpm --filter @happyvertical/smrt-users test
71
+ pnpm --filter @happyvertical/smrt-users typecheck
72
+ pnpm --filter @happyvertical/smrt-users test:postgres
176
73
  ```
177
74
 
178
- ## Mobile `/api/mobile` Handlers (ADR 0001 Phase 3.5, #1748)
179
-
180
- `createMobileAuthHandlers(options)` (from `/sveltekit`) returns the mountable
181
- server side of the KMP mobile contract: `authStart` (`POST auth/start`,
182
- server-brokered PKCE via `OidcLoginService`), `authComplete`
183
- (`POST auth/complete`, code + echoed `state`/`codeVerifier` → bearer
184
- session), `session.GET`/`session.DELETE` (bootstrap/logout), and
185
- `guard`/`withSession` — the bearer middleware for app-owned mobile routes.
186
- Core logic lives in `MobileAuthService` (framework-agnostic; exported from
187
- the package root).
188
-
189
- - **Bearer = session id** (same convention as `TerminalAuthService`); 401
190
- bodies are `{ error, code }` and drive the mobile client's re-auth flow.
191
- - **Stateless handshake**: the OAuth `state` is an HMAC-signed token
192
- (secret: `stateSecret` ?? provider `clientSecret`) carrying
193
- nonce/provider/createdAt — full ID-token nonce verification with no
194
- server-side pending state. The `codeVerifier` never enters a URL: it is
195
- client-held per the frozen contract.
196
- - **Wire DTOs** come from `@happyvertical/smrt-mobile-contract`
197
- (`MobileAuthStartRequest` etc.) — one owning package for the Kotlin,
198
- Swift, and TypeScript shapes (compile-checked descriptors + parity test).
199
- - **Tenant options** honor `Role.inheritsToDescendants` (#1867): direct
200
- ACTIVE memberships plus descendants of flagged memberships (nearest
201
- flagged ancestor labels the option; any direct row pins; inactive direct
202
- rows exclude). Session binding defaults to the first DIRECT tenant —
203
- override with `resolveTenantId`.
204
- - **Hooks**: `resolveUser` (invite-gating; default provisions via
205
- `getOrCreateFromOidc`), `resolveTenantId`, `buildExtras` (bootstrap
206
- `extras`; model JSON must use `toPublicJSON({ permissions })` — #1822).
207
- `buildExtras` is the only app-domain bootstrap extension point. Never emit
208
- app fields such as `dashboard` at the response top level: they are outside
209
- `MobileSessionBootstrap` and the Kotlin decoder ignores them.
210
- - **Guard** wraps `withSessionPermissionContext`, so
211
- `assertOperationPermission`, tenancy context, and Postgres RLS all see the
212
- bearer caller; `OperationPermissionError` maps to 403 with a
213
- machine-readable `reason`.
214
- - **Uploads**: `resolveMobileUploadDedupKey` + the documented contract in
215
- `docs/content/architecture/mobile-upload-contract.md` (`clientCaptureId`
216
- field, `Idempotency-Key` header fallback); domain ingestion stays
217
- app-side. Framework-model writes ride `sync/apply`, not this path.
218
- - Configure `redirectUris` in production — RFC 8252 scheme rules always
219
- apply, but the allow list is the defense against redirecting authorization
220
- responses to attacker-controlled URIs.
221
-
222
- ## Security (S5 #1400)
223
-
224
- - **Generated REST/MCP surface is READ-ONLY for every RBAC/identity model.**
225
- User, Tenant, Group, Membership, MembershipOverride, Role, Permission,
226
- RolePermission, GroupRole, GroupMember, and TenantPermissionOverride generate
227
- `list`/`get` only — `create`/`update`/`delete` are intentionally NOT
228
- generated. The merged `requireRouteAuth` gate (#1540) enforces *authentication*,
229
- not *authorization*, and these models are not `@TenantScoped`, so an
230
- auto-generated mutating route would let any authenticated user self-grant a
231
- role/permission, flip a tenant's cascade flags, or change another user's auth
232
- identity. Mutate them through the permission-gated services (`TenantService`,
233
- collection helpers) or consumer-owned, permission-checked handlers. A
234
- structural regression test (`security-audit-1400.test.ts`) enumerates the
235
- registry to assert no authority model exposes a mutating op. (`cli` stays
236
- enabled — local-operator surface, outside the network/agent threat model.)
237
- - **`switchTenant` is fail-closed AND rotates the session id.**
238
- `SessionService.switchTenant` / `switchSessionTenant` verify the session's user
239
- has an ACTIVE membership in the target tenant before any write (the tenant id
240
- is the isolation key for every `@TenantScoped` query). A non-member/unknown-
241
- session switch returns `{ switched: false, sessionId: null, ... }` and mutates
242
- nothing. On a successful switch into a NON-null tenant the session id is
243
- ROTATED: a fresh `Session` (new secure id, fresh TTL, same user, new tenant,
244
- device context carried over) is minted and the old session is REVOKED — so a
245
- captured pre-switch id immediately stops validating, shrinking the blast radius
246
- of a leaked id across a tenant boundary. `switchTenant` returns a
247
- `SwitchTenantResult` (`{ switched, sessionId, session, rotated }`); callers MUST
248
- persist the returned `sessionId`. `switchSessionTenant` does this for you by
249
- re-setting the session cookie (preserving httpOnly/secure/sameSite) to the new
250
- id. A `null` clear stays in place (no rotation, no cookie change). The
251
- low-level `SessionCollection.setSessionTenant` is the UNGUARDED primitive (used
252
- for the null-clear path) — never call it with an untrusted tenant id.
253
- - **OIDC `email_verified` is enforced.** `UserCollection.getOrCreateFromOidc`
254
- refuses to provision a user when the IdP explicitly returns
255
- `email_verified: false` (opt out with `{ allowUnverifiedEmail: true }`). An
256
- absent claim makes no assertion and is not enforced.
257
- - **RFC 9207 response issuer is exact.** OIDC callbacks validate a supplied
258
- `iss` against discovered metadata with exact string comparison before trusting
259
- a code or error. If metadata advertises issuer-response support, `iss` is
260
- required.
261
- - **Verified-email Profile reuse is fail-closed.** The typed canonical scenarios
262
- live in
263
- `packages/profiles/src/testing/oidcProvisioningDecisionMatrix.ts`; both
264
- package suites execute that matrix and public docs reference it rather than
265
- maintaining another behavioral table. Default provisioning reuses only one
266
- unowned, global `Person`. Tenant-scoped, non-Person, duplicate-email,
267
- and already-owned matches fail before User/session creation. An existing
268
- issuer/subject link without a User must still be the unique global Person for
269
- the current verified claim email; once owned, the stable issuer/subject link
270
- reuses its canonical Person and owner.
271
- Issuer and subject are opaque, case-sensitive identifiers; preserve their
272
- exact value and use trim only to reject blank claims.
273
- - **`resolveProfile` is the application reconciliation boundary.** The
274
- SvelteKit handlers, `OidcLoginService`, and `getOrCreateFromOidc` accept the
275
- same hook inside the provisioning transaction. The service/handler path
276
- supplies protocol-validated claims; direct collection callers must validate
277
- and trust their claim source before calling `getOrCreateFromOidc`.
278
- Token/userinfo merging keeps `email` and `email_verified` paired to the same
279
- claim source; verification is never borrowed across sources.
280
- Resolver reads/writes use the supplied `db`, and the hook must be idempotent
281
- because a concurrent unique-key conflict can retry it. `undefined` chooses
282
- the secure default and `null` rejects, including exact issuer/subject reuse.
283
- For a new identity, a supplied Profile is still validated as the unique,
284
- unowned global Person for the verified email. For an exact existing identity,
285
- it must be the already-linked Profile and cannot rebind identity authority;
286
- stable-link owner and canonical-Person checks still apply. The hook receives a
287
- separate frozen claims snapshot; internal retry and persistence state is not
288
- exposed for mutation.
289
- - **Owned first binding requires `authorizeProfileOwner`.** An invitation or
290
- approval workflow may explicitly return both its pre-provisioned canonical
291
- global `Person` and existing approved `User` from this transaction-bound
292
- hook. `undefined` keeps the secure `profile_owned` default and `null`
293
- rejects. SMRT treats only the selected IDs as input, reloads them in the
294
- provisioning transaction, and requires `email_verified === true`, the unique
295
- canonical global Person for the normalized claim email, exactly one owner,
296
- that owner as the selected User, and the same normalized User email. The
297
- hook runs before identity/User/session creation and may be retried, so use
298
- only its supplied `db` and `users` handles and keep application authorization
299
- idempotent. Never authorize from email matching alone. Existing exact
300
- identities cannot be rebound; when `resolveProfile` is also present both
301
- hooks must select the same Profile.
302
- - **OIDC first login is atomic.** The Profile, `OidcIdentity`, and User are one
303
- transaction. The database arbiters are `OidcIdentity.identityKey`,
304
- private `oidc_profile_email_reservations.email_key`, `User.emailKey`, and the
305
- unique `User.profileId`; local callbacks acquire exact issuer/subject and normalized
306
- email locks in deterministic order so changed email claims also serialize.
307
- SQLite and DuckDB callbacks additionally serialize every root-handle statement
308
- the coordinator owns per database URL — `_smrt_backfills` initialization, the
309
- transaction, and the post-commit rebind — because one adapter multiplexes a
310
- single native connection and cannot safely overlap unrelated root
311
- transactions. Never overlap two statements on one such handle, inside a
312
- transaction or not: rebind and owner/email candidate reads are sequential,
313
- never `Promise.all`. Owner-authorized DuckDB callbacks use that same
314
- root-handle serialization;
315
- PostgreSQL deadlock and serialization errors use a bounded transaction retry.
316
- Newly provisioned Profiles use non-semantic per-profile slugs so equal IdP
317
- display names cannot trigger a natural-key upsert;
318
- run
319
- `smrt db:status`, `smrt db:migrate`, then `smrt db:status` before deployment.
320
- Stop or upgrade old writers first. Before migration, group
321
- non-null `users.profile_id` values, then reconcile duplicates. After
322
- migration, run public `backfillProfileEmailKeys(db)` followed by
323
- `backfillUserEmailKeys(db)` from one deploy process. Both use the shared
324
- TypeScript `normalizeIdentityEmail()` implementation transactionally and are
325
- idempotent; the User backfill fails before writes if normalized duplicates
326
- remain. Every OIDC path requires the Profile email-key readiness marker;
327
- creating a User or checking User email uniqueness additionally requires the
328
- User marker. A stable issuer/subject with an existing owning User skips only
329
- the User email-key lookup and marker. Full scans remain in the explicit deploy
330
- step; guarded runtime paths use indexed keys and validate only returned
331
- candidates. Multiple null links remain valid.
332
- Legacy race keys backfill only after canonical validation. Pass a root
333
- database on adapters such as DuckDB that cannot create nested savepoints.
334
- Root adapters must expose `beginTransaction`; transaction-only handles are
335
- ambiguous and fail closed before provisioning writes. Caller-owned
336
- transactions never run `_smrt_backfills` DDL and require that table to
337
- already exist; use the root database when initialization or recovery is
338
- needed.
339
-
340
- ## Gotchas
341
-
342
- - **seedSystemRoles() required**: call `RoleCollection.seedSystemRoles()` at app init (creates owner/admin/member/viewer)
343
- - **PermissionResolver casts `as any`**: collections have protected constructors — known framework limitation
344
- - **Session TTL in seconds**: `DEFAULT_SESSION_TTL = 7 * 24 * 60 * 60` (not milliseconds)
345
- - **Users are cross-tenant**: one user, many tenants via Membership. Email globally unique.
346
- - **Batch permission queries**: resolver fetches all permission IDs in one query, then maps to slugs (avoids N+1)
75
+ Start with the relevant test file via `test -- src/__tests__/<file>.test.ts`.
76
+ Run `test:postgres` for RLS, principal context, OIDC, or terminal-auth database
77
+ changes. `typecheck` includes Svelte accessibility checks; plain `tsc` is
78
+ insufficient. Follow root knowledge freshness checks before shipping.