import { createHash, randomBytes } from 'node:crypto'; import { newId } from '../engine/ids.js'; import type { ControlDb } from './db.js'; /** * One-shot account tokens. * * Same shape as an invite, and for the same reasons: 32 random bytes, stored * only as a SHA-256 hash, spendable exactly once, and expiring on its own. The * difference is what they stand for — an invite grants a role in one workspace, * these two stand in for the *account itself*. * * That is why a reset lives for an hour rather than a week. An invite link that * leaks costs one workspace; a reset link that leaks costs every workspace the * account can open. Verification is harmless by comparison and gets a day. */ export type TokenPurpose = 'reset' | 'verify'; export const TOKEN_TTL_MS: Record = { reset: 60 * 60_000, verify: 24 * 60 * 60_000, }; function hashToken(token: string): string { return createHash('sha256').update(token).digest('hex'); } export interface AccountToken { id: string; accountId: string; purpose: TokenPurpose; createdAt: string; expiresAt: string; usedAt: string | null; } interface TokenRow { id: string; account_id: string; purpose: string; created_at: string; expires_at: string; used_at: string | null; } function toToken(row: TokenRow): AccountToken { return { id: row.id, accountId: row.account_id, purpose: row.purpose as TokenPurpose, createdAt: row.created_at, expiresAt: row.expires_at, usedAt: row.used_at, }; } export interface IssuedToken { record: AccountToken; /** Returned once, at creation. Only its hash is stored. */ token: string; } /** * Issue a token, replacing any live one for the same account and purpose. * * Replacing rather than adding matters: someone who clicks "forgot password" * four times should end up with one working link, not four, and the newest is * the one in the message they are actually looking at. */ export function issueToken( control: ControlDb, accountId: string, purpose: TokenPurpose, ): IssuedToken { const token = randomBytes(32).toString('base64url'); const id = newId('tok'); const expiresAt = new Date(Date.now() + TOKEN_TTL_MS[purpose]).toISOString(); control.sqlite .prepare('DELETE FROM account_token WHERE account_id = ? AND purpose = ? AND used_at IS NULL') .run(accountId, purpose); control.sqlite .prepare( `INSERT INTO account_token (id, token_hash, account_id, purpose, expires_at) VALUES (?, ?, ?, ?, ?)`, ) .run(id, hashToken(token), accountId, purpose, expiresAt); const record = control.sqlite .prepare<[string], TokenRow>( 'SELECT id, account_id, purpose, created_at, expires_at, used_at FROM account_token WHERE id = ?', ) .get(id)!; return { record: toToken(record), token }; } /** Null for unknown, spent, expired and wrong-purpose tokens alike. */ export function getLiveToken( control: ControlDb, token: string, purpose: TokenPurpose, ): AccountToken | null { const row = control.sqlite .prepare<[string], TokenRow>( 'SELECT id, account_id, purpose, created_at, expires_at, used_at FROM account_token WHERE token_hash = ?', ) .get(hashToken(token)); if (!row) return null; const record = toToken(row); if (record.purpose !== purpose) return null; if (record.usedAt) return null; if (new Date(record.expiresAt).getTime() <= Date.now()) return null; return record; } /** * Spend a token. The `used_at IS NULL` guard is the atomic claim, so two * requests racing the same link produce exactly one winner. */ export function claimToken(control: ControlDb, id: string): boolean { const info = control.sqlite .prepare(`UPDATE account_token SET used_at = datetime('now') WHERE id = ? AND used_at IS NULL`) .run(id); return info.changes === 1; } /** Housekeeping, same as invites: spent and expired tokens are not history. */ export function pruneStaleTokens(control: ControlDb): number { const info = control.sqlite .prepare( `DELETE FROM account_token WHERE expires_at <= datetime('now', '-7 days') OR used_at <= datetime('now', '-7 days')`, ) .run(); return info.changes; }