@happyvertical/smrt-users 0.39.13 → 0.39.15

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
@@ -6,7 +6,7 @@ Multi-tenant user management with RBAC, hierarchical tenants, session handling,
6
6
 
7
7
  | Model | Key Pattern |
8
8
  |-------|-------------|
9
- | User | Auth identity. `profileId` is plain string (not FK) to smrt-profiles. Email auto-lowercased. |
9
+ | 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. |
10
10
  | 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). |
11
11
  | Tenant | **STI** + hierarchical parent-child. `hierarchyPath` (materialized path), `hierarchyLevel`. Max depth 10. |
12
12
  | Session | Server-side. Secure UUID. TTL in **seconds** (not ms). Status auto-updates to EXPIRED on access. |
@@ -240,6 +240,61 @@ the package root).
240
240
  refuses to provision a user when the IdP explicitly returns
241
241
  `email_verified: false` (opt out with `{ allowUnverifiedEmail: true }`). An
242
242
  absent claim makes no assertion and is not enforced.
243
+ - **Verified-email Profile reuse is fail-closed.** Default provisioning reuses
244
+ only one unowned, global `Person`. Tenant-scoped, non-Person, duplicate-email,
245
+ and already-owned matches fail before User/session creation. An existing
246
+ issuer/subject link without a User must still be the unique global Person for
247
+ the current verified claim email; once owned, the stable issuer/subject link
248
+ reuses its canonical Person and owner.
249
+ Issuer and subject are opaque, case-sensitive identifiers; preserve their
250
+ exact value and use trim only to reject blank claims.
251
+ - **`resolveProfile` is the application reconciliation boundary.** The
252
+ SvelteKit handlers, `OidcLoginService`, and `getOrCreateFromOidc` accept the
253
+ same hook inside the provisioning transaction. The service/handler path
254
+ supplies protocol-validated claims; direct collection callers must validate
255
+ and trust their claim source before calling `getOrCreateFromOidc`.
256
+ Token/userinfo merging keeps `email` and `email_verified` paired to the same
257
+ claim source; verification is never borrowed across sources.
258
+ Resolver reads/writes use the supplied `db`, and the hook must be idempotent
259
+ because a concurrent unique-key conflict can retry it. `undefined` chooses
260
+ the secure default and `null` rejects, including exact issuer/subject reuse.
261
+ For a new identity, a supplied Profile is still validated as the unique,
262
+ unowned global Person for the verified email. For an exact existing identity,
263
+ it must be the already-linked Profile and cannot rebind identity authority;
264
+ stable-link owner and canonical-Person checks still apply. The hook receives a
265
+ separate frozen claims snapshot; internal retry and persistence state is not
266
+ exposed for mutation.
267
+ - **OIDC first login is atomic.** The Profile, `OidcIdentity`, and User are one
268
+ transaction. The database arbiters are `OidcIdentity.identityKey`,
269
+ private `oidc_profile_email_reservations.email_key`, `User.emailKey`, and the
270
+ unique `User.profileId`; local callbacks acquire exact issuer/subject and normalized
271
+ email locks in deterministic order so changed email claims also serialize.
272
+ SQLite and DuckDB callbacks additionally serialize transactions per database
273
+ URL because one adapter cannot safely overlap unrelated root transactions;
274
+ PostgreSQL deadlock and serialization errors use a bounded transaction retry.
275
+ Newly provisioned Profiles use non-semantic per-profile slugs so equal IdP
276
+ display names cannot trigger a natural-key upsert;
277
+ run
278
+ `smrt db:status`, `smrt db:migrate`, then `smrt db:status` before deployment.
279
+ Stop or upgrade old writers first. Before migration, group
280
+ non-null `users.profile_id` values, then reconcile duplicates. After
281
+ migration, run public `backfillProfileEmailKeys(db)` followed by
282
+ `backfillUserEmailKeys(db)` from one deploy process. Both use the shared
283
+ TypeScript `normalizeIdentityEmail()` implementation transactionally and are
284
+ idempotent; the User backfill fails before writes if normalized duplicates
285
+ remain. Every OIDC path requires the Profile email-key readiness marker;
286
+ creating a User or checking User email uniqueness additionally requires the
287
+ User marker. A stable issuer/subject with an existing owning User skips only
288
+ the User email-key lookup and marker. Full scans remain in the explicit deploy
289
+ step; guarded runtime paths use indexed keys and validate only returned
290
+ candidates. Multiple null links remain valid.
291
+ Legacy race keys backfill only after canonical validation. Pass a root
292
+ database on adapters such as DuckDB that cannot create nested savepoints.
293
+ Root adapters must expose `beginTransaction`; transaction-only handles are
294
+ ambiguous and fail closed before provisioning writes. Caller-owned
295
+ transactions never run `_smrt_backfills` DDL and require that table to
296
+ already exist; use the root database when initialization or recovery is
297
+ needed.
243
298
 
244
299
  ## Gotchas
245
300
 
package/README.md CHANGED
@@ -374,6 +374,136 @@ can pass `transactionCookieSecret` to the route helpers. On success it creates
374
374
  or reuses a SMRT `Profile`, links an `OidcIdentity`, creates or reuses a `User`,
375
375
  records `lastLoginAt`, and sets the standard SMRT session cookie.
376
376
 
377
+ Verified-email provisioning is fail-closed. The default resolver reuses a
378
+ Profile only when exactly one case-insensitive email match exists and that row
379
+ is an unowned, global `Person`. A tenant-scoped Profile, a non-`Person` STI row,
380
+ duplicate email matches, or a Profile already owned by another `User` rejects
381
+ the callback before User or session creation. An existing OIDC issuer/subject
382
+ link without a User must still identify the unique global `Person` for the
383
+ current verified claim email. Once a User owns it, the stable issuer/subject
384
+ link continues to select its canonical global `Person` and existing User.
385
+
386
+ Canonical Profile failures use `CanonicalPersonProfileError` from
387
+ `@happyvertical/smrt-profiles`, with codes `ambiguous_email`, `email_mismatch`,
388
+ `email_key_backfill_required`, `missing_profile`, `non_person`,
389
+ `reservation_conflict`, or `tenant_scoped`.
390
+ User ownership/provisioning failures use `OidcProvisioningError`, with codes
391
+ `ambiguous_identity`, `concurrency_conflict`, `profile_owned`, `rejected`,
392
+ `transaction_required`, `user_email_backfill_required`, or
393
+ `user_email_conflict`. `completeOidcLogin()` rejects with the full error. The
394
+ ready-made callback handler passes that error to a configured `failureRedirect`
395
+ callback; without one it returns a generic 401 and does not expose account,
396
+ resolver, or database details to the browser.
397
+
398
+ Applications that already own an identity-reconciliation policy can provide a
399
+ `resolveProfile` hook without replacing transaction cookies, token exchange,
400
+ claim verification, or session creation:
401
+
402
+ ```typescript
403
+ // src/routes/auth/[provider]/callback/+server.ts
404
+ import { createOidcCallbackHandler } from '@happyvertical/smrt-users/sveltekit';
405
+
406
+ export const GET = createOidcCallbackHandler({
407
+ db: { type: 'postgres', url: process.env.DATABASE_URL! },
408
+ resolveProfile: async ({ claims, db }) => {
409
+ // All reads and writes must use this transaction-bound `db` handle.
410
+ const profile = await resolveApplicationIdentity({ claims, db });
411
+
412
+ // undefined: use SMRT's secure default
413
+ // null: reject this login
414
+ // Profile: select an application-reconciled canonical global Person
415
+ return profile;
416
+ },
417
+ successRedirect: '/dashboard',
418
+ });
419
+ ```
420
+
421
+ The service and SvelteKit handler run the hook after protocol claim validation
422
+ and inside the same provisioning transaction as OIDC identity and User
423
+ creation. Direct `UserCollection.getOrCreateFromOidc()` callers must first
424
+ validate and trust their supplied claims. The hook may run again after a
425
+ concurrent unique-key conflict, so it must be idempotent. For a new
426
+ issuer/subject, a supplied Profile is still validated as the unique, unowned
427
+ global `Person` for a verified email; resolver reuse is rejected unless
428
+ `email_verified` is exactly `true`. For an exact existing issuer/subject,
429
+ `null` still rejects login, a supplied Profile must be the already-linked
430
+ Profile and cannot rebind it, and stable-link owner/canonical-Person checks
431
+ still apply. The resolver receives a separate frozen claims snapshot; retry
432
+ locks, identity lookups, and persistence retain SMRT's immutable internal
433
+ snapshot.
434
+
435
+ When userinfo supplies a missing email, its `email_verified` value travels with
436
+ that email as one source-bound pair. SMRT never borrows a verification flag
437
+ from the ID token for a userinfo address, or from userinfo for an ID-token
438
+ address.
439
+
440
+ The concurrency guarantee uses four database arbiters: nullable unique
441
+ `OidcIdentity.identityKey`, private unique
442
+ `oidc_profile_email_reservations.email_key`, nullable unique `User.emailKey`,
443
+ and unique `User.profileId`. `User.emailKey` is derived from the trimmed,
444
+ lowercase email on every save, preventing independent database connections from
445
+ creating ambiguous User rows for the same address. Profile and User keys share
446
+ the exported TypeScript `normalizeIdentityEmail()` implementation; identity
447
+ lookups never depend on adapter-specific SQL `lower()` or `trim()` behavior.
448
+ Before trusting those keys, identity lookup verifies that every stored key
449
+ on a returned candidate still equals the application-normalized source email.
450
+ Every OIDC path validates or synchronizes its canonical Profile and therefore
451
+ requires the Profile email-key readiness marker. Creating a User or checking
452
+ User email uniqueness additionally requires the User email-key marker. A stable
453
+ issuer/subject that already has an owning User skips only the User email-key
454
+ lookup and marker. Full table validation stays in the explicit backfill, while
455
+ guarded runtime paths use only indexed candidate rows.
456
+ In-process callbacks also acquire the exact issuer/subject and normalized email
457
+ locks in deterministic order, including when the same subject presents changed
458
+ email claims on independent database handles. SQLite and DuckDB also acquire a
459
+ database-URL transaction lock because one adapter cannot safely overlap
460
+ unrelated root transactions; PostgreSQL deadlock and serialization failures use
461
+ a bounded transaction retry. New OIDC Profiles use non-semantic unique slugs,
462
+ so equal IdP display names cannot overwrite one another through SMRT's
463
+ natural-key upsert.
464
+ Existing installations must run `smrt db:status`, `smrt db:migrate`, then
465
+ `smrt db:status` before deploying this users version; legacy identities reserve
466
+ an address only after the Profile passes canonical validation, and existing
467
+ issuer/subject reuse synchronizes that reservation with the Profile's current
468
+ stored email. Stop or upgrade old Profile and User writers before migration.
469
+ Before migration, find duplicate ownership links:
470
+
471
+ ```sql
472
+ SELECT profile_id, COUNT(*) AS user_count
473
+ FROM users
474
+ WHERE profile_id IS NOT NULL
475
+ GROUP BY profile_id
476
+ HAVING COUNT(*) > 1;
477
+ ```
478
+
479
+ Reconcile every result before applying the unique Profile constraint; legacy
480
+ empty-string Profile placeholders should be normalized to `NULL`. Multiple
481
+ `NULL` links remain valid. After the schema migration, populate both durable
482
+ keys from a single deploy process:
483
+
484
+ ```typescript
485
+ import { backfillProfileEmailKeys } from '@happyvertical/smrt-profiles';
486
+ import { backfillUserEmailKeys } from '@happyvertical/smrt-users';
487
+
488
+ await backfillProfileEmailKeys(database);
489
+ await backfillUserEmailKeys(database);
490
+ ```
491
+
492
+ The supported backfills are transactional and idempotent. The User backfill
493
+ fails without changing rows if legacy emails are still ambiguous; reconcile
494
+ the reported normalized keys and rerun it. All OIDC paths require the Profile
495
+ marker; paths that create a User or arbitrate User email uniqueness also require
496
+ the User marker. Run both before enabling OIDC provisioning. Pass the
497
+ root database to provisioning on adapters such as DuckDB that do not support
498
+ nested savepoints; root adapters must expose `beginTransaction`. A handle
499
+ exposing only `transaction()` is ambiguous and fails closed before resolver
500
+ writes rather than risking a nested transaction that could roll back
501
+ caller-owned work. A transaction-bound handle reads an existing
502
+ `_smrt_backfills` table but never attempts tracker DDL; pass the root database
503
+ when initialization or recovery is needed. OIDC `iss` and `sub` are preserved as exact opaque,
504
+ case-sensitive identifiers (trim is used only to reject blank claims), so
505
+ whitespace-distinct subjects never reuse one another.
506
+
377
507
  With `postgresRls: true`, SMRT opens a request-scoped Postgres transaction,
378
508
  loads the session, resolves permissions, and sets session variables used by the
379
509
  generated RLS helpers:
@@ -447,7 +577,7 @@ TenantService supports three modes: `flexible` (no auto-create), `personal` (aut
447
577
 
448
578
  | Export | Description |
449
579
  |--------|-------------|
450
- | `User` | Auth identity. Email auto-lowercased. `profileId` links to smrt-profiles (plain string). |
580
+ | `User` | Auth identity. Email auto-lowercased. `profileId` is a unique cross-package Profile reference (one User per non-null Profile). |
451
581
  | `Tenant` | Organizational boundary. STI. Hierarchical via `parentTenantId`/`hierarchyPath`. |
452
582
  | `Role` | Permission template. `tenantId = null` for system roles. `isSystem` blocks deletion. |
453
583
  | `Permission` | Named capability. Slug format: `resource.action`. |
@@ -480,6 +610,9 @@ TenantService supports three modes: `flexible` (no auto-create), `personal` (aut
480
610
  | `generatePostgresPermissionSql()`, `applyPostgresPermissionPolicies()` | Preview or apply Postgres RLS helper functions and table policies. |
481
611
  | `SessionService` | High-level session management. `createSession()`, `loadSessionContext()`, `destroySession()`. |
482
612
  | `OidcLoginService` | Generic OIDC authorization-code login with PKCE for Kanidm, Dex, and other standards-compliant providers. |
613
+ | `backfillUserEmailKeys` | Idempotently populate durable normalized-email keys after migrating legacy Users; fails closed on duplicates. |
614
+ | `OidcProfileResolver` | Transaction-bound pre-provision hook for application identity reconciliation. |
615
+ | `NormalizedOidcClaims` | Frozen resolver claims with required normalized `email`. |
483
616
  | `withSessionPermissionContext()` | Loads a session, optionally enters tenancy context, and exposes a request-scoped database/permission context. |
484
617
  | `getCurrentSessionPermissionContext()`, `getRequestScopedDatabase()` | Read the active request/session context inside app code. |
485
618
  | `TenantService` | Policy-driven tenant lifecycle. `ensureTenantForUser()`, `createTenantWithOwnership()`. |