import { Router } from "express"; import { z } from "zod"; import type { Db } from "@paperclipai/db"; import { normalizeIssueIdentifier } from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; import { activityService, normalizeActivityLimit } from "../services/activity.js"; import { assertAuthenticated, assertBoard, assertCompanyAccess, getAccessibleResource, getActorInfo, hasCompanyAccess } from "./authz.js"; import { accessService, heartbeatService, issueService } from "../services/index.js"; import { sanitizeRecord } from "../redaction.js"; import { badRequest, forbidden } from "../errors.js"; import { agentActionAuditService } from "../services/agent-action-audit.js"; import { logActivity } from "../services/activity-log.js"; import { canActorReadHeartbeatRun } from "../services/heartbeat-run-privacy.js"; import { issueReadSqlCondition } from "../services/authorization.js"; import { caseActivityReadSqlCondition } from "../services/case-activity-access.js"; /** Max rows a single CSV export will stream (guards against runaway exports). */ const AUDIT_CSV_EXPORT_MAX_ROWS = 10_000; const AUDIT_CSV_PAGE_SIZE = 200; const CSV_FORMULA_CHARS = /^[=+\-@\t\r]/; const AUDIT_CSV_COLUMNS = [ "createdAt", "action", "actorType", "actorId", "agentId", "runId", "responsibleUserId", "entityType", "entityId", "issueIdentifier", "issueTitle", "commentExcerpt", "documentKey", ] as const; function csvCell(value: unknown): string { if (value === null || value === undefined) return ""; const str = value instanceof Date ? value.toISOString() : String(value); // Prevent spreadsheet applications from interpreting user-controlled cells // as formulas when an operator opens the export. const safe = CSV_FORMULA_CHARS.test(str) ? `'${str}` : str; // Quote if the value contains a delimiter, quote, or newline; escape quotes by doubling. return /[",\r\n]/.test(safe) ? `"${safe.replaceAll('"', '""')}"` : safe; } function readNested(value: unknown, ...keys: string[]): string | null { let cursor: unknown = value; for (const key of keys) { if (!cursor || typeof cursor !== "object") return null; cursor = (cursor as Record)[key]; } return typeof cursor === "string" ? cursor : null; } type AuditCsvRow = { createdAt: Date | string; action: string; actorType: string | null; actorId: string | null; agentId: string | null; runId: string | null; responsibleUserId: string | null; entityType: string; entityId: string; // Enrichment snippet is redacted server-side into a plain record, so read it // defensively rather than assuming a fixed shape. entity: unknown; }; function auditRowsToCsv(rows: AuditCsvRow[]): string { const lines = [AUDIT_CSV_COLUMNS.join(",")]; for (const row of rows) { lines.push([ csvCell(row.createdAt), csvCell(row.action), csvCell(row.actorType), csvCell(row.actorId), csvCell(row.agentId), csvCell(row.runId), csvCell(row.responsibleUserId), csvCell(row.entityType), csvCell(row.entityId), csvCell(readNested(row.entity, "issue", "identifier")), csvCell(readNested(row.entity, "issue", "title")), csvCell(readNested(row.entity, "comment", "excerpt")), csvCell(readNested(row.entity, "document", "key")), ].join(",")); } // Trailing newline keeps POSIX tools + spreadsheet importers happy. return `${lines.join("\r\n")}\r\n`; } const createActivitySchema = z.object({ actorType: z.enum(["agent", "user", "system", "plugin"]).optional().default("system"), actorId: z.string().min(1), action: z.string().min(1), entityType: z.string().min(1), entityId: z.string().min(1), agentId: z.string().guid().optional().nullable(), details: z.record(z.string(), z.unknown()).optional().nullable(), }); const agentActionAuditActorScopeSchema = z.enum(["agents", "all"]); const agentActionAuditQuerySchema = z.object({ actorScope: agentActionAuditActorScopeSchema.default("agents"), agentId: z.string().guid().optional(), responsibleUserId: z.string().min(1).optional(), runId: z.string().guid().optional(), entityType: z.string().min(1).optional(), entityId: z.string().min(1).optional(), action: z.string().min(1).optional(), actorType: z.enum(["agent", "user", "system", "plugin"]).optional(), from: z.coerce.date().optional(), to: z.coerce.date().optional(), cursor: z.string().min(1).optional(), limit: z.coerce.number().int().min(1).max(200).default(50), }); export function activityRoutes(db: Db) { const router = Router(); const svc = activityService(db); const access = accessService(db); const heartbeat = heartbeatService(db); const issueSvc = issueService(db); const agentAudit = agentActionAuditService(db); async function hasAgentAuditPermission(req: import("express").Request, companyId: string) { if (req.actor.type !== "board") return false; if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return true; return Boolean( req.actor.userId && await access.canUser(companyId, req.actor.userId, "audit:view_agent_actions"), ); } async function assertAgentAuditPermission(req: import("express").Request, companyId: string) { assertBoard(req); assertCompanyAccess(req, companyId); if (await hasAgentAuditPermission(req, companyId)) return; throw forbidden("Missing permission: audit:view_agent_actions"); } function hasAttributionFilters(query: z.infer) { // Case history is available to the case's ordinary readers. Its public // dimensions do not expose run/responsible-person attribution; the case // predicate still filters every row before pagination and enrichment. const caseScope = query.entityType === "case" || query.action?.startsWith("case."); return query.agentId !== undefined || query.responsibleUserId !== undefined || query.runId !== undefined || (!caseScope && (query.entityType !== undefined || query.entityId !== undefined || query.action !== undefined || query.actorType !== undefined || query.from !== undefined || query.to !== undefined)); } function stripAuditAttribution; }>(result: T): T { return { ...result, items: result.items.map((item) => ({ ...item, agentId: null, runId: null, responsibleUserId: null, details: null, })), }; } async function assertCompanyScopeReadAllowed(req: Parameters[0], res: any, companyId: string) { const decision = await access.decide({ actor: req.actor, action: "company_scope:read", resource: { type: "company", companyId }, }); if (decision.allowed) return true; res.status(403).json({ error: "Activity is outside this actor's authorization boundary" }); return false; } async function assertIssueReadAllowed(req: Parameters[0], res: any, issue: { id: string; companyId: string; projectId: string | null; parentId: string | null; assigneeAgentId: string | null; assigneeUserId: string | null; status: string; }) { const decision = await access.decide({ actor: req.actor, action: "issue:read", resource: { type: "issue", companyId: issue.companyId, issueId: issue.id, projectId: issue.projectId, parentIssueId: issue.parentId, assigneeAgentId: issue.assigneeAgentId, assigneeUserId: issue.assigneeUserId, status: issue.status, }, }); if (decision.allowed) return true; res.status(403).json({ error: "Issue activity is outside this actor's authorization boundary" }); return false; } async function resolveIssueByRef(rawId: string) { const identifier = normalizeIssueIdentifier(rawId); if (identifier) { return issueSvc.getByIdentifier(identifier); } return issueSvc.getById(rawId); } router.get("/companies/:companyId/activity", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); if (!(await assertCompanyScopeReadAllowed(req, res, companyId))) return; const filters = { companyId, agentId: req.query.agentId as string | undefined, entityType: req.query.entityType as string | undefined, entityId: req.query.entityId as string | undefined, limit: normalizeActivityLimit(Number(req.query.limit)), readCondition: await issueReadSqlCondition(db, req.actor), caseReadCondition: await caseActivityReadSqlCondition(db, req.actor), }; const result = await svc.list(filters); res.json(result); }); router.get("/companies/:companyId/audit/agent-actions", async (req, res) => { const companyId = req.params.companyId as string; const parsedActorScope = agentActionAuditActorScopeSchema.safeParse(req.query.actorScope ?? "agents"); if (!parsedActorScope.success) { throw badRequest("Invalid agent action audit query", parsedActorScope.error.issues); } if (parsedActorScope.data === "agents") { // Keep the legacy authorization-before-validation behavior for callers // that omit the new flag. await assertAgentAuditPermission(req, companyId); } else { assertCompanyAccess(req, companyId); if (!(await assertCompanyScopeReadAllowed(req, res, companyId))) return; } const parsedQuery = agentActionAuditQuerySchema.safeParse(req.query); if (!parsedQuery.success) { throw badRequest("Invalid agent action audit query", parsedQuery.error.issues); } if (parsedQuery.data.actorScope === "agents") { const result = await agentAudit.list({ companyId, ...parsedQuery.data, caseReadCondition: await caseActivityReadSqlCondition(db, req.actor) }); res.json({ ...result, accessTier: "full" }); return; } const canViewAttribution = await hasAgentAuditPermission(req, companyId); if (!canViewAttribution && hasAttributionFilters(parsedQuery.data)) { throw forbidden("Audit filters require permission: audit:view_agent_actions"); } const result = await agentAudit.list({ companyId, ...parsedQuery.data, caseReadCondition: await caseActivityReadSqlCondition(db, req.actor) }); res.json({ ...(canViewAttribution ? result : stripAuditAttribution(result)), accessTier: canViewAttribution ? "full" : "basic", }); }); router.get("/companies/:companyId/audit/agent-actions.csv", async (req, res) => { const companyId = req.params.companyId as string; await assertAgentAuditPermission(req, companyId); const parsedQuery = agentActionAuditQuerySchema.safeParse(req.query); if (!parsedQuery.success) { throw badRequest("Invalid agent action audit query", parsedQuery.error.issues); } // Drive our own pagination for the export; a client-supplied cursor/limit // would silently truncate the export, so ignore them. const { cursor: _cursor, limit: _limit, ...filters } = parsedQuery.data; const caseReadCondition = await caseActivityReadSqlCondition(db, req.actor); const rows: Awaited>["items"] = []; let cursor: string | undefined; do { const page = await agentAudit.list({ companyId, ...filters, cursor, limit: AUDIT_CSV_PAGE_SIZE, caseReadCondition }); for (const item of page.items) { if (rows.length >= AUDIT_CSV_EXPORT_MAX_ROWS) break; rows.push(item); } cursor = page.nextCursor ?? undefined; } while (cursor && rows.length < AUDIT_CSV_EXPORT_MAX_ROWS); // The export is itself an auditable act (training-data export precedent): // record who exported what filter set and how many rows left the system. const actorUserId = req.actor.type === "board" ? req.actor.userId ?? null : null; await logActivity(db, { companyId, actorType: actorUserId ? "user" : "system", actorId: actorUserId ?? "local-board", action: "audit.exported", entityType: "company", entityId: companyId, details: { format: "csv", rowCount: rows.length, truncated: rows.length >= AUDIT_CSV_EXPORT_MAX_ROWS && Boolean(cursor), filters: { actorScope: filters.actorScope, agentId: filters.agentId ?? null, responsibleUserId: filters.responsibleUserId ?? null, runId: filters.runId ?? null, entityType: filters.entityType ?? null, entityId: filters.entityId ?? null, action: filters.action ?? null, actorType: filters.actorType ?? null, from: filters.from ? filters.from.toISOString() : null, to: filters.to ? filters.to.toISOString() : null, }, }, }); res.setHeader("Content-Type", "text/csv; charset=utf-8"); res.setHeader("Content-Disposition", `attachment; filename="agent-audit-${companyId}.csv"`); res.send(auditRowsToCsv(rows)); }); router.post("/companies/:companyId/activity", validate(createActivitySchema), async (req, res) => { assertBoard(req); const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const event = await svc.create({ companyId, ...req.body, details: req.body.details ? sanitizeRecord(req.body.details) : null, }); res.status(201).json(event); }); router.get("/issues/:id/activity", async (req, res) => { const rawId = req.params.id as string; const issue = await getAccessibleResource(req, res, resolveIssueByRef(rawId), "Issue not found"); if (!issue) return; if (!(await assertIssueReadAllowed(req, res, issue))) return; if (req.query.view === "comment-inputs") { const parsed = z.array(z.string().uuid()).max(100).safeParse( typeof req.query.commentIds === "string" && req.query.commentIds ? req.query.commentIds.split(",") : [], ); if (!parsed.success) throw badRequest("commentIds must contain at most 100 UUIDs"); res.json(await svc.inputFactsForIssue(issue.companyId, issue.id, parsed.data)); return; } const result = await (req.query.view === "recent" ? svc.forIssue(issue.id, 50) : svc.forIssue(issue.id)); res.json(result); }); router.get("/issues/:id/runs", async (req, res) => { const rawId = req.params.id as string; const issue = await getAccessibleResource(req, res, resolveIssueByRef(rawId), "Issue not found"); if (!issue) return; if (!(await assertIssueReadAllowed(req, res, issue))) return; const viewerUserId = req.actor.type === "board" ? getActorInfo(req).actorId : null; const result = await (req.query.view === "recent" ? svc.runsForIssue(issue.companyId, issue.id, viewerUserId, 2) : svc.runsForIssue(issue.companyId, issue.id, viewerUserId)); res.json(result); }); router.get("/heartbeat-runs/:runId/issues", async (req, res) => { assertAuthenticated(req); const runId = req.params.runId as string; const run = await heartbeat.getRunReadAccess(runId); if (!run || !hasCompanyAccess(req, run.companyId)) { // Return `200 []` for both "doesn't exist" and "cross-tenant" — preserves the // legacy API contract while keeping the cross-tenant existence oracle closed // (both branches yield indistinguishable responses). res.json([]); return; } assertCompanyAccess(req, run.companyId); if (!(await assertCompanyScopeReadAllowed(req, res, run.companyId))) return; if (!(await canActorReadHeartbeatRun(db, access, req.actor, run))) { res.json([]); return; } const result = await svc.issuesForRun(runId, await issueReadSqlCondition(db, req.actor)); res.json(result); }); return router; }