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:
@@ -1,5 +1,8 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { copySeededSecretsKey, rebindWorkspaceCwd } from "../commands/worktree.js";
|
||||||
import {
|
import {
|
||||||
buildWorktreeConfig,
|
buildWorktreeConfig,
|
||||||
buildWorktreeEnvEntries,
|
buildWorktreeEnvEntries,
|
||||||
@@ -122,4 +125,78 @@ describe("worktree helpers", () => {
|
|||||||
expect(full.excludedTables).toEqual([]);
|
expect(full.excludedTables).toEqual([]);
|
||||||
expect(full.nullifyColumns).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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { createServer } from "node:net";
|
import { createServer } from "node:net";
|
||||||
import * as p from "@clack/prompts";
|
import * as p from "@clack/prompts";
|
||||||
import pc from "picocolors";
|
import pc from "picocolors";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
applyPendingMigrations,
|
applyPendingMigrations,
|
||||||
|
createDb,
|
||||||
ensurePostgresDatabase,
|
ensurePostgresDatabase,
|
||||||
formatDatabaseBackupResult,
|
formatDatabaseBackupResult,
|
||||||
|
projectWorkspaces,
|
||||||
runDatabaseBackup,
|
runDatabaseBackup,
|
||||||
runDatabaseRestore,
|
runDatabaseRestore,
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
@@ -18,6 +21,7 @@ import { expandHomePrefix } from "../config/home.js";
|
|||||||
import type { PaperclipConfig } from "../config/schema.js";
|
import type { PaperclipConfig } from "../config/schema.js";
|
||||||
import { readConfig, resolveConfigPath, writeConfig } from "../config/store.js";
|
import { readConfig, resolveConfigPath, writeConfig } from "../config/store.js";
|
||||||
import { printPaperclipCliBanner } from "../utils/banner.js";
|
import { printPaperclipCliBanner } from "../utils/banner.js";
|
||||||
|
import { resolveRuntimeLikePath } from "../utils/path-resolver.js";
|
||||||
import {
|
import {
|
||||||
buildWorktreeConfig,
|
buildWorktreeConfig,
|
||||||
buildWorktreeEnvEntries,
|
buildWorktreeEnvEntries,
|
||||||
@@ -73,6 +77,20 @@ type EmbeddedPostgresHandle = {
|
|||||||
stop: () => Promise<void>;
|
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 {
|
function nonEmpty(value: string | null | undefined): string | null {
|
||||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : 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 {
|
function resolveSourceConfigPath(opts: WorktreeInitOptions): string {
|
||||||
if (opts.fromConfig) return path.resolve(opts.fromConfig);
|
if (opts.fromConfig) return path.resolve(opts.fromConfig);
|
||||||
const sourceHome = path.resolve(expandHomePrefix(opts.fromDataDir ?? "~/.paperclip"));
|
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`;
|
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> {
|
async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): Promise<EmbeddedPostgresHandle> {
|
||||||
const moduleName = "embedded-postgres";
|
const moduleName = "embedded-postgres";
|
||||||
let EmbeddedPostgres: EmbeddedPostgresCtor;
|
let EmbeddedPostgres: EmbeddedPostgresCtor;
|
||||||
@@ -211,10 +378,16 @@ async function seedWorktreeDatabase(input: {
|
|||||||
targetPaths: WorktreeLocalPaths;
|
targetPaths: WorktreeLocalPaths;
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
seedMode: WorktreeSeedMode;
|
seedMode: WorktreeSeedMode;
|
||||||
}): Promise<string> {
|
}): Promise<SeedWorktreeDatabaseResult> {
|
||||||
const seedPlan = resolveWorktreeSeedPlan(input.seedMode);
|
const seedPlan = resolveWorktreeSeedPlan(input.seedMode);
|
||||||
const sourceEnvFile = resolvePaperclipEnvFile(input.sourceConfigPath);
|
const sourceEnvFile = resolvePaperclipEnvFile(input.sourceConfigPath);
|
||||||
const sourceEnvEntries = readPaperclipEnvEntries(sourceEnvFile);
|
const sourceEnvEntries = readPaperclipEnvEntries(sourceEnvFile);
|
||||||
|
copySeededSecretsKey({
|
||||||
|
sourceConfigPath: input.sourceConfigPath,
|
||||||
|
sourceConfig: input.sourceConfig,
|
||||||
|
sourceEnvEntries,
|
||||||
|
targetKeyFilePath: input.targetPaths.secretsKeyFilePath,
|
||||||
|
});
|
||||||
let sourceHandle: EmbeddedPostgresHandle | null = null;
|
let sourceHandle: EmbeddedPostgresHandle | null = null;
|
||||||
let targetHandle: EmbeddedPostgresHandle | null = null;
|
let targetHandle: EmbeddedPostgresHandle | null = null;
|
||||||
|
|
||||||
@@ -253,8 +426,15 @@ async function seedWorktreeDatabase(input: {
|
|||||||
backupFile: backup.backupFile,
|
backupFile: backup.backupFile,
|
||||||
});
|
});
|
||||||
await applyPendingMigrations(targetConnectionString);
|
await applyPendingMigrations(targetConnectionString);
|
||||||
|
const reboundWorkspaces = await rebindSeededProjectWorkspaces({
|
||||||
|
targetConnectionString,
|
||||||
|
currentCwd: input.targetPaths.cwd,
|
||||||
|
});
|
||||||
|
|
||||||
return formatDatabaseBackupResult(backup);
|
return {
|
||||||
|
backupSummary: formatDatabaseBackupResult(backup),
|
||||||
|
reboundWorkspaces,
|
||||||
|
};
|
||||||
} finally {
|
} finally {
|
||||||
if (targetHandle?.startedByThisProcess) {
|
if (targetHandle?.startedByThisProcess) {
|
||||||
await targetHandle.stop();
|
await targetHandle.stop();
|
||||||
@@ -315,6 +495,7 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
|
|||||||
loadPaperclipEnvFile(paths.configPath);
|
loadPaperclipEnvFile(paths.configPath);
|
||||||
|
|
||||||
let seedSummary: string | null = null;
|
let seedSummary: string | null = null;
|
||||||
|
let reboundWorkspaceSummary: SeedWorktreeDatabaseResult["reboundWorkspaces"] = [];
|
||||||
if (opts.seed !== false) {
|
if (opts.seed !== false) {
|
||||||
if (!sourceConfig) {
|
if (!sourceConfig) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -324,7 +505,7 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
|
|||||||
const spinner = p.spinner();
|
const spinner = p.spinner();
|
||||||
spinner.start(`Seeding isolated worktree database from source instance (${seedMode})...`);
|
spinner.start(`Seeding isolated worktree database from source instance (${seedMode})...`);
|
||||||
try {
|
try {
|
||||||
seedSummary = await seedWorktreeDatabase({
|
const seeded = await seedWorktreeDatabase({
|
||||||
sourceConfigPath,
|
sourceConfigPath,
|
||||||
sourceConfig,
|
sourceConfig,
|
||||||
targetConfig,
|
targetConfig,
|
||||||
@@ -332,6 +513,8 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
|
|||||||
instanceId,
|
instanceId,
|
||||||
seedMode,
|
seedMode,
|
||||||
});
|
});
|
||||||
|
seedSummary = seeded.backupSummary;
|
||||||
|
reboundWorkspaceSummary = seeded.reboundWorkspaces;
|
||||||
spinner.stop(`Seeded isolated worktree database (${seedMode}).`);
|
spinner.stop(`Seeded isolated worktree database (${seedMode}).`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
spinner.stop(pc.red("Failed to seed worktree database."));
|
spinner.stop(pc.red("Failed to seed worktree database."));
|
||||||
@@ -347,6 +530,11 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
|
|||||||
if (seedSummary) {
|
if (seedSummary) {
|
||||||
p.log.message(pc.dim(`Seed mode: ${seedMode}`));
|
p.log.message(pc.dim(`Seed mode: ${seedMode}`));
|
||||||
p.log.message(pc.dim(`Seed snapshot: ${seedSummary}`));
|
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(
|
p.outro(
|
||||||
pc.green(
|
pc.green(
|
||||||
|
|||||||
110
server/src/__tests__/approval-routes-idempotency.test.ts
Normal file
110
server/src/__tests__/approval-routes-idempotency.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
110
server/src/__tests__/approvals-service.test.ts
Normal file
110
server/src/__tests__/approvals-service.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
97
server/src/__tests__/attachment-types.test.ts
Normal file
97
server/src/__tests__/attachment-types.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
68
server/src/attachment-types.ts
Normal file
68
server/src/attachment-types.ts
Normal 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;
|
||||||
@@ -121,85 +121,92 @@ export function approvalRoutes(db: Db) {
|
|||||||
router.post("/approvals/:id/approve", validate(resolveApprovalSchema), async (req, res) => {
|
router.post("/approvals/:id/approve", validate(resolveApprovalSchema), async (req, res) => {
|
||||||
assertBoard(req);
|
assertBoard(req);
|
||||||
const id = req.params.id as string;
|
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(
|
||||||
const linkedIssues = await issueApprovalsSvc.listIssuesForApproval(approval.id);
|
id,
|
||||||
const linkedIssueIds = linkedIssues.map((issue) => issue.id);
|
req.body.decidedByUserId ?? "board",
|
||||||
const primaryIssueId = linkedIssueIds[0] ?? null;
|
req.body.decisionNote,
|
||||||
|
);
|
||||||
|
|
||||||
await logActivity(db, {
|
if (applied) {
|
||||||
companyId: approval.companyId,
|
const linkedIssues = await issueApprovalsSvc.listIssuesForApproval(approval.id);
|
||||||
actorType: "user",
|
const linkedIssueIds = linkedIssues.map((issue) => issue.id);
|
||||||
actorId: req.actor.userId ?? "board",
|
const primaryIssueId = linkedIssueIds[0] ?? null;
|
||||||
action: "approval.approved",
|
|
||||||
entityType: "approval",
|
|
||||||
entityId: approval.id,
|
|
||||||
details: {
|
|
||||||
type: approval.type,
|
|
||||||
requestedByAgentId: approval.requestedByAgentId,
|
|
||||||
linkedIssueIds,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (approval.requestedByAgentId) {
|
await logActivity(db, {
|
||||||
try {
|
companyId: approval.companyId,
|
||||||
const wakeRun = await heartbeat.wakeup(approval.requestedByAgentId, {
|
actorType: "user",
|
||||||
source: "automation",
|
actorId: req.actor.userId ?? "board",
|
||||||
triggerDetail: "system",
|
action: "approval.approved",
|
||||||
reason: "approval_approved",
|
entityType: "approval",
|
||||||
payload: {
|
entityId: approval.id,
|
||||||
approvalId: approval.id,
|
details: {
|
||||||
approvalStatus: approval.status,
|
type: approval.type,
|
||||||
issueId: primaryIssueId,
|
requestedByAgentId: approval.requestedByAgentId,
|
||||||
issueIds: linkedIssueIds,
|
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, {
|
if (approval.requestedByAgentId) {
|
||||||
companyId: approval.companyId,
|
try {
|
||||||
actorType: "user",
|
const wakeRun = await heartbeat.wakeup(approval.requestedByAgentId, {
|
||||||
actorId: req.actor.userId ?? "board",
|
source: "automation",
|
||||||
action: "approval.requester_wakeup_queued",
|
triggerDetail: "system",
|
||||||
entityType: "approval",
|
reason: "approval_approved",
|
||||||
entityId: approval.id,
|
payload: {
|
||||||
details: {
|
approvalId: approval.id,
|
||||||
requesterAgentId: approval.requestedByAgentId,
|
approvalStatus: approval.status,
|
||||||
wakeRunId: wakeRun?.id ?? null,
|
issueId: primaryIssueId,
|
||||||
linkedIssueIds,
|
issueIds: linkedIssueIds,
|
||||||
},
|
},
|
||||||
});
|
requestedByActorType: "user",
|
||||||
} catch (err) {
|
requestedByActorId: req.actor.userId ?? "board",
|
||||||
logger.warn(
|
contextSnapshot: {
|
||||||
{
|
source: "approval.approved",
|
||||||
err,
|
approvalId: approval.id,
|
||||||
approvalId: approval.id,
|
approvalStatus: approval.status,
|
||||||
requestedByAgentId: approval.requestedByAgentId,
|
issueId: primaryIssueId,
|
||||||
},
|
issueIds: linkedIssueIds,
|
||||||
"failed to queue requester wakeup after approval",
|
taskId: primaryIssueId,
|
||||||
);
|
wakeReason: "approval_approved",
|
||||||
await logActivity(db, {
|
},
|
||||||
companyId: approval.companyId,
|
});
|
||||||
actorType: "user",
|
|
||||||
actorId: req.actor.userId ?? "board",
|
await logActivity(db, {
|
||||||
action: "approval.requester_wakeup_failed",
|
companyId: approval.companyId,
|
||||||
entityType: "approval",
|
actorType: "user",
|
||||||
entityId: approval.id,
|
actorId: req.actor.userId ?? "board",
|
||||||
details: {
|
action: "approval.requester_wakeup_queued",
|
||||||
requesterAgentId: approval.requestedByAgentId,
|
entityType: "approval",
|
||||||
linkedIssueIds,
|
entityId: approval.id,
|
||||||
error: err instanceof Error ? err.message : String(err),
|
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) => {
|
router.post("/approvals/:id/reject", validate(resolveApprovalSchema), async (req, res) => {
|
||||||
assertBoard(req);
|
assertBoard(req);
|
||||||
const id = req.params.id as string;
|
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, {
|
if (applied) {
|
||||||
companyId: approval.companyId,
|
await logActivity(db, {
|
||||||
actorType: "user",
|
companyId: approval.companyId,
|
||||||
actorId: req.actor.userId ?? "board",
|
actorType: "user",
|
||||||
action: "approval.rejected",
|
actorId: req.actor.userId ?? "board",
|
||||||
entityType: "approval",
|
action: "approval.rejected",
|
||||||
entityId: approval.id,
|
entityType: "approval",
|
||||||
details: { type: approval.type },
|
entityId: approval.id,
|
||||||
});
|
details: { type: approval.type },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.json(redactApprovalPayload(approval));
|
res.json(redactApprovalPayload(approval));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,22 +5,14 @@ import { createAssetImageMetadataSchema } from "@paperclipai/shared";
|
|||||||
import type { StorageService } from "../storage/types.js";
|
import type { StorageService } from "../storage/types.js";
|
||||||
import { assetService, logActivity } from "../services/index.js";
|
import { assetService, logActivity } from "../services/index.js";
|
||||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||||
|
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.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",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export function assetRoutes(db: Db, storage: StorageService) {
|
export function assetRoutes(db: Db, storage: StorageService) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const svc = assetService(db);
|
const svc = assetService(db);
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage: multer.memoryStorage(),
|
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) {
|
async function runSingleFileUpload(req: Request, res: Response) {
|
||||||
@@ -41,7 +33,7 @@ export function assetRoutes(db: Db, storage: StorageService) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof multer.MulterError) {
|
if (err instanceof multer.MulterError) {
|
||||||
if (err.code === "LIMIT_FILE_SIZE") {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
res.status(400).json({ error: err.message });
|
res.status(400).json({ error: err.message });
|
||||||
@@ -57,8 +49,8 @@ export function assetRoutes(db: Db, storage: StorageService) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const contentType = (file.mimetype || "").toLowerCase();
|
const contentType = (file.mimetype || "").toLowerCase();
|
||||||
if (!ALLOWED_IMAGE_CONTENT_TYPES.has(contentType)) {
|
if (!isAllowedContentType(contentType)) {
|
||||||
res.status(422).json({ error: `Unsupported image type: ${contentType || "unknown"}` });
|
res.status(422).json({ error: `Unsupported file type: ${contentType || "unknown"}` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (file.buffer.length <= 0) {
|
if (file.buffer.length <= 0) {
|
||||||
|
|||||||
@@ -26,15 +26,7 @@ import { logger } from "../middleware/logger.js";
|
|||||||
import { forbidden, HttpError, unauthorized } from "../errors.js";
|
import { forbidden, HttpError, unauthorized } from "../errors.js";
|
||||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||||
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
|
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
|
||||||
|
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.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",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export function issueRoutes(db: Db, storage: StorageService) {
|
export function issueRoutes(db: Db, storage: StorageService) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -230,6 +222,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||||||
touchedByUserId,
|
touchedByUserId,
|
||||||
unreadForUserId,
|
unreadForUserId,
|
||||||
projectId: req.query.projectId as string | undefined,
|
projectId: req.query.projectId as string | undefined,
|
||||||
|
parentId: req.query.parentId as string | undefined,
|
||||||
labelId: req.query.labelId as string | undefined,
|
labelId: req.query.labelId as string | undefined,
|
||||||
q: req.query.q as string | undefined,
|
q: req.query.q as string | undefined,
|
||||||
});
|
});
|
||||||
@@ -1067,7 +1060,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const contentType = (file.mimetype || "").toLowerCase();
|
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"}` });
|
res.status(422).json({ error: `Unsupported attachment type: ${contentType || "unknown"}` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 type { Db } from "@paperclipai/db";
|
||||||
import { approvalComments, approvals } from "@paperclipai/db";
|
import { approvalComments, approvals } from "@paperclipai/db";
|
||||||
import { notFound, unprocessable } from "../errors.js";
|
import { notFound, unprocessable } from "../errors.js";
|
||||||
@@ -8,6 +8,9 @@ import { notifyHireApproved } from "./hire-hook.js";
|
|||||||
export function approvalService(db: Db) {
|
export function approvalService(db: Db) {
|
||||||
const agentsSvc = agentService(db);
|
const agentsSvc = agentService(db);
|
||||||
const canResolveStatuses = new Set(["pending", "revision_requested"]);
|
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) {
|
async function getExistingApproval(id: string) {
|
||||||
const existing = await db
|
const existing = await db
|
||||||
@@ -19,6 +22,50 @@ export function approvalService(db: Db) {
|
|||||||
return existing;
|
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 {
|
return {
|
||||||
list: (companyId: string, status?: string) => {
|
list: (companyId: string, status?: string) => {
|
||||||
const conditions = [eq(approvals.companyId, companyId)];
|
const conditions = [eq(approvals.companyId, companyId)];
|
||||||
@@ -41,27 +88,16 @@ export function approvalService(db: Db) {
|
|||||||
.then((rows) => rows[0]),
|
.then((rows) => rows[0]),
|
||||||
|
|
||||||
approve: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {
|
approve: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {
|
||||||
const existing = await getExistingApproval(id);
|
const { approval: updated, applied } = await resolveApproval(
|
||||||
if (!canResolveStatuses.has(existing.status)) {
|
id,
|
||||||
throw unprocessable("Only pending or revision requested approvals can be approved");
|
"approved",
|
||||||
}
|
decidedByUserId,
|
||||||
|
decisionNote,
|
||||||
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]);
|
|
||||||
|
|
||||||
let hireApprovedAgentId: string | null = null;
|
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 payload = updated.payload as Record<string, unknown>;
|
||||||
const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null;
|
const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null;
|
||||||
if (payloadAgentId) {
|
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) => {
|
reject: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {
|
||||||
const existing = await getExistingApproval(id);
|
const { approval: updated, applied } = await resolveApproval(
|
||||||
if (!canResolveStatuses.has(existing.status)) {
|
id,
|
||||||
throw unprocessable("Only pending or revision requested approvals can be rejected");
|
"rejected",
|
||||||
}
|
decidedByUserId,
|
||||||
|
decisionNote,
|
||||||
|
);
|
||||||
|
|
||||||
const now = new Date();
|
if (applied && updated.type === "hire_agent") {
|
||||||
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") {
|
|
||||||
const payload = updated.payload as Record<string, unknown>;
|
const payload = updated.payload as Record<string, unknown>;
|
||||||
const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null;
|
const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null;
|
||||||
if (payloadAgentId) {
|
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) => {
|
requestRevision: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export interface IssueFilters {
|
|||||||
touchedByUserId?: string;
|
touchedByUserId?: string;
|
||||||
unreadForUserId?: string;
|
unreadForUserId?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
parentId?: string;
|
||||||
labelId?: string;
|
labelId?: string;
|
||||||
q?: string;
|
q?: string;
|
||||||
}
|
}
|
||||||
@@ -462,6 +463,7 @@ export function issueService(db: Db) {
|
|||||||
conditions.push(unreadForUserCondition(companyId, unreadForUserId));
|
conditions.push(unreadForUserCondition(companyId, unreadForUserId));
|
||||||
}
|
}
|
||||||
if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId));
|
if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId));
|
||||||
|
if (filters?.parentId) conditions.push(eq(issues.parentId, filters.parentId));
|
||||||
if (filters?.labelId) {
|
if (filters?.labelId) {
|
||||||
const labeledIssueIds = await db
|
const labeledIssueIds = await db
|
||||||
.select({ issueId: issueLabels.issueId })
|
.select({ issueId: issueLabels.issueId })
|
||||||
|
|||||||
Reference in New Issue
Block a user