ubiquitous-invention/apps/web/server/lib/audit.ts
Randall Stillwell 336a5890a8 feat(audit): append-only audit_log, workspace archive cascade + restore, audit view
Soft-delete cascade was the missing half of archive: stamping
workspaces.archived_at alone left objects visible to anyone with a
direct id. The cascade runs in one transaction so the partial state
isn't reachable, and restore inverts it for any archived row in the
workspace — provenance-blind on purpose until we have a use case
that needs to distinguish per-workspace from per-object archives.

audit_log keeps the keyset index on (workspace_id, created_at) and
the actor_user_id FK with onDelete set null. recordAudit() refuses
to write a null actor without a metadata.system_actor label so the
audit view always has something to render. workspaces and invites
mutations call recordAudit on success; objects-router instrumentation
and the markdown importer's system-actor flow are filed as P2
follow-ups because each needs a thoughtful "what's audit-worthy?"
pass, not mechanical wiring.

Settings → Audit log lives at /<slug>/settings/audit, owner-gated,
keyset-paginated. ACTION_LABELS is small on purpose; new actions
fall back to their raw key so missing a label degrades gracefully.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 15:35:16 -05:00

63 lines
2.5 KiB
TypeScript

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,
});
}