/* The audit log: JSON Lines in `${HUB_DATA}/audit.log`, one entry per line, newest last on disk and newest first from `list`. Rotated at 10 MB to audit.log.1 (one kept). If the folder is not writable, one line goes to stderr and the log lives in memory (last 1000). Entries carry fixed fields only. Callers never pass passwords, tokens or request bodies. */ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync } from 'node:fs' import path from 'node:path' export const MAX_BYTES = 10 * 1024 * 1024 const RING = 1000 const DETAIL_MAX = 300 export interface AuditEntry { ts: string user: string role?: string ip: string action: string server?: string target?: string outcome: 'ok' | 'failed' | 'refused' detail?: string } export type AuditInput = Omit const cut = (value: string, max: number): string => value.slice(0, max) export class Audit { #file: string #maxBytes: number #now: () => number #ring: AuditEntry[] = [] #size = 0 #memoryOnly = false #listener: ((entry: AuditEntry) => void) | null = null constructor(dir: string, options: { maxBytes?: number; now?: () => number } = {}) { this.#maxBytes = options.maxBytes ?? MAX_BYTES this.#now = options.now ?? Date.now this.#file = path.join(dir, 'audit.log') try { mkdirSync(dir, { recursive: true }) this.#size = existsSync(this.#file) ? statSync(this.#file).size : 0 appendFileSync(this.#file, '') } catch (e) { this.#fallBack(e) } } get inMemory(): boolean { return this.#memoryOnly } #fallBack(e: unknown): void { if (!this.#memoryOnly) { console.error( `hub: audit log is not writable (${e instanceof Error ? e.message : String(e)}); keeping the last ${RING} entries in memory.`, ) } this.#memoryOnly = true } /** One function is told about every entry as it is written (the webhook uses it for failed writes). */ onEntry(listener: (entry: AuditEntry) => void): void { this.#listener = listener } log(input: AuditInput): void { const entry: AuditEntry = { ts: new Date(this.#now()).toISOString(), user: cut(input.user, 64), ...(input.role === undefined ? {} : { role: input.role }), ip: cut(input.ip, 64), action: input.action, ...(input.server === undefined ? {} : { server: input.server }), ...(input.target === undefined ? {} : { target: cut(input.target, 200) }), outcome: input.outcome, ...(input.detail === undefined ? {} : { detail: cut(input.detail, DETAIL_MAX) }), } this.#ring.push(entry) try { this.#listener?.(entry) } catch { // A listener never stops the log. } if (this.#ring.length > RING) this.#ring.shift() if (this.#memoryOnly) return const line = JSON.stringify(entry) + '\n' try { if (this.#size + Buffer.byteLength(line) > this.#maxBytes) this.#rotate() appendFileSync(this.#file, line) this.#size += Buffer.byteLength(line) } catch (e) { this.#fallBack(e) } } #rotate(): void { rmSync(`${this.#file}.1`, { force: true }) renameSync(this.#file, `${this.#file}.1`) this.#size = 0 } #read(file: string): AuditEntry[] { if (!existsSync(file)) return [] const out: AuditEntry[] = [] for (const line of readFileSync(file, 'utf8').split('\n')) { if (line === '') continue try { out.push(JSON.parse(line) as AuditEntry) } catch { // A torn line is skipped. } } return out } /** Newest first. `before` is an ISO time or epoch milliseconds: only older entries are returned. */ list(limit = 200, before?: string): AuditEntry[] { const max = Math.min(Math.max(Math.trunc(limit) || 200, 1), 1000) let cutoff = Infinity if (before !== undefined && before !== '') { const t = /^\d+$/.test(before) ? Number(before) : Date.parse(before) if (Number.isFinite(t)) cutoff = t } const all = this.#memoryOnly ? this.#ring : [...this.#read(`${this.#file}.1`), ...this.#read(this.#file)] const result: AuditEntry[] = [] for (let i = all.length - 1; i >= 0 && result.length < max; i--) { if (Date.parse(all[i].ts) < cutoff) result.push(all[i]) } return result } }