@happyvertical/smrt-users 0.39.16 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -268,6 +268,19 @@ the package root).
268
268
  stable-link owner and canonical-Person checks still apply. The hook receives a
269
269
  separate frozen claims snapshot; internal retry and persistence state is not
270
270
  exposed for mutation.
271
+ - **Owned first binding requires `authorizeProfileOwner`.** An invitation or
272
+ approval workflow may explicitly return both its pre-provisioned canonical
273
+ global `Person` and existing approved `User` from this transaction-bound
274
+ hook. `undefined` keeps the secure `profile_owned` default and `null`
275
+ rejects. SMRT treats only the selected IDs as input, reloads them in the
276
+ provisioning transaction, and requires `email_verified === true`, the unique
277
+ canonical global Person for the normalized claim email, exactly one owner,
278
+ that owner as the selected User, and the same normalized User email. The
279
+ hook runs before identity/User/session creation and may be retried, so use
280
+ only its supplied `db` and `users` handles and keep application authorization
281
+ idempotent. Never authorize from email matching alone. Existing exact
282
+ identities cannot be rebound; when `resolveProfile` is also present both
283
+ hooks must select the same Profile.
271
284
  - **OIDC first login is atomic.** The Profile, `OidcIdentity`, and User are one
272
285
  transaction. The database arbiters are `OidcIdentity.identityKey`,
273
286
  private `oidc_profile_email_reservations.email_key`, `User.emailKey`, and the
@@ -275,6 +288,7 @@ the package root).
275
288
  email locks in deterministic order so changed email claims also serialize.
276
289
  SQLite and DuckDB callbacks additionally serialize transactions per database
277
290
  URL because one adapter cannot safely overlap unrelated root transactions;
291
+ owner-authorized DuckDB callbacks use that same root-handle serialization;
278
292
  PostgreSQL deadlock and serialization errors use a bounded transaction retry.
279
293
  Newly provisioned Profiles use non-semantic per-profile slugs so equal IdP
280
294
  display names cannot trigger a natural-key upsert;
package/README.md CHANGED
@@ -381,8 +381,10 @@ rebinding, ownership/collision failures, readiness, retries, adapter support,
381
381
  public errors, and permitted Profile/OIDC identity/User/session creation. For a
382
382
  new identity, the Users path is deliberately fail-closed before User or session
383
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.
384
+ allowed by that matrix. An owned Profile still returns `profile_owned` unless
385
+ the application explicitly supplies the owner authorization described below.
386
+ An exact issuer/subject link may instead continue to its already-owned canonical
387
+ global `Person`, but it cannot be rebound.
386
388
 
387
389
  Canonical Profile failures use `CanonicalPersonProfileError` from
388
390
  `@happyvertical/smrt-profiles`, with codes `ambiguous_email`, `email_mismatch`,
@@ -433,6 +435,52 @@ still apply. The resolver receives a separate frozen claims snapshot; retry
433
435
  locks, identity lookups, and persistence retain SMRT's immutable internal
434
436
  snapshot.
435
437
 
438
+ An invitation or approval workflow that pre-provisions both the canonical
439
+ global `Person` and its approved owning `User` can authorize the first identity
440
+ binding with `authorizeProfileOwner`:
441
+
442
+ ```typescript
443
+ // src/routes/auth/[provider]/callback/+server.ts
444
+ import { ProfileCollection } from '@happyvertical/smrt-profiles';
445
+ import { createOidcCallbackHandler } from '@happyvertical/smrt-users/sveltekit';
446
+
447
+ export const GET = createOidcCallbackHandler({
448
+ db: { type: 'postgres', url: process.env.DATABASE_URL! },
449
+ authorizeProfileOwner: async ({ claims, db, users }) => {
450
+ // This application record is the authorization decision. Select by its
451
+ // approved IDs; do not authorize an account from matching email alone.
452
+ const approval = await findApprovedOidcUser({
453
+ db,
454
+ email: claims.email,
455
+ });
456
+ if (!approval) return undefined; // preserve SMRT's secure default
457
+
458
+ const profiles = await ProfileCollection.create({ db });
459
+ const [profile, user] = await Promise.all([
460
+ profiles.get({ id: approval.profileId }),
461
+ users.get({ id: approval.userId }),
462
+ ]);
463
+ if (!profile || !user) return null; // explicitly reject stale approval
464
+ return { profile, user };
465
+ },
466
+ successRedirect: '/dashboard',
467
+ });
468
+ ```
469
+
470
+ The authorizer runs after protocol validation and inside the provisioning
471
+ transaction. It receives frozen normalized claims, the transaction-bound `db`,
472
+ and a `UserCollection` bound to that same transaction. Return both selected
473
+ objects only after application authorization; `undefined` uses the fail-closed
474
+ default and `null` rejects. SMRT reloads and verifies the selected IDs rather
475
+ than trusting the returned objects: `email_verified` must be exactly `true`,
476
+ the Profile must be the unique canonical global `Person` for the claim email,
477
+ exactly one User must own it, and that User must have the same normalized email.
478
+ An exact issuer/subject cannot be rebound. Identity creation and login remain
479
+ atomic, and a race retry may invoke the authorizer again, so its reads and
480
+ writes must use only the supplied handles and be idempotent. Supplying both
481
+ `resolveProfile` and `authorizeProfileOwner` is allowed only when they select
482
+ the same Profile.
483
+
436
484
  When userinfo supplies a missing email, its `email_verified` value travels with
437
485
  that email as one source-bound pair. SMRT never borrows a verification flag
438
486
  from the ID token for a userinfo address, or from userinfo for an ID-token
@@ -459,7 +507,9 @@ locks in deterministic order, including when the same subject presents changed
459
507
  email claims on independent database handles. SQLite and DuckDB also acquire a
460
508
  database-URL transaction lock because one adapter cannot safely overlap
461
509
  unrelated root transactions; PostgreSQL deadlock and serialization failures use
462
- a bounded transaction retry. New OIDC Profiles use non-semantic unique slugs,
510
+ a bounded transaction retry. Owner-authorized binding uses the same contract:
511
+ pass the DuckDB root handle and let SMRT serialize the callback transaction.
512
+ New OIDC Profiles use non-semantic unique slugs,
463
513
  so equal IdP display names cannot overwrite one another through SMRT's
464
514
  natural-key upsert.
465
515
  Existing installations must run `smrt db:status`, `smrt db:migrate`, then
@@ -613,6 +663,7 @@ TenantService supports three modes: `flexible` (no auto-create), `personal` (aut
613
663
  | `OidcLoginService` | Generic OIDC authorization-code login with PKCE for Kanidm, Dex, and other standards-compliant providers. |
614
664
  | `backfillUserEmailKeys` | Idempotently populate durable normalized-email keys after migrating legacy Users; fails closed on duplicates. |
615
665
  | `OidcProfileResolver` | Transaction-bound pre-provision hook for application identity reconciliation. |
666
+ | `OidcProfileOwnerAuthorizer` | Transaction-bound application authorization for binding a first identity to an existing canonical Profile and its sole approved User owner. |
616
667
  | `NormalizedOidcClaims` | Frozen resolver claims with required normalized `email`. |
617
668
  | `withSessionPermissionContext()` | Loads a session, optionally enters tenancy context, and exposes a request-scoped database/permission context. |
618
669
  | `getCurrentSessionPermissionContext()`, `getRequestScopedDatabase()` | Read the active request/session context inside app code. |