import { createHash, randomBytes } from 'node:crypto'; import type { Account } from '@wellformed/shared'; import { toAccount } from './accounts.js'; import type { ControlDb } from './db.js'; export const SESSION_COOKIE = 'wellformed_session'; /** * Sessions are stored hashed, so a leaked control database cannot be replayed. * * One session spans every workspace the account belongs to. Which workspace a * request touches comes from its URL, and is authorised by a membership lookup * on each request — never by anything carried in the cookie. */ function hashToken(token: string): string { return createHash('sha256').update(token).digest('hex'); } export interface IssuedSession { token: string; expiresAt: Date; } export function createSession( control: ControlDb, accountId: string, ttlMs: number, userAgent?: string, ): IssuedSession { const token = randomBytes(32).toString('base64url'); const expiresAt = new Date(Date.now() + ttlMs); control.sqlite .prepare('INSERT INTO session (token_hash, account_id, expires_at, user_agent) VALUES (?, ?, ?, ?)') .run(hashToken(token), accountId, expiresAt.toISOString(), userAgent?.slice(0, 300) ?? null); return { token, expiresAt }; } export function resolveSession(control: ControlDb, token: string | undefined): Account | null { if (!token) return null; const row = control.sqlite .prepare< [string], { id: string; email: string; name: string; disabled: number; created_at: string; last_seen_at: string | null; verified_at: string | null; expires_at: string; } >( `SELECT a.id, a.email, a.name, a.disabled, a.created_at, a.last_seen_at, a.verified_at, s.expires_at FROM session s JOIN account a ON a.id = s.account_id WHERE s.token_hash = ?`, ) .get(hashToken(token)); if (!row) return null; if (row.disabled === 1) return null; if (new Date(row.expires_at).getTime() <= Date.now()) { destroySession(control, token); return null; } return toAccount(row); } export function destroySession(control: ControlDb, token: string): void { control.sqlite.prepare('DELETE FROM session WHERE token_hash = ?').run(hashToken(token)); } export function destroyAllSessions(control: ControlDb, accountId: string): void { control.sqlite.prepare('DELETE FROM session WHERE account_id = ?').run(accountId); } export function pruneExpiredSessions(control: ControlDb): number { const info = control.sqlite .prepare(`DELETE FROM session WHERE expires_at <= datetime('now')`) .run(); return info.changes; }