@happyvertical/smrt-users 0.53.3 → 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.
- package/AGENTS.md +43 -2
- package/README.md +185 -2
- package/agents/retention.md +14 -7
- package/dist/chunks/{TerminalAuthService-DoadwiqV.js → TerminalAuthService-BdB_0vDA.js} +3373 -2121
- package/dist/chunks/TerminalAuthService-BdB_0vDA.js.map +1 -0
- package/dist/collections/CliAuthApproveLimitCollection.d.ts +4 -0
- package/dist/collections/CliAuthApproveLimitCollection.d.ts.map +1 -1
- package/dist/collections/CliAuthRequestCollection.d.ts.map +1 -1
- package/dist/collections/LoginAttemptCollection.d.ts +105 -0
- package/dist/collections/LoginAttemptCollection.d.ts.map +1 -0
- package/dist/collections/LoginAuditEventCollection.d.ts +19 -0
- package/dist/collections/LoginAuditEventCollection.d.ts.map +1 -0
- package/dist/collections/PinCredentialCollection.d.ts +26 -0
- package/dist/collections/PinCredentialCollection.d.ts.map +1 -0
- package/dist/collections/SessionCollection.d.ts +33 -1
- package/dist/collections/SessionCollection.d.ts.map +1 -1
- package/dist/collections/index.d.ts +3 -0
- package/dist/collections/index.d.ts.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +144 -31
- package/dist/index.js.map +1 -1
- package/dist/manifest.json +3245 -1990
- package/dist/models/CliAuthApproveLimit.d.ts +8 -1
- package/dist/models/CliAuthApproveLimit.d.ts.map +1 -1
- package/dist/models/LoginAttempt.d.ts +59 -0
- package/dist/models/LoginAttempt.d.ts.map +1 -0
- package/dist/models/LoginAuditEvent.d.ts +49 -0
- package/dist/models/LoginAuditEvent.d.ts.map +1 -0
- package/dist/models/PinCredential.d.ts +31 -0
- package/dist/models/PinCredential.d.ts.map +1 -0
- package/dist/models/Session.d.ts +69 -1
- package/dist/models/Session.d.ts.map +1 -1
- package/dist/models/index.d.ts +4 -1
- package/dist/models/index.d.ts.map +1 -1
- package/dist/retention.d.ts +7 -1
- package/dist/retention.d.ts.map +1 -1
- package/dist/services/DeviceCredentialService.d.ts +348 -0
- package/dist/services/DeviceCredentialService.d.ts.map +1 -0
- package/dist/services/LoginAttemptLimiter.d.ts +227 -0
- package/dist/services/LoginAttemptLimiter.d.ts.map +1 -0
- package/dist/services/MobileAuthService.d.ts.map +1 -1
- package/dist/services/OperationPermissionService.d.ts.map +1 -1
- package/dist/services/SessionPermissionContext.d.ts.map +1 -1
- package/dist/services/SessionService.d.ts +56 -1
- package/dist/services/SessionService.d.ts.map +1 -1
- package/dist/services/TerminalAuthService.d.ts +18 -3
- package/dist/services/TerminalAuthService.d.ts.map +1 -1
- package/dist/services/index.d.ts +4 -2
- package/dist/services/index.d.ts.map +1 -1
- package/dist/smrt-knowledge.json +679 -9
- package/dist/sveltekit/device-credential-handlers.d.ts +60 -0
- package/dist/sveltekit/device-credential-handlers.d.ts.map +1 -0
- package/dist/sveltekit/index.d.ts +10 -0
- package/dist/sveltekit/index.d.ts.map +1 -1
- package/dist/sveltekit/types.d.ts +6 -0
- package/dist/sveltekit/types.d.ts.map +1 -1
- package/dist/sveltekit.js +184 -4
- package/dist/sveltekit.js.map +1 -1
- package/package.json +12 -12
- 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/
|
|
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
|
|
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
|
|
package/agents/retention.md
CHANGED
|
@@ -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`
|
|
28
|
-
|
|
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
|
|
32
|
-
- **
|
|
33
|
-
nothing worth retaining, so each
|
|
34
|
-
application that keeps expired sessions for audit should opt the
|
|
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
|