@voltro/plugin-auth 0.1.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.
@@ -0,0 +1,1114 @@
1
+ import { AuthStrategy } from '@voltro/protocol';
2
+ import { Effect } from 'effect';
3
+ import { KeyedSecret } from '@voltro/protocol/session';
4
+ import { SessionSecrets } from '@voltro/protocol/session';
5
+ import { SqlClient } from '@effect/sql/SqlClient';
6
+ import { Subject } from '@voltro/protocol';
7
+ import { VerifyOptions } from '@voltro/protocol/session';
8
+ import { VerifyResult } from '@voltro/protocol/session';
9
+ import { VoidIfEmpty } from 'effect/Types';
10
+ import { VoltroPlugin } from '@voltro/protocol';
11
+ import { YieldableError } from 'effect/Cause';
12
+
13
+ export declare type AssertionOutcome = {
14
+ readonly ok: true;
15
+ readonly result: AssertionResult;
16
+ } | {
17
+ readonly ok: false;
18
+ readonly error: WebAuthnError;
19
+ };
20
+
21
+ export declare interface AssertionResult {
22
+ /** The new signature counter to persist. */
23
+ readonly newCounter: number;
24
+ }
25
+
26
+ export declare interface AssertionVerifyInput {
27
+ readonly clientDataJSON: string;
28
+ readonly authenticatorData: string;
29
+ /** base64url signature over `authenticatorData ‖ sha256(clientDataJSON)`. */
30
+ readonly signature: string;
31
+ readonly expectedChallenge: string;
32
+ readonly expectedOrigin: string;
33
+ readonly rpId: string;
34
+ /** The stored COSE public key (base64url) for the asserted credential. */
35
+ readonly storedPublicKey: string;
36
+ /** The stored signature counter for the credential. */
37
+ readonly storedCounter: number;
38
+ }
39
+
40
+ export declare interface AuthConfig {
41
+ readonly secret: string;
42
+ /** Explicit keyed secret set for zero-downtime rotation. When absent,
43
+ * the set is derived from `secret` (current) + the
44
+ * `VOLTRO_SESSION_SECRET_PREVIOUS` / `VOLTRO_SESSION_KID*` env vars —
45
+ * so env-var rotation works without setting this. Cookies are always
46
+ * SIGNED with the current key; verification also accepts `previous`. */
47
+ readonly secrets?: SessionSecrets;
48
+ readonly defaultTenantId: string;
49
+ readonly cookieDomain?: string;
50
+ readonly cookieSecure?: boolean;
51
+ /** Where to redirect on successful sign-in / sign-up. Default: '/'. */
52
+ readonly successRedirect?: string;
53
+ /** Injected email transport for magic-link + password-reset. When
54
+ * absent, those handlers still mint + persist the token but cannot
55
+ * deliver it (they return 202 so existence isn't leaked). The
56
+ * documented default forwards to `@voltro/plugin-mail` via
57
+ * `mailSender(mailService)`. */
58
+ readonly sendEmail?: SendEmail;
59
+ /** Absolute base URL used to build the links inside emails, e.g.
60
+ * `https://app.example.com`. Defaults to '' (relative links). */
61
+ readonly appBaseUrl?: string;
62
+ /** Post-authentication subject guards. After a login path resolves the
63
+ * authenticated user (credentials verified) but BEFORE a session is
64
+ * issued, every guard runs against that `UserRecord`; the first to veto
65
+ * (`{ ok: false }`) aborts the login with a 403 carrying its `code` and
66
+ * NO session cookie. General-purpose: `@voltro/plugin-deactivation`'s
67
+ * `deactivationGuard()` is the canonical one ("account deactivated"),
68
+ * but any concern (unverified email, suspended tenant, …) can hook here.
69
+ * Absent / empty ⇒ every authenticated user proceeds unchanged. */
70
+ readonly subjectGuards?: ReadonlyArray<SubjectGuard>;
71
+ }
72
+
73
+ /**
74
+ * The auth plugin instance also carries a pre-wired session
75
+ * `AuthStrategy` (keyed verify + the SAME revocation cache the HTTP
76
+ * routes use) so the framework's serve pipeline can slot it into the
77
+ * per-app auth chain — the rpc/WS path then rejects revoked sessions
78
+ * exactly like the `/auth/*` routes. Read it via
79
+ * `getAuthSessionStrategy`.
80
+ */
81
+ export declare interface AuthPlugin extends VoltroPlugin {
82
+ readonly auth: {
83
+ readonly sessionStrategy: AuthStrategy;
84
+ };
85
+ }
86
+
87
+ export declare const authRoutesPlugin: (options: AuthRoutesPluginOptions) => AuthPlugin;
88
+
89
+ export declare interface AuthRoutesPluginOptions extends AuthConfig {
90
+ /** The user store backing every handler. */
91
+ readonly store: UserStore;
92
+ /** Passkey config — when omitted, the four passkey routes return 501. */
93
+ readonly passkey?: PasskeyConfig;
94
+ /** Challenge store for the passkey ceremonies. Defaults to an in-memory
95
+ * single-node store. */
96
+ readonly challengeStore?: ChallengeStore;
97
+ /** MFA (TOTP) enrolment config. When set, the `/auth/mfa/enroll/*`
98
+ * routes are mounted so an authenticated user can enrol a second
99
+ * factor; `issuer` is the label shown in their authenticator app.
100
+ * Sign-in enforcement (the `/auth/mfa/verify` challenge) is ALWAYS
101
+ * active for enrolled users regardless of this — it only gates the
102
+ * enrolment routes. */
103
+ readonly mfa?: MfaConfig;
104
+ /** Rebind hook for switch-tenant on the live WS path — pass
105
+ * `bindConnectionSubject` from `@voltro/runtime`. */
106
+ readonly rebind?: RebindConnection;
107
+ /** Route prefix. Default `/auth`. */
108
+ readonly prefix?: string;
109
+ /** Tuning for the request-time session-revocation check (TTL cache
110
+ * window, default 30s; `now` is a testing seam). */
111
+ readonly sessionRevocation?: SessionRevocationOptions;
112
+ /** Disambiguates multiple instances of this plugin in one app. */
113
+ readonly name?: string;
114
+ }
115
+
116
+ /** Single-use challenge storage. `key` is `<userId>:<ceremony>`. */
117
+ export declare interface ChallengeStore {
118
+ readonly put: (key: string, challenge: string, ttlSeconds: number) => Effect.Effect<void>;
119
+ /** Read AND delete (single-use). Returns null when absent/expired. */
120
+ readonly take: (key: string) => Effect.Effect<string | null>;
121
+ }
122
+
123
+ /**
124
+ * Build a `Set-Cookie` value that clears the session cookie. Send
125
+ * this on logout.
126
+ */
127
+ export declare const clearSessionCookie: (options?: IssueSessionOptions) => string;
128
+
129
+ /** The keyed secret set a handler signs/verifies with — the explicit
130
+ * `config.secrets` when set, else `config.secret` widened with the
131
+ * rotation env vars. */
132
+ export declare const configSessionSecrets: (config: AuthConfig) => SessionSecrets;
133
+
134
+ export declare const CSRF_COOKIE_NAME: "voltro:csrf";
135
+
136
+ export declare const CSRF_HEADER_NAME: "x-csrf-token";
137
+
138
+ /**
139
+ * Shared-store `ChallengeStore` over the `passkeyChallenges` table (see
140
+ * `./schema`). Use this in ANY deployment running more than one replica:
141
+ * the `register/options` and `assert/options` requests may land on a
142
+ * different node than their matching `verify`, and a memory challenge
143
+ * would be missing there. Rows are:
144
+ *
145
+ * - single-use — `take` is a single `DELETE … RETURNING` statement, so
146
+ * two concurrent verifies can't both redeem the same challenge (the
147
+ * loser gets zero rows); and
148
+ * - short-lived — `put` stamps `expiresAt`, and `take` treats an expired
149
+ * row as absent (while still deleting it). A background reaper is
150
+ * optional; abandoned rows are tiny and self-invalidate.
151
+ *
152
+ * The caller owns the `SqlClient` lifecycle (same contract as
153
+ * `postgresUserStore`).
154
+ */
155
+ export declare const dataStoreChallengeStore: (sql: SqlClient) => ChallengeStore;
156
+
157
+ /** Mint a fresh WebAuthn challenge (base64url, 32 random bytes). The
158
+ * server stashes it (keyed by user / ceremony) and hands it to the
159
+ * browser; `verifyRegistration` / `verifyAssertion` check it back. */
160
+ export declare const generateChallenge: () => string;
161
+
162
+ /** Generate N base32 recovery codes (5 chars each, 5 groups separated
163
+ * by '-'). 25-character codes, ~125 bits of entropy — enough that
164
+ * brute-forcing the recovery surface is unrealistic, short enough
165
+ * the user can type them. Each code is single-use; the caller must
166
+ * persist them hashed (same as passwords) + mark them consumed on
167
+ * use. */
168
+ export declare const generateRecoveryCodes: (count?: number) => ReadonlyArray<string>;
169
+
170
+ /** Generate the current TOTP code for a secret. Caller controls the
171
+ * clock to make this testable — pass `Date.now()` in production. */
172
+ export declare const generateTotpCode: (secretBase32: string, nowMs?: number) => string;
173
+
174
+ /** Generate a fresh TOTP secret (160-bit, base32-encoded). Hand this
175
+ * to the user via an otpauth URL — `otpauthUrl()` below builds it. */
176
+ export declare const generateTotpSecret: () => string;
177
+
178
+ /**
179
+ * Pull the pre-wired session strategy off a plugin list (the framework's
180
+ * serve pipeline calls this when composing the auth chain). Returns the
181
+ * FIRST auth plugin's strategy, or null when no auth plugin is present.
182
+ */
183
+ export declare const getAuthSessionStrategy: (plugins: ReadonlyArray<VoltroPlugin>) => AuthStrategy | null;
184
+
185
+ /**
186
+ * Run the config's post-authentication subject guards against a resolved
187
+ * user. Returns a 403 `HandlerResult` (carrying the vetoing guard's `code`
188
+ * as `error`) when a guard rejects the login, or `null` to let it proceed.
189
+ *
190
+ * Every login path that grants a session to a PRE-EXISTING user calls this
191
+ * after the credential check and before issuing the session — so a guarded
192
+ * account (e.g. deactivated) is turned away uniformly across password,
193
+ * MFA, magic-link, and passkey sign-in. Sign-up is exempt: it creates a
194
+ * brand-new user that no guard could yet reject.
195
+ */
196
+ export declare const guardSubject: (user: UserRecord, config: AuthConfig) => Effect.Effect<HandlerResult | null>;
197
+
198
+ /**
199
+ * Issue a CSRF token. Returns the token in the JSON body AND sets a
200
+ * readable (non-HttpOnly) `voltro:csrf` cookie. The SPA reads the cookie
201
+ * and echoes the value in the `x-csrf-token` header on every mutation;
202
+ * the server verifies the two match + the HMAC.
203
+ */
204
+ export declare const handleCsrf: (config: AuthConfig) => HandlerResult;
205
+
206
+ export declare const handleListMemberships: (input: {
207
+ readonly userId: string;
208
+ }, store: UserStore) => Effect.Effect<HandlerResult>;
209
+
210
+ export declare const handleListSessions: (input: ListSessionsInput, store: UserStore) => Effect.Effect<HandlerResult>;
211
+
212
+ /**
213
+ * Consume a magic-link token: validate (single-use, unexpired) and, on
214
+ * success, issue a session for the bound user. Writes a session row +
215
+ * loads memberships, same as password sign-in.
216
+ */
217
+ export declare const handleMagicLinkConsume: (input: MagicLinkConsumeInput, store: UserStore, config: AuthConfig) => Effect.Effect<HandlerResult>;
218
+
219
+ /**
220
+ * Request a magic-link. Always returns 202 regardless of whether the
221
+ * email exists (no account-enumeration). When the user exists we mint a
222
+ * single-use token, persist its hash, and call `config.sendEmail`.
223
+ */
224
+ export declare const handleMagicLinkRequest: (input: MagicLinkRequestInput, store: UserStore, config: AuthConfig) => Effect.Effect<HandlerResult>;
225
+
226
+ export declare const handleMfaEnrollStart: (input: MfaEnrollStartInput, store: UserStore) => Effect.Effect<HandlerResult>;
227
+
228
+ export declare const handleMfaEnrollVerify: (input: MfaEnrollVerifyInput, store: UserStore) => Effect.Effect<HandlerResult>;
229
+
230
+ /** Regenerate the user's recovery codes — wipes the prior set and returns
231
+ * a fresh plaintext batch (shown once). Only valid for an enrolled user. */
232
+ export declare const handleMfaRegenerateRecoveryCodes: (input: MfaRegenerateRecoveryCodesInput, store: UserStore) => Effect.Effect<HandlerResult>;
233
+
234
+ export declare const handleMfaUnenroll: (input: MfaUnenrollInput, store: UserStore) => Effect.Effect<HandlerResult>;
235
+
236
+ /**
237
+ * Complete an MFA sign-in: redeem the single-use pending token, verify the
238
+ * submitted TOTP code (or a recovery code) against the user's stored
239
+ * secret, and ONLY THEN issue the real session — through the same
240
+ * `issueUserSession` path as password sign-in, so rotation + revocation
241
+ * apply. A wrong code (or missing enrolment) is a 401; the pending token is
242
+ * consumed on redemption regardless, so a guessed code can't be retried
243
+ * against the same challenge.
244
+ */
245
+ export declare const handleMfaVerify: (input: MfaVerifyInput, store: UserStore, config: AuthConfig) => Effect.Effect<HandlerResult>;
246
+
247
+ /** Step 3: mint assertion options for `navigator.credentials.get`. */
248
+ export declare const handlePasskeyAssertOptions: (input: PasskeyAssertOptionsInput, store: UserStore, challenges: ChallengeStore, pk: PasskeyConfig) => Effect.Effect<HandlerResult>;
249
+
250
+ /** Step 4: verify the assertion (signature + strictly-increasing counter)
251
+ * and issue a session. */
252
+ export declare const handlePasskeyAssertVerify: (input: PasskeyAssertVerifyInput, store: UserStore, challenges: ChallengeStore, pk: PasskeyConfig, config: AuthConfig) => Effect.Effect<HandlerResult>;
253
+
254
+ /** Step 1: mint registration options for `navigator.credentials.create`. */
255
+ export declare const handlePasskeyRegisterOptions: (input: PasskeyRegisterOptionsInput, store: UserStore, challenges: ChallengeStore, pk: PasskeyConfig) => Effect.Effect<HandlerResult>;
256
+
257
+ /** Step 2: verify the registration and persist the credential. */
258
+ export declare const handlePasskeyRegisterVerify: (input: PasskeyRegisterVerifyInput, store: UserStore, challenges: ChallengeStore, pk: PasskeyConfig) => Effect.Effect<HandlerResult>;
259
+
260
+ /** Confirm a password reset: validate the token, set the new (hashed)
261
+ * password, and revoke ALL existing sessions for the user (a reset
262
+ * invalidates every prior login). */
263
+ export declare const handlePasswordResetConfirm: (input: PasswordResetConfirmInput, store: UserStore) => Effect.Effect<HandlerResult>;
264
+
265
+ /** Request a password reset. Uniform 202 (no enumeration); mints + emails
266
+ * a single-use reset token when the user exists. */
267
+ export declare const handlePasswordResetRequest: (input: PasswordResetRequestInput, store: UserStore, config: AuthConfig) => Effect.Effect<HandlerResult>;
268
+
269
+ export declare const handleRevokeAllOtherSessions: (input: RevokeOtherSessionsInput, store: UserStore) => Effect.Effect<HandlerResult>;
270
+
271
+ export declare const handleRevokeSession: (input: RevokeSessionInput, store: UserStore) => Effect.Effect<HandlerResult>;
272
+
273
+ export declare interface HandlerResult {
274
+ readonly status: number;
275
+ readonly body: string;
276
+ readonly contentType?: string;
277
+ readonly setCookie?: string;
278
+ readonly location?: string;
279
+ }
280
+
281
+ /**
282
+ * Sign-in handler. Accepts either form-encoded OR JSON input with
283
+ * `email` + `password`. Returns:
284
+ * - 200 JSON `{ ok: true, mfaRequired: true, pendingToken }` when the
285
+ * user has MFA enrolled — NO session cookie is issued; the caller must
286
+ * complete `POST /auth/mfa/verify` with the pending token + a TOTP (or
287
+ * recovery) code before a real session is granted.
288
+ * - 200 JSON with subject + Set-Cookie (for fetch-based callers) when the
289
+ * user has NO MFA enrolled.
290
+ * - 302 redirect when `redirectAfter` is true (form post path, non-MFA).
291
+ *
292
+ * Always runs `verifyPassword` even when the user is missing, so
293
+ * sign-in latency doesn't leak existence (timing-oracle defence).
294
+ *
295
+ * On the non-MFA success path it ALSO: (a) loads the user's tenant
296
+ * memberships and carries them on the Subject, (b) writes a `sessions` row
297
+ * for the device list + revocation, and (c) rehashes the stored password
298
+ * when it's below the current scrypt cost (rehash-on-verify).
299
+ */
300
+ export declare const handleSignIn: (input: SignInInput, store: UserStore, config: AuthConfig) => Effect.Effect<HandlerResult>;
301
+
302
+ /**
303
+ * Sign-out handler. Clears the session cookie. Synchronous so it
304
+ * stays callable as a plain function from non-Effect call sites; no
305
+ * error channel because there's nothing to fail.
306
+ */
307
+ export declare const handleSignOut: (config: AuthConfig) => HandlerResult;
308
+
309
+ /**
310
+ * Sign-up handler. Creates a new user with the hashed password +
311
+ * issues a session immediately (auto sign-in after sign-up).
312
+ *
313
+ * Inputs failing validation collapse to 4xx HandlerResults. Storage
314
+ * errors (`UserAlreadyExistsError`, `PasswordHashError`) propagate
315
+ * via the Effect's error channel — callers either map them to 5xx or
316
+ * pattern-match for finer-grained responses.
317
+ */
318
+ export declare const handleSignUp: (input: SignUpInput, store: UserStore, config: AuthConfig) => Effect.Effect<HandlerResult>;
319
+
320
+ /**
321
+ * Switch the active tenant. Validates the user is actually a member of
322
+ * `targetTenantId` (the switch-tenant guard), re-issues the session
323
+ * cookie with the new active tenant, and — when a `clientId` + `rebind`
324
+ * are supplied — rebinds the live connection's Subject in place (the
325
+ * dispatcher re-scopes subscriptions on rebind).
326
+ */
327
+ export declare const handleSwitchTenant: (input: SwitchTenantInput, store: UserStore, config: AuthConfig, rebind?: RebindConnection) => Effect.Effect<HandlerResult>;
328
+
329
+ /**
330
+ * Hash a password. Returns a self-describing string that
331
+ * `verifyPassword` can re-parse without external config. Don't
332
+ * truncate this string — the parameters are encoded inline.
333
+ */
334
+ export declare const hashPassword: (plaintext: string) => Effect.Effect<string, PasswordEmptyError | PasswordHashError>;
335
+
336
+ /** Hash a plaintext token for storage / lookup. SHA-256 is sufficient —
337
+ * the token is high-entropy (32 random bytes), so there's no
338
+ * brute-force surface that would need a slow KDF. */
339
+ export declare const hashToken: (plaintext: string) => string;
340
+
341
+ /** Verify a token's own HMAC (signature integrity), independent of the
342
+ * cookie/header match. */
343
+ export declare const isCsrfTokenWellFormed: (token: string, secret: string) => boolean;
344
+
345
+ /**
346
+ * Issue a fresh CSRF token: `<randomB64url>.<hmacB64url>`. Set this as a
347
+ * readable (non-HttpOnly) cookie AND hand it to the SPA so it can echo
348
+ * it in the `x-csrf-token` header.
349
+ */
350
+ export declare const issueCsrfToken: (secret: string) => string;
351
+
352
+ export declare interface IssuedSession {
353
+ /** The opaque cookie value — `<payload>.<sig>`. */
354
+ readonly value: string;
355
+ /** Ready-to-Set-Cookie header string with sane defaults. */
356
+ readonly setCookie: string;
357
+ }
358
+
359
+ /**
360
+ * Mint a signed session AND build the corresponding `Set-Cookie`
361
+ * header in one call. Apps just attach the header to their
362
+ * response. Pass a `SessionSecrets` set to sign with the current
363
+ * rotation key (its `kid` is stamped into the payload).
364
+ */
365
+ export declare const issueSession: (subject: Subject, secret: SessionSecretInput, options?: IssueSessionOptions) => IssuedSession;
366
+
367
+ export declare interface IssueSessionOptions {
368
+ readonly ttlSeconds?: number;
369
+ readonly domain?: string;
370
+ readonly secure?: boolean;
371
+ }
372
+
373
+ /**
374
+ * Issue a real session for a fully-authenticated user: load memberships,
375
+ * write the `sessions` row (so device-list + revocation cover it), and
376
+ * mint the signed cookie. Shared by password sign-in (post-MFA), sign-up,
377
+ * magic-link, MFA verify, and passkey assertion so rotation + revocation
378
+ * apply uniformly on every path that grants a session.
379
+ */
380
+ export declare const issueUserSession: (user: UserRecord, store: UserStore, config: AuthConfig, meta?: {
381
+ readonly ipAddress?: string | null;
382
+ readonly userAgent?: string | null;
383
+ }) => Effect.Effect<{
384
+ readonly setCookie: string;
385
+ readonly subject: ReturnType<typeof subjectFromUser>;
386
+ }>;
387
+
388
+ export { KeyedSecret }
389
+
390
+ declare interface ListSessionsInput {
391
+ readonly userId: string;
392
+ }
393
+
394
+ /** Default magic-link lifetime — short, since it's an inbox round-trip. */
395
+ export declare const MAGIC_LINK_TTL_S: number;
396
+
397
+ declare interface MagicLinkConsumeInput {
398
+ readonly token: string;
399
+ readonly redirectAfter?: boolean;
400
+ }
401
+
402
+ declare interface MagicLinkRequestInput {
403
+ readonly email: string;
404
+ /** Path the email link points at (the consume endpoint). Default
405
+ * `/auth/magic-link/callback`. */
406
+ readonly callbackPath?: string;
407
+ }
408
+
409
+ /** The slice of `@voltro/plugin-mail`'s `MailService` we depend on. */
410
+ export declare interface MailLike {
411
+ readonly send: (message: {
412
+ readonly to: string;
413
+ readonly subject: string;
414
+ readonly html: string;
415
+ readonly text?: string;
416
+ }) => Effect.Effect<unknown, unknown>;
417
+ }
418
+
419
+ /**
420
+ * Build a `SendEmail` hook backed by `@voltro/plugin-mail`. Wire it into
421
+ * `authRoutesPlugin({ sendEmail: mailSender(mail) })` where `mail` is the
422
+ * yielded `MailService`.
423
+ *
424
+ * ```ts
425
+ * const mail = yield* MailService
426
+ * authRoutesPlugin({ store, secret, sendEmail: mailSender(mail) })
427
+ * ```
428
+ */
429
+ export declare const mailSender: (mail: MailLike) => SendEmail;
430
+
431
+ /**
432
+ * Build the revocation checker over a `UserStore`'s session rows.
433
+ * `authRoutesPlugin` constructs one internally (options via
434
+ * `sessionRevocation`); `voltroPasswordStrategy({ store })` builds or
435
+ * accepts one so the framework's rpc auth chain enforces the same
436
+ * revocations the HTTP routes do.
437
+ */
438
+ export declare const makeSessionRevocationChecker: (store: Pick<UserStore, "findSession">, options?: SessionRevocationOptions) => SessionRevocationChecker;
439
+
440
+ /** One tenant a user belongs to. The active tenant lives on the Subject;
441
+ * the full set drives the switch-tenant menu + the switch-tenant guard. */
442
+ export declare interface MembershipRecord {
443
+ readonly userId: string;
444
+ readonly tenantId: string;
445
+ readonly role: string;
446
+ readonly joinedAt: Date;
447
+ }
448
+
449
+ /** In-memory challenge store. Single-node only — a challenge minted on one
450
+ * replica is invisible to another, so this is the DEV / single-node
451
+ * default. Multi-replica deployments pass `dataStoreChallengeStore`. */
452
+ export declare const memoryChallengeStore: () => ChallengeStore;
453
+
454
+ /**
455
+ * Build a UserStore backed by in-memory Maps. Useful for tests +
456
+ * dev scenarios that don't want to spin up Postgres. NOT for
457
+ * production — restart wipes everything.
458
+ */
459
+ export declare const memoryUserStore: (seed?: ReadonlyArray<UserRecord>) => UserStore;
460
+
461
+ /** Lifetime of the short-lived MFA pending token (the second-factor
462
+ * challenge). Long enough for the user to fetch a TOTP code, short enough
463
+ * that a leaked challenge is near-useless. */
464
+ export declare const MFA_PENDING_TTL_S: number;
465
+
466
+ /** MFA (TOTP) enrolment config. */
467
+ export declare interface MfaConfig {
468
+ /** Issuer label shown in the user's authenticator app (typically your
469
+ * product name, e.g. "Voltro Cloud"). */
470
+ readonly issuer: string;
471
+ }
472
+
473
+ export declare interface MfaEnrollStartInput {
474
+ readonly userId: string;
475
+ /** Issuer for the otpauth URL — typically your product name
476
+ * ("Voltro Cloud"). Shown in the user's authenticator app. */
477
+ readonly issuer: string;
478
+ /** Account name shown in the authenticator app under the issuer.
479
+ * Email is the conventional pick. */
480
+ readonly accountName: string;
481
+ }
482
+
483
+ export declare interface MfaEnrollVerifyInput {
484
+ readonly userId: string;
485
+ readonly code: string;
486
+ }
487
+
488
+ export declare interface MfaRegenerateRecoveryCodesInput {
489
+ readonly userId: string;
490
+ }
491
+
492
+ export declare interface MfaUnenrollInput {
493
+ readonly userId: string;
494
+ }
495
+
496
+ export declare interface MfaVerifyInput {
497
+ /** The pending token returned by `handleSignIn` when MFA was required. */
498
+ readonly pendingToken: string;
499
+ /** A 6-digit TOTP code. Provide this OR `recoveryCode`. */
500
+ readonly code?: string;
501
+ /** A single-use recovery (backup) code — the fallback when the
502
+ * authenticator is unavailable. Provide this OR `code`. */
503
+ readonly recoveryCode?: string;
504
+ readonly redirectAfter?: boolean;
505
+ readonly ipAddress?: string;
506
+ readonly userAgent?: string;
507
+ }
508
+
509
+ export declare interface MintedToken {
510
+ /** The plaintext — put this in the emailed link. Never stored. */
511
+ readonly token: string;
512
+ /** SHA-256 of the plaintext — store this. */
513
+ readonly tokenHash: string;
514
+ /** Unix-ms expiry. */
515
+ readonly expiresAt: Date;
516
+ readonly purpose: TokenPurpose;
517
+ }
518
+
519
+ /**
520
+ * Mint a fresh single-use token. The caller persists `{ tokenHash,
521
+ * userId, purpose, expiresAt }` via `UserStore.insertToken` and emails
522
+ * the plaintext `token` in a link.
523
+ */
524
+ export declare const mintToken: (purpose: TokenPurpose, ttlSeconds?: number) => MintedToken;
525
+
526
+ /**
527
+ * Does this stored hash use parameters weaker than the framework's
528
+ * current cost? `true` means "re-hash on next successful verify". A
529
+ * malformed hash also returns `true` (it should be replaced).
530
+ */
531
+ export declare const needsRehash: (stored: string) => boolean;
532
+
533
+ export declare const newAuthId: (prefix: string) => string;
534
+
535
+ /** Build the `otpauth://totp/...` URL the user's authenticator app
536
+ * scans / imports. Both label + issuer are URL-encoded. */
537
+ export declare const otpauthUrl: (params: {
538
+ readonly issuer: string;
539
+ readonly accountName: string;
540
+ readonly secret: string;
541
+ }) => string;
542
+
543
+ declare interface ParsedHash {
544
+ readonly N: number;
545
+ readonly r: number;
546
+ readonly p: number;
547
+ readonly keyLen: number;
548
+ }
549
+
550
+ /** Parse the scrypt parameters out of a stored hash. Returns null when the
551
+ * string isn't a well-formed `scrypt$N$r$p$salt$derived`. */
552
+ export declare const parseScryptParams: (stored: string) => ParsedHash | null;
553
+
554
+ declare interface PasskeyAssertOptionsInput {
555
+ /** Optional — for a usernameless flow leave it undefined. When present
556
+ * we scope `allowCredentials` to that user's registered passkeys. */
557
+ readonly userId?: string;
558
+ }
559
+
560
+ declare interface PasskeyAssertVerifyInput {
561
+ readonly credentialId: string;
562
+ readonly clientDataJSON: string;
563
+ readonly authenticatorData: string;
564
+ readonly signature: string;
565
+ /** Echo back the userId used at options time, when the flow knew it. */
566
+ readonly userId?: string;
567
+ }
568
+
569
+ export declare interface PasskeyConfig {
570
+ /** Relying-party id — the registrable domain (e.g. `example.com`). */
571
+ readonly rpId: string;
572
+ /** Human-readable RP name shown in the OS prompt. */
573
+ readonly rpName: string;
574
+ /** The exact origin ceremonies must run on (e.g. `https://app.example.com`). */
575
+ readonly origin: string;
576
+ }
577
+
578
+ /** A registered WebAuthn credential. */
579
+ export declare interface PasskeyRecord {
580
+ readonly id: string;
581
+ readonly credentialId: string;
582
+ readonly userId: string;
583
+ readonly publicKey: string;
584
+ readonly counter: number;
585
+ readonly transports?: string | null;
586
+ readonly createdAt: Date;
587
+ readonly lastUsedAt: Date | null;
588
+ }
589
+
590
+ declare interface PasskeyRegisterOptionsInput {
591
+ readonly userId: string;
592
+ readonly userName: string;
593
+ }
594
+
595
+ declare interface PasskeyRegisterVerifyInput {
596
+ readonly userId: string;
597
+ readonly credentialId: string;
598
+ readonly clientDataJSON: string;
599
+ readonly authenticatorData: string;
600
+ readonly transports?: string;
601
+ }
602
+
603
+ /** Default password-reset lifetime — a bit longer. */
604
+ export declare const PASSWORD_RESET_TTL_S: number;
605
+
606
+ export declare class PasswordEmptyError extends PasswordEmptyError_base<{
607
+ readonly message: string;
608
+ }> {
609
+ }
610
+
611
+ declare const PasswordEmptyError_base: new <A extends Record<string, any> = {}>(args: VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => YieldableError & {
612
+ readonly _tag: "PasswordEmptyError";
613
+ } & Readonly<A>;
614
+
615
+ export declare class PasswordHashError extends PasswordHashError_base<{
616
+ readonly message: string;
617
+ readonly cause: unknown;
618
+ }> {
619
+ }
620
+
621
+ declare const PasswordHashError_base: new <A extends Record<string, any> = {}>(args: VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => YieldableError & {
622
+ readonly _tag: "PasswordHashError";
623
+ } & Readonly<A>;
624
+
625
+ declare interface PasswordResetConfirmInput {
626
+ readonly token: string;
627
+ readonly newPassword: string;
628
+ }
629
+
630
+ declare interface PasswordResetRequestInput {
631
+ readonly email: string;
632
+ readonly callbackPath?: string;
633
+ }
634
+
635
+ /**
636
+ * Build a UserStore that reads + writes the auth tables via the provided
637
+ * `SqlClient`. The caller owns the client's lifecycle.
638
+ *
639
+ * Find* flatten database-error channels to `never` (we treat connection
640
+ * issues as "not found" — surfacing lets a brute-forcer tell flaky DB
641
+ * from missing user). `insert` surfaces `UserAlreadyExistsError`.
642
+ */
643
+ export declare const postgresUserStore: (sql: SqlClient) => UserStore;
644
+
645
+ /**
646
+ * Read + verify the session cookie from a Cookie header string.
647
+ * Returns the decoded Subject or null when the cookie is missing,
648
+ * malformed, tampered, or expired. Never throws — callers branch
649
+ * on the boolean.
650
+ *
651
+ * Verification is keyed: the given secret is widened via
652
+ * `sessionSecretsOf`, so a cookie signed with the previous rotation
653
+ * key keeps verifying while `VOLTRO_SESSION_SECRET_PREVIOUS` is set.
654
+ */
655
+ export declare const readSession: (cookieHeader: string | undefined, secret: SessionSecretInput) => Subject | null;
656
+
657
+ /**
658
+ * Keyed read: the full `VerifyResult` — subject + the `kid` that
659
+ * verified + the sliding-window `renew` flag + `exp`/`iat`. The auth
660
+ * routes plugin re-issues the cookie when `renew` is set or when the
661
+ * value verified under the previous key.
662
+ */
663
+ export declare const readSessionKeyed: (cookieHeader: string | undefined, secret: SessionSecretInput, options?: VerifyOptions) => VerifyResult | null;
664
+
665
+ /** Injected rebinder — the app passes `bindConnectionSubject` from
666
+ * `@voltro/runtime` so the plugin stays runtime-agnostic. */
667
+ export declare type RebindConnection = (clientId: number, subject: ReturnType<typeof subjectFromUser>) => void;
668
+
669
+ /** A hashed, single-use MFA recovery (backup) code. Minted alongside
670
+ * enrolment; accepted at sign-in as an alternative to a TOTP code when
671
+ * the authenticator is lost. Stored hashed (SHA-256), like tokens. */
672
+ export declare interface RecoveryCodeRecord {
673
+ readonly id: string;
674
+ readonly userId: string;
675
+ readonly codeHash: string;
676
+ readonly consumedAt: Date | null;
677
+ readonly createdAt: Date;
678
+ }
679
+
680
+ export declare type RegistrationOutcome = {
681
+ readonly ok: true;
682
+ readonly result: RegistrationResult;
683
+ } | {
684
+ readonly ok: false;
685
+ readonly error: WebAuthnError;
686
+ };
687
+
688
+ export declare interface RegistrationResult {
689
+ /** base64url credential id to store. */
690
+ readonly credentialId: string;
691
+ /** base64url COSE public key to store. */
692
+ readonly publicKey: string;
693
+ /** Initial signature counter. */
694
+ readonly counter: number;
695
+ }
696
+
697
+ export declare interface RegistrationVerifyInput {
698
+ /** base64url clientDataJSON from `navigator.credentials.create`. */
699
+ readonly clientDataJSON: string;
700
+ /** base64url authenticatorData (or attestationObject's authData — see
701
+ * the client helper, which forwards the raw authenticatorData). */
702
+ readonly authenticatorData: string;
703
+ /** The challenge the server issued for this ceremony (base64url). */
704
+ readonly expectedChallenge: string;
705
+ /** The exact origin the ceremony must have run on (e.g.
706
+ * `https://app.example.com`). */
707
+ readonly expectedOrigin: string;
708
+ /** The relying-party id (registrable domain, e.g. `example.com`). */
709
+ readonly rpId: string;
710
+ }
711
+
712
+ export declare const resolveSessionSecret: () => string;
713
+
714
+ export declare const resolveSessionSecrets: () => SessionSecrets;
715
+
716
+ declare interface RevokeOtherSessionsInput {
717
+ readonly userId: string;
718
+ /** The session to KEEP — typically the caller's current session id. */
719
+ readonly keepSessionId: string;
720
+ }
721
+
722
+ declare interface RevokeSessionInput {
723
+ readonly userId: string;
724
+ readonly sessionId: string;
725
+ }
726
+
727
+ /**
728
+ * Run the guards in order against `user`, short-circuiting on the FIRST
729
+ * rejection. With no guards (or all allowing) it resolves `{ ok: true }`.
730
+ * The login handlers call this once, just before issuing a session.
731
+ */
732
+ export declare const runSubjectGuards: (guards: ReadonlyArray<SubjectGuard>, user: UserRecord) => Effect.Effect<SubjectGuardVerdict>;
733
+
734
+ export declare type SendEmail = (input: SendEmailInput) => Promise<void>;
735
+
736
+ /** The email hook the plugin calls for magic-link + password-reset. Inject
737
+ * it on the config. The documented default wiring forwards to
738
+ * `@voltro/plugin-mail`'s `MailService.send` (see `mailSender`). */
739
+ export declare interface SendEmailInput {
740
+ readonly to: string;
741
+ readonly subject: string;
742
+ readonly html: string;
743
+ readonly text: string;
744
+ /** Discriminates the flow so a custom sender can branch. */
745
+ readonly kind: 'magic-link' | 'password-reset';
746
+ /** The action URL embedded in the email (also present inside `html`). */
747
+ readonly actionUrl: string;
748
+ }
749
+
750
+ export declare const SESSION_COOKIE_NAME: "voltro:session";
751
+
752
+ /** Read the server-side session id carried on a Subject (stamped by the
753
+ * sign-in/sign-up/magic-link handlers as `metadata.sessionId`). Returns
754
+ * null for non-user subjects and for cookies minted without a session
755
+ * row (e.g. a hand-rolled `issueSession` call) — those can't be checked
756
+ * against the sessions table. */
757
+ export declare const sessionIdOfSubject: (subject: Subject) => string | null;
758
+
759
+ /** A server-side session row — enumerated so apps can list active
760
+ * devices + revoke them. Written on sign-in; deleted on revoke. */
761
+ export declare interface SessionRecord {
762
+ readonly id: string;
763
+ readonly userId: string;
764
+ readonly tenantId: string;
765
+ readonly expiresAt: Date;
766
+ readonly ipAddress?: string | null;
767
+ readonly userAgent?: string | null;
768
+ readonly createdAt: Date;
769
+ readonly lastSeenAt: Date;
770
+ }
771
+
772
+ export declare interface SessionRevocationChecker {
773
+ /** Is this session still live? `false` means the row was revoked (or
774
+ * is past its `expiresAt`) — the caller rejects the cookie. Verdicts
775
+ * are cached for `ttlMs`. */
776
+ readonly isLive: (sessionId: string) => Effect.Effect<boolean>;
777
+ /** Drop the cached verdict for one session — called inline by
778
+ * sign-out / revoke handlers so the kill is immediate on THIS
779
+ * process (other replicas converge within `ttlMs`). */
780
+ readonly invalidate: (sessionId: string) => void;
781
+ /** The effective cache window in milliseconds. */
782
+ readonly ttlMs: number;
783
+ }
784
+
785
+ export declare interface SessionRevocationOptions {
786
+ /** Cache window in milliseconds. A revocation performed elsewhere
787
+ * takes effect on this process within this window. Default 30s.
788
+ * `0` disables caching (every verify hits the store). */
789
+ readonly ttlMs?: number;
790
+ /** Upper bound on cached session ids. When exceeded, expired entries
791
+ * are swept; if still over, the oldest entries are dropped. Default
792
+ * 10 000. */
793
+ readonly maxEntries?: number;
794
+ /** Clock override (epoch milliseconds). Defaults to `Date.now`. A
795
+ * testing seam — lets suites cross the cache window without
796
+ * wall-clock sleeps. */
797
+ readonly now?: () => number;
798
+ }
799
+
800
+ /** Every session helper takes either a bare secret string (single-key)
801
+ * or the keyed `{ current, previous? }` rotation set. */
802
+ export declare type SessionSecretInput = string | SessionSecrets;
803
+
804
+ export { SessionSecrets }
805
+
806
+ /**
807
+ * Normalise a secret input into the keyed set VERIFICATION runs against.
808
+ *
809
+ * - A `SessionSecrets` set passes through unchanged.
810
+ * - A bare string becomes `current` and is widened with the env-driven
811
+ * `previous` key (`VOLTRO_SESSION_SECRET_PREVIOUS` +
812
+ * `VOLTRO_SESSION_KID_PREVIOUS`) so env-var rotation works without
813
+ * the app switching its config to the keyed shape. When the string
814
+ * IS the env secret (`VOLTRO_SESSION_SECRET`), it inherits the env
815
+ * `kid`; otherwise the default kid applies. The comparison is
816
+ * constant-time — secrets never go through `===`.
817
+ */
818
+ export declare const sessionSecretsOf: (secret: SessionSecretInput) => SessionSecrets;
819
+
820
+ export declare interface SignInInput {
821
+ readonly email: string;
822
+ readonly password: string;
823
+ readonly redirectAfter?: boolean;
824
+ /** Recorded on the session row for the device list. */
825
+ readonly ipAddress?: string;
826
+ readonly userAgent?: string;
827
+ }
828
+
829
+ export declare interface SignUpInput {
830
+ readonly email: string;
831
+ readonly password: string;
832
+ readonly tenantId?: string;
833
+ readonly redirectAfter?: boolean;
834
+ }
835
+
836
+ /**
837
+ * Convert a UserRecord into the protocol Subject the framework
838
+ * carries around per-request. Tenant id flows through; user type
839
+ * is locked to 'user'.
840
+ *
841
+ * The user's tenant memberships, when supplied, are carried in
842
+ * `metadata.memberships` (the protocol Subject's metadata slot — the
843
+ * framework never reads it, but app code + the switch-tenant menu do).
844
+ * Pass an explicit `tenantId` to make the ACTIVE tenant differ from the
845
+ * user's home tenant (post switch-tenant rebind).
846
+ */
847
+ export declare const subjectFromUser: (user: UserRecord, options?: {
848
+ readonly tenantId?: string;
849
+ readonly memberships?: ReadonlyArray<MembershipRecord>;
850
+ }) => Subject;
851
+
852
+ /**
853
+ * A post-authentication subject guard: given the authenticated user,
854
+ * decide whether the login may proceed. Runs after the credential check,
855
+ * before the session is issued. Effect-native so a guard can do IO (e.g.
856
+ * read a fresh flag) without a Promise bridge.
857
+ */
858
+ export declare type SubjectGuard = (user: UserRecord) => Effect.Effect<SubjectGuardVerdict>;
859
+
860
+ /** A guard's decision. Allow, or reject with a stable machine `code`
861
+ * (surfaced as the 403 body's `error`) plus a human `message`. */
862
+ export declare type SubjectGuardVerdict = {
863
+ readonly ok: true;
864
+ } | {
865
+ readonly ok: false;
866
+ readonly code: string;
867
+ readonly message: string;
868
+ };
869
+
870
+ /** Read the memberships carried on a Subject (set by `subjectFromUser`).
871
+ * Returns [] when none are present. */
872
+ export declare const subjectMemberships: (subject: Subject) => ReadonlyArray<{
873
+ tenantId: string;
874
+ role: string;
875
+ }>;
876
+
877
+ declare interface SwitchTenantInput {
878
+ readonly userId: string;
879
+ /** The connection (clientId) whose subject to rebind, when invoked over
880
+ * the live WS path. Absent on the pure HTTP path (cookie re-issue only). */
881
+ readonly clientId?: number;
882
+ readonly targetTenantId: string;
883
+ /** The caller's current server-side session id — carried onto the
884
+ * re-issued cookie so the request-time revocation check keeps
885
+ * covering the session after a tenant switch. */
886
+ readonly sessionId?: string;
887
+ }
888
+
889
+ export declare type TokenPurpose = 'magic-link' | 'password-reset' | 'mfa-pending';
890
+
891
+ /** A single-use, hashed, expiring token (magic-link / password-reset). */
892
+ export declare interface TokenRecord {
893
+ readonly id: string;
894
+ readonly tokenHash: string;
895
+ readonly userId: string;
896
+ readonly purpose: TokenPurpose;
897
+ readonly expiresAt: Date;
898
+ readonly consumedAt: Date | null;
899
+ readonly createdAt: Date;
900
+ }
901
+
902
+ export declare class TotpVerifyError extends TotpVerifyError_base<{
903
+ readonly reason: 'invalidCode' | 'malformedSecret' | 'replay';
904
+ }> {
905
+ }
906
+
907
+ declare const TotpVerifyError_base: new <A extends Record<string, any> = {}>(args: VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => YieldableError & {
908
+ readonly _tag: "TotpVerifyError";
909
+ } & Readonly<A>;
910
+
911
+ export declare class UserAlreadyExistsError extends UserAlreadyExistsError_base<{
912
+ readonly email: string;
913
+ }> {
914
+ }
915
+
916
+ declare const UserAlreadyExistsError_base: new <A extends Record<string, any> = {}>(args: VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => YieldableError & {
917
+ readonly _tag: "UserAlreadyExistsError";
918
+ } & Readonly<A>;
919
+
920
+ export declare class UserNotFoundError extends UserNotFoundError_base<{
921
+ readonly userId: string;
922
+ }> {
923
+ }
924
+
925
+ declare const UserNotFoundError_base: new <A extends Record<string, any> = {}>(args: VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => YieldableError & {
926
+ readonly _tag: "UserNotFoundError";
927
+ } & Readonly<A>;
928
+
929
+ export declare interface UserRecord {
930
+ readonly id: string;
931
+ readonly email: string;
932
+ readonly passwordHash: string;
933
+ readonly tenantId: string;
934
+ readonly createdAt: Date;
935
+ /** Base32-encoded TOTP secret. `null` = MFA not enrolled. Set by
936
+ * `setMfaSecret()` from the enrolment handler. Stored as plaintext. */
937
+ readonly mfaSecret?: string | null;
938
+ /** Timestamp the user completed TOTP enrolment (first successful
939
+ * verify). Distinct from the secret being set — a half-completed
940
+ * enrolment leaves `mfaSecret` populated but this null. */
941
+ readonly mfaEnrolledAt?: Date | null;
942
+ /** Optional lifecycle flag read by post-authentication subject guards.
943
+ * Set (a `Date`) means the account is DEACTIVATED — `@voltro/plugin-
944
+ * deactivation`'s `deactivationGuard()` vetoes login when it's non-null.
945
+ * `null` / absent means active. A store populates it from the app's
946
+ * `deactivatedAt` column (added by the `deactivation()` mixin) when that
947
+ * column exists; stores without the column simply leave it undefined and
948
+ * the guard treats the account as active. */
949
+ readonly deactivatedAt?: Date | null;
950
+ }
951
+
952
+ export declare interface UserStore {
953
+ readonly findByEmail: (email: string) => Effect.Effect<UserRecord | null>;
954
+ readonly findById: (id: string) => Effect.Effect<UserRecord | null>;
955
+ readonly insert: (user: Omit<UserRecord, 'createdAt'>) => Effect.Effect<UserRecord, UserAlreadyExistsError>;
956
+ /** Replace a user's stored password hash. Used by password-reset AND
957
+ * by rehash-on-verify (silently upgrading an under-cost hash on a
958
+ * successful sign-in). */
959
+ readonly updatePassword: (userId: string, newHash: string) => Effect.Effect<UserRecord, UserNotFoundError>;
960
+ /** Stash a freshly-generated TOTP secret on the user. Called by
961
+ * the MFA enrolment handler before the user verifies the first
962
+ * code. */
963
+ readonly setMfaSecret: (userId: string, secret: string) => Effect.Effect<UserRecord, UserNotFoundError>;
964
+ /** Mark MFA as fully enrolled (called after the first successful
965
+ * TOTP verify completes the enrolment ceremony). */
966
+ readonly markMfaEnrolled: (userId: string) => Effect.Effect<UserRecord, UserNotFoundError>;
967
+ /** Wipe the secret + enrolledAt. Used by /account/security's
968
+ * "remove MFA" action. */
969
+ readonly clearMfa: (userId: string) => Effect.Effect<UserRecord, UserNotFoundError>;
970
+ /** Every tenant the user belongs to. */
971
+ readonly listMemberships: (userId: string) => Effect.Effect<ReadonlyArray<MembershipRecord>>;
972
+ /** Idempotent grant: inserts the [userId, tenantId] membership or
973
+ * updates its role if it already exists. The DB-level composite
974
+ * UNIQUE on `[userId, tenantId]` (see `membershipsTable`) is the
975
+ * source of truth; this upsert converges to it. */
976
+ readonly addMembership: (membership: Omit<MembershipRecord, 'joinedAt'>) => Effect.Effect<MembershipRecord>;
977
+ /** Resolve the role for a [userId, tenantId] pair, or null when the
978
+ * user is NOT a member. The switch-tenant handler calls this to
979
+ * validate the requested tenant before rebinding the connection. */
980
+ readonly membershipRole: (userId: string, tenantId: string) => Effect.Effect<string | null>;
981
+ /** Active sessions for a user (most recent first). */
982
+ readonly listSessions: (userId: string) => Effect.Effect<ReadonlyArray<SessionRecord>>;
983
+ /** Record a session on sign-in. */
984
+ readonly insertSession: (session: Omit<SessionRecord, 'createdAt' | 'lastSeenAt'>) => Effect.Effect<SessionRecord>;
985
+ /** Look up one session row by id. The request-time revocation check
986
+ * (`makeSessionRevocationChecker`) calls this — a missing row means
987
+ * the session was revoked. */
988
+ readonly findSession: (sessionId: string) => Effect.Effect<SessionRecord | null>;
989
+ /** Slide a session row forward: bump `expiresAt` + `lastSeenAt`. Called
990
+ * when the sliding-window renewal re-issues the cookie, so the row
991
+ * outlives the renewed cookie and the revocation check keeps passing. */
992
+ readonly touchSession: (sessionId: string, expiresAt: Date) => Effect.Effect<void>;
993
+ /** Revoke a single session — only if it belongs to `userId` (so a
994
+ * caller can't revoke another user's session by id-guessing). Returns
995
+ * whether a row was removed. */
996
+ readonly revokeSession: (userId: string, sessionId: string) => Effect.Effect<boolean>;
997
+ /** Revoke every session for the user EXCEPT `keepSessionId` — the
998
+ * "sign out other devices" action. Returns the count removed. */
999
+ readonly revokeAllOtherSessions: (userId: string, keepSessionId: string) => Effect.Effect<number>;
1000
+ /** Persist a single-use token (already hashed). */
1001
+ readonly insertToken: (token: Omit<TokenRecord, 'consumedAt' | 'createdAt'>) => Effect.Effect<TokenRecord>;
1002
+ /** Atomically redeem a token by its hash + purpose: returns the row
1003
+ * only when it exists, matches the purpose, is unexpired, and was not
1004
+ * already consumed — and marks it consumed in the same step. Returns
1005
+ * null otherwise (single-use guard). */
1006
+ readonly consumeToken: (tokenHash: string, purpose: TokenPurpose) => Effect.Effect<TokenRecord | null>;
1007
+ /** Replace the user's recovery codes with a fresh (already hashed) set.
1008
+ * Called at enrolment / regeneration — wipes any prior codes so the
1009
+ * displayed set is the only valid one. */
1010
+ readonly replaceRecoveryCodes: (userId: string, codeHashes: ReadonlyArray<string>) => Effect.Effect<void>;
1011
+ /** Atomically redeem one recovery code by its hash: returns true only
1012
+ * when an unconsumed row for `userId` matched — and marks it consumed
1013
+ * in the same step (single-use). false otherwise. */
1014
+ readonly consumeRecoveryCode: (userId: string, codeHash: string) => Effect.Effect<boolean>;
1015
+ /** Count the user's remaining (unconsumed) recovery codes — surfaced so
1016
+ * the UI can warn when the user is running low. */
1017
+ readonly countRecoveryCodes: (userId: string) => Effect.Effect<number>;
1018
+ readonly listPasskeys: (userId: string) => Effect.Effect<ReadonlyArray<PasskeyRecord>>;
1019
+ readonly findPasskey: (credentialId: string) => Effect.Effect<PasskeyRecord | null>;
1020
+ readonly insertPasskey: (passkey: Omit<PasskeyRecord, 'createdAt' | 'lastUsedAt'>) => Effect.Effect<PasskeyRecord>;
1021
+ /** Atomically bump the stored signature counter, guarding against a
1022
+ * cloned authenticator (WebAuthn §6.1.1): the update only applies when
1023
+ * `newCounter` strictly exceeds the stored counter. Returns whether a
1024
+ * row was advanced. `false` with an unchanged stored counter is the
1025
+ * clone/replay signal the assertion handler rejects on — the check and
1026
+ * the write are ONE atomic statement at the store, so two replicas
1027
+ * racing the same counter can't both succeed. */
1028
+ readonly advancePasskeyCounter: (credentialId: string, newCounter: number) => Effect.Effect<boolean>;
1029
+ }
1030
+
1031
+ /** Verify a passkey assertion (sign-in). */
1032
+ export declare const verifyAssertion: (input: AssertionVerifyInput) => AssertionOutcome;
1033
+
1034
+ /**
1035
+ * Verify a state-changing request's CSRF protection. Both the header
1036
+ * value and the cookie value must (a) be the SAME token and (b) carry a
1037
+ * valid HMAC. Returns true only when both hold.
1038
+ */
1039
+ export declare const verifyCsrf: (headerToken: string | undefined, cookieToken: string | undefined, secret: string) => boolean;
1040
+
1041
+ /**
1042
+ * Verify a plaintext password against a stored hash.
1043
+ *
1044
+ * Uses `timingSafeEqual` for the comparison so attackers can't
1045
+ * fingerprint correct prefixes via response-time analysis.
1046
+ *
1047
+ * Returns `Effect<boolean, never>` — parse errors or scrypt errors
1048
+ * collapse to `false` because surfacing them lets attackers
1049
+ * fingerprint malformed-vs-mismatched, which leaks structural info.
1050
+ */
1051
+ export declare const verifyPassword: (plaintext: string, stored: string) => Effect.Effect<boolean>;
1052
+
1053
+ /**
1054
+ * Verify a password and, when it matches an under-cost hash, return a
1055
+ * freshly-minted replacement. The caller wires `rehash` into
1056
+ * `UserStore.updatePassword(userId, rehash)`. Never throws — parse /
1057
+ * scrypt failures collapse to `{ valid: false }`.
1058
+ */
1059
+ export declare const verifyPasswordWithRehash: (plaintext: string, stored: string) => Effect.Effect<VerifyWithRehashResult>;
1060
+
1061
+ /** Verify a passkey registration. Attestation is intentionally NOT
1062
+ * checked (90% path); everything binding the credential to this
1063
+ * origin/rpId IS. */
1064
+ export declare const verifyRegistration: (input: RegistrationVerifyInput) => RegistrationOutcome;
1065
+
1066
+ export { VerifyResult }
1067
+
1068
+ /** Verify a TOTP code against the user's secret. Returns an Effect
1069
+ * that fails with `TotpVerifyError` on mismatch / malformed secret.
1070
+ * Accepts ±`TOTP_SKEW` steps (default ±1 → ±30s window) to tolerate
1071
+ * clock drift. */
1072
+ export declare const verifyTotpCode: (secretBase32: string, code: string, nowMs?: number) => Effect.Effect<true, TotpVerifyError>;
1073
+
1074
+ export declare interface VerifyWithRehashResult {
1075
+ /** Whether the password matched the stored hash. */
1076
+ readonly valid: boolean;
1077
+ /** A freshly-minted hash under the current cost, present ONLY when the
1078
+ * password was valid AND the stored hash was below current cost. The
1079
+ * caller persists it via `UserStore.updatePassword`. */
1080
+ readonly rehash?: string;
1081
+ }
1082
+
1083
+ export declare const VOLTRO_DEV_SESSION_SECRET: "voltro-dev-session-secret-32b-DO-NOT-USE-IN-PROD";
1084
+
1085
+ export declare const voltroPasswordStrategy: (options?: VoltroPasswordStrategyOptions) => AuthStrategy;
1086
+
1087
+ export declare interface VoltroPasswordStrategyOptions {
1088
+ /** Secret(s) used to verify the cookie: a bare string OR a keyed
1089
+ * `SessionSecrets` set. Defaults to `resolveSessionSecret()` which
1090
+ * reads `VOLTRO_SESSION_SECRET` env with a dev fallback — same
1091
+ * resolver used by the mint side, so there's no drift between
1092
+ * issuing and verifying. A bare string is widened with the
1093
+ * `VOLTRO_SESSION_SECRET_PREVIOUS` env var (`sessionSecretsOf`),
1094
+ * so env-var rotation applies either way. */
1095
+ readonly secret?: SessionSecretInput;
1096
+ /** Cookie name. Defaults to `voltro:session`. Override when running
1097
+ * the strategy alongside another tenant on the same domain. */
1098
+ readonly cookieName?: string;
1099
+ /** The user store whose `sessions` rows back the request-time
1100
+ * revocation check. When absent, verification stays purely
1101
+ * stateless — a revoked session's cookie keeps working until it
1102
+ * expires. */
1103
+ readonly store?: UserStore;
1104
+ /** Tuning for the revocation check: options for the built-in TTL
1105
+ * cache (default window 30s), or a pre-built checker — pass the
1106
+ * auth plugin's own checker to share one cache between the HTTP
1107
+ * routes and the rpc auth chain. Ignored without a `store`
1108
+ * (unless a checker is passed directly). */
1109
+ readonly revocation?: SessionRevocationOptions | SessionRevocationChecker;
1110
+ }
1111
+
1112
+ export declare type WebAuthnError = 'bad-client-data' | 'wrong-type' | 'challenge-mismatch' | 'origin-mismatch' | 'rpid-mismatch' | 'user-not-present' | 'bad-auth-data' | 'no-credential' | 'unsupported-key' | 'bad-signature' | 'counter-replay';
1113
+
1114
+ export { }