@happyvertical/smrt-users 0.55.22 → 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.
- package/AGENTS.md +8 -0
- package/README.md +129 -0
- package/agents/permissions.md +8 -0
- package/dist/chunks/{TerminalAuthService-BHqR0fQH.js → TerminalAuthService-B0icE2mR.js} +282 -116
- package/dist/chunks/TerminalAuthService-B0icE2mR.js.map +1 -0
- package/dist/collections/GroupMemberCollection.d.ts +6 -2
- package/dist/collections/GroupMemberCollection.d.ts.map +1 -1
- package/dist/collections/MembershipOverrideCollection.d.ts +9 -2
- package/dist/collections/MembershipOverrideCollection.d.ts.map +1 -1
- package/dist/collections/TenantPermissionOverrideCollection.d.ts +8 -2
- package/dist/collections/TenantPermissionOverrideCollection.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +84 -29
- package/dist/index.js.map +1 -1
- package/dist/manifest.json +75 -45
- package/dist/migrations/deduplicateAuthorityPairs.d.ts +27 -0
- package/dist/migrations/deduplicateAuthorityPairs.d.ts.map +1 -0
- package/dist/migrations/deduplicateRolePermissions.d.ts +3 -10
- package/dist/migrations/deduplicateRolePermissions.d.ts.map +1 -1
- package/dist/migrations/natural-key-dedup.d.ts +32 -0
- package/dist/migrations/natural-key-dedup.d.ts.map +1 -0
- package/dist/models/GroupMember.d.ts.map +1 -1
- package/dist/models/LoginAuditEvent.d.ts +5 -0
- package/dist/models/LoginAuditEvent.d.ts.map +1 -1
- package/dist/models/MembershipOverride.d.ts.map +1 -1
- package/dist/models/Session.d.ts +9 -0
- package/dist/models/Session.d.ts.map +1 -1
- package/dist/models/TenantPermissionOverride.d.ts.map +1 -1
- package/dist/services/LoginAttemptLimiter.d.ts +62 -11
- package/dist/services/LoginAttemptLimiter.d.ts.map +1 -1
- package/dist/services/PasswordCredentialService.d.ts +83 -1
- package/dist/services/PasswordCredentialService.d.ts.map +1 -1
- package/dist/services/index.d.ts +2 -2
- package/dist/services/index.d.ts.map +1 -1
- package/dist/smrt-knowledge.json +16 -9
- package/dist/sveltekit/password-handlers.d.ts +7 -0
- package/dist/sveltekit/password-handlers.d.ts.map +1 -1
- package/dist/sveltekit.js +13 -1
- package/dist/sveltekit.js.map +1 -1
- package/package.json +13 -13
- package/dist/chunks/TerminalAuthService-BHqR0fQH.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
|
package/agents/permissions.md
CHANGED
|
@@ -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.
|