diff --git a/packages/dashboard/app/components/command-center/OrgPortabilityControls.css b/packages/dashboard/app/components/command-center/OrgPortabilityControls.css
new file mode 100644
index 0000000000..ac51f009a2
--- /dev/null
+++ b/packages/dashboard/app/components/command-center/OrgPortabilityControls.css
@@ -0,0 +1,81 @@
+/*
+FNXC:CommandCenterConfig 2026-07-18-12:00:
+The portability and history cards remain in the existing control grid on desktop
+and collapse to one column at the Command Center mobile breakpoint. Shared
+buttons retain the established token-based touch target instead of adding a
+parallel control variant.
+*/
+.cc-portability-card {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-sm);
+}
+
+.cc-portability-card--versions {
+ grid-column: 1 / -1;
+}
+
+.cc-portability-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-sm);
+}
+
+.cc-portability-import-label {
+ color: var(--text);
+ font-size: 0.8125rem;
+}
+
+.cc-portability-import-input {
+ min-block-size: calc(var(--space-2xl) * 3);
+ resize: vertical;
+}
+
+.cc-portability-version-list {
+ display: grid;
+ gap: var(--space-sm);
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.cc-portability-version {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-md);
+ padding-block: var(--space-sm);
+ border-block-end: 1px solid var(--border-subtle);
+}
+
+.cc-portability-version span {
+ display: grid;
+ gap: var(--space-xs);
+ min-inline-size: 0;
+}
+
+.cc-portability-version strong {
+ color: var(--text);
+ font-size: 0.8125rem;
+}
+
+.cc-portability-version small {
+ color: var(--text-muted);
+ font-size: 0.75rem;
+}
+
+@media (max-width: 768px) {
+ .cc-portability-actions {
+ flex-direction: column;
+ }
+
+ .cc-portability-actions .cc-controls-action {
+ inline-size: 100%;
+ justify-content: center;
+ }
+
+ .cc-portability-version {
+ align-items: stretch;
+ flex-direction: column;
+ }
+}
diff --git a/packages/dashboard/app/components/command-center/OrgPortabilityControls.tsx b/packages/dashboard/app/components/command-center/OrgPortabilityControls.tsx
new file mode 100644
index 0000000000..02e902c7ab
--- /dev/null
+++ b/packages/dashboard/app/components/command-center/OrgPortabilityControls.tsx
@@ -0,0 +1,184 @@
+import { useEffect, useRef, useState } from "react";
+import { Download, RotateCcw, Upload } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { api, withProjectId } from "../../api/legacy";
+import { useConfirm } from "../../hooks/useConfirm";
+import "./OrgPortabilityControls.css";
+
+interface ConfigurationRevision {
+ id: string;
+ configKind: string;
+ createdAt: string;
+ source: "mutation" | "rollback";
+ changedBy?: { kind?: string; id?: string };
+}
+
+interface OrgPortabilityControlsProps {
+ projectId?: string;
+ onSettingsRefresh: () => Promise
;
+}
+
+function downloadBundle(bundle: unknown) {
+ const href = URL.createObjectURL(new Blob([JSON.stringify(bundle, null, 2)], { type: "application/json" }));
+ const link = document.createElement("a");
+ link.href = href;
+ link.download = "fusion-org-bundle.json";
+ link.click();
+ URL.revokeObjectURL(href);
+}
+
+/*
+FNXC:CommandCenterConfig 2026-07-18-12:00:
+FR-05 puts portable org handoff and safe configuration undo beside operator
+controls. Import accepts the core's secret-scrubbed bundle only, and rollback is
+a single confirmed restore action rather than a manual reconstruction workflow.
+*/
+export function OrgPortabilityControls({ projectId, onSettingsRefresh }: OrgPortabilityControlsProps) {
+ const { t } = useTranslation("app");
+ const { confirm } = useConfirm();
+ const [exportState, setExportState] = useState<"idle" | "working" | "success" | "error">("idle");
+ const [importState, setImportState] = useState<"idle" | "previewing" | "previewed" | "importing" | "success" | "error">("idle");
+ const [bundleText, setBundleText] = useState("");
+ const [importError, setImportError] = useState(null);
+ const [preview, setPreview] = useState(null);
+ const [previewedBundle, setPreviewedBundle] = useState | null>(null);
+ const bundleVersion = useRef(0);
+ const [revisions, setRevisions] = useState(null);
+ const [revisionsError, setRevisionsError] = useState(null);
+ const [rollbackId, setRollbackId] = useState(null);
+
+ const revisionPath = withProjectId("/config/revisions", projectId);
+ const loadRevisions = async () => {
+ try {
+ setRevisionsError(null);
+ const response = await api<{ revisions?: ConfigurationRevision[] }>(revisionPath);
+ setRevisions(Array.isArray(response.revisions) ? response.revisions : []);
+ } catch (error) {
+ setRevisions(null);
+ setRevisionsError(error instanceof Error ? error.message : t("commandCenter.portability.versions.loadError", "Unable to load configuration versions"));
+ }
+ };
+
+ useEffect(() => { void loadRevisions(); }, [projectId]);
+
+ const exportOrg = async () => {
+ setExportState("working");
+ try {
+ const response = await api<{ bundle: unknown }>(withProjectId("/org/export", projectId), { method: "POST" });
+ downloadBundle(response.bundle);
+ setExportState("success");
+ } catch {
+ setExportState("error");
+ }
+ };
+
+ const parseBundle = (): Record | null => {
+ try {
+ const value = JSON.parse(bundleText) as unknown;
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid");
+ return value as Record;
+ } catch {
+ setImportError(t("commandCenter.portability.import.invalid", "Paste a valid org bundle JSON document"));
+ setImportState("error");
+ return null;
+ }
+ };
+
+ const previewImport = async () => {
+ const bundle = parseBundle();
+ if (!bundle) return;
+ const previewVersion = bundleVersion.current;
+ setImportState("previewing");
+ setImportError(null);
+ try {
+ const response = await api<{ result: unknown }>(withProjectId("/org/import", projectId), { method: "POST", body: JSON.stringify({ bundle, dryRun: true }) });
+ if (bundleVersion.current !== previewVersion) return;
+ setPreview(response.result);
+ setPreviewedBundle(bundle);
+ setImportState("previewed");
+ } catch (error) {
+ if (bundleVersion.current !== previewVersion) return;
+ setImportError(error instanceof Error ? error.message : t("commandCenter.portability.import.error", "Unable to preview import"));
+ setImportState("error");
+ }
+ };
+
+ const applyImport = async () => {
+ if (!previewedBundle) return;
+ const approved = await confirm({
+ title: t("commandCenter.portability.import.confirmTitle", "Import organization bundle?"),
+ message: t("commandCenter.portability.import.confirmMessage", "This applies the previewed configuration to this project."),
+ confirmLabel: t("commandCenter.portability.import.confirmApply", "Import bundle"),
+ cancelLabel: t("actions.cancel", "Cancel"),
+ });
+ if (!approved) return;
+ setImportState("importing");
+ try {
+ await api(withProjectId("/org/import", projectId), { method: "POST", body: JSON.stringify({ bundle: previewedBundle, dryRun: false }) });
+ await onSettingsRefresh();
+ await loadRevisions();
+ setImportState("success");
+ } catch (error) {
+ setImportError(error instanceof Error ? error.message : t("commandCenter.portability.import.error", "Unable to import bundle"));
+ setImportState("error");
+ }
+ };
+
+ const rollback = async (revision: ConfigurationRevision) => {
+ const approved = await confirm({
+ title: t("commandCenter.portability.versions.confirmTitle", "Roll back configuration?"),
+ message: t("commandCenter.portability.versions.confirmMessage", "Restore this version? The rollback is recorded as a new version."),
+ confirmLabel: t("commandCenter.portability.versions.confirmRollback", "Roll back"),
+ cancelLabel: t("actions.cancel", "Cancel"),
+ });
+ if (!approved) return;
+ setRollbackId(revision.id);
+ try {
+ await api(withProjectId(`/config/revisions/${encodeURIComponent(revision.id)}/rollback`, projectId), { method: "POST" });
+ await onSettingsRefresh();
+ await loadRevisions();
+ } catch (error) {
+ setRevisionsError(error instanceof Error ? error.message : t("commandCenter.portability.versions.rollbackError", "Unable to roll back configuration"));
+ } finally {
+ setRollbackId(null);
+ }
+ };
+
+ const exportLabel = exportState === "working" ? t("commandCenter.portability.export.working", "Exporting…") : t("commandCenter.portability.export.action", "Export org bundle");
+
+ return <>
+
+
+
{t("commandCenter.portability.title", "Org export / import")} {t("commandCenter.portability.description", "Download or preview a secret-scrubbed organization bundle.")}
+
{exportState === "success" ? t("commandCenter.portability.export.success", "Export ready") : exportState === "error" ? t("commandCenter.portability.export.error", "Export failed") : t("commandCenter.controls.status.ready", "Ready")}
+
+
+
void exportOrg()} disabled={exportState === "working"}> {exportLabel}
+
{t("commandCenter.portability.import.label", "Org bundle JSON")}
+
+ {preview ? {t("commandCenter.portability.import.previewReady", "Preview ready. Confirm to apply this bundle.")}
: null}
+ {importError ? {importError}
: null}
+
+
+
+ {t("commandCenter.portability.versions.title", "Configuration versions")} {t("commandCenter.portability.versions.description", "Restore any recorded project configuration version.")}
+ {revisionsError ? {revisionsError}
: revisions === null ? {t("commandCenter.portability.versions.loading", "Loading versions…")}
: revisions.length === 0 ? {t("commandCenter.portability.versions.empty", "No configuration versions yet.")}
: {revisions.map((revision) => {revision.configKind} {new Date(revision.createdAt).toLocaleString()} void rollback(revision)} disabled={rollbackId !== null}>{rollbackId === revision.id ? t("commandCenter.portability.versions.rollingBack", "Rolling back…") : <> {t("commandCenter.portability.versions.rollback", "Roll back")}>} )} }
+
+ >;
+}
diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx
index f08fd57e03..1203f91307 100644
--- a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx
+++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx
@@ -381,6 +381,8 @@ describe("CommandCenter mobile scroll regression (FN-6595)", () => {
await screen.findByTestId("command-center-empty");
expect(screen.getByTestId("command-center-controls")).toBeTruthy();
expect(screen.getByTestId("cc-controls-concurrency")).toBeTruthy();
+ expect(screen.getByTestId("cc-controls-org-portability")).toBeTruthy();
+ expect(screen.getByTestId("cc-controls-config-versions")).toBeTruthy();
expect(screen.queryByTestId("cc-controls-org-chart")).toBeNull();
expect(screen.queryByTestId("cc-controls-heartbeat")).toBeNull();
assertScrollOwnerContract(overviewPanel);
diff --git a/packages/dashboard/app/components/command-center/__tests__/OrgPortabilityControls.test.tsx b/packages/dashboard/app/components/command-center/__tests__/OrgPortabilityControls.test.tsx
new file mode 100644
index 0000000000..6207955911
--- /dev/null
+++ b/packages/dashboard/app/components/command-center/__tests__/OrgPortabilityControls.test.tsx
@@ -0,0 +1,94 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import { ConfirmDialogProvider } from "../../../hooks/useConfirm";
+import { OrgPortabilityControls } from "../OrgPortabilityControls";
+
+const api = vi.fn();
+vi.mock("../../../api/legacy", () => ({
+ api: (...args: unknown[]) => api(...args),
+ withProjectId: (path: string, projectId?: string) => projectId ? `${path}?projectId=${projectId}` : path,
+}));
+
+function renderControls() {
+ const onSettingsRefresh = vi.fn().mockResolvedValue(undefined);
+ render( );
+ return { onSettingsRefresh };
+}
+
+describe("OrgPortabilityControls", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.stubGlobal("URL", { createObjectURL: vi.fn(() => "blob:test"), revokeObjectURL: vi.fn() });
+ });
+ afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
+
+ it("renders the export/import and empty version surfaces", async () => {
+ api.mockResolvedValueOnce({ revisions: [] });
+ renderControls();
+
+ expect(await screen.findByTestId("cc-controls-org-portability")).toBeTruthy();
+ expect(screen.getByTestId("cc-controls-config-versions")).toBeTruthy();
+ expect(screen.getByTestId("cc-config-versions-empty")).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Export org bundle" })).toBeTruthy();
+ });
+
+ it("exports through the project-scoped route and reports success", async () => {
+ api.mockResolvedValueOnce({ revisions: [] }).mockResolvedValueOnce({ bundle: { version: 1 } });
+ renderControls();
+ await screen.findByTestId("cc-config-versions-empty");
+ fireEvent.click(screen.getByRole("button", { name: "Export org bundle" }));
+
+ await waitFor(() => expect(api).toHaveBeenCalledWith("/org/export?projectId=project-1", { method: "POST" }));
+ expect(await screen.findByText("Export ready")).toBeTruthy();
+ });
+
+ it("shows version load errors instead of an empty leftover list shell", async () => {
+ api.mockRejectedValueOnce(new Error("history unavailable"));
+ renderControls();
+
+ expect(await screen.findByRole("alert")).toHaveTextContent("history unavailable");
+ expect(screen.queryByTestId("cc-config-versions-empty")).toBeNull();
+ expect(screen.queryByTestId("cc-config-versions-list")).toBeNull();
+ });
+
+ it("previews then confirms and applies an import", async () => {
+ api.mockResolvedValueOnce({ revisions: [] }).mockResolvedValueOnce({ result: { created: { agents: ["agent"] } } }).mockResolvedValueOnce({ result: {} }).mockResolvedValueOnce({ revisions: [] });
+ renderControls();
+ await screen.findByTestId("cc-config-versions-empty");
+ fireEvent.change(screen.getByLabelText("Org bundle JSON"), { target: { value: '{"version":1}' } });
+ fireEvent.click(screen.getByRole("button", { name: "Preview import" }));
+ expect(await screen.findByTestId("cc-org-import-preview")).toBeTruthy();
+
+ fireEvent.click(screen.getByRole("button", { name: "Apply import" }));
+ fireEvent.click(await screen.findByRole("button", { name: "Import bundle" }));
+ await waitFor(() => expect(api).toHaveBeenCalledWith("/org/import?projectId=project-1", expect.objectContaining({ method: "POST", body: JSON.stringify({ bundle: { version: 1 }, dryRun: false }) })));
+ });
+
+ it("invalidates a preview when the bundle changes before its dry-run response", async () => {
+ let resolvePreview: ((value: { result: unknown }) => void) | undefined;
+ api.mockResolvedValueOnce({ revisions: [] }).mockImplementationOnce(() => new Promise<{ result: unknown }>((resolve) => { resolvePreview = resolve; }));
+ renderControls();
+ await screen.findByTestId("cc-config-versions-empty");
+
+ fireEvent.change(screen.getByLabelText("Org bundle JSON"), { target: { value: '{"version":1}' } });
+ fireEvent.click(screen.getByRole("button", { name: "Preview import" }));
+ fireEvent.change(screen.getByLabelText("Org bundle JSON"), { target: { value: '{"version":2}' } });
+ expect(screen.getByRole("button", { name: "Apply import" })).toBeDisabled();
+
+ resolvePreview?.({ result: { created: { agents: ["agent"] } } });
+ await waitFor(() => expect(screen.queryByTestId("cc-org-import-preview")).toBeNull());
+ expect(screen.getByRole("button", { name: "Apply import" })).toBeDisabled();
+ expect(api).toHaveBeenCalledTimes(2);
+ });
+
+ it("renders populated versions and rolls one back after confirmation", async () => {
+ api.mockResolvedValueOnce({ revisions: [{ id: "revision-1", configKind: "project-settings", createdAt: "2026-07-18T12:00:00.000Z", source: "mutation" }] }).mockResolvedValueOnce({ revision: { id: "forward" } }).mockResolvedValueOnce({ revisions: [] });
+ const { onSettingsRefresh } = renderControls();
+ expect(await screen.findByTestId("cc-config-versions-list")).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: "Roll back" }));
+ fireEvent.click(within(await screen.findByRole("dialog", { name: "Roll back configuration?" })).getByRole("button", { name: "Roll back" }));
+
+ await waitFor(() => expect(api).toHaveBeenCalledWith("/config/revisions/revision-1/rollback?projectId=project-1", { method: "POST" }));
+ expect(onSettingsRefresh).toHaveBeenCalled();
+ });
+});
diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts
index a6e7eee2d0..27e6f1fed6 100644
--- a/packages/dashboard/src/routes.ts
+++ b/packages/dashboard/src/routes.ts
@@ -173,6 +173,7 @@ import { registerAgentCoreListCreateRoutes, registerAgentCoreRoutes } from "./ro
import { registerAgentRuntimeRoutes } from "./routes/register-agent-runtime-routes.js";
import { registerAgentReflectionRatingRoutes } from "./routes/register-agent-reflection-rating-routes.js";
import { registerAgentImportExportRoutes, registerAgentGenerationRoutes } from "./routes/register-agent-import-export-generation-routes.js";
+import { registerOrgPortabilityRoutes } from "./routes/register-org-portability-routes.js";
import { registerAgentSkillsRoutes } from "./routes/register-agent-skills-routes.js";
import { registerPluginsAutomationRoutes } from "./routes/register-plugins-automation.js";
import { registerProxyRoutes } from "./routes/register-proxy-routes.js";
@@ -3390,6 +3391,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
});
registerAgentImportExportRoutes(routeContext);
+ registerOrgPortabilityRoutes(routeContext);
registerAgentCoreRoutes(routeContext, {
sanitizeAgentTaskLinks,
diff --git a/packages/dashboard/src/routes/__tests__/register-org-portability-routes.test.ts b/packages/dashboard/src/routes/__tests__/register-org-portability-routes.test.ts
new file mode 100644
index 0000000000..ea4546f11b
--- /dev/null
+++ b/packages/dashboard/src/routes/__tests__/register-org-portability-routes.test.ts
@@ -0,0 +1,109 @@
+// @vitest-environment node
+
+import express from "express";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { request } from "../../test-request.js";
+import { registerOrgPortabilityRoutes } from "../register-org-portability-routes.js";
+
+const core = vi.hoisted(() => ({
+ AgentStore: vi.fn(),
+ RoutineStore: vi.fn(),
+ AutomationStore: vi.fn(),
+ assembleOrgBundle: vi.fn(),
+ materializeOrgBundle: vi.fn(),
+ ConfigurationRevisionStore: vi.fn(),
+}));
+
+vi.mock("@fusion/core", () => core);
+
+function createApp() {
+ const store = {
+ getAsyncLayer: vi.fn(() => ({ projectId: "project-1" })),
+ getFusionDir: vi.fn(() => "/project/.fusion"),
+ getRootDir: vi.fn(() => "/project"),
+ rollbackConfiguration: vi.fn(),
+ };
+ const router = express.Router();
+ registerOrgPortabilityRoutes({
+ router,
+ store: store as never,
+ runtimeLogger: {} as never, planningLogger: {} as never, chatLogger: {} as never,
+ getProjectIdFromRequest: vi.fn(() => "project-1"),
+ getScopedStore: vi.fn(),
+ getProjectContext: vi.fn(async () => ({ store, projectId: "project-1" })),
+ prioritizeProjectsForCurrentDirectory: vi.fn(), emitRemoteRouteDiagnostic: vi.fn(), emitAuthSyncAuditLog: vi.fn(),
+ parseScopeParam: vi.fn(), resolveAutomationStore: vi.fn(), resolveRoutineStore: vi.fn(), resolveRoutineRunner: vi.fn(),
+ registerDispose: vi.fn(), dispose: vi.fn(), rethrowAsApiError: (error: unknown) => { throw error; },
+ });
+ const app = express();
+ app.use(express.json());
+ app.use("/api", router);
+ app.use((error: { statusCode?: number; message?: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
+ res.status(error.statusCode ?? 500).json({ error: error.message });
+ });
+ return { app, store };
+}
+
+describe("register-org-portability-routes", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ core.AgentStore.mockImplementation(function AgentStore() { return { init: vi.fn().mockResolvedValue(undefined) }; });
+ core.RoutineStore.mockImplementation(function RoutineStore() { return {}; });
+ core.AutomationStore.mockImplementation(function AutomationStore() { return {}; });
+ });
+
+ it("exports a scrubbed bundle and removes secret-bearing response keys", async () => {
+ core.assembleOrgBundle.mockResolvedValue({
+ settings: { apiKey: "leaked", nested: { daemonToken: "leaked", secretRef: "allowed-reference" } },
+ routines: [{ trigger: { secret: "leaked" } }],
+ });
+ const { app } = createApp();
+ const response = await request(app, "POST", "/api/org/export", "{}", { "Content-Type": "application/json" });
+
+ expect(response.status).toBe(200);
+ expect(response.body.bundle.settings).toEqual({ nested: { secretRef: "allowed-reference" } });
+ expect(JSON.stringify(response.body)).not.toContain("leaked");
+ expect(JSON.stringify(response.body)).not.toContain("apiKey");
+ expect(JSON.stringify(response.body)).not.toContain("daemonToken");
+ });
+
+ it("imports a scrubbed bundle in dry-run mode", async () => {
+ core.materializeOrgBundle.mockResolvedValue({ created: { agents: ["planned-agent"] } });
+ const { app } = createApp();
+ const response = await request(app, "POST", "/api/org/import", JSON.stringify({ bundle: { version: 1, apiKey: "nope" }, dryRun: true }), { "Content-Type": "application/json" });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ dryRun: true, result: { created: { agents: ["planned-agent"] } } });
+ expect(core.materializeOrgBundle).toHaveBeenCalledWith(expect.any(Object), { version: 1 }, { dryRun: true, collisionMode: undefined });
+ });
+
+ it("lists project revisions in the core's newest-first order", async () => {
+ const list = vi.fn().mockResolvedValue([{ id: "new" }, { id: "old" }]);
+ core.ConfigurationRevisionStore.mockImplementation(function ConfigurationRevisionStore() { return { list }; });
+ const { app } = createApp();
+ const response = await request(app, "GET", "/api/config/revisions");
+
+ expect(response.status).toBe(200);
+ expect(response.body.revisions.map((revision: { id: string }) => revision.id)).toEqual(["new", "old"]);
+ expect(list).toHaveBeenCalledWith("project-settings", { projectId: "project-1" });
+ });
+
+ it("rolls back through the core store and returns its forward revision", async () => {
+ const { app, store } = createApp();
+ store.rollbackConfiguration.mockResolvedValue({ id: "forward-revision", source: "rollback" });
+ const response = await request(app, "POST", "/api/config/revisions/prior/rollback", "{}", { "Content-Type": "application/json" });
+
+ expect(response.status).toBe(200);
+ expect(response.body.revision).toMatchObject({ id: "forward-revision", source: "rollback" });
+ expect(store.rollbackConfiguration).toHaveBeenCalledWith("prior", { kind: "human", id: "dashboard-operator" });
+ });
+
+ it("rejects malformed imports and unsupported revision filters", async () => {
+ const { app } = createApp();
+ const importResponse = await request(app, "POST", "/api/org/import", JSON.stringify({ bundle: [] }), { "Content-Type": "application/json" });
+ const listResponse = await request(app, "GET", "/api/config/revisions?configKind=routine");
+
+ expect(importResponse.status).toBe(400);
+ expect(listResponse.status).toBe(400);
+ });
+});
diff --git a/packages/dashboard/src/routes/register-org-portability-routes.ts b/packages/dashboard/src/routes/register-org-portability-routes.ts
new file mode 100644
index 0000000000..1849b3edc2
--- /dev/null
+++ b/packages/dashboard/src/routes/register-org-portability-routes.ts
@@ -0,0 +1,129 @@
+import { ApiError, badRequest } from "../api-error.js";
+import type { ApiRoutesContext } from "./types.js";
+
+/**
+ * Values that are never safe to return from a portability endpoint. Reference-only
+ * `secretRef` values remain portable because they do not contain secret material.
+ */
+const SECRET_RESPONSE_KEY = /(?:api[_-]?key|token|password|credential|auth|secret)(?!ref$)/i;
+
+function scrubResponseSecrets(value: unknown, key?: string): unknown {
+ if (key && SECRET_RESPONSE_KEY.test(key)) return undefined;
+ if (Array.isArray(value)) return value.map((entry) => scrubResponseSecrets(entry));
+ if (!value || typeof value !== "object") return value;
+ return Object.fromEntries(Object.entries(value as Record).flatMap(([entryKey, entryValue]) => {
+ const scrubbed = scrubResponseSecrets(entryValue, entryKey);
+ return scrubbed === undefined ? [] : [[entryKey, scrubbed]];
+ }));
+}
+
+function isRecord(value: unknown): value is Record {
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
+}
+
+/**
+ * Register project-scoped org portability and configuration history endpoints.
+ */
+export function registerOrgPortabilityRoutes(ctx: ApiRoutesContext): void {
+ const { router, getProjectContext, rethrowAsApiError } = ctx;
+
+ /*
+ FNXC:CommandCenterConfig 2026-07-18-12:00:
+ FR-05 requires the dashboard to export a portable organization bundle without
+ relying on a CLI handoff. The core assembler scrubs secrets by default; this
+ route applies a second response-boundary scrub so no credential value can be
+ exposed even if a future core caller accidentally returns one.
+ */
+ router.post("/org/export", async (req, res) => {
+ try {
+ const { store: scopedStore } = await getProjectContext(req);
+ // FNXC:CommandCenterConfig 2026-07-18-12:00: FN-8283 exports are intentionally typed at this route boundary until its core branch lands; do not duplicate bundle assembly or secret scrubbing in the dashboard.
+ const { AgentStore, RoutineStore, AutomationStore, assembleOrgBundle } = await import("@fusion/core") as unknown as {
+ AgentStore: new (options: { rootDir: string; asyncLayer?: unknown }) => { init(): Promise };
+ RoutineStore: new (rootDir: string, options: { asyncLayer?: unknown }) => unknown;
+ AutomationStore: new (rootDir: string, options: { asyncLayer?: unknown }) => unknown;
+ assembleOrgBundle: (stores: unknown) => Promise;
+ };
+ const asyncLayer = scopedStore.getAsyncLayer() ?? undefined;
+ const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir(), asyncLayer });
+ await agentStore.init();
+ const bundle = await assembleOrgBundle({
+ projectRoot: scopedStore.getRootDir(),
+ agentStore,
+ routineStore: new RoutineStore(scopedStore.getRootDir(), { asyncLayer }),
+ automationStore: new AutomationStore(scopedStore.getRootDir(), { asyncLayer }),
+ settingsStore: scopedStore,
+ });
+ res.json({ bundle: scrubResponseSecrets(bundle) });
+ } catch (error: unknown) {
+ if (error instanceof ApiError) throw error;
+ rethrowAsApiError(error);
+ }
+ });
+
+ router.post("/org/import", async (req, res) => {
+ try {
+ const { bundle, dryRun = false, collisionMode } = req.body ?? {};
+ if (!isRecord(bundle)) throw badRequest("bundle must be an object");
+ if (typeof dryRun !== "boolean") throw badRequest("dryRun must be a boolean");
+ if (collisionMode !== undefined && collisionMode !== "skip" && collisionMode !== "suffix") {
+ throw badRequest("collisionMode must be 'skip' or 'suffix'");
+ }
+ const { store: scopedStore } = await getProjectContext(req);
+ const { AgentStore, RoutineStore, AutomationStore, materializeOrgBundle } = await import("@fusion/core") as unknown as {
+ AgentStore: new (options: { rootDir: string; asyncLayer?: unknown }) => { init(): Promise };
+ RoutineStore: new (rootDir: string, options: { asyncLayer?: unknown }) => unknown;
+ AutomationStore: new (rootDir: string, options: { asyncLayer?: unknown }) => unknown;
+ materializeOrgBundle: (stores: unknown, bundle: Record, options: { dryRun: boolean; collisionMode?: "skip" | "suffix" }) => Promise;
+ };
+ const asyncLayer = scopedStore.getAsyncLayer() ?? undefined;
+ const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir(), asyncLayer });
+ await agentStore.init();
+ const result = await materializeOrgBundle({
+ projectRoot: scopedStore.getRootDir(), agentStore,
+ routineStore: new RoutineStore(scopedStore.getRootDir(), { asyncLayer }),
+ automationStore: new AutomationStore(scopedStore.getRootDir(), { asyncLayer }),
+ settingsStore: scopedStore,
+ }, scrubResponseSecrets(bundle) as Record, { dryRun, collisionMode });
+ res.json({ result: scrubResponseSecrets(result), dryRun });
+ } catch (error: unknown) {
+ if (error instanceof ApiError) throw error;
+ rethrowAsApiError(error);
+ }
+ });
+
+ router.get("/config/revisions", async (req, res) => {
+ try {
+ const configKind = req.query.configKind;
+ if (configKind !== undefined && configKind !== "project-settings") {
+ throw badRequest("configKind must be project-settings");
+ }
+ const { store: scopedStore, projectId } = await getProjectContext(req);
+ const layer = scopedStore.getAsyncLayer();
+ if (!layer) throw badRequest("Configuration history requires the PostgreSQL revision store");
+ // FNXC:CommandCenterConfig 2026-07-18-12:00: FN-8282's revision facade is consumed through this narrow compatibility type until the dependency export is merged into this branch.
+ const { ConfigurationRevisionStore } = await import("@fusion/core") as unknown as {
+ ConfigurationRevisionStore: new (layer: unknown, projectId?: string) => { list(kind: "project-settings", target: Record): Promise };
+ };
+ // FNXC:CommandCenterConfig 2026-07-18-12:00: Dashboard history starts with the project settings target because that is the configuration surface rendered beside these controls; rollback remains the core's exact, forward-recorded operation.
+ const revisions = await new ConfigurationRevisionStore(layer, projectId).list("project-settings", { projectId: projectId ?? "" });
+ res.json({ revisions });
+ } catch (error: unknown) {
+ if (error instanceof ApiError) throw error;
+ rethrowAsApiError(error);
+ }
+ });
+
+ router.post("/config/revisions/:revisionId/rollback", async (req, res) => {
+ try {
+ const revisionId = req.params.revisionId?.trim();
+ if (!revisionId) throw badRequest("revisionId is required");
+ const { store: scopedStore } = await getProjectContext(req);
+ const revision = await (scopedStore as unknown as { rollbackConfiguration(id: string, changedBy: { kind: "human"; id: string }): Promise }).rollbackConfiguration(revisionId, { kind: "human", id: "dashboard-operator" });
+ res.json({ revision });
+ } catch (error: unknown) {
+ if (error instanceof ApiError) throw error;
+ rethrowAsApiError(error);
+ }
+ });
+}
diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json
index a0db3031f7..f0a7aba7d8 100644
--- a/packages/i18n/locales/en/app.json
+++ b/packages/i18n/locales/en/app.json
@@ -1590,6 +1590,43 @@
},
"title": "Operator controls"
},
+ "portability": {
+ "title": "Org export / import",
+ "description": "Download or preview a secret-scrubbed organization bundle.",
+ "export": {
+ "action": "Export org bundle",
+ "working": "Exporting…",
+ "success": "Export ready",
+ "error": "Export failed"
+ },
+ "import": {
+ "label": "Org bundle JSON",
+ "placeholder": "Paste a secret-scrubbed org bundle",
+ "preview": "Preview import",
+ "previewing": "Previewing…",
+ "previewReady": "Preview ready. Confirm to apply this bundle.",
+ "apply": "Apply import",
+ "importing": "Importing…",
+ "invalid": "Paste a valid org bundle JSON document",
+ "error": "Unable to preview or import bundle",
+ "confirmTitle": "Import organization bundle?",
+ "confirmMessage": "This applies the previewed configuration to this project.",
+ "confirmApply": "Import bundle"
+ },
+ "versions": {
+ "title": "Configuration versions",
+ "description": "Restore any recorded project configuration version.",
+ "loading": "Loading versions…",
+ "loadError": "Unable to load configuration versions",
+ "rollbackError": "Unable to roll back configuration",
+ "empty": "No configuration versions yet.",
+ "rollback": "Roll back",
+ "rollingBack": "Rolling back…",
+ "confirmTitle": "Roll back configuration?",
+ "confirmMessage": "Restore this version? The rollback is recorded as a new version.",
+ "confirmRollback": "Roll back"
+ }
+ },
"ecosystem": {
"breadthTitle": "Ecosystem breadth",
"empty": "No models or plugins active in the selected range.",