From cc44d309c0a5f5bbc6d718d901f6e9ab27bae34d Mon Sep 17 00:00:00 2001 From: Aron Prins Date: Tue, 7 Apr 2026 09:41:13 +0200 Subject: [PATCH 1/6] feat(backups): gzip compress backups and add retention config to Instance Settings Compress database backups with gzip (.sql.gz), reducing file size ~83%. Add backup retention configuration to Instance Settings UI with preset options (7 days, 2 weeks, 1 month). The backup scheduler now reads retention from the database on each tick so changes take effect without restart. Default retention changed from 30 to 7 days. --- packages/db/src/backup-lib.test.ts | 4 +- packages/db/src/backup-lib.ts | 24 +++++++++-- packages/shared/src/config-schema.ts | 4 +- packages/shared/src/index.ts | 6 +++ packages/shared/src/types/index.ts | 3 +- packages/shared/src/types/instance.ts | 5 +++ packages/shared/src/validators/instance.ts | 8 ++++ server/src/config.ts | 2 +- server/src/index.ts | 16 ++++--- server/src/services/instance-settings.ts | 3 ++ ui/src/pages/InstanceGeneralSettings.tsx | 49 +++++++++++++++++++++- 11 files changed, 107 insertions(+), 17 deletions(-) diff --git a/packages/db/src/backup-lib.test.ts b/packages/db/src/backup-lib.test.ts index dcdc87c50e4..2367d26d54e 100644 --- a/packages/db/src/backup-lib.test.ts +++ b/packages/db/src/backup-lib.test.ts @@ -129,8 +129,8 @@ describeEmbeddedPostgres("runDatabaseBackup", () => { filenamePrefix: "paperclip-test", }); - expect(result.backupFile).toMatch(/paperclip-test-.*\.sql$/); - expect(result.sizeBytes).toBeGreaterThan(1024 * 1024); + expect(result.backupFile).toMatch(/paperclip-test-.*\.sql\.gz$/); + expect(result.sizeBytes).toBeGreaterThan(0); expect(fs.existsSync(result.backupFile)).toBe(true); await runDatabaseRestore({ diff --git a/packages/db/src/backup-lib.ts b/packages/db/src/backup-lib.ts index ea76a2b6d3c..36b5ee9805b 100644 --- a/packages/db/src/backup-lib.ts +++ b/packages/db/src/backup-lib.ts @@ -1,6 +1,8 @@ import { createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs"; import { basename, resolve } from "node:path"; import { createInterface } from "node:readline"; +import { pipeline } from "node:stream/promises"; +import { createGunzip, createGzip } from "node:zlib"; import postgres from "postgres"; export type RunDatabaseBackupOptions = { @@ -82,7 +84,8 @@ function pruneOldBackups(backupDir: string, retentionDays: number, filenamePrefi let pruned = 0; for (const name of readdirSync(backupDir)) { - if (!name.startsWith(`${filenamePrefix}-`) || !name.endsWith(".sql")) continue; + if (!name.startsWith(`${filenamePrefix}-`)) continue; + if (!name.endsWith(".sql") && !name.endsWith(".sql.gz")) continue; const fullPath = resolve(backupDir, name); const stat = statSync(fullPath); if (stat.mtimeMs < cutoff) { @@ -148,7 +151,9 @@ function tableKey(schemaName: string, tableName: string): string { } async function* readRestoreStatements(backupFile: string): AsyncGenerator { - const stream = createReadStream(backupFile, { encoding: "utf8" }); + const raw = createReadStream(backupFile); + const stream = backupFile.endsWith(".gz") ? raw.pipe(createGunzip()) : raw; + stream.setEncoding("utf8"); const reader = createInterface({ input: stream, crlfDelay: Infinity, @@ -180,6 +185,7 @@ async function* readRestoreStatements(backupFile: string): AsyncGenerator + (BACKUP_RETENTION_PRESETS as readonly number[]).includes(v), + { message: `Must be one of: ${BACKUP_RETENTION_PRESETS.join(", ")}` }, +); + export const instanceGeneralSettingsSchema = z.object({ censorUsernameInLogs: z.boolean().default(false), keyboardShortcuts: z.boolean().default(false), feedbackDataSharingPreference: feedbackDataSharingPreferenceSchema.default( DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, ), + backupRetentionDays: backupRetentionDaysSchema.default(DEFAULT_BACKUP_RETENTION_DAYS), }).strict(); export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema.partial(); diff --git a/server/src/config.ts b/server/src/config.ts index 71084cc097d..5ae50400efc 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -216,7 +216,7 @@ export function loadConfig(): Config { 1, Number(process.env.PAPERCLIP_DB_BACKUP_RETENTION_DAYS) || fileDatabaseBackup?.retentionDays || - 30, + 7, ); const databaseBackupDir = resolveHomeAwarePath( process.env.PAPERCLIP_DB_BACKUP_DIR ?? diff --git a/server/src/index.ts b/server/src/index.ts index a384342f5e5..768d1fd6a63 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -31,6 +31,7 @@ import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js"; import { feedbackService, heartbeatService, + instanceSettingsService, reconcilePersistedRuntimeServicesOnStartup, routineService, } from "./services/index.js"; @@ -628,20 +629,25 @@ export async function startServer(): Promise { if (config.databaseBackupEnabled) { const backupIntervalMs = config.databaseBackupIntervalMinutes * 60 * 1000; + const settingsSvc = instanceSettingsService(db); let backupInFlight = false; - + const runScheduledBackup = async () => { if (backupInFlight) { logger.warn("Skipping scheduled database backup because a previous backup is still running"); return; } - + backupInFlight = true; try { + // Read retention from Instance Settings (DB) so changes take effect without restart + const generalSettings = await settingsSvc.getGeneral(); + const retentionDays = generalSettings.backupRetentionDays; + const result = await runDatabaseBackup({ connectionString: activeDatabaseConnectionString, backupDir: config.databaseBackupDir, - retentionDays: config.databaseBackupRetentionDays, + retentionDays, filenamePrefix: "paperclip", }); logger.info( @@ -650,7 +656,7 @@ export async function startServer(): Promise { sizeBytes: result.sizeBytes, prunedCount: result.prunedCount, backupDir: config.databaseBackupDir, - retentionDays: config.databaseBackupRetentionDays, + retentionDays, }, `Automatic database backup complete: ${formatDatabaseBackupResult(result)}`, ); @@ -660,7 +666,7 @@ export async function startServer(): Promise { backupInFlight = false; } }; - + logger.info( { intervalMinutes: config.databaseBackupIntervalMinutes, diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 7856591d689..5c7db61dfb0 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -2,6 +2,7 @@ import type { Db } from "@paperclipai/db"; import { companies, instanceSettings } from "@paperclipai/db"; import { DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, + DEFAULT_BACKUP_RETENTION_DAYS, instanceGeneralSettingsSchema, type InstanceGeneralSettings, instanceExperimentalSettingsSchema, @@ -22,12 +23,14 @@ function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings { keyboardShortcuts: parsed.data.keyboardShortcuts ?? false, feedbackDataSharingPreference: parsed.data.feedbackDataSharingPreference ?? DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, + backupRetentionDays: parsed.data.backupRetentionDays ?? DEFAULT_BACKUP_RETENTION_DAYS, }; } return { censorUsernameInLogs: false, keyboardShortcuts: false, feedbackDataSharingPreference: DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, + backupRetentionDays: DEFAULT_BACKUP_RETENTION_DAYS, }; } diff --git a/ui/src/pages/InstanceGeneralSettings.tsx b/ui/src/pages/InstanceGeneralSettings.tsx index 28e00b29f05..9004e418bc2 100644 --- a/ui/src/pages/InstanceGeneralSettings.tsx +++ b/ui/src/pages/InstanceGeneralSettings.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import type { PatchInstanceGeneralSettings } from "@paperclipai/shared"; -import { LogOut, SlidersHorizontal } from "lucide-react"; +import type { PatchInstanceGeneralSettings, BackupRetentionDays } from "@paperclipai/shared"; +import { BACKUP_RETENTION_PRESETS, DEFAULT_BACKUP_RETENTION_DAYS } from "@paperclipai/shared"; +import { Database, LogOut, SlidersHorizontal } from "lucide-react"; import { authApi } from "@/api/auth"; import { instanceSettingsApi } from "@/api/instanceSettings"; import { Button } from "../components/ui/button"; @@ -67,6 +68,7 @@ export function InstanceGeneralSettings() { const censorUsernameInLogs = generalQuery.data?.censorUsernameInLogs === true; const keyboardShortcuts = generalQuery.data?.keyboardShortcuts === true; const feedbackDataSharingPreference = generalQuery.data?.feedbackDataSharingPreference ?? "prompt"; + const backupRetentionDays: BackupRetentionDays = generalQuery.data?.backupRetentionDays ?? DEFAULT_BACKUP_RETENTION_DAYS; return (
@@ -123,6 +125,49 @@ export function InstanceGeneralSettings() {
+
+
+
+ +
+

Backup retention

+

+ How long to keep automatic database backups before pruning. Backups are compressed + with gzip to minimize disk usage. +

+
+
+
+ {BACKUP_RETENTION_PRESETS.map((days) => { + const active = backupRetentionDays === days; + const label = + days === 7 ? "7 days" : days === 14 ? "2 weeks" : "1 month"; + return ( + + ); + })} +
+
+
+
From fcbae62baf1e821e2b65f64545381defccda3230 Mon Sep 17 00:00:00 2001 From: Aron Prins Date: Tue, 7 Apr 2026 09:54:39 +0200 Subject: [PATCH 2/6] feat(backups): tiered daily/weekly/monthly retention with UI controls Replace single retentionDays with a three-tier BackupRetentionPolicy: - Daily: keep all backups (presets: 3, 7, 14 days; default 7) - Weekly: keep one per calendar week (presets: 1, 2, 4 weeks; default 4) - Monthly: keep one per calendar month (presets: 1, 3, 6 months; default 1) Pruning sorts backups newest-first and applies each tier's cutoff, keeping only the newest entry per ISO week/month bucket. The Instance Settings General page now shows three preset selectors (no icon, matches existing page design). Remove Database icon import. --- cli/src/commands/db-backup.ts | 2 +- cli/src/commands/worktree.ts | 2 +- packages/db/src/backup-lib.test.ts | 2 +- packages/db/src/backup-lib.ts | 97 ++++++++++++-- packages/db/src/backup.ts | 4 +- packages/db/src/index.ts | 1 + packages/shared/src/index.ts | 8 +- packages/shared/src/types/index.ts | 4 +- packages/shared/src/types/instance.ts | 20 ++- packages/shared/src/validators/instance.ts | 26 +++- server/src/index.ts | 6 +- server/src/services/instance-settings.ts | 6 +- ui/src/pages/InstanceGeneralSettings.tsx | 144 +++++++++++++++------ 13 files changed, 243 insertions(+), 79 deletions(-) diff --git a/cli/src/commands/db-backup.ts b/cli/src/commands/db-backup.ts index bdbf739f44b..7e1adce546a 100644 --- a/cli/src/commands/db-backup.ts +++ b/cli/src/commands/db-backup.ts @@ -73,7 +73,7 @@ export async function dbBackupCommand(opts: DbBackupOptions): Promise { const result = await runDatabaseBackup({ connectionString: connection.value, backupDir, - retentionDays, + retention: { dailyDays: retentionDays, weeklyWeeks: 4, monthlyMonths: 1 }, filenamePrefix, }); spinner.stop(`Backup saved: ${formatDatabaseBackupResult(result)}`); diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index 3025e955f94..d67bd15488a 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -903,7 +903,7 @@ async function seedWorktreeDatabase(input: { const backup = await runDatabaseBackup({ connectionString: sourceConnectionString, backupDir: path.resolve(input.targetPaths.backupDir, "seed"), - retentionDays: 7, + retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 }, filenamePrefix: `${input.instanceId}-seed`, includeMigrationJournal: true, excludeTables: seedPlan.excludedTables, diff --git a/packages/db/src/backup-lib.test.ts b/packages/db/src/backup-lib.test.ts index 2367d26d54e..2ea9b07001c 100644 --- a/packages/db/src/backup-lib.test.ts +++ b/packages/db/src/backup-lib.test.ts @@ -125,7 +125,7 @@ describeEmbeddedPostgres("runDatabaseBackup", () => { const result = await runDatabaseBackup({ connectionString: sourceConnectionString, backupDir, - retentionDays: 7, + retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 }, filenamePrefix: "paperclip-test", }); diff --git a/packages/db/src/backup-lib.ts b/packages/db/src/backup-lib.ts index 36b5ee9805b..0fe70f132af 100644 --- a/packages/db/src/backup-lib.ts +++ b/packages/db/src/backup-lib.ts @@ -5,10 +5,16 @@ import { pipeline } from "node:stream/promises"; import { createGunzip, createGzip } from "node:zlib"; import postgres from "postgres"; +export type BackupRetentionPolicy = { + dailyDays: number; + weeklyWeeks: number; + monthlyMonths: number; +}; + export type RunDatabaseBackupOptions = { connectionString: string; backupDir: string; - retentionDays: number; + retention: BackupRetentionPolicy; filenamePrefix?: string; connectTimeoutSeconds?: number; includeMigrationJournal?: boolean; @@ -77,24 +83,91 @@ function timestamp(date: Date = new Date()): string { return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; } -function pruneOldBackups(backupDir: string, retentionDays: number, filenamePrefix: string): number { +/** + * ISO week key for grouping backups by calendar week (ISO 8601). + */ +function isoWeekKey(date: Date): string { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const weekNo = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); + return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`; +} + +function monthKey(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; +} + +/** + * Tiered backup pruning: + * - Daily tier: keep ALL backups from the last `dailyDays` days + * - Weekly tier: keep the NEWEST backup per calendar week for `weeklyWeeks` weeks + * - Monthly tier: keep the NEWEST backup per calendar month for `monthlyMonths` months + * - Everything else is deleted + */ +function pruneOldBackups(backupDir: string, retention: BackupRetentionPolicy, filenamePrefix: string): number { if (!existsSync(backupDir)) return 0; - const safeRetention = Math.max(1, Math.trunc(retentionDays)); - const cutoff = Date.now() - safeRetention * 24 * 60 * 60 * 1000; - let pruned = 0; + + const now = Date.now(); + const dailyCutoff = now - Math.max(1, retention.dailyDays) * 24 * 60 * 60 * 1000; + const weeklyCutoff = now - Math.max(1, retention.weeklyWeeks) * 7 * 24 * 60 * 60 * 1000; + const monthlyCutoff = now - Math.max(1, retention.monthlyMonths) * 30 * 24 * 60 * 60 * 1000; + + type BackupEntry = { name: string; fullPath: string; mtimeMs: number }; + const entries: BackupEntry[] = []; for (const name of readdirSync(backupDir)) { if (!name.startsWith(`${filenamePrefix}-`)) continue; if (!name.endsWith(".sql") && !name.endsWith(".sql.gz")) continue; const fullPath = resolve(backupDir, name); const stat = statSync(fullPath); - if (stat.mtimeMs < cutoff) { - unlinkSync(fullPath); - pruned++; - } + entries.push({ name, fullPath, mtimeMs: stat.mtimeMs }); } - return pruned; + // Sort newest first so the first entry per week/month bucket is the one we keep + entries.sort((a, b) => b.mtimeMs - a.mtimeMs); + + const keepWeekBuckets = new Set(); + const keepMonthBuckets = new Set(); + const toDelete: string[] = []; + + for (const entry of entries) { + // Daily tier — keep everything within dailyDays + if (entry.mtimeMs >= dailyCutoff) continue; + + const date = new Date(entry.mtimeMs); + const week = isoWeekKey(date); + const month = monthKey(date); + + // Weekly tier — keep newest per calendar week + if (entry.mtimeMs >= weeklyCutoff) { + if (keepWeekBuckets.has(week)) { + toDelete.push(entry.fullPath); + } else { + keepWeekBuckets.add(week); + } + continue; + } + + // Monthly tier — keep newest per calendar month + if (entry.mtimeMs >= monthlyCutoff) { + if (keepMonthBuckets.has(month)) { + toDelete.push(entry.fullPath); + } else { + keepMonthBuckets.add(month); + } + continue; + } + + // Beyond all retention tiers — delete + toDelete.push(entry.fullPath); + } + + for (const filePath of toDelete) { + unlinkSync(filePath); + } + + return toDelete.length; } function formatBackupSize(sizeBytes: number): string { @@ -287,7 +360,7 @@ export function createBufferedTextFileWriter(filePath: string, maxBufferedBytes export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise { const filenamePrefix = opts.filenamePrefix ?? "paperclip"; - const retentionDays = Math.max(1, Math.trunc(opts.retentionDays)); + const retention = opts.retention; const connectTimeout = Math.max(1, Math.trunc(opts.connectTimeoutSeconds ?? 5)); const includeMigrationJournal = opts.includeMigrationJournal === true; const excludedTableNames = normalizeTableNameSet(opts.excludeTables); @@ -678,7 +751,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise unlinkSync(sqlFile); const sizeBytes = statSync(backupFile).size; - const prunedCount = pruneOldBackups(opts.backupDir, retentionDays, filenamePrefix); + const prunedCount = pruneOldBackups(opts.backupDir, retention, filenamePrefix); return { backupFile, diff --git a/packages/db/src/backup.ts b/packages/db/src/backup.ts index f07dc646f36..c11822c9503 100644 --- a/packages/db/src/backup.ts +++ b/packages/db/src/backup.ts @@ -85,7 +85,7 @@ function resolveBackupDir(config: PartialConfig | null): string { } function resolveRetentionDays(config: PartialConfig | null): number { - return asPositiveInt(config?.database?.backup?.retentionDays) ?? 30; + return asPositiveInt(config?.database?.backup?.retentionDays) ?? 7; } async function main() { @@ -103,7 +103,7 @@ async function main() { const result = await runDatabaseBackup({ connectionString, backupDir, - retentionDays, + retention: { dailyDays: retentionDays, weeklyWeeks: 4, monthlyMonths: 1 }, filenamePrefix: "paperclip", }); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index cf4a26335fd..200e29813d1 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -21,6 +21,7 @@ export { runDatabaseBackup, runDatabaseRestore, formatDatabaseBackupResult, + type BackupRetentionPolicy, type RunDatabaseBackupOptions, type RunDatabaseBackupResult, type RunDatabaseRestoreOptions, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ef9ce5baa61..e9075356067 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -189,7 +189,7 @@ export type { InstanceExperimentalSettings, InstanceGeneralSettings, InstanceSettings, - BackupRetentionDays, + BackupRetentionPolicy, Agent, AgentAccessState, AgentChainOfCommandEntry, @@ -371,8 +371,10 @@ export { } from "./types/feedback.js"; export { - BACKUP_RETENTION_PRESETS, - DEFAULT_BACKUP_RETENTION_DAYS, + DAILY_RETENTION_PRESETS, + WEEKLY_RETENTION_PRESETS, + MONTHLY_RETENTION_PRESETS, + DEFAULT_BACKUP_RETENTION, } from "./types/instance.js"; export { diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 20d38a7aff9..1f0ec2c757d 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -11,8 +11,8 @@ export type { FeedbackTraceBundleFile, FeedbackTraceBundle, } from "./feedback.js"; -export type { InstanceExperimentalSettings, InstanceGeneralSettings, InstanceSettings, BackupRetentionDays } from "./instance.js"; -export { BACKUP_RETENTION_PRESETS, DEFAULT_BACKUP_RETENTION_DAYS } from "./instance.js"; +export type { InstanceExperimentalSettings, InstanceGeneralSettings, InstanceSettings, BackupRetentionPolicy } from "./instance.js"; +export { DAILY_RETENTION_PRESETS, WEEKLY_RETENTION_PRESETS, MONTHLY_RETENTION_PRESETS, DEFAULT_BACKUP_RETENTION } from "./instance.js"; export type { CompanySkillSourceType, CompanySkillTrustLevel, diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index 30f07f7dd39..4e83c92529d 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -1,14 +1,26 @@ import type { FeedbackDataSharingPreference } from "./feedback.js"; -export const BACKUP_RETENTION_PRESETS = [7, 14, 30] as const; -export type BackupRetentionDays = (typeof BACKUP_RETENTION_PRESETS)[number]; -export const DEFAULT_BACKUP_RETENTION_DAYS: BackupRetentionDays = 7; +export const DAILY_RETENTION_PRESETS = [3, 7, 14] as const; +export const WEEKLY_RETENTION_PRESETS = [1, 2, 4] as const; +export const MONTHLY_RETENTION_PRESETS = [1, 3, 6] as const; + +export interface BackupRetentionPolicy { + dailyDays: (typeof DAILY_RETENTION_PRESETS)[number]; + weeklyWeeks: (typeof WEEKLY_RETENTION_PRESETS)[number]; + monthlyMonths: (typeof MONTHLY_RETENTION_PRESETS)[number]; +} + +export const DEFAULT_BACKUP_RETENTION: BackupRetentionPolicy = { + dailyDays: 7, + weeklyWeeks: 4, + monthlyMonths: 1, +}; export interface InstanceGeneralSettings { censorUsernameInLogs: boolean; keyboardShortcuts: boolean; feedbackDataSharingPreference: FeedbackDataSharingPreference; - backupRetentionDays: BackupRetentionDays; + backupRetention: BackupRetentionPolicy; } export interface InstanceExperimentalSettings { diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index a62f19966d4..930e183f9bd 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -1,13 +1,25 @@ import { z } from "zod"; import { DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE } from "../types/feedback.js"; -import { BACKUP_RETENTION_PRESETS, DEFAULT_BACKUP_RETENTION_DAYS } from "../types/instance.js"; +import { + DAILY_RETENTION_PRESETS, + WEEKLY_RETENTION_PRESETS, + MONTHLY_RETENTION_PRESETS, + DEFAULT_BACKUP_RETENTION, +} from "../types/instance.js"; import { feedbackDataSharingPreferenceSchema } from "./feedback.js"; -export const backupRetentionDaysSchema = z.number().refine( - (v): v is (typeof BACKUP_RETENTION_PRESETS)[number] => - (BACKUP_RETENTION_PRESETS as readonly number[]).includes(v), - { message: `Must be one of: ${BACKUP_RETENTION_PRESETS.join(", ")}` }, -); +function presetSchema(presets: T, label: string) { + return z.number().refine( + (v): v is T[number] => (presets as readonly number[]).includes(v), + { message: `${label} must be one of: ${presets.join(", ")}` }, + ); +} + +export const backupRetentionPolicySchema = z.object({ + dailyDays: presetSchema(DAILY_RETENTION_PRESETS, "dailyDays").default(DEFAULT_BACKUP_RETENTION.dailyDays), + weeklyWeeks: presetSchema(WEEKLY_RETENTION_PRESETS, "weeklyWeeks").default(DEFAULT_BACKUP_RETENTION.weeklyWeeks), + monthlyMonths: presetSchema(MONTHLY_RETENTION_PRESETS, "monthlyMonths").default(DEFAULT_BACKUP_RETENTION.monthlyMonths), +}); export const instanceGeneralSettingsSchema = z.object({ censorUsernameInLogs: z.boolean().default(false), @@ -15,7 +27,7 @@ export const instanceGeneralSettingsSchema = z.object({ feedbackDataSharingPreference: feedbackDataSharingPreferenceSchema.default( DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, ), - backupRetentionDays: backupRetentionDaysSchema.default(DEFAULT_BACKUP_RETENTION_DAYS), + backupRetention: backupRetentionPolicySchema.default(DEFAULT_BACKUP_RETENTION), }).strict(); export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema.partial(); diff --git a/server/src/index.ts b/server/src/index.ts index 768d1fd6a63..b63789a2051 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -642,12 +642,12 @@ export async function startServer(): Promise { try { // Read retention from Instance Settings (DB) so changes take effect without restart const generalSettings = await settingsSvc.getGeneral(); - const retentionDays = generalSettings.backupRetentionDays; + const retention = generalSettings.backupRetention; const result = await runDatabaseBackup({ connectionString: activeDatabaseConnectionString, backupDir: config.databaseBackupDir, - retentionDays, + retention, filenamePrefix: "paperclip", }); logger.info( @@ -656,7 +656,7 @@ export async function startServer(): Promise { sizeBytes: result.sizeBytes, prunedCount: result.prunedCount, backupDir: config.databaseBackupDir, - retentionDays, + retention, }, `Automatic database backup complete: ${formatDatabaseBackupResult(result)}`, ); diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 5c7db61dfb0..65a1263226d 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -2,7 +2,7 @@ import type { Db } from "@paperclipai/db"; import { companies, instanceSettings } from "@paperclipai/db"; import { DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, - DEFAULT_BACKUP_RETENTION_DAYS, + DEFAULT_BACKUP_RETENTION, instanceGeneralSettingsSchema, type InstanceGeneralSettings, instanceExperimentalSettingsSchema, @@ -23,14 +23,14 @@ function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings { keyboardShortcuts: parsed.data.keyboardShortcuts ?? false, feedbackDataSharingPreference: parsed.data.feedbackDataSharingPreference ?? DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, - backupRetentionDays: parsed.data.backupRetentionDays ?? DEFAULT_BACKUP_RETENTION_DAYS, + backupRetention: parsed.data.backupRetention ?? DEFAULT_BACKUP_RETENTION, }; } return { censorUsernameInLogs: false, keyboardShortcuts: false, feedbackDataSharingPreference: DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, - backupRetentionDays: DEFAULT_BACKUP_RETENTION_DAYS, + backupRetention: DEFAULT_BACKUP_RETENTION, }; } diff --git a/ui/src/pages/InstanceGeneralSettings.tsx b/ui/src/pages/InstanceGeneralSettings.tsx index 9004e418bc2..04c3638d523 100644 --- a/ui/src/pages/InstanceGeneralSettings.tsx +++ b/ui/src/pages/InstanceGeneralSettings.tsx @@ -1,8 +1,13 @@ import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import type { PatchInstanceGeneralSettings, BackupRetentionDays } from "@paperclipai/shared"; -import { BACKUP_RETENTION_PRESETS, DEFAULT_BACKUP_RETENTION_DAYS } from "@paperclipai/shared"; -import { Database, LogOut, SlidersHorizontal } from "lucide-react"; +import type { PatchInstanceGeneralSettings, BackupRetentionPolicy } from "@paperclipai/shared"; +import { + DAILY_RETENTION_PRESETS, + WEEKLY_RETENTION_PRESETS, + MONTHLY_RETENTION_PRESETS, + DEFAULT_BACKUP_RETENTION, +} from "@paperclipai/shared"; +import { LogOut, SlidersHorizontal } from "lucide-react"; import { authApi } from "@/api/auth"; import { instanceSettingsApi } from "@/api/instanceSettings"; import { Button } from "../components/ui/button"; @@ -68,7 +73,7 @@ export function InstanceGeneralSettings() { const censorUsernameInLogs = generalQuery.data?.censorUsernameInLogs === true; const keyboardShortcuts = generalQuery.data?.keyboardShortcuts === true; const feedbackDataSharingPreference = generalQuery.data?.feedbackDataSharingPreference ?? "prompt"; - const backupRetentionDays: BackupRetentionDays = generalQuery.data?.backupRetentionDays ?? DEFAULT_BACKUP_RETENTION_DAYS; + const backupRetention: BackupRetentionPolicy = generalQuery.data?.backupRetention ?? DEFAULT_BACKUP_RETENTION; return (
@@ -126,44 +131,103 @@ export function InstanceGeneralSettings() {
-
-
- -
-

Backup retention

-

- How long to keep automatic database backups before pruning. Backups are compressed - with gzip to minimize disk usage. -

+
+
+

Backup retention

+

+ Configure how long to keep automatic database backups at each tier. Daily backups + are kept in full, then thinned to one per week and one per month. Backups are + compressed with gzip. +

+
+ +
+

Daily

+
+ {DAILY_RETENTION_PRESETS.map((days) => { + const active = backupRetention.dailyDays === days; + return ( + + ); + })}
-
- {BACKUP_RETENTION_PRESETS.map((days) => { - const active = backupRetentionDays === days; - const label = - days === 7 ? "7 days" : days === 14 ? "2 weeks" : "1 month"; - return ( - - ); - })} + +
+

Weekly

+
+ {WEEKLY_RETENTION_PRESETS.map((weeks) => { + const active = backupRetention.weeklyWeeks === weeks; + const label = weeks === 1 ? "1 week" : `${weeks} weeks`; + return ( + + ); + })} +
+
+ +
+

Monthly

+
+ {MONTHLY_RETENTION_PRESETS.map((months) => { + const active = backupRetention.monthlyMonths === months; + const label = months === 1 ? "1 month" : `${months} months`; + return ( + + ); + })} +
From b1e457365b5ab2df0721b58417df2c70ef3e1168 Mon Sep 17 00:00:00 2001 From: Aron Prins Date: Tue, 7 Apr 2026 10:55:32 +0200 Subject: [PATCH 3/6] fix: clean up orphaned .sql on compression failure and fix stale startup log - backup-lib: delete uncompressed .sql file in catch block when gzip compression fails, preventing silent disk usage accumulation - server: replace stale retentionDays scalar with retentionSource in startup log since retention is now read from DB on each backup tick --- packages/db/src/backup-lib.ts | 3 +++ server/src/index.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/db/src/backup-lib.ts b/packages/db/src/backup-lib.ts index 0fe70f132af..e1e88724f8a 100644 --- a/packages/db/src/backup-lib.ts +++ b/packages/db/src/backup-lib.ts @@ -763,6 +763,9 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise if (existsSync(backupFile)) { try { unlinkSync(backupFile); } catch { /* ignore */ } } + if (existsSync(sqlFile)) { + try { unlinkSync(sqlFile); } catch { /* ignore */ } + } throw error; } finally { await sql.end(); diff --git a/server/src/index.ts b/server/src/index.ts index b63789a2051..c3f4a8b136c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -670,7 +670,7 @@ export async function startServer(): Promise { logger.info( { intervalMinutes: config.databaseBackupIntervalMinutes, - retentionDays: config.databaseBackupRetentionDays, + retentionSource: "instance-settings-db", backupDir: config.databaseBackupDir, }, "Automatic database backups enabled", From 7c42345177567882ca643f8d7d8caf5c89738d5e Mon Sep 17 00:00:00 2001 From: Aron Prins Date: Fri, 10 Apr 2026 12:16:25 +0200 Subject: [PATCH 4/6] chore: re-trigger CI to refresh PR base SHA From 724893ad5b5dc5164a2538881fdde52508230be8 Mon Sep 17 00:00:00 2001 From: Aron Prins Date: Fri, 10 Apr 2026 14:22:48 +0200 Subject: [PATCH 5/6] fix claude instruction sibling path hint --- packages/adapters/claude-local/src/server/execute.ts | 6 +++++- server/src/__tests__/claude-local-execute.test.ts | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index ae5ea3abd4d..7116be449dc 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -387,7 +387,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise { expect(captured[1]?.appendedSystemPromptFilePath).not.toBe(instructionsFile); expect(captured[1]?.appendedSystemPromptFileContents).toContain("# Agent instructions"); expect(captured[1]?.appendedSystemPromptFileContents).toContain( - `The above agent instructions were loaded from ${instructionsFile}. Resolve any relative file references from ${path.dirname(instructionsFile)}/.`, + `The above agent instructions were loaded from ${instructionsFile}. ` + + `Resolve any relative file references from ${path.dirname(instructionsFile)}/. ` + + `This base directory is authoritative for sibling instruction files such as ` + + `./HEARTBEAT.md, ./SOUL.md, and ./TOOLS.md; do not resolve those from the parent agent directory.`, ); expect(metaEvents).toHaveLength(2); expect(metaEvents[0]?.commandNotes).toHaveLength(0); From ac664df8e48326135a913e97ee7ed937d913586b Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:55:27 -0500 Subject: [PATCH 6/6] fix(authz): scope import, approvals, activity, and heartbeat routes (#3315) ## Thinking Path > - Paperclip orchestrates AI agents and company-scoped control-plane actions for zero-human companies. > - This change touches the server authz boundary around company portability, approvals, activity, and heartbeat-run operations. > - The vulnerability was that board-authenticated callers could cross company boundaries or create new companies through import paths without the same authorization checks enforced elsewhere. > - Once that gap existed, an attacker could chain it into higher-impact behavior through agent execution paths. > - The fix needed to harden every confirmed authorization gap in the reported chain, not just the first route that exposed it. > - This pull request adds the missing instance-admin and company-access checks and adds regression tests for each affected route. > - The benefit is that cross-company actions and new-company import flows now follow the same control-plane authorization rules as the rest of the product. ## What Changed - Required instance-admin access for `new_company` import preview/apply flows in `server/src/routes/companies.ts`. - Required company access before approval decision routes in `server/src/routes/approvals.ts`. - Required company access for activity creation and heartbeat-run issue listing in `server/src/routes/activity.ts`. - Required company access before heartbeat cancellation in `server/src/routes/agents.ts`. - Added regression coverage in the corresponding server route tests. ## Verification - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/company-portability-routes.test.ts src/__tests__/approval-routes-idempotency.test.ts src/__tests__/activity-routes.test.ts src/__tests__/agent-permissions-routes.test.ts` - `pnpm --filter @paperclipai/server typecheck` - Prior verification on the original security patch branch also included `pnpm build`. ## Risks - Low code risk: the change is narrow and only adds missing authorization gates to existing routes. - Operational risk: the advisory is already public, so this PR should be merged quickly to minimize the public unpatched window. - Residual product risk remains around open signup / bootstrap defaults, which was intentionally left out of this patch because the current first-user onboarding flow depends on it. ## Model Used - OpenAI GPT-5 Codex coding agent with tool use and local code execution in the Codex CLI environment. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Forgotten --- server/src/__tests__/activity-routes.test.ts | 33 ++++++++++++ .../agent-permissions-routes.test.ts | 24 +++++++++ .../approval-routes-idempotency.test.ts | 52 ++++++++++++++++++- .../company-portability-routes.test.ts | 46 ++++++++++++++++ server/src/routes/activity.ts | 10 +++- server/src/routes/agents.ts | 4 ++ server/src/routes/approvals.ts | 23 +++++++- server/src/routes/companies.ts | 21 +++++--- 8 files changed, 203 insertions(+), 10 deletions(-) diff --git a/server/src/__tests__/activity-routes.test.ts b/server/src/__tests__/activity-routes.test.ts index 86ee374dbdd..ffd343dffe4 100644 --- a/server/src/__tests__/activity-routes.test.ts +++ b/server/src/__tests__/activity-routes.test.ts @@ -10,6 +10,10 @@ const mockActivityService = vi.hoisted(() => ({ create: vi.fn(), })); +const mockHeartbeatService = vi.hoisted(() => ({ + getRun: vi.fn(), +})); + const mockIssueService = vi.hoisted(() => ({ getById: vi.fn(), getByIdentifier: vi.fn(), @@ -22,6 +26,7 @@ function registerRouteMocks() { vi.doMock("../services/index.js", () => ({ issueService: () => mockIssueService, + heartbeatService: () => mockHeartbeatService, })); } @@ -75,4 +80,32 @@ describe("activity routes", () => { expect(mockActivityService.runsForIssue).toHaveBeenCalledWith("company-1", "issue-uuid-1"); expect(res.body).toEqual([{ runId: "run-1", adapterType: "codex_local" }]); }); + + it("requires company access before creating activity events", async () => { + const app = await createApp(); + const res = await request(app) + .post("/api/companies/company-2/activity") + .send({ + actorId: "user-1", + action: "test.event", + entityType: "issue", + entityId: "issue-1", + }); + + expect(res.status).toBe(403); + expect(mockActivityService.create).not.toHaveBeenCalled(); + }); + + it("requires company access before listing issues for another company's run", async () => { + mockHeartbeatService.getRun.mockResolvedValue({ + id: "run-2", + companyId: "company-2", + }); + + const app = await createApp(); + const res = await request(app).get("/api/heartbeat-runs/run-2/issues"); + + expect(res.status).toBe(403); + expect(mockActivityService.issuesForRun).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 6ef45db164f..c5593f035fd 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -59,6 +59,8 @@ const mockBudgetService = vi.hoisted(() => ({ const mockHeartbeatService = vi.hoisted(() => ({ listTaskSessions: vi.fn(), resetRuntimeSession: vi.fn(), + getRun: vi.fn(), + cancelRun: vi.fn(), })); const mockIssueApprovalService = vi.hoisted(() => ({ @@ -397,4 +399,26 @@ describe("agent permission routes", () => { }, ]); }); + + it("rejects heartbeat cancellation outside the caller company scope", async () => { + mockHeartbeatService.getRun.mockResolvedValue({ + id: "run-1", + companyId: "33333333-3333-4333-8333-333333333333", + agentId, + status: "running", + }); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "session", + isInstanceAdmin: false, + companyIds: [companyId], + }); + + const res = await request(app).post("/api/heartbeat-runs/run-1/cancel").send({}); + + expect(res.status).toBe(403); + expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/__tests__/approval-routes-idempotency.test.ts b/server/src/__tests__/approval-routes-idempotency.test.ts index 83d34cf1f93..cecee0e8ba9 100644 --- a/server/src/__tests__/approval-routes-idempotency.test.ts +++ b/server/src/__tests__/approval-routes-idempotency.test.ts @@ -39,7 +39,7 @@ vi.mock("../services/index.js", () => ({ secretService: () => mockSecretService, })); -function createApp() { +function createApp(actorOverrides: Record = {}) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -49,6 +49,7 @@ function createApp() { companyIds: ["company-1"], source: "session", isInstanceAdmin: false, + ...actorOverrides, }; next(); }); @@ -84,6 +85,14 @@ describe("approval routes idempotent retries", () => { }); it("does not emit duplicate approval side effects when approve is already resolved", async () => { + mockApprovalService.getById.mockResolvedValue({ + id: "approval-1", + companyId: "company-1", + type: "hire_agent", + status: "approved", + payload: {}, + requestedByAgentId: "agent-1", + }); mockApprovalService.approve.mockResolvedValue({ approval: { id: "approval-1", @@ -107,6 +116,13 @@ describe("approval routes idempotent retries", () => { }); it("does not emit duplicate rejection logs when reject is already resolved", async () => { + mockApprovalService.getById.mockResolvedValue({ + id: "approval-1", + companyId: "company-1", + type: "hire_agent", + status: "rejected", + payload: {}, + }); mockApprovalService.reject.mockResolvedValue({ approval: { id: "approval-1", @@ -126,6 +142,40 @@ describe("approval routes idempotent retries", () => { expect(mockLogActivity).not.toHaveBeenCalled(); }); + it("rejects approval decisions for companies outside the caller scope", async () => { + mockApprovalService.getById.mockResolvedValue({ + id: "approval-2", + companyId: "company-2", + type: "hire_agent", + status: "pending", + payload: {}, + }); + + const res = await request(createApp()) + .post("/api/approvals/approval-2/approve") + .send({}); + + expect(res.status).toBe(403); + expect(mockApprovalService.approve).not.toHaveBeenCalled(); + }); + + it("rejects approval revision requests for companies outside the caller scope", async () => { + mockApprovalService.getById.mockResolvedValue({ + id: "approval-3", + companyId: "company-2", + type: "hire_agent", + status: "pending", + payload: {}, + }); + + const res = await request(createApp()) + .post("/api/approvals/approval-3/request-revision") + .send({ decisionNote: "Need changes" }); + + expect(res.status).toBe(403); + expect(mockApprovalService.requestRevision).not.toHaveBeenCalled(); + }); + it("lets agents create generic issue-linked board approval requests", async () => { mockApprovalService.create.mockResolvedValue({ id: "approval-1", diff --git a/server/src/__tests__/company-portability-routes.test.ts b/server/src/__tests__/company-portability-routes.test.ts index 8649f631f63..075f2d9bc60 100644 --- a/server/src/__tests__/company-portability-routes.test.ts +++ b/server/src/__tests__/company-portability-routes.test.ts @@ -175,4 +175,50 @@ describe("company portability routes", () => { expect(res.status).toBe(403); expect(res.body.error).toContain("Board access required"); }); + + it("requires instance admin for new-company import preview", async () => { + const app = await createApp({ + type: "board", + userId: "user-1", + companyIds: ["11111111-1111-4111-8111-111111111111"], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app) + .post("/api/companies/import/preview") + .send({ + source: { type: "inline", files: { "COMPANY.md": "---\nname: Test\n---\n" } }, + include: { company: true, agents: true, projects: false, issues: false }, + target: { mode: "new_company", newCompanyName: "Imported Test" }, + collisionStrategy: "rename", + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Instance admin"); + expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled(); + }); + + it("requires instance admin for new-company import apply", async () => { + const app = await createApp({ + type: "board", + userId: "user-1", + companyIds: ["11111111-1111-4111-8111-111111111111"], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app) + .post("/api/companies/import") + .send({ + source: { type: "inline", files: { "COMPANY.md": "---\nname: Test\n---\n" } }, + include: { company: true, agents: true, projects: false, issues: false }, + target: { mode: "new_company", newCompanyName: "Imported Test" }, + collisionStrategy: "rename", + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Instance admin"); + expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/routes/activity.ts b/server/src/routes/activity.ts index 0884b455df3..20f2ed534ed 100644 --- a/server/src/routes/activity.ts +++ b/server/src/routes/activity.ts @@ -4,7 +4,7 @@ import type { Db } from "@paperclipai/db"; import { validate } from "../middleware/validate.js"; import { activityService } from "../services/activity.js"; import { assertBoard, assertCompanyAccess } from "./authz.js"; -import { issueService } from "../services/index.js"; +import { heartbeatService, issueService } from "../services/index.js"; import { sanitizeRecord } from "../redaction.js"; const createActivitySchema = z.object({ @@ -20,6 +20,7 @@ const createActivitySchema = z.object({ export function activityRoutes(db: Db) { const router = Router(); const svc = activityService(db); + const heartbeat = heartbeatService(db); const issueSvc = issueService(db); async function resolveIssueByRef(rawId: string) { @@ -46,6 +47,7 @@ export function activityRoutes(db: Db) { router.post("/companies/:companyId/activity", validate(createActivitySchema), async (req, res) => { assertBoard(req); const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); const event = await svc.create({ companyId, ...req.body, @@ -80,6 +82,12 @@ export function activityRoutes(db: Db) { router.get("/heartbeat-runs/:runId/issues", async (req, res) => { const runId = req.params.runId as string; + const run = await heartbeat.getRun(runId); + if (!run) { + res.json([]); + return; + } + assertCompanyAccess(req, run.companyId); const result = await svc.issuesForRun(runId); res.json(result); }); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 266803ecd9b..3d982cdc0f4 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -2296,6 +2296,10 @@ export function agentRoutes(db: Db) { router.post("/heartbeat-runs/:runId/cancel", async (req, res) => { assertBoard(req); const runId = req.params.runId as string; + const existing = await heartbeat.getRun(runId); + if (existing) { + assertCompanyAccess(req, existing.companyId); + } const run = await heartbeat.cancelRun(runId); if (run) { diff --git a/server/src/routes/approvals.ts b/server/src/routes/approvals.ts index 99d33abd86a..3ed20374700 100644 --- a/server/src/routes/approvals.ts +++ b/server/src/routes/approvals.ts @@ -1,4 +1,4 @@ -import { Router } from "express"; +import { Router, type Request } from "express"; import type { Db } from "@paperclipai/db"; import { addApprovalCommentSchema, @@ -34,6 +34,15 @@ export function approvalRoutes(db: Db) { const secretsSvc = secretService(db); const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true"; + async function requireApprovalAccess(req: Request, id: string) { + const approval = await svc.getById(id); + if (!approval) { + return null; + } + assertCompanyAccess(req, approval.companyId); + return approval; + } + router.get("/companies/:companyId/approvals", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); @@ -121,6 +130,10 @@ export function approvalRoutes(db: Db) { router.post("/approvals/:id/approve", validate(resolveApprovalSchema), async (req, res) => { assertBoard(req); const id = req.params.id as string; + if (!(await requireApprovalAccess(req, id))) { + res.status(404).json({ error: "Approval not found" }); + return; + } const { approval, applied } = await svc.approve( id, req.body.decidedByUserId ?? "board", @@ -216,6 +229,10 @@ export function approvalRoutes(db: Db) { router.post("/approvals/:id/reject", validate(resolveApprovalSchema), async (req, res) => { assertBoard(req); const id = req.params.id as string; + if (!(await requireApprovalAccess(req, id))) { + res.status(404).json({ error: "Approval not found" }); + return; + } const { approval, applied } = await svc.reject( id, req.body.decidedByUserId ?? "board", @@ -243,6 +260,10 @@ export function approvalRoutes(db: Db) { async (req, res) => { assertBoard(req); const id = req.params.id as string; + if (!(await requireApprovalAccess(req, id))) { + res.status(404).json({ error: "Approval not found" }); + return; + } const approval = await svc.requestRevision( id, req.body.decidedByUserId ?? "board", diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index d1978c9d6f7..22be5f863ca 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -24,7 +24,7 @@ import { logActivity, } from "../services/index.js"; import type { StorageService } from "../storage/types.js"; -import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; +import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; export function companyRoutes(db: Db, storage?: StorageService) { const router = Router(); @@ -48,6 +48,17 @@ export function companyRoutes(db: Db, storage?: StorageService) { return parsed; } + function assertImportTargetAccess( + req: Request, + target: { mode: "new_company" } | { mode: "existing_company"; companyId: string }, + ) { + if (target.mode === "new_company") { + assertInstanceAdmin(req); + return; + } + assertCompanyAccess(req, target.companyId); + } + async function assertCanUpdateBranding(req: Request, companyId: string) { assertCompanyAccess(req, companyId); if (req.actor.type === "board") return; @@ -160,18 +171,14 @@ export function companyRoutes(db: Db, storage?: StorageService) { router.post("/import/preview", validate(companyPortabilityPreviewSchema), async (req, res) => { assertBoard(req); - if (req.body.target.mode === "existing_company") { - assertCompanyAccess(req, req.body.target.companyId); - } + assertImportTargetAccess(req, req.body.target); const preview = await portability.previewImport(req.body); res.json(preview); }); router.post("/import", validate(companyPortabilityImportSchema), async (req, res) => { assertBoard(req); - if (req.body.target.mode === "existing_company") { - assertCompanyAccess(req, req.body.target.companyId); - } + assertImportTargetAccess(req, req.body.target); const actor = getActorInfo(req); const result = await portability.importBundle(req.body, req.actor.type === "board" ? req.actor.userId : null); await logActivity(db, {