@voltro/plugin-auth 0.71.0 → 0.72.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.
@@ -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
- declare interface UserStore {
219
- readonly findByEmail: (email: string) => Effect.Effect<UserRecord | null>;
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.71.0",
3
+ "version": "0.72.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.71.0",
83
- "@voltro/protocol": "0.71.0"
82
+ "@voltro/database": "0.72.0",
83
+ "@voltro/protocol": "0.72.0"
84
84
  },
85
85
  "peerDependencies": {
86
86
  "effect": "^3.22.1",