@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 +56 -1
- package/README.md +134 -1
- package/dist/chunks/{TerminalAuthService-B5U0hvzu.js → TerminalAuthService-CpLKHLl5.js} +288 -29
- package/dist/chunks/TerminalAuthService-CpLKHLl5.js.map +1 -0
- package/dist/collections/UserCollection.d.ts +75 -2
- package/dist/collections/UserCollection.d.ts.map +1 -1
- package/dist/collections/index.d.ts +1 -1
- package/dist/collections/index.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/manifest.json +64 -38
- package/dist/migrations/backfillUserEmailKeys.d.ts +31 -0
- package/dist/migrations/backfillUserEmailKeys.d.ts.map +1 -0
- package/dist/models/User.d.ts +4 -0
- package/dist/models/User.d.ts.map +1 -1
- package/dist/services/OidcLoginService.d.ts +7 -1
- package/dist/services/OidcLoginService.d.ts.map +1 -1
- package/dist/smrt-knowledge.json +15 -8
- package/dist/sveltekit/index.d.ts +8 -0
- package/dist/sveltekit/index.d.ts.map +1 -1
- package/dist/sveltekit.js +3 -4
- package/dist/sveltekit.js.map +1 -1
- package/package.json +11 -11
- package/dist/chunks/TerminalAuthService-B5U0hvzu.js.map +0 -1
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
|
|
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`
|
|
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()`. |
|