@voltro/plugin-auth 0.32.0 → 0.34.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/dist/index.d.ts CHANGED
@@ -1,15 +1,28 @@
1
1
  import { AuthStrategy } from '@voltro/protocol';
2
+ import { ConnectionCredential } from '@voltro/protocol';
2
3
  import { Effect } from 'effect';
3
4
  import { KeyedSecret } from '@voltro/protocol/session';
4
5
  import { SessionSecrets } from '@voltro/protocol/session';
5
6
  import { SqlClient } from '@effect/sql/SqlClient';
6
7
  import { Subject } from '@voltro/protocol';
8
+ import { SubjectIdentity } from '@voltro/protocol';
7
9
  import { VerifyOptions } from '@voltro/protocol/session';
8
10
  import { VerifyResult } from '@voltro/protocol/session';
9
11
  import { VoidIfEmpty } from 'effect/Types';
10
12
  import { VoltroPlugin } from '@voltro/protocol';
11
13
  import { YieldableError } from 'effect/Cause';
12
14
 
15
+ export declare interface AcceptInvitationInput {
16
+ readonly token: string;
17
+ /** The authenticated caller's id. Absent means nobody is signed in. */
18
+ readonly userId?: string;
19
+ }
20
+
21
+ export declare interface AcceptInvitationWithSignUpInput {
22
+ readonly token: string;
23
+ readonly password: string;
24
+ }
25
+
13
26
  export declare type AssertionOutcome = {
14
27
  readonly ok: true;
15
28
  readonly result: AssertionResult;
@@ -94,6 +107,17 @@ export declare interface AuthConfig {
94
107
  /** Injectable clock (epoch-ms) for the lockout counter. Default `Date.now`;
95
108
  * tests pass a controllable one to exercise window/lock expiry. */
96
109
  readonly now?: () => number;
110
+ /**
111
+ * Email verification. OFF by default (`policy: 'off'`) — the endpoints and
112
+ * the `emailVerifiedAt` column exist, nothing is gated.
113
+ *
114
+ * `'strict'` is enforced through `subjectGuards`, so it covers every path
115
+ * that issues a session; `authRoutesPlugin` installs that guard for you.
116
+ * Hand-wiring the handlers? Add `emailVerificationGuard(config)` to
117
+ * `subjectGuards` yourself — setting the policy alone changes only what the
118
+ * session is MARKED with, not who may log in.
119
+ */
120
+ readonly emailVerification?: EmailVerificationConfig;
97
121
  }
98
122
 
99
123
  /**
@@ -128,13 +152,36 @@ export declare interface AuthRoutesPluginOptions extends AuthConfig {
128
152
  * enrolment routes. */
129
153
  readonly mfa?: MfaConfig;
130
154
  /** Rebind hook for switch-tenant on the live WS path — pass
131
- * `bindConnectionSubject` from `@voltro/runtime`. */
155
+ * `bindConnectionCredential` from `@voltro/runtime`. It receives the cookie
156
+ * this plugin just minted, not a Subject: the connection then presents the
157
+ * same credential a reconnecting browser would, and the auth chain judges it
158
+ * the same way (revocation included). */
132
159
  readonly rebind?: RebindConnection;
133
160
  /** Route prefix. Default `/auth`. */
134
161
  readonly prefix?: string;
135
162
  /** Tuning for the request-time session-revocation check (TTL cache
136
163
  * window, default 30s; `now` is a testing seam). */
137
164
  readonly sessionRevocation?: SessionRevocationOptions;
165
+ /**
166
+ * Tenant invitations. PRESENT (even as `{}`) mounts the five
167
+ * `/auth/invitations/*` routes and arms the retention bound on the
168
+ * `invitations` table; ABSENT mounts nothing.
169
+ *
170
+ * Opt-in by presence, like `mfa` and `passkey`, and for the same reason:
171
+ * the table ships in `authTables` but an app that has not spread it would
172
+ * otherwise get 500s from a route it never asked for.
173
+ */
174
+ readonly invitations?: InvitationsConfig;
175
+ /**
176
+ * User impersonation ("log in as"). PRESENT mounts `/auth/impersonate/start`
177
+ * + `/stop`, arms the retention bound on `impersonationGrants`, and closes
178
+ * the account-security routes to impersonated sessions; ABSENT mounts
179
+ * nothing and the block below cannot fire.
180
+ *
181
+ * Both `authority` and `audit` are REQUIRED — there is no way to switch this
182
+ * on while leaving either unanswered. See `ImpersonationConfig`.
183
+ */
184
+ readonly impersonation?: ImpersonationConfig;
138
185
  /** Disambiguates multiple instances of this plugin in one app. */
139
186
  readonly name?: string;
140
187
  }
@@ -157,6 +204,15 @@ export declare const clearSessionCookie: (options?: IssueSessionOptions) => stri
157
204
  * rotation env vars. */
158
205
  export declare const configSessionSecrets: (config: AuthConfig) => SessionSecrets;
159
206
 
207
+ export declare interface CreateInvitationInput {
208
+ /** The authenticated caller — resolved from their session, never from the
209
+ * request body. */
210
+ readonly inviterUserId: string;
211
+ readonly tenantId: string;
212
+ readonly email: string;
213
+ readonly role: string;
214
+ }
215
+
160
216
  export declare const CSRF_COOKIE_NAME: "voltro:csrf";
161
217
 
162
218
  export declare const CSRF_HEADER_NAME: "x-csrf-token";
@@ -180,6 +236,122 @@ export declare const CSRF_HEADER_NAME: "x-csrf-token";
180
236
  */
181
237
  export declare const dataStoreChallengeStore: (sql: SqlClient) => ChallengeStore;
182
238
 
239
+ /** Default set of roles allowed to invite at all. */
240
+ export declare const DEFAULT_INVITER_ROLES: ReadonlyArray<string>;
241
+
242
+ /** Default role ordering, most privileged first. */
243
+ export declare const DEFAULT_ROLE_RANK: ReadonlyArray<string>;
244
+
245
+ /** The shipped rank-based rule. Exported so an app extending it can call it. */
246
+ export declare const defaultCanGrantRole: (ctx: GrantRoleContext, rank?: ReadonlyArray<string>) => boolean;
247
+
248
+ /** The 403 body's `error` when `'strict'` refuses a login. */
249
+ export declare const EMAIL_NOT_VERIFIED_CODE: "email_not_verified";
250
+
251
+ /** The subject-metadata key carrying the verification mark. */
252
+ export declare const EMAIL_VERIFIED_METADATA_KEY: "emailVerified";
253
+
254
+ /** Default gap between two verification emails to one user. */
255
+ export declare const EMAIL_VERIFY_RESEND_COOLDOWN_S = 60;
256
+
257
+ /**
258
+ * Default email-verification lifetime — 24 hours.
259
+ *
260
+ * Longer than the other two on purpose, and the asymmetry is the point. A
261
+ * magic link and a reset link are CREDENTIALS: whoever holds one signs in, so
262
+ * a short fuse is worth the friction of asking for another. A verification
263
+ * link grants nothing — redeeming it only asserts that the address reaches its
264
+ * owner — so the cost of a short lifetime is all friction and no security, paid
265
+ * by exactly the users who check mail once a day.
266
+ */
267
+ export declare const EMAIL_VERIFY_TTL_S: number;
268
+
269
+ /** Whether the verification feature does anything at all for this app. */
270
+ export declare const emailVerificationActive: (config: EmailVerificationConfig | undefined) => boolean;
271
+
272
+ export declare interface EmailVerificationConfig {
273
+ /** Default `'off'` — see the header for why a stricter default is an
274
+ * outage rather than a safer choice. */
275
+ readonly policy?: EmailVerificationPolicy;
276
+ /** Lifetime of a verification link. Default {@link EMAIL_VERIFY_TTL_S} (24h). */
277
+ readonly ttlSeconds?: number;
278
+ /** Path the emailed link points at. Default `/auth/verify-email/callback`. */
279
+ readonly callbackPath?: string;
280
+ /**
281
+ * Minimum gap between two verification emails to the same user. Default 60s.
282
+ *
283
+ * The only bound on an endpoint that sends mail to an address chosen by an
284
+ * unauthenticated caller. Without it, "resend" is a mail-bomb primitive
285
+ * pointed at any address the attacker knows has an account, and the cost is
286
+ * carried by your sending reputation.
287
+ */
288
+ readonly resendCooldownSeconds?: number;
289
+ /** Send the verification email automatically on sign-up. Default: true
290
+ * whenever `policy` is not `'off'`. */
291
+ readonly sendOnSignUp?: boolean;
292
+ /**
293
+ * Accounts created STRICTLY BEFORE this instant count as verified.
294
+ *
295
+ * The migration seam, and the reason `'strict'` is adoptable at all: an app
296
+ * turning the policy on has a table full of `emailVerifiedAt IS NULL` rows
297
+ * belonging to real users who did nothing wrong. Set this to the deploy
298
+ * instant and only accounts created from then on have to prove anything.
299
+ *
300
+ * It is deliberately a CONFIG value rather than a backfill we run for you:
301
+ * an app that genuinely wants its existing users to re-verify says so by
302
+ * leaving this unset, and one that does not is spared writing to every row
303
+ * of its users table.
304
+ */
305
+ readonly exemptAccountsCreatedBefore?: Date;
306
+ }
307
+
308
+ /** The `AuthConfig` slice these handlers read. Declared structurally so this
309
+ * module does not import `handlers.ts` (which imports this one). */
310
+ export declare interface EmailVerificationDeps {
311
+ readonly appBaseUrl?: string;
312
+ readonly sendEmail?: (input: {
313
+ readonly to: string;
314
+ readonly subject: string;
315
+ readonly html: string;
316
+ readonly text: string;
317
+ readonly kind: 'magic-link' | 'password-reset' | 'email-verify' | 'invitation';
318
+ readonly actionUrl: string;
319
+ }) => Promise<void>;
320
+ readonly emailVerification?: EmailVerificationConfig;
321
+ readonly now?: () => number;
322
+ }
323
+
324
+ /**
325
+ * The `'strict'` enforcement, as a post-authentication subject guard.
326
+ *
327
+ * A guard rather than a check inside `handleSignIn` on purpose: the seam
328
+ * already runs at EVERY path that grants a session to a pre-existing user
329
+ * (password, MFA verify, magic-link consume, passkey assertion), so wiring it
330
+ * here covers all four and cannot be forgotten on the fifth. `authRoutesPlugin`
331
+ * appends it automatically under `'strict'`; an app hand-wiring the handlers
332
+ * adds it to `subjectGuards` itself.
333
+ *
334
+ * Under `'off'` and `'soft'` it returns a guard that always allows, rather
335
+ * than the caller having to decide whether to install one — a policy read in
336
+ * two places is a policy that can disagree with itself.
337
+ */
338
+ export declare const emailVerificationGuard: (config: EmailVerificationConfig | undefined) => SubjectGuard;
339
+
340
+ /** What an account with an unproven address may do. See the header. */
341
+ export declare type EmailVerificationPolicy = 'off' | 'soft' | 'strict';
342
+
343
+ export declare interface EmailVerifyConfirmInput {
344
+ readonly token: string;
345
+ }
346
+
347
+ export declare interface EmailVerifyRequestInput {
348
+ /** The address to send to. Either this or `userId`. */
349
+ readonly email?: string;
350
+ /** The authenticated caller's id — the "resend to me" path, which needs no
351
+ * address in the request and therefore cannot be pointed at someone else. */
352
+ readonly userId?: string;
353
+ }
354
+
183
355
  /** Mint a fresh WebAuthn challenge (base64url, 32 random bytes). The
184
356
  * server stashes it (keyed by user / ceremony) and hands it to the
185
357
  * browser; `verifyRegistration` / `verifyAssertion` check it back. */
@@ -208,6 +380,14 @@ export declare const generateTotpSecret: () => string;
208
380
  */
209
381
  export declare const getAuthSessionStrategy: (plugins: ReadonlyArray<VoltroPlugin>) => AuthStrategy | null;
210
382
 
383
+ export declare interface GrantRoleContext {
384
+ /** The inviter's role in THIS tenant, as read from `memberships`. */
385
+ readonly inviterRole: string;
386
+ /** The role the invitation would carry. */
387
+ readonly requestedRole: string;
388
+ readonly tenantId: string;
389
+ }
390
+
211
391
  /**
212
392
  * Run the config's post-authentication subject guards against a resolved
213
393
  * user. Returns a 403 `HandlerResult` (carrying the vetoing guard's `code`
@@ -221,6 +401,65 @@ export declare const getAuthSessionStrategy: (plugins: ReadonlyArray<VoltroPlugi
221
401
  */
222
402
  export declare const guardSubject: (user: UserRecord, config: AuthConfig) => Effect.Effect<HandlerResult | null>;
223
403
 
404
+ /**
405
+ * Accept an invitation as an ALREADY-AUTHENTICATED user.
406
+ *
407
+ * The three refusals, in the order they matter:
408
+ *
409
+ * - **401 `authentication_required`** when nobody is signed in. The response
410
+ * carries the invited `email` so the sign-in page can pre-fill it — that
411
+ * address is already in the recipient's mailbox, so echoing it to the
412
+ * holder of the token reveals nothing they do not have.
413
+ * - **403 `invitation_email_mismatch`** when the signed-in user's address is
414
+ * not the invited one. This is the property that makes a leaked or
415
+ * forwarded link useless: without it, the first person to open the link
416
+ * while signed into any account joins the tenant.
417
+ * - **401 `invalid_or_expired_invitation`** for a token that is unknown,
418
+ * spent, revoked or lapsed — one answer for all four, because
419
+ * distinguishing them tells a holder of a dead token which kind of dead.
420
+ *
421
+ * The membership is written with the role from the INVITATION ROW. Note the
422
+ * ordering: the invitation is redeemed FIRST (the single-use UPDATE), and only
423
+ * a winning redemption goes on to write the membership. Reversed, two
424
+ * concurrent accepts would both write.
425
+ */
426
+ export declare const handleAcceptInvitation: (input: AcceptInvitationInput, store: UserStore, deps?: InvitationDeps) => Effect.Effect<HandlerResultShape_2>;
427
+
428
+ /**
429
+ * Accept an invitation by CREATING the account it was addressed to — the
430
+ * "you've been invited, choose a password" flow.
431
+ *
432
+ * The account's email is taken from the INVITATION, never from the request:
433
+ * an input-supplied address would let the holder of a leaked token create an
434
+ * account under an address of their choosing and have it join the tenant. For
435
+ * the same reason there is no `tenantId` input — it is the invitation's.
436
+ *
437
+ * The new account is created ALREADY VERIFIED: the invitation arrived in that
438
+ * mailbox and came back, which is the same proof a verification link provides.
439
+ *
440
+ * Refuses with 409 when the address already has an account, and directs the
441
+ * caller to sign in and use {@link handleAcceptInvitation} — creating a second
442
+ * account for one address is not a fallback, it is a duplicate identity.
443
+ */
444
+ export declare const handleAcceptInvitationWithSignUp: (input: AcceptInvitationWithSignUpInput, store: UserStore, deps: InvitationDeps & {
445
+ /** Hash a plaintext password. Injected so this module does not import the
446
+ * password module's Effect error channel into the invitation surface. */
447
+ readonly hashPassword: (plaintext: string) => Effect.Effect<string, unknown>;
448
+ /** Minimum password length, mirroring `handleSignUp`. Default 8. */
449
+ readonly minPasswordLength?: number;
450
+ }) => Effect.Effect<HandlerResultShape_2>;
451
+
452
+ /**
453
+ * Create + email an invitation.
454
+ *
455
+ * Refuses when the caller may not invite into this tenant, when the requested
456
+ * role is above what they may grant, or when the tenant is at its pending cap.
457
+ * A re-invite SUPERSEDES: every pending invitation for the same (tenant,
458
+ * email) is revoked first, so the address never holds two live tokens and
459
+ * "resend" cannot be used to accumulate them.
460
+ */
461
+ export declare const handleCreateInvitation: (input: CreateInvitationInput, store: UserStore, deps: InvitationDeps) => Effect.Effect<HandlerResultShape_2>;
462
+
224
463
  /**
225
464
  * Issue a CSRF token. Returns the token in the JSON body AND sets a
226
465
  * readable (non-HttpOnly) `voltro:csrf` cookie. The SPA reads the cookie
@@ -229,6 +468,52 @@ export declare const guardSubject: (user: UserRecord, config: AuthConfig) => Eff
229
468
  */
230
469
  export declare const handleCsrf: (config: AuthConfig) => HandlerResult;
231
470
 
471
+ /**
472
+ * Redeem a verification link.
473
+ *
474
+ * Issues NO session — see the header. The response says whether the address is
475
+ * now verified, and the client sends the user to sign in.
476
+ */
477
+ export declare const handleEmailVerifyConfirm: (input: EmailVerifyConfirmInput, store: UserStore) => Effect.Effect<HandlerResultShape>;
478
+
479
+ /**
480
+ * Request (or re-request) a verification link.
481
+ *
482
+ * Always 202, whether or not the address has an account and whether or not the
483
+ * cooldown suppressed the send — same reasoning as magic-link and
484
+ * password-reset. Two things leak from a status that varies: which addresses
485
+ * are registered, and (through the cooldown) whether one was recently active.
486
+ * An already-verified user is also answered 202 and sent nothing.
487
+ */
488
+ export declare const handleEmailVerifyRequest: (input: EmailVerifyRequestInput, store: UserStore, deps: EmailVerificationDeps) => Effect.Effect<HandlerResultShape>;
489
+
490
+ export declare const handleImpersonationStart: (input: ImpersonationStartInput, store: UserStore, deps: ImpersonationDeps) => Effect.Effect<HandlerResultShape_3>;
491
+
492
+ /**
493
+ * End an impersonation and put the impersonator back in their own identity.
494
+ *
495
+ * Three things happen, in this order, and all three matter:
496
+ *
497
+ * 1. the grant is CLOSED (`endedAt`), so the record says when it stopped;
498
+ * 2. the impersonated `sessions` row is DELETED and dropped from the
499
+ * revocation cache — so a copy of that cookie taken during the grant is
500
+ * dead immediately, not at its own expiry;
501
+ * 3. the actor's ORIGINAL session row (never revoked, still ticking) gets a
502
+ * fresh cookie for its REMAINING lifetime.
503
+ *
504
+ * (3) is why the actor's session is left alive throughout. Minting them a new
505
+ * session instead would silently extend their login by a full TTL every time
506
+ * they used the support tool. If their own session has expired or been revoked
507
+ * meanwhile, the response clears the cookie — being signed out is the correct
508
+ * outcome, and it is strictly better than staying signed in as somebody else.
509
+ */
510
+ export declare const handleImpersonationStop: (input: ImpersonationStopInput, store: UserStore, deps: ImpersonationDeps) => Effect.Effect<HandlerResultShape_3>;
511
+
512
+ export declare const handleListInvitations: (input: {
513
+ readonly callerUserId: string;
514
+ readonly tenantId: string;
515
+ }, store: UserStore, deps: InvitationDeps) => Effect.Effect<HandlerResultShape_2>;
516
+
232
517
  export declare const handleListMemberships: (input: {
233
518
  readonly userId: string;
234
519
  }, store: UserStore) => Effect.Effect<HandlerResult>;
@@ -292,8 +577,26 @@ export declare const handlePasswordResetConfirm: (input: PasswordResetConfirmInp
292
577
  * a single-use reset token when the user exists. */
293
578
  export declare const handlePasswordResetRequest: (input: PasswordResetRequestInput, store: UserStore, config: AuthConfig) => Effect.Effect<HandlerResult>;
294
579
 
580
+ /**
581
+ * What is behind this link, without redeeming it — the accept page's read.
582
+ *
583
+ * Deliberately unauthenticated and deliberately thin: tenant, role, the
584
+ * invited address and whether it is still live. It does NOT reveal whether an
585
+ * account already exists for the address, because that would turn a leaked
586
+ * invitation into an account-existence oracle for a third party.
587
+ */
588
+ export declare const handlePreviewInvitation: (input: {
589
+ readonly token: string;
590
+ }, store: UserStore, deps?: InvitationDeps) => Effect.Effect<HandlerResultShape_2>;
591
+
295
592
  export declare const handleRevokeAllOtherSessions: (input: RevokeOtherSessionsInput, store: UserStore) => Effect.Effect<HandlerResult>;
296
593
 
594
+ export declare const handleRevokeInvitation: (input: {
595
+ readonly callerUserId: string;
596
+ readonly tenantId: string;
597
+ readonly invitationId: string;
598
+ }, store: UserStore, deps: InvitationDeps) => Effect.Effect<HandlerResultShape_2>;
599
+
297
600
  export declare const handleRevokeSession: (input: RevokeSessionInput, store: UserStore) => Effect.Effect<HandlerResult>;
298
601
 
299
602
  export declare interface HandlerResult {
@@ -304,6 +607,26 @@ export declare interface HandlerResult {
304
607
  readonly location?: string;
305
608
  }
306
609
 
610
+ declare interface HandlerResultShape {
611
+ readonly status: number;
612
+ readonly body: string;
613
+ readonly contentType?: string;
614
+ }
615
+
616
+ declare interface HandlerResultShape_2 {
617
+ readonly status: number;
618
+ readonly body: string;
619
+ readonly contentType?: string;
620
+ readonly setCookie?: string;
621
+ }
622
+
623
+ declare interface HandlerResultShape_3 {
624
+ readonly status: number;
625
+ readonly body: string;
626
+ readonly contentType?: string;
627
+ readonly setCookie?: string;
628
+ }
629
+
307
630
  /**
308
631
  * Sign-in handler. Accepts either form-encoded OR JSON input with
309
632
  * `email` + `password`. Returns:
@@ -347,10 +670,15 @@ export declare const handleSignUp: (input: SignUpInput, store: UserStore, config
347
670
 
348
671
  /**
349
672
  * Switch the active tenant. Validates the user is actually a member of
350
- * `targetTenantId` (the switch-tenant guard), re-issues the session
351
- * cookie with the new active tenant, and — when a `clientId` + `rebind`
352
- * are supplied — rebinds the live connection's Subject in place (the
353
- * dispatcher re-scopes subscriptions on rebind).
673
+ * `targetTenantId` (the switch-tenant guard), re-issues the session cookie
674
+ * with the new active tenant, and — when a `clientId` + `rebind` are supplied —
675
+ * presents that new cookie on the live connection so its subsequent calls
676
+ * resolve against the target tenant without a reconnect.
677
+ *
678
+ * Calls only. Subscriptions already open on that connection were authorized
679
+ * under the previous cookie and keep running until the client re-subscribes;
680
+ * nothing re-scopes them, and nothing ever did — the `onBindConnectionSubject`
681
+ * listener that claimed to had no subscribers in the entire framework.
354
682
  */
355
683
  export declare const handleSwitchTenant: (input: SwitchTenantInput, store: UserStore, config: AuthConfig, rebind?: RebindConnection) => Effect.Effect<HandlerResult>;
356
684
 
@@ -366,10 +694,324 @@ export declare const hashPassword: (plaintext: string) => Effect.Effect<string,
366
694
  * brute-force surface that would need a slow KDF. */
367
695
  export declare const hashToken: (plaintext: string) => string;
368
696
 
697
+ /** Default grant lifetime — 15 minutes. A support interaction, not a shift. */
698
+ export declare const IMPERSONATION_DEFAULT_DURATION_S: number;
699
+
700
+ /**
701
+ * Sub-paths of the auth surface that an IMPERSONATED session may not reach.
702
+ *
703
+ * Every one of them either mints a durable credential on the account (MFA,
704
+ * passkeys), destroys the impersonator's way back (revoke-others, which would
705
+ * kill the actor's own restored-to session), moves the session somewhere the
706
+ * grant does not describe (switch-tenant), or extends the grant (a second
707
+ * impersonation). Suffixes, matched against the plugin's configured prefix.
708
+ */
709
+ export declare const IMPERSONATION_FORBIDDEN_SUBPATHS: ReadonlyArray<string>;
710
+
711
+ /** The subject-metadata key carrying the mark. */
712
+ export declare const IMPERSONATION_METADATA_KEY: "impersonation";
713
+
714
+ export declare interface ImpersonationAuditEvent {
715
+ readonly kind: ImpersonationAuditKind;
716
+ readonly at: Date;
717
+ /** The impersonator. Null only when the attempt had no resolvable caller. */
718
+ readonly actorUserId: string | null;
719
+ readonly actorEmail: string | null;
720
+ /** The impersonated user. Null on a refusal that never resolved one. */
721
+ readonly targetUserId: string | null;
722
+ readonly targetEmail: string | null;
723
+ readonly tenantId: string | null;
724
+ /** The impersonated session, once one exists. */
725
+ readonly sessionId?: string;
726
+ readonly grantId?: string;
727
+ readonly reason?: string | null;
728
+ readonly expiresAt?: Date;
729
+ /** The machine code on `kind: 'refused'` — `self`, `nested`, `not_permitted`,
730
+ * `target_may_impersonate`, `reason_required`, `target_not_found`. */
731
+ readonly refusal?: string;
732
+ readonly ipAddress?: string | null;
733
+ readonly userAgent?: string | null;
734
+ }
735
+
736
+ /** What happened, for the audit sink. `refused` is recorded too: an attempt
737
+ * that was turned away is the single most interesting row in this table. */
738
+ export declare type ImpersonationAuditKind = 'started' | 'stopped' | 'refused';
739
+
740
+ /**
741
+ * A `redactSubject` function for `@voltro/plugin-audit` that keeps the
742
+ * impersonation mark and redacts the rest of `subject.metadata`.
743
+ *
744
+ * **Wire this, or your audit rows will not say who was behind an impersonated
745
+ * action.** `auditPlugin`'s default is `redactSubject: 'metadata'`, which
746
+ * replaces the whole metadata bag — correct in general (that bag is where a
747
+ * per-user provider credential lands, and an audit table is the last place a
748
+ * live PAT should be) and it takes this mark with it. The result is a trail in
749
+ * which an impersonated action is indistinguishable from the user's own, which
750
+ * is the exact failure this module exists to prevent.
751
+ *
752
+ * auditPlugin({
753
+ * sink: 'datastore',
754
+ * redactSubject: impersonationAuditRedactor(),
755
+ * })
756
+ *
757
+ * It is strictly safer than `redactSubject: 'none'` (which would store the
758
+ * whole bag, credentials included) and strictly more informative than the
759
+ * default. The `impersonationGrants` row remains the record that does not
760
+ * depend on anyone wiring this.
761
+ */
762
+ export declare const impersonationAuditRedactor: () => (subject: Subject) => unknown;
763
+
764
+ export declare interface ImpersonationAuthorityInput {
765
+ /** The caller's resolved subject. */
766
+ readonly actor: Subject;
767
+ /** The caller's user row. */
768
+ readonly actorUser: UserRecord;
769
+ /** The user they are asking to act as. */
770
+ readonly target: UserRecord;
771
+ }
772
+
773
+ export declare interface ImpersonationConfig {
774
+ /**
775
+ * MAY this actor impersonate this target? REQUIRED — see the header for why
776
+ * there is no default and why it must not be a scope check.
777
+ *
778
+ * It is called TWICE per start: once as asked, and once with the identities
779
+ * SWAPPED to answer "could the target impersonate the actor?". A `true` from
780
+ * the second call refuses the request (the PEER rule). Write it as a pure
781
+ * predicate over the two records; a function whose answer depends on which
782
+ * request is in flight will make that probe meaningless.
783
+ */
784
+ readonly authority: (input: ImpersonationAuthorityInput) => Effect.Effect<boolean>;
785
+ /**
786
+ * Where impersonation events go. REQUIRED, and required precisely because
787
+ * this is the feature whose whole risk is an unrecorded action: a config
788
+ * that let you enable impersonation while leaving the destination unset
789
+ * would make the dangerous half optional and the safe half opt-in.
790
+ *
791
+ * Called for `started`, `stopped` AND `refused`. A throwing or rejecting
792
+ * sink is swallowed — a broken log must not leave a grant half-created —
793
+ * so treat this as best-effort ON TOP of the `impersonationGrants` row,
794
+ * which is written transactionally with the session and is not.
795
+ */
796
+ readonly audit: (event: ImpersonationAuditEvent) => Effect.Effect<void> | Promise<void> | void;
797
+ /**
798
+ * Hard ceiling on one grant, in seconds. Default 900 (15 minutes).
799
+ *
800
+ * A caller may request LESS and never more — a requested duration is
801
+ * clamped, not validated, because a support tool asking for eight hours
802
+ * should get fifteen minutes rather than an error it will retry around.
803
+ * The number is the impersonated cookie's TTL as well as the grant row's
804
+ * `expiresAt`, so the bound survives the process that issued it.
805
+ */
806
+ readonly maxDurationSeconds?: number;
807
+ /** Require a non-empty `reason` on every start. Default `true` — a support
808
+ * action with no stated cause is the one you cannot review later. */
809
+ readonly requireReason?: boolean;
810
+ }
811
+
812
+ export declare interface ImpersonationDeps {
813
+ readonly impersonation: ImpersonationConfig;
814
+ readonly issuer: ImpersonationSessionIssuer;
815
+ readonly now?: () => number;
816
+ /** Drop a session id from the request-time revocation cache, so ending a
817
+ * grant kills the cookie on THIS process immediately rather than within the
818
+ * cache window. */
819
+ readonly invalidateSession?: (sessionId: string) => void;
820
+ }
821
+
822
+ /** One impersonation grant — "actor is acting as target, on this session,
823
+ * until this instant". See `impersonationGrantsTable`. */
824
+ export declare interface ImpersonationGrantRecord {
825
+ readonly id: string;
826
+ /** The IMPERSONATED session's row id. */
827
+ readonly sessionId: string;
828
+ readonly actorUserId: string;
829
+ /** The impersonator's own session, restored when the grant ends. */
830
+ readonly actorSessionId: string;
831
+ readonly targetUserId: string;
832
+ readonly tenantId: string;
833
+ readonly reason: string | null;
834
+ readonly startedAt: Date;
835
+ readonly expiresAt: Date;
836
+ readonly endedAt: Date | null;
837
+ readonly endedReason: string | null;
838
+ readonly ipAddress?: string | null;
839
+ readonly userAgent?: string | null;
840
+ }
841
+
842
+ /** The mark carried on an impersonated session's Subject. Plain JSON — it is
843
+ * serialised into a cookie payload and into audit rows. */
844
+ export declare interface ImpersonationMark {
845
+ /** The impersonating user. */
846
+ readonly actorUserId: string;
847
+ /** Their own session, restored when the grant ends. */
848
+ readonly actorSessionId: string;
849
+ readonly grantId: string;
850
+ /** ISO-8601. */
851
+ readonly startedAt: string;
852
+ /** ISO-8601. Equals the cookie's own expiry — one number, two places. */
853
+ readonly expiresAt: string;
854
+ readonly reason?: string;
855
+ }
856
+
857
+ /** The impersonation mark on a Subject, or null for an ordinary session. */
858
+ export declare const impersonationOf: (subject: Subject) => ImpersonationMark | null;
859
+
860
+ /**
861
+ * How a session is minted. Injected rather than imported so this module has no
862
+ * edge to `handlers.ts` — and, more usefully, so a test can observe exactly
863
+ * what TTL and what metadata the impersonated cookie was built with.
864
+ */
865
+ export declare interface ImpersonationSessionIssuer {
866
+ readonly issue: (user: UserRecord, options: {
867
+ readonly sessionId: string;
868
+ readonly ttlSeconds: number;
869
+ readonly metadata: Record<string, unknown>;
870
+ readonly ipAddress?: string | null;
871
+ readonly userAgent?: string | null;
872
+ }) => Effect.Effect<{
873
+ readonly setCookie: string;
874
+ readonly subject: Subject;
875
+ }>;
876
+ /** Re-issue a cookie for an EXISTING session row — the restore on stop. */
877
+ readonly reissue: (user: UserRecord, options: {
878
+ readonly sessionId: string;
879
+ readonly ttlSeconds: number;
880
+ }) => Effect.Effect<{
881
+ readonly setCookie: string;
882
+ readonly subject: Subject;
883
+ }>;
884
+ /** A `Set-Cookie` that clears the session — the fallback when the actor's
885
+ * own session no longer exists to restore. */
886
+ readonly clear: () => string;
887
+ }
888
+
889
+ export declare interface ImpersonationStartInput {
890
+ /** The caller's resolved subject — from the session cookie, never the body. */
891
+ readonly actorSubject: Subject;
892
+ /** The caller's own server-side session id. Restored when the grant ends. */
893
+ readonly actorSessionId: string;
894
+ /** Who to act as. One of the two; `userId` wins when both are given. */
895
+ readonly targetUserId?: string;
896
+ readonly targetEmail?: string;
897
+ readonly reason?: string;
898
+ /** Requested duration. CLAMPED to `maxDurationSeconds`, never exceeded. */
899
+ readonly durationSeconds?: number;
900
+ readonly ipAddress?: string | null;
901
+ readonly userAgent?: string | null;
902
+ }
903
+
904
+ export declare interface ImpersonationStopInput {
905
+ /** The caller's resolved subject — must be an impersonated one. */
906
+ readonly subject: Subject;
907
+ /** The caller's current (impersonated) session id. */
908
+ readonly sessionId: string;
909
+ }
910
+
911
+ /** Default cap on live invitations per tenant. */
912
+ export declare const INVITATION_MAX_PENDING = 500;
913
+
914
+ /** Default invitation lifetime — 7 days. Long enough to survive a weekend and
915
+ * a holiday Monday, short enough that a forgotten mailbox is not a standing
916
+ * key to a tenant. */
917
+ export declare const INVITATION_TTL_S: number;
918
+
919
+ /** The `AuthConfig` slice these handlers read. Structural so this module does
920
+ * not import `handlers.ts`. */
921
+ export declare interface InvitationDeps {
922
+ readonly appBaseUrl?: string;
923
+ readonly sendEmail?: (input: {
924
+ readonly to: string;
925
+ readonly subject: string;
926
+ readonly html: string;
927
+ readonly text: string;
928
+ readonly kind: 'magic-link' | 'password-reset' | 'email-verify' | 'invitation';
929
+ readonly actionUrl: string;
930
+ }) => Promise<void>;
931
+ readonly invitations?: InvitationsConfig;
932
+ readonly now?: () => number;
933
+ }
934
+
935
+ /**
936
+ * A tenant invitation — the grant that turns into a `memberships` row when
937
+ * redeemed. See `invitationsTable` for why `role` lives here and not in the
938
+ * accept request.
939
+ */
940
+ export declare interface InvitationRecord {
941
+ readonly id: string;
942
+ /** SHA-256 hex of the plaintext token. The plaintext lives only in the email. */
943
+ readonly tokenHash: string;
944
+ /** The invited address, lower-cased. */
945
+ readonly email: string;
946
+ readonly tenantId: string;
947
+ /** The role the membership is created with. Chosen by the INVITER. */
948
+ readonly role: string;
949
+ readonly invitedByUserId: string;
950
+ readonly expiresAt: Date;
951
+ readonly acceptedAt: Date | null;
952
+ readonly acceptedByUserId: string | null;
953
+ readonly revokedAt: Date | null;
954
+ readonly revokedByUserId: string | null;
955
+ readonly createdAt: Date;
956
+ }
957
+
958
+ export declare interface InvitationsConfig {
959
+ /** Lifetime of an invitation. Default {@link INVITATION_TTL_S} (7 days). */
960
+ readonly ttlSeconds?: number;
961
+ /** Path the emailed link points at. Default `/auth/invitations/accept`. */
962
+ readonly callbackPath?: string;
963
+ /**
964
+ * Cap on simultaneously-live invitations per tenant. Default 500.
965
+ *
966
+ * A bound, not a business rule: a compromised admin account otherwise turns
967
+ * the invite endpoint into a mail cannon aimed at any list of addresses, one
968
+ * row and one message each, with your domain as the sender.
969
+ */
970
+ readonly maxPending?: number;
971
+ /** Roles permitted to create / list / revoke invitations. Default
972
+ * `['owner', 'admin']`. */
973
+ readonly inviterRoles?: ReadonlyArray<string>;
974
+ /** Role ordering, most privileged FIRST. Feeds the default
975
+ * {@link InvitationsConfig.canGrantRole}. Default
976
+ * `['owner','admin','member','viewer']`. */
977
+ readonly roleRank?: ReadonlyArray<string>;
978
+ /**
979
+ * Replace the whole "may this inviter hand out this role" rule.
980
+ *
981
+ * The default ranks by {@link InvitationsConfig.roleRank} and permits an
982
+ * inviter to grant their own role or any role BELOW it; a requested role
983
+ * absent from the rank list may be granted only by the top-ranked role, and
984
+ * an inviter whose own role is absent may grant nothing. Supply your own for
985
+ * any model that is not a line — a matrix, a per-tenant plan, a capability
986
+ * set. Whatever you write, keep it a refusal by default: this function is
987
+ * the only thing between an admin and self-promotion by invitation.
988
+ */
989
+ readonly canGrantRole?: (ctx: GrantRoleContext) => boolean;
990
+ }
991
+
369
992
  /** Verify a token's own HMAC (signature integrity), independent of the
370
993
  * cookie/header match. */
371
994
  export declare const isCsrfTokenWellFormed: (token: string, secret: string) => boolean;
372
995
 
996
+ /**
997
+ * Read the verification mark off a session's Subject.
998
+ *
999
+ * `true` — proven.
1000
+ * `false` — NOT proven, and the session says so.
1001
+ * `null` — this session carries no mark at all.
1002
+ *
1003
+ * **`null` is not `false`, and conflating them is the failure this tri-state
1004
+ * exists to prevent.** A session minted while the policy was `'off'` — or by
1005
+ * an app that never enabled it — has nothing to say about verification. App
1006
+ * code that treats "no mark" as "unverified" would refuse every one of those
1007
+ * callers the moment the policy is switched on, which is the same outage the
1008
+ * `'off'` default avoids, re-created one layer up. Gate on `=== false`.
1009
+ */
1010
+ export declare const isEmailVerified: (subject: Subject) => boolean | null;
1011
+
1012
+ /** Is this session an impersonated one? */
1013
+ export declare const isImpersonated: (subject: Subject) => boolean;
1014
+
373
1015
  /**
374
1016
  * Issue a fresh CSRF token: `<randomB64url>.<hmacB64url>`. Set this as a
375
1017
  * readable (non-HttpOnly) cookie AND hand it to the SPA so it can echo
@@ -389,8 +1031,14 @@ export declare interface IssuedSession {
389
1031
  * header in one call. Apps just attach the header to their
390
1032
  * response. Pass a `SessionSecrets` set to sign with the current
391
1033
  * rotation key (its `kid` is stamped into the payload).
1034
+ *
1035
+ * The subject is a `Subject | SubjectIdentity` at the type level and an
1036
+ * IDENTITY on the wire: a session cookie carries no `scopes`, and
1037
+ * `signSession` throws rather than dropping them silently. Authority for
1038
+ * this session is resolved per request by `auth.resolveScopes`, so a role
1039
+ * removed after sign-in takes effect on this same session.
392
1040
  */
393
- export declare const issueSession: (subject: Subject, secret: SessionSecretInput, options?: IssueSessionOptions) => IssuedSession;
1041
+ export declare const issueSession: (subject: Subject | SubjectIdentity, secret: SessionSecretInput, options?: IssueSessionOptions) => IssuedSession;
394
1042
 
395
1043
  export declare interface IssueSessionOptions {
396
1044
  readonly ttlSeconds?: number;
@@ -399,20 +1047,60 @@ export declare interface IssueSessionOptions {
399
1047
  }
400
1048
 
401
1049
  /**
402
- * Issue a real session for a fully-authenticated user: load memberships,
403
- * write the `sessions` row (so device-list + revocation cover it), and
404
- * mint the signed cookie. Shared by password sign-in (post-MFA), sign-up,
405
- * magic-link, MFA verify, and passkey assertion so rotation + revocation
406
- * apply uniformly on every path that grants a session.
1050
+ * Issue a real session for a user: load memberships, write the `sessions` row
1051
+ * (so device-list + revocation cover it), and mint the signed cookie.
1052
+ *
1053
+ * **The ONE path that mints a session.** Password sign-in (post-MFA), sign-up,
1054
+ * magic-link, MFA verify, passkey assertion and impersonation all come through
1055
+ * here, so rotation, revocation, the session row and every metadata mark apply
1056
+ * uniformly. Sign-up and magic-link used to carry their own inline copies of
1057
+ * this body — which is exactly how a mark added in one place ends up missing
1058
+ * on two of five login paths, discoverable only by logging in the right way.
407
1059
  */
408
- export declare const issueUserSession: (user: UserRecord, store: UserStore, config: AuthConfig, meta?: {
409
- readonly ipAddress?: string | null;
410
- readonly userAgent?: string | null;
411
- }) => Effect.Effect<{
1060
+ export declare const issueUserSession: (user: UserRecord, store: UserStore, config: AuthConfig, meta?: IssueUserSessionOptions) => Effect.Effect<{
412
1061
  readonly setCookie: string;
413
1062
  readonly subject: ReturnType<typeof subjectFromUser>;
414
1063
  }>;
415
1064
 
1065
+ /** What an `issueUserSession` caller may vary. */
1066
+ export declare interface IssueUserSessionOptions {
1067
+ readonly ipAddress?: string | null;
1068
+ readonly userAgent?: string | null;
1069
+ /**
1070
+ * Pre-allocated session-row id. Impersonation supplies one so the grant row
1071
+ * can be written BEFORE the session exists — a session with no record of who
1072
+ * is behind it must never be reachable, not even for a moment.
1073
+ */
1074
+ readonly sessionId?: string;
1075
+ /** Cookie + session-row lifetime. Defaults to the 7-day session TTL;
1076
+ * impersonation passes its (much shorter) grant duration. */
1077
+ readonly ttlSeconds?: number;
1078
+ /**
1079
+ * `'insert'` (default) writes a fresh `sessions` row. `'existing'` mints a
1080
+ * cookie for a row that is ALREADY there and leaves it untouched.
1081
+ *
1082
+ * `'existing'` is the impersonation stop path putting the actor back into
1083
+ * their own, never-revoked session. Inserting there would duplicate the row
1084
+ * (the memory store appends; a keyed lookup then answers with whichever came
1085
+ * first), and touching it would silently extend the actor's own login by a
1086
+ * full lifetime every time they used the support tool.
1087
+ */
1088
+ readonly sessionRow?: 'insert' | 'existing';
1089
+ /** Extra keys merged onto the Subject's metadata slot — the impersonation
1090
+ * mark. `sessionId` and the verification flag are set by this function and
1091
+ * win over anything here. */
1092
+ readonly metadata?: Record<string, unknown>;
1093
+ /**
1094
+ * Clear the brute-force counter for this email. Default TRUE, because a
1095
+ * completed login is proof the password is known.
1096
+ *
1097
+ * IMPERSONATION PASSES FALSE. Nobody authenticated as the target, so
1098
+ * clearing their lockout would let a support action undo brute-force
1099
+ * protection on the very account someone is hammering.
1100
+ */
1101
+ readonly clearLoginFailures?: boolean;
1102
+ }
1103
+
416
1104
  export { KeyedSecret }
417
1105
 
418
1106
  declare interface ListSessionsInput {
@@ -508,7 +1196,12 @@ export declare const memoryUserStore: (seed?: ReadonlyArray<UserRecord>) => User
508
1196
 
509
1197
  /** Lifetime of the short-lived MFA pending token (the second-factor
510
1198
  * challenge). Long enough for the user to fetch a TOTP code, short enough
511
- * that a leaked challenge is near-useless. */
1199
+ * that a leaked challenge is near-useless.
1200
+ *
1201
+ * Lives HERE rather than beside the MFA handler that mints it, because the
1202
+ * default-TTL table below has to be exhaustive over `TokenPurpose` and a
1203
+ * constant imported from `handlers.ts` would be a cycle. `handlers.ts`
1204
+ * re-exports it, so the public spelling is unchanged. */
512
1205
  export declare const MFA_PENDING_TTL_S: number;
513
1206
 
514
1207
  /** MFA (TOTP) enrolment config. */
@@ -564,6 +1257,19 @@ export declare interface MintedToken {
564
1257
  readonly purpose: TokenPurpose;
565
1258
  }
566
1259
 
1260
+ /**
1261
+ * Mint a fresh single-use secret with NO purpose attached — 32 CSPRNG bytes,
1262
+ * its SHA-256, and an expiry.
1263
+ *
1264
+ * Exists for the credentials that are single-use, hashed and expiring but do
1265
+ * NOT live in `authTokens`: a tenant invitation carries its own row (with an
1266
+ * address, a tenant and a role), so it has no `TokenPurpose` to name. Reaching
1267
+ * for `mintToken('magic-link', …)` just to obtain the bytes would file an
1268
+ * invitation under a purpose it does not have, in a type everything else reads
1269
+ * as authoritative.
1270
+ */
1271
+ export declare const mintSecret: (ttlSeconds: number) => Omit<MintedToken, "purpose">;
1272
+
567
1273
  /**
568
1274
  * Mint a fresh single-use token. The caller persists `{ tokenHash,
569
1275
  * userId, purpose, expiresAt }` via `UserStore.insertToken` and emails
@@ -596,7 +1302,8 @@ declare interface ParsedHash {
596
1302
  }
597
1303
 
598
1304
  /** Parse the scrypt parameters out of a stored hash. Returns null when the
599
- * string isn't a well-formed `scrypt$N$r$p$salt$derived`. */
1305
+ * string isn't a well-formed `scrypt$N$r$p$salt$derived`, or when its
1306
+ * parameters exceed the derivation ceiling. */
600
1307
  export declare const parseScryptParams: (stored: string) => ParsedHash | null;
601
1308
 
602
1309
  declare interface PasskeyAssertOptionsInput {
@@ -692,15 +1399,16 @@ export declare const postgresUserStore: (sql: SqlClient) => UserStore;
692
1399
 
693
1400
  /**
694
1401
  * Read + verify the session cookie from a Cookie header string.
695
- * Returns the decoded Subject or null when the cookie is missing,
696
- * malformed, tampered, or expired. Never throws — callers branch
697
- * on the boolean.
1402
+ * Returns the decoded IDENTITY (a Subject with no `scopes` — authority is
1403
+ * resolved per request, never carried in the cookie) or null when the cookie
1404
+ * is missing, malformed, tampered, expired, or minted under a superseded
1405
+ * payload version. Never throws — callers branch on the boolean.
698
1406
  *
699
1407
  * Verification is keyed: the given secret is widened via
700
1408
  * `sessionSecretsOf`, so a cookie signed with the previous rotation
701
1409
  * key keeps verifying while `VOLTRO_SESSION_SECRET_PREVIOUS` is set.
702
1410
  */
703
- export declare const readSession: (cookieHeader: string | undefined, secret: SessionSecretInput) => Subject | null;
1411
+ export declare const readSession: (cookieHeader: string | undefined, secret: SessionSecretInput) => SubjectIdentity | null;
704
1412
 
705
1413
  /**
706
1414
  * Keyed read: the full `VerifyResult` — subject + the `kid` that
@@ -710,9 +1418,18 @@ export declare const readSession: (cookieHeader: string | undefined, secret: Ses
710
1418
  */
711
1419
  export declare const readSessionKeyed: (cookieHeader: string | undefined, secret: SessionSecretInput, options?: VerifyOptions) => VerifyResult | null;
712
1420
 
713
- /** Injected rebinder — the app passes `bindConnectionSubject` from
714
- * `@voltro/runtime` so the plugin stays runtime-agnostic. */
715
- export declare type RebindConnection = (clientId: number, subject: ReturnType<typeof subjectFromUser>) => void;
1421
+ /**
1422
+ * Injected rebinder — the app passes `bindConnectionCredential` from
1423
+ * `@voltro/runtime` so the plugin stays runtime-agnostic.
1424
+ *
1425
+ * It takes the CREDENTIAL this handler just minted, not the Subject it built.
1426
+ * The Subject form was a fast path around the entire auth chain: the middleware
1427
+ * returned the stored value directly, so the connection never re-ran the
1428
+ * session-revocation check, `resolveScopes` or the scope cache again. Handing
1429
+ * the cookie over instead means the connection presents the same credential a
1430
+ * reconnecting browser would, and is judged the same way.
1431
+ */
1432
+ export declare type RebindConnection = (clientId: number, credential: ConnectionCredential) => void;
716
1433
 
717
1434
  /** A hashed, single-use MFA recovery (backup) code. Minted alongside
718
1435
  * enrolment; accepted at sign-in as an alternative to a TOTP code when
@@ -790,11 +1507,21 @@ export declare interface SendEmailInput {
790
1507
  readonly html: string;
791
1508
  readonly text: string;
792
1509
  /** Discriminates the flow so a custom sender can branch. */
793
- readonly kind: 'magic-link' | 'password-reset';
1510
+ readonly kind: 'magic-link' | 'password-reset' | 'email-verify' | 'invitation';
794
1511
  /** The action URL embedded in the email (also present inside `html`). */
795
1512
  readonly actionUrl: string;
796
1513
  }
797
1514
 
1515
+ /**
1516
+ * Mint + send a verification link for `user`. Shared by the request handler
1517
+ * and by sign-up, so the cooldown and the URL shape have ONE implementation.
1518
+ *
1519
+ * Returns whether an email was actually dispatched — `false` when the cooldown
1520
+ * suppressed it or no transport is wired. The caller must NOT surface that
1521
+ * distinction to an unauthenticated client (see `handleEmailVerifyRequest`).
1522
+ */
1523
+ export declare const sendEmailVerification: (user: UserRecord, store: UserStore, deps: EmailVerificationDeps) => Effect.Effect<boolean>;
1524
+
798
1525
  export declare const SESSION_COOKIE_NAME: "voltro:session";
799
1526
 
800
1527
  /** Read the server-side session id carried on a Subject (stamped by the
@@ -970,7 +1697,7 @@ export declare interface SwitchTenantInput {
970
1697
  readonly metadata?: Record<string, unknown>;
971
1698
  }
972
1699
 
973
- export declare type TokenPurpose = 'magic-link' | 'password-reset' | 'mfa-pending';
1700
+ export declare type TokenPurpose = 'magic-link' | 'password-reset' | 'mfa-pending' | 'email-verify';
974
1701
 
975
1702
  /** A single-use, hashed, expiring token (magic-link / password-reset). */
976
1703
  export declare interface TokenRecord {
@@ -1001,6 +1728,16 @@ declare const UserAlreadyExistsError_base: new <A extends Record<string, any> =
1001
1728
  readonly _tag: "UserAlreadyExistsError";
1002
1729
  } & Readonly<A>;
1003
1730
 
1731
+ /**
1732
+ * Is this user's address proven, under `config`?
1733
+ *
1734
+ * `exemptAccountsCreatedBefore` is applied HERE rather than at the guard, so
1735
+ * the exemption and the session mark cannot disagree — an exempt user must
1736
+ * read as verified to the client too, or the app shows a "confirm your email"
1737
+ * banner to someone the login already excused.
1738
+ */
1739
+ export declare const userEmailVerified: (user: UserRecord, config: EmailVerificationConfig | undefined) => boolean;
1740
+
1004
1741
  export declare class UserNotFoundError extends UserNotFoundError_base<{
1005
1742
  readonly userId: string;
1006
1743
  }> {
@@ -1039,6 +1776,18 @@ export declare interface UserRecord {
1039
1776
  * verify). Distinct from the secret being set — a half-completed
1040
1777
  * enrolment leaves `mfaSecret` populated but this null. */
1041
1778
  readonly mfaEnrolledAt?: Date | null;
1779
+ /**
1780
+ * When this address was PROVEN to reach its owner — by redeeming a
1781
+ * verification link, a magic link, or a password-reset link. `null` /
1782
+ * absent = unproven.
1783
+ *
1784
+ * A store whose backing table predates the `emailVerifiedAt` column simply
1785
+ * leaves it undefined, which reads as unverified — and that is why
1786
+ * `emailVerification.policy` defaults to `'off'`: under any stricter
1787
+ * default, "the column is not there yet" and "this user never confirmed"
1788
+ * would both refuse the login.
1789
+ */
1790
+ readonly emailVerifiedAt?: Date | null;
1042
1791
  /** Optional lifecycle flag read by post-authentication subject guards.
1043
1792
  * Set (a `Date`) means the account is DEACTIVATED — `@voltro/plugin-
1044
1793
  * deactivation`'s `deactivationGuard()` vetoes login when it's non-null.
@@ -1067,6 +1816,15 @@ export declare interface UserStore {
1067
1816
  /** Wipe the secret + enrolledAt. Used by /account/security's
1068
1817
  * "remove MFA" action. */
1069
1818
  readonly clearMfa: (userId: string) => Effect.Effect<UserRecord, UserNotFoundError>;
1819
+ /**
1820
+ * Stamp `emailVerifiedAt` — the address has been proven.
1821
+ *
1822
+ * IDEMPOTENT and non-regressing: a user already verified keeps their
1823
+ * ORIGINAL timestamp. "When was this address first proven" is the question
1824
+ * the column answers, and re-stamping it on every later magic-link login
1825
+ * would quietly turn it into "when did they last click something".
1826
+ */
1827
+ readonly markEmailVerified: (userId: string) => Effect.Effect<UserRecord, UserNotFoundError>;
1070
1828
  /** Every tenant the user belongs to. */
1071
1829
  readonly listMemberships: (userId: string) => Effect.Effect<ReadonlyArray<MembershipRecord>>;
1072
1830
  /** Idempotent grant: inserts the [userId, tenantId] membership or
@@ -1104,6 +1862,52 @@ export declare interface UserStore {
1104
1862
  * already consumed — and marks it consumed in the same step. Returns
1105
1863
  * null otherwise (single-use guard). */
1106
1864
  readonly consumeToken: (tokenHash: string, purpose: TokenPurpose) => Effect.Effect<TokenRecord | null>;
1865
+ /**
1866
+ * The most recently CREATED token of this purpose for the user, consumed or
1867
+ * not — or null when there is none.
1868
+ *
1869
+ * Exists for the resend cooldown, which is the only bound on an endpoint
1870
+ * that sends mail to an address supplied by an unauthenticated caller. Note
1871
+ * it deliberately does NOT filter on `consumedAt`: the cost being rate-
1872
+ * limited is the EMAIL, and a token that was minted and then redeemed cost
1873
+ * exactly as much to send as one still pending.
1874
+ */
1875
+ readonly latestToken: (userId: string, purpose: TokenPurpose) => Effect.Effect<TokenRecord | null>;
1876
+ /** Persist an invitation (token already hashed). */
1877
+ readonly insertInvitation: (invitation: Omit<InvitationRecord, 'acceptedAt' | 'acceptedByUserId' | 'revokedAt' | 'revokedByUserId' | 'createdAt'>) => Effect.Effect<InvitationRecord>;
1878
+ /** Look an invitation up by token hash WITHOUT redeeming it — the read the
1879
+ * "what am I being invited to" preview needs. Returns the row whatever its
1880
+ * state; the caller decides what an expired or revoked one means. */
1881
+ readonly findInvitationByHash: (tokenHash: string) => Effect.Effect<InvitationRecord | null>;
1882
+ /**
1883
+ * Atomically redeem an invitation: returns the row only when it exists, is
1884
+ * unaccepted, unrevoked and unexpired — and stamps `acceptedAt` +
1885
+ * `acceptedByUserId` in the same step. Null otherwise.
1886
+ *
1887
+ * The single-use guard, and it has to be ONE statement: two clicks on the
1888
+ * same emailed link race, and a read-then-write would let both through and
1889
+ * write the membership twice.
1890
+ */
1891
+ readonly acceptInvitation: (tokenHash: string, acceptedByUserId: string, now: Date) => Effect.Effect<InvitationRecord | null>;
1892
+ /** Withdraw a PENDING invitation, scoped to its tenant so an admin of one
1893
+ * tenant cannot revoke another's by id. Returns whether a row changed. */
1894
+ readonly revokeInvitation: (invitationId: string, tenantId: string, revokedByUserId: string, now: Date) => Effect.Effect<boolean>;
1895
+ /** Every invitation for a tenant, newest first. The admin list. */
1896
+ readonly listInvitations: (tenantId: string) => Effect.Effect<ReadonlyArray<InvitationRecord>>;
1897
+ /** How many invitations for this tenant are still redeemable — the bound
1898
+ * `maxPending` enforces. */
1899
+ readonly countPendingInvitations: (tenantId: string, now: Date) => Effect.Effect<number>;
1900
+ /** Revoke every pending invitation for (tenant, email). Called before
1901
+ * issuing a fresh one so re-inviting supersedes rather than accumulates —
1902
+ * otherwise every resend leaves a live token behind. Returns the count. */
1903
+ readonly supersedePendingInvitations: (tenantId: string, email: string, revokedByUserId: string, now: Date) => Effect.Effect<number>;
1904
+ /** Record an impersonation grant when the impersonated session is minted. */
1905
+ readonly insertImpersonationGrant: (grant: Omit<ImpersonationGrantRecord, 'startedAt' | 'endedAt' | 'endedReason'>) => Effect.Effect<ImpersonationGrantRecord>;
1906
+ /** The grant for an impersonated session, or null. The stop path's lookup. */
1907
+ readonly findImpersonationGrantBySession: (sessionId: string) => Effect.Effect<ImpersonationGrantRecord | null>;
1908
+ /** Close a grant. Only an OPEN grant is closed (`endedAt IS NULL`), so a
1909
+ * double stop is a no-op rather than a rewritten end time. */
1910
+ readonly endImpersonationGrant: (sessionId: string, endedReason: string, now: Date) => Effect.Effect<boolean>;
1107
1911
  /** Replace the user's recovery codes with a fresh (already hashed) set.
1108
1912
  * Called at enrolment / regeneration — wipes any prior codes so the
1109
1913
  * displayed set is the only valid one. */