// The audit handler — appends one line per event to an append-only audit log. It is the whole of // the module's code: an audit logger is not a privileged component, only a module that listens to // everything and writes it down (novox/hq ADR 0041). // // **Written once per event.** Delivery is at-least-once, and since novox/hq issue 276 a failed write // is asked for again and an event that keeps failing is replayed from the spool — so the same event can // reach the trail more than once. An append-only file has no ON CONFLICT; its equivalent is the SDK's // other answer ("Taking an event"): skip an event id already written, remembering it only once the line // is on disk. The ids remembered are the most recent ones, seeded from the end of the trail on start, // which covers every redelivery (seconds apart) and a restart between a write and its answer. import { mkdir, open, stat } from "node:fs/promises"; import { dirname } from "node:path"; import type { Event } from "@novox/mesh-sdk/events"; import { keyOf } from "./spool.js"; /** Where the trail is written. A directory the host applies; one file per node. */ export function auditLogPath(env: NodeJS.ProcessEnv = process.env): string { return env.AUDIT_LOG ?? "/var/lib/audit-logger/audit.log"; } /** Where an event that could not be written waits (see spool.ts). Beside the trail unless said. */ export function auditSpoolPath(env: NodeJS.ProcessEnv = process.env): string { return env.AUDIT_SPOOL ?? `${dirname(auditLogPath(env))}/spool`; } /** One event as the trail's line, keeping the metadata an audit needs first. The id is the event's own * x-event-id (ADR 0042) — the handle a reader dedups the at-least-once trail on. */ export function lineOf(event: Event): string { return ( JSON.stringify({ id: event.id, type: event.type, source: event.source, node: event.node, at: event.at, ...(event.causationId ? { causationId: event.causationId } : {}), ...(event.schema ? { schema: event.schema } : {}), body: event.body ?? null, }) + "\n" ); } /** How many recent event ids a trail remembers, and how much of its end it reads to seed them. */ const REMEMBERED = 50_000; const SEED_BYTES = 8 * 1024 * 1024; export class Trail { private readonly seen = new Map(); private constructor(readonly path: string) {} /** Open the trail at `path`, remembering the ids at its end. */ static async open(path: string): Promise { const t = new Trail(path); await t.seed(); return t; } has(event: Event): boolean { return this.seen.has(keyOf(event)); } /** Append the event as one line and sync it, unless this trail already holds it. Throws when the * line could not be written — the handler's cue to ask for the event again. */ async record(event: Event): Promise { const key = keyOf(event); if (this.seen.has(key)) return; await mkdir(dirname(this.path), { recursive: true }).catch(() => {}); const fh = await open(this.path, "a", 0o600); try { await fh.appendFile(lineOf(event)); await fh.datasync(); } finally { await fh.close(); } this.remember(key); } private remember(key: string): void { this.seen.set(key, true); if (this.seen.size > REMEMBERED) { const first = this.seen.keys().next().value; if (first !== undefined) this.seen.delete(first); } } private async seed(): Promise { let size: number; try { size = (await stat(this.path)).size; } catch { return; // no trail yet } const from = Math.max(0, size - SEED_BYTES); const fh = await open(this.path, "r"); let text: string; try { const buf = Buffer.alloc(size - from); await fh.read(buf, 0, buf.length, from); text = buf.toString("utf8"); } finally { await fh.close(); } const lines = text.split("\n"); if (from > 0) lines.shift(); // the first is cut for (const line of lines) { if (!line) continue; try { const l = JSON.parse(line); this.remember(keyOf({ id: l.id ?? "", type: l.type, source: l.source, node: l.node, at: l.at, body: l.body })); } catch { // a line cut short by a failed write: its event was asked for again } } } }