import { AuthStrategy } from '@voltro/protocol'; import { ConnectionCredential } from '@voltro/protocol'; import { DataStore } from '@voltro/database'; import { Effect } from 'effect'; import { KeyedSecret } from '@voltro/protocol/session'; import { MembershipsSource } from '@voltro/protocol'; import { SessionSecrets } from '@voltro/protocol/session'; import { SqlClient } from '@effect/sql/SqlClient'; import { Subject } from '@voltro/protocol'; import { SubjectIdentity } from '@voltro/protocol'; import { VerifyOptions } from '@voltro/protocol/session'; import { VerifyResult } from '@voltro/protocol/session'; import { VoidIfEmpty } from 'effect/Types'; import { VoltroPlugin } from '@voltro/protocol'; import { YieldableError } from 'effect/Cause'; export declare interface AcceptInvitationInput { readonly token: string; /** The authenticated caller's id. Absent means nobody is signed in. */ readonly userId?: string; } export declare interface AcceptInvitationWithSignUpInput { readonly token: string; readonly password: string; } export declare type AssertionOutcome = { readonly ok: true; readonly result: AssertionResult; } | { readonly ok: false; readonly error: WebAuthnError; }; export declare interface AssertionResult { /** The new signature counter to persist. */ readonly newCounter: number; } export declare interface AssertionVerifyInput { readonly clientDataJSON: string; readonly authenticatorData: string; /** base64url signature over `authenticatorData ‖ sha256(clientDataJSON)`. */ readonly signature: string; readonly expectedChallenge: string; readonly expectedOrigin: string; readonly rpId: string; /** The stored COSE public key (base64url) for the asserted credential. */ readonly storedPublicKey: string; /** The stored signature counter for the credential. */ readonly storedCounter: number; } export declare interface AuthConfig { /** * The session-signing key. OMIT IT and the value is read from * `VOLTRO_SESSION_SECRET` when a request is actually handled. * * Omitting is the better default: `app.config.ts` is evaluated before the * environment gate runs (and before `voltro dev` mints a project-local * secret), so reading `process.env` here to pass a value is precisely what * pushes apps into shipping a hardcoded fallback. Deferring the read means * there is nothing to fall back TO. * * Pass one explicitly only when the key does not come from the environment. */ readonly secret?: string; /** Explicit keyed secret set for zero-downtime rotation. When absent, * the set is derived from `secret` (or `VOLTRO_SESSION_SECRET`) plus the * `VOLTRO_SESSION_SECRET_PREVIOUS` / `VOLTRO_SESSION_KID*` env vars — * so env-var rotation works without setting this. Cookies are always * SIGNED with the current key; verification also accepts `previous`. */ readonly secrets?: SessionSecrets; readonly defaultTenantId: string; readonly cookieDomain?: string; readonly cookieSecure?: boolean; /** Where to redirect on successful sign-in / sign-up. Default: '/'. */ readonly successRedirect?: string; /** Injected email transport for magic-link + password-reset. When * absent, those handlers still mint + persist the token but cannot * deliver it (they return 202 so existence isn't leaked). The * documented default forwards to `@voltro/plugin-mail` via * `mailSender(mailService)`. */ readonly sendEmail?: SendEmail; /** Absolute base URL used to build the links inside emails, e.g. * `https://app.example.com`. Defaults to '' (relative links). */ readonly appBaseUrl?: string; /** Post-authentication subject guards. After a login path resolves the * authenticated user (credentials verified) but BEFORE a session is * issued, every guard runs against that `UserRecord`; the first to veto * (`{ ok: false }`) aborts the login with a 403 carrying its `code` and * NO session cookie. General-purpose: `@voltro/plugin-deactivation`'s * `deactivationGuard()` is the canonical one ("account deactivated"), * but any concern (unverified email, suspended tenant, …) can hook here. * Absent / empty ⇒ every authenticated user proceeds unchanged. */ readonly subjectGuards?: ReadonlyArray; /** Brute-force lockout. After `maxAttempts` failed credential attempts * (wrong password OR wrong MFA code) within `windowSeconds`, the account — * keyed by EMAIL, so an unknown address locks exactly like a real one and * the lock can't be used as an existence oracle — is refused with a 429 for * `lockSeconds`; a completed login clears the counter. ON by default (a * security default, not opt-in); raise `maxAttempts` very high to disable. */ readonly lockout?: { readonly maxAttempts?: number; readonly windowSeconds?: number; readonly lockSeconds?: number; }; /** Injectable clock (epoch-ms) for the lockout counter. Default `Date.now`; * tests pass a controllable one to exercise window/lock expiry. */ readonly now?: () => number; /** * Email verification. OFF by default (`policy: 'off'`) — the endpoints and * the `emailVerifiedAt` column exist, nothing is gated. * * `'strict'` is enforced through `subjectGuards`, so it covers every path * that issues a session; `authRoutesPlugin` installs that guard for you. * Hand-wiring the handlers? Add `emailVerificationGuard(config)` to * `subjectGuards` yourself — setting the policy alone changes only what the * session is MARKED with, not who may log in. */ readonly emailVerification?: EmailVerificationConfig; } export declare const authMemberships: (store: UserStore) => MembershipsSource; /** * The auth plugin instance also carries a pre-wired session * `AuthStrategy` (keyed verify + the SAME revocation cache the HTTP * routes use) so the framework's serve pipeline can slot it into the * per-app auth chain — the rpc/WS path then rejects revoked sessions * exactly like the `/auth/*` routes. Read it via * `getAuthSessionStrategy`. */ export declare interface AuthPlugin extends VoltroPlugin { readonly auth: { readonly sessionStrategy: AuthStrategy; }; } export declare const authRoutesPlugin: (options: AuthRoutesPluginOptions) => AuthPlugin; export declare interface AuthRoutesPluginOptions extends AuthConfig { /** The user store backing every handler. */ readonly store: UserStore; /** Passkey config — when omitted, the four passkey routes return 501. */ readonly passkey?: PasskeyConfig; /** Challenge store for the passkey ceremonies. Defaults to an in-memory * single-node store. */ readonly challengeStore?: ChallengeStore; /** MFA (TOTP) enrolment config. When set, the `/auth/mfa/enroll/*` * routes are mounted so an authenticated user can enrol a second * factor; `issuer` is the label shown in their authenticator app. * Sign-in enforcement (the `/auth/mfa/verify` challenge) is ALWAYS * active for enrolled users regardless of this — it only gates the * enrolment routes. */ readonly mfa?: MfaConfig; /** Rebind hook for switch-tenant on the live WS path — pass * `bindConnectionCredential` from `@voltro/runtime`. It receives the cookie * this plugin just minted, not a Subject: the connection then presents the * same credential a reconnecting browser would, and the auth chain judges it * the same way (revocation included). */ readonly rebind?: RebindConnection; /** Route prefix. Default `/auth`. */ readonly prefix?: string; /** Tuning for the request-time session-revocation check (TTL cache * window, default 30s; `now` is a testing seam). */ readonly sessionRevocation?: SessionRevocationOptions; /** * Tenant invitations. PRESENT (even as `{}`) mounts the five * `/auth/invitations/*` routes and arms the retention bound on the * `invitations` table; ABSENT mounts nothing. * * Opt-in by presence, like `mfa` and `passkey`, and for the same reason: * the table ships in `authTables` but an app that has not spread it would * otherwise get 500s from a route it never asked for. */ readonly invitations?: InvitationsConfig; /** * User impersonation ("log in as"). PRESENT mounts `/auth/impersonate/start` * + `/stop`, arms the retention bound on `impersonationGrants`, and closes * the account-security routes to impersonated sessions; ABSENT mounts * nothing and the block below cannot fire. * * Both `authority` and `audit` are REQUIRED — there is no way to switch this * on while leaving either unanswered. See `ImpersonationConfig`. */ readonly impersonation?: ImpersonationConfig; /** Disambiguates multiple instances of this plugin in one app. */ readonly name?: string; } /** Single-use challenge storage. `key` is `:`. */ export declare interface ChallengeStore { readonly put: (key: string, challenge: string, ttlSeconds: number) => Effect.Effect; /** Read AND delete (single-use). Returns null when absent/expired. */ readonly take: (key: string) => Effect.Effect; } /** * Build a `Set-Cookie` value that clears the session cookie. Send * this on logout. */ export declare const clearSessionCookie: (options?: IssueSessionOptions) => string; /** The keyed secret set a handler signs/verifies with — the explicit * `config.secrets` when set, else `config.secret` widened with the * rotation env vars. */ export declare const configSessionSecrets: (config: AuthConfig) => SessionSecrets; export declare interface CreateInvitationInput { /** The authenticated caller — resolved from their session, never from the * request body. */ readonly inviterUserId: string; readonly tenantId: string; readonly email: string; readonly role: string; } export declare const CSRF_COOKIE_NAME: "voltro:csrf"; export declare const CSRF_HEADER_NAME: "x-csrf-token"; /** * Shared-store `ChallengeStore` over the `passkeyChallenges` table (see * `./schema`). Use this in ANY deployment running more than one replica: * the `register/options` and `assert/options` requests may land on a * different node than their matching `verify`, and a memory challenge * would be missing there. Rows are: * * - single-use — `take` is a single `DELETE … RETURNING` statement, so * two concurrent verifies can't both redeem the same challenge (the * loser gets zero rows); and * - short-lived — `put` stamps `expiresAt`, and `take` treats an expired * row as absent (while still deleting it). A background reaper is * optional; abandoned rows are tiny and self-invalidate. * * The caller owns the `SqlClient` lifecycle (same contract as * `postgresUserStore`). */ export declare const dataStoreChallengeStore: (sql: SqlClient) => ChallengeStore; /** Default set of roles allowed to invite at all. */ export declare const DEFAULT_INVITER_ROLES: ReadonlyArray; /** Default role ordering, most privileged first. */ export declare const DEFAULT_ROLE_RANK: ReadonlyArray; /** The shipped rank-based rule. Exported so an app extending it can call it. */ export declare const defaultCanGrantRole: (ctx: GrantRoleContext, rank?: ReadonlyArray) => boolean; /** The 403 body's `error` when `'strict'` refuses a login. */ export declare const EMAIL_NOT_VERIFIED_CODE: "email_not_verified"; /** The subject-metadata key carrying the verification mark. */ export declare const EMAIL_VERIFIED_METADATA_KEY: "emailVerified"; /** Default gap between two verification emails to one user. */ export declare const EMAIL_VERIFY_RESEND_COOLDOWN_S = 60; /** * Default email-verification lifetime — 24 hours. * * Longer than the other two on purpose, and the asymmetry is the point. A * magic link and a reset link are CREDENTIALS: whoever holds one signs in, so * a short fuse is worth the friction of asking for another. A verification * link grants nothing — redeeming it only asserts that the address reaches its * owner — so the cost of a short lifetime is all friction and no security, paid * by exactly the users who check mail once a day. */ export declare const EMAIL_VERIFY_TTL_S: number; /** Whether the verification feature does anything at all for this app. */ export declare const emailVerificationActive: (config: EmailVerificationConfig | undefined) => boolean; export declare interface EmailVerificationConfig { /** Default `'off'` — see the header for why a stricter default is an * outage rather than a safer choice. */ readonly policy?: EmailVerificationPolicy; /** Lifetime of a verification link. Default {@link EMAIL_VERIFY_TTL_S} (24h). */ readonly ttlSeconds?: number; /** Path the emailed link points at. Default `/auth/verify-email/callback`. */ readonly callbackPath?: string; /** * Minimum gap between two verification emails to the same user. Default 60s. * * The only bound on an endpoint that sends mail to an address chosen by an * unauthenticated caller. Without it, "resend" is a mail-bomb primitive * pointed at any address the attacker knows has an account, and the cost is * carried by your sending reputation. */ readonly resendCooldownSeconds?: number; /** Send the verification email automatically on sign-up. Default: true * whenever `policy` is not `'off'`. */ readonly sendOnSignUp?: boolean; /** * Accounts created STRICTLY BEFORE this instant count as verified. * * The migration seam, and the reason `'strict'` is adoptable at all: an app * turning the policy on has a table full of `emailVerifiedAt IS NULL` rows * belonging to real users who did nothing wrong. Set this to the deploy * instant and only accounts created from then on have to prove anything. * * It is deliberately a CONFIG value rather than a backfill we run for you: * an app that genuinely wants its existing users to re-verify says so by * leaving this unset, and one that does not is spared writing to every row * of its users table. */ readonly exemptAccountsCreatedBefore?: Date; } /** The `AuthConfig` slice these handlers read. Declared structurally so this * module does not import `handlers.ts` (which imports this one). */ export declare interface EmailVerificationDeps { readonly appBaseUrl?: string; readonly sendEmail?: (input: { readonly to: string; readonly subject: string; readonly html: string; readonly text: string; readonly kind: 'magic-link' | 'password-reset' | 'email-verify' | 'invitation'; readonly actionUrl: string; }) => Promise; readonly emailVerification?: EmailVerificationConfig; readonly now?: () => number; } /** * The `'strict'` enforcement, as a post-authentication subject guard. * * A guard rather than a check inside `handleSignIn` on purpose: the seam * already runs at EVERY path that grants a session to a pre-existing user * (password, MFA verify, magic-link consume, passkey assertion), so wiring it * here covers all four and cannot be forgotten on the fifth. `authRoutesPlugin` * appends it automatically under `'strict'`; an app hand-wiring the handlers * adds it to `subjectGuards` itself. * * Under `'off'` and `'soft'` it returns a guard that always allows, rather * than the caller having to decide whether to install one — a policy read in * two places is a policy that can disagree with itself. */ export declare const emailVerificationGuard: (config: EmailVerificationConfig | undefined) => SubjectGuard; /** What an account with an unproven address may do. See the header. */ export declare type EmailVerificationPolicy = 'off' | 'soft' | 'strict'; export declare interface EmailVerifyConfirmInput { readonly token: string; } export declare interface EmailVerifyRequestInput { /** The address to send to. Either this or `userId`. */ readonly email?: string; /** The authenticated caller's id — the "resend to me" path, which needs no * address in the request and therefore cannot be pointed at someone else. */ readonly userId?: string; } /** Mint a fresh WebAuthn challenge (base64url, 32 random bytes). The * server stashes it (keyed by user / ceremony) and hands it to the * browser; `verifyRegistration` / `verifyAssertion` check it back. */ export declare const generateChallenge: () => string; /** Generate N base32 recovery codes (5 chars each, 5 groups separated * by '-'). 25-character codes, ~125 bits of entropy — enough that * brute-forcing the recovery surface is unrealistic, short enough * the user can type them. Each code is single-use; the caller must * persist them hashed (same as passwords) + mark them consumed on * use. */ export declare const generateRecoveryCodes: (count?: number) => ReadonlyArray; /** Generate the current TOTP code for a secret. Caller controls the * clock to make this testable — pass `Date.now()` in production. */ export declare const generateTotpCode: (secretBase32: string, nowMs?: number) => string; /** Generate a fresh TOTP secret (160-bit, base32-encoded). Hand this * to the user via an otpauth URL — `otpauthUrl()` below builds it. */ export declare const generateTotpSecret: () => string; /** * Pull the pre-wired session strategy off a plugin list (the framework's * serve pipeline calls this when composing the auth chain). Returns the * FIRST auth plugin's strategy, or null when no auth plugin is present. */ export declare const getAuthSessionStrategy: (plugins: ReadonlyArray) => AuthStrategy | null; export declare interface GrantRoleContext { /** The inviter's role in THIS tenant, as read from `memberships`. */ readonly inviterRole: string; /** The role the invitation would carry. */ readonly requestedRole: string; readonly tenantId: string; } /** * Run the config's post-authentication subject guards against a resolved * user. Returns a 403 `HandlerResult` (carrying the vetoing guard's `code` * as `error`) when a guard rejects the login, or `null` to let it proceed. * * Every login path that grants a session to a PRE-EXISTING user calls this * after the credential check and before issuing the session — so a guarded * account (e.g. deactivated) is turned away uniformly across password, * MFA, magic-link, and passkey sign-in. Sign-up is exempt: it creates a * brand-new user that no guard could yet reject. */ export declare const guardSubject: (user: UserRecord, config: AuthConfig) => Effect.Effect; /** * Accept an invitation as an ALREADY-AUTHENTICATED user. * * The three refusals, in the order they matter: * * - **401 `authentication_required`** when nobody is signed in. The response * carries the invited `email` so the sign-in page can pre-fill it — that * address is already in the recipient's mailbox, so echoing it to the * holder of the token reveals nothing they do not have. * - **403 `invitation_email_mismatch`** when the signed-in user's address is * not the invited one. This is the property that makes a leaked or * forwarded link useless: without it, the first person to open the link * while signed into any account joins the tenant. * - **401 `invalid_or_expired_invitation`** for a token that is unknown, * spent, revoked or lapsed — one answer for all four, because * distinguishing them tells a holder of a dead token which kind of dead. * * The membership is written with the role from the INVITATION ROW. Note the * ordering: the invitation is redeemed FIRST (the single-use UPDATE), and only * a winning redemption goes on to write the membership. Reversed, two * concurrent accepts would both write. */ export declare const handleAcceptInvitation: (input: AcceptInvitationInput, store: IdentityStore & MembershipStore & InvitationStore, deps?: InvitationDeps) => Effect.Effect; /** * Accept an invitation by CREATING the account it was addressed to — the * "you've been invited, choose a password" flow. * * The account's email is taken from the INVITATION, never from the request: * an input-supplied address would let the holder of a leaked token create an * account under an address of their choosing and have it join the tenant. For * the same reason there is no `tenantId` input — it is the invitation's. * * The new account is created ALREADY VERIFIED: the invitation arrived in that * mailbox and came back, which is the same proof a verification link provides. * * Refuses with 409 when the address already has an account, and directs the * caller to sign in and use {@link handleAcceptInvitation} — creating a second * account for one address is not a fallback, it is a duplicate identity. */ export declare const handleAcceptInvitationWithSignUp: (input: AcceptInvitationWithSignUpInput, store: IdentityStore & MembershipStore & InvitationStore, deps: InvitationDeps & { /** Hash a plaintext password. Injected so this module does not import the * password module's Effect error channel into the invitation surface. */ readonly hashPassword: (plaintext: string) => Effect.Effect; /** Minimum password length, mirroring `handleSignUp`. Default 8. */ readonly minPasswordLength?: number; }) => Effect.Effect; /** * Create + email an invitation. * * Refuses when the caller may not invite into this tenant, when the requested * role is above what they may grant, or when the tenant is at its pending cap. * A re-invite SUPERSEDES: every pending invitation for the same (tenant, * email) is revoked first, so the address never holds two live tokens and * "resend" cannot be used to accumulate them. */ export declare const handleCreateInvitation: (input: CreateInvitationInput, store: MembershipStore & InvitationStore, deps: InvitationDeps) => Effect.Effect; /** * Issue a CSRF token. Returns the token in the JSON body AND sets a * readable (non-HttpOnly) `voltro:csrf` cookie. The SPA reads the cookie * and echoes the value in the `x-csrf-token` header on every mutation; * the server verifies the two match + the HMAC. */ export declare const handleCsrf: (config: AuthConfig) => HandlerResult; /** * Redeem a verification link. * * Issues NO session — see the header. The response says whether the address is * now verified, and the client sends the user to sign in. */ export declare const handleEmailVerifyConfirm: (input: EmailVerifyConfirmInput, store: IdentityStore & TokenStore) => Effect.Effect; /** * Request (or re-request) a verification link. * * Always 202, whether or not the address has an account and whether or not the * cooldown suppressed the send — same reasoning as magic-link and * password-reset. Two things leak from a status that varies: which addresses * are registered, and (through the cooldown) whether one was recently active. * An already-verified user is also answered 202 and sent nothing. */ export declare const handleEmailVerifyRequest: (input: EmailVerifyRequestInput, store: IdentityStore & TokenStore, deps: EmailVerificationDeps) => Effect.Effect; export declare const handleImpersonationStart: (input: ImpersonationStartInput, store: IdentityStore & ImpersonationStore, deps: ImpersonationDeps) => Effect.Effect; /** * End an impersonation and put the impersonator back in their own identity. * * Three things happen, in this order, and all three matter: * * 1. the grant is CLOSED (`endedAt`), so the record says when it stopped; * 2. the impersonated `sessions` row is DELETED and dropped from the * revocation cache — so a copy of that cookie taken during the grant is * dead immediately, not at its own expiry; * 3. the actor's ORIGINAL session row (never revoked, still ticking) gets a * fresh cookie for its REMAINING lifetime. * * (3) is why the actor's session is left alive throughout. Minting them a new * session instead would silently extend their login by a full TTL every time * they used the support tool. If their own session has expired or been revoked * meanwhile, the response clears the cookie — being signed out is the correct * outcome, and it is strictly better than staying signed in as somebody else. */ export declare const handleImpersonationStop: (input: ImpersonationStopInput, store: IdentityStore & SessionStore & ImpersonationStore, deps: ImpersonationDeps) => Effect.Effect; export declare const handleListInvitations: (input: { readonly callerUserId: string; readonly tenantId: string; }, store: MembershipStore & InvitationStore, deps: InvitationDeps) => Effect.Effect; export declare const handleListMemberships: (input: { readonly userId: string; }, store: MembershipStore) => Effect.Effect; export declare const handleListSessions: (input: ListSessionsInput, store: SessionStore) => Effect.Effect; /** * Consume a magic-link token: validate (single-use, unexpired) and, on * success, issue a session for the bound user. Writes a session row + * loads memberships, same as password sign-in. */ export declare const handleMagicLinkConsume: (input: MagicLinkConsumeInput, store: IdentityStore & MembershipStore & SessionStore & TokenStore & LockoutStore, config: AuthConfig) => Effect.Effect; /** * Request a magic-link. Always returns 202 regardless of whether the * email exists (no account-enumeration). When the user exists we mint a * single-use token, persist its hash, and call `config.sendEmail`. */ export declare const handleMagicLinkRequest: (input: MagicLinkRequestInput, store: IdentityStore & TokenStore, config: AuthConfig) => Effect.Effect; export declare const handleMfaEnrollStart: (input: MfaEnrollStartInput, store: IdentityStore & MfaStore) => Effect.Effect; export declare const handleMfaEnrollVerify: (input: MfaEnrollVerifyInput, store: IdentityStore & MfaStore) => Effect.Effect; /** Regenerate the user's recovery codes — wipes the prior set and returns * a fresh plaintext batch (shown once). Only valid for an enrolled user. */ export declare const handleMfaRegenerateRecoveryCodes: (input: MfaRegenerateRecoveryCodesInput, store: IdentityStore & MfaStore) => Effect.Effect; export declare const handleMfaUnenroll: (input: MfaUnenrollInput, store: IdentityStore & MfaStore) => Effect.Effect; /** * Complete an MFA sign-in: redeem the single-use pending token, verify the * submitted TOTP code (or a recovery code) against the user's stored * secret, and ONLY THEN issue the real session — through the same * `issueUserSession` path as password sign-in, so rotation + revocation * apply. A wrong code (or missing enrolment) is a 401; the pending token is * consumed on redemption regardless, so a guessed code can't be retried * against the same challenge. */ export declare const handleMfaVerify: (input: MfaVerifyInput, store: IdentityStore & MfaStore & MembershipStore & SessionStore & TokenStore & LockoutStore, config: AuthConfig) => Effect.Effect; /** Step 3: mint assertion options for `navigator.credentials.get`. */ export declare const handlePasskeyAssertOptions: (input: PasskeyAssertOptionsInput, store: PasskeyStore, challenges: ChallengeStore, pk: PasskeyConfig) => Effect.Effect; /** Step 4: verify the assertion (signature + strictly-increasing counter) * and issue a session. */ export declare const handlePasskeyAssertVerify: (input: PasskeyAssertVerifyInput, store: IdentityStore & MembershipStore & SessionStore & PasskeyStore & LockoutStore, challenges: ChallengeStore, pk: PasskeyConfig, config: AuthConfig) => Effect.Effect; /** Step 1: mint registration options for `navigator.credentials.create`. */ export declare const handlePasskeyRegisterOptions: (input: PasskeyRegisterOptionsInput, store: IdentityStore & PasskeyStore, challenges: ChallengeStore, pk: PasskeyConfig) => Effect.Effect; /** Step 2: verify the registration and persist the credential. */ export declare const handlePasskeyRegisterVerify: (input: PasskeyRegisterVerifyInput, store: IdentityStore & PasskeyStore, challenges: ChallengeStore, pk: PasskeyConfig) => Effect.Effect; /** Confirm a password reset: validate the token, set the new (hashed) * password, and revoke ALL existing sessions for the user (a reset * invalidates every prior login). */ export declare const handlePasswordResetConfirm: (input: PasswordResetConfirmInput, store: IdentityStore & SessionStore & TokenStore) => Effect.Effect; /** Request a password reset. Uniform 202 (no enumeration); mints + emails * a single-use reset token when the user exists. */ export declare const handlePasswordResetRequest: (input: PasswordResetRequestInput, store: IdentityStore & TokenStore, config: AuthConfig) => Effect.Effect; /** * What is behind this link, without redeeming it — the accept page's read. * * Deliberately unauthenticated and deliberately thin: tenant, role, the * invited address and whether it is still live. It does NOT reveal whether an * account already exists for the address, because that would turn a leaked * invitation into an account-existence oracle for a third party. */ export declare const handlePreviewInvitation: (input: { readonly token: string; }, store: InvitationStore, deps?: InvitationDeps) => Effect.Effect; export declare const handleRevokeAllOtherSessions: (input: RevokeOtherSessionsInput, store: SessionStore) => Effect.Effect; export declare const handleRevokeInvitation: (input: { readonly callerUserId: string; readonly tenantId: string; readonly invitationId: string; }, store: MembershipStore & InvitationStore, deps: InvitationDeps) => Effect.Effect; export declare const handleRevokeSession: (input: RevokeSessionInput, store: SessionStore) => Effect.Effect; export declare interface HandlerResult { readonly status: number; readonly body: string; readonly contentType?: string; readonly setCookie?: string; readonly location?: string; } declare interface HandlerResultShape { readonly status: number; readonly body: string; readonly contentType?: string; } declare interface HandlerResultShape_2 { readonly status: number; readonly body: string; readonly contentType?: string; readonly setCookie?: string; } declare interface HandlerResultShape_3 { readonly status: number; readonly body: string; readonly contentType?: string; readonly setCookie?: string; } /** * Sign-in handler. Accepts either form-encoded OR JSON input with * `email` + `password`. Returns: * - 200 JSON `{ ok: true, mfaRequired: true, pendingToken }` when the * user has MFA enrolled — NO session cookie is issued; the caller must * complete `POST /auth/mfa/verify` with the pending token + a TOTP (or * recovery) code before a real session is granted. * - 200 JSON with subject + Set-Cookie (for fetch-based callers) when the * user has NO MFA enrolled. * - 302 redirect when `redirectAfter` is true (form post path, non-MFA). * * Always runs `verifyPassword` even when the user is missing — or when the * user exists but has NO stored password hash — so sign-in latency doesn't * leak existence, nor which accounts are password-less (timing-oracle * defence). Both cases return the identical 401. * * On the non-MFA success path it ALSO: (a) loads the user's tenant * memberships and carries them on the Subject, (b) writes a `sessions` row * for the device list + revocation, and (c) rehashes the stored password * when it's below the current scrypt cost (rehash-on-verify). */ export declare const handleSignIn: (input: SignInInput, store: IdentityStore & MembershipStore & SessionStore & TokenStore & LockoutStore, config: AuthConfig) => Effect.Effect; /** * Sign-out handler. Clears the session cookie. Synchronous so it * stays callable as a plain function from non-Effect call sites; no * error channel because there's nothing to fail. */ export declare const handleSignOut: (config: AuthConfig) => HandlerResult; /** * Sign-up handler. Creates a new user with the hashed password + * issues a session immediately (auto sign-in after sign-up). * * Inputs failing validation collapse to 4xx HandlerResults. Storage * errors (`UserAlreadyExistsError`, `PasswordHashError`) propagate * via the Effect's error channel — callers either map them to 5xx or * pattern-match for finer-grained responses. */ export declare const handleSignUp: (input: SignUpInput, store: IdentityStore & MembershipStore & SessionStore & TokenStore & LockoutStore, config: AuthConfig) => Effect.Effect; /** * Switch the active tenant. Validates the user is actually a member of * `targetTenantId` (the switch-tenant guard), re-issues the session cookie * with the new active tenant, and — when a `clientId` + `rebind` are supplied — * presents that new cookie on the live connection so its subsequent calls * resolve against the target tenant without a reconnect. * * Calls only. Subscriptions already open on that connection were authorized * under the previous cookie and keep running until the client re-subscribes; * nothing re-scopes them, and nothing ever did — the `onBindConnectionSubject` * listener that claimed to had no subscribers in the entire framework. */ export declare const handleSwitchTenant: (input: SwitchTenantInput, store: IdentityStore & MembershipStore, config: AuthConfig, rebind?: RebindConnection, /** The app's DataStore, when the plugin has been bound to one: the switch * is then ALSO recorded as the framework's tenant selection * (`_voltro_tenant_selections`), so the cookie and the selection never * disagree — a request this user makes with any OTHER credential (an IdP * JWT, an API key) resolves in the tenant they switched to as well. */ dataStore?: DataStore) => Effect.Effect; /** * Hash a password. Returns a self-describing string that * `verifyPassword` can re-parse without external config. Don't * truncate this string — the parameters are encoded inline. */ export declare const hashPassword: (plaintext: string) => Effect.Effect; /** Hash a plaintext token for storage / lookup. SHA-256 is sufficient — * the token is high-entropy (32 random bytes), so there's no * brute-force surface that would need a slow KDF. */ export declare const hashToken: (plaintext: string) => string; /** The identity columns the store reads and writes, by their record name. */ export declare type IdentityColumn = 'id' | 'email' | 'passwordHash' | 'tenantId' | 'createdAt' | 'updatedAt' | 'mfaSecret' | 'mfaEnrolledAt' | 'emailVerifiedAt' | 'deactivatedAt'; /** Identity — who a user is. The only role an app with its own identity table has to write itself; everything else composes from the shipped store. */ export declare interface IdentityStore { readonly findByEmail: (email: string) => Effect.Effect; readonly findById: (id: string) => Effect.Effect; readonly insert: (user: Omit) => Effect.Effect; /** Replace a user's stored password hash. Used by password-reset AND * by rehash-on-verify (silently upgrading an under-cost hash on a * successful sign-in). */ readonly updatePassword: (userId: string, newHash: string) => Effect.Effect; /** * Stamp `emailVerifiedAt` — the address has been proven. * * IDEMPOTENT and non-regressing: a user already verified keeps their * ORIGINAL timestamp. "When was this address first proven" is the question * the column answers, and re-stamping it on every later magic-link login * would quietly turn it into "when did they last click something". */ readonly markEmailVerified: (userId: string) => Effect.Effect; } export declare interface IdentityTableMapping { /** The table (default `users`). Unqualified or `schema.table`. */ readonly table?: string; /** * Record name → column name. Unmapped columns keep their record name. * `id`, `email`, `tenantId` and `createdAt` must exist. The others may be * `false` — "my table has no such column": reads answer null, and a write * that exists only to set that column (`updatePassword` → `passwordHash`, * the MFA methods → `mfaSecret` / `mfaEnrolledAt`, `markEmailVerified` → * `emailVerifiedAt`) dies naming the mapping. `updatedAt: false` merely * omits the stamp; `deactivatedAt` is `false` by default because the * shipped table has no such column — map it to read the app's own. */ readonly columns?: { readonly id?: string; readonly email?: string; readonly tenantId?: string; readonly createdAt?: string; readonly passwordHash?: string | false; readonly updatedAt?: string | false; readonly mfaSecret?: string | false; readonly mfaEnrolledAt?: string | false; readonly emailVerifiedAt?: string | false; readonly deactivatedAt?: string | false; }; } /** Default grant lifetime — 15 minutes. A support interaction, not a shift. */ export declare const IMPERSONATION_DEFAULT_DURATION_S: number; /** * Sub-paths of the auth surface that an IMPERSONATED session may not reach. * * Every one of them either mints a durable credential on the account (MFA, * passkeys), destroys the impersonator's way back (revoke-others, which would * kill the actor's own restored-to session), moves the session somewhere the * grant does not describe (switch-tenant), or extends the grant (a second * impersonation). Suffixes, matched against the plugin's configured prefix. */ export declare const IMPERSONATION_FORBIDDEN_SUBPATHS: ReadonlyArray; export declare interface ImpersonationAuditEvent { readonly kind: ImpersonationAuditKind; readonly at: Date; /** The impersonator. Null only when the attempt had no resolvable caller. */ readonly actorUserId: string | null; readonly actorEmail: string | null; /** The impersonated user. Null on a refusal that never resolved one. */ readonly targetUserId: string | null; readonly targetEmail: string | null; readonly tenantId: string | null; /** The impersonated session, once one exists. */ readonly sessionId?: string; readonly grantId?: string; readonly reason?: string | null; readonly expiresAt?: Date; /** The machine code on `kind: 'refused'` — `self`, `nested`, `not_permitted`, * `target_may_impersonate`, `reason_required`, `target_not_found`. */ readonly refusal?: string; readonly ipAddress?: string | null; readonly userAgent?: string | null; } /** What happened, for the audit sink. `refused` is recorded too: an attempt * that was turned away is the single most interesting row in this table. */ export declare type ImpersonationAuditKind = 'started' | 'stopped' | 'refused'; /** * A `redactSubject` function for `@voltro/plugin-audit` that keeps the * impersonation mark and redacts the rest of `subject.metadata`. * * **Wire this, or your audit rows will not say who was behind an impersonated * action.** `auditPlugin`'s default is `redactSubject: 'metadata'`, which * replaces the whole metadata bag — correct in general (that bag is where a * per-user provider credential lands, and an audit table is the last place a * live PAT should be) and it takes this mark with it. The result is a trail in * which an impersonated action is indistinguishable from the user's own, which * is the exact failure this module exists to prevent. * * auditPlugin({ * sink: 'datastore', * redactSubject: impersonationAuditRedactor(), * }) * * It is strictly safer than `redactSubject: 'none'` (which would store the * whole bag, credentials included) and strictly more informative than the * default. The `impersonationGrants` row remains the record that does not * depend on anyone wiring this. */ export declare const impersonationAuditRedactor: () => (subject: Subject) => unknown; export declare interface ImpersonationAuthorityInput { /** The caller's resolved subject. */ readonly actor: Subject; /** The caller's user row. */ readonly actorUser: UserRecord; /** The user they are asking to act as. */ readonly target: UserRecord; } export declare interface ImpersonationConfig { /** * MAY this actor impersonate this target? REQUIRED — see the header for why * there is no default and why it must not be a scope check. * * It is called TWICE per start: once as asked, and once with the identities * SWAPPED to answer "could the target impersonate the actor?". A `true` from * the second call refuses the request (the PEER rule). Write it as a pure * predicate over the two records; a function whose answer depends on which * request is in flight will make that probe meaningless. */ readonly authority: (input: ImpersonationAuthorityInput) => Effect.Effect; /** * Where impersonation events go. REQUIRED, and required precisely because * this is the feature whose whole risk is an unrecorded action: a config * that let you enable impersonation while leaving the destination unset * would make the dangerous half optional and the safe half opt-in. * * Called for `started`, `stopped` AND `refused`. A throwing or rejecting * sink is swallowed — a broken log must not leave a grant half-created — * so treat this as best-effort ON TOP of the `impersonationGrants` row, * which is written transactionally with the session and is not. */ readonly audit: (event: ImpersonationAuditEvent) => Effect.Effect | Promise | void; /** * Hard ceiling on one grant, in seconds. Default 900 (15 minutes). * * A caller may request LESS and never more — a requested duration is * clamped, not validated, because a support tool asking for eight hours * should get fifteen minutes rather than an error it will retry around. * The number is the impersonated cookie's TTL as well as the grant row's * `expiresAt`, so the bound survives the process that issued it. */ readonly maxDurationSeconds?: number; /** Require a non-empty `reason` on every start. Default `true` — a support * action with no stated cause is the one you cannot review later. */ readonly requireReason?: boolean; } export declare interface ImpersonationDeps { readonly impersonation: ImpersonationConfig; readonly issuer: ImpersonationSessionIssuer; readonly now?: () => number; /** Drop a session id from the request-time revocation cache, so ending a * grant kills the cookie on THIS process immediately rather than within the * cache window. */ readonly invalidateSession?: (sessionId: string) => void; } /** One impersonation grant — "actor is acting as target, on this session, * until this instant". See `impersonationGrantsTable`. */ export declare interface ImpersonationGrantRecord { readonly id: string; /** The IMPERSONATED session's row id. */ readonly sessionId: string; readonly actorUserId: string; /** The impersonator's own session, restored when the grant ends. */ readonly actorSessionId: string; readonly targetUserId: string; readonly tenantId: string; readonly reason: string | null; readonly startedAt: Date; readonly expiresAt: Date; readonly endedAt: Date | null; readonly endedReason: string | null; readonly ipAddress?: string | null; readonly userAgent?: string | null; } /** The mark carried on an impersonated session's Subject. Plain JSON — it is * serialised into a cookie payload and into audit rows. */ export declare interface ImpersonationMark { /** The impersonating user. */ readonly actorUserId: string; /** Their own session, restored when the grant ends. */ readonly actorSessionId: string; readonly grantId: string; /** ISO-8601. */ readonly startedAt: string; /** ISO-8601. Equals the cookie's own expiry — one number, two places. */ readonly expiresAt: string; readonly reason?: string; } /** The impersonation mark on a Subject, or null for an ordinary session. */ export declare const impersonationOf: (subject: Subject) => ImpersonationMark | null; /** * How a session is minted. Injected rather than imported so this module has no * edge to `handlers.ts` — and, more usefully, so a test can observe exactly * what TTL and what metadata the impersonated cookie was built with. */ export declare interface ImpersonationSessionIssuer { readonly issue: (user: UserRecord, options: { readonly sessionId: string; readonly ttlSeconds: number; readonly metadata: Record; readonly ipAddress?: string | null; readonly userAgent?: string | null; }) => Effect.Effect<{ readonly setCookie: string; readonly subject: Subject; }>; /** Re-issue a cookie for an EXISTING session row — the restore on stop. */ readonly reissue: (user: UserRecord, options: { readonly sessionId: string; readonly ttlSeconds: number; }) => Effect.Effect<{ readonly setCookie: string; readonly subject: Subject; }>; /** A `Set-Cookie` that clears the session — the fallback when the actor's * own session no longer exists to restore. */ readonly clear: () => string; } export declare interface ImpersonationStartInput { /** The caller's resolved subject — from the session cookie, never the body. */ readonly actorSubject: Subject; /** The caller's own server-side session id. Restored when the grant ends. */ readonly actorSessionId: string; /** Who to act as. One of the two; `userId` wins when both are given. */ readonly targetUserId?: string; readonly targetEmail?: string; readonly reason?: string; /** Requested duration. CLAMPED to `maxDurationSeconds`, never exceeded. */ readonly durationSeconds?: number; readonly ipAddress?: string | null; readonly userAgent?: string | null; } export declare interface ImpersonationStopInput { /** The caller's resolved subject — must be an impersonated one. */ readonly subject: Subject; /** The caller's current (impersonated) session id. */ readonly sessionId: string; } /** Impersonation grants. */ export declare interface ImpersonationStore { /** Record an impersonation grant when the impersonated session is minted. */ readonly insertImpersonationGrant: (grant: Omit) => Effect.Effect; /** The grant for an impersonated session, or null. The stop path's lookup. */ readonly findImpersonationGrantBySession: (sessionId: string) => Effect.Effect; /** Close a grant. Only an OPEN grant is closed (`endedAt IS NULL`), so a * double stop is a no-op rather than a rewritten end time. */ readonly endImpersonationGrant: (sessionId: string, endedReason: string, now: Date) => Effect.Effect; } /** Default cap on live invitations per tenant. */ export declare const INVITATION_MAX_PENDING = 500; /** Default invitation lifetime — 7 days. Long enough to survive a weekend and * a holiday Monday, short enough that a forgotten mailbox is not a standing * key to a tenant. */ export declare const INVITATION_TTL_S: number; /** The `AuthConfig` slice these handlers read. Structural so this module does * not import `handlers.ts`. */ export declare interface InvitationDeps { readonly appBaseUrl?: string; readonly sendEmail?: (input: { readonly to: string; readonly subject: string; readonly html: string; readonly text: string; readonly kind: 'magic-link' | 'password-reset' | 'email-verify' | 'invitation'; readonly actionUrl: string; }) => Promise; readonly invitations?: InvitationsConfig; readonly now?: () => number; } /** * A tenant invitation — the grant that turns into a `memberships` row when * redeemed. See `invitationsTable` for why `role` lives here and not in the * accept request. */ export declare interface InvitationRecord { readonly id: string; /** SHA-256 hex of the plaintext token. The plaintext lives only in the email. */ readonly tokenHash: string; /** The invited address, lower-cased. */ readonly email: string; readonly tenantId: string; /** The role the membership is created with. Chosen by the INVITER. */ readonly role: string; readonly invitedByUserId: string; readonly expiresAt: Date; readonly acceptedAt: Date | null; readonly acceptedByUserId: string | null; readonly revokedAt: Date | null; readonly revokedByUserId: string | null; readonly createdAt: Date; } export declare interface InvitationsConfig { /** Lifetime of an invitation. Default {@link INVITATION_TTL_S} (7 days). */ readonly ttlSeconds?: number; /** Path the emailed link points at. Default `/auth/invitations/accept`. */ readonly callbackPath?: string; /** * Cap on simultaneously-live invitations per tenant. Default 500. * * A bound, not a business rule: a compromised admin account otherwise turns * the invite endpoint into a mail cannon aimed at any list of addresses, one * row and one message each, with your domain as the sender. */ readonly maxPending?: number; /** Roles permitted to create / list / revoke invitations. Default * `['owner', 'admin']`. */ readonly inviterRoles?: ReadonlyArray; /** Role ordering, most privileged FIRST. Feeds the default * {@link InvitationsConfig.canGrantRole}. Default * `['owner','admin','member','viewer']`. */ readonly roleRank?: ReadonlyArray; /** * Replace the whole "may this inviter hand out this role" rule. * * The default ranks by {@link InvitationsConfig.roleRank} and permits an * inviter to grant their own role or any role BELOW it; a requested role * absent from the rank list may be granted only by the top-ranked role, and * an inviter whose own role is absent may grant nothing. Supply your own for * any model that is not a line — a matrix, a per-tenant plan, a capability * set. Whatever you write, keep it a refusal by default: this function is * the only thing between an admin and self-promotion by invitation. */ readonly canGrantRole?: (ctx: GrantRoleContext) => boolean; } /** Tenant invitations. */ export declare interface InvitationStore { /** Persist an invitation (token already hashed). */ readonly insertInvitation: (invitation: Omit) => Effect.Effect; /** Look an invitation up by token hash WITHOUT redeeming it — the read the * "what am I being invited to" preview needs. Returns the row whatever its * state; the caller decides what an expired or revoked one means. */ readonly findInvitationByHash: (tokenHash: string) => Effect.Effect; /** * Atomically redeem an invitation: returns the row only when it exists, is * unaccepted, unrevoked and unexpired — and stamps `acceptedAt` + * `acceptedByUserId` in the same step. Null otherwise. * * The single-use guard, and it has to be ONE statement: two clicks on the * same emailed link race, and a read-then-write would let both through and * write the membership twice. */ readonly acceptInvitation: (tokenHash: string, acceptedByUserId: string, now: Date) => Effect.Effect; /** Withdraw a PENDING invitation, scoped to its tenant so an admin of one * tenant cannot revoke another's by id. Returns whether a row changed. */ readonly revokeInvitation: (invitationId: string, tenantId: string, revokedByUserId: string, now: Date) => Effect.Effect; /** Every invitation for a tenant, newest first. The admin list. */ readonly listInvitations: (tenantId: string) => Effect.Effect>; /** How many invitations for this tenant are still redeemable — the bound * `maxPending` enforces. */ readonly countPendingInvitations: (tenantId: string, now: Date) => Effect.Effect; /** Revoke every pending invitation for (tenant, email). Called before * issuing a fresh one so re-inviting supersedes rather than accumulates — * otherwise every resend leaves a live token behind. Returns the count. */ readonly supersedePendingInvitations: (tenantId: string, email: string, revokedByUserId: string, now: Date) => Effect.Effect; } /** Verify a token's own HMAC (signature integrity), independent of the * cookie/header match. */ export declare const isCsrfTokenWellFormed: (token: string, secret: string) => boolean; /** * Read the verification mark off a session's Subject. * * `true` — proven. * `false` — NOT proven, and the session says so. * `null` — this session carries no mark at all. * * **`null` is not `false`, and conflating them is the failure this tri-state * exists to prevent.** A session minted while the policy was `'off'` — or by * an app that never enabled it — has nothing to say about verification. App * code that treats "no mark" as "unverified" would refuse every one of those * callers the moment the policy is switched on, which is the same outage the * `'off'` default avoids, re-created one layer up. Gate on `=== false`. */ export declare const isEmailVerified: (subject: Subject) => boolean | null; /** Is this session an impersonated one? */ export declare const isImpersonated: (subject: Subject) => boolean; /** * Issue a fresh CSRF token: `.`. Set this as a * readable (non-HttpOnly) cookie AND hand it to the SPA so it can echo * it in the `x-csrf-token` header. */ export declare const issueCsrfToken: (secret: string) => string; export declare interface IssuedSession { /** The opaque cookie value — `.`. */ readonly value: string; /** Ready-to-Set-Cookie header string with sane defaults. */ readonly setCookie: string; } /** * Mint a signed session AND build the corresponding `Set-Cookie` * header in one call. Apps just attach the header to their * response. Pass a `SessionSecrets` set to sign with the current * rotation key (its `kid` is stamped into the payload). * * The subject is a `Subject | SubjectIdentity` at the type level and an * IDENTITY on the wire: a session cookie carries no `scopes`, and * `signSession` throws rather than dropping them silently. Authority for * this session is resolved per request by `auth.resolveScopes`, so a role * removed after sign-in takes effect on this same session. */ export declare const issueSession: (subject: Subject | SubjectIdentity, secret: SessionSecretInput, options?: IssueSessionOptions) => IssuedSession; export declare interface IssueSessionOptions { readonly ttlSeconds?: number; readonly domain?: string; readonly secure?: boolean; } /** * Issue a real session for a user: load memberships, write the `sessions` row * (so device-list + revocation cover it), and mint the signed cookie. * * **The ONE path that mints a session.** Password sign-in (post-MFA), sign-up, * magic-link, MFA verify, passkey assertion and impersonation all come through * here, so rotation, revocation, the session row and every metadata mark apply * uniformly. Sign-up and magic-link used to carry their own inline copies of * this body — which is exactly how a mark added in one place ends up missing * on two of five login paths, discoverable only by logging in the right way. */ export declare const issueUserSession: (user: UserRecord, store: MembershipStore & SessionStore & LockoutStore, config: AuthConfig, meta?: IssueUserSessionOptions) => Effect.Effect<{ readonly setCookie: string; readonly subject: ReturnType; }>; /** What an `issueUserSession` caller may vary. */ export declare interface IssueUserSessionOptions { readonly ipAddress?: string | null; readonly userAgent?: string | null; /** * Pre-allocated session-row id. Impersonation supplies one so the grant row * can be written BEFORE the session exists — a session with no record of who * is behind it must never be reachable, not even for a moment. */ readonly sessionId?: string; /** Cookie + session-row lifetime. Defaults to the 7-day session TTL; * impersonation passes its (much shorter) grant duration. */ readonly ttlSeconds?: number; /** * `'insert'` (default) writes a fresh `sessions` row. `'existing'` mints a * cookie for a row that is ALREADY there and leaves it untouched. * * `'existing'` is the impersonation stop path putting the actor back into * their own, never-revoked session. Inserting there would duplicate the row * (the memory store appends; a keyed lookup then answers with whichever came * first), and touching it would silently extend the actor's own login by a * full lifetime every time they used the support tool. */ readonly sessionRow?: 'insert' | 'existing'; /** Extra keys merged onto the Subject's metadata slot — the impersonation * mark. `sessionId` and the verification flag are set by this function and * win over anything here. */ readonly metadata?: Record; /** * Clear the brute-force counter for this email. Default TRUE, because a * completed login is proof the password is known. * * IMPERSONATION PASSES FALSE. Nobody authenticated as the target, so * clearing their lockout would let a support action undo brute-force * protection on the very account someone is hammering. */ readonly clearLoginFailures?: boolean; } export { KeyedSecret } declare interface ListSessionsInput { readonly userId: string; } /** * Brute-force lockout policy, resolved from `AuthConfig.lockout` and passed * INTO the store on each failure so the store itself stays policy-free. * Times in milliseconds; `now` everywhere is epoch-ms so tests can inject a * clock. Kept keyed by EMAIL (not user id) on purpose — a locked account must * not read differently from an unknown one, or the lock becomes an existence * oracle. See `loginAttemptsTable`. */ declare interface LockoutPolicy { readonly maxAttempts: number; readonly windowMs: number; readonly lockMs: number; } declare interface LockoutState { readonly locked: boolean; /** Seconds until the lock lifts; 0 when not locked. */ readonly retryAfterSeconds: number; } /** Brute-force lockout, keyed by normalized email. */ export declare interface LockoutStore { /** Is this email currently locked out? Reads only — never mutates. Returns * the remaining cooldown so the handler can surface a retry-after. */ readonly isLockedOut: (email: string, now: number) => Effect.Effect; /** Record one failed credential attempt for `email`. Opens a fresh window * when the previous one (or a prior lock) has expired, otherwise increments; * sets the lock once `failedCount` reaches `policy.maxAttempts`. Called only * for a NOT-currently-locked email (the handler checks `isLockedOut` first). */ readonly recordLoginFailure: (email: string, now: number, policy: LockoutPolicy) => Effect.Effect; /** Clear the counter for `email` on a completed login. */ readonly clearLoginFailures: (email: string) => Effect.Effect; } /** Default magic-link lifetime — short, since it's an inbox round-trip. */ export declare const MAGIC_LINK_TTL_S: number; declare interface MagicLinkConsumeInput { readonly token: string; readonly redirectAfter?: boolean; } declare interface MagicLinkRequestInput { readonly email: string; /** Path the email link points at (the consume endpoint). Default * `/auth/magic-link/callback`. */ readonly callbackPath?: string; } /** The slice of `@voltro/plugin-mail`'s `MailService` we depend on. */ export declare interface MailLike { readonly send: (message: { readonly to: string; readonly subject: string; readonly html: string; readonly text?: string; }) => Effect.Effect; } /** * Build a `SendEmail` hook backed by `@voltro/plugin-mail`. Wire it into * `authRoutesPlugin({ sendEmail: mailSender(mail) })` where `mail` is the * yielded `MailService`. * * ```ts * const mail = yield* MailService * authRoutesPlugin({ store, secret, sendEmail: mailSender(mail) }) * ``` */ export declare const mailSender: (mail: MailLike) => SendEmail; /** * Build the revocation checker over a `UserStore`'s session rows. * `authRoutesPlugin` constructs one internally (options via * `sessionRevocation`); `voltroPasswordStrategy({ store })` builds or * accepts one so the framework's rpc auth chain enforces the same * revocations the HTTP routes do. */ export declare const makeSessionRevocationChecker: (store: Pick, options?: SessionRevocationOptions) => SessionRevocationChecker; /** One tenant a user belongs to. The active tenant lives on the Subject; * the full set drives the switch-tenant menu + the switch-tenant guard. */ export declare interface MembershipRecord { readonly userId: string; readonly tenantId: string; readonly role: string; readonly joinedAt: Date; } /** Memberships + switch-tenant. */ export declare interface MembershipStore { /** Every tenant the user belongs to. */ readonly listMemberships: (userId: string) => Effect.Effect>; /** Idempotent grant: inserts the [userId, tenantId] membership or * updates its role if it already exists. The DB-level composite * UNIQUE on `[userId, tenantId]` (see `membershipsTable`) is the * source of truth; this upsert converges to it. */ readonly addMembership: (membership: Omit) => Effect.Effect; /** Resolve the role for a [userId, tenantId] pair, or null when the * user is NOT a member. The switch-tenant handler calls this to * validate the requested tenant before rebinding the connection. */ readonly membershipRole: (userId: string, tenantId: string) => Effect.Effect; } /** In-memory challenge store. Single-node only — a challenge minted on one * replica is invisible to another, so this is the DEV / single-node * default. Multi-replica deployments pass `dataStoreChallengeStore`. */ export declare const memoryChallengeStore: () => ChallengeStore; /** * Build a UserStore backed by in-memory Maps. Useful for tests + * dev scenarios that don't want to spin up Postgres. NOT for * production — restart wipes everything. */ export declare const memoryUserStore: (seed?: ReadonlyArray) => UserStore; /** Lifetime of the short-lived MFA pending token (the second-factor * challenge). Long enough for the user to fetch a TOTP code, short enough * that a leaked challenge is near-useless. * * Lives HERE rather than beside the MFA handler that mints it, because the * default-TTL table below has to be exhaustive over `TokenPurpose` and a * constant imported from `handlers.ts` would be a cycle. `handlers.ts` * re-exports it, so the public spelling is unchanged. */ export declare const MFA_PENDING_TTL_S: number; /** MFA (TOTP) enrolment config. */ export declare interface MfaConfig { /** Issuer label shown in the user's authenticator app (typically your * product name, e.g. "Voltro Cloud"). */ readonly issuer: string; } export declare interface MfaEnrollStartInput { readonly userId: string; /** Issuer for the otpauth URL — typically your product name * ("Voltro Cloud"). Shown in the user's authenticator app. */ readonly issuer: string; /** Account name shown in the authenticator app under the issuer. * Email is the conventional pick. */ readonly accountName: string; } export declare interface MfaEnrollVerifyInput { readonly userId: string; readonly code: string; } export declare interface MfaRegenerateRecoveryCodesInput { readonly userId: string; } /** TOTP enrolment + recovery codes. */ export declare interface MfaStore { /** Stash a freshly-generated TOTP secret on the user. Called by * the MFA enrolment handler before the user verifies the first * code. */ readonly setMfaSecret: (userId: string, secret: string) => Effect.Effect; /** Mark MFA as fully enrolled (called after the first successful * TOTP verify completes the enrolment ceremony). */ readonly markMfaEnrolled: (userId: string) => Effect.Effect; /** Wipe the secret + enrolledAt. Used by /account/security's * "remove MFA" action. */ readonly clearMfa: (userId: string) => Effect.Effect; /** Replace the user's recovery codes with a fresh (already hashed) set. * Called at enrolment / regeneration — wipes any prior codes so the * displayed set is the only valid one. */ readonly replaceRecoveryCodes: (userId: string, codeHashes: ReadonlyArray) => Effect.Effect; /** Atomically redeem one recovery code by its hash: returns true only * when an unconsumed row for `userId` matched — and marks it consumed * in the same step (single-use). false otherwise. */ readonly consumeRecoveryCode: (userId: string, codeHash: string) => Effect.Effect; /** Count the user's remaining (unconsumed) recovery codes — surfaced so * the UI can warn when the user is running low. */ readonly countRecoveryCodes: (userId: string) => Effect.Effect; } export declare interface MfaUnenrollInput { readonly userId: string; } export declare interface MfaVerifyInput { /** The pending token returned by `handleSignIn` when MFA was required. */ readonly pendingToken: string; /** A 6-digit TOTP code. Provide this OR `recoveryCode`. */ readonly code?: string; /** A single-use recovery (backup) code — the fallback when the * authenticator is unavailable. Provide this OR `code`. */ readonly recoveryCode?: string; readonly redirectAfter?: boolean; readonly ipAddress?: string; readonly userAgent?: string; } export declare interface MintedToken { /** The plaintext — put this in the emailed link. Never stored. */ readonly token: string; /** SHA-256 of the plaintext — store this. */ readonly tokenHash: string; /** Unix-ms expiry. */ readonly expiresAt: Date; readonly purpose: TokenPurpose; } /** * Mint a fresh single-use secret with NO purpose attached — 32 CSPRNG bytes, * its SHA-256, and an expiry. * * Exists for the credentials that are single-use, hashed and expiring but do * NOT live in `authTokens`: a tenant invitation carries its own row (with an * address, a tenant and a role), so it has no `TokenPurpose` to name. Reaching * for `mintToken('magic-link', …)` just to obtain the bytes would file an * invitation under a purpose it does not have, in a type everything else reads * as authoritative. */ export declare const mintSecret: (ttlSeconds: number) => Omit; /** * Mint a fresh single-use token. The caller persists `{ tokenHash, * userId, purpose, expiresAt }` via `UserStore.insertToken` and emails * the plaintext `token` in a link. */ export declare const mintToken: (purpose: TokenPurpose, ttlSeconds?: number) => MintedToken; /** * Does this stored hash use parameters weaker than the framework's * current cost? `true` means "re-hash on next successful verify". A * malformed hash also returns `true` (it should be replaced). */ export declare const needsRehash: (stored: string) => boolean; export declare const newAuthId: (prefix: string) => string; /** Build the `otpauth://totp/...` URL the user's authenticator app * scans / imports. Both label + issuer are URL-encoded. */ export declare const otpauthUrl: (params: { readonly issuer: string; readonly accountName: string; readonly secret: string; }) => string; declare interface ParsedHash { readonly N: number; readonly r: number; readonly p: number; readonly keyLen: number; } /** Parse the scrypt parameters out of a stored hash. Returns null when the * string isn't a well-formed `scrypt$N$r$p$salt$derived`, or when its * parameters exceed the derivation ceiling. */ export declare const parseScryptParams: (stored: string) => ParsedHash | null; declare interface PasskeyAssertOptionsInput { /** Optional — for a usernameless flow leave it undefined. When present * we scope `allowCredentials` to that user's registered passkeys. */ readonly userId?: string; } declare interface PasskeyAssertVerifyInput { readonly credentialId: string; readonly clientDataJSON: string; readonly authenticatorData: string; readonly signature: string; /** Echo back the userId used at options time, when the flow knew it. */ readonly userId?: string; } export declare interface PasskeyConfig { /** Relying-party id — the registrable domain (e.g. `example.com`). */ readonly rpId: string; /** Human-readable RP name shown in the OS prompt. */ readonly rpName: string; /** The exact origin ceremonies must run on (e.g. `https://app.example.com`). */ readonly origin: string; } /** A registered WebAuthn credential. */ export declare interface PasskeyRecord { readonly id: string; readonly credentialId: string; readonly userId: string; readonly publicKey: string; readonly counter: number; readonly transports?: string | null; readonly createdAt: Date; readonly lastUsedAt: Date | null; } declare interface PasskeyRegisterOptionsInput { readonly userId: string; readonly userName: string; } declare interface PasskeyRegisterVerifyInput { readonly userId: string; readonly credentialId: string; readonly clientDataJSON: string; readonly authenticatorData: string; readonly transports?: string; } /** Passkeys (WebAuthn). */ export declare interface PasskeyStore { readonly listPasskeys: (userId: string) => Effect.Effect>; readonly findPasskey: (credentialId: string) => Effect.Effect; readonly insertPasskey: (passkey: Omit) => Effect.Effect; /** Atomically bump the stored signature counter, guarding against a * cloned authenticator (WebAuthn §6.1.1): the update only applies when * `newCounter` strictly exceeds the stored counter. Returns whether a * row was advanced. `false` with an unchanged stored counter is the * clone/replay signal the assertion handler rejects on — the check and * the write are ONE atomic statement at the store, so two replicas * racing the same counter can't both succeed. */ readonly advancePasskeyCounter: (credentialId: string, newCounter: number) => Effect.Effect; } /** Default password-reset lifetime — a bit longer. */ export declare const PASSWORD_RESET_TTL_S: number; export declare class PasswordEmptyError extends PasswordEmptyError_base<{ readonly message: string; }> { } declare const PasswordEmptyError_base: new = {}>(args: VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => YieldableError & { readonly _tag: "PasswordEmptyError"; } & Readonly; export declare class PasswordHashError extends PasswordHashError_base<{ readonly message: string; readonly cause: unknown; }> { } declare const PasswordHashError_base: new = {}>(args: VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => YieldableError & { readonly _tag: "PasswordHashError"; } & Readonly; declare interface PasswordResetConfirmInput { readonly token: string; readonly newPassword: string; } declare interface PasswordResetRequestInput { readonly email: string; readonly callbackPath?: string; } /** * Build a UserStore that reads + writes the auth tables via the provided * `SqlClient`. The caller owns the client's lifecycle. `options.identity` * points the identity role at an existing table (see `IdentityTableMapping`). * * Find* flatten database-error channels to `never` (we treat connection * issues as "not found" — surfacing lets a brute-forcer tell flaky DB * from missing user). `insert` surfaces `UserAlreadyExistsError`. */ export declare const postgresUserStore: (sql: SqlClient, options?: PostgresUserStoreOptions) => UserStore; export declare interface PostgresUserStoreOptions { /** Map the identity role onto an existing table. Every other role stays on * the plugin's own tables. */ readonly identity?: IdentityTableMapping; } /** * Read + verify the session cookie from a Cookie header string. * Returns the decoded IDENTITY (a Subject with no `scopes` — authority is * resolved per request, never carried in the cookie) or null when the cookie * is missing, malformed, tampered, expired, or minted under a superseded * payload version. Never throws — callers branch on the boolean. * * Verification is keyed: the given secret is widened via * `sessionSecretsOf`, so a cookie signed with the previous rotation * key keeps verifying while `VOLTRO_SESSION_SECRET_PREVIOUS` is set. */ export declare const readSession: (cookieHeader: string | undefined, secret: SessionSecretInput) => SubjectIdentity | null; /** * Keyed read: the full `VerifyResult` — subject + the `kid` that * verified + the sliding-window `renew` flag + `exp`/`iat`. The auth * routes plugin re-issues the cookie when `renew` is set or when the * value verified under the previous key. */ export declare const readSessionKeyed: (cookieHeader: string | undefined, secret: SessionSecretInput, options?: VerifyOptions) => VerifyResult | null; /** * Injected rebinder — the app passes `bindConnectionCredential` from * `@voltro/runtime` so the plugin stays runtime-agnostic. * * It takes the CREDENTIAL this handler just minted, not the Subject it built. * The Subject form was a fast path around the entire auth chain: the middleware * returned the stored value directly, so the connection never re-ran the * session-revocation check, `resolveScopes` or the scope cache again. Handing * the cookie over instead means the connection presents the same credential a * reconnecting browser would, and is judged the same way. */ export declare type RebindConnection = (clientId: number, credential: ConnectionCredential) => void; /** A hashed, single-use MFA recovery (backup) code. Minted alongside * enrolment; accepted at sign-in as an alternative to a TOTP code when * the authenticator is lost. Stored hashed (SHA-256), like tokens. */ export declare interface RecoveryCodeRecord { readonly id: string; readonly userId: string; readonly codeHash: string; readonly consumedAt: Date | null; readonly createdAt: Date; } export declare type RegistrationOutcome = { readonly ok: true; readonly result: RegistrationResult; } | { readonly ok: false; readonly error: WebAuthnError; }; export declare interface RegistrationResult { /** base64url credential id to store. */ readonly credentialId: string; /** base64url COSE public key to store. */ readonly publicKey: string; /** Initial signature counter. */ readonly counter: number; } export declare interface RegistrationVerifyInput { /** base64url clientDataJSON from `navigator.credentials.create`. */ readonly clientDataJSON: string; /** base64url authenticatorData (or attestationObject's authData — see * the client helper, which forwards the raw authenticatorData). */ readonly authenticatorData: string; /** The challenge the server issued for this ceremony (base64url). */ readonly expectedChallenge: string; /** The exact origin the ceremony must have run on (e.g. * `https://app.example.com`). */ readonly expectedOrigin: string; /** The relying-party id (registrable domain, e.g. `example.com`). */ readonly rpId: string; } export declare const resolveSessionSecret: () => string; export declare const resolveSessionSecrets: () => SessionSecrets; declare interface RevokeOtherSessionsInput { readonly userId: string; /** The session to KEEP — typically the caller's current session id. */ readonly keepSessionId: string; } declare interface RevokeSessionInput { readonly userId: string; readonly sessionId: string; } /** * Run the guards in order against `user`, short-circuiting on the FIRST * rejection. With no guards (or all allowing) it resolves `{ ok: true }`. * The login handlers call this once, just before issuing a session. */ export declare const runSubjectGuards: (guards: ReadonlyArray, user: UserRecord) => Effect.Effect; export declare type SendEmail = (input: SendEmailInput) => Promise; /** The email hook the plugin calls for magic-link + password-reset. Inject * it on the config. The documented default wiring forwards to * `@voltro/plugin-mail`'s `MailService.send` (see `mailSender`). */ export declare interface SendEmailInput { readonly to: string; readonly subject: string; readonly html: string; readonly text: string; /** Discriminates the flow so a custom sender can branch. */ readonly kind: 'magic-link' | 'password-reset' | 'email-verify' | 'invitation'; /** The action URL embedded in the email (also present inside `html`). */ readonly actionUrl: string; } /** * Mint + send a verification link for `user`. Shared by the request handler * and by sign-up, so the cooldown and the URL shape have ONE implementation. * * Returns whether an email was actually dispatched — `false` when the cooldown * suppressed it or no transport is wired. The caller must NOT surface that * distinction to an unauthenticated client (see `handleEmailVerifyRequest`). */ export declare const sendEmailVerification: (user: UserRecord, store: TokenStore, deps: EmailVerificationDeps) => Effect.Effect; export declare const SESSION_COOKIE_NAME: "voltro:session"; /** Read the server-side session id carried on a Subject (stamped by the * sign-in/sign-up/magic-link handlers as `metadata.sessionId`). Returns * null for non-user subjects and for cookies minted without a session * row (e.g. a hand-rolled `issueSession` call) — those can't be checked * against the sessions table. */ export declare const sessionIdOfSubject: (subject: Subject) => string | null; /** A server-side session row — enumerated so apps can list active * devices + revoke them. Written on sign-in; deleted on revoke. */ export declare interface SessionRecord { readonly id: string; readonly userId: string; readonly tenantId: string; readonly expiresAt: Date; readonly ipAddress?: string | null; readonly userAgent?: string | null; readonly createdAt: Date; readonly lastSeenAt: Date; } export declare interface SessionRevocationChecker { /** Is this session still live? `false` means the row was revoked (or * is past its `expiresAt`) — the caller rejects the cookie. Verdicts * are cached for `ttlMs`. */ readonly isLive: (sessionId: string) => Effect.Effect; /** Drop the cached verdict for one session — called inline by * sign-out / revoke handlers so the kill is immediate on THIS * process (other replicas converge within `ttlMs`). */ readonly invalidate: (sessionId: string) => void; /** The effective cache window in milliseconds. */ readonly ttlMs: number; } export declare interface SessionRevocationOptions { /** Cache window in milliseconds. A revocation performed elsewhere * takes effect on this process within this window. Default 30s. * `0` disables caching (every verify hits the store). */ readonly ttlMs?: number; /** Upper bound on cached session ids. When exceeded, expired entries * are swept; if still over, the oldest entries are dropped. Default * 10 000. */ readonly maxEntries?: number; /** Clock override (epoch milliseconds). Defaults to `Date.now`. A * testing seam — lets suites cross the cache window without * wall-clock sleeps. */ readonly now?: () => number; } /** Every session helper takes either a bare secret string (single-key) * or the keyed `{ current, previous? }` rotation set. */ export declare type SessionSecretInput = string | SessionSecrets; export { SessionSecrets } /** * Normalise a secret input into the keyed set VERIFICATION runs against. * * - A `SessionSecrets` set passes through unchanged. * - A bare string becomes `current` and is widened with the env-driven * `previous` key (`VOLTRO_SESSION_SECRET_PREVIOUS` + * `VOLTRO_SESSION_KID_PREVIOUS`) so env-var rotation works without * the app switching its config to the keyed shape. When the string * IS the env secret (`VOLTRO_SESSION_SECRET`), it inherits the env * `kid`; otherwise the default kid applies. The comparison is * constant-time — secrets never go through `===`. */ export declare const sessionSecretsOf: (secret: SessionSecretInput) => SessionSecrets; /** Server-side sessions — enumeration + revocation. */ export declare interface SessionStore { /** Active sessions for a user (most recent first). */ readonly listSessions: (userId: string) => Effect.Effect>; /** Record a session on sign-in. */ readonly insertSession: (session: Omit) => Effect.Effect; /** Look up one session row by id. The request-time revocation check * (`makeSessionRevocationChecker`) calls this — a missing row means * the session was revoked. */ readonly findSession: (sessionId: string) => Effect.Effect; /** Slide a session row forward: bump `expiresAt` + `lastSeenAt`. Called * when the sliding-window renewal re-issues the cookie, so the row * outlives the renewed cookie and the revocation check keeps passing. */ readonly touchSession: (sessionId: string, expiresAt: Date) => Effect.Effect; /** Revoke a single session — only if it belongs to `userId` (so a * caller can't revoke another user's session by id-guessing). Returns * whether a row was removed. */ readonly revokeSession: (userId: string, sessionId: string) => Effect.Effect; /** Revoke every session for the user EXCEPT `keepSessionId` — the * "sign out other devices" action. Returns the count removed. */ readonly revokeAllOtherSessions: (userId: string, keepSessionId: string) => Effect.Effect; } export declare interface SignInInput { readonly email: string; readonly password: string; readonly redirectAfter?: boolean; /** Recorded on the session row for the device list. */ readonly ipAddress?: string; readonly userAgent?: string; } export declare interface SignUpInput { readonly email: string; readonly password: string; readonly tenantId?: string; readonly redirectAfter?: boolean; } /** * Convert a UserRecord into the protocol Subject the framework * carries around per-request. Tenant id flows through; user type * is locked to 'user'. * * The user's tenant memberships, when supplied, are carried in * `metadata.memberships` (the protocol Subject's metadata slot — the * framework never reads it, but app code + the switch-tenant menu do). * Pass an explicit `tenantId` to make the ACTIVE tenant differ from the * user's home tenant (post switch-tenant rebind). * * `metadata` carries anything else the app needs on the Subject — most * often a provider credential captured at login and read back by a * plugin's `credentialsResolver` (`subject.metadata.jiraToken` for * `@voltro/plugin-atlassian`, say). Without it an app that needs such a * credential could not adopt this helper at all: building the Subject by * hand was the only way to keep the value. * * **Precedence, when a `memberships` key appears in BOTH:** the dedicated * `memberships` option wins. It is the specific, typed input and it is * projected to the `{ tenantId, role }` shape `subjectMemberships` reads, * so letting a free-form bag silently shadow it would break the * switch-tenant menu in a way nothing type-checks. A `memberships` key * inside `metadata` is passed through UNCHANGED when the option is absent * — no reshaping, no validation. * * Note this only stamps the metadata slot at CONSTRUCTION. The sign-in / * sign-up / magic-link / passkey handlers later merge `sessionId` (and * the password strategy merges `provider`) onto whatever is here, so keys * set through this option survive those paths — unless you name a key * `sessionId` or `provider`, which those merges overwrite by design. */ export declare const subjectFromUser: (user: UserRecord, options?: { readonly tenantId?: string; readonly memberships?: ReadonlyArray; readonly metadata?: Record; }) => Subject; /** * A post-authentication subject guard: given the authenticated user, * decide whether the login may proceed. Runs after the credential check, * before the session is issued. Effect-native so a guard can do IO (e.g. * read a fresh flag) without a Promise bridge. */ export declare type SubjectGuard = (user: UserRecord) => Effect.Effect; /** A guard's decision. Allow, or reject with a stable machine `code` * (surfaced as the 403 body's `error`) plus a human `message`. */ export declare type SubjectGuardVerdict = { readonly ok: true; } | { readonly ok: false; readonly code: string; readonly message: string; }; /** Read the memberships carried on a Subject (set by `subjectFromUser`). * Returns [] when none are present. */ export declare const subjectMemberships: (subject: Subject) => ReadonlyArray<{ tenantId: string; role: string; }>; export declare interface SwitchTenantInput { readonly userId: string; /** The connection (clientId) whose subject to rebind, when invoked over * the live WS path. Absent on the pure HTTP path (cookie re-issue only). */ readonly clientId?: number; readonly targetTenantId: string; /** The caller's current server-side session id — carried onto the * re-issued cookie so the request-time revocation check keeps * covering the session after a tenant switch. */ readonly sessionId?: string; /** * The metadata on the caller's CURRENT subject, carried onto the re-issued * one. Pass `subject.metadata` — the route does. * * Load-bearing, not a convenience: a switch rebuilds the Subject from the * user record, so anything an app put in the metadata slot is otherwise * dropped the first time someone changes tenant. For an app that carries a * provider credential there (an Atlassian PAT read back by * `credentialsResolver`, say) that means the integration silently dies on * switch — the user stays authenticated, and every call to the provider * starts failing. `memberships` and `tenantId` are deliberately NOT carried: * they are re-derived for the target tenant and stale copies would be wrong. */ readonly metadata?: Record; } export declare type TokenPurpose = 'magic-link' | 'password-reset' | 'mfa-pending' | 'email-verify'; /** A single-use, hashed, expiring token (magic-link / password-reset). */ export declare interface TokenRecord { readonly id: string; readonly tokenHash: string; readonly userId: string; readonly purpose: TokenPurpose; readonly expiresAt: Date; readonly consumedAt: Date | null; readonly createdAt: Date; } /** Single-use tokens: magic link, password reset, MFA pending, email verification. */ export declare interface TokenStore { /** Persist a single-use token (already hashed). */ readonly insertToken: (token: Omit) => Effect.Effect; /** Atomically redeem a token by its hash + purpose: returns the row * only when it exists, matches the purpose, is unexpired, and was not * already consumed — and marks it consumed in the same step. Returns * null otherwise (single-use guard). */ readonly consumeToken: (tokenHash: string, purpose: TokenPurpose) => Effect.Effect; /** * The most recently CREATED token of this purpose for the user, consumed or * not — or null when there is none. * * Exists for the resend cooldown, which is the only bound on an endpoint * that sends mail to an address supplied by an unauthenticated caller. Note * it deliberately does NOT filter on `consumedAt`: the cost being rate- * limited is the EMAIL, and a token that was minted and then redeemed cost * exactly as much to send as one still pending. */ readonly latestToken: (userId: string, purpose: TokenPurpose) => Effect.Effect; } export declare class TotpVerifyError extends TotpVerifyError_base<{ readonly reason: 'invalidCode' | 'malformedSecret' | 'replay'; }> { } declare const TotpVerifyError_base: new = {}>(args: VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => YieldableError & { readonly _tag: "TotpVerifyError"; } & Readonly; export declare class UserAlreadyExistsError extends UserAlreadyExistsError_base<{ readonly email: string; }> { } declare const UserAlreadyExistsError_base: new = {}>(args: VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => YieldableError & { readonly _tag: "UserAlreadyExistsError"; } & Readonly; /** * Is this user's address proven, under `config`? * * `exemptAccountsCreatedBefore` is applied HERE rather than at the guard, so * the exemption and the session mark cannot disagree — an exempt user must * read as verified to the client too, or the app shows a "confirm your email" * banner to someone the login already excused. */ export declare const userEmailVerified: (user: UserRecord, config: EmailVerificationConfig | undefined) => boolean; export declare class UserNotFoundError extends UserNotFoundError_base<{ readonly userId: string; }> { } declare const UserNotFoundError_base: new = {}>(args: VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => YieldableError & { readonly _tag: "UserNotFoundError"; } & Readonly; export declare interface UserRecord { readonly id: string; readonly email: string; /** * The stored scrypt hash — OPTIONAL, because not every identity has a * password. An SSO-only, magic-link-only, passkey-only, or provider-PAT * app has no hash to supply, and requiring one forced it to invent a fake * value (or to hand-build the `Subject` and lose `subjectFromUser` * altogether). `null` and `undefined` both mean "this account has no * password"; a store may use whichever its backing column produces. * * SECURITY: absent is NOT "any password matches". `handleSignIn` treats a * missing hash exactly as it treats a missing user — it still burns a * decoy scrypt and returns the same 401 — so a password-less account can * never be signed into with the password strategy, and the response does * not reveal that the account lacks a password. `updatePassword` still * assigns one, so a password-reset / set-password flow promotes a * password-less user to a password user normally. */ readonly passwordHash?: string | null; readonly tenantId: string; readonly createdAt: Date; /** Base32-encoded TOTP secret. `null` = MFA not enrolled. Set by * `setMfaSecret()` from the enrolment handler. Stored as plaintext. */ readonly mfaSecret?: string | null; /** Timestamp the user completed TOTP enrolment (first successful * verify). Distinct from the secret being set — a half-completed * enrolment leaves `mfaSecret` populated but this null. */ readonly mfaEnrolledAt?: Date | null; /** * When this address was PROVEN to reach its owner — by redeeming a * verification link, a magic link, or a password-reset link. `null` / * absent = unproven. * * A store whose backing table predates the `emailVerifiedAt` column simply * leaves it undefined, which reads as unverified — and that is why * `emailVerification.policy` defaults to `'off'`: under any stricter * default, "the column is not there yet" and "this user never confirmed" * would both refuse the login. */ readonly emailVerifiedAt?: Date | null; /** Optional lifecycle flag read by post-authentication subject guards. * Set (a `Date`) means the account is DEACTIVATED — `@voltro/plugin- * deactivation`'s `deactivationGuard()` vetoes login when it's non-null. * `null` / absent means active. A store populates it from the app's * `deactivatedAt` column (added by the `deactivation()` mixin) when that * column exists; stores without the column simply leave it undefined and * the guard treats the account as active. */ readonly deactivatedAt?: Date | null; } /** Every role at once — what the shipped stores return and what `authRoutesPlugin` takes. */ export declare interface UserStore extends IdentityStore, MfaStore, MembershipStore, SessionStore, TokenStore, InvitationStore, ImpersonationStore, PasskeyStore, LockoutStore { } /** Verify a passkey assertion (sign-in). */ export declare const verifyAssertion: (input: AssertionVerifyInput) => AssertionOutcome; /** * Verify a state-changing request's CSRF protection. Both the header * value and the cookie value must (a) be the SAME token and (b) carry a * valid HMAC. Returns true only when both hold. */ export declare const verifyCsrf: (headerToken: string | undefined, cookieToken: string | undefined, secret: string) => boolean; /** * Verify a plaintext password against a stored hash. * * Uses `timingSafeEqual` for the comparison so attackers can't * fingerprint correct prefixes via response-time analysis. * * Returns `Effect` — parse errors or scrypt errors * collapse to `false` because surfacing them lets attackers * fingerprint malformed-vs-mismatched, which leaks structural info. */ export declare const verifyPassword: (plaintext: string, stored: string) => Effect.Effect; /** * Verify a password and, when it matches an under-cost hash, return a * freshly-minted replacement. The caller wires `rehash` into * `UserStore.updatePassword(userId, rehash)`. Never throws — parse / * scrypt failures collapse to `{ valid: false }`. */ export declare const verifyPasswordWithRehash: (plaintext: string, stored: string) => Effect.Effect; /** Verify a passkey registration. Attestation is intentionally NOT * checked (90% path); everything binding the credential to this * origin/rpId IS. */ export declare const verifyRegistration: (input: RegistrationVerifyInput) => RegistrationOutcome; export { VerifyResult } /** Verify a TOTP code against the user's secret. Returns an Effect * that fails with `TotpVerifyError` on mismatch / malformed secret. * Accepts ±`TOTP_SKEW` steps (default ±1 → ±30s window) to tolerate * clock drift. */ export declare const verifyTotpCode: (secretBase32: string, code: string, nowMs?: number) => Effect.Effect; export declare interface VerifyWithRehashResult { /** Whether the password matched the stored hash. */ readonly valid: boolean; /** A freshly-minted hash under the current cost, present ONLY when the * password was valid AND the stored hash was below current cost. The * caller persists it via `UserStore.updatePassword`. */ readonly rehash?: string; } export declare const voltroPasswordStrategy: (options?: VoltroPasswordStrategyOptions) => AuthStrategy; export declare interface VoltroPasswordStrategyOptions { /** Secret(s) used to verify the cookie: a bare string OR a keyed * `SessionSecrets` set. Defaults to `resolveSessionSecret()` which * reads `VOLTRO_SESSION_SECRET` env with a dev fallback — same * resolver used by the mint side, so there's no drift between * issuing and verifying. A bare string is widened with the * `VOLTRO_SESSION_SECRET_PREVIOUS` env var (`sessionSecretsOf`), * so env-var rotation applies either way. */ readonly secret?: SessionSecretInput; /** Cookie name. Defaults to `voltro:session`. Override when running * the strategy alongside another tenant on the same domain. */ readonly cookieName?: string; /** The user store whose `sessions` rows back the request-time * revocation check. When absent, verification stays purely * stateless — a revoked session's cookie keeps working until it * expires. */ readonly store?: UserStore; /** Tuning for the revocation check: options for the built-in TTL * cache (default window 30s), or a pre-built checker — pass the * auth plugin's own checker to share one cache between the HTTP * routes and the rpc auth chain. Ignored without a `store` * (unless a checker is passed directly). */ readonly revocation?: SessionRevocationOptions | SessionRevocationChecker; } 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'; export { }