diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index 18bdabf1..ac316df2 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -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(); + }); }); diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index f9363ff8..4ef43572 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -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; }; +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 { + 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 }; + }; + + 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; + 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 { const moduleName = "embedded-postgres"; let EmbeddedPostgres: EmbeddedPostgresCtor; @@ -211,10 +378,16 @@ async function seedWorktreeDatabase(input: { targetPaths: WorktreeLocalPaths; instanceId: string; seedMode: WorktreeSeedMode; -}): Promise { +}): Promise { 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 ${rebound.toCwd}`), + ); + } } p.outro( pc.green( diff --git a/server/src/__tests__/approval-routes-idempotency.test.ts b/server/src/__tests__/approval-routes-idempotency.test.ts new file mode 100644 index 00000000..51181ff5 --- /dev/null +++ b/server/src/__tests__/approval-routes-idempotency.test.ts @@ -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(); + }); +}); diff --git a/server/src/__tests__/approvals-service.test.ts b/server/src/__tests__/approvals-service.test.ts new file mode 100644 index 00000000..967fd295 --- /dev/null +++ b/server/src/__tests__/approvals-service.test.ts @@ -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; + 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); + }); +}); diff --git a/server/src/__tests__/attachment-types.test.ts b/server/src/__tests__/attachment-types.test.ts new file mode 100644 index 00000000..5a430102 --- /dev/null +++ b/server/src/__tests__/attachment-types.test.ts @@ -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); + }); +}); diff --git a/server/src/attachment-types.ts b/server/src/attachment-types.ts new file mode 100644 index 00000000..f9625de1 --- /dev/null +++ b/server/src/attachment-types.ts @@ -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; diff --git a/server/src/routes/approvals.ts b/server/src/routes/approvals.ts index f9903435..99d33abd 100644 --- a/server/src/routes/approvals.ts +++ b/server/src/routes/approvals.ts @@ -121,85 +121,92 @@ 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 linkedIssues = await issueApprovalsSvc.listIssuesForApproval(approval.id); - const linkedIssueIds = linkedIssues.map((issue) => issue.id); - const primaryIssueId = linkedIssueIds[0] ?? null; + const { approval, applied } = await svc.approve( + id, + req.body.decidedByUserId ?? "board", + req.body.decisionNote, + ); - await logActivity(db, { - companyId: approval.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", - action: "approval.approved", - entityType: "approval", - entityId: approval.id, - details: { - type: approval.type, - requestedByAgentId: approval.requestedByAgentId, - linkedIssueIds, - }, - }); + if (applied) { + const linkedIssues = await issueApprovalsSvc.listIssuesForApproval(approval.id); + const linkedIssueIds = linkedIssues.map((issue) => issue.id); + const primaryIssueId = linkedIssueIds[0] ?? null; - if (approval.requestedByAgentId) { - try { - const wakeRun = await heartbeat.wakeup(approval.requestedByAgentId, { - source: "automation", - triggerDetail: "system", - reason: "approval_approved", - payload: { - approvalId: approval.id, - approvalStatus: approval.status, - issueId: primaryIssueId, - issueIds: linkedIssueIds, - }, - requestedByActorType: "user", - requestedByActorId: req.actor.userId ?? "board", - contextSnapshot: { - source: "approval.approved", - approvalId: approval.id, - approvalStatus: approval.status, - issueId: primaryIssueId, - issueIds: linkedIssueIds, - taskId: primaryIssueId, - wakeReason: "approval_approved", - }, - }); + await logActivity(db, { + companyId: approval.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "approval.approved", + entityType: "approval", + entityId: approval.id, + details: { + type: approval.type, + requestedByAgentId: approval.requestedByAgentId, + linkedIssueIds, + }, + }); - await logActivity(db, { - companyId: approval.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", - action: "approval.requester_wakeup_queued", - entityType: "approval", - entityId: approval.id, - details: { - requesterAgentId: approval.requestedByAgentId, - wakeRunId: wakeRun?.id ?? null, - linkedIssueIds, - }, - }); - } catch (err) { - logger.warn( - { - err, - approvalId: approval.id, - requestedByAgentId: approval.requestedByAgentId, - }, - "failed to queue requester wakeup after approval", - ); - await logActivity(db, { - companyId: approval.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", - action: "approval.requester_wakeup_failed", - entityType: "approval", - entityId: approval.id, - details: { - requesterAgentId: approval.requestedByAgentId, - linkedIssueIds, - error: err instanceof Error ? err.message : String(err), - }, - }); + if (approval.requestedByAgentId) { + try { + const wakeRun = await heartbeat.wakeup(approval.requestedByAgentId, { + source: "automation", + triggerDetail: "system", + reason: "approval_approved", + payload: { + approvalId: approval.id, + approvalStatus: approval.status, + issueId: primaryIssueId, + issueIds: linkedIssueIds, + }, + requestedByActorType: "user", + requestedByActorId: req.actor.userId ?? "board", + contextSnapshot: { + source: "approval.approved", + approvalId: approval.id, + approvalStatus: approval.status, + issueId: primaryIssueId, + issueIds: linkedIssueIds, + taskId: primaryIssueId, + wakeReason: "approval_approved", + }, + }); + + await logActivity(db, { + companyId: approval.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "approval.requester_wakeup_queued", + entityType: "approval", + entityId: approval.id, + details: { + requesterAgentId: approval.requestedByAgentId, + wakeRunId: wakeRun?.id ?? null, + linkedIssueIds, + }, + }); + } catch (err) { + logger.warn( + { + err, + approvalId: approval.id, + requestedByAgentId: approval.requestedByAgentId, + }, + "failed to queue requester wakeup after approval", + ); + await logActivity(db, { + companyId: approval.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "approval.requester_wakeup_failed", + entityType: "approval", + entityId: approval.id, + details: { + requesterAgentId: approval.requestedByAgentId, + linkedIssueIds, + error: err instanceof Error ? err.message : String(err), + }, + }); + } } } @@ -209,17 +216,23 @@ 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, + ); - await logActivity(db, { - companyId: approval.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", - action: "approval.rejected", - entityType: "approval", - entityId: approval.id, - details: { type: approval.type }, - }); + if (applied) { + await logActivity(db, { + companyId: approval.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "approval.rejected", + entityType: "approval", + entityId: approval.id, + details: { type: approval.type }, + }); + } res.json(redactApprovalPayload(approval)); }); diff --git a/server/src/routes/assets.ts b/server/src/routes/assets.ts index cde29ada..bd2f154d 100644 --- a/server/src/routes/assets.ts +++ b/server/src/routes/assets.ts @@ -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) { diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index e4035dfc..9c91fec4 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -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; } diff --git a/server/src/services/approvals.ts b/server/src/services/approvals.ts index ba2890a3..13e57c8a 100644 --- a/server/src/services/approvals.ts +++ b/server/src/services/approvals.ts @@ -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 { + 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", - decidedByUserId, - decisionNote: decisionNote ?? null, - decidedAt: now, - updatedAt: now, - }) - .where(eq(approvals.id, id)) - .returning() - .then((rows) => rows[0]); + const { approval: updated, applied } = await resolveApproval( + id, + "approved", + decidedByUserId, + 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; 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 { approval: updated, applied } = await resolveApproval( + id, + "rejected", + decidedByUserId, + decisionNote, + ); - const now = new Date(); - const updated = await db - .update(approvals) - .set({ - status: "rejected", - decidedByUserId, - decisionNote: decisionNote ?? null, - decidedAt: now, - updatedAt: now, - }) - .where(eq(approvals.id, id)) - .returning() - .then((rows) => rows[0]); - - if (updated.type === "hire_agent") { + if (applied && updated.type === "hire_agent") { const payload = updated.payload as Record; 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) => { diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index bc05db69..f875ea53 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -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 })