@voltro/plugin-auth 0.71.1 → 0.73.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.
- package/CHANGELOG.md +468 -0
- package/THIRD-PARTY-NOTICES.md +29 -1
- package/dist/index.d.ts +260 -176
- package/dist/index.js +184 -131
- package/dist/{plugin-CPcpSSf9.js → plugin-DFQ8N52Q.js} +236 -232
- package/dist/plugin.d.ts +179 -142
- package/dist/plugin.js +1 -1
- package/dist/strategy.d.ts +179 -142
- package/package.json +3 -3
package/dist/strategy.d.ts
CHANGED
|
@@ -4,6 +4,26 @@ import { SessionSecrets } from '@voltro/protocol/session';
|
|
|
4
4
|
import { VoidIfEmpty } from 'effect/Types';
|
|
5
5
|
import { YieldableError } from 'effect/Cause';
|
|
6
6
|
|
|
7
|
+
/** Identity — who a user is. The only role an app with its own identity table has to write itself; everything else composes from the shipped store. */
|
|
8
|
+
declare interface IdentityStore {
|
|
9
|
+
readonly findByEmail: (email: string) => Effect.Effect<UserRecord | null>;
|
|
10
|
+
readonly findById: (id: string) => Effect.Effect<UserRecord | null>;
|
|
11
|
+
readonly insert: (user: Omit<UserRecord, 'createdAt'>) => Effect.Effect<UserRecord, UserAlreadyExistsError>;
|
|
12
|
+
/** Replace a user's stored password hash. Used by password-reset AND
|
|
13
|
+
* by rehash-on-verify (silently upgrading an under-cost hash on a
|
|
14
|
+
* successful sign-in). */
|
|
15
|
+
readonly updatePassword: (userId: string, newHash: string) => Effect.Effect<UserRecord, UserNotFoundError>;
|
|
16
|
+
/**
|
|
17
|
+
* Stamp `emailVerifiedAt` — the address has been proven.
|
|
18
|
+
*
|
|
19
|
+
* IDEMPOTENT and non-regressing: a user already verified keeps their
|
|
20
|
+
* ORIGINAL timestamp. "When was this address first proven" is the question
|
|
21
|
+
* the column answers, and re-stamping it on every later magic-link login
|
|
22
|
+
* would quietly turn it into "when did they last click something".
|
|
23
|
+
*/
|
|
24
|
+
readonly markEmailVerified: (userId: string) => Effect.Effect<UserRecord, UserNotFoundError>;
|
|
25
|
+
}
|
|
26
|
+
|
|
7
27
|
/** One impersonation grant — "actor is acting as target, on this session,
|
|
8
28
|
* until this instant". See `impersonationGrantsTable`. */
|
|
9
29
|
declare interface ImpersonationGrantRecord {
|
|
@@ -24,6 +44,17 @@ declare interface ImpersonationGrantRecord {
|
|
|
24
44
|
readonly userAgent?: string | null;
|
|
25
45
|
}
|
|
26
46
|
|
|
47
|
+
/** Impersonation grants. */
|
|
48
|
+
declare interface ImpersonationStore {
|
|
49
|
+
/** Record an impersonation grant when the impersonated session is minted. */
|
|
50
|
+
readonly insertImpersonationGrant: (grant: Omit<ImpersonationGrantRecord, 'startedAt' | 'endedAt' | 'endedReason'>) => Effect.Effect<ImpersonationGrantRecord>;
|
|
51
|
+
/** The grant for an impersonated session, or null. The stop path's lookup. */
|
|
52
|
+
readonly findImpersonationGrantBySession: (sessionId: string) => Effect.Effect<ImpersonationGrantRecord | null>;
|
|
53
|
+
/** Close a grant. Only an OPEN grant is closed (`endedAt IS NULL`), so a
|
|
54
|
+
* double stop is a no-op rather than a rewritten end time. */
|
|
55
|
+
readonly endImpersonationGrant: (sessionId: string, endedReason: string, now: Date) => Effect.Effect<boolean>;
|
|
56
|
+
}
|
|
57
|
+
|
|
27
58
|
/**
|
|
28
59
|
* A tenant invitation — the grant that turns into a `memberships` row when
|
|
29
60
|
* redeemed. See `invitationsTable` for why `role` lives here and not in the
|
|
@@ -47,6 +78,38 @@ declare interface InvitationRecord {
|
|
|
47
78
|
readonly createdAt: Date;
|
|
48
79
|
}
|
|
49
80
|
|
|
81
|
+
/** Tenant invitations. */
|
|
82
|
+
declare interface InvitationStore {
|
|
83
|
+
/** Persist an invitation (token already hashed). */
|
|
84
|
+
readonly insertInvitation: (invitation: Omit<InvitationRecord, 'acceptedAt' | 'acceptedByUserId' | 'revokedAt' | 'revokedByUserId' | 'createdAt'>) => Effect.Effect<InvitationRecord>;
|
|
85
|
+
/** Look an invitation up by token hash WITHOUT redeeming it — the read the
|
|
86
|
+
* "what am I being invited to" preview needs. Returns the row whatever its
|
|
87
|
+
* state; the caller decides what an expired or revoked one means. */
|
|
88
|
+
readonly findInvitationByHash: (tokenHash: string) => Effect.Effect<InvitationRecord | null>;
|
|
89
|
+
/**
|
|
90
|
+
* Atomically redeem an invitation: returns the row only when it exists, is
|
|
91
|
+
* unaccepted, unrevoked and unexpired — and stamps `acceptedAt` +
|
|
92
|
+
* `acceptedByUserId` in the same step. Null otherwise.
|
|
93
|
+
*
|
|
94
|
+
* The single-use guard, and it has to be ONE statement: two clicks on the
|
|
95
|
+
* same emailed link race, and a read-then-write would let both through and
|
|
96
|
+
* write the membership twice.
|
|
97
|
+
*/
|
|
98
|
+
readonly acceptInvitation: (tokenHash: string, acceptedByUserId: string, now: Date) => Effect.Effect<InvitationRecord | null>;
|
|
99
|
+
/** Withdraw a PENDING invitation, scoped to its tenant so an admin of one
|
|
100
|
+
* tenant cannot revoke another's by id. Returns whether a row changed. */
|
|
101
|
+
readonly revokeInvitation: (invitationId: string, tenantId: string, revokedByUserId: string, now: Date) => Effect.Effect<boolean>;
|
|
102
|
+
/** Every invitation for a tenant, newest first. The admin list. */
|
|
103
|
+
readonly listInvitations: (tenantId: string) => Effect.Effect<ReadonlyArray<InvitationRecord>>;
|
|
104
|
+
/** How many invitations for this tenant are still redeemable — the bound
|
|
105
|
+
* `maxPending` enforces. */
|
|
106
|
+
readonly countPendingInvitations: (tenantId: string, now: Date) => Effect.Effect<number>;
|
|
107
|
+
/** Revoke every pending invitation for (tenant, email). Called before
|
|
108
|
+
* issuing a fresh one so re-inviting supersedes rather than accumulates —
|
|
109
|
+
* otherwise every resend leaves a live token behind. Returns the count. */
|
|
110
|
+
readonly supersedePendingInvitations: (tenantId: string, email: string, revokedByUserId: string, now: Date) => Effect.Effect<number>;
|
|
111
|
+
}
|
|
112
|
+
|
|
50
113
|
/**
|
|
51
114
|
* Brute-force lockout policy, resolved from `AuthConfig.lockout` and passed
|
|
52
115
|
* INTO the store on each failure so the store itself stays policy-free.
|
|
@@ -67,6 +130,20 @@ declare interface LockoutState {
|
|
|
67
130
|
readonly retryAfterSeconds: number;
|
|
68
131
|
}
|
|
69
132
|
|
|
133
|
+
/** Brute-force lockout, keyed by normalized email. */
|
|
134
|
+
declare interface LockoutStore {
|
|
135
|
+
/** Is this email currently locked out? Reads only — never mutates. Returns
|
|
136
|
+
* the remaining cooldown so the handler can surface a retry-after. */
|
|
137
|
+
readonly isLockedOut: (email: string, now: number) => Effect.Effect<LockoutState>;
|
|
138
|
+
/** Record one failed credential attempt for `email`. Opens a fresh window
|
|
139
|
+
* when the previous one (or a prior lock) has expired, otherwise increments;
|
|
140
|
+
* sets the lock once `failedCount` reaches `policy.maxAttempts`. Called only
|
|
141
|
+
* for a NOT-currently-locked email (the handler checks `isLockedOut` first). */
|
|
142
|
+
readonly recordLoginFailure: (email: string, now: number, policy: LockoutPolicy) => Effect.Effect<void>;
|
|
143
|
+
/** Clear the counter for `email` on a completed login. */
|
|
144
|
+
readonly clearLoginFailures: (email: string) => Effect.Effect<void>;
|
|
145
|
+
}
|
|
146
|
+
|
|
70
147
|
/** One tenant a user belongs to. The active tenant lives on the Subject;
|
|
71
148
|
* the full set drives the switch-tenant menu + the switch-tenant guard. */
|
|
72
149
|
declare interface MembershipRecord {
|
|
@@ -76,6 +153,46 @@ declare interface MembershipRecord {
|
|
|
76
153
|
readonly joinedAt: Date;
|
|
77
154
|
}
|
|
78
155
|
|
|
156
|
+
/** Memberships + switch-tenant. */
|
|
157
|
+
declare interface MembershipStore {
|
|
158
|
+
/** Every tenant the user belongs to. */
|
|
159
|
+
readonly listMemberships: (userId: string) => Effect.Effect<ReadonlyArray<MembershipRecord>>;
|
|
160
|
+
/** Idempotent grant: inserts the [userId, tenantId] membership or
|
|
161
|
+
* updates its role if it already exists. The DB-level composite
|
|
162
|
+
* UNIQUE on `[userId, tenantId]` (see `membershipsTable`) is the
|
|
163
|
+
* source of truth; this upsert converges to it. */
|
|
164
|
+
readonly addMembership: (membership: Omit<MembershipRecord, 'joinedAt'>) => Effect.Effect<MembershipRecord>;
|
|
165
|
+
/** Resolve the role for a [userId, tenantId] pair, or null when the
|
|
166
|
+
* user is NOT a member. The switch-tenant handler calls this to
|
|
167
|
+
* validate the requested tenant before rebinding the connection. */
|
|
168
|
+
readonly membershipRole: (userId: string, tenantId: string) => Effect.Effect<string | null>;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** TOTP enrolment + recovery codes. */
|
|
172
|
+
declare interface MfaStore {
|
|
173
|
+
/** Stash a freshly-generated TOTP secret on the user. Called by
|
|
174
|
+
* the MFA enrolment handler before the user verifies the first
|
|
175
|
+
* code. */
|
|
176
|
+
readonly setMfaSecret: (userId: string, secret: string) => Effect.Effect<UserRecord, UserNotFoundError>;
|
|
177
|
+
/** Mark MFA as fully enrolled (called after the first successful
|
|
178
|
+
* TOTP verify completes the enrolment ceremony). */
|
|
179
|
+
readonly markMfaEnrolled: (userId: string) => Effect.Effect<UserRecord, UserNotFoundError>;
|
|
180
|
+
/** Wipe the secret + enrolledAt. Used by /account/security's
|
|
181
|
+
* "remove MFA" action. */
|
|
182
|
+
readonly clearMfa: (userId: string) => Effect.Effect<UserRecord, UserNotFoundError>;
|
|
183
|
+
/** Replace the user's recovery codes with a fresh (already hashed) set.
|
|
184
|
+
* Called at enrolment / regeneration — wipes any prior codes so the
|
|
185
|
+
* displayed set is the only valid one. */
|
|
186
|
+
readonly replaceRecoveryCodes: (userId: string, codeHashes: ReadonlyArray<string>) => Effect.Effect<void>;
|
|
187
|
+
/** Atomically redeem one recovery code by its hash: returns true only
|
|
188
|
+
* when an unconsumed row for `userId` matched — and marks it consumed
|
|
189
|
+
* in the same step (single-use). false otherwise. */
|
|
190
|
+
readonly consumeRecoveryCode: (userId: string, codeHash: string) => Effect.Effect<boolean>;
|
|
191
|
+
/** Count the user's remaining (unconsumed) recovery codes — surfaced so
|
|
192
|
+
* the UI can warn when the user is running low. */
|
|
193
|
+
readonly countRecoveryCodes: (userId: string) => Effect.Effect<number>;
|
|
194
|
+
}
|
|
195
|
+
|
|
79
196
|
/** A registered WebAuthn credential. */
|
|
80
197
|
declare interface PasskeyRecord {
|
|
81
198
|
readonly id: string;
|
|
@@ -88,6 +205,21 @@ declare interface PasskeyRecord {
|
|
|
88
205
|
readonly lastUsedAt: Date | null;
|
|
89
206
|
}
|
|
90
207
|
|
|
208
|
+
/** Passkeys (WebAuthn). */
|
|
209
|
+
declare interface PasskeyStore {
|
|
210
|
+
readonly listPasskeys: (userId: string) => Effect.Effect<ReadonlyArray<PasskeyRecord>>;
|
|
211
|
+
readonly findPasskey: (credentialId: string) => Effect.Effect<PasskeyRecord | null>;
|
|
212
|
+
readonly insertPasskey: (passkey: Omit<PasskeyRecord, 'createdAt' | 'lastUsedAt'>) => Effect.Effect<PasskeyRecord>;
|
|
213
|
+
/** Atomically bump the stored signature counter, guarding against a
|
|
214
|
+
* cloned authenticator (WebAuthn §6.1.1): the update only applies when
|
|
215
|
+
* `newCounter` strictly exceeds the stored counter. Returns whether a
|
|
216
|
+
* row was advanced. `false` with an unchanged stored counter is the
|
|
217
|
+
* clone/replay signal the assertion handler rejects on — the check and
|
|
218
|
+
* the write are ONE atomic statement at the store, so two replicas
|
|
219
|
+
* racing the same counter can't both succeed. */
|
|
220
|
+
readonly advancePasskeyCounter: (credentialId: string, newCounter: number) => Effect.Effect<boolean>;
|
|
221
|
+
}
|
|
222
|
+
|
|
91
223
|
/** A server-side session row — enumerated so apps can list active
|
|
92
224
|
* devices + revoke them. Written on sign-in; deleted on revoke. */
|
|
93
225
|
declare interface SessionRecord {
|
|
@@ -133,6 +265,29 @@ declare interface SessionRevocationOptions {
|
|
|
133
265
|
* or the keyed `{ current, previous? }` rotation set. */
|
|
134
266
|
declare type SessionSecretInput = string | SessionSecrets;
|
|
135
267
|
|
|
268
|
+
/** Server-side sessions — enumeration + revocation. */
|
|
269
|
+
declare interface SessionStore {
|
|
270
|
+
/** Active sessions for a user (most recent first). */
|
|
271
|
+
readonly listSessions: (userId: string) => Effect.Effect<ReadonlyArray<SessionRecord>>;
|
|
272
|
+
/** Record a session on sign-in. */
|
|
273
|
+
readonly insertSession: (session: Omit<SessionRecord, 'createdAt' | 'lastSeenAt'>) => Effect.Effect<SessionRecord>;
|
|
274
|
+
/** Look up one session row by id. The request-time revocation check
|
|
275
|
+
* (`makeSessionRevocationChecker`) calls this — a missing row means
|
|
276
|
+
* the session was revoked. */
|
|
277
|
+
readonly findSession: (sessionId: string) => Effect.Effect<SessionRecord | null>;
|
|
278
|
+
/** Slide a session row forward: bump `expiresAt` + `lastSeenAt`. Called
|
|
279
|
+
* when the sliding-window renewal re-issues the cookie, so the row
|
|
280
|
+
* outlives the renewed cookie and the revocation check keeps passing. */
|
|
281
|
+
readonly touchSession: (sessionId: string, expiresAt: Date) => Effect.Effect<void>;
|
|
282
|
+
/** Revoke a single session — only if it belongs to `userId` (so a
|
|
283
|
+
* caller can't revoke another user's session by id-guessing). Returns
|
|
284
|
+
* whether a row was removed. */
|
|
285
|
+
readonly revokeSession: (userId: string, sessionId: string) => Effect.Effect<boolean>;
|
|
286
|
+
/** Revoke every session for the user EXCEPT `keepSessionId` — the
|
|
287
|
+
* "sign out other devices" action. Returns the count removed. */
|
|
288
|
+
readonly revokeAllOtherSessions: (userId: string, keepSessionId: string) => Effect.Effect<number>;
|
|
289
|
+
}
|
|
290
|
+
|
|
136
291
|
declare type TokenPurpose = 'magic-link' | 'password-reset' | 'mfa-pending' | 'email-verify';
|
|
137
292
|
|
|
138
293
|
/** A single-use, hashed, expiring token (magic-link / password-reset). */
|
|
@@ -146,6 +301,28 @@ declare interface TokenRecord {
|
|
|
146
301
|
readonly createdAt: Date;
|
|
147
302
|
}
|
|
148
303
|
|
|
304
|
+
/** Single-use tokens: magic link, password reset, MFA pending, email verification. */
|
|
305
|
+
declare interface TokenStore {
|
|
306
|
+
/** Persist a single-use token (already hashed). */
|
|
307
|
+
readonly insertToken: (token: Omit<TokenRecord, 'consumedAt' | 'createdAt'>) => Effect.Effect<TokenRecord>;
|
|
308
|
+
/** Atomically redeem a token by its hash + purpose: returns the row
|
|
309
|
+
* only when it exists, matches the purpose, is unexpired, and was not
|
|
310
|
+
* already consumed — and marks it consumed in the same step. Returns
|
|
311
|
+
* null otherwise (single-use guard). */
|
|
312
|
+
readonly consumeToken: (tokenHash: string, purpose: TokenPurpose) => Effect.Effect<TokenRecord | null>;
|
|
313
|
+
/**
|
|
314
|
+
* The most recently CREATED token of this purpose for the user, consumed or
|
|
315
|
+
* not — or null when there is none.
|
|
316
|
+
*
|
|
317
|
+
* Exists for the resend cooldown, which is the only bound on an endpoint
|
|
318
|
+
* that sends mail to an address supplied by an unauthenticated caller. Note
|
|
319
|
+
* it deliberately does NOT filter on `consumedAt`: the cost being rate-
|
|
320
|
+
* limited is the EMAIL, and a token that was minted and then redeemed cost
|
|
321
|
+
* exactly as much to send as one still pending.
|
|
322
|
+
*/
|
|
323
|
+
readonly latestToken: (userId: string, purpose: TokenPurpose) => Effect.Effect<TokenRecord | null>;
|
|
324
|
+
}
|
|
325
|
+
|
|
149
326
|
declare class UserAlreadyExistsError extends UserAlreadyExistsError_base<{
|
|
150
327
|
readonly email: string;
|
|
151
328
|
}> {
|
|
@@ -215,148 +392,8 @@ declare interface UserRecord {
|
|
|
215
392
|
readonly deactivatedAt?: Date | null;
|
|
216
393
|
}
|
|
217
394
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
readonly findById: (id: string) => Effect.Effect<UserRecord | null>;
|
|
221
|
-
readonly insert: (user: Omit<UserRecord, 'createdAt'>) => Effect.Effect<UserRecord, UserAlreadyExistsError>;
|
|
222
|
-
/** Replace a user's stored password hash. Used by password-reset AND
|
|
223
|
-
* by rehash-on-verify (silently upgrading an under-cost hash on a
|
|
224
|
-
* successful sign-in). */
|
|
225
|
-
readonly updatePassword: (userId: string, newHash: string) => Effect.Effect<UserRecord, UserNotFoundError>;
|
|
226
|
-
/** Stash a freshly-generated TOTP secret on the user. Called by
|
|
227
|
-
* the MFA enrolment handler before the user verifies the first
|
|
228
|
-
* code. */
|
|
229
|
-
readonly setMfaSecret: (userId: string, secret: string) => Effect.Effect<UserRecord, UserNotFoundError>;
|
|
230
|
-
/** Mark MFA as fully enrolled (called after the first successful
|
|
231
|
-
* TOTP verify completes the enrolment ceremony). */
|
|
232
|
-
readonly markMfaEnrolled: (userId: string) => Effect.Effect<UserRecord, UserNotFoundError>;
|
|
233
|
-
/** Wipe the secret + enrolledAt. Used by /account/security's
|
|
234
|
-
* "remove MFA" action. */
|
|
235
|
-
readonly clearMfa: (userId: string) => Effect.Effect<UserRecord, UserNotFoundError>;
|
|
236
|
-
/**
|
|
237
|
-
* Stamp `emailVerifiedAt` — the address has been proven.
|
|
238
|
-
*
|
|
239
|
-
* IDEMPOTENT and non-regressing: a user already verified keeps their
|
|
240
|
-
* ORIGINAL timestamp. "When was this address first proven" is the question
|
|
241
|
-
* the column answers, and re-stamping it on every later magic-link login
|
|
242
|
-
* would quietly turn it into "when did they last click something".
|
|
243
|
-
*/
|
|
244
|
-
readonly markEmailVerified: (userId: string) => Effect.Effect<UserRecord, UserNotFoundError>;
|
|
245
|
-
/** Every tenant the user belongs to. */
|
|
246
|
-
readonly listMemberships: (userId: string) => Effect.Effect<ReadonlyArray<MembershipRecord>>;
|
|
247
|
-
/** Idempotent grant: inserts the [userId, tenantId] membership or
|
|
248
|
-
* updates its role if it already exists. The DB-level composite
|
|
249
|
-
* UNIQUE on `[userId, tenantId]` (see `membershipsTable`) is the
|
|
250
|
-
* source of truth; this upsert converges to it. */
|
|
251
|
-
readonly addMembership: (membership: Omit<MembershipRecord, 'joinedAt'>) => Effect.Effect<MembershipRecord>;
|
|
252
|
-
/** Resolve the role for a [userId, tenantId] pair, or null when the
|
|
253
|
-
* user is NOT a member. The switch-tenant handler calls this to
|
|
254
|
-
* validate the requested tenant before rebinding the connection. */
|
|
255
|
-
readonly membershipRole: (userId: string, tenantId: string) => Effect.Effect<string | null>;
|
|
256
|
-
/** Active sessions for a user (most recent first). */
|
|
257
|
-
readonly listSessions: (userId: string) => Effect.Effect<ReadonlyArray<SessionRecord>>;
|
|
258
|
-
/** Record a session on sign-in. */
|
|
259
|
-
readonly insertSession: (session: Omit<SessionRecord, 'createdAt' | 'lastSeenAt'>) => Effect.Effect<SessionRecord>;
|
|
260
|
-
/** Look up one session row by id. The request-time revocation check
|
|
261
|
-
* (`makeSessionRevocationChecker`) calls this — a missing row means
|
|
262
|
-
* the session was revoked. */
|
|
263
|
-
readonly findSession: (sessionId: string) => Effect.Effect<SessionRecord | null>;
|
|
264
|
-
/** Slide a session row forward: bump `expiresAt` + `lastSeenAt`. Called
|
|
265
|
-
* when the sliding-window renewal re-issues the cookie, so the row
|
|
266
|
-
* outlives the renewed cookie and the revocation check keeps passing. */
|
|
267
|
-
readonly touchSession: (sessionId: string, expiresAt: Date) => Effect.Effect<void>;
|
|
268
|
-
/** Revoke a single session — only if it belongs to `userId` (so a
|
|
269
|
-
* caller can't revoke another user's session by id-guessing). Returns
|
|
270
|
-
* whether a row was removed. */
|
|
271
|
-
readonly revokeSession: (userId: string, sessionId: string) => Effect.Effect<boolean>;
|
|
272
|
-
/** Revoke every session for the user EXCEPT `keepSessionId` — the
|
|
273
|
-
* "sign out other devices" action. Returns the count removed. */
|
|
274
|
-
readonly revokeAllOtherSessions: (userId: string, keepSessionId: string) => Effect.Effect<number>;
|
|
275
|
-
/** Persist a single-use token (already hashed). */
|
|
276
|
-
readonly insertToken: (token: Omit<TokenRecord, 'consumedAt' | 'createdAt'>) => Effect.Effect<TokenRecord>;
|
|
277
|
-
/** Atomically redeem a token by its hash + purpose: returns the row
|
|
278
|
-
* only when it exists, matches the purpose, is unexpired, and was not
|
|
279
|
-
* already consumed — and marks it consumed in the same step. Returns
|
|
280
|
-
* null otherwise (single-use guard). */
|
|
281
|
-
readonly consumeToken: (tokenHash: string, purpose: TokenPurpose) => Effect.Effect<TokenRecord | null>;
|
|
282
|
-
/**
|
|
283
|
-
* The most recently CREATED token of this purpose for the user, consumed or
|
|
284
|
-
* not — or null when there is none.
|
|
285
|
-
*
|
|
286
|
-
* Exists for the resend cooldown, which is the only bound on an endpoint
|
|
287
|
-
* that sends mail to an address supplied by an unauthenticated caller. Note
|
|
288
|
-
* it deliberately does NOT filter on `consumedAt`: the cost being rate-
|
|
289
|
-
* limited is the EMAIL, and a token that was minted and then redeemed cost
|
|
290
|
-
* exactly as much to send as one still pending.
|
|
291
|
-
*/
|
|
292
|
-
readonly latestToken: (userId: string, purpose: TokenPurpose) => Effect.Effect<TokenRecord | null>;
|
|
293
|
-
/** Persist an invitation (token already hashed). */
|
|
294
|
-
readonly insertInvitation: (invitation: Omit<InvitationRecord, 'acceptedAt' | 'acceptedByUserId' | 'revokedAt' | 'revokedByUserId' | 'createdAt'>) => Effect.Effect<InvitationRecord>;
|
|
295
|
-
/** Look an invitation up by token hash WITHOUT redeeming it — the read the
|
|
296
|
-
* "what am I being invited to" preview needs. Returns the row whatever its
|
|
297
|
-
* state; the caller decides what an expired or revoked one means. */
|
|
298
|
-
readonly findInvitationByHash: (tokenHash: string) => Effect.Effect<InvitationRecord | null>;
|
|
299
|
-
/**
|
|
300
|
-
* Atomically redeem an invitation: returns the row only when it exists, is
|
|
301
|
-
* unaccepted, unrevoked and unexpired — and stamps `acceptedAt` +
|
|
302
|
-
* `acceptedByUserId` in the same step. Null otherwise.
|
|
303
|
-
*
|
|
304
|
-
* The single-use guard, and it has to be ONE statement: two clicks on the
|
|
305
|
-
* same emailed link race, and a read-then-write would let both through and
|
|
306
|
-
* write the membership twice.
|
|
307
|
-
*/
|
|
308
|
-
readonly acceptInvitation: (tokenHash: string, acceptedByUserId: string, now: Date) => Effect.Effect<InvitationRecord | null>;
|
|
309
|
-
/** Withdraw a PENDING invitation, scoped to its tenant so an admin of one
|
|
310
|
-
* tenant cannot revoke another's by id. Returns whether a row changed. */
|
|
311
|
-
readonly revokeInvitation: (invitationId: string, tenantId: string, revokedByUserId: string, now: Date) => Effect.Effect<boolean>;
|
|
312
|
-
/** Every invitation for a tenant, newest first. The admin list. */
|
|
313
|
-
readonly listInvitations: (tenantId: string) => Effect.Effect<ReadonlyArray<InvitationRecord>>;
|
|
314
|
-
/** How many invitations for this tenant are still redeemable — the bound
|
|
315
|
-
* `maxPending` enforces. */
|
|
316
|
-
readonly countPendingInvitations: (tenantId: string, now: Date) => Effect.Effect<number>;
|
|
317
|
-
/** Revoke every pending invitation for (tenant, email). Called before
|
|
318
|
-
* issuing a fresh one so re-inviting supersedes rather than accumulates —
|
|
319
|
-
* otherwise every resend leaves a live token behind. Returns the count. */
|
|
320
|
-
readonly supersedePendingInvitations: (tenantId: string, email: string, revokedByUserId: string, now: Date) => Effect.Effect<number>;
|
|
321
|
-
/** Record an impersonation grant when the impersonated session is minted. */
|
|
322
|
-
readonly insertImpersonationGrant: (grant: Omit<ImpersonationGrantRecord, 'startedAt' | 'endedAt' | 'endedReason'>) => Effect.Effect<ImpersonationGrantRecord>;
|
|
323
|
-
/** The grant for an impersonated session, or null. The stop path's lookup. */
|
|
324
|
-
readonly findImpersonationGrantBySession: (sessionId: string) => Effect.Effect<ImpersonationGrantRecord | null>;
|
|
325
|
-
/** Close a grant. Only an OPEN grant is closed (`endedAt IS NULL`), so a
|
|
326
|
-
* double stop is a no-op rather than a rewritten end time. */
|
|
327
|
-
readonly endImpersonationGrant: (sessionId: string, endedReason: string, now: Date) => Effect.Effect<boolean>;
|
|
328
|
-
/** Replace the user's recovery codes with a fresh (already hashed) set.
|
|
329
|
-
* Called at enrolment / regeneration — wipes any prior codes so the
|
|
330
|
-
* displayed set is the only valid one. */
|
|
331
|
-
readonly replaceRecoveryCodes: (userId: string, codeHashes: ReadonlyArray<string>) => Effect.Effect<void>;
|
|
332
|
-
/** Atomically redeem one recovery code by its hash: returns true only
|
|
333
|
-
* when an unconsumed row for `userId` matched — and marks it consumed
|
|
334
|
-
* in the same step (single-use). false otherwise. */
|
|
335
|
-
readonly consumeRecoveryCode: (userId: string, codeHash: string) => Effect.Effect<boolean>;
|
|
336
|
-
/** Count the user's remaining (unconsumed) recovery codes — surfaced so
|
|
337
|
-
* the UI can warn when the user is running low. */
|
|
338
|
-
readonly countRecoveryCodes: (userId: string) => Effect.Effect<number>;
|
|
339
|
-
readonly listPasskeys: (userId: string) => Effect.Effect<ReadonlyArray<PasskeyRecord>>;
|
|
340
|
-
readonly findPasskey: (credentialId: string) => Effect.Effect<PasskeyRecord | null>;
|
|
341
|
-
readonly insertPasskey: (passkey: Omit<PasskeyRecord, 'createdAt' | 'lastUsedAt'>) => Effect.Effect<PasskeyRecord>;
|
|
342
|
-
/** Atomically bump the stored signature counter, guarding against a
|
|
343
|
-
* cloned authenticator (WebAuthn §6.1.1): the update only applies when
|
|
344
|
-
* `newCounter` strictly exceeds the stored counter. Returns whether a
|
|
345
|
-
* row was advanced. `false` with an unchanged stored counter is the
|
|
346
|
-
* clone/replay signal the assertion handler rejects on — the check and
|
|
347
|
-
* the write are ONE atomic statement at the store, so two replicas
|
|
348
|
-
* racing the same counter can't both succeed. */
|
|
349
|
-
readonly advancePasskeyCounter: (credentialId: string, newCounter: number) => Effect.Effect<boolean>;
|
|
350
|
-
/** Is this email currently locked out? Reads only — never mutates. Returns
|
|
351
|
-
* the remaining cooldown so the handler can surface a retry-after. */
|
|
352
|
-
readonly isLockedOut: (email: string, now: number) => Effect.Effect<LockoutState>;
|
|
353
|
-
/** Record one failed credential attempt for `email`. Opens a fresh window
|
|
354
|
-
* when the previous one (or a prior lock) has expired, otherwise increments;
|
|
355
|
-
* sets the lock once `failedCount` reaches `policy.maxAttempts`. Called only
|
|
356
|
-
* for a NOT-currently-locked email (the handler checks `isLockedOut` first). */
|
|
357
|
-
readonly recordLoginFailure: (email: string, now: number, policy: LockoutPolicy) => Effect.Effect<void>;
|
|
358
|
-
/** Clear the counter for `email` on a completed login. */
|
|
359
|
-
readonly clearLoginFailures: (email: string) => Effect.Effect<void>;
|
|
395
|
+
/** Every role at once — what the shipped stores return and what `authRoutesPlugin` takes. */
|
|
396
|
+
declare interface UserStore extends IdentityStore, MfaStore, MembershipStore, SessionStore, TokenStore, InvitationStore, ImpersonationStore, PasskeyStore, LockoutStore {
|
|
360
397
|
}
|
|
361
398
|
|
|
362
399
|
export declare const voltroPasswordStrategy: (options?: VoltroPasswordStrategyOptions) => AuthStrategy;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltro/plugin-auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.73.0",
|
|
4
4
|
"description": "Authentication primitives: password hashing, session creation, schema mixin + tables. Pairs with the `auth` app template for the UI; both independently usable. Server-side only (uses node:crypto).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"voltro",
|
|
@@ -79,8 +79,8 @@
|
|
|
79
79
|
},
|
|
80
80
|
"dependencies": {
|
|
81
81
|
"@effect/sql": "^0.52.1",
|
|
82
|
-
"@voltro/database": "0.
|
|
83
|
-
"@voltro/protocol": "0.
|
|
82
|
+
"@voltro/database": "0.73.0",
|
|
83
|
+
"@voltro/protocol": "0.73.0"
|
|
84
84
|
},
|
|
85
85
|
"peerDependencies": {
|
|
86
86
|
"effect": "^3.22.1",
|