1// The moderation log: who did what, when, to whom. Every management method2// that changes something (the ones without `reads` in manage.ts) and every NIP-29 moderation event3// (groups.ts) leaves a row. The owner and moderators read it with listaudit4// and on the console's Moderation tab. Each row also goes out as a JSON5// line on the console, so an OTLP logs destination gets it as it happens6// (docs/11-hosting-bindws.md, Observe). Rows past the cap are dropped7// oldest first.89export const AUDIT_SCHEMA = `10CREATE TABLE IF NOT EXISTS audit (seq INTEGER PRIMARY KEY AUTOINCREMENT, at INTEGER NOT NULL, actor TEXT NOT NULL, action TEXT NOT NULL, target TEXT NOT NULL DEFAULT '', detail TEXT NOT NULL DEFAULT '');11`;1213export const AUDIT_KEEP = 5000;14const MAX_DETAIL = 500;15const PAGE = 100;1617export type AuditRow = { seq: number; at: number; actor: string; action: string; target: string; detail: string };18type Row = Record<string, string | number>;1920// detailOf compresses a method's parameters into the detail column: the21// first string parameter is the target, the rest is JSON, and bulky22// payloads are described rather than copied.23export function detailOf(method: string, params: unknown[]): { target: string; detail: string } {24 const target = typeof params[0] === "string" ? params[0].slice(0, 200) : "";25 let rest = typeof params[0] === "string" ? params.slice(1) : params;26 if (method === "importconfig") {27 const c = (params[0] ?? {}) as Record<string, unknown[]>;28 const n = (k: string) => (Array.isArray(c[k]) ? c[k].length : 0);29 return { target: "", detail: `members ${n("members")}, bans ${n("bans")}, banned events ${n("banned_events")}, addresses ${n("addresses")}` };30 }31 if (method === "setpolicy") rest = [Object.keys((params[0] ?? {}) as object)];32 let detail = "";33 try {34 detail = rest.length ? JSON.stringify(rest.length === 1 ? rest[0] : rest) : "";35 } catch {36 detail = "";37 }38 return { target, detail: detail.length > MAX_DETAIL ? detail.slice(0, MAX_DETAIL - 3) + "..." : detail };39}4041export class Audit {42 constructor(private sql: SqlStorage, private relay: () => string) {43 sql.exec(AUDIT_SCHEMA);44 }4546 record(at: number, actor: string, action: string, target = "", detail = "") {47 this.sql.exec(`INSERT INTO audit(at,actor,action,target,detail) VALUES(?,?,?,?,?)`, at, actor, action, target, detail);48 this.sql.exec(`DELETE FROM audit WHERE seq <= (SELECT max(seq) FROM audit) - ?`, AUDIT_KEEP);49 console.log(JSON.stringify({ msg: "audit", relay: this.relay(), at, actor, action, target, detail }));50 }5152 // list pages backward: rows with seq below `before` (0 for the newest), newest first.53 list(before = 0, limit = PAGE): AuditRow[] {54 limit = Math.min(Math.max(limit, 1), PAGE);55 return before > 056 ? (this.sql.exec<Row>(`SELECT * FROM audit WHERE seq < ? ORDER BY seq DESC LIMIT ?`, before, limit).toArray() as AuditRow[])57 : (this.sql.exec<Row>(`SELECT * FROM audit ORDER BY seq DESC LIMIT ?`, limit).toArray() as AuditRow[]);58 }59}