Allow configurable issue attachment limits

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-28 08:17:52 -05:00
1 parent 559a843a25
commit e3869bda4c
17 files changed
+16020 -14

No files matched your search

@@ -0,0 +1 @@
ALTER TABLE "companies" ADD COLUMN "attachment_max_bytes" integer DEFAULT 1073741824 NOT NULL;
File diff suppressed because it is too large. Load diff
@@ -512,6 +512,13 @@
"when": 1777305216238,
"tag": "0072_large_sandman",
"breakpoints": true
},
{
"idx": 73,
"version": "7",
"when": 1777382021347,
"tag": "0073_shiny_salo",
"breakpoints": true
}
]
}
}
+3
View File
@@ -13,6 +13,9 @@ export const companies = pgTable(
issueCounter: integer("issue_counter").notNull().default(0),
budgetMonthlyCents: integer("budget_monthly_cents").notNull().default(0),
spentMonthlyCents: integer("spent_monthly_cents").notNull().default(0),
attachmentMaxBytes: integer("attachment_max_bytes")
.notNull()
.default(1024 * 1024 * 1024),
requireBoardApprovalForNewAgents: boolean("require_board_approval_for_new_agents")
.notNull()
.default(false),
+3
View File
@@ -1,6 +1,9 @@
export const COMPANY_STATUSES = ["active", "paused", "archived"] as const;
export type CompanyStatus = (typeof COMPANY_STATUSES)[number];
export const DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES = 1024 * 1024 * 1024;
export const MAX_COMPANY_ATTACHMENT_MAX_BYTES = 1024 * 1024 * 1024;
export const DEPLOYMENT_MODES = ["local_trusted", "authenticated"] as const;
export type DeploymentMode = (typeof DEPLOYMENT_MODES)[number];
+2
View File
@@ -1,6 +1,8 @@
export { agentAdapterTypeSchema, optionalAgentAdapterTypeSchema } from "./adapter-type.js";
export {
COMPANY_STATUSES,
DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES,
MAX_COMPANY_ATTACHMENT_MAX_BYTES,
DEPLOYMENT_MODES,
DEPLOYMENT_EXPOSURES,
BIND_MODES,
@@ -34,6 +34,7 @@ export interface CompanyPortabilityCompanyManifestEntry {
description: string | null;
brandColor: string | null;
logoPath: string | null;
attachmentMaxBytes: number | null;
requireBoardApprovalForNewAgents: boolean;
feedbackDataSharingEnabled: boolean;
feedbackDataSharingConsentAt: string | null;
+1
View File
@@ -11,6 +11,7 @@ export interface Company {
issueCounter: number;
budgetMonthlyCents: number;
spentMonthlyCents: number;
attachmentMaxBytes?: number;
requireBoardApprovalForNewAgents: boolean;
feedbackDataSharingEnabled: boolean;
feedbackDataSharingConsentAt: Date | null;
@@ -1,4 +1,5 @@
import { z } from "zod";
import { MAX_COMPANY_ATTACHMENT_MAX_BYTES } from "../constants.js";
import { routineVariableSchema } from "./routine.js";
export const portabilityIncludeSchema = z
@@ -37,6 +38,7 @@ export const portabilityCompanyManifestEntrySchema = z.object({
description: z.string().nullable(),
brandColor: z.string().nullable(),
logoPath: z.string().nullable(),
attachmentMaxBytes: z.number().int().min(1).max(MAX_COMPANY_ATTACHMENT_MAX_BYTES).nullable().default(null),
requireBoardApprovalForNewAgents: z.boolean(),
feedbackDataSharingEnabled: z.boolean().default(false),
feedbackDataSharingConsentAt: z.string().datetime().nullable().default(null),
+11 -1
View File
@@ -1,14 +1,23 @@
import { z } from "zod";
import { COMPANY_STATUSES } from "../constants.js";
import {
COMPANY_STATUSES,
MAX_COMPANY_ATTACHMENT_MAX_BYTES,
} from "../constants.js";
const logoAssetIdSchema = z.string().uuid().nullable().optional();
const brandColorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional();
const feedbackDataSharingTermsVersionSchema = z.string().min(1).nullable().optional();
const attachmentMaxBytesSchema = z
.number()
.int()
.min(1)
.max(MAX_COMPANY_ATTACHMENT_MAX_BYTES);
export const createCompanySchema = z.object({
name: z.string().min(1),
description: z.string().optional().nullable(),
budgetMonthlyCents: z.number().int().nonnegative().optional().default(0),
attachmentMaxBytes: attachmentMaxBytesSchema.optional(),
});
export type CreateCompany = z.infer<typeof createCompanySchema>;
@@ -25,6 +34,7 @@ export const updateCompanySchema = createCompanySchema
feedbackDataSharingTermsVersion: feedbackDataSharingTermsVersionSchema,
brandColor: brandColorSchema,
logoAssetId: logoAssetIdSchema,
attachmentMaxBytes: attachmentMaxBytesSchema.optional(),
});
export type UpdateCompany = z.infer<typeof updateCompanySchema>;
@@ -10,6 +10,9 @@ const mockIssueService = vi.hoisted(() => ({
createAttachment: vi.fn(),
getAttachmentById: vi.fn(),
}));
const mockCompanyService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
@@ -39,6 +42,7 @@ function registerRouteMocks() {
agentService: () => ({
getById: vi.fn(),
}),
companyService: () => mockCompanyService,
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => ({
@@ -180,6 +184,10 @@ describe("issue attachment routes", () => {
registerRouteMocks();
vi.clearAllMocks();
mockLogActivity.mockResolvedValue(undefined);
mockCompanyService.getById.mockResolvedValue({
id: "company-1",
attachmentMaxBytes: 1024 * 1024 * 1024,
});
});
it("accepts zip uploads for issue attachments", async () => {
@@ -215,6 +223,49 @@ describe("issue attachment routes", () => {
expect(res.body.contentType).toBe("application/zip");
});
it("allows issue attachments larger than the legacy 10 MiB process default when company limit allows it", async () => {
const storage = createStorageService();
mockIssueService.getById.mockResolvedValue({
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
identifier: "PAP-1",
});
mockIssueService.createAttachment.mockResolvedValue(makeAttachment("application/octet-stream", "large.bin"));
const app = await createApp(storage);
const res = await request(app)
.post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments")
.attach("file", Buffer.alloc(10 * 1024 * 1024 + 1), {
filename: "large.bin",
contentType: "application/octet-stream",
});
expect(res.status).toBe(201);
expect(storage.__calls.putFile?.body.length).toBe(10 * 1024 * 1024 + 1);
});
it("enforces the configured per-company issue attachment limit", async () => {
const storage = createStorageService();
mockCompanyService.getById.mockResolvedValue({
id: "company-1",
attachmentMaxBytes: 4,
});
mockIssueService.getById.mockResolvedValue({
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
identifier: "PAP-1",
});
const app = await createApp(storage);
const res = await request(app)
.post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments")
.attach("file", Buffer.from("large"), { filename: "large.txt", contentType: "text/plain" });
expect(res.status).toBe(422);
expect(res.body.error).toBe("Attachment exceeds 4 bytes");
expect(mockIssueService.createAttachment).not.toHaveBeenCalled();
});
it("serves html attachments as downloads with nosniff", async () => {
const storage = createStorageService();
mockIssueService.getAttachmentById.mockResolvedValue(makeAttachment("text/html", "report.html"));
+11
View File
@@ -14,6 +14,10 @@
* - Exact types: "application/pdf"
* - Wildcards: "image/*" or "application/vnd.openxmlformats-officedocument.*"
*/
import {
DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES,
MAX_COMPANY_ATTACHMENT_MAX_BYTES,
} from "@paperclipai/shared";
export const DEFAULT_ALLOWED_TYPES: readonly string[] = [
"image/png",
@@ -92,3 +96,10 @@ export function isAllowedContentType(contentType: string): boolean {
export const MAX_ATTACHMENT_BYTES =
Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
export function normalizeIssueAttachmentMaxBytes(value: number | null | undefined): number {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
return DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES;
}
return Math.min(Math.floor(value), MAX_COMPANY_ATTACHMENT_MAX_BYTES);
}
+13 -9
View File
@@ -39,6 +39,7 @@ import * as serviceIndex from "../services/index.js";
import {
accessService,
agentService,
companyService,
executionWorkspaceService,
goalService,
heartbeatService,
@@ -65,7 +66,7 @@ import {
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
import {
isInlineAttachmentContentType,
MAX_ATTACHMENT_BYTES,
normalizeIssueAttachmentMaxBytes,
normalizeContentType,
SVG_CONTENT_TYPE,
} from "../attachment-types.js";
@@ -404,6 +405,7 @@ export function issueRoutes(
pluginWorkerManager: opts.pluginWorkerManager,
});
const feedback = feedbackService(db);
const companiesSvc = companyService(db);
const instanceSettings = instanceSettingsService(db);
const agentsSvc = agentService(db);
const projectsSvc = projectService(db);
@@ -427,11 +429,6 @@ export function issueRoutes(
};
const feedbackExportService = opts?.feedbackExportService;
const environmentsSvc = environmentService(db);
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
});
function withContentPath<T extends { id: string }>(attachment: T) {
return {
...attachment,
@@ -493,7 +490,11 @@ export function issueRoutes(
return parsed;
}
async function runSingleFileUpload(req: Request, res: Response) {
async function runSingleFileUpload(req: Request, res: Response, fileSizeLimit: number) {
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: fileSizeLimit, files: 1 },
});
await new Promise<void>((resolve, reject) => {
upload.single("file")(req, res, (err: unknown) => {
if (err) reject(err);
@@ -3692,12 +3693,15 @@ export function issueRoutes(
}
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
const company = await companiesSvc.getById(companyId);
const attachmentMaxBytes = normalizeIssueAttachmentMaxBytes(company?.attachmentMaxBytes);
try {
await runSingleFileUpload(req, res);
await runSingleFileUpload(req, res, attachmentMaxBytes);
} catch (err) {
if (err instanceof multer.MulterError) {
if (err.code === "LIMIT_FILE_SIZE") {
res.status(422).json({ error: `Attachment exceeds ${MAX_ATTACHMENT_BYTES} bytes` });
res.status(422).json({ error: `Attachment exceeds ${attachmentMaxBytes} bytes` });
return;
}
res.status(400).json({ error: err.message });
+1
View File
@@ -45,6 +45,7 @@ export function companyService(db: Db) {
issueCounter: companies.issueCounter,
budgetMonthlyCents: companies.budgetMonthlyCents,
spentMonthlyCents: companies.spentMonthlyCents,
attachmentMaxBytes: companies.attachmentMaxBytes,
requireBoardApprovalForNewAgents: companies.requireBoardApprovalForNewAgents,
feedbackDataSharingEnabled: companies.feedbackDataSharingEnabled,
feedbackDataSharingConsentAt: companies.feedbackDataSharingConsentAt,
@@ -2428,6 +2428,10 @@ function buildManifestFromPackageFiles(
description: asString(companyFrontmatter.description),
brandColor: asString(paperclipCompany.brandColor),
logoPath: asString(paperclipCompany.logoPath) ?? asString(paperclipCompany.logo),
attachmentMaxBytes:
typeof paperclipCompany.attachmentMaxBytes === "number" && Number.isFinite(paperclipCompany.attachmentMaxBytes)
? Math.max(1, Math.floor(paperclipCompany.attachmentMaxBytes))
: null,
requireBoardApprovalForNewAgents:
typeof paperclipCompany.requireBoardApprovalForNewAgents === "boolean"
? paperclipCompany.requireBoardApprovalForNewAgents
@@ -3465,6 +3469,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
company: stripEmptyValues({
brandColor: company.brandColor ?? null,
logoPath: companyLogoPath,
attachmentMaxBytes: company.attachmentMaxBytes,
requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents ? true : undefined,
feedbackDataSharingEnabled: company.feedbackDataSharingEnabled ? true : undefined,
feedbackDataSharingConsentAt: company.feedbackDataSharingConsentAt?.toISOString() ?? null,
@@ -3963,6 +3968,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
id: string;
name: string;
requireBoardApprovalForNewAgents?: boolean | null;
attachmentMaxBytes?: number | null;
} | null = null;
let companyAction: "created" | "updated" | "unchanged" = "unchanged";
@@ -3985,6 +3991,9 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
name: companyName,
description: include.company ? (sourceManifest.company?.description ?? null) : null,
brandColor: include.company ? (sourceManifest.company?.brandColor ?? null) : null,
attachmentMaxBytes: include.company
? (sourceManifest.company?.attachmentMaxBytes ?? undefined)
: undefined,
requireBoardApprovalForNewAgents: include.company
? (sourceManifest.company?.requireBoardApprovalForNewAgents ?? false)
: false,
@@ -4016,6 +4025,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
name: sourceManifest.company.name,
description: sourceManifest.company.description,
brandColor: sourceManifest.company.brandColor,
attachmentMaxBytes: sourceManifest.company.attachmentMaxBytes ?? undefined,
requireBoardApprovalForNewAgents: sourceManifest.company.requireBoardApprovalForNewAgents,
feedbackDataSharingEnabled: sourceManifest.company.feedbackDataSharingEnabled,
feedbackDataSharingConsentAt: sourceManifest.company.feedbackDataSharingConsentAt
+1
View File
@@ -32,6 +32,7 @@ export const companiesApi = {
| "description"
| "status"
| "budgetMonthlyCents"
| "attachmentMaxBytes"
| "requireBoardApprovalForNewAgents"
| "feedbackDataSharingEnabled"
| "brandColor"
+42 -3
View File
@@ -2,6 +2,8 @@ import { ChangeEvent, useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AGENT_ADAPTER_TYPES,
DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES,
MAX_COMPANY_ATTACHMENT_MAX_BYTES,
getAdapterEnvironmentSupport,
type Environment,
type EnvironmentProbeResult,
@@ -54,6 +56,9 @@ const ENVIRONMENT_SUPPORT_ROWS = AGENT_ADAPTER_TYPES.map((adapterType) => ({
adapterType,
support: getAdapterEnvironmentSupport(adapterType),
}));
const BYTES_PER_MIB = 1024 * 1024;
const DEFAULT_COMPANY_ATTACHMENT_MAX_MIB = DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES / BYTES_PER_MIB;
const MAX_COMPANY_ATTACHMENT_MAX_MIB = MAX_COMPANY_ATTACHMENT_MAX_BYTES / BYTES_PER_MIB;
function buildEnvironmentPayload(form: EnvironmentFormState) {
return {
@@ -183,6 +188,7 @@ export function CompanySettings() {
const [companyName, setCompanyName] = useState("");
const [description, setDescription] = useState("");
const [brandColor, setBrandColor] = useState("");
const [attachmentMaxMiB, setAttachmentMaxMiB] = useState(String(DEFAULT_COMPANY_ATTACHMENT_MAX_MIB));
const [logoUrl, setLogoUrl] = useState("");
const [logoUploadError, setLogoUploadError] = useState<string | null>(null);
const [editingEnvironmentId, setEditingEnvironmentId] = useState<string | null>(null);
@@ -195,6 +201,7 @@ export function CompanySettings() {
setCompanyName(selectedCompany.name);
setDescription(selectedCompany.description ?? "");
setBrandColor(selectedCompany.brandColor ?? "");
setAttachmentMaxMiB(String(Math.round((selectedCompany.attachmentMaxBytes ?? DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES) / BYTES_PER_MIB)));
setLogoUrl(selectedCompany.logoUrl ?? "");
}, [selectedCompany]);
@@ -227,17 +234,24 @@ export function CompanySettings() {
enabled: Boolean(selectedCompanyId),
});
const attachmentMaxBytes = Number.parseInt(attachmentMaxMiB, 10) * BYTES_PER_MIB;
const attachmentMaxValid =
Number.isInteger(attachmentMaxBytes)
&& attachmentMaxBytes >= BYTES_PER_MIB
&& attachmentMaxBytes <= MAX_COMPANY_ATTACHMENT_MAX_BYTES;
const generalDirty =
!!selectedCompany &&
(companyName !== selectedCompany.name ||
description !== (selectedCompany.description ?? "") ||
brandColor !== (selectedCompany.brandColor ?? ""));
brandColor !== (selectedCompany.brandColor ?? "") ||
attachmentMaxBytes !== (selectedCompany.attachmentMaxBytes ?? DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES));
const generalMutation = useMutation({
mutationFn: (data: {
name: string;
description: string | null;
brandColor: string | null;
attachmentMaxBytes: number;
}) => companiesApi.update(selectedCompanyId!, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
@@ -477,7 +491,8 @@ export function CompanySettings() {
generalMutation.mutate({
name: companyName.trim(),
description: description.trim() || null,
brandColor: brandColor || null
brandColor: brandColor || null,
attachmentMaxBytes
});
}
@@ -716,6 +731,30 @@ export function CompanySettings() {
)}
</div>
</Field>
<Field
label="Attachment size limit"
hint={`Accepted range: 1-${MAX_COMPANY_ATTACHMENT_MAX_MIB} MiB.`}
>
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<input
type="number"
min={1}
max={MAX_COMPANY_ATTACHMENT_MAX_MIB}
step={1}
value={attachmentMaxMiB}
onChange={(e) => setAttachmentMaxMiB(e.target.value)}
className="w-28 rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
/>
<span className="text-xs text-muted-foreground">MiB</span>
</div>
{!attachmentMaxValid && (
<span className="text-xs text-destructive">
Enter a whole number from 1 to {MAX_COMPANY_ATTACHMENT_MAX_MIB}.
</span>
)}
</div>
</Field>
</div>
</div>
</div>
@@ -727,7 +766,7 @@ export function CompanySettings() {
<Button
size="sm"
onClick={handleSaveGeneral}
disabled={generalMutation.isPending || !companyName.trim()}
disabled={generalMutation.isPending || !companyName.trim() || !attachmentMaxValid}
>
{generalMutation.isPending ? "Saving..." : "Save changes"}
</Button>