import { AuthStrategy } from '@voltro/protocol'; import { ConnectionCredential } from '@voltro/protocol'; import { Effect } from 'effect'; import { SessionSecrets } from '@voltro/protocol/session'; import { Subject } from '@voltro/protocol'; import { VoidIfEmpty } from 'effect/Types'; import { VoltroPlugin } from '@voltro/protocol'; import { YieldableError } from 'effect/Cause'; 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; } /** * 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 `:`. */ 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; } 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; } /** What an account with an unproven address may do. See the header. */ declare type EmailVerificationPolicy = 'off' | 'soft' | 'strict'; /** * 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; 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; } /** 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. */ 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; } 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. */ declare type ImpersonationAuditKind = 'started' | 'stopped' | 'refused'; 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; } 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; } /** One impersonation grant — "actor is acting as target, on this session, * until this instant". See `impersonationGrantsTable`. */ 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; } /** Impersonation grants. */ 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; } /** * 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. */ 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; } 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. */ 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; } /** * 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. */ 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; } /** 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. */ declare interface MembershipRecord { readonly userId: string; readonly tenantId: string; readonly role: string; readonly joinedAt: Date; } /** Memberships + switch-tenant. */ 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; } /** 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; } /** TOTP enrolment + recovery codes. */ 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; } 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. */ 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; } /** Passkeys (WebAuthn). */ 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; } /** * 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. */ declare type RebindConnection = (clientId: number, credential: ConnectionCredential) => void; 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`). */ 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; } /** A server-side session row — enumerated so apps can list active * devices + revoke them. Written on sign-in; deleted on revoke. */ 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; } 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; } /** Server-side sessions — enumeration + revocation. */ 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; } /** * 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. */ 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`. */ declare type SubjectGuardVerdict = { readonly ok: true; } | { readonly ok: false; readonly code: string; readonly message: string; }; declare type TokenPurpose = 'magic-link' | 'password-reset' | 'mfa-pending' | 'email-verify'; /** A single-use, hashed, expiring token (magic-link / password-reset). */ 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. */ 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; } 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; 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; 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. */ declare interface UserStore extends IdentityStore, MfaStore, MembershipStore, SessionStore, TokenStore, InvitationStore, ImpersonationStore, PasskeyStore, LockoutStore { } export { }