/** * The audit trail: every attempt to change something, as one row — who, * what (a resource and a verb from the contract), on which row, when, and * whether they were let. Refusals are rows too: a stranger knocking on a * door that stays shut is exactly what an owner wants to read later. * * Recording never fails a request. A row that cannot be written becomes a * line in the log, with everything the row would have said, and the * request goes on; the log is where the forge keeps a run's output. * * Reads are not recorded (a page view is not an event), and neither is a * request refused for coming too fast: a flood must not become the history, * or push the real history out. */ import type { Database } from "bun:sqlite"; import type { Who } from "./who.ts"; export interface AuditRow { readonly id: number; readonly at: string; readonly who_id: string | null; readonly who_name: string | null; readonly role: string; readonly resource: string; readonly verb: string; readonly target: string | null; readonly allowed: 0 | 1; /** `ok`, or the failure: `signin_required`, `forbidden`, `invalid`, `not_found`, `cross_site`. */ readonly outcome: string; } export interface Attempt { readonly who: Who; readonly resource: string; readonly verb: string; readonly target?: string | number | undefined; readonly outcome: string; } /** The newest rows kept; older ones go as new ones come. */ export const KEEP = 50_000; /** Was it let? Only the door says no; a bad body or a missing row got past it. */ export const allowed = (outcome: string): boolean => outcome !== "signin_required" && outcome !== "forbidden" && outcome !== "cross_site"; export function record(db: Database, attempt: Attempt): void { const { who, resource, verb, target, outcome } = attempt; const row = { who_id: who.role === "anon" ? null : who.id, who_name: who.role === "anon" ? null : who.name, role: who.role, resource, verb, target: target === undefined ? null : String(target), allowed: allowed(outcome) ? 1 : 0, outcome, }; try { const { lastInsertRowid } = db .query( "INSERT INTO audit (who_id, who_name, role, resource, verb, target, allowed, outcome) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", ) .run( row.who_id, row.who_name, row.role, row.resource, row.verb, row.target, row.allowed, row.outcome, ); db.query("DELETE FROM audit WHERE id <= ?1").run( Number(lastInsertRowid) - KEEP, ); } catch (error) { console.error( JSON.stringify({ audit: row, at: new Date().toISOString(), unrecorded: String(error), }), ); } }