ubiquitous-invention/apps/web/server/lib/audit.ts

64 lines
2.5 KiB
TypeScript
Raw Normal View History

import type { db as defaultDb } from "@tasks/database";
import { auditLog } from "@tasks/database/schema";
/**
* Append a single audit-log row. Every mutation that wants accountability
* (workspace state changes, member changes, invite lifecycle, object
* mutations once we instrument them) calls this once on success.
*
* Invariants:
* - Never fail silently if the insert throws, let it propagate. An
* audit miss is data-loss; the caller's transaction should roll back
* too if it can.
* - Keep `metadata` SMALL. Caller is responsible for picking the right
* handful of fields, not dumping the whole row. The convention is
* "what would a support engineer want to see at a glance?"
* - `actorUserId === null` is reserved for genuine system actors
* (markdown importer, scheduled jobs). Caller must also stamp
* `metadata.system_actor` with a short identifier so the audit UI
* can render "system: markdown-importer" instead of an empty cell.
*/
export type RecordAuditInput = {
workspaceId: string;
/** null = system actor. Pair with `metadata.system_actor` when null. */
actorUserId: string | null;
/** `<target>.<verb>` — e.g. `workspace.archive`, `invite.create`. */
action: string;
/** e.g. `workspace`, `invite`, `workspace_member`, `object`. */
targetType: string;
/** Natural id of the affected row, or null for batch / cascade actions. */
targetId?: string | null;
metadata?: Record<string, unknown> | null;
};
export async function recordAudit(
db: typeof defaultDb,
input: RecordAuditInput,
): Promise<void> {
// Light validation. Procedure-layer zod schemas should already enforce
// these, but the audit table is the last stop and we want garbage rows
// to fail loudly rather than silently distort the trail.
if (!input.workspaceId) {
throw new Error("recordAudit: workspaceId is required");
}
if (!input.action || !input.action.includes(".")) {
throw new Error(
`recordAudit: action must follow "<target>.<verb>", got: ${input.action}`,
);
}
if (input.actorUserId === null && !input.metadata?.system_actor) {
throw new Error(
"recordAudit: actorUserId is null but metadata.system_actor is missing — name the system actor explicitly",
);
}
await db.insert(auditLog).values({
workspaceId: input.workspaceId,
actorUserId: input.actorUserId,
action: input.action,
targetType: input.targetType,
targetId: input.targetId ?? null,
metadata: input.metadata ?? null,
});
}