import { randomUUID } from "node:crypto"; import { appendFile, mkdir, readFile } from "node:fs/promises"; import path from "node:path"; export type AuditLogEntry = { id: string; at: string; username: string; accountId: string | null; action: string; summary: string; details?: Record; ip?: string | null; /** Short device label (e.g. "Desktop · Windows · Chrome"). */ device?: string | null; /** Raw User-Agent (truncated), mainly for login audits. */ userAgent?: string | null; }; export type AuditLogInput = { username: string; accountId?: string | null; action: string; summary: string; details?: Record; ip?: string | null; device?: string | null; userAgent?: string | null; }; const DEFAULT_READ_LIMIT = 500; function auditLogPath(): string { const override = process.env.ADMIN_AUDIT_LOG_PATH?.trim(); if (override) return path.resolve(override); return path.join(process.cwd(), "data", "admin-audit.jsonl"); } let writeChain: Promise = Promise.resolve(); function enqueueWrite(fn: () => Promise): Promise { const next = writeChain.then(fn, fn); writeChain = next.then( () => undefined, () => undefined, ); return next; } /** Append one audit entry. Never throws to callers (best-effort). */ export async function appendAuditLog( input: AuditLogInput, ): Promise { try { await enqueueWrite(async () => { const filePath = auditLogPath(); await mkdir(path.dirname(filePath), { recursive: true }); const entry: AuditLogEntry = { id: randomUUID(), at: new Date().toISOString(), username: input.username, accountId: input.accountId ?? null, action: input.action, summary: input.summary, ...(input.details ? { details: input.details } : {}), ...(input.ip != null ? { ip: input.ip } : {}), ...(input.device != null ? { device: input.device } : {}), ...(input.userAgent != null ? { userAgent: input.userAgent } : {}), }; await appendFile(filePath, `${JSON.stringify(entry)}\n`, "utf8"); }); } catch (err) { console.error("[audit-log] failed to append", err); } } function parseLine(line: string): AuditLogEntry | null { const trimmed = line.trim(); if (!trimmed) return null; try { const raw = JSON.parse(trimmed) as Partial; if ( typeof raw.id !== "string" || typeof raw.at !== "string" || typeof raw.username !== "string" || typeof raw.action !== "string" || typeof raw.summary !== "string" ) { return null; } return { id: raw.id, at: raw.at, username: raw.username, accountId: typeof raw.accountId === "string" ? raw.accountId : null, action: raw.action, summary: raw.summary, ...(raw.details && typeof raw.details === "object" ? { details: raw.details as Record } : {}), ...(typeof raw.ip === "string" || raw.ip === null ? { ip: raw.ip } : {}), ...(typeof raw.device === "string" || raw.device === null ? { device: raw.device } : {}), ...(typeof raw.userAgent === "string" || raw.userAgent === null ? { userAgent: raw.userAgent } : {}), }; } catch { return null; } } /** Newest-first. Caps at `limit` entries. */ export async function readAuditLog( limit = DEFAULT_READ_LIMIT, ): Promise { const filePath = auditLogPath(); let text: string; try { text = await readFile(filePath, "utf8"); } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code === "ENOENT") return []; throw err; } const lines = text.split("\n"); const entries: AuditLogEntry[] = []; for (let i = lines.length - 1; i >= 0; i--) { const entry = parseLine(lines[i]!); if (!entry) continue; entries.push(entry); if (entries.length >= limit) break; } return entries; }