Files
kickkingsadmin/src/lib/auth/audit-log.ts
T
2026-07-26 18:31:43 +00:00

141 lines
3.9 KiB
TypeScript

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<string, unknown>;
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<string, unknown>;
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<unknown> = Promise.resolve();
function enqueueWrite<T>(fn: () => Promise<T>): Promise<T> {
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<void> {
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<AuditLogEntry>;
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<string, unknown> }
: {}),
...(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<AuditLogEntry[]> {
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;
}