Files
PaperClipAI/server/src/routes/companies.ts
T

1625 lines
65 KiB
TypeScript

import { createHash, randomUUID } from "node:crypto";
import express, { Router, type NextFunction, type Request, type Response } from "express";
import multer from "multer";
import { and, count as countFn, eq } from "drizzle-orm";
import { z } from "zod";
import type { Db } from "@paperclipai/db";
import { agents as agentsTable } from "@paperclipai/db";
import type { CompanyPortabilityImportResult } from "@paperclipai/shared";
import {
MAX_ZIP_ENTRY_DECOMPRESSED_BYTES,
MAX_ZIP_TOTAL_DECOMPRESSED_BYTES,
readZipArchive,
} from "@paperclipai/shared/portability-zip";
import {
DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION,
SETTINGS_OPERATOR_MANAGED_ERROR_CODE,
companyArtifactsQuerySchema,
companyPortabilityExportSchema,
companyPortabilityImportSchema,
companyPortabilityPreviewSchema,
createCompanySchema,
feedbackTargetTypeSchema,
feedbackTraceStatusSchema,
feedbackVoteValueSchema,
hidesCompanyPage,
updateCompanyBrandingSchema,
updateCompanySchema,
} from "@paperclipai/shared";
import {
COMPANY_IMPORT_TRANSFERS_ROUTE_PATH,
companyImportTransferDeclarationSchema,
type CompanyImportTransferCreated,
type CompanyImportTransferDeclaration,
type CompanyImportTransferPartUploadResult,
type CompanyImportTransferStatus,
} from "@paperclipai/shared/company-import-transfer";
import { badRequest, conflict, forbidden, notFound, unprocessable } from "../errors.js";
import { PORTABLE_ZIP_UPLOAD_LIMIT_BYTES } from "../http/body-limits.js";
import { logger } from "../middleware/logger.js";
import { validate } from "../middleware/validate.js";
import {
assembleImportTransferZip,
importTransferPartName,
importTransferPartSizeOnDisk,
isImportTransferRunId,
removeImportTransferPart,
removeImportTransferSpool,
resolveDefaultImportTransferSpoolRoot,
writeImportTransferPart,
} from "../services/company-import-transfers.js";
import { companyTransferRunService } from "../services/company-transfer-runs.js";
import { agentInstructionsBundleMode } from "../services/agent-instructions.js";
import { resolvePortableExportAgentSelection } from "../services/company-portability-agent-selection.js";
import {
accessService,
agentService,
budgetService,
heartbeatService,
buildExportFidelityReport,
collectExportFidelityCounts,
companyArtifactsService,
companyPortabilityService,
companyService,
feedbackService,
logActivity,
workTimelineService,
} from "../services/index.js";
import { isCloudManagedInstance } from "../services/cloud-instance.js";
import { getHiddenSettings } from "../services/settings-visibility.js";
import type { StorageService } from "../storage/types.js";
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo, hasCompanyAccess } from "./authz.js";
import { COMPANY_IMPORT_ROUTE_PATH } from "./company-import-paths.js";
// A company import can arrive one of two ways on the import + preview routes:
// • application/json — the original inline body `{ source, target, ... }`,
// kept byte-identical for CLI and programmatic callers; or
// • the raw compressed zip, either as multipart/form-data (a `package` file
// field plus a JSON `meta` field) or a bare application/zip body. The zip
// is a third of the inline size and already gzip-friendly, so it survives
// the browser → edge → harness-proxy → tenant chain that truncated the
// inflated inline JSON on large companies.
// The zip is unzipped server-side into the exact `{ rootPath, files }` bundle
// the inline source carries, then fed through the unchanged preview/import
// logic, so import semantics are identical regardless of transport.
const PORTABLE_ZIP_CONTENT_TYPES = ["application/zip", "application/x-zip-compressed"] as const;
const zipPackageUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: PORTABLE_ZIP_UPLOAD_LIMIT_BYTES, files: 1 },
});
// Chunked resumable variant of the zip upload above: the client slices the
// exact same .zip into byte-range parts and uploads them individually, so a
// dropped connection re-uploads one part instead of the whole package. Parts
// are spooled to disk and reassembled at apply, then fed through the same
// preview/import logic — no new container format, no change to import
// semantics. Only one part is held in memory at a time during upload; the
// full-zip buffer exists only while an apply runs (matching the single-shot
// path's memory profile).
const IMPORT_TRANSFER_PART_SIZE_LIMIT_BYTES = 64 * 1024 * 1024;
// The declaration body and response shapes are the shared wire contract in
// @paperclipai/shared/company-import-transfer — the browser and CLI clients
// type against the same schemas and path builders.
const importTransferManifestSchema = companyImportTransferDeclarationSchema;
type ImportTransferManifest = CompanyImportTransferDeclaration;
const importTransferPartBodyParser = express.raw({
type: () => true,
limit: IMPORT_TRANSFER_PART_SIZE_LIMIT_BYTES,
});
const rawZipBodyParser = express.raw({
type: [...PORTABLE_ZIP_CONTENT_TYPES],
limit: PORTABLE_ZIP_UPLOAD_LIMIT_BYTES,
});
function requestContentType(req: Request) {
return (req.header("content-type") ?? "").toLowerCase();
}
function isMultipartImport(req: Request) {
return requestContentType(req).includes("multipart/form-data");
}
function isZipImport(req: Request) {
const contentType = requestContentType(req);
return PORTABLE_ZIP_CONTENT_TYPES.some((type) => contentType.includes(type));
}
function runMiddleware(
middleware: (req: Request, res: Response, next: (err?: unknown) => void) => void,
req: Request,
res: Response,
) {
return new Promise<void>((resolve, reject) => {
middleware(req, res, (err?: unknown) => (err ? reject(err) : resolve()));
});
}
/**
* Parse the `meta` form/query field into the object the import schemas expect.
* A client-declared `source` is ignored — the source is always the uploaded
* zip — so a multipart caller only ships the other import fields (include,
* target, collisionStrategy, nameOverrides, selectedFiles, adapterOverrides,
* pauseAutomations, ...).
*/
function parseImportMeta(metaRaw: string | undefined): Record<string, unknown> {
if (metaRaw === undefined || metaRaw.trim().length === 0) return {};
let parsed: unknown;
try {
parsed = JSON.parse(metaRaw);
} catch {
throw badRequest("Import package metadata was not valid JSON.");
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw badRequest("Import package metadata must be a JSON object.");
}
const { source: _ignoredSource, ...rest } = parsed as Record<string, unknown>;
return rest;
}
/**
* The chunked-transfer apply body is the already-parsed JSON equivalent of the
* multipart `meta` field: the import fields minus `source` (the source is
* always the assembled zip). Mirrors `parseImportMeta` for object bodies.
*/
function importTransferApplyMeta(body: unknown): Record<string, unknown> {
if (body === undefined || body === null) return {};
if (typeof body !== "object" || Array.isArray(body)) {
throw badRequest("Import transfer apply body must be a JSON object.");
}
const { source: _ignoredSource, ...rest } = body as Record<string, unknown>;
return rest;
}
/**
* Resolve the object to hand the preview/import zod schemas. For a JSON request
* this is the inline body unchanged. For a multipart or application/zip request
* the uploaded zip is read into `{ rootPath, files }` and combined with the
* `meta` fields as `source = { type: "inline", rootPath, files }`. A truncated,
* corrupt, or empty zip fails closed with a 400/422 before anything is imported.
*/
async function resolveImportPayload(req: Request, res: Response): Promise<unknown> {
const multipart = isMultipartImport(req);
const rawZip = isZipImport(req);
if (!multipart && !rawZip) {
return req.body;
}
let zipBytes: Buffer | undefined;
let metaRaw: string | undefined;
if (multipart) {
try {
await runMiddleware(zipPackageUpload.single("package"), req, res);
} catch (error) {
if (error instanceof multer.MulterError) {
if (error.code === "LIMIT_FILE_SIZE") {
throw unprocessable(
`Import package exceeds the ${Math.floor(PORTABLE_ZIP_UPLOAD_LIMIT_BYTES / (1024 * 1024))} MB upload limit`,
);
}
throw badRequest(error.message);
}
throw error;
}
const file = (req as Request & { file?: { buffer?: Buffer } }).file;
zipBytes = file?.buffer;
const metaField = (req.body as { meta?: unknown } | undefined)?.meta;
metaRaw = typeof metaField === "string" ? metaField : undefined;
} else {
try {
await runMiddleware(rawZipBodyParser, req, res);
} catch (error) {
throw badRequest(error instanceof Error ? error.message : "Invalid zip upload");
}
zipBytes = Buffer.isBuffer(req.body) ? req.body : undefined;
const metaQuery = req.query.meta;
metaRaw = typeof metaQuery === "string" ? metaQuery : undefined;
}
if (!zipBytes || zipBytes.length === 0) {
throw badRequest("Import package upload was empty.");
}
const archive = await readImportZipArchive(zipBytes);
return {
...parseImportMeta(metaRaw),
source: { type: "inline", rootPath: archive.rootPath, files: archive.files },
};
}
/**
* Read an uploaded import zip with the scaled bomb guards. Shared by the
* single-shot upload path (`resolveImportPayload`) and the chunked transfer
* apply path, so both feed the exact same `{ rootPath, files }` bundle into
* the unchanged preview/import logic.
*/
async function readImportZipArchive(zipBytes: Buffer) {
let archive: Awaited<ReturnType<typeof readZipArchive>>;
try {
// Scale the bomb guards from the configured upload cap so a legitimately
// compressible package under the cap never trips them (~4x covers dense
// text; the per-entry ceiling is bounded by V8's string limit regardless).
archive = await readZipArchive(zipBytes, {
maxEntryDecompressedBytes: MAX_ZIP_ENTRY_DECOMPRESSED_BYTES,
maxTotalDecompressedBytes: Math.max(
MAX_ZIP_TOTAL_DECOMPRESSED_BYTES,
PORTABLE_ZIP_UPLOAD_LIMIT_BYTES * 4,
),
});
} catch (error) {
throw badRequest(`Import package could not be read: ${errorMessage(error)}`);
}
if (Object.keys(archive.files).length === 0) {
throw badRequest("Import package contained no files.");
}
return archive;
}
/**
* Async job opt-in. Cloud tenants set the `x-paperclip-cloud-async-import`
* header server-side (they are not a browser, so it is never stripped). Board
* browsers cannot use that header — the Cloud harness proxy strips every
* inbound `x-paperclip-cloud-*` header as anti-spoofing — so they opt in with
* the proxy-safe `?async=1` query parameter instead. Either signal enters the
* async path.
*/
function wantsAsyncImport(req: Request) {
return req.query.async === "1" || req.header("x-paperclip-cloud-async-import") === "1";
}
export interface CompanyRoutesOptions {
/** Overridable in tests; defaults to `<instance root>/import-transfers`. */
importTransferSpoolRoot?: string;
}
export function companyRoutes(db: Db, storage?: StorageService, options?: CompanyRoutesOptions) {
const router = Router();
const importTransferSpoolRoot =
options?.importTransferSpoolRoot ?? resolveDefaultImportTransferSpoolRoot();
const svc = companyService(db, {
cancelWorkForScope: (scope) => heartbeatService(db).cancelBudgetScopeWork(scope),
});
const agents = agentService(db);
const portability = companyPortabilityService(db, storage);
const access = accessService(db);
const budgets = budgetService(db);
const artifacts = companyArtifactsService(db, storage);
const feedback = feedbackService(db);
const importJobs = new Map<string, ImportJobRecord>();
// Terminal jobs are retained in memory for an hour after they settle. A long
// import can outlast a user stepping away, and dropping the completion after
// only a few minutes made a later poll 404 — surfacing a finished, fully
// written import as a scary failure. An hour is long enough to cover a real
// "walk away and come back" gap while keeping the map bounded, and it needs
// no new persistence (jobs remain in-memory-only; a restart still 404s, which
// the client now treats as a soft success once it has seen the job running).
const importJobTerminalRetentionMs = 60 * 60 * 1000;
function parseBooleanQuery(value: unknown) {
return value === true || value === "true" || value === "1";
}
function parseDateQuery(value: unknown, field: string) {
if (typeof value !== "string" || value.trim().length === 0) return undefined;
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
throw badRequest(`Invalid ${field} query value`);
}
return parsed;
}
function parseIntegerQuery(value: unknown, field: string) {
if (value === undefined || value === null || value === "") return undefined;
const parsed = typeof value === "string" ? Number(value) : NaN;
if (!Number.isFinite(parsed)) {
throw badRequest(`Invalid ${field} query value`);
}
return Math.floor(parsed);
}
async function assertExternalInstructionExportAllowed(
req: Request,
companyId: string,
input: { include?: { agents?: boolean }; agents?: string[] },
) {
const instanceAdmin = req.actor.type === "board"
&& (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin === true);
const includesAgents = input.agents && input.agents.length > 0
? true
: input.include?.agents ?? true;
if (!includesAgents) return instanceAdmin;
const companyAgents = await agents.list(companyId, { includeTerminated: true });
const selection = resolvePortableExportAgentSelection(companyAgents, input.agents, includesAgents);
if (selection.agents.some((agent) => agentInstructionsBundleMode(agent) === "external")) {
assertInstanceAdmin(req);
}
return instanceAdmin;
}
const timelineQuerySchema = z.object({
from: z.string().optional(),
to: z.string().optional(),
userId: z.string().min(1).optional(),
goalId: z.string().guid().optional(),
projectId: z.string().guid().optional(),
issueId: z.string().guid().optional(),
limit: z.string().optional(),
offset: z.string().optional(),
}).passthrough();
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 assertSameCompanyCeoAgentOrBoard(req: Request, companyId: string, capability: string) {
assertCompanyAccess(req, companyId);
if (req.actor.type === "board") {
return;
}
if (!req.actor.agentId) throw forbidden("Agent authentication required");
const actorAgent = await agents.getById(req.actor.agentId);
if (!actorAgent || actorAgent.companyId !== companyId) {
throw forbidden("Agent key cannot access another company");
}
if (actorAgent.role !== "ceo") {
throw forbidden(`Only CEO agents can manage ${capability}`);
}
}
router.get("/", async (req, res) => {
assertBoard(req);
const scope = req.query.scope;
if (scope !== undefined && scope !== "accessible") {
throw badRequest("scope must be a single accessible value when provided");
}
const result = await svc.list();
// Navigation needs the same membership scope as company detail routes.
// Instance admins can inspect the directory without membership, but that
// visibility alone does not let them open a company's inbox or tasks.
if (scope === "accessible") {
res.json(result.filter((company) => hasCompanyAccess(req, company.id)));
return;
}
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) {
res.json(result);
return;
}
const allowed = new Set(req.actor.companyIds ?? []);
res.json(result.filter((company) => allowed.has(company.id)));
});
router.get("/stats", async (req, res) => {
assertBoard(req);
const allowed = req.actor.source === "local_implicit" || req.actor.isInstanceAdmin
? null
: new Set(req.actor.companyIds ?? []);
const stats = await svc.stats();
if (!allowed) {
res.json(stats);
return;
}
const filtered = Object.fromEntries(Object.entries(stats).filter(([companyId]) => allowed.has(companyId)));
res.json(filtered);
});
// Common malformed path when companyId is empty in "/api/companies/{companyId}/issues".
router.get("/issues", (_req, res) => {
res.status(400).json({
error: "Missing companyId in path. Use /api/companies/{companyId}/issues.",
});
});
router.get("/:companyId/artifacts", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const query = companyArtifactsQuerySchema.parse(req.query);
res.json(await artifacts.list(companyId, query, {
userId: query.starred && req.actor.type === "board" ? req.actor.userId : undefined,
}));
});
router.get("/:companyId/timeline", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const companyScopeDecision = await access.decide({
actor: req.actor,
action: "company_scope:read",
resource: { type: "company", companyId },
});
if (!companyScopeDecision.allowed) {
res.status(403).json({ error: "Timeline is outside this actor's authorization boundary" });
return;
}
const query = timelineQuerySchema.parse(req.query);
const timeline = workTimelineService(db);
const result = await timeline.getTimeline({
companyId,
from: parseDateQuery(query.from, "from"),
to: parseDateQuery(query.to, "to"),
userId: query.userId,
goalId: query.goalId,
projectId: query.projectId,
issueId: query.issueId,
limit: parseIntegerQuery(query.limit, "limit"),
offset: parseIntegerQuery(query.offset, "offset"),
canReadIssue: async (issue) => {
const decision = await access.decide({
actor: req.actor,
action: "issue:read",
resource: {
type: "issue",
companyId: issue.companyId,
issueId: issue.id,
projectId: issue.projectId,
parentIssueId: issue.parentId,
assigneeAgentId: issue.assigneeAgentId,
assigneeUserId: issue.assigneeUserId,
status: issue.status,
},
scope: {
issueId: issue.id,
projectId: issue.projectId,
parentIssueId: issue.parentId,
assigneeAgentId: issue.assigneeAgentId,
assigneeUserId: issue.assigneeUserId,
},
});
return decision.allowed;
},
});
res.json(result);
});
router.get("/:companyId", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
// Allow agents (CEO) to read their own company; board always allowed
if (req.actor.type !== "agent") {
assertBoard(req);
}
const company = await svc.getById(companyId);
if (!company) {
res.status(404).json({ error: "Company not found" });
return;
}
res.json(company);
});
router.get("/:companyId/feedback-traces", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertBoard(req);
const targetTypeRaw = typeof req.query.targetType === "string" ? req.query.targetType : undefined;
const voteRaw = typeof req.query.vote === "string" ? req.query.vote : undefined;
const statusRaw = typeof req.query.status === "string" ? req.query.status : undefined;
const issueId = typeof req.query.issueId === "string" && req.query.issueId.trim().length > 0 ? req.query.issueId : undefined;
const projectId = typeof req.query.projectId === "string" && req.query.projectId.trim().length > 0
? req.query.projectId
: undefined;
const traces = await feedback.listFeedbackTraces({
companyId,
issueId,
projectId,
targetType: targetTypeRaw ? feedbackTargetTypeSchema.parse(targetTypeRaw) : undefined,
vote: voteRaw ? feedbackVoteValueSchema.parse(voteRaw) : undefined,
status: statusRaw ? feedbackTraceStatusSchema.parse(statusRaw) : undefined,
from: parseDateQuery(req.query.from, "from"),
to: parseDateQuery(req.query.to, "to"),
sharedOnly: parseBooleanQuery(req.query.sharedOnly),
includePayload: parseBooleanQuery(req.query.includePayload),
});
res.json(traces);
});
router.post("/:companyId/export", async (req, res) => {
const companyId = req.params.companyId as string;
await assertSameCompanyCeoAgentOrBoard(req, companyId, "company exports");
const body = companyPortabilityExportSchema.parse(req.body);
const allowExternalInstructions = await assertExternalInstructionExportAllowed(req, companyId, body);
const result = await portability.exportBundle(companyId, body, { allowExternalInstructions });
res.json(result);
});
router.get("/:companyId/export/fidelity", async (req, res) => {
const companyId = req.params.companyId as string;
await assertSameCompanyCeoAgentOrBoard(req, companyId, "company export fidelity");
const counts = await collectExportFidelityCounts(db, companyId);
res.json(buildExportFidelityReport(companyId, counts));
});
/**
* Floor for the whole company-import surface: single-shot upload, preview,
* job polling, chunked transfers, and the per-company agent-safe import
* routes. Two independent signals close it, both checked up front — before
* auth or body work, the same way the company-creation floor does:
*
* - a cloud-managed instance (`cloud_managed`): the hosting platform
* provisions the company, so materializing imported companies on a
* managed instance is disabled unconditionally;
* - the operator hiding the Import page (`company.import` in
* `PAPERCLIP_HIDDEN_SETTINGS` → `settings_operator_managed`): hiding the
* page also disables its API, so the hide is real rather than cosmetic.
*
* Export routes stay open either way — they are the tenant's
* data-portability escape hatch.
*/
const importFloor = (_req: Request, _res: Response, next: NextFunction) => {
if (isCloudManagedInstance()) {
throw forbidden("Company import is disabled on cloud-managed instances", {
code: "cloud_managed",
});
}
if (hidesCompanyPage(getHiddenSettings(), "company.import")) {
throw forbidden("Company import is hidden by the hosting operator", {
code: SETTINGS_OPERATOR_MANAGED_ERROR_CODE,
});
}
next();
};
// COMPANY_IMPORT_TRANSFERS_ROUTE_PATH nests under the import path, so these
// two prefixes cover every import route registered below.
router.use(COMPANY_IMPORT_ROUTE_PATH, importFloor);
router.use("/:companyId/imports", importFloor);
router.post("/import/preview", async (req, res) => {
assertBoard(req);
const body = companyPortabilityPreviewSchema.parse(await resolveImportPayload(req, res));
assertImportTargetAccess(req, body.target);
const preview = await portability.previewImport(body);
res.json(preview);
});
router.get("/import/jobs/:jobId", async (req, res) => {
// Board sessions and trusted Cloud tenants both poll here. A job is
// readable only by the actor key that created it; every other caller —
// like every unknown or expired id — gets the same 404, so job ids
// cannot be probed across users or tenants. Jobs live in memory only
// (see the async block below), so a restart also surfaces as this 404.
assertBoard(req);
cleanupTerminalImportJobs(importJobs, importJobTerminalRetentionMs);
const job = importJobs.get(req.params.jobId as string);
if (!job || job.actorKey !== importJobActorKey(req)) {
res.status(404).json({ error: "Import job not found" });
return;
}
res.json(importJobResponse(job));
});
/**
* Post-resolution import execution, shared by the single-shot upload route
* and the chunked transfer apply route: validate the resolved body, run the
* import synchronously or through the in-memory async job machinery, and
* fire the optional completion hooks (the transfer path uses them to settle
* its ledger run) on the import's real outcome in either mode.
*/
async function executeImportRequest(
req: Request,
res: Response,
rawImportBody: unknown,
hooks?: {
onSuccess?: (result: CompanyPortabilityImportResult) => Promise<void>;
/** Fires when the request is refused before any import work starts (e.g. another import is already running for the actor). */
onConflict?: () => Promise<void>;
onFailure?: (message: string) => Promise<void>;
},
) {
const actor = getActorInfo(req);
const boardUserId = req.actor.type === "board" ? req.actor.userId : null;
const operation = async () => {
try {
const importBody = companyPortabilityImportSchema.parse(rawImportBody);
assertImportTargetAccess(req, importBody.target);
const activity = importedCompanyActivityContext(actor, importBody.include ?? null);
const result = await portability.importBundle(importBody, boardUserId, {
pauseAutomations: importBody.pauseAutomations === true,
});
// The import is committed. Settlement (hooks) runs before the
// best-effort audit entry so a logging failure cannot make a
// committed import read as failed — or, on the transfer path, release
// the apply claim and invite a duplicate re-import.
await hooks?.onSuccess?.(result);
try {
await logImportedCompanyActivity(db, activity, result);
} catch (activityError) {
logger.warn(
{ err: activityError, companyId: result.company.id },
"failed to write the company.imported activity entry for a committed import",
);
}
return result;
} catch (error) {
await hooks?.onFailure?.(errorMessage(error));
throw error;
}
};
if (wantsAsyncImport(req)) {
// Async job path. Two kinds of callers opt in:
// - trusted Cloud tenants (original behavior, kept byte-identical),
// keyed by their tenant identity headers;
// - any other board session, keyed by its user id, so long imports
// survive proxies cutting the connection while the server finishes.
// Jobs are held in memory only and are lost on restart — existing
// semantics; the status route above 404s unknown ids, so a client
// that can no longer see its job treats it as gone and resubmits.
cleanupTerminalImportJobs(importJobs, importJobTerminalRetentionMs);
const isCloudTenant = req.actor.source === "cloud_tenant";
const actorKey = importJobActorKey(req);
const signature = importRequestSignature(rawImportBody);
if (!isCloudTenant) {
// One live import per board actor: while a job for this key is still
// running, a resubmit of the *same* import gets 409 carrying the
// running job's id and status URL so the client adopts it instead of
// importing the same bundle twice. A *different* import (another tab,
// another target) gets a 409 without a job to adopt, so the client
// surfaces it as an error rather than switching to the wrong result.
// Terminal jobs never block a resubmit.
const running = findRunningImportJob(importJobs, actorKey);
if (running) {
// No import work starts on this path, so a caller holding a claim
// (the transfer apply) must be told to release it — otherwise the
// refused transfer would sit in "applying" until a restart.
await hooks?.onConflict?.();
if (running.signature === signature) {
res.status(409).json(importJobConflictResponse(running));
} else {
res.status(409).json(importAlreadyRunningResponse());
}
return;
}
}
const job = createImportJob(
actorKey,
isCloudTenant ? "cloud_tenant" : "board",
signature,
);
importJobs.set(job.id, job);
res.status(202).json(importJobAcceptedResponse(job));
setImmediate(() => {
void runImportJob(job, operation);
});
return;
}
const result = await operation();
res.json(result);
}
router.post(COMPANY_IMPORT_ROUTE_PATH, async (req, res) => {
assertBoard(req);
// Resolve the request body up front: a JSON caller's inline body is used
// unchanged; a multipart/application-zip caller's uploaded zip is read into
// the same `{ source: { type: "inline", rootPath, files } }` bundle here,
// fast and in-memory, so the async job machinery below is transport-agnostic.
const rawImportBody: unknown = await resolveImportPayload(req, res);
await executeImportRequest(req, res, rawImportBody);
});
/**
* Load the transfer run for the URL's id, scoped to the caller the same way
* import jobs are (`importJobActorKey`): an unknown id, another actor's id,
* and a non-import run are all the same 404, so transfer ids cannot be
* probed across users or tenants. The id is regex-validated as a UUID before
* it is used anywhere (it is later joined into spool paths).
*/
async function requireImportTransferRun(req: Request) {
const transferId = req.params.transferId as string;
if (!isImportTransferRunId(transferId)) {
throw notFound("Import transfer not found");
}
const run = await companyTransferRunService.getRunForActor(db, transferId, importJobActorKey(req));
if (!run || run.direction !== "import") {
throw notFound("Import transfer not found");
}
return run;
}
/** The declared part list persisted on the run at create time. */
function storedImportTransferManifest(run: { manifest: unknown }): ImportTransferManifest {
const parsed = importTransferManifestSchema.safeParse(run.manifest);
if (!parsed.success) {
throw conflict("Import transfer has no recorded part manifest");
}
return parsed.data;
}
/**
* Indexes the client still has to upload. A part counts as done only when
* the ledger recorded it AND its spool file is still on disk at the declared
* size — so a swept or damaged spool surfaces as missing parts to re-upload
* instead of a failing apply.
*/
async function importTransferMissingParts(
run: { id: string; completedParts: string[] },
manifest: ImportTransferManifest,
): Promise<number[]> {
const completed = new Set(run.completedParts);
const missing: number[] = [];
for (const part of manifest.parts) {
if (!completed.has(importTransferPartName(part.index))) {
missing.push(part.index);
continue;
}
const sizeOnDisk = await importTransferPartSizeOnDisk(importTransferSpoolRoot, run.id, part.index);
if (sizeOnDisk !== part.byteSize) {
missing.push(part.index);
}
}
return missing;
}
// Declare a chunked import transfer. The declaration is content-addressed:
// the idempotency key derives from the whole-zip hash plus every part hash,
// so re-declaring the same zip resumes the prior run (with its uploaded
// parts intact) instead of starting over.
router.post(COMPANY_IMPORT_TRANSFERS_ROUTE_PATH, async (req, res) => {
assertBoard(req);
const declared = importTransferManifestSchema.parse(req.body);
if (declared.totalBytes > PORTABLE_ZIP_UPLOAD_LIMIT_BYTES) {
throw unprocessable(
`Import package exceeds the ${Math.floor(PORTABLE_ZIP_UPLOAD_LIMIT_BYTES / (1024 * 1024))} MB upload limit`,
);
}
if (declared.partSizeBytes > IMPORT_TRANSFER_PART_SIZE_LIMIT_BYTES) {
throw unprocessable(
`Import transfer parts may be at most ${Math.floor(IMPORT_TRANSFER_PART_SIZE_LIMIT_BYTES / (1024 * 1024))} MB`,
);
}
declared.parts.forEach((part, position) => {
if (part.index !== position) {
throw unprocessable("Import transfer parts must be contiguous, ordered 0..n-1");
}
if (part.byteSize > declared.partSizeBytes) {
throw unprocessable(`Part ${part.index} is larger than the declared partSizeBytes`);
}
});
const declaredTotal = declared.parts.reduce((sum, part) => sum + part.byteSize, 0);
if (declaredTotal !== declared.totalBytes) {
throw unprocessable("Sum of part byte sizes must equal totalBytes");
}
const idempotencyKey = createHash("sha256")
.update(JSON.stringify([declared.zipSha256, declared.parts.map((part) => part.sha256)]))
.digest("hex");
const { run, alreadyCompleted } = await companyTransferRunService.resumeOrCreate(db, {
direction: "import",
actorKey: importJobActorKey(req),
idempotencyKey,
containerRef: { kind: "chunked_zip_upload" },
});
if (alreadyCompleted) {
// Tell the caller WHERE the prior apply landed. companyId on the run is
// written best-effort after apply and the company may have been deleted
// since, so this lookup is null-safe and the field stays optional. The
// actor key proves this caller ran the original import, but their
// access may have been revoked since — withhold the identity unless
// they can still reach the company today (hasCompanyAccess, so a
// revoked caller sees the same null as a deleted company).
const landedCompany = run.companyId && hasCompanyAccess(req, run.companyId)
? await svc.getById(run.companyId)
: null;
res.json({
transferId: run.id,
status: "completed",
alreadyCompleted: true,
totalParts: declared.parts.length,
missingParts: [],
company: landedCompany
? { id: landedCompany.id, name: landedCompany.name ?? null, issuePrefix: landedCompany.issuePrefix ?? null }
: null,
} satisfies CompanyImportTransferCreated);
return;
}
if (run.manifest === null || run.manifest === undefined) {
const manifestSha256 = createHash("sha256").update(JSON.stringify(declared)).digest("hex");
await companyTransferRunService.recordManifest(db, run.id, declared, manifestSha256, {
chunkCount: declared.parts.length,
blobCount: 0,
});
}
await companyTransferRunService.start(db, run.id);
res.json({
transferId: run.id,
status: "running",
alreadyCompleted: false,
totalParts: declared.parts.length,
missingParts: await importTransferMissingParts(run, declared),
} satisfies CompanyImportTransferCreated);
});
// Upload one declared part as a raw body. Verified against the declared
// size and sha256 before it is spooled; re-uploading an already completed
// part is a no-op success, so clients can blindly retry.
router.put(
`${COMPANY_IMPORT_TRANSFERS_ROUTE_PATH}/:transferId/parts/:partIndex`,
importTransferPartBodyParser,
async (req, res) => {
assertBoard(req);
const run = await requireImportTransferRun(req);
if (run.status === "completed") {
throw conflict("Import transfer has already been applied");
}
if (run.status === "applying") {
// An apply is assembling the spool right now; accepting writes would
// let a retried upload race the files being read. Parts are frozen
// until the apply settles (completed short-circuits, failed resumes).
throw conflict("Import transfer apply is in progress; retry after it settles");
}
const manifest = storedImportTransferManifest(run);
const rawIndex = req.params.partIndex as string;
// Strict integer-in-bounds validation before the index goes anywhere
// near a filesystem path.
if (!/^\d{1,5}$/.test(rawIndex) || Number(rawIndex) >= manifest.parts.length) {
throw notFound("Import transfer part not found");
}
const partIndex = Number(rawIndex);
const declared = manifest.parts[partIndex]!;
const partName = importTransferPartName(partIndex);
if (run.completedParts.includes(partName)) {
const sizeOnDisk = await importTransferPartSizeOnDisk(importTransferSpoolRoot, run.id, partIndex);
if (sizeOnDisk === declared.byteSize) {
res.json({ ok: true, index: partIndex, alreadyCompleted: true } satisfies CompanyImportTransferPartUploadResult);
return;
}
}
const bytes = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
if (bytes.length !== declared.byteSize) {
throw unprocessable(
`Part ${partIndex} upload is ${bytes.length} bytes but was declared as ${declared.byteSize} bytes`,
);
}
const digest = createHash("sha256").update(bytes).digest("hex");
if (digest !== declared.sha256) {
throw unprocessable(`Part ${partIndex} content does not match its declared sha256`);
}
await writeImportTransferPart(importTransferSpoolRoot, run.id, partIndex, bytes);
const recorded = await companyTransferRunService.completePart(db, run.id, partName);
if (!recorded) {
// The run left pending/running between the load above and this
// record. Which way it went decides the file's fate: under a claimed
// or completed apply the just-written bytes are hash-verified
// identical to what the apply assembles, so the file must NOT be
// deleted out from under it; a swept or failed run is dead, so the
// stray part is dropped and the client told to re-create.
const current = await companyTransferRunService.getRun(db, run.id);
if (current?.status === "applying" || current?.status === "completed") {
throw conflict("Import transfer apply is in progress; retry after it settles");
}
await removeImportTransferPart(importTransferSpoolRoot, run.id, partIndex);
res.status(410).json({ error: "Import transfer expired — re-create it" });
return;
}
res.json({ ok: true, index: partIndex, alreadyCompleted: false } satisfies CompanyImportTransferPartUploadResult);
},
);
// Resume polling: which parts are done, which are still missing.
router.get(`${COMPANY_IMPORT_TRANSFERS_ROUTE_PATH}/:transferId`, async (req, res) => {
assertBoard(req);
const run = await requireImportTransferRun(req);
const manifest = storedImportTransferManifest(run);
const missingParts = run.status === "completed" ? [] : await importTransferMissingParts(run, manifest);
res.json({
transferId: run.id,
status: run.status,
totalParts: manifest.parts.length,
completedParts: manifest.parts.length - missingParts.length,
missingParts,
} satisfies CompanyImportTransferStatus);
});
/**
* Resolve a completed transfer into the same raw preview/import body a
* single-shot zip upload produces: load the run, require every part, then
* assemble the spool and verify it against the declared whole-file hash.
* Shared by the transfer preview and apply routes; returns null after
* responding 409 when parts are still missing. A whole-file mismatch fails
* closed for both callers: every part verified individually but the whole
* does not match the declaration, so the spool is deleted and a resume
* re-uploads every part instead of re-assembling the same corrupt bytes.
*
* With `claimApply` (the apply route), the run is atomically claimed before
* the spool is touched: the import pipeline is not idempotent, so of any
* overlapping applies of this transfer exactly one may run it — a single
* guarded status flip to "applying" that losers see as 409. The claim is
* settled by complete() on success or the guarded releaseApplyClaim() on
* error (a released run stays retryable; a settled one is never reopened).
* The preview route takes no claim — it never consumes the transfer — so
* both its status gate and its assembly errors are re-checked against the
* ledger (see the catch below) to keep a concurrent apply's spool cleanup
* from surfacing as an internal error.
*/
async function resolveImportTransferBody(
req: Request,
res: Response,
options: { claimApply?: boolean } = {},
) {
const run = await requireImportTransferRun(req);
if (run.status === "completed") {
throw conflict("Import transfer has already been applied");
}
if (run.status === "applying") {
// An apply holds the claim right now: its assembly is reading the spool
// and its success deletes it, so neither a preview nor a second apply
// may touch the parts until the claim settles.
throw conflict("Import transfer apply is in progress; retry after it settles");
}
if (run.status === "cancelled") {
// The abandoned-spool sweep cancelled the run and deleted its parts —
// same contract as the part-upload route: the caller re-creates it.
res.status(410).json({ error: "Import transfer expired — re-create it" });
return null;
}
const manifest = storedImportTransferManifest(run);
const missingParts = await importTransferMissingParts(run, manifest);
if (missingParts.length > 0) {
res.status(409).json({
error: "Import transfer is missing parts",
missingParts,
});
return null;
}
if (options.claimApply && !(await companyTransferRunService.claimApply(db, run.id))) {
throw conflict("Import transfer apply is already in progress");
}
try {
const zipBytes = await assembleImportTransferZip(importTransferSpoolRoot, run.id, manifest.parts.length);
const zipSha256 = createHash("sha256").update(zipBytes).digest("hex");
if (zipBytes.length !== manifest.totalBytes || zipSha256 !== manifest.zipSha256) {
await companyTransferRunService.fail(db, run.id, "Assembled import package failed whole-file verification");
await removeImportTransferSpool(importTransferSpoolRoot, run.id);
throw unprocessable("Assembled import package failed verification; upload the transfer again");
}
const archive = await readImportZipArchive(zipBytes);
return {
run,
rawBody: {
...importTransferApplyMeta(req.body),
source: { type: "inline", rootPath: archive.rootPath, files: archive.files },
},
};
} catch (error) {
// Whatever escapes here (a malformed zip, the verification failure
// above) must not leave a claimed run parked in "applying": release
// the claim, which fails the run and keeps it retryable. The release
// is guarded on "applying", so the verification path that already
// failed the run keeps its more specific terminal state.
if (options.claimApply) {
await companyTransferRunService.releaseApplyClaim(db, run.id, errorMessage(error));
throw error;
}
// Unclaimed (preview) path: assembly read the spool without freezing
// it, so a concurrent apply may have completed — or the sweep cancelled
// the run — and deleted the part files mid-assembly. Re-read the ledger
// and surface those races as the same clean signals the status gate
// above sends; only a run that is genuinely still open rethrows as a
// real assembly failure.
const current = await companyTransferRunService.getRun(db, run.id);
if (current?.status === "completed") {
throw conflict("Import transfer has already been applied");
}
if (current?.status === "applying") {
throw conflict("Import transfer apply is in progress; retry after it settles");
}
if (current?.status === "cancelled") {
res.status(410).json({ error: "Import transfer expired — re-create it" });
return null;
}
throw error;
}
}
// Run the import preview against the assembled spool without consuming the
// transfer: the ledger run stays open and the parts stay spooled, so the
// subsequent apply reuses them instead of re-uploading. The body carries the
// same meta fields the multipart preview route's `meta` field does.
router.post(`${COMPANY_IMPORT_TRANSFERS_ROUTE_PATH}/:transferId/preview`, async (req, res) => {
assertBoard(req);
const resolved = await resolveImportTransferBody(req, res);
if (!resolved) return;
const body = companyPortabilityPreviewSchema.parse(resolved.rawBody);
assertImportTargetAccess(req, body.target);
const preview = await portability.previewImport(body);
res.json(preview);
});
// Assemble the spooled parts back into the original zip, verify it against
// the declared whole-file hash, and run it through the exact import path a
// single-shot zip upload takes. The body carries the same meta fields the
// multipart route's `meta` field does (include/target/collisionStrategy/...).
router.post(`${COMPANY_IMPORT_TRANSFERS_ROUTE_PATH}/:transferId/apply`, async (req, res) => {
assertBoard(req);
const resolved = await resolveImportTransferBody(req, res, { claimApply: true });
if (!resolved) return;
const { run, rawBody: rawImportBody } = resolved;
try {
await executeImportRequest(req, res, rawImportBody, {
onSuccess: async (result) => {
// Settle the run the moment the import is committed: every later
// step is best-effort, because any error escaping this hook reads
// as a failed apply and — before complete() — would release the
// claim on an already-committed import. complete() gets one retry;
// if both attempts fail the run is left "applying" (unclaimable, so
// no duplicate import) and logged for the operator; a restart's
// stranded-run recovery then makes it inspectable as failed.
try {
await companyTransferRunService.complete(db, run.id);
} catch {
try {
await companyTransferRunService.complete(db, run.id);
} catch (settleError) {
logger.error(
{ err: settleError, transferId: run.id },
"import committed but the transfer run could not be marked completed",
);
return;
}
}
try {
await companyTransferRunService.attachCompany(db, run.id, result.company.id);
} catch (attachError) {
logger.warn(
{ err: attachError, transferId: run.id },
"failed to attach the imported company to its transfer run",
);
}
// Best-effort cleanup: the import is committed and the run is
// completed, so a spool deletion error must not escape — it would
// reach the failure paths below and read as a failed apply. The
// leftover dir is harmless (completed runs never assemble again)
// and the sweep collects terminal-run spools immediately.
try {
await removeImportTransferSpool(importTransferSpoolRoot, run.id);
} catch (cleanupError) {
logger.warn(
{ err: cleanupError, transferId: run.id },
"failed to delete import transfer spool after a successful apply",
);
}
},
onFailure: async (message) => {
// Parts remain spooled: the failure is in the import itself, not
// the upload, so a retry of apply can reuse the verified parts.
// Guarded release, not a blind fail: this hook also fires when an
// error escapes onSuccess above, i.e. possibly after complete(),
// and a completed run must never be flipped back to claimable.
await companyTransferRunService.releaseApplyClaim(db, run.id, message);
},
onConflict: async () => {
// The request was refused before any import work (another import is
// running for this actor): release the claim so the transfer stays
// retryable instead of stranded in "applying" until a restart.
await companyTransferRunService.releaseApplyClaim(
db,
run.id,
"another import is already running for this actor",
);
},
});
} catch (error) {
// Whatever escapes here (bad meta, an import error rethrown after
// onFailure) must not leave the run parked in "applying": release the
// claim, which keeps the run retryable. The release is guarded on
// "applying", so a run that already settled — failed by onFailure, or
// completed before a late error — keeps its terminal state.
await companyTransferRunService.releaseApplyClaim(db, run.id, errorMessage(error));
throw error;
}
});
router.post("/:companyId/exports/preview", async (req, res) => {
const companyId = req.params.companyId as string;
await assertSameCompanyCeoAgentOrBoard(req, companyId, "company exports");
const body = companyPortabilityExportSchema.parse(req.body);
const allowExternalInstructions = await assertExternalInstructionExportAllowed(req, companyId, body);
const preview = await portability.previewExport(companyId, body, { allowExternalInstructions });
res.json(preview);
});
router.post("/:companyId/exports", async (req, res) => {
const companyId = req.params.companyId as string;
await assertSameCompanyCeoAgentOrBoard(req, companyId, "company exports");
const body = companyPortabilityExportSchema.parse(req.body);
const allowExternalInstructions = await assertExternalInstructionExportAllowed(req, companyId, body);
const result = await portability.exportBundle(companyId, body, { allowExternalInstructions });
res.json(result);
});
router.post("/:companyId/imports/preview", async (req, res) => {
const companyId = req.params.companyId as string;
await assertSameCompanyCeoAgentOrBoard(req, companyId, "company imports");
const body = companyPortabilityPreviewSchema.parse(req.body);
if (body.target.mode === "existing_company" && body.target.companyId !== companyId) {
throw forbidden("Safe import route can only target the route company");
}
if (body.collisionStrategy === "replace") {
throw forbidden("Safe import route does not allow replace collision strategy");
}
const preview = await portability.previewImport(body, {
mode: "agent_safe",
sourceCompanyId: companyId,
});
res.json(preview);
});
router.post("/:companyId/imports/apply", async (req, res) => {
const companyId = req.params.companyId as string;
await assertSameCompanyCeoAgentOrBoard(req, companyId, "company imports");
const body = companyPortabilityImportSchema.parse(req.body);
if (body.target.mode === "existing_company" && body.target.companyId !== companyId) {
throw forbidden("Safe import route can only target the route company");
}
if (body.collisionStrategy === "replace") {
throw forbidden("Safe import route does not allow replace collision strategy");
}
const actor = getActorInfo(req);
const result = await portability.importBundle(body, req.actor.type === "board" ? req.actor.userId : null, {
mode: "agent_safe",
sourceCompanyId: companyId,
pauseAutomations: body.pauseAutomations === true,
});
await logActivity(db, {
companyId: result.company.id,
actorType: actor.actorType,
actorId: actor.actorId,
entityType: "company",
entityId: result.company.id,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "company.imported",
details: {
include: body.include ?? null,
agentCount: result.agents.length,
warningCount: result.warnings.length,
companyAction: result.company.action,
importMode: "agent_safe",
},
});
res.json(result);
});
router.post("/", (req, _res, next) => {
assertBoard(req);
if (isCloudManagedInstance()) {
throw forbidden("Company creation is managed by Paperclip Cloud", {
code: "cloud_managed",
});
}
next();
}, validate(createCompanySchema), async (req, res) => {
assertBoard(req);
if (!(req.actor.source === "local_implicit" || req.actor.isInstanceAdmin)) {
throw forbidden("Instance admin required");
}
const ownerPrincipalId = req.actor.userId ?? "local-board";
const company = await svc.create({
...req.body,
defaultResponsibleUserId: req.body.defaultResponsibleUserId ?? ownerPrincipalId,
});
await access.ensureMembership(company.id, "user", ownerPrincipalId, "owner", "active");
await access.ensureRoleDefaultGrants(
company.id,
ownerPrincipalId,
"owner",
req.actor.userId ?? null,
);
await logActivity(db, {
companyId: company.id,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "company.created",
entityType: "company",
entityId: company.id,
details: { name: company.name },
});
if (company.budgetMonthlyCents > 0) {
await budgets.upsertPolicy(
company.id,
{
scopeType: "company",
scopeId: company.id,
amount: company.budgetMonthlyCents,
windowKind: "calendar_month_utc",
},
req.actor.userId ?? "board",
);
}
res.status(201).json(company);
});
router.patch("/:companyId", async (req, res) => {
const companyId = req.params.companyId as string;
await assertSameCompanyCeoAgentOrBoard(req, companyId, "company settings");
const actor = getActorInfo(req);
let body: Record<string, unknown>;
if (req.actor.type === "agent") {
body = updateCompanyBrandingSchema.parse(req.body);
} else {
body = updateCompanySchema.parse(req.body);
}
const existingCompany = await svc.getById(companyId);
if (!existingCompany) {
res.status(404).json({ error: "Company not found" });
return;
}
if (req.actor.type !== "agent") {
if (body.feedbackDataSharingEnabled === true && !existingCompany.feedbackDataSharingEnabled) {
body = {
...body,
feedbackDataSharingConsentAt: new Date(),
feedbackDataSharingConsentByUserId: req.actor.userId ?? "local-board",
feedbackDataSharingTermsVersion:
typeof body.feedbackDataSharingTermsVersion === "string" && body.feedbackDataSharingTermsVersion.length > 0
? body.feedbackDataSharingTermsVersion
: DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION,
};
}
}
const transitionsToArchived =
body.status === "archived" && existingCompany.status !== "archived";
const transitionsArchivedToActive =
body.status === "active" && existingCompany.status === "archived";
let transitionsPausedToActiveWithArchivePausedAgents = false;
if (body.status === "active" && existingCompany.status === "paused") {
const [archivedPausedCount] = await db
.select({ value: countFn() })
.from(agentsTable)
.where(and(
eq(agentsTable.companyId, companyId),
eq(agentsTable.status, "paused"),
eq(agentsTable.pauseReason, "company_archived"),
));
transitionsPausedToActiveWithArchivePausedAgents =
Number(archivedPausedCount?.value ?? 0) > 0;
}
const lifecycleEventEmittedByService =
transitionsToArchived ||
transitionsArchivedToActive ||
transitionsPausedToActiveWithArchivePausedAgents;
const company = await svc.update(companyId, body, actor);
if (!company) {
res.status(404).json({ error: "Company not found" });
return;
}
if (!lifecycleEventEmittedByService) {
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "company.updated",
entityType: "company",
entityId: companyId,
details: body,
});
}
res.json(company);
});
router.patch("/:companyId/branding", async (req, res) => {
const companyId = req.params.companyId as string;
await assertSameCompanyCeoAgentOrBoard(req, companyId, "company branding");
const body = updateCompanyBrandingSchema.parse(req.body);
const company = await svc.update(companyId, body);
if (!company) {
res.status(404).json({ error: "Company not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "company.branding_updated",
entityType: "company",
entityId: companyId,
details: body,
});
res.json(company);
});
router.post("/:companyId/archive", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertBoard(req);
const company = await svc.archive(companyId, getActorInfo(req));
if (!company) {
res.status(404).json({ error: "Company not found" });
return;
}
res.json(company);
});
router.delete("/:companyId", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertBoard(req);
const company = await svc.remove(companyId);
if (!company) {
res.status(404).json({ error: "Company not found" });
return;
}
res.json({ ok: true });
});
return router;
}
type CompanyImportResult = {
company: { id: string; action: unknown };
agents: unknown[];
warnings: unknown[];
};
type ImportJobActorKind = "cloud_tenant" | "board";
/**
* In-memory only: import jobs do not survive a server restart, and terminal
* jobs are dropped after `importJobTerminalRetentionMs`. Both cases surface
* to pollers as the status route's 404 for an unknown id.
*/
interface ImportJobRecord {
id: string;
/** Identity that created the job; the only key allowed to read it. */
actorKey: string;
actorKind: ImportJobActorKind;
/**
* Fingerprint of the import request body. A resubmit is adopted (409 →
* watch the running job) only when it carries the same signature, so a
* *different* import from another tab is rejected instead of silently
* adopting an unrelated job (and its target). Board jobs only.
*/
signature?: string;
status: "running" | "succeeded" | "failed";
createdAt: string;
updatedAt: string;
completedAt?: string;
error?: { message: string };
result?: {
companyId: string;
agentCount: number;
warningCount: number;
companyAction: unknown;
};
/**
* Full import result, retained for board-created jobs only so the import
* page can run the same success path as the synchronous response. Cloud
* tenant job responses keep their original summary-only shape.
*/
fullResult?: CompanyPortabilityImportResult;
}
interface ImportedCompanyActivityContext {
actorType: "user" | "agent";
actorId: string;
agentId: string | null;
runId: string | null;
include: unknown;
}
function cloudTenantRequestKey(req: Request) {
return [
req.actor.userId ?? "",
req.header("x-paperclip-cloud-stack-id")?.trim() ?? "",
req.header("x-paperclip-cloud-paperclip-company-id")?.trim() ?? "",
].join(":");
}
/**
* Identity a job is created under and authorized against. Cloud tenant
* callers keep their header-derived tenant key; every other board session is
* keyed by its user id. The distinct prefixes keep the two namespaces
* disjoint, so crafted tenant headers can never collide with a board key.
*/
function importJobActorKey(req: Request) {
if (req.actor.source === "cloud_tenant") {
return `cloud-tenant:${cloudTenantRequestKey(req)}`;
}
return `board:${req.actor.userId ?? "local-board"}`;
}
function createImportJob(
actorKey: string,
actorKind: ImportJobActorKind,
signature?: string,
): ImportJobRecord {
const now = new Date().toISOString();
return {
// Cloud tenant job ids keep their original prefix; board jobs get their own.
id: `${actorKind === "cloud_tenant" ? "tenant-import" : "import"}-${randomUUID()}`,
actorKey,
actorKind,
signature,
status: "running",
createdAt: now,
updatedAt: now,
};
}
/**
* Stable fingerprint of an import request body. The same client resubmitting
* the same import produces a byte-identical body (no nonce/timestamp), so its
* signature matches; a different import differs. Used to tell a duplicate
* submit apart from a concurrent, unrelated import.
*/
function importRequestSignature(body: unknown): string {
return createHash("sha256").update(JSON.stringify(body) ?? "").digest("hex");
}
function findRunningImportJob(importJobs: Map<string, ImportJobRecord>, actorKey: string) {
for (const job of importJobs.values()) {
if (job.status === "running" && job.actorKey === actorKey) {
return job;
}
}
return undefined;
}
async function runImportJob(
job: ImportJobRecord,
operation: () => Promise<CompanyPortabilityImportResult>,
) {
try {
const result = await operation();
const now = new Date().toISOString();
job.status = "succeeded";
job.updatedAt = now;
job.completedAt = now;
job.result = {
companyId: result.company.id,
agentCount: result.agents.length,
warningCount: result.warnings.length,
companyAction: result.company.action,
};
if (job.actorKind === "board") {
job.fullResult = result;
}
} catch (error) {
const now = new Date().toISOString();
job.status = "failed";
job.updatedAt = now;
job.completedAt = now;
job.error = { message: errorMessage(error) };
}
}
function importedCompanyActivityContext(
actor: ReturnType<typeof getActorInfo>,
include: unknown,
): ImportedCompanyActivityContext {
return {
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
include,
};
}
async function logImportedCompanyActivity(
db: Db,
activity: ImportedCompanyActivityContext,
result: CompanyImportResult,
) {
await logActivity(db, {
companyId: result.company.id,
actorType: activity.actorType,
actorId: activity.actorId,
action: "company.imported",
entityType: "company",
entityId: result.company.id,
agentId: activity.agentId,
runId: activity.runId,
details: {
include: activity.include,
agentCount: result.agents.length,
warningCount: result.warnings.length,
companyAction: result.company.action,
},
});
}
function importJobStatusUrl(job: ImportJobRecord) {
return `/api/companies/import/jobs/${encodeURIComponent(job.id)}`;
}
function importJobAcceptedResponse(job: ImportJobRecord) {
return {
job: {
id: job.id,
status: job.status,
},
statusUrl: importJobStatusUrl(job),
retryAfterMs: 1000,
};
}
/**
* 409 body for a concurrent, *different* import while one is already running.
* Carries no job to adopt, so the client reports it as an error rather than
* watching (and switching to) an unrelated import's result.
*/
function importAlreadyRunningResponse() {
return {
error: "A different import is already running for this account. Wait for it to finish before starting another.",
};
}
/** 409 body for a duplicate submit: points at the job already running. */
function importJobConflictResponse(job: ImportJobRecord) {
return {
error: "An import is already running for this account",
job: {
id: job.id,
status: job.status,
},
statusUrl: importJobStatusUrl(job),
retryAfterMs: 1000,
};
}
function importJobResponse(job: ImportJobRecord) {
const isTerminal = job.status === "succeeded" || job.status === "failed";
const response: Record<string, unknown> = {
job: {
id: job.id,
status: job.status,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
...(job.completedAt ? { completedAt: job.completedAt } : {}),
...(job.error ? { error: job.error } : {}),
...(job.result ? { result: job.result } : {}),
// Board jobs additionally carry the full import result (parity with
// the synchronous response); cloud tenant jobs never set it.
...(job.fullResult ? { importResult: job.fullResult } : {}),
},
...(isTerminal ? {} : { retryAfterMs: 1000 }),
};
if (job.error?.message) {
response.error = job.error.message;
response.message = job.error.message;
response.reason = job.error.message;
}
return response;
}
function cleanupTerminalImportJobs(importJobs: Map<string, ImportJobRecord>, terminalRetentionMs: number) {
const now = Date.now();
for (const [jobId, job] of importJobs) {
if (job.status === "running" || !job.completedAt) continue;
if (now - Date.parse(job.completedAt) > terminalRetentionMs) {
importJobs.delete(jobId);
}
}
}
function errorMessage(error: unknown) {
return error instanceof Error && error.message.trim() ? error.message : String(error);
}