141 lines
3.9 KiB
TypeScript
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;
|
|
}
|