@happyvertical/smrt-users 0.53.3 → 0.54.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.
Files changed (61) hide show
  1. package/AGENTS.md +43 -2
  2. package/README.md +185 -2
  3. package/agents/retention.md +14 -7
  4. package/dist/chunks/{TerminalAuthService-DoadwiqV.js → TerminalAuthService-BfxG493c.js} +3373 -2121
  5. package/dist/chunks/TerminalAuthService-BfxG493c.js.map +1 -0
  6. package/dist/collections/CliAuthApproveLimitCollection.d.ts +4 -0
  7. package/dist/collections/CliAuthApproveLimitCollection.d.ts.map +1 -1
  8. package/dist/collections/CliAuthRequestCollection.d.ts.map +1 -1
  9. package/dist/collections/LoginAttemptCollection.d.ts +105 -0
  10. package/dist/collections/LoginAttemptCollection.d.ts.map +1 -0
  11. package/dist/collections/LoginAuditEventCollection.d.ts +19 -0
  12. package/dist/collections/LoginAuditEventCollection.d.ts.map +1 -0
  13. package/dist/collections/PinCredentialCollection.d.ts +26 -0
  14. package/dist/collections/PinCredentialCollection.d.ts.map +1 -0
  15. package/dist/collections/SessionCollection.d.ts +33 -1
  16. package/dist/collections/SessionCollection.d.ts.map +1 -1
  17. package/dist/collections/index.d.ts +3 -0
  18. package/dist/collections/index.d.ts.map +1 -1
  19. package/dist/index.d.ts +4 -4
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +144 -31
  22. package/dist/index.js.map +1 -1
  23. package/dist/manifest.json +3245 -1990
  24. package/dist/models/CliAuthApproveLimit.d.ts +8 -1
  25. package/dist/models/CliAuthApproveLimit.d.ts.map +1 -1
  26. package/dist/models/LoginAttempt.d.ts +59 -0
  27. package/dist/models/LoginAttempt.d.ts.map +1 -0
  28. package/dist/models/LoginAuditEvent.d.ts +49 -0
  29. package/dist/models/LoginAuditEvent.d.ts.map +1 -0
  30. package/dist/models/PinCredential.d.ts +31 -0
  31. package/dist/models/PinCredential.d.ts.map +1 -0
  32. package/dist/models/Session.d.ts +69 -1
  33. package/dist/models/Session.d.ts.map +1 -1
  34. package/dist/models/index.d.ts +4 -1
  35. package/dist/models/index.d.ts.map +1 -1
  36. package/dist/retention.d.ts +7 -1
  37. package/dist/retention.d.ts.map +1 -1
  38. package/dist/services/DeviceCredentialService.d.ts +348 -0
  39. package/dist/services/DeviceCredentialService.d.ts.map +1 -0
  40. package/dist/services/LoginAttemptLimiter.d.ts +227 -0
  41. package/dist/services/LoginAttemptLimiter.d.ts.map +1 -0
  42. package/dist/services/MobileAuthService.d.ts.map +1 -1
  43. package/dist/services/OperationPermissionService.d.ts.map +1 -1
  44. package/dist/services/SessionPermissionContext.d.ts.map +1 -1
  45. package/dist/services/SessionService.d.ts +56 -1
  46. package/dist/services/SessionService.d.ts.map +1 -1
  47. package/dist/services/TerminalAuthService.d.ts +18 -3
  48. package/dist/services/TerminalAuthService.d.ts.map +1 -1
  49. package/dist/services/index.d.ts +4 -2
  50. package/dist/services/index.d.ts.map +1 -1
  51. package/dist/smrt-knowledge.json +679 -9
  52. package/dist/sveltekit/device-credential-handlers.d.ts +60 -0
  53. package/dist/sveltekit/device-credential-handlers.d.ts.map +1 -0
  54. package/dist/sveltekit/index.d.ts +10 -0
  55. package/dist/sveltekit/index.d.ts.map +1 -1
  56. package/dist/sveltekit/types.d.ts +6 -0
  57. package/dist/sveltekit/types.d.ts.map +1 -1
  58. package/dist/sveltekit.js +184 -4
  59. package/dist/sveltekit.js.map +1 -1
  60. package/package.json +12 -12
  61. package/dist/chunks/TerminalAuthService-DoadwiqV.js.map +0 -1
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
@@ -600,6 +600,181 @@ With `enterTenantContext: true`, the same request also enters
600
600
  `@happyvertical/smrt-tenancy` context so regular collection access is scoped to
601
601
  the current tenant in application code.
602
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
+
603
778
  ### Request-scoped database access
604
779
 
605
780
  Generated SvelteKit helpers and custom server code can read the current
@@ -833,7 +1008,9 @@ TenantService supports three modes: `flexible` (no auto-create), `personal` (aut
833
1008
  | `Tenant` | Organizational boundary. STI. Hierarchical via `parentTenantId`/`hierarchyPath`. |
834
1009
  | `Role` | Permission template. `tenantId = null` for system roles. `isSystem` blocks deletion. |
835
1010
  | `Permission` | Named capability. Slug format: `resource.action`. |
836
- | `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. |
837
1014
  | `Group` | Team within a tenant. Gains permissions via GroupRole. |
838
1015
  | `Membership` | User + Tenant + Role junction. UNIQUE(userId, tenantId). |
839
1016
  | `MembershipOverride` | Per-user permission grant/deny on a membership. |
@@ -851,6 +1028,7 @@ TenantService supports three modes: `flexible` (no auto-create), `personal` (aut
851
1028
  | `MembershipOverrideCollection`, `TenantPermissionOverrideCollection` | Override management at membership and tenant levels |
852
1029
  | `GroupCollection`, `GroupMemberCollection`, `GroupRoleCollection`, `RolePermissionCollection` | Group and role-permission junction management |
853
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 |
854
1032
 
855
1033
  ### Services
856
1034
 
@@ -861,7 +1039,9 @@ TenantService supports three modes: `flexible` (no auto-create), `personal` (aut
861
1039
  | `PermissionCatalogService`, `syncPermissionCatalog()` | Discovers manifest/config/runtime permissions and upserts them into `Permission` rows. |
862
1040
  | `registerPermissionDefinitions()` | Register app or integration permissions at runtime and receive an unregister cleanup function. |
863
1041
  | `generatePostgresPermissionSql()`, `applyPostgresPermissionPolicies()` | Preview or apply Postgres RLS helper functions and table policies. |
864
- | `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`. |
865
1045
  | `OidcLoginService` | Generic OIDC authorization-code login with PKCE for Kanidm, Dex, and other standards-compliant providers. |
866
1046
  | `backfillLegacyUserProfiles` | Transactionally create and link canonical global Person Profiles for legacy Users; never creates OIDC identities or infers ownership. |
867
1047
  | `backfillUserEmailKeys` | Idempotently populate durable normalized-email keys after migrating legacy Users; fails closed on duplicates. |
@@ -886,6 +1066,7 @@ TenantService supports three modes: `flexible` (no auto-create), `personal` (aut
886
1066
  | `beginOidcLogin`, `completeOidcLogin` | Low-level SvelteKit helpers for custom OIDC login routes |
887
1067
  | `createOidcLoginHandler`, `createOidcCallbackHandler` | Ready-to-use SvelteKit route handlers for OIDC login and callback |
888
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 |
889
1070
  | `resolveMobileUploadDedupKey` | Resolves `clientCaptureId` with `Idempotency-Key` fallback for app-owned multipart routes |
890
1071
  | `SessionLocals` | Type for `event.locals` (extend in `app.d.ts`) |
891
1072
 
@@ -906,6 +1087,8 @@ for authentication, deduplication, and status semantics.
906
1087
  | `ACCESS_REQUEST_CAPABILITIES`, `AccessRequestError` | Operator capability slugs; typed domain error (`error.code`) |
907
1088
  | `DEFAULT_ROLE_SLUGS`, `DEFAULT_ROLES`, `DEFAULT_TENANT_POLICY` | System role slugs, role configs, default tenant policy |
908
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` |
909
1092
  | `TenantHierarchyError` | Thrown on a missing parent, a cycle, or exceeding the hierarchy depth limit (`code`) |
910
1093
  | `TenantHierarchyMaterializationError` | Thrown by `materializeTenantHierarchy` when any tenant's parent chain is broken; lists `problems` |
911
1094
 
@@ -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