@happyvertical/smrt-users 0.52.0 → 0.53.5

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.
Files changed (82) hide show
  1. package/AGENTS.md +43 -2
  2. package/README.md +227 -2
  3. package/agents/permissions.md +12 -1
  4. package/agents/retention.md +14 -7
  5. package/dist/chunks/{TerminalAuthService-Br64vHtd.js → TerminalAuthService-BdB_0vDA.js} +3492 -2172
  6. package/dist/chunks/TerminalAuthService-BdB_0vDA.js.map +1 -0
  7. package/dist/collections/CliAuthApproveLimitCollection.d.ts +4 -0
  8. package/dist/collections/CliAuthApproveLimitCollection.d.ts.map +1 -1
  9. package/dist/collections/CliAuthRequestCollection.d.ts.map +1 -1
  10. package/dist/collections/LoginAttemptCollection.d.ts +105 -0
  11. package/dist/collections/LoginAttemptCollection.d.ts.map +1 -0
  12. package/dist/collections/LoginAuditEventCollection.d.ts +19 -0
  13. package/dist/collections/LoginAuditEventCollection.d.ts.map +1 -0
  14. package/dist/collections/PinCredentialCollection.d.ts +26 -0
  15. package/dist/collections/PinCredentialCollection.d.ts.map +1 -0
  16. package/dist/collections/RoleCollection.d.ts.map +1 -1
  17. package/dist/collections/RolePermissionCollection.d.ts +4 -0
  18. package/dist/collections/RolePermissionCollection.d.ts.map +1 -1
  19. package/dist/collections/SessionCollection.d.ts +33 -1
  20. package/dist/collections/SessionCollection.d.ts.map +1 -1
  21. package/dist/collections/index.d.ts +3 -0
  22. package/dist/collections/index.d.ts.map +1 -1
  23. package/dist/collections/seeding-transaction.d.ts +43 -0
  24. package/dist/collections/seeding-transaction.d.ts.map +1 -0
  25. package/dist/index.d.ts +5 -4
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +180 -32
  28. package/dist/index.js.map +1 -1
  29. package/dist/manifest.json +3281 -2018
  30. package/dist/migrations/deduplicateRolePermissions.d.ts +23 -0
  31. package/dist/migrations/deduplicateRolePermissions.d.ts.map +1 -0
  32. package/dist/models/CliAuthApproveLimit.d.ts +8 -1
  33. package/dist/models/CliAuthApproveLimit.d.ts.map +1 -1
  34. package/dist/models/LoginAttempt.d.ts +59 -0
  35. package/dist/models/LoginAttempt.d.ts.map +1 -0
  36. package/dist/models/LoginAuditEvent.d.ts +49 -0
  37. package/dist/models/LoginAuditEvent.d.ts.map +1 -0
  38. package/dist/models/PinCredential.d.ts +31 -0
  39. package/dist/models/PinCredential.d.ts.map +1 -0
  40. package/dist/models/RolePermission.d.ts.map +1 -1
  41. package/dist/models/Session.d.ts +69 -1
  42. package/dist/models/Session.d.ts.map +1 -1
  43. package/dist/models/index.d.ts +4 -1
  44. package/dist/models/index.d.ts.map +1 -1
  45. package/dist/retention.d.ts +7 -1
  46. package/dist/retention.d.ts.map +1 -1
  47. package/dist/services/DeviceCredentialService.d.ts +348 -0
  48. package/dist/services/DeviceCredentialService.d.ts.map +1 -0
  49. package/dist/services/LoginAttemptLimiter.d.ts +227 -0
  50. package/dist/services/LoginAttemptLimiter.d.ts.map +1 -0
  51. package/dist/services/MobileAuthService.d.ts.map +1 -1
  52. package/dist/services/OperationPermissionService.d.ts.map +1 -1
  53. package/dist/services/PermissionCatalogService.d.ts.map +1 -1
  54. package/dist/services/SessionPermissionContext.d.ts.map +1 -1
  55. package/dist/services/SessionService.d.ts +56 -1
  56. package/dist/services/SessionService.d.ts.map +1 -1
  57. package/dist/services/TerminalAuthService.d.ts +18 -3
  58. package/dist/services/TerminalAuthService.d.ts.map +1 -1
  59. package/dist/services/index.d.ts +4 -2
  60. package/dist/services/index.d.ts.map +1 -1
  61. package/dist/smrt-knowledge.json +685 -11
  62. package/dist/sveltekit/device-credential-handlers.d.ts +60 -0
  63. package/dist/sveltekit/device-credential-handlers.d.ts.map +1 -0
  64. package/dist/sveltekit/index.d.ts +10 -0
  65. package/dist/sveltekit/index.d.ts.map +1 -1
  66. package/dist/sveltekit/types.d.ts +6 -0
  67. package/dist/sveltekit/types.d.ts.map +1 -1
  68. package/dist/sveltekit.js +184 -4
  69. package/dist/sveltekit.js.map +1 -1
  70. package/package.json +13 -13
  71. package/dist/__tests__/helpers/oidc-test-server.d.ts +0 -45
  72. package/dist/__tests__/helpers/oidc-test-server.d.ts.map +0 -1
  73. package/dist/__tests__/helpers/postgres-rls-reset.d.ts +0 -15
  74. package/dist/__tests__/helpers/postgres-rls-reset.d.ts.map +0 -1
  75. package/dist/chunks/TerminalAuthService-Br64vHtd.js.map +0 -1
  76. package/dist/svelte/components/__tests__/InviteUserModal.test.js +0 -65
  77. package/dist/svelte/components/__tests__/UserAvatar.test.js +0 -31
  78. package/dist/svelte/components/__tests__/UserCard.test.js +0 -39
  79. package/dist/svelte/components/__tests__/UserForm.test.js +0 -50
  80. package/dist/svelte/components/__tests__/UserList.test.js +0 -48
  81. package/dist/svelte/components/__tests__/UserMenu.test.js +0 -83
  82. package/dist/svelte/components/__tests__/UserStatus.test.js +0 -36
package/AGENTS.md CHANGED
@@ -12,7 +12,8 @@ are not prerequisites for unrelated user-package work.
12
12
  | `src/services/PermissionResolver.ts` | permission precedence, inherited memberships, guards, RLS, role seeding | [agents/permissions.md](agents/permissions.md) |
13
13
  | `src/services/OidcLoginService.ts`, collections and OIDC handlers | identity reconciliation, transaction boundaries, migration readiness | [agents/oidc-provisioning.md](agents/oidc-provisioning.md) |
14
14
  | `src/services/MobileAuthService.ts` | mobile handshake, bearer sessions, bootstrap extension boundary | [agents/mobile-auth.md](agents/mobile-auth.md) |
15
- | `src/services/TerminalAuthService.ts`, `src/collections/CliAuthApproveLimitCollection.ts` | terminal approval concurrency, shared attempt budget, retry-safe database fixtures | [agents/terminal-auth-testing.md](agents/terminal-auth-testing.md) |
15
+ | `src/services/TerminalAuthService.ts`, `src/services/LoginAttemptLimiter.ts`, `src/collections/LoginAttemptCollection.ts` | terminal approval concurrency, shared login budget and lockout (#3273), retry-safe database fixtures | [agents/terminal-auth-testing.md](agents/terminal-auth-testing.md) |
16
+ | `src/services/DeviceCredentialService.ts` | per-person PIN layered on a device session (#3276), verifier seam for other credential kinds | this file, "Security boundaries" |
16
17
  | `src/retention.ts` | expired session/token/CLI-auth reaping and retention sweep wiring | [agents/retention.md](agents/retention.md) |
17
18
 
18
19
  ## Models and authority
@@ -61,11 +62,51 @@ are not prerequisites for unrelated user-package work.
61
62
  consumers: null membership can represent inherited authority.
62
63
  - Sessions use secure UUIDs; TTL is seconds (default seven days). Access marks
63
64
  expired sessions EXPIRED. Magic-link tokens are single use.
65
+ - `Session.authMethod` is server-set at mint time and never read from the
66
+ client; every mint site in this package sets it. A session with
67
+ `parentSessionId` is valid only while its parent validates
68
+ (`loadSessionContext` enforces this; there is no FK cascade by design), may
69
+ not switch to a tenant other than the parent's, and is revoked with its
70
+ parent by `destroySession`.
71
+ - Credential sign-ins reserve through `LoginAttemptLimiter` BEFORE credential
72
+ work and key the subject on the submitted identifier, never a resolved user,
73
+ so unknown and wrong take the same path. Reservations are fail-closed: keep
74
+ them on failure, release on success or abort, never skip the reserve.
75
+ Limiter writes are raw SQL and bypass the change feed; the audit sink is the
76
+ observability seam, and it must never throw into the sign-in path.
77
+ - `DeviceCredentialService` accepts a person credential only from a
78
+ first-class session whose `authMethod` is a device-enrollment method
79
+ (`terminal` by default) and that the host's `assertEnrolledDevice` confirms.
80
+ A layered or PIN session can never administer PINs regardless of
81
+ permissions. PIN hashes carry a per-row salt and the configured pepper;
82
+ treat an unset pepper as a production misconfiguration.
83
+ - A person session carries the PERSON's authority (resolved from their own
84
+ membership on every load), never the device account's. `Session.data` keys
85
+ in `SESSION_DATA_KEYS` are server-set controls enforced by the model:
86
+ `permissionCeiling` only ever intersects (malformed fails closed to none)
87
+ and suppresses `superAdminBypass`/`systemContext`; `absoluteExpiresAt`
88
+ bounds `extend()` and `isValid()`; `idleSeconds` overrides the resolving
89
+ service's TTL policy. Never write them from client input. The ceiling is a
90
+ sign-in snapshot; `singleOccupant` revokes other children only AFTER a
91
+ successful mint. A `mustReset` sign-in mints with an empty ceiling and
92
+ `setPin` ends that session; do not grant it authority another way.
93
+ - A layered session whose tenant differs from its parent's never loads (null
94
+ is allowed: narrower). Limiter rows carry `retain_until`; retention must
95
+ not fall back to a process-wide horizon.
96
+ - `Session.parentSessionId` is the parent's bearer: sensitive, never a
97
+ `list()` filter (raw-SQL id reads, which also keep revocation sweeps free of
98
+ caller list bounds), never serialized to clients. `users.pin.manage` reaches
99
+ only people whose every active membership is in the actor's tenant (the PIN
100
+ is global per person). A sign-in re-checks the verified
101
+ credential generation AFTER the mint (`stillValid`); keep that ordering.
102
+ Operation guards deny outside a session's ceiling; `loadPersonSession`
103
+ re-checks device enrollment.
64
104
  - Tenant switching verifies active membership before writing and rotates the
65
105
  session ID for non-null targets, revoking the old session. Persist the returned
66
106
  `SwitchTenantResult.sessionId`; `switchSessionTenant()` updates the cookie and
67
107
  preserves its security settings. Failed switches mutate nothing; null clears
68
- do not rotate. `SessionCollection.setSessionTenant()` is unguarded and must
108
+ do not rotate, and layered sessions never rotate (a replacement would escape
109
+ the revocation sweeps they depend on). Layers do not nest. `SessionCollection.setSessionTenant()` is unguarded and must
69
110
  never receive an untrusted tenant ID.
70
111
  - OIDC provisioning is atomic and fail-closed. Preserve exact issuer/subject
71
112
  identifiers, claim-source email verification, unique global Person ownership,
package/README.md CHANGED
@@ -113,6 +113,8 @@ Catalog sync is additive and fail-closed:
113
113
  - it updates `name`, `description`, and `category` by slug
114
114
  - it does not auto-grant permissions to roles
115
115
  - it does not delete stale permissions in v1
116
+ - it commits created/updated rows in small batched transactions; when calling
117
+ it inside your own transaction, pass that transaction's database handle
116
118
 
117
119
  ### App-defined permissions in `smrt.config.ts`
118
120
 
@@ -598,6 +600,181 @@ With `enterTenantContext: true`, the same request also enters
598
600
  `@happyvertical/smrt-tenancy` context so regular collection access is scoped to
599
601
  the current tenant in application code.
600
602
 
603
+ ### Login rate limiting and lockout
604
+
605
+ Every credential-based sign-in (terminal approval, device PIN, and any
606
+ password or passkey flow an app adds) draws from one shared budget,
607
+ `LoginAttemptLimiter` (#3273). The budget lives in `users_login_attempts`, so it
608
+ holds across every replica on Postgres; keys are hashed, so the table never
609
+ becomes an index of emails or IPs.
610
+
611
+ ```ts
612
+ import {
613
+ InvalidCredentialsError,
614
+ LoginAttemptLimiter,
615
+ LoginRateLimitError,
616
+ } from '@happyvertical/smrt-users';
617
+
618
+ const limiter = await LoginAttemptLimiter.create({
619
+ db,
620
+ maxAttempts: 5, // per key, per window
621
+ windowSeconds: 300,
622
+ lockout: { baseSeconds: 60, factor: 2, maxSeconds: 3600 },
623
+ keyPepper: process.env.LOGIN_KEY_PEPPER,
624
+ });
625
+
626
+ const lease = await limiter.reserve({
627
+ kind: 'password',
628
+ subject: submittedEmailKey, // what the client typed — NOT a resolved user
629
+ source: clientIp, // or a device/station id
630
+ });
631
+ if (!lease.allowed) throw new LoginRateLimitError(lease); // 429 + Retry-After
632
+
633
+ try {
634
+ // Always do the same work for an unknown account as for a wrong password.
635
+ const ok = await verifyPassword(user?.passwordHash ?? DUMMY_HASH, password);
636
+ if (!ok || !user) {
637
+ await lease.fail();
638
+ throw new InvalidCredentialsError();
639
+ }
640
+ await lease.succeed();
641
+ } catch (error) {
642
+ if (!(error instanceof InvalidCredentialsError)) await lease.release();
643
+ throw error;
644
+ }
645
+ ```
646
+
647
+ Subject and source are independent budgets; either one exhausted refuses the
648
+ attempt, and a refusal on the source hands the subject reservation back so one
649
+ noisy address cannot burn every account. The failure that exhausts a window —
650
+ or that completes another `maxAttempts` consecutive failures, however they
651
+ were paced across windows — locks the key for `base × factor^n` seconds, where
652
+ `n` counts consecutive exhausted budgets. A success resets the subject's
653
+ streak only; a shared source (a tablet, an office address) keeps its history,
654
+ so one valid credential cannot clear the backoff on guessing at others.
655
+ Instead a source sheds one failure per `sourceStreakDecaySeconds` (default:
656
+ twice the window) since its last failure: mistakes spaced further apart than
657
+ that never add up to locking everyone behind the source, while a burst of
658
+ guesses leaves no time to decay and still escalates. Either streak is forgiven
659
+ outright after `streakResetSeconds` without a failure. Every decision is
660
+ reported to a `LoginAuditSink` — by default a durable `UsersLoginAuditEvent`
661
+ row (pruned after 90 days by the retention sweep), or pass `audit` to forward
662
+ into the host's own log, or `audit: false`. `TerminalAuthService` uses this
663
+ limiter for approvals, keyed on the approving user only under its own subject
664
+ prefix, so that budget never pools with the same person's PIN or password
665
+ budget; pass `loginLimiter` to share one instance and audit sink.
666
+
667
+ ### Per-person PIN on an enrolled device
668
+
669
+ A shared tablet enrolled through the terminal device-code grant holds a
670
+ device-account bearer session. `DeviceCredentialService` (#3276) lets a person
671
+ sign in **on that device only** with a short credential and mints a session
672
+ *layered on* the device session: it carries `authMethod: 'pin'` and
673
+ `parentSessionId`, is valid only while the device session is, never widens
674
+ tenant scope, and signs out independently.
675
+
676
+ ```ts
677
+ import { createDeviceCredentialHandlers } from '@happyvertical/smrt-users/sveltekit';
678
+
679
+ export const deviceAuth = createDeviceCredentialHandlers({
680
+ db,
681
+ pin: { pepper: process.env.PIN_PEPPER }, // required in production
682
+ personIdleSeconds: 15 * 60, // sliding; default 8 hours
683
+ personMaxSeconds: 10 * 60 * 60, // absolute; default none
684
+ // Optional: the most anyone may do on this device. Default: no ceiling.
685
+ deviceCeiling: async (device) => devices.permissionCeilingFor(device.user.id),
686
+ // smrt-users does not own a Device object: say whether this device
687
+ // account is still an enrolled, active device.
688
+ assertEnrolledDevice: async (device) =>
689
+ (await devices.findActiveByUserId(device.user.id)) !== null,
690
+ });
691
+ // POST /api/device/pin/sign-in → deviceAuth.pinSignIn (device bearer + { userId, pin })
692
+ // POST /api/device/sign-out → deviceAuth.signOut (person bearer)
693
+ // PUT /api/device/pin → deviceAuth.setPin (self with currentPin, or admin)
694
+ // POST /api/device/pin/reset → deviceAuth.resetPin (admin; forces a new PIN)
695
+ // DELETE /api/device/pin → deviceAuth.clearPin
696
+ ```
697
+
698
+ The tablet swaps its `Authorization` bearer to the returned `sessionId` for
699
+ person-attributed work; `loadSessionContext()` resolves it with `parent` set,
700
+ so host gates can tell a person-on-device session from a browser session via
701
+ `authMethod`/`parent` (also on `event.locals.authMethod` and
702
+ `event.locals.sessionParent`). Any bearer that is not a first-class
703
+ device-enrolled session — a browser cookie, a mobile session, another person's
704
+ layered session — is refused with the same 401 as a wrong PIN. Lockout is per
705
+ person **and** per device through the login limiter. Administering PINs needs
706
+ `users.pin.manage` from a non-PIN session; a PIN session can only change its
707
+ own PIN with the current one. An admin reset revokes the person's live PIN
708
+ sessions and flags `mustReset`; signing in with the temporary PIN then yields a
709
+ restricted session that resolves to no permissions and can only call `setPin`,
710
+ which ends it — the person signs in again with the new PIN.
711
+
712
+ **Whose authority.** The device session authenticates the tablet; the person's
713
+ session authorizes the work. Its `permissions` are resolved on every load from
714
+ the *person's* own membership in the device tenant (role plus per-membership
715
+ overrides) — never the device account's — so a welder and a foreman on the
716
+ same tablet get different permission sets. Both identities are on the resolved
717
+ context: `user` is the person and `parent` (`event.locals.sessionParent`, and
718
+ `session.parent` on the request permission context) is the device session and
719
+ its account, so a consumer can record "this person, at this tablet".
720
+
721
+ **Device ceiling.** `deviceCeiling(device)` may return permission slugs that
722
+ cap every person on that device. A non-null result is snapshotted into the
723
+ person's session at sign-in (`data.permissionCeiling`) and intersected with
724
+ their resolved permissions on every load; it only removes slugs, and `[]`
725
+ leaves none. A ceilinged session never receives `superAdminBypass` or
726
+ `systemContext` from `withSessionPermissionContext`, and
727
+ `assertOperationPermission` / `checkOperationPermission` called inside that
728
+ session deny anything outside the ceiling. Returning `null` (or
729
+ omitting the hook) means no ceiling. Because it is a snapshot, **a ceiling
730
+ change applies at each person's next sign-in**; a throwing hook refuses the
731
+ sign-in.
732
+
733
+ **Switching people.** `signOut` ends only the person's session. With
734
+ `singleOccupant` (default `true`) a successful sign-in also ends every other
735
+ person's session on that device session, so signing in as the next person is
736
+ the hand-over; a failed sign-in leaves the current person signed in. (Two
737
+ sign-ins that overlap on one device can end each other — both are refused and
738
+ the person signs in again.) Set
739
+ `singleOccupant: false` for devices several people stay signed in on.
740
+ `personIdleSeconds` is a sliding idle timeout stored on the session
741
+ (`data.idleSeconds`), so it slides by that value whichever `SessionService`
742
+ resolves the bearer and is independent of the device session's long life.
743
+ `personMaxSeconds` adds an absolute lifetime (`data.absoluteExpiresAt`) that
744
+ activity cannot extend. `permissionCeiling`, `idleSeconds`, and
745
+ `absoluteExpiresAt` are reserved `Session.data` keys (`SESSION_DATA_KEYS`):
746
+ server-set at mint, never to be written from client input.
747
+
748
+ **Operational contracts.** `parent.sessionId` is the device's bearer
749
+ credential: keep `SessionContext.parent` / `locals.sessionParent` server-side
750
+ and give clients `parent.userId` as the device identity
751
+ (`Session.parentSessionId` is a sensitive field and never appears in public
752
+ serialization). Resolve person bearers with `service.loadPersonSession(token)`:
753
+ it re-checks `assertEnrolledDevice` on every call and revokes the person
754
+ session of a device that fails it (the PIN management handlers resolve person
755
+ bearers the same way). A host that resolves them through its own
756
+ `SessionService` must instead revoke the device's bearer session when it
757
+ un-enrols the device. `assertEnrolledDevice` returning `false` is un-enrolment
758
+ (the person session is revoked); a throw only refuses that request. The PIN is
759
+ one per person across tenants, so PIN administration (`users.pin.manage`)
760
+ reaches only people whose every active membership is in the administrator's
761
+ session tenant; people who belong to several tenants manage their own PIN from
762
+ a first-class session. `users.pin.manage` is impersonation-equivalent on
763
+ enrolled devices — whoever sets a person's PIN can sign in as them there, and
764
+ the membership rule is checked when the PIN is written, not when the person
765
+ later joins another tenant — so grant it like an owner-level permission; every
766
+ administrative change is audited with the actor's id. Any PIN change ends the sessions minted under the old
767
+ PIN (a person changing their own keeps the session they changed it from).
768
+
769
+ Existing installations need `smrt db:migrate` for the additive
770
+ `sessions.auth_method` / `sessions.parent_session_id` columns and the
771
+ `users_login_attempts`, `users_login_audit_events`, and
772
+ `users_pin_credentials` tables.
773
+
774
+ Other credential kinds (a fob or badge reader, say) implement
775
+ `DeviceCredentialVerifier` and go through `service.signIn(verifier, input)`;
776
+ `PinVerifier` is the reference implementation.
777
+
601
778
  ### Request-scoped database access
602
779
 
603
780
  Generated SvelteKit helpers and custom server code can read the current
@@ -831,7 +1008,9 @@ TenantService supports three modes: `flexible` (no auto-create), `personal` (aut
831
1008
  | `Tenant` | Organizational boundary. STI. Hierarchical via `parentTenantId`/`hierarchyPath`. |
832
1009
  | `Role` | Permission template. `tenantId = null` for system roles. `isSystem` blocks deletion. |
833
1010
  | `Permission` | Named capability. Slug format: `resource.action`. |
834
- | `Session` | Server-side session. Secure UUID. TTL in seconds. |
1011
+ | `Session` | Server-side session. Secure UUID. TTL in seconds. `authMethod` records the channel; `parentSessionId` makes it a layered session valid only while its parent is. Reserved `data` keys (`SESSION_DATA_KEYS`) carry a permission ceiling, sliding idle timeout, and absolute expiry. |
1012
+ | `UsersLoginAttempt`, `UsersLoginAuditEvent` | Hashed-key login budget rows and durable sign-in audit events (#3273). Closed generated surface. |
1013
+ | `UsersPinCredential` | Per-person scrypt PIN hash for enrolled-device sign-in (#3276). Closed generated surface. |
835
1014
  | `Group` | Team within a tenant. Gains permissions via GroupRole. |
836
1015
  | `Membership` | User + Tenant + Role junction. UNIQUE(userId, tenantId). |
837
1016
  | `MembershipOverride` | Per-user permission grant/deny on a membership. |
@@ -849,6 +1028,7 @@ TenantService supports three modes: `flexible` (no auto-create), `personal` (aut
849
1028
  | `MembershipOverrideCollection`, `TenantPermissionOverrideCollection` | Override management at membership and tenant levels |
850
1029
  | `GroupCollection`, `GroupMemberCollection`, `GroupRoleCollection`, `RolePermissionCollection` | Group and role-permission junction management |
851
1030
  | `AccessRequestCollection` | AccessRequest queries: `findByEmail()`, `findOpenByEmail()`, `findByStatus()`, `findOpen()` |
1031
+ | `UsersLoginAttemptCollection`, `UsersLoginAuditEventCollection`, `UsersPinCredentialCollection` | Atomic limiter primitives, audit storage, and PIN credential rows behind the services below |
852
1032
 
853
1033
  ### Services
854
1034
 
@@ -859,7 +1039,9 @@ TenantService supports three modes: `flexible` (no auto-create), `personal` (aut
859
1039
  | `PermissionCatalogService`, `syncPermissionCatalog()` | Discovers manifest/config/runtime permissions and upserts them into `Permission` rows. |
860
1040
  | `registerPermissionDefinitions()` | Register app or integration permissions at runtime and receive an unregister cleanup function. |
861
1041
  | `generatePostgresPermissionSql()`, `applyPostgresPermissionPolicies()` | Preview or apply Postgres RLS helper functions and table policies. |
862
- | `SessionService` | High-level session management. `createSession()`, `loadSessionContext()`, `destroySession()`; tenant contexts include direct or inherited membership provenance. |
1042
+ | `SessionService` | High-level session management. `createSession()`, `loadSessionContext()`, `destroySession()`; tenant contexts include direct or inherited membership provenance, `authMethod`, and `parent` for layered sessions. |
1043
+ | `LoginAttemptLimiter` | Shared sign-in budget with exponential lockout and audit (#3273): `reserve()` → lease `.fail()`/`.succeed()`/`.release()`. `LoginRateLimitError`, `InvalidCredentialsError`, `LoginAuditSink`, `DurableLoginAuditSink`. |
1044
+ | `DeviceCredentialService` | Per-person sign-in layered on an enrolled device session (#3276): `signInWithPin()`, `signIn(verifier, input)`, `signOut()`, `setPin()`, `resetPin()`, `clearPin()`. Person authority with optional `deviceCeiling`, `singleOccupant` hand-over, `personIdleSeconds` / `personMaxSeconds`. `PinVerifier`, `DeviceCredentialVerifier`. |
863
1045
  | `OidcLoginService` | Generic OIDC authorization-code login with PKCE for Kanidm, Dex, and other standards-compliant providers. |
864
1046
  | `backfillLegacyUserProfiles` | Transactionally create and link canonical global Person Profiles for legacy Users; never creates OIDC identities or infers ownership. |
865
1047
  | `backfillUserEmailKeys` | Idempotently populate durable normalized-email keys after migrating legacy Users; fails closed on duplicates. |
@@ -884,6 +1066,7 @@ TenantService supports three modes: `flexible` (no auto-create), `personal` (aut
884
1066
  | `beginOidcLogin`, `completeOidcLogin` | Low-level SvelteKit helpers for custom OIDC login routes |
885
1067
  | `createOidcLoginHandler`, `createOidcCallbackHandler` | Ready-to-use SvelteKit route handlers for OIDC login and callback |
886
1068
  | `createMobileAuthHandlers` | Mountable `/api/mobile` PKCE, bearer session, bootstrap, logout, and route-guard handlers |
1069
+ | `createDeviceCredentialHandlers` | Mountable PIN sign-in, sign-out, and PIN management handlers for enrolled devices |
887
1070
  | `resolveMobileUploadDedupKey` | Resolves `clientCaptureId` with `Idempotency-Key` fallback for app-owned multipart routes |
888
1071
  | `SessionLocals` | Type for `event.locals` (extend in `app.d.ts`) |
889
1072
 
@@ -904,6 +1087,8 @@ for authentication, deduplication, and status semantics.
904
1087
  | `ACCESS_REQUEST_CAPABILITIES`, `AccessRequestError` | Operator capability slugs; typed domain error (`error.code`) |
905
1088
  | `DEFAULT_ROLE_SLUGS`, `DEFAULT_ROLES`, `DEFAULT_TENANT_POLICY` | System role slugs, role configs, default tenant policy |
906
1089
  | `DEFAULT_SESSION_TTL`, `MAX_TENANT_HIERARCHY_DEPTH` | 604800 (7 days in seconds), 10 |
1090
+ | `DEFAULT_LOGIN_MAX_ATTEMPTS`, `DEFAULT_LOGIN_ATTEMPT_WINDOW_SECONDS`, `DEFAULT_LOGIN_LOCKOUT_*` | 5 attempts per 300 s window; lockout 60 s × 2ⁿ, capped at 3600 s |
1091
+ | `DEFAULT_PIN_MANAGE_PERMISSION`, `PIN_LOGIN_KIND` | `users.pin.manage`, `pin` |
907
1092
  | `TenantHierarchyError` | Thrown on a missing parent, a cycle, or exceeding the hierarchy depth limit (`code`) |
908
1093
  | `TenantHierarchyMaterializationError` | Thrown by `materializeTenantHierarchy` when any tenant's parent chain is broken; lists `problems` |
909
1094
 
@@ -918,3 +1103,43 @@ for authentication, deduplication, and status semantics.
918
1103
  ## License
919
1104
 
920
1105
  MIT
1106
+
1107
+ ### Upgrade duplicate role grants (#3329)
1108
+
1109
+ `RolePermission` now has a unique natural key `(role_id, permission_id)`.
1110
+ Concurrent seeders converge through core's conflict-tolerant writes, preserving
1111
+ one grant ID per pair. Permissions already use `(slug, context)` and system
1112
+ roles use the tenant-aware natural key. System-role bootstrap holds a
1113
+ PostgreSQL transaction advisory lock across its read/create sequence so two
1114
+ seeders also return the same global (`tenant_id = NULL`) role IDs. The concurrent cold-seed test
1115
+ checks all three catalogs. Seeding still uses bounded batches and is additive
1116
+ unless `prune: true`; concurrent passes must use the same catalog/matrix.
1117
+
1118
+ Existing deployments need a maintenance window **before ordinary schema
1119
+ migration**: adding the new unique index directly fails if duplicates exist.
1120
+ Back up the database, stop every application writer and bootstrap/seed process,
1121
+ and run the following once with the new package from an operator process:
1122
+
1123
+ ```typescript
1124
+ import { getDatabase } from '@happyvertical/sql';
1125
+ import { deduplicateRolePermissions } from '@happyvertical/smrt-users';
1126
+
1127
+ const db = await getDatabase(databaseConfig);
1128
+ console.log(await deduplicateRolePermissions(db, { dryRun: true }));
1129
+ console.log(await deduplicateRolePermissions(db, {
1130
+ maintenanceConfirmed: true,
1131
+ }));
1132
+ ```
1133
+
1134
+ The migration keeps the earliest `created_at` per pair (lowest `id` breaks ties;
1135
+ null timestamps sort last), deletes only extra grants, and creates
1136
+ `role_permissions_role_id_permission_id_idx` in the same transaction. Other
1137
+ pairs and surviving grant data remain intact. Failure rolls the transaction
1138
+ back; after resolving its cause, rerun the migration. Dry runs change nothing.
1139
+ The helper requires transaction support and supports SQLite, DuckDB and
1140
+ PostgreSQL; PostgreSQL locks the grant table while repairing it. Writer shutdown
1141
+ is still required because older application versions cannot seed safely
1142
+ against the new constraint. Apply the remaining application schema migrations,
1143
+ run `smrt doctor --db` / `db:status --parity`, deploy the new version to every
1144
+ writer, then resume traffic. Do not roll back application writers without also
1145
+ restoring the pre-upgrade schema/database backup.
@@ -93,7 +93,11 @@ membership or tenant DENY to attenuate inherited role authority.
93
93
  seedSystemRoles({ seedPermissions: true }) maps owner/admin to all catalog
94
94
  permissions, member to ordinary-resource read/create, viewer to read. Member
95
95
  create excludes identity/RBAC/security resources and their joins/overrides.
96
- Seeding is additive/idempotent; removal requires prune: true.
96
+ Seeding is additive/idempotent; removal requires prune: true. Catalog sync
97
+ and grants plan from one read, then commit in `SEEDING_BATCH_SIZE` batches
98
+ (#3323): never one durable commit per row (an fsync each on local SQLite),
99
+ and never one catalog-wide transaction (`@happyvertical/sql` transaction
100
+ scopes cost superlinearly per statement). Pass a caller transaction's handle.
97
101
  - When a package adds built-in self-personalization permissions after role
98
102
  creation, explicitly call seedDefaultRolePersonalizationPermissions(). It
99
103
  upgrades owner/admin/member/viewer idempotently and never grants custom roles.
@@ -184,3 +188,10 @@ access once principal context is set. Child-tenant sessions receive resolved
184
188
  inherited permissions automatically, but RLS row filtering remains bound to the
185
189
  session tenant: a root session's app-level guard authorization does not itself
186
190
  permit child rows through RLS. Preserve this distinction.
191
+
192
+ RolePermission's natural key is `(role_id, permission_id)` (#3329); repeated or
193
+ concurrent inserts converge without changing the grant ID. Existing deployments
194
+ must run `deduplicateRolePermissions()` with stopped writers before ordinary
195
+ schema migration; see README's "Upgrade duplicate role grants". It keeps the
196
+ oldest row and installs uniqueness atomically. Runtime seeding never performs
197
+ this schema repair.
@@ -24,15 +24,22 @@ reap them.
24
24
  optionally for exactly that reason. Registering is not scheduling: nothing is
25
25
  deleted until something runs a sweep.
26
26
  - **Task names** are `users-sessions`, `users-magic-link-tokens`,
27
- `users-cli-auth-requests` — prefixed with the package's short name because
28
- the retention registry is one process-global namespace. Opt one out with
27
+ `users-cli-auth-requests`, `users-login-attempts`, `users-login-audit-events`
28
+ — prefixed with the package's short name because the retention registry is
29
+ one process-global namespace. Opt one out with
29
30
  `runRetentionSweep(db, { tasks: { 'users-sessions': false } })` or
30
31
  `smrt db:prune --skip users-sessions`; `unregisterUserRetentionTasks()`
31
- removes all three.
32
- - **There is no retention window to configure.** An expired credential has
33
- nothing worth retaining, so each task deletes only already-expired rows. An
34
- application that keeps expired sessions for audit should opt the task out and
35
- archive them itself.
32
+ removes all of them.
33
+ - **The credential tasks have no retention window to configure.** An expired
34
+ credential has nothing worth retaining, so each deletes only already-expired
35
+ rows. An application that keeps expired sessions for audit should opt the
36
+ task out and archive them itself. The two #3273 tasks differ: limiter rows
37
+ are pruned once past their own `retain_until` — written by the limiter from
38
+ its configured window and streak-forgiveness horizon, so a custom-configured
39
+ limiter is never swept early — and never while a lockout is live; audit
40
+ events are pruned after
41
+ `DEFAULT_LOGIN_AUDIT_RETENTION_DAYS` (90). A host that needs longer audit
42
+ retention opts `users-login-audit-events` out and archives.
36
43
  - **All three are a single counted DELETE**, not a hydrate-and-delete loop.
37
44
  They now run unattended on a timer, and a per-row delete that throws part-way
38
45
  leaves the rest of the expired rows un-reaped — the #1400 reasoning that