/** * Signed audit log with hash-chain integrity. * * Every significant mesh event is recorded as an append-only entry. * Each entry's SHA-256 hash includes the previous entry's hash, * forming a tamper-evident chain per mesh. If any row is modified * or deleted, all subsequent hashes will fail verification. * * NEVER logs message content (ciphertext or plaintext) — only metadata. */ import { createHash } from "node:crypto"; import { asc, desc, eq, sql, and } from "drizzle-orm"; import { db } from "./db"; import { auditLog } from "@turbostarter/db/schema/mesh"; import { log } from "./logger"; // --------------------------------------------------------------------------- // In-memory last-hash cache (one entry per mesh, loaded from DB on startup) // --------------------------------------------------------------------------- const lastHash = new Map(); // --------------------------------------------------------------------------- // Core audit logging // --------------------------------------------------------------------------- /** * Deterministic JSON serialization: keys sorted recursively. The store * is JSONB, which does NOT preserve key order, so hashing a naive * JSON.stringify(row.payload) on verify can yield a different string * from insert-time — false tamper reports. Canonical form guarantees * both sides agree. */ function canonicalJson(value: unknown): string { if (value === null || typeof value !== "object") return JSON.stringify(value); if (Array.isArray(value)) { return "[" + value.map(canonicalJson).join(",") + "]"; } const obj = value as Record; const keys = Object.keys(obj).sort(); return ( "{" + keys .map((k) => JSON.stringify(k) + ":" + canonicalJson(obj[k])) .join(",") + "}" ); } function computeHash( prevHash: string, meshId: string, eventType: string, actorMemberId: string | null, payload: Record, createdAt: Date, ): string { const input = `${prevHash}|${meshId}|${eventType}|${actorMemberId}|${canonicalJson(payload)}|${createdAt.toISOString()}`; return createHash("sha256").update(input).digest("hex"); } /** * Stable 63-bit lock key per mesh for audit serialization under HA. * Use the audit lock space; keep distinct from migrate's 74737_73831. */ function meshLockKey(meshId: string): bigint { const digest = createHash("sha256").update("audit:" + meshId).digest(); const unsigned = digest.readBigUInt64BE(0); return unsigned & 0x7fffffffffffffffn; } /** * Append an audit entry for a mesh event. * * Fire-and-forget safe — callers should `void audit(...)` or * `.catch(log.warn)` to avoid blocking the hot path. * * Concurrency under HA: wraps the write in a transaction that takes * `pg_advisory_xact_lock(meshLockKey(meshId))` before reading the * tail hash from the DB. This serializes all concurrent writers to * the same mesh and prevents the chain from forking. The in-memory * `lastHash` cache is updated after a successful commit. */ export async function audit( meshId: string, eventType: string, actorMemberId: string | null, actorDisplayName: string | null, payload: Record, ): Promise { const createdAt = new Date(); try { await db.transaction(async (tx) => { const key = meshLockKey(meshId); await tx.execute(sql`SELECT pg_advisory_xact_lock(${key}::bigint)`); const [latest] = await tx .select({ hash: auditLog.hash }) .from(auditLog) .where(eq(auditLog.meshId, meshId)) .orderBy(desc(auditLog.id)) .limit(1); const prevHash = latest?.hash ?? "genesis"; const hash = computeHash(prevHash, meshId, eventType, actorMemberId, payload, createdAt); await tx.insert(auditLog).values({ meshId, eventType, actorMemberId, actorDisplayName, payload, prevHash, hash, createdAt, }); lastHash.set(meshId, hash); }); } catch (e) { log.warn("audit log insert failed", { mesh_id: meshId, event_type: eventType, error: e instanceof Error ? e.message : String(e), }); } } // --------------------------------------------------------------------------- // Startup: load last hash per mesh from DB // --------------------------------------------------------------------------- export async function loadLastHashes(): Promise { try { // For each mesh, find the most recent audit entry by id (serial). // DISTINCT ON (mesh_id) ORDER BY id DESC gives us one row per mesh. const rows = await db.execute<{ mesh_id: string; hash: string }>(sql` SELECT DISTINCT ON (mesh_id) mesh_id, hash FROM mesh.audit_log ORDER BY mesh_id, id DESC `); for (const row of rows) { lastHash.set(row.mesh_id, row.hash); } log.info("audit: loaded last hashes", { meshes: lastHash.size }); } catch (e) { // Table may not exist yet on first boot — that's fine. log.warn("audit: loadLastHashes failed (table may not exist yet)", { error: e instanceof Error ? e.message : String(e), }); } } // --------------------------------------------------------------------------- // Chain verification // --------------------------------------------------------------------------- export async function verifyChain( meshId: string, ): Promise<{ valid: boolean; entries: number; brokenAt?: number }> { const rows = await db .select() .from(auditLog) .where(eq(auditLog.meshId, meshId)) .orderBy(asc(auditLog.id)); if (rows.length === 0) { return { valid: true, entries: 0 }; } for (let i = 0; i < rows.length; i++) { const row = rows[i]!; const expectedPrevHash = i === 0 ? "genesis" : rows[i - 1]!.hash; // Verify prevHash linkage if (row.prevHash !== expectedPrevHash) { return { valid: false, entries: rows.length, brokenAt: row.id }; } // Recompute hash and verify const recomputed = computeHash( row.prevHash, row.meshId, row.eventType, row.actorMemberId, row.payload as Record, row.createdAt, ); if (recomputed !== row.hash) { return { valid: false, entries: rows.length, brokenAt: row.id }; } } return { valid: true, entries: rows.length }; } // --------------------------------------------------------------------------- // Query: paginated audit entries // --------------------------------------------------------------------------- export async function queryAuditLog( meshId: string, options?: { limit?: number; offset?: number; eventType?: string }, ): Promise<{ entries: Array<{ id: number; eventType: string; actor: string; payload: Record; hash: string; createdAt: string }>; total: number }> { const limit = options?.limit ?? 50; const offset = options?.offset ?? 0; const conditions = [eq(auditLog.meshId, meshId)]; if (options?.eventType) { conditions.push(eq(auditLog.eventType, options.eventType)); } const where = conditions.length === 1 ? conditions[0]! : and(...conditions); const [rows, countResult] = await Promise.all([ db .select() .from(auditLog) .where(where) .orderBy(desc(auditLog.id)) .limit(limit) .offset(offset), db .select({ count: sql`count(*)` }) .from(auditLog) .where(where), ]); return { entries: rows.map((r) => ({ id: r.id, eventType: r.eventType, actor: r.actorDisplayName ?? r.actorMemberId ?? "system", payload: r.payload as Record, hash: r.hash, createdAt: r.createdAt.toISOString(), })), total: Number(countResult[0]?.count ?? 0), }; } // --------------------------------------------------------------------------- // Ensure table exists (raw DDL for first-boot before migrations run) // --------------------------------------------------------------------------- export async function ensureAuditLogTable(): Promise { try { await db.execute(sql` CREATE TABLE IF NOT EXISTS mesh.audit_log ( id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, mesh_id TEXT NOT NULL REFERENCES mesh.mesh(id) ON DELETE CASCADE ON UPDATE CASCADE, event_type TEXT NOT NULL, actor_member_id TEXT, actor_display_name TEXT, payload JSONB NOT NULL DEFAULT '{}', prev_hash TEXT NOT NULL, hash TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT now() ) `); } catch (e) { log.warn("audit: ensureAuditLogTable failed", { error: e instanceof Error ? e.message : String(e), }); } }