import { AuthStrategy } from '@voltro/protocol'; import { Effect } from 'effect'; import { SessionSecrets } from '@voltro/protocol/session'; import { VoidIfEmpty } from 'effect/Types'; import { YieldableError } from 'effect/Cause'; /** 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; } /** 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; } /** 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; } /** 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; } /** 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; } /** 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 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; } 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. */ declare type SessionSecretInput = string | SessionSecrets; /** 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; } 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 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 { }