Merge public-gh/master into paperclip-company-import-export

This commit is contained in:
Dotta
2026-03-16 17:02:39 -05:00
125 changed files with 38085 additions and 683 deletions

View File

@@ -30,6 +30,7 @@ import {
accessService,
approvalService,
companySkillService,
budgetService,
heartbeatService,
issueApprovalService,
issueService,
@@ -64,6 +65,7 @@ export function agentRoutes(db: Db) {
const svc = agentService(db);
const access = accessService(db);
const approvalsSvc = approvalService(db);
const budgets = budgetService(db);
const heartbeat = heartbeatService(db);
const issueApprovalsSvc = issueApprovalService(db);
const secretsSvc = secretService(db);
@@ -1094,6 +1096,19 @@ export function agentRoutes(db: Db) {
details: { name: agent.name, role: agent.role },
});
if (agent.budgetMonthlyCents > 0) {
await budgets.upsertPolicy(
companyId,
{
scopeType: "agent",
scopeId: agent.id,
amount: agent.budgetMonthlyCents,
windowKind: "calendar_month_utc",
},
actor.actorType === "user" ? actor.actorId : null,
);
}
res.status(201).json(agent);
});

View File

@@ -1,21 +1,104 @@
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 { assertCompanyAccess, getActorInfo } from "./authz.js";
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
import { assertCompanyAccess, 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 upload = multer({
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(req: Request, res: Response) {
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);
@@ -29,7 +112,7 @@ export function assetRoutes(db: Db, storage: StorageService) {
assertCompanyAccess(req, companyId);
try {
await runSingleFileUpload(req, res);
await runSingleFileUpload(assetUpload, req, res);
} catch (err) {
if (err instanceof multer.MulterError) {
if (err.code === "LIMIT_FILE_SIZE") {
@@ -48,16 +131,6 @@ export function assetRoutes(db: Db, storage: StorageService) {
return;
}
const contentType = (file.mimetype || "").toLowerCase();
if (!isAllowedContentType(contentType)) {
res.status(422).json({ error: `Unsupported file type: ${contentType || "unknown"}` });
return;
}
if (file.buffer.length <= 0) {
res.status(422).json({ error: "Image is empty" });
return;
}
const parsedMeta = createAssetImageMetadataSchema.safeParse(req.body ?? {});
if (!parsedMeta.success) {
res.status(400).json({ error: "Invalid image metadata", details: parsedMeta.error.issues });
@@ -65,13 +138,32 @@ export function assetRoutes(db: Db, storage: StorageService) {
}
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: file.buffer,
body: fileBody,
});
const asset = await svc.create(companyId, {
@@ -118,6 +210,105 @@ export function assetRoutes(db: Db, storage: StorageService) {
});
});
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,
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 svc.getById(assetId);
@@ -128,9 +319,14 @@ export function assetRoutes(db: Db, storage: StorageService) {
assertCompanyAccess(req, asset.companyId);
const object = await storage.getObject(asset.companyId, asset.objectKey);
res.setHeader("Content-Type", asset.contentType || object.contentType || "application/octet-stream");
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("\"", "")}\"`);
@@ -142,4 +338,3 @@ export function assetRoutes(db: Db, storage: StorageService) {
return router;
}

View File

@@ -9,7 +9,13 @@ import {
} from "@paperclipai/shared";
import { forbidden } from "../errors.js";
import { validate } from "../middleware/validate.js";
import { accessService, companyPortabilityService, companyService, logActivity } from "../services/index.js";
import {
accessService,
budgetService,
companyPortabilityService,
companyService,
logActivity,
} from "../services/index.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
export function companyRoutes(db: Db) {
@@ -17,6 +23,7 @@ export function companyRoutes(db: Db) {
const svc = companyService(db);
const portability = companyPortabilityService(db);
const access = accessService(db);
const budgets = budgetService(db);
router.get("/", async (req, res) => {
assertBoard(req);
@@ -122,6 +129,18 @@ export function companyRoutes(db: Db) {
entityId: company.id,
details: { name: company.name },
});
if (company.budgetMonthlyCents > 0) {
await budgets.upsertPolicy(
company.id,
{
scopeType: "company",
scopeId: company.id,
amount: company.budgetMonthlyCents,
windowKind: "calendar_month_utc",
},
req.actor.userId ?? "board",
);
}
res.status(201).json(company);
});

View File

@@ -1,13 +1,35 @@
import { Router } from "express";
import type { Db } from "@paperclipai/db";
import { createCostEventSchema, updateBudgetSchema } from "@paperclipai/shared";
import {
createCostEventSchema,
createFinanceEventSchema,
resolveBudgetIncidentSchema,
updateBudgetSchema,
upsertBudgetPolicySchema,
} from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { costService, companyService, agentService, logActivity } from "../services/index.js";
import {
budgetService,
costService,
financeService,
companyService,
agentService,
heartbeatService,
logActivity,
} from "../services/index.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
import { fetchAllQuotaWindows } from "../services/quota-windows.js";
import { badRequest } from "../errors.js";
export function costRoutes(db: Db) {
const router = Router();
const costs = costService(db);
const heartbeat = heartbeatService(db);
const budgetHooks = {
cancelWorkForScope: heartbeat.cancelBudgetScopeWork,
};
const costs = costService(db, budgetHooks);
const finance = financeService(db);
const budgets = budgetService(db, budgetHooks);
const companies = companyService(db);
const agents = agentService(db);
@@ -40,12 +62,56 @@ export function costRoutes(db: Db) {
res.status(201).json(event);
});
router.post("/companies/:companyId/finance-events", validate(createFinanceEventSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertBoard(req);
const event = await finance.createEvent(companyId, {
...req.body,
occurredAt: new Date(req.body.occurredAt),
});
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "finance_event.reported",
entityType: "finance_event",
entityId: event.id,
details: {
amountCents: event.amountCents,
biller: event.biller,
eventKind: event.eventKind,
direction: event.direction,
},
});
res.status(201).json(event);
});
function parseDateRange(query: Record<string, unknown>) {
const from = query.from ? new Date(query.from as string) : undefined;
const to = query.to ? new Date(query.to as string) : undefined;
const fromRaw = query.from as string | undefined;
const toRaw = query.to as string | undefined;
const from = fromRaw ? new Date(fromRaw) : undefined;
const to = toRaw ? new Date(toRaw) : undefined;
if (from && isNaN(from.getTime())) throw badRequest("invalid 'from' date");
if (to && isNaN(to.getTime())) throw badRequest("invalid 'to' date");
return (from || to) ? { from, to } : undefined;
}
function parseLimit(query: Record<string, unknown>) {
const raw = query.limit as string | undefined;
if (!raw) return 100;
const limit = Number.parseInt(raw, 10);
if (!Number.isFinite(limit) || limit <= 0 || limit > 500) {
throw badRequest("invalid 'limit' value");
}
return limit;
}
router.get("/companies/:companyId/costs/summary", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
@@ -62,6 +128,117 @@ export function costRoutes(db: Db) {
res.json(rows);
});
router.get("/companies/:companyId/costs/by-agent-model", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const range = parseDateRange(req.query);
const rows = await costs.byAgentModel(companyId, range);
res.json(rows);
});
router.get("/companies/:companyId/costs/by-provider", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const range = parseDateRange(req.query);
const rows = await costs.byProvider(companyId, range);
res.json(rows);
});
router.get("/companies/:companyId/costs/by-biller", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const range = parseDateRange(req.query);
const rows = await costs.byBiller(companyId, range);
res.json(rows);
});
router.get("/companies/:companyId/costs/finance-summary", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const range = parseDateRange(req.query);
const summary = await finance.summary(companyId, range);
res.json(summary);
});
router.get("/companies/:companyId/costs/finance-by-biller", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const range = parseDateRange(req.query);
const rows = await finance.byBiller(companyId, range);
res.json(rows);
});
router.get("/companies/:companyId/costs/finance-by-kind", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const range = parseDateRange(req.query);
const rows = await finance.byKind(companyId, range);
res.json(rows);
});
router.get("/companies/:companyId/costs/finance-events", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const range = parseDateRange(req.query);
const limit = parseLimit(req.query);
const rows = await finance.list(companyId, range, limit);
res.json(rows);
});
router.get("/companies/:companyId/costs/window-spend", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const rows = await costs.windowSpend(companyId);
res.json(rows);
});
router.get("/companies/:companyId/costs/quota-windows", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertBoard(req);
// validate companyId resolves to a real company so the "__none__" sentinel
// and any forged ids are rejected before we touch provider credentials
const company = await companies.getById(companyId);
if (!company) {
res.status(404).json({ error: "Company not found" });
return;
}
const results = await fetchAllQuotaWindows();
res.json(results);
});
router.get("/companies/:companyId/budgets/overview", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const overview = await budgets.overview(companyId);
res.json(overview);
});
router.post(
"/companies/:companyId/budgets/policies",
validate(upsertBudgetPolicySchema),
async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const summary = await budgets.upsertPolicy(companyId, req.body, req.actor.userId ?? "board");
res.json(summary);
},
);
router.post(
"/companies/:companyId/budget-incidents/:incidentId/resolve",
validate(resolveBudgetIncidentSchema),
async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
const incidentId = req.params.incidentId as string;
assertCompanyAccess(req, companyId);
const incident = await budgets.resolveIncident(companyId, incidentId, req.body, req.actor.userId ?? "board");
res.json(incident);
},
);
router.get("/companies/:companyId/costs/by-project", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
@@ -73,6 +250,7 @@ export function costRoutes(db: Db) {
router.patch("/companies/:companyId/budgets", validate(updateBudgetSchema), async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const company = await companies.update(companyId, { budgetMonthlyCents: req.body.budgetMonthlyCents });
if (!company) {
res.status(404).json({ error: "Company not found" });
@@ -89,6 +267,17 @@ export function costRoutes(db: Db) {
details: { budgetMonthlyCents: req.body.budgetMonthlyCents },
});
await budgets.upsertPolicy(
companyId,
{
scopeType: "company",
scopeId: companyId,
amount: req.body.budgetMonthlyCents,
windowKind: "calendar_month_utc",
},
req.actor.userId ?? "board",
);
res.json(company);
});
@@ -100,6 +289,8 @@ export function costRoutes(db: Db) {
return;
}
assertCompanyAccess(req, agent.companyId);
if (req.actor.type === "agent") {
if (req.actor.agentId !== agentId) {
res.status(403).json({ error: "Agent can only change its own budget" });
@@ -125,6 +316,17 @@ export function costRoutes(db: Db) {
details: { budgetMonthlyCents: updated.budgetMonthlyCents },
});
await budgets.upsertPolicy(
updated.companyId,
{
scopeType: "agent",
scopeId: updated.id,
amount: updated.budgetMonthlyCents,
windowKind: "calendar_month_utc",
},
req.actor.type === "board" ? req.actor.userId ?? "board" : null,
);
res.json(updated);
});

View File

@@ -921,6 +921,19 @@ export function issueRoutes(db: Db, storage: StorageService) {
}
assertCompanyAccess(req, issue.companyId);
if (issue.projectId) {
const project = await projectsSvc.getById(issue.projectId);
if (project?.pausedAt) {
res.status(409).json({
error:
project.pauseReason === "budget"
? "Project is paused because its budget hard-stop was reached"
: "Project is paused",
});
return;
}
}
if (req.actor.type === "agent" && req.actor.agentId !== req.body.agentId) {
res.status(403).json({ error: "Agent can only checkout as itself" });
return;