Add CEO-safe company portability flows
Expose CEO-scoped import/export preview and apply routes, keep safe imports non-destructive, add export preview-first UI behavior, and document the new portability workflows. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -42,6 +42,20 @@ export function companyRoutes(db: Db) {
|
||||
}
|
||||
}
|
||||
|
||||
async function assertCanManagePortability(req: Request, companyId: string, capability: "imports" | "exports") {
|
||||
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 company ${capability}`);
|
||||
}
|
||||
}
|
||||
|
||||
router.get("/", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const result = await svc.list();
|
||||
@@ -94,20 +108,18 @@ export function companyRoutes(db: Db) {
|
||||
});
|
||||
|
||||
router.post("/import/preview", validate(companyPortabilityPreviewSchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
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) => {
|
||||
assertBoard(req);
|
||||
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);
|
||||
@@ -130,6 +142,70 @@ export function companyRoutes(db: Db) {
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.post("/:companyId/exports/preview", validate(companyPortabilityExportSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertCanManagePortability(req, companyId, "exports");
|
||||
const preview = await portability.previewExport(companyId, req.body);
|
||||
res.json(preview);
|
||||
});
|
||||
|
||||
router.post("/:companyId/exports", validate(companyPortabilityExportSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertCanManagePortability(req, companyId, "exports");
|
||||
const result = await portability.exportBundle(companyId, req.body);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.post("/:companyId/imports/preview", validate(companyPortabilityPreviewSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertCanManagePortability(req, companyId, "imports");
|
||||
if (req.body.target.mode === "existing_company" && req.body.target.companyId !== companyId) {
|
||||
throw forbidden("Safe import route can only target the route company");
|
||||
}
|
||||
if (req.body.collisionStrategy === "replace") {
|
||||
throw forbidden("Safe import route does not allow replace collision strategy");
|
||||
}
|
||||
const preview = await portability.previewImport(req.body, {
|
||||
mode: "agent_safe",
|
||||
sourceCompanyId: companyId,
|
||||
});
|
||||
res.json(preview);
|
||||
});
|
||||
|
||||
router.post("/:companyId/imports/apply", validate(companyPortabilityImportSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertCanManagePortability(req, companyId, "imports");
|
||||
if (req.body.target.mode === "existing_company" && req.body.target.companyId !== companyId) {
|
||||
throw forbidden("Safe import route can only target the route company");
|
||||
}
|
||||
if (req.body.collisionStrategy === "replace") {
|
||||
throw forbidden("Safe import route does not allow replace collision strategy");
|
||||
}
|
||||
const actor = getActorInfo(req);
|
||||
const result = await portability.importBundle(req.body, req.actor.type === "board" ? req.actor.userId : null, {
|
||||
mode: "agent_safe",
|
||||
sourceCompanyId: companyId,
|
||||
});
|
||||
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,
|
||||
action: "company.imported",
|
||||
details: {
|
||||
include: req.body.include ?? null,
|
||||
agentCount: result.agents.length,
|
||||
warningCount: result.warnings.length,
|
||||
companyAction: result.company.action,
|
||||
importMode: "agent_safe",
|
||||
},
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.post("/", validate(createCompanySchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
if (!(req.actor.source === "local_implicit" || req.actor.isInstanceAdmin)) {
|
||||
|
||||
Reference in New Issue
Block a user