feat: company portability — export/import companies and agents

Add company export, import preview, and import endpoints with manifest-
based bundle format. Includes URL key utilities for agents and projects,
collision detection/rename strategies, and secret requirement tracking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-03-02 09:06:58 -06:00
parent 79a9dffc80
commit cc2c724ad2
10 changed files with 1197 additions and 3 deletions

View File

@@ -1,14 +1,21 @@
import { Router } from "express";
import type { Db } from "@paperclip/db";
import { createCompanySchema, updateCompanySchema } from "@paperclip/shared";
import {
companyPortabilityExportSchema,
companyPortabilityImportSchema,
companyPortabilityPreviewSchema,
createCompanySchema,
updateCompanySchema,
} from "@paperclip/shared";
import { forbidden } from "../errors.js";
import { validate } from "../middleware/validate.js";
import { accessService, companyService, logActivity } from "../services/index.js";
import { assertBoard, assertCompanyAccess } from "./authz.js";
import { accessService, companyPortabilityService, companyService, logActivity } from "../services/index.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
export function companyRoutes(db: Db) {
const router = Router();
const svc = companyService(db);
const portability = companyPortabilityService(db);
const access = accessService(db);
router.get("/", async (req, res) => {
@@ -48,6 +55,50 @@ export function companyRoutes(db: Db) {
res.json(company);
});
router.post("/:companyId/export", validate(companyPortabilityExportSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const result = await portability.exportBundle(companyId, req.body);
res.json(result);
});
router.post("/import/preview", validate(companyPortabilityPreviewSchema), async (req, res) => {
if (req.body.target.mode === "existing_company") {
assertCompanyAccess(req, req.body.target.companyId);
} else {
assertBoard(req);
}
const preview = await portability.previewImport(req.body);
res.json(preview);
});
router.post("/import", validate(companyPortabilityImportSchema), async (req, res) => {
if (req.body.target.mode === "existing_company") {
assertCompanyAccess(req, req.body.target.companyId);
} else {
assertBoard(req);
}
const actor = getActorInfo(req);
const result = await portability.importBundle(req.body, req.actor.type === "board" ? req.actor.userId : null);
await logActivity(db, {
companyId: result.company.id,
actorType: actor.actorType,
actorId: actor.actorId,
action: "company.imported",
entityType: "company",
entityId: result.company.id,
agentId: actor.agentId,
runId: actor.runId,
details: {
include: req.body.include ?? null,
agentCount: result.agents.length,
warningCount: result.warnings.length,
companyAction: result.company.action,
},
});
res.json(result);
});
router.post("/", validate(createCompanySchema), async (req, res) => {
assertBoard(req);
if (!(req.actor.source === "local_implicit" || req.actor.isInstanceAdmin)) {