import { createHash, randomBytes } from 'node:crypto'; import type { PendingInvite, Role } from '@wellformed/shared'; import { newId } from '../engine/ids.js'; import { normaliseEmail } from './accounts.js'; import type { ControlDb } from './db.js'; /** * Invites. * * The link is the credential. Wellformed sends no email — an admin creates an * invite and passes the URL along themselves — so whoever holds the token can * accept it. Three things keep that honest: the token is 32 random bytes and * stored only as a hash, it expires, and it can be accepted exactly once. * * The invite also names an email, and accepting is only possible as that email: * either the signed-in account already has it, or accepting creates an account * with it. That stops a forwarded link from quietly attaching a stranger's * existing account to the workspace. */ export const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000; function hashToken(token: string): string { return createHash('sha256').update(token).digest('hex'); } export interface InviteRecord { id: string; workspaceId: string; email: string; role: Role; invitedBy: string | null; createdAt: string; expiresAt: string; acceptedAt: string | null; } interface InviteRow { id: string; workspace_id: string; email: string; role: string; invited_by: string | null; created_at: string; expires_at: string; accepted_at: string | null; } function toRecord(row: InviteRow): InviteRecord { return { id: row.id, workspaceId: row.workspace_id, email: row.email, role: row.role as Role, invitedBy: row.invited_by, createdAt: row.created_at, expiresAt: row.expires_at, acceptedAt: row.accepted_at, }; } const COLUMNS = 'id, workspace_id, email, role, invited_by, created_at, expires_at, accepted_at'; export interface IssuedInvite { record: InviteRecord; /** Shown once, at creation. Never recoverable afterwards. */ token: string; } export function createInvite( control: ControlDb, input: { workspaceId: string; email: string; role: Role; invitedBy: string | null }, ): IssuedInvite { const token = randomBytes(32).toString('base64url'); const id = newId('inv'); const email = normaliseEmail(input.email); const expiresAt = new Date(Date.now() + INVITE_TTL_MS).toISOString(); // One live invite per address per workspace: re-inviting replaces the old // link rather than leaving two working tokens behind. control.sqlite .prepare('DELETE FROM invite WHERE workspace_id = ? AND email = ? AND accepted_at IS NULL') .run(input.workspaceId, email); control.sqlite .prepare( `INSERT INTO invite (id, token_hash, workspace_id, email, role, invited_by, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, ) .run(id, hashToken(token), input.workspaceId, email, input.role, input.invitedBy, expiresAt); return { record: getInviteById(control, id)!, token }; } export function getInviteById(control: ControlDb, id: string): InviteRecord | null { const row = control.sqlite .prepare<[string], InviteRow>(`SELECT ${COLUMNS} FROM invite WHERE id = ?`) .get(id); return row ? toRecord(row) : null; } /** Look an invite up by its token. Returns null for unknown, spent or expired tokens alike. */ export function getLiveInviteByToken(control: ControlDb, token: string): InviteRecord | null { const row = control.sqlite .prepare<[string], InviteRow>(`SELECT ${COLUMNS} FROM invite WHERE token_hash = ?`) .get(hashToken(token)); if (!row) return null; const record = toRecord(row); if (record.acceptedAt) return null; if (new Date(record.expiresAt).getTime() <= Date.now()) return null; return record; } /** * Mark an invite spent. * * The `accepted_at IS NULL` guard makes this the atomic claim: two requests * racing the same link produce exactly one winner, and `changes` says which. */ export function claimInvite(control: ControlDb, id: string, accountId: string): boolean { const info = control.sqlite .prepare( `UPDATE invite SET accepted_at = datetime('now'), accepted_by = ? WHERE id = ? AND accepted_at IS NULL`, ) .run(accountId, id); return info.changes === 1; } export function revokeInvite(control: ControlDb, id: string): void { control.sqlite.prepare('DELETE FROM invite WHERE id = ? AND accepted_at IS NULL').run(id); } export function listPendingInvites(control: ControlDb, workspaceId: string): PendingInvite[] { return control.sqlite .prepare<[string], InviteRow & { inviter: string | null }>( `SELECT i.id, i.workspace_id, i.email, i.role, i.invited_by, i.created_at, i.expires_at, i.accepted_at, a.name AS inviter FROM invite i LEFT JOIN account a ON a.id = i.invited_by WHERE i.workspace_id = ? AND i.accepted_at IS NULL AND i.expires_at > datetime('now') ORDER BY i.created_at DESC`, ) .all(workspaceId) .map((row) => ({ id: row.id, email: row.email, role: row.role as Role, invitedBy: row.inviter, createdAt: row.created_at, expiresAt: row.expires_at, })); } /** Housekeeping: spent and expired invites are not worth keeping forever. */ export function pruneStaleInvites(control: ControlDb): number { const info = control.sqlite .prepare( `DELETE FROM invite WHERE expires_at <= datetime('now', '-30 days') OR accepted_at <= datetime('now', '-30 days')`, ) .run(); return info.changes; }