@happyvertical/smrt-users 0.39.14 → 0.39.16

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