import type { Kysely } from 'kysely'; import type { Database, User } from '../db/schema.js'; /** * Single-use tokens for setting a password. * * An admin does **not** choose someone else's password. They generate a link and hand it over, * and the person sets their own — so no administrator ever knows a colleague's password, and no * temporary password is stored anywhere in plaintext or sent through a channel nobody controls. * * It is also the shape self-service reset already needs: "forgot password" is this exact flow with * a different way of delivering the link and no `created_by`. That is why the table has that * column now rather than later — adding email means adding a sender, not reshaping a table or * re-testing the token semantics. * * The token is hashed at rest with the same helper sessions use. The raw value exists only in the * link, so a database dump is not a set of live password resets. */ /** How long a generated link stays usable. */ export declare const RESET_TOKEN_TTL_MS: number; export declare class PasswordResetError extends Error { readonly code: 'invalid' | 'weak_password'; name: string; constructor(message: string, code: 'invalid' | 'weak_password'); } /** * The minimum a password has to clear. * * Length only, and deliberately so. Composition rules — a digit, a symbol, mixed case — push people * towards `Password1!` and are worse than useless; length is the property that actually costs an * attacker something. NIST dropped composition requirements for the same reason. */ export declare const MIN_PASSWORD_LENGTH = 12; export declare function assertUsablePassword(password: string): void; export interface GeneratedResetToken { /** The raw token. Exists only here and in the link — never stored, never logged. */ token: string; expiresAt: Date; } export declare function createPasswordResetToken(db: Kysely, userId: string, options?: { createdBy?: string | null; }): Promise; /** The user a token is for, if it is valid. Does not consume it. */ export declare function resolvePasswordResetToken(db: Kysely, token: string): Promise; /** * Set a password using a token, and burn the token. * * Every session for the user is dropped as well. Someone setting a password because they lost * control of the account gains nothing if the session that took it stays live — and that is the * likeliest reason for a reset to be happening at all. */ export declare function consumePasswordResetToken(db: Kysely, token: string, password: string): Promise; /** * Mint a reset token for whoever holds this address, if anyone does. * * Returns `undefined` for an unknown or deactivated account, and the **caller must respond * identically either way**. That is the whole discipline of a forgot-password form: a page that * says "no account with that address" is a free membership check, and on a CMS the membership list * is the list of people worth phishing. It is returned rather than hidden so the caller can still * choose to send mail only when there is somewhere to send it. * * `created_by` is null, which is the difference from an admin-generated link and the reason the * column was made nullable before there was anything to put in it: nobody authorised this, someone * merely asked. */ export declare function requestPasswordReset(db: Kysely, email: string): Promise<{ user: User; token: string; expiresAt: Date; } | undefined>; /** * Drop tokens that have expired or been used. Safe to call on a schedule. * * **Two statements rather than one `or`, because an `or` here is a table scan.** Both columns are * indexed by `0020_perf_indexes`, and measured with `explain query plan` against 20,000 rows the * combined form still plans as `SCAN password_reset_tokens` while each half on its own is a * covering-index seek — SQLite's OR-to-union optimisation does not fire for this delete. The sweep * runs every five minutes forever, so the difference is the whole table walked 288 times a day * against two seeks that usually match nothing. * * Splitting cannot double-count: a token that is both expired and used is deleted by the first * statement and is no longer there for the second, so the sum is the number of rows removed. */ export declare function purgeStaleResetTokens(db: Kysely): Promise;