@happyvertical/smrt-users 0.55.23 → 0.55.24

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 (42) hide show
  1. package/AGENTS.md +8 -0
  2. package/README.md +129 -0
  3. package/agents/permissions.md +8 -0
  4. package/dist/chunks/{TerminalAuthService-C8zxBkNB.js → TerminalAuthService-B0icE2mR.js} +282 -116
  5. package/dist/chunks/TerminalAuthService-B0icE2mR.js.map +1 -0
  6. package/dist/collections/GroupMemberCollection.d.ts +6 -2
  7. package/dist/collections/GroupMemberCollection.d.ts.map +1 -1
  8. package/dist/collections/MembershipOverrideCollection.d.ts +9 -2
  9. package/dist/collections/MembershipOverrideCollection.d.ts.map +1 -1
  10. package/dist/collections/TenantPermissionOverrideCollection.d.ts +8 -2
  11. package/dist/collections/TenantPermissionOverrideCollection.d.ts.map +1 -1
  12. package/dist/index.d.ts +2 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +84 -29
  15. package/dist/index.js.map +1 -1
  16. package/dist/manifest.json +75 -45
  17. package/dist/migrations/deduplicateAuthorityPairs.d.ts +27 -0
  18. package/dist/migrations/deduplicateAuthorityPairs.d.ts.map +1 -0
  19. package/dist/migrations/deduplicateRolePermissions.d.ts +3 -10
  20. package/dist/migrations/deduplicateRolePermissions.d.ts.map +1 -1
  21. package/dist/migrations/natural-key-dedup.d.ts +32 -0
  22. package/dist/migrations/natural-key-dedup.d.ts.map +1 -0
  23. package/dist/models/GroupMember.d.ts.map +1 -1
  24. package/dist/models/LoginAuditEvent.d.ts +5 -0
  25. package/dist/models/LoginAuditEvent.d.ts.map +1 -1
  26. package/dist/models/MembershipOverride.d.ts.map +1 -1
  27. package/dist/models/Session.d.ts +9 -0
  28. package/dist/models/Session.d.ts.map +1 -1
  29. package/dist/models/TenantPermissionOverride.d.ts.map +1 -1
  30. package/dist/services/LoginAttemptLimiter.d.ts +62 -11
  31. package/dist/services/LoginAttemptLimiter.d.ts.map +1 -1
  32. package/dist/services/PasswordCredentialService.d.ts +83 -1
  33. package/dist/services/PasswordCredentialService.d.ts.map +1 -1
  34. package/dist/services/index.d.ts +2 -2
  35. package/dist/services/index.d.ts.map +1 -1
  36. package/dist/smrt-knowledge.json +16 -9
  37. package/dist/sveltekit/password-handlers.d.ts +7 -0
  38. package/dist/sveltekit/password-handlers.d.ts.map +1 -1
  39. package/dist/sveltekit.js +13 -1
  40. package/dist/sveltekit.js.map +1 -1
  41. package/package.json +13 -13
  42. package/dist/chunks/TerminalAuthService-C8zxBkNB.js.map +0 -1
package/AGENTS.md CHANGED
@@ -78,6 +78,9 @@ are not prerequisites for unrelated user-package work.
78
78
  them on failure, release on success or abort, never skip the reserve.
79
79
  Limiter writes are raw SQL and bypass the change feed; the audit sink is the
80
80
  observability seam, and it must never throw into the sign-in path.
81
+ Subject and source budgets are sized independently (#3531); `source: false`
82
+ ignores the source key, so a reservation then needs a subject (fail closed).
83
+ The `source-locked` audit entry never carries a subject or caller metadata.
81
84
  - `DeviceCredentialService` accepts a person credential only from a
82
85
  first-class session whose `authMethod` is a device-enrollment method
83
86
  (`terminal` by default) and that the host's `assertEnrolledDevice` confirms.
@@ -109,6 +112,11 @@ are not prerequisites for unrelated user-package work.
109
112
  set; `users.password.manage` follows the PIN's single-tenant target
110
113
  rule and never resets the actor's own. Layered sessions never manage
111
114
  passwords; a `mustChange` sign-in mints an empty ceiling.
115
+ `replaceForgottenPassword` (#3905, opt-in `recovery`) is the only
116
+ self-service write without the current password: it decides on the stored
117
+ session row (owner, first-class, no ceiling, listed non-password/pin
118
+ `authMethod`, fresh `outOfBandProofAt` stamp) and ends every session,
119
+ its own included. Never set that stamp from client input.
112
120
  - A layered session whose tenant differs from its parent's never loads (null
113
121
  is allowed: narrower). Limiter rows carry `retain_until`; retention must
114
122
  not fall back to a process-wide horizon.
package/README.md CHANGED
@@ -749,6 +749,48 @@ limiter for approvals, keyed on the approving user only under its own subject
749
749
  prefix, so that budget never pools with the same person's PIN or password
750
750
  budget; pass `loginLimiter` to share one instance and audit sink.
751
751
 
752
+ #### Separate account and address budgets (#3531)
753
+
754
+ By default both budgets use `maxAttempts`, `windowSeconds` and `lockout`.
755
+ Size them independently with `subject` and `source` (each field falls back to
756
+ the top-level option), or turn the address budget off with `source: false`:
757
+
758
+ ```ts
759
+ const limiter = await LoginAttemptLimiter.create({
760
+ db,
761
+ subject: { maxFailures: 5, lockout: { baseSeconds: 60, maxSeconds: 3600 } },
762
+ source: { maxFailures: 100, windowSeconds: 300, lockout: false },
763
+ // or: source: false,
764
+ });
765
+ ```
766
+
767
+ When a failure starts a **source** lockout the audit sink also receives one
768
+ `source-locked` entry carrying the source hash and `retryAfterSeconds` but no
769
+ subject hash and none of the caller's metadata, so it attributes an outage to
770
+ an address or device without naming the accounts that were tried. It is
771
+ reported at most once per source per `sourceLockoutAuditSeconds` (default 300)
772
+ per limiter instance. Refusals are recorded as `denied` entries with the
773
+ refusing `scope` in their metadata.
774
+
775
+ **Deployments without a trustworthy client address.** If the app sits behind
776
+ a reverse proxy that does not forward a client address you can trust, every
777
+ browser shares one `source`, and a handful of failures from anyone — including
778
+ for emails that do not exist — locks password sign-in out for everyone.
779
+ Prefer, in order:
780
+
781
+ 1. Make the proxy set a client address you trust (and strip any the client
782
+ sent), and pass that, not a raw `X-Forwarded-For`, as `ipAddress`.
783
+ 2. Otherwise give the source budget a much larger allowance with no lockout
784
+ (`source: { maxFailures: 200, lockout: false }`), so it only caps bursts.
785
+ 3. Or disable it (`source: false`) and rate limit `/signin` at the proxy
786
+ instead. The per-account budget still applies to every attempt.
787
+
788
+ `source: false` applies to every credential kind using that limiter. A PIN
789
+ sign-in keys its source on the enrolled device, which is what stops guessing
790
+ across many people's short PINs on one tablet, so if PIN and password share an
791
+ instance, prefer option 2 or give `PasswordCredentialService` its own
792
+ `limiter` options rather than disabling the source budget for both.
793
+
752
794
  ### Per-person PIN on an enrolled device
753
795
 
754
796
  A shared tablet enrolled through the terminal device-code grant holds a
@@ -917,6 +959,7 @@ words). There are no composition rules. Optional `pepper`. Violations are
917
959
  | `changePassword` | the person, proving the current password (from the sign-in budget) | every other session of theirs |
918
960
  | `resetPassword` | an admin with `users.password.manage`, never for themself; `mustChange` defaults to true | every session of the person |
919
961
  | `clearPassword` | an admin, or the person proving the current password | the person's password sessions |
962
+ | `replaceForgottenPassword` | the person, from a fresh out-of-band recovery session (off unless `recovery` is configured) | every session of the person, this one included |
920
963
 
921
964
  Self-service writes are guarded on the exact credential the person proved
922
965
  (or, for a first password, insert-only), so a reset that lands while a change
@@ -939,6 +982,56 @@ permission. It is registered in the runtime catalog
939
982
  (`seedRolePermissions()`) grants it to `owner` and `admin` (`*`), not to
940
983
  `member` or `viewer`.
941
984
 
985
+ **Forgotten password (#3905).** A person who signed in some other way — an
986
+ owner-recovery or operator-issued link, an emailed code — can replace a
987
+ password they forgot, without the current one, through
988
+ `replaceForgottenPassword({ actor, newPassword })`. It is disabled until the
989
+ host opts in, and every condition is checked on the stored session row, not
990
+ on the caller's context:
991
+
992
+ ```ts
993
+ import {
994
+ outOfBandProofSessionData,
995
+ PasswordCredentialService,
996
+ SessionService,
997
+ } from '@happyvertical/smrt-users';
998
+
999
+ const sessions = await SessionService.create({ db });
1000
+
1001
+ const passwords = await PasswordCredentialService.create({
1002
+ db,
1003
+ recovery: {
1004
+ authMethods: ['owner-recovery'], // channels minted ONLY after an out-of-band proof
1005
+ maxAgeSeconds: 600, // default 10 minutes, at most 1 hour
1006
+ assertProof: ({ actor, provenAt }) => true, // optional extra host check
1007
+ },
1008
+ });
1009
+
1010
+ // In the recovery flow, after verifying the link or code (never from client input):
1011
+ const sessionId = await sessions.createSession(user.id, tenantId, {
1012
+ authMethod: 'owner-recovery',
1013
+ data: outOfBandProofSessionData(), // stamps SESSION_DATA_KEYS.outOfBandProofAt
1014
+ });
1015
+ ```
1016
+
1017
+ The call is refused (`PasswordCredentialForbiddenError` / 403) unless the
1018
+ session is valid and the person's own, first-class (not layered, no
1019
+ permission ceiling), has one of the listed `authMethods` (`password` and
1020
+ `pin` can never be listed), and carries a proof stamp no older than
1021
+ `maxAgeSeconds` (a stamp more than 30 seconds in the future is refused). The
1022
+ freshness bound is measured from the stamp, which a tenant switch copies to
1023
+ the rotated session, so switching tenants never makes an old proof look new.
1024
+ `assertProof`, when given, must return exactly `true`. On success the new
1025
+ password (policy-checked, `mustChange` cleared) is written guarded on the row
1026
+ read before any hashing — or inserted if the person had none — in the same
1027
+ transaction as a conditional update that consumes the recovery session, so
1028
+ of any concurrent uses of one proof exactly one succeeds and a failed write
1029
+ leaves the proof unconsumed. Every other session of the person ends before
1030
+ and after the write, and a `managed` audit entry records
1031
+ `action: 'recover'` with the channel and the proof's age. Do not list a
1032
+ channel whose sessions an ordinary sign-in can mint: whoever holds such a
1033
+ session can then choose the account's password.
1034
+
942
1035
  #### Consumer wiring (SvelteKit)
943
1036
 
944
1037
  ```ts
@@ -958,6 +1051,7 @@ export const passwordAuth = createPasswordCredentialHandlers({
958
1051
  // POST /account/password → passwordAuth.setPassword ({ password } or admin { userId, password })
959
1052
  // DELETE /account/password → passwordAuth.clearPassword ({ currentPassword } or admin { userId })
960
1053
  // POST /admin/users/password → passwordAuth.resetPassword ({ userId, password, mustChange? })
1054
+ // POST /account/password/recover → passwordAuth.replaceForgottenPassword ({ newPassword }; needs `recovery`)
961
1055
  // sign out → destroySessionCookie(event, { db })
962
1056
  ```
963
1057
 
@@ -1356,6 +1450,41 @@ run `smrt doctor --db` / `db:status --parity`, deploy the new version to every
1356
1450
  writer, then resume traffic. Do not roll back application writers without also
1357
1451
  restoring the pre-upgrade schema/database backup.
1358
1452
 
1453
+ ### Upgrade duplicate overrides and group members (#3375)
1454
+
1455
+ `MembershipOverride` is unique on `(membership_id, permission_id)`,
1456
+ `TenantPermissionOverride` on `(tenant_id, permission_id)` and `GroupMember` on
1457
+ `(group_id, user_id)`. `setOverride()` (and the grant/deny/inherit helpers) is
1458
+ an upsert on that key: concurrent calls leave one row, the last effect written
1459
+ wins, and an existing override keeps its id. `addMember()` is idempotent.
1460
+ `removeOverride()` / `removeMember()` delete every row for the pair. Their
1461
+ `(slug, context)` index is no longer unique, and the single-column
1462
+ `membership_id` / `group_id` indexes are replaced by the composite ones.
1463
+
1464
+ Existing deployments upgrade exactly like the role-grant upgrade above, in the
1465
+ same maintenance window and **before ordinary schema migration**:
1466
+
1467
+ ```typescript
1468
+ import { deduplicateAuthorityPairs } from '@happyvertical/smrt-users';
1469
+
1470
+ console.log(await deduplicateAuthorityPairs(db, { dryRun: true }));
1471
+ console.log(await deduplicateAuthorityPairs(db, {
1472
+ maintenanceConfirmed: true,
1473
+ }));
1474
+ ```
1475
+
1476
+ For an override pair it keeps the row whose effect the resolver already
1477
+ applies — DENY, then GRANT, then INHERIT — so no pair's effective authority
1478
+ changes; ties, and group members, keep the earliest `created_at` (lowest `id`
1479
+ breaks ties; null timestamps sort last). It creates
1480
+ `membership_overrides_membership_id_permission_id_idx`,
1481
+ `tenant_permission_overrides_tenant_id_permission_id_idx` and
1482
+ `group_members_group_id_user_id_idx` in the same transaction as the deletes,
1483
+ locks the three tables on PostgreSQL, and is safe to rerun. Until it has run,
1484
+ new-version override and group-member writes fail (there is no unique index for
1485
+ their upsert to target), so writers stay stopped until it and the schema
1486
+ migration complete.
1487
+
1359
1488
  ## Hosted OAuth authorization
1360
1489
 
1361
1490
  Use `SmrtOAuthAuthorizationStorage` with the SDK authorization server, and
@@ -198,3 +198,11 @@ must run `deduplicateRolePermissions()` with stopped writers before ordinary
198
198
  schema migration; see README's "Upgrade duplicate role grants". It keeps the
199
199
  oldest row and installs uniqueness atomically. Runtime seeding never performs
200
200
  this schema repair.
201
+
202
+ MembershipOverride `(membership_id, permission_id)`, TenantPermissionOverride
203
+ `(tenant_id, permission_id)` and GroupMember `(group_id, user_id)` are natural
204
+ keys too (#3375): `setOverride()` upserts (last effect wins), removal deletes by
205
+ the pair, and their FKs declare `onDelete: 'NO ACTION'` so the natural key does
206
+ not turn them into CASCADE. Existing deployments run
207
+ `deduplicateAuthorityPairs()` in the same stopped-writer window; it keeps the
208
+ effective override (DENY > GRANT > INHERIT) so authority never widens.