/** * TASK-0133 — audit CSV writer (stable contract — asserted by the export * tests + documented in `docs/api/org-admin.md`). * * Header (13 columns, FIXED order): * id,created_at,actor_type,actor_id,actor_name,workspace_id,workspace_name, * resource_type,resource_id,action,ip_address,user_agent,metadata * * Rules: ISO-8601 UTC timestamps; `actor_name` resolved for USER actors, * `-` for API_KEY/SYSTEM/INTEGRATION (their `actor_id` already carries the * kind, e.g. `api:{keyId}`); `metadata` = compact JSON, RFC-4180-quoted * (internal quotes doubled); `user_agent` ALWAYS quoted (user agents always * contain commas); other fields quoted only when they contain a comma / * quote / newline. Rows ordered `created_at DESC, id DESC` (the caller * passes pre-ordered rows; the writer does not re-sort). */ export const AUDIT_CSV_HEADER = "id,created_at,actor_type,actor_id,actor_name,workspace_id,workspace_name,resource_type,resource_id,action,ip_address,user_agent,metadata"; export interface AuditCsvRow { id: string; createdAt: Date; actorType: string; actorId: string; workspaceId: string | null; resourceType: string; resourceId: string; action: string; ipAddress: string | null; userAgent: string | null; metadata: unknown; } function quote(value: string | null | undefined, always = false): string { if (value == null) return always ? '""' : ""; const s = String(value); if (always || /[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`; return s; } /** Resolve actor names for USER rows (one bulk lookup; `-` otherwise). */ export function resolveActorNames( rows: AuditCsvRow[], userNames: Map ): string[] { return rows.map((r) => r.actorType === "USER" ? (userNames.get(r.actorId) ?? "-") : "-" ); } /** Render one row (no header). The worker's streaming export calls this per * batch; `renderAuditCsv` is the sync path's header+rows convenience. */ export function auditCsvLine( r: AuditCsvRow, actorName: string, workspaceNames: Map ): string { return [ quote(r.id), quote(r.createdAt.toISOString()), quote(r.actorType), quote(r.actorId), quote(actorName), quote(r.workspaceId), quote(r.workspaceId ? (workspaceNames.get(r.workspaceId) ?? "") : ""), quote(r.resourceType), quote(r.resourceId), quote(r.action), quote(r.ipAddress), quote(r.userAgent, true), quote(JSON.stringify(r.metadata ?? {}), true) ].join(","); } /** Render the CSV (header + rows). Pure — no I/O. */ export function renderAuditCsv( rows: AuditCsvRow[], userNames: Map, workspaceNames: Map ): string { const names = resolveActorNames(rows, userNames); const lines = [AUDIT_CSV_HEADER]; rows.forEach((r, i) => lines.push(auditCsvLine(r, names[i] ?? "-", workspaceNames))); return lines.join("\n") + "\n"; }