mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 00:30:19 +02:00
## Thinking Path > - Paperclip is the control plane people use to manage AI-agent companies and their work > - The activity log is the generic audit spine for mutations across the control plane > - Activity rows identify agents and runs, but they do not persist the responsible human upstream > - Relying only on run joins loses attribution after run pruning and misses agent API-key actions outside a run > - This pull request resolves responsible-user attribution when each activity row is written and stores it directly > - The benefit is durable, queryable agent audit feeds without rewriting historical provenance ## Linked Issues or Issue Description ### Problem or motivation Agent activity records do not persist the responsible user, so attribution can disappear when runs are pruned and no-run API-key mutations cannot be attributed correctly. ### Proposed solution Resolve attribution for each new activity row from the run, related issue, active agent API key, or company default, in that order, and persist the result directly. ### Alternatives considered Read-time joins alone were rejected because pruned runs lose durable attribution and out-of-run agent-key actions have no run to join. Historical backfill was rejected because it would invent provenance. ### Roadmap alignment This strengthens the durable audit-trail direction described in `ROADMAP.md` without adding a new product surface. ## What Changed - Added nullable `activity_log.responsible_user_id` plus company/agent/time and company/responsible-user/time indexes. - Added an idempotent forward-only migration with no historical backfill. - Added centralized write-time resolution: heartbeat run → issue attribution → active agent API key → company default. - Propagated authenticated API-key IDs through existing request-backed `logActivity()` calls. - Added unit coverage for every fallback and an embedded-Postgres assertion for the no-run API-key stamping path. ## Verification - `pnpm --filter @paperclipai/db typecheck` - `pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/activity-log-responsible-user.test.ts src/__tests__/authz-company-access.test.ts` - Initial focused verification: 25 tests passed; migration safety passed. - Follow-up regression verification: 54 focused attribution/company-skill/environment/issue-tree/tool-gateway tests passed; server typecheck passed. - GitHub: full build, typecheck, server shards, serialized suites, e2e, security, and policy checks passed. ## Risks - Adding two indexes to an existing large table can hold a write lock while the transactional migration runs. The migration safety suppressions document why `CONCURRENTLY` is unavailable under the current Drizzle migration runner. - Historical rows remain nullable by design; this avoids inventing provenance and keeps the migration forward-only. - API-key attribution requires request-backed activity call sites to pass the authenticated key ID; this PR mechanically updates the existing actor-based activity calls and covers the no-run path with integration testing. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, `gpt-5.4`; context-window size was not exposed by the runtime. Medium reasoning with repository editing, terminal execution, and test execution capabilities was used. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
339 lines
11 KiB
TypeScript
339 lines
11 KiB
TypeScript
import { Router, type Request, type Response } from "express";
|
|
import multer from "multer";
|
|
import createDOMPurify from "dompurify";
|
|
import { JSDOM } from "jsdom";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { createAssetImageMetadataSchema } from "@paperclipai/shared";
|
|
import type { StorageService } from "../storage/types.js";
|
|
import { assetService, logActivity } from "../services/index.js";
|
|
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
|
|
import { assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.js";
|
|
const SVG_CONTENT_TYPE = "image/svg+xml";
|
|
const ALLOWED_COMPANY_LOGO_CONTENT_TYPES = new Set([
|
|
"image/png",
|
|
"image/jpeg",
|
|
"image/jpg",
|
|
"image/webp",
|
|
"image/gif",
|
|
SVG_CONTENT_TYPE,
|
|
]);
|
|
|
|
function sanitizeSvgBuffer(input: Buffer): Buffer | null {
|
|
const raw = input.toString("utf8").trim();
|
|
if (!raw) return null;
|
|
|
|
const baseDom = new JSDOM("");
|
|
const domPurify = createDOMPurify(
|
|
baseDom.window as unknown as Parameters<typeof createDOMPurify>[0],
|
|
);
|
|
domPurify.addHook("uponSanitizeAttribute", (_node, data) => {
|
|
const attrName = data.attrName.toLowerCase();
|
|
const attrValue = (data.attrValue ?? "").trim();
|
|
|
|
if (attrName.startsWith("on")) {
|
|
data.keepAttr = false;
|
|
return;
|
|
}
|
|
|
|
if ((attrName === "href" || attrName === "xlink:href") && attrValue && !attrValue.startsWith("#")) {
|
|
data.keepAttr = false;
|
|
}
|
|
});
|
|
|
|
let parsedDom: JSDOM | null = null;
|
|
try {
|
|
const sanitized = domPurify.sanitize(raw, {
|
|
USE_PROFILES: { svg: true, svgFilters: true, html: false },
|
|
FORBID_TAGS: ["script", "foreignObject"],
|
|
FORBID_CONTENTS: ["script", "foreignObject"],
|
|
RETURN_TRUSTED_TYPE: false,
|
|
});
|
|
|
|
parsedDom = new JSDOM(sanitized, { contentType: SVG_CONTENT_TYPE });
|
|
const document = parsedDom.window.document;
|
|
const root = document.documentElement;
|
|
if (!root || root.tagName.toLowerCase() !== "svg") return null;
|
|
|
|
for (const el of Array.from(root.querySelectorAll("script, foreignObject"))) {
|
|
el.remove();
|
|
}
|
|
for (const el of Array.from(root.querySelectorAll("*"))) {
|
|
for (const attr of Array.from(el.attributes)) {
|
|
const attrName = attr.name.toLowerCase();
|
|
const attrValue = attr.value.trim();
|
|
if (attrName.startsWith("on")) {
|
|
el.removeAttribute(attr.name);
|
|
continue;
|
|
}
|
|
if ((attrName === "href" || attrName === "xlink:href") && attrValue && !attrValue.startsWith("#")) {
|
|
el.removeAttribute(attr.name);
|
|
}
|
|
}
|
|
}
|
|
|
|
const output = root.outerHTML.trim();
|
|
if (!output || !/^<svg[\s>]/i.test(output)) return null;
|
|
return Buffer.from(output, "utf8");
|
|
} catch {
|
|
return null;
|
|
} finally {
|
|
parsedDom?.window.close();
|
|
baseDom.window.close();
|
|
}
|
|
}
|
|
|
|
export function assetRoutes(db: Db, storage: StorageService) {
|
|
const router = Router();
|
|
const svc = assetService(db);
|
|
const assetUpload = multer({
|
|
storage: multer.memoryStorage(),
|
|
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
|
|
});
|
|
const companyLogoUpload = multer({
|
|
storage: multer.memoryStorage(),
|
|
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
|
|
});
|
|
|
|
async function runSingleFileUpload(
|
|
upload: ReturnType<typeof multer>,
|
|
req: Request,
|
|
res: Response,
|
|
) {
|
|
await new Promise<void>((resolve, reject) => {
|
|
upload.single("file")(req, res, (err: unknown) => {
|
|
if (err) reject(err);
|
|
else resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
router.post("/companies/:companyId/assets/images", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
|
|
try {
|
|
await runSingleFileUpload(assetUpload, req, res);
|
|
} catch (err) {
|
|
if (err instanceof multer.MulterError) {
|
|
if (err.code === "LIMIT_FILE_SIZE") {
|
|
res.status(422).json({ error: `File exceeds ${MAX_ATTACHMENT_BYTES} bytes` });
|
|
return;
|
|
}
|
|
res.status(400).json({ error: err.message });
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
const file = (req as Request & { file?: { mimetype: string; buffer: Buffer; originalname: string } }).file;
|
|
if (!file) {
|
|
res.status(400).json({ error: "Missing file field 'file'" });
|
|
return;
|
|
}
|
|
|
|
const parsedMeta = createAssetImageMetadataSchema.safeParse(req.body ?? {});
|
|
if (!parsedMeta.success) {
|
|
res.status(400).json({ error: "Invalid image metadata", details: parsedMeta.error.issues });
|
|
return;
|
|
}
|
|
|
|
const namespaceSuffix = parsedMeta.data.namespace ?? "general";
|
|
const contentType = (file.mimetype || "").toLowerCase();
|
|
if (contentType !== SVG_CONTENT_TYPE && !isAllowedContentType(contentType)) {
|
|
res.status(422).json({ error: `Unsupported file type: ${contentType || "unknown"}` });
|
|
return;
|
|
}
|
|
let fileBody = file.buffer;
|
|
if (contentType === SVG_CONTENT_TYPE) {
|
|
const sanitized = sanitizeSvgBuffer(file.buffer);
|
|
if (!sanitized || sanitized.length <= 0) {
|
|
res.status(422).json({ error: "SVG could not be sanitized" });
|
|
return;
|
|
}
|
|
fileBody = sanitized;
|
|
}
|
|
if (fileBody.length <= 0) {
|
|
res.status(422).json({ error: "Image is empty" });
|
|
return;
|
|
}
|
|
|
|
const actor = getActorInfo(req);
|
|
const stored = await storage.putFile({
|
|
companyId,
|
|
namespace: `assets/${namespaceSuffix}`,
|
|
originalFilename: file.originalname || null,
|
|
contentType,
|
|
body: fileBody,
|
|
});
|
|
|
|
const asset = await svc.create(companyId, {
|
|
provider: stored.provider,
|
|
objectKey: stored.objectKey,
|
|
contentType: stored.contentType,
|
|
byteSize: stored.byteSize,
|
|
sha256: stored.sha256,
|
|
originalFilename: stored.originalFilename,
|
|
createdByAgentId: actor.agentId,
|
|
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
|
});
|
|
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
agentApiKeyId: actor.agentApiKeyId,
|
|
action: "asset.created",
|
|
entityType: "asset",
|
|
entityId: asset.id,
|
|
details: {
|
|
originalFilename: asset.originalFilename,
|
|
contentType: asset.contentType,
|
|
byteSize: asset.byteSize,
|
|
},
|
|
});
|
|
|
|
res.status(201).json({
|
|
assetId: asset.id,
|
|
companyId: asset.companyId,
|
|
provider: asset.provider,
|
|
objectKey: asset.objectKey,
|
|
contentType: asset.contentType,
|
|
byteSize: asset.byteSize,
|
|
sha256: asset.sha256,
|
|
originalFilename: asset.originalFilename,
|
|
createdByAgentId: asset.createdByAgentId,
|
|
createdByUserId: asset.createdByUserId,
|
|
createdAt: asset.createdAt,
|
|
updatedAt: asset.updatedAt,
|
|
contentPath: `/api/assets/${asset.id}/content`,
|
|
});
|
|
});
|
|
|
|
router.post("/companies/:companyId/logo", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
|
|
try {
|
|
await runSingleFileUpload(companyLogoUpload, req, res);
|
|
} catch (err) {
|
|
if (err instanceof multer.MulterError) {
|
|
if (err.code === "LIMIT_FILE_SIZE") {
|
|
res.status(422).json({ error: `Image exceeds ${MAX_ATTACHMENT_BYTES} bytes` });
|
|
return;
|
|
}
|
|
res.status(400).json({ error: err.message });
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
const file = (req as Request & { file?: { mimetype: string; buffer: Buffer; originalname: string } }).file;
|
|
if (!file) {
|
|
res.status(400).json({ error: "Missing file field 'file'" });
|
|
return;
|
|
}
|
|
|
|
const contentType = (file.mimetype || "").toLowerCase();
|
|
if (!ALLOWED_COMPANY_LOGO_CONTENT_TYPES.has(contentType)) {
|
|
res.status(422).json({ error: `Unsupported image type: ${contentType || "unknown"}` });
|
|
return;
|
|
}
|
|
|
|
let fileBody = file.buffer;
|
|
if (contentType === SVG_CONTENT_TYPE) {
|
|
const sanitized = sanitizeSvgBuffer(file.buffer);
|
|
if (!sanitized || sanitized.length <= 0) {
|
|
res.status(422).json({ error: "SVG could not be sanitized" });
|
|
return;
|
|
}
|
|
fileBody = sanitized;
|
|
}
|
|
|
|
if (fileBody.length <= 0) {
|
|
res.status(422).json({ error: "Image is empty" });
|
|
return;
|
|
}
|
|
|
|
const actor = getActorInfo(req);
|
|
const stored = await storage.putFile({
|
|
companyId,
|
|
namespace: "assets/companies",
|
|
originalFilename: file.originalname || null,
|
|
contentType,
|
|
body: fileBody,
|
|
});
|
|
|
|
const asset = await svc.create(companyId, {
|
|
provider: stored.provider,
|
|
objectKey: stored.objectKey,
|
|
contentType: stored.contentType,
|
|
byteSize: stored.byteSize,
|
|
sha256: stored.sha256,
|
|
originalFilename: stored.originalFilename,
|
|
createdByAgentId: actor.agentId,
|
|
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
|
});
|
|
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
agentApiKeyId: actor.agentApiKeyId,
|
|
action: "asset.created",
|
|
entityType: "asset",
|
|
entityId: asset.id,
|
|
details: {
|
|
originalFilename: asset.originalFilename,
|
|
contentType: asset.contentType,
|
|
byteSize: asset.byteSize,
|
|
namespace: "assets/companies",
|
|
},
|
|
});
|
|
|
|
res.status(201).json({
|
|
assetId: asset.id,
|
|
companyId: asset.companyId,
|
|
provider: asset.provider,
|
|
objectKey: asset.objectKey,
|
|
contentType: asset.contentType,
|
|
byteSize: asset.byteSize,
|
|
sha256: asset.sha256,
|
|
originalFilename: asset.originalFilename,
|
|
createdByAgentId: asset.createdByAgentId,
|
|
createdByUserId: asset.createdByUserId,
|
|
createdAt: asset.createdAt,
|
|
updatedAt: asset.updatedAt,
|
|
contentPath: `/api/assets/${asset.id}/content`,
|
|
});
|
|
});
|
|
|
|
router.get("/assets/:assetId/content", async (req, res, next) => {
|
|
const assetId = req.params.assetId as string;
|
|
const asset = await getAccessibleResource(req, res, svc.getById(assetId), "Asset not found");
|
|
if (!asset) return;
|
|
|
|
const object = await storage.getObject(asset.companyId, asset.objectKey);
|
|
const responseContentType = asset.contentType || object.contentType || "application/octet-stream";
|
|
res.setHeader("Content-Type", responseContentType);
|
|
res.setHeader("Content-Length", String(asset.byteSize || object.contentLength || 0));
|
|
res.setHeader("Cache-Control", "private, max-age=60");
|
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
if (responseContentType === SVG_CONTENT_TYPE) {
|
|
res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'");
|
|
}
|
|
const filename = asset.originalFilename ?? "asset";
|
|
res.setHeader("Content-Disposition", `inline; filename=\"${filename.replaceAll("\"", "")}\"`);
|
|
|
|
object.stream.on("error", (err) => {
|
|
next(err);
|
|
});
|
|
object.stream.pipe(res);
|
|
});
|
|
|
|
return router;
|
|
}
|