account system and syslogs
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user