Merge remote-tracking branch 'public-gh/master' into feature/workspace-runtime-support

* public-gh/master:
  Rebind seeded project workspaces to the current worktree
  Copy seeded secrets key into worktree instances
  server: make approval retries idempotent (#499)
  fix: address review feedback — stale error message and * wildcard
  Update server/src/routes/assets.ts
  feat: make attachment content types configurable via env var
  fix: wire parentId query filter into issues list endpoint
This commit is contained in:
Dotta
2026-03-10 14:19:11 -05:00
11 changed files with 828 additions and 154 deletions

View File

@@ -1,5 +1,8 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { copySeededSecretsKey, rebindWorkspaceCwd } from "../commands/worktree.js";
import {
buildWorktreeConfig,
buildWorktreeEnvEntries,
@@ -122,4 +125,78 @@ describe("worktree helpers", () => {
expect(full.excludedTables).toEqual([]);
expect(full.nullifyColumns).toEqual({});
});
it("copies the source local_encrypted secrets key into the seeded worktree instance", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-"));
try {
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
const sourceKeyPath = path.join(tempRoot, "source", "secrets", "master.key");
const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key");
fs.mkdirSync(path.dirname(sourceKeyPath), { recursive: true });
fs.writeFileSync(sourceKeyPath, "source-master-key", "utf8");
const sourceConfig = buildSourceConfig();
sourceConfig.secrets.localEncrypted.keyFilePath = sourceKeyPath;
copySeededSecretsKey({
sourceConfigPath,
sourceConfig,
sourceEnvEntries: {},
targetKeyFilePath: targetKeyPath,
});
expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("source-master-key");
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("writes the source inline secrets master key into the seeded worktree instance", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-"));
try {
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key");
copySeededSecretsKey({
sourceConfigPath,
sourceConfig: buildSourceConfig(),
sourceEnvEntries: {
PAPERCLIP_SECRETS_MASTER_KEY: "inline-source-master-key",
},
targetKeyFilePath: targetKeyPath,
});
expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("inline-source-master-key");
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("rebinds same-repo workspace paths onto the current worktree root", () => {
expect(
rebindWorkspaceCwd({
sourceRepoRoot: "/Users/nmurray/paperclip",
targetRepoRoot: "/Users/nmurray/paperclip-pr-432",
workspaceCwd: "/Users/nmurray/paperclip",
}),
).toBe("/Users/nmurray/paperclip-pr-432");
expect(
rebindWorkspaceCwd({
sourceRepoRoot: "/Users/nmurray/paperclip",
targetRepoRoot: "/Users/nmurray/paperclip-pr-432",
workspaceCwd: "/Users/nmurray/paperclip/packages/db",
}),
).toBe("/Users/nmurray/paperclip-pr-432/packages/db");
});
it("does not rebind paths outside the source repo root", () => {
expect(
rebindWorkspaceCwd({
sourceRepoRoot: "/Users/nmurray/paperclip",
targetRepoRoot: "/Users/nmurray/paperclip-pr-432",
workspaceCwd: "/Users/nmurray/other-project",
}),
).toBeNull();
});
});

View File

@@ -1,14 +1,17 @@
import { existsSync, readFileSync, rmSync } from "node:fs";
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { createServer } from "node:net";
import * as p from "@clack/prompts";
import pc from "picocolors";
import { eq } from "drizzle-orm";
import {
applyPendingMigrations,
createDb,
ensurePostgresDatabase,
formatDatabaseBackupResult,
projectWorkspaces,
runDatabaseBackup,
runDatabaseRestore,
} from "@paperclipai/db";
@@ -18,6 +21,7 @@ import { expandHomePrefix } from "../config/home.js";
import type { PaperclipConfig } from "../config/schema.js";
import { readConfig, resolveConfigPath, writeConfig } from "../config/store.js";
import { printPaperclipCliBanner } from "../utils/banner.js";
import { resolveRuntimeLikePath } from "../utils/path-resolver.js";
import {
buildWorktreeConfig,
buildWorktreeEnvEntries,
@@ -73,6 +77,20 @@ type EmbeddedPostgresHandle = {
stop: () => Promise<void>;
};
type GitWorkspaceInfo = {
root: string;
commonDir: string;
};
type SeedWorktreeDatabaseResult = {
backupSummary: string;
reboundWorkspaces: Array<{
name: string;
fromCwd: string;
toCwd: string;
}>;
};
function nonEmpty(value: string | null | undefined): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
@@ -132,6 +150,107 @@ function detectGitBranchName(cwd: string): string | null {
}
}
function detectGitWorkspaceInfo(cwd: string): GitWorkspaceInfo | null {
try {
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
const commonDirRaw = execFileSync("git", ["rev-parse", "--git-common-dir"], {
cwd: root,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
return {
root: path.resolve(root),
commonDir: path.resolve(root, commonDirRaw),
};
} catch {
return null;
}
}
export function rebindWorkspaceCwd(input: {
sourceRepoRoot: string;
targetRepoRoot: string;
workspaceCwd: string;
}): string | null {
const sourceRepoRoot = path.resolve(input.sourceRepoRoot);
const targetRepoRoot = path.resolve(input.targetRepoRoot);
const workspaceCwd = path.resolve(input.workspaceCwd);
const relative = path.relative(sourceRepoRoot, workspaceCwd);
if (!relative || relative === "") {
return targetRepoRoot;
}
if (relative.startsWith("..") || path.isAbsolute(relative)) {
return null;
}
return path.resolve(targetRepoRoot, relative);
}
async function rebindSeededProjectWorkspaces(input: {
targetConnectionString: string;
currentCwd: string;
}): Promise<SeedWorktreeDatabaseResult["reboundWorkspaces"]> {
const targetRepo = detectGitWorkspaceInfo(input.currentCwd);
if (!targetRepo) return [];
const db = createDb(input.targetConnectionString);
const closableDb = db as typeof db & {
$client?: { end?: (opts?: { timeout?: number }) => Promise<void> };
};
try {
const rows = await db
.select({
id: projectWorkspaces.id,
name: projectWorkspaces.name,
cwd: projectWorkspaces.cwd,
})
.from(projectWorkspaces);
const rebound: SeedWorktreeDatabaseResult["reboundWorkspaces"] = [];
for (const row of rows) {
const workspaceCwd = nonEmpty(row.cwd);
if (!workspaceCwd) continue;
const sourceRepo = detectGitWorkspaceInfo(workspaceCwd);
if (!sourceRepo) continue;
if (sourceRepo.commonDir !== targetRepo.commonDir) continue;
const reboundCwd = rebindWorkspaceCwd({
sourceRepoRoot: sourceRepo.root,
targetRepoRoot: targetRepo.root,
workspaceCwd,
});
if (!reboundCwd) continue;
const normalizedCurrent = path.resolve(workspaceCwd);
if (reboundCwd === normalizedCurrent) continue;
if (!existsSync(reboundCwd)) continue;
await db
.update(projectWorkspaces)
.set({
cwd: reboundCwd,
updatedAt: new Date(),
})
.where(eq(projectWorkspaces.id, row.id));
rebound.push({
name: row.name,
fromCwd: normalizedCurrent,
toCwd: reboundCwd,
});
}
return rebound;
} finally {
await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined);
}
}
function resolveSourceConfigPath(opts: WorktreeInitOptions): string {
if (opts.fromConfig) return path.resolve(opts.fromConfig);
const sourceHome = path.resolve(expandHomePrefix(opts.fromDataDir ?? "~/.paperclip"));
@@ -154,6 +273,54 @@ function resolveSourceConnectionString(config: PaperclipConfig, envEntries: Reco
return `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
}
export function copySeededSecretsKey(input: {
sourceConfigPath: string;
sourceConfig: PaperclipConfig;
sourceEnvEntries: Record<string, string>;
targetKeyFilePath: string;
}): void {
if (input.sourceConfig.secrets.provider !== "local_encrypted") {
return;
}
mkdirSync(path.dirname(input.targetKeyFilePath), { recursive: true });
const sourceInlineMasterKey =
nonEmpty(input.sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY) ??
nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY);
if (sourceInlineMasterKey) {
writeFileSync(input.targetKeyFilePath, sourceInlineMasterKey, {
encoding: "utf8",
mode: 0o600,
});
try {
chmodSync(input.targetKeyFilePath, 0o600);
} catch {
// best effort
}
return;
}
const sourceKeyFileOverride =
nonEmpty(input.sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY_FILE) ??
nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE);
const sourceConfiguredKeyPath = sourceKeyFileOverride ?? input.sourceConfig.secrets.localEncrypted.keyFilePath;
const sourceKeyFilePath = resolveRuntimeLikePath(sourceConfiguredKeyPath, input.sourceConfigPath);
if (!existsSync(sourceKeyFilePath)) {
throw new Error(
`Cannot seed worktree database because source local_encrypted secrets key was not found at ${sourceKeyFilePath}.`,
);
}
copyFileSync(sourceKeyFilePath, input.targetKeyFilePath);
try {
chmodSync(input.targetKeyFilePath, 0o600);
} catch {
// best effort
}
}
async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): Promise<EmbeddedPostgresHandle> {
const moduleName = "embedded-postgres";
let EmbeddedPostgres: EmbeddedPostgresCtor;
@@ -211,10 +378,16 @@ async function seedWorktreeDatabase(input: {
targetPaths: WorktreeLocalPaths;
instanceId: string;
seedMode: WorktreeSeedMode;
}): Promise<string> {
}): Promise<SeedWorktreeDatabaseResult> {
const seedPlan = resolveWorktreeSeedPlan(input.seedMode);
const sourceEnvFile = resolvePaperclipEnvFile(input.sourceConfigPath);
const sourceEnvEntries = readPaperclipEnvEntries(sourceEnvFile);
copySeededSecretsKey({
sourceConfigPath: input.sourceConfigPath,
sourceConfig: input.sourceConfig,
sourceEnvEntries,
targetKeyFilePath: input.targetPaths.secretsKeyFilePath,
});
let sourceHandle: EmbeddedPostgresHandle | null = null;
let targetHandle: EmbeddedPostgresHandle | null = null;
@@ -253,8 +426,15 @@ async function seedWorktreeDatabase(input: {
backupFile: backup.backupFile,
});
await applyPendingMigrations(targetConnectionString);
const reboundWorkspaces = await rebindSeededProjectWorkspaces({
targetConnectionString,
currentCwd: input.targetPaths.cwd,
});
return formatDatabaseBackupResult(backup);
return {
backupSummary: formatDatabaseBackupResult(backup),
reboundWorkspaces,
};
} finally {
if (targetHandle?.startedByThisProcess) {
await targetHandle.stop();
@@ -315,6 +495,7 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
loadPaperclipEnvFile(paths.configPath);
let seedSummary: string | null = null;
let reboundWorkspaceSummary: SeedWorktreeDatabaseResult["reboundWorkspaces"] = [];
if (opts.seed !== false) {
if (!sourceConfig) {
throw new Error(
@@ -324,7 +505,7 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
const spinner = p.spinner();
spinner.start(`Seeding isolated worktree database from source instance (${seedMode})...`);
try {
seedSummary = await seedWorktreeDatabase({
const seeded = await seedWorktreeDatabase({
sourceConfigPath,
sourceConfig,
targetConfig,
@@ -332,6 +513,8 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
instanceId,
seedMode,
});
seedSummary = seeded.backupSummary;
reboundWorkspaceSummary = seeded.reboundWorkspaces;
spinner.stop(`Seeded isolated worktree database (${seedMode}).`);
} catch (error) {
spinner.stop(pc.red("Failed to seed worktree database."));
@@ -347,6 +530,11 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
if (seedSummary) {
p.log.message(pc.dim(`Seed mode: ${seedMode}`));
p.log.message(pc.dim(`Seed snapshot: ${seedSummary}`));
for (const rebound of reboundWorkspaceSummary) {
p.log.message(
pc.dim(`Rebound workspace ${rebound.name}: ${rebound.fromCwd} -> ${rebound.toCwd}`),
);
}
}
p.outro(
pc.green(

View File

@@ -0,0 +1,110 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { approvalRoutes } from "../routes/approvals.js";
import { errorHandler } from "../middleware/index.js";
const mockApprovalService = vi.hoisted(() => ({
list: vi.fn(),
getById: vi.fn(),
create: vi.fn(),
approve: vi.fn(),
reject: vi.fn(),
requestRevision: vi.fn(),
resubmit: vi.fn(),
listComments: vi.fn(),
addComment: vi.fn(),
}));
const mockHeartbeatService = vi.hoisted(() => ({
wakeup: vi.fn(),
}));
const mockIssueApprovalService = vi.hoisted(() => ({
listIssuesForApproval: vi.fn(),
linkManyForApproval: vi.fn(),
}));
const mockSecretService = vi.hoisted(() => ({
normalizeHireApprovalPayloadForPersistence: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
approvalService: () => mockApprovalService,
heartbeatService: () => mockHeartbeatService,
issueApprovalService: () => mockIssueApprovalService,
logActivity: mockLogActivity,
secretService: () => mockSecretService,
}));
function createApp() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "user-1",
companyIds: ["company-1"],
source: "session",
isInstanceAdmin: false,
};
next();
});
app.use("/api", approvalRoutes({} as any));
app.use(errorHandler);
return app;
}
describe("approval routes idempotent retries", () => {
beforeEach(() => {
vi.clearAllMocks();
mockHeartbeatService.wakeup.mockResolvedValue({ id: "wake-1" });
mockIssueApprovalService.listIssuesForApproval.mockResolvedValue([{ id: "issue-1" }]);
mockLogActivity.mockResolvedValue(undefined);
});
it("does not emit duplicate approval side effects when approve is already resolved", async () => {
mockApprovalService.approve.mockResolvedValue({
approval: {
id: "approval-1",
companyId: "company-1",
type: "hire_agent",
status: "approved",
payload: {},
requestedByAgentId: "agent-1",
},
applied: false,
});
const res = await request(createApp())
.post("/api/approvals/approval-1/approve")
.send({});
expect(res.status).toBe(200);
expect(mockIssueApprovalService.listIssuesForApproval).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("does not emit duplicate rejection logs when reject is already resolved", async () => {
mockApprovalService.reject.mockResolvedValue({
approval: {
id: "approval-1",
companyId: "company-1",
type: "hire_agent",
status: "rejected",
payload: {},
},
applied: false,
});
const res = await request(createApp())
.post("/api/approvals/approval-1/reject")
.send({});
expect(res.status).toBe(200);
expect(mockLogActivity).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,110 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { approvalService } from "../services/approvals.js";
const mockAgentService = vi.hoisted(() => ({
activatePendingApproval: vi.fn(),
create: vi.fn(),
terminate: vi.fn(),
}));
const mockNotifyHireApproved = vi.hoisted(() => vi.fn());
vi.mock("../services/agents.js", () => ({
agentService: vi.fn(() => mockAgentService),
}));
vi.mock("../services/hire-hook.js", () => ({
notifyHireApproved: mockNotifyHireApproved,
}));
type ApprovalRecord = {
id: string;
companyId: string;
type: string;
status: string;
payload: Record<string, unknown>;
requestedByAgentId: string | null;
};
function createApproval(status: string): ApprovalRecord {
return {
id: "approval-1",
companyId: "company-1",
type: "hire_agent",
status,
payload: { agentId: "agent-1" },
requestedByAgentId: "requester-1",
};
}
function createDbStub(selectResults: ApprovalRecord[][], updateResults: ApprovalRecord[]) {
const selectWhere = vi.fn();
for (const result of selectResults) {
selectWhere.mockResolvedValueOnce(result);
}
const from = vi.fn(() => ({ where: selectWhere }));
const select = vi.fn(() => ({ from }));
const returning = vi.fn().mockResolvedValue(updateResults);
const updateWhere = vi.fn(() => ({ returning }));
const set = vi.fn(() => ({ where: updateWhere }));
const update = vi.fn(() => ({ set }));
return {
db: { select, update },
selectWhere,
returning,
};
}
describe("approvalService resolution idempotency", () => {
beforeEach(() => {
vi.clearAllMocks();
mockAgentService.activatePendingApproval.mockResolvedValue(undefined);
mockAgentService.create.mockResolvedValue({ id: "agent-1" });
mockAgentService.terminate.mockResolvedValue(undefined);
mockNotifyHireApproved.mockResolvedValue(undefined);
});
it("treats repeated approve retries as no-ops after another worker resolves the approval", async () => {
const dbStub = createDbStub(
[[createApproval("pending")], [createApproval("approved")]],
[],
);
const svc = approvalService(dbStub.db as any);
const result = await svc.approve("approval-1", "board", "ship it");
expect(result.applied).toBe(false);
expect(result.approval.status).toBe("approved");
expect(mockAgentService.activatePendingApproval).not.toHaveBeenCalled();
expect(mockNotifyHireApproved).not.toHaveBeenCalled();
});
it("treats repeated reject retries as no-ops after another worker resolves the approval", async () => {
const dbStub = createDbStub(
[[createApproval("pending")], [createApproval("rejected")]],
[],
);
const svc = approvalService(dbStub.db as any);
const result = await svc.reject("approval-1", "board", "not now");
expect(result.applied).toBe(false);
expect(result.approval.status).toBe("rejected");
expect(mockAgentService.terminate).not.toHaveBeenCalled();
});
it("still performs side effects when the resolution update is newly applied", async () => {
const approved = createApproval("approved");
const dbStub = createDbStub([[createApproval("pending")]], [approved]);
const svc = approvalService(dbStub.db as any);
const result = await svc.approve("approval-1", "board", "ship it");
expect(result.applied).toBe(true);
expect(mockAgentService.activatePendingApproval).toHaveBeenCalledWith("agent-1");
expect(mockNotifyHireApproved).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,97 @@
import { describe, it, expect } from "vitest";
import {
parseAllowedTypes,
matchesContentType,
DEFAULT_ALLOWED_TYPES,
} from "../attachment-types.js";
describe("parseAllowedTypes", () => {
it("returns default image types when input is undefined", () => {
expect(parseAllowedTypes(undefined)).toEqual([...DEFAULT_ALLOWED_TYPES]);
});
it("returns default image types when input is empty string", () => {
expect(parseAllowedTypes("")).toEqual([...DEFAULT_ALLOWED_TYPES]);
});
it("parses comma-separated types", () => {
expect(parseAllowedTypes("image/*,application/pdf")).toEqual([
"image/*",
"application/pdf",
]);
});
it("trims whitespace", () => {
expect(parseAllowedTypes(" image/png , application/pdf ")).toEqual([
"image/png",
"application/pdf",
]);
});
it("lowercases entries", () => {
expect(parseAllowedTypes("Application/PDF")).toEqual(["application/pdf"]);
});
it("filters empty segments", () => {
expect(parseAllowedTypes("image/png,,application/pdf,")).toEqual([
"image/png",
"application/pdf",
]);
});
});
describe("matchesContentType", () => {
it("matches exact types", () => {
const patterns = ["application/pdf", "image/png"];
expect(matchesContentType("application/pdf", patterns)).toBe(true);
expect(matchesContentType("image/png", patterns)).toBe(true);
expect(matchesContentType("text/plain", patterns)).toBe(false);
});
it("matches /* wildcard patterns", () => {
const patterns = ["image/*"];
expect(matchesContentType("image/png", patterns)).toBe(true);
expect(matchesContentType("image/jpeg", patterns)).toBe(true);
expect(matchesContentType("image/svg+xml", patterns)).toBe(true);
expect(matchesContentType("application/pdf", patterns)).toBe(false);
});
it("matches .* wildcard patterns", () => {
const patterns = ["application/vnd.openxmlformats-officedocument.*"];
expect(
matchesContentType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
patterns,
),
).toBe(true);
expect(
matchesContentType(
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
patterns,
),
).toBe(true);
expect(matchesContentType("application/pdf", patterns)).toBe(false);
});
it("is case-insensitive", () => {
const patterns = ["application/pdf"];
expect(matchesContentType("APPLICATION/PDF", patterns)).toBe(true);
expect(matchesContentType("Application/Pdf", patterns)).toBe(true);
});
it("combines exact and wildcard patterns", () => {
const patterns = ["image/*", "application/pdf", "text/*"];
expect(matchesContentType("image/webp", patterns)).toBe(true);
expect(matchesContentType("application/pdf", patterns)).toBe(true);
expect(matchesContentType("text/csv", patterns)).toBe(true);
expect(matchesContentType("application/zip", patterns)).toBe(false);
});
it("handles plain * as allow-all wildcard", () => {
const patterns = ["*"];
expect(matchesContentType("image/png", patterns)).toBe(true);
expect(matchesContentType("application/pdf", patterns)).toBe(true);
expect(matchesContentType("text/plain", patterns)).toBe(true);
expect(matchesContentType("application/zip", patterns)).toBe(true);
});
});

View File

@@ -0,0 +1,68 @@
/**
* Shared attachment content-type configuration.
*
* By default only image types are allowed. Set the
* `PAPERCLIP_ALLOWED_ATTACHMENT_TYPES` environment variable to a
* comma-separated list of MIME types or wildcard patterns to expand the
* allowed set.
*
* Examples:
* PAPERCLIP_ALLOWED_ATTACHMENT_TYPES=image/*,application/pdf
* PAPERCLIP_ALLOWED_ATTACHMENT_TYPES=image/*,application/pdf,text/*
*
* Supported pattern syntax:
* - Exact types: "application/pdf"
* - Wildcards: "image/*" or "application/vnd.openxmlformats-officedocument.*"
*/
export const DEFAULT_ALLOWED_TYPES: readonly string[] = [
"image/png",
"image/jpeg",
"image/jpg",
"image/webp",
"image/gif",
];
/**
* Parse a comma-separated list of MIME type patterns into a normalised array.
* Returns the default image-only list when the input is empty or undefined.
*/
export function parseAllowedTypes(raw: string | undefined): string[] {
if (!raw) return [...DEFAULT_ALLOWED_TYPES];
const parsed = raw
.split(",")
.map((s) => s.trim().toLowerCase())
.filter((s) => s.length > 0);
return parsed.length > 0 ? parsed : [...DEFAULT_ALLOWED_TYPES];
}
/**
* Check whether `contentType` matches any entry in `allowedPatterns`.
*
* Supports exact matches ("application/pdf") and wildcard / prefix
* patterns ("image/*", "application/vnd.openxmlformats-officedocument.*").
*/
export function matchesContentType(contentType: string, allowedPatterns: string[]): boolean {
const ct = contentType.toLowerCase();
return allowedPatterns.some((pattern) => {
if (pattern === "*") return true;
if (pattern.endsWith("/*") || pattern.endsWith(".*")) {
return ct.startsWith(pattern.slice(0, -1));
}
return ct === pattern;
});
}
// ---------- Module-level singletons read once at startup ----------
const allowedPatterns: string[] = parseAllowedTypes(
process.env.PAPERCLIP_ALLOWED_ATTACHMENT_TYPES,
);
/** Convenience wrapper using the process-level allowed list. */
export function isAllowedContentType(contentType: string): boolean {
return matchesContentType(contentType, allowedPatterns);
}
export const MAX_ATTACHMENT_BYTES =
Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;

View File

@@ -121,7 +121,13 @@ export function approvalRoutes(db: Db) {
router.post("/approvals/:id/approve", validate(resolveApprovalSchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const approval = await svc.approve(id, req.body.decidedByUserId ?? "board", req.body.decisionNote);
const { approval, applied } = await svc.approve(
id,
req.body.decidedByUserId ?? "board",
req.body.decisionNote,
);
if (applied) {
const linkedIssues = await issueApprovalsSvc.listIssuesForApproval(approval.id);
const linkedIssueIds = linkedIssues.map((issue) => issue.id);
const primaryIssueId = linkedIssueIds[0] ?? null;
@@ -202,6 +208,7 @@ export function approvalRoutes(db: Db) {
});
}
}
}
res.json(redactApprovalPayload(approval));
});
@@ -209,8 +216,13 @@ export function approvalRoutes(db: Db) {
router.post("/approvals/:id/reject", validate(resolveApprovalSchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const approval = await svc.reject(id, req.body.decidedByUserId ?? "board", req.body.decisionNote);
const { approval, applied } = await svc.reject(
id,
req.body.decidedByUserId ?? "board",
req.body.decisionNote,
);
if (applied) {
await logActivity(db, {
companyId: approval.companyId,
actorType: "user",
@@ -220,6 +232,7 @@ export function approvalRoutes(db: Db) {
entityId: approval.id,
details: { type: approval.type },
});
}
res.json(redactApprovalPayload(approval));
});

View File

@@ -5,22 +5,14 @@ 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";
const MAX_ASSET_IMAGE_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
const ALLOWED_IMAGE_CONTENT_TYPES = new Set([
"image/png",
"image/jpeg",
"image/jpg",
"image/webp",
"image/gif",
]);
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
export function assetRoutes(db: Db, storage: StorageService) {
const router = Router();
const svc = assetService(db);
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: MAX_ASSET_IMAGE_BYTES, files: 1 },
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
});
async function runSingleFileUpload(req: Request, res: Response) {
@@ -41,7 +33,7 @@ export function assetRoutes(db: Db, storage: StorageService) {
} catch (err) {
if (err instanceof multer.MulterError) {
if (err.code === "LIMIT_FILE_SIZE") {
res.status(422).json({ error: `Image exceeds ${MAX_ASSET_IMAGE_BYTES} bytes` });
res.status(422).json({ error: `File exceeds ${MAX_ATTACHMENT_BYTES} bytes` });
return;
}
res.status(400).json({ error: err.message });
@@ -57,8 +49,8 @@ export function assetRoutes(db: Db, storage: StorageService) {
}
const contentType = (file.mimetype || "").toLowerCase();
if (!ALLOWED_IMAGE_CONTENT_TYPES.has(contentType)) {
res.status(422).json({ error: `Unsupported image type: ${contentType || "unknown"}` });
if (!isAllowedContentType(contentType)) {
res.status(422).json({ error: `Unsupported file type: ${contentType || "unknown"}` });
return;
}
if (file.buffer.length <= 0) {

View File

@@ -26,15 +26,7 @@ import { logger } from "../middleware/logger.js";
import { forbidden, HttpError, unauthorized } from "../errors.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
const MAX_ATTACHMENT_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
const ALLOWED_ATTACHMENT_CONTENT_TYPES = new Set([
"image/png",
"image/jpeg",
"image/jpg",
"image/webp",
"image/gif",
]);
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
export function issueRoutes(db: Db, storage: StorageService) {
const router = Router();
@@ -230,6 +222,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
touchedByUserId,
unreadForUserId,
projectId: req.query.projectId as string | undefined,
parentId: req.query.parentId as string | undefined,
labelId: req.query.labelId as string | undefined,
q: req.query.q as string | undefined,
});
@@ -1067,7 +1060,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
return;
}
const contentType = (file.mimetype || "").toLowerCase();
if (!ALLOWED_ATTACHMENT_CONTENT_TYPES.has(contentType)) {
if (!isAllowedContentType(contentType)) {
res.status(422).json({ error: `Unsupported attachment type: ${contentType || "unknown"}` });
return;
}

View File

@@ -1,4 +1,4 @@
import { and, asc, eq } from "drizzle-orm";
import { and, asc, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { approvalComments, approvals } from "@paperclipai/db";
import { notFound, unprocessable } from "../errors.js";
@@ -8,6 +8,9 @@ import { notifyHireApproved } from "./hire-hook.js";
export function approvalService(db: Db) {
const agentsSvc = agentService(db);
const canResolveStatuses = new Set(["pending", "revision_requested"]);
const resolvableStatuses = Array.from(canResolveStatuses);
type ApprovalRecord = typeof approvals.$inferSelect;
type ResolutionResult = { approval: ApprovalRecord; applied: boolean };
async function getExistingApproval(id: string) {
const existing = await db
@@ -19,6 +22,50 @@ export function approvalService(db: Db) {
return existing;
}
async function resolveApproval(
id: string,
targetStatus: "approved" | "rejected",
decidedByUserId: string,
decisionNote: string | null | undefined,
): Promise<ResolutionResult> {
const existing = await getExistingApproval(id);
if (!canResolveStatuses.has(existing.status)) {
if (existing.status === targetStatus) {
return { approval: existing, applied: false };
}
throw unprocessable(
`Only pending or revision requested approvals can be ${targetStatus === "approved" ? "approved" : "rejected"}`,
);
}
const now = new Date();
const updated = await db
.update(approvals)
.set({
status: targetStatus,
decidedByUserId,
decisionNote: decisionNote ?? null,
decidedAt: now,
updatedAt: now,
})
.where(and(eq(approvals.id, id), inArray(approvals.status, resolvableStatuses)))
.returning()
.then((rows) => rows[0] ?? null);
if (updated) {
return { approval: updated, applied: true };
}
const latest = await getExistingApproval(id);
if (latest.status === targetStatus) {
return { approval: latest, applied: false };
}
throw unprocessable(
`Only pending or revision requested approvals can be ${targetStatus === "approved" ? "approved" : "rejected"}`,
);
}
return {
list: (companyId: string, status?: string) => {
const conditions = [eq(approvals.companyId, companyId)];
@@ -41,27 +88,16 @@ export function approvalService(db: Db) {
.then((rows) => rows[0]),
approve: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {
const existing = await getExistingApproval(id);
if (!canResolveStatuses.has(existing.status)) {
throw unprocessable("Only pending or revision requested approvals can be approved");
}
const now = new Date();
const updated = await db
.update(approvals)
.set({
status: "approved",
const { approval: updated, applied } = await resolveApproval(
id,
"approved",
decidedByUserId,
decisionNote: decisionNote ?? null,
decidedAt: now,
updatedAt: now,
})
.where(eq(approvals.id, id))
.returning()
.then((rows) => rows[0]);
decisionNote,
);
let hireApprovedAgentId: string | null = null;
if (updated.type === "hire_agent") {
const now = new Date();
if (applied && updated.type === "hire_agent") {
const payload = updated.payload as Record<string, unknown>;
const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null;
if (payloadAgentId) {
@@ -103,30 +139,18 @@ export function approvalService(db: Db) {
}
}
return updated;
return { approval: updated, applied };
},
reject: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {
const existing = await getExistingApproval(id);
if (!canResolveStatuses.has(existing.status)) {
throw unprocessable("Only pending or revision requested approvals can be rejected");
}
const now = new Date();
const updated = await db
.update(approvals)
.set({
status: "rejected",
const { approval: updated, applied } = await resolveApproval(
id,
"rejected",
decidedByUserId,
decisionNote: decisionNote ?? null,
decidedAt: now,
updatedAt: now,
})
.where(eq(approvals.id, id))
.returning()
.then((rows) => rows[0]);
decisionNote,
);
if (updated.type === "hire_agent") {
if (applied && updated.type === "hire_agent") {
const payload = updated.payload as Record<string, unknown>;
const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null;
if (payloadAgentId) {
@@ -134,7 +158,7 @@ export function approvalService(db: Db) {
}
}
return updated;
return { approval: updated, applied };
},
requestRevision: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {

View File

@@ -57,6 +57,7 @@ export interface IssueFilters {
touchedByUserId?: string;
unreadForUserId?: string;
projectId?: string;
parentId?: string;
labelId?: string;
q?: string;
}
@@ -462,6 +463,7 @@ export function issueService(db: Db) {
conditions.push(unreadForUserCondition(companyId, unreadForUserId));
}
if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId));
if (filters?.parentId) conditions.push(eq(issues.parentId, filters.parentId));
if (filters?.labelId) {
const labeledIssueIds = await db
.select({ issueId: issueLabels.issueId })