feat(FN-2548): extract dashboard routes into registrar modules

- Split monolithic dashboard route registration into dedicated project, node, mesh discovery, and settings sync registrar files
- Add settings sync helper and inbound sync registrar modules to isolate shared logic and simplify route composition
- Expand routes test coverage to enforce registrar ordering and prevent regressions after extraction
- Update routes README and clean up lint/doc issues introduced during the refactor
This commit is contained in:
Fusion
2026-04-26 06:23:14 -07:00
committed by gsxdsm
parent 6da077237c
commit 559e908865
11 changed files with 2307 additions and 2167 deletions

View File

@@ -210,6 +210,45 @@ async function REQUEST(
return { status: res.status, body: res.body };
}
function collectOrderedRouteKeys(router: express.Router): string[] {
const stack = (router as unknown as {
stack?: Array<{ route?: { path?: string; methods?: Record<string, boolean> } }>;
}).stack ?? [];
const orderedKeys: string[] = [];
for (const layer of stack) {
const route = layer.route;
if (!route?.path || !route.methods) continue;
const method = Object.keys(route.methods).find((name) => route.methods?.[name]);
if (!method) continue;
orderedKeys.push(`${method.toUpperCase()} ${route.path}`);
}
return orderedKeys;
}
describe("route registrar ordering invariants", () => {
it("keeps project, node settings, and mesh/discovery precedence-sensitive routes ordered", () => {
const router = createApiRoutes(createMockStore());
const orderedKeys = collectOrderedRouteKeys(router);
const indexOf = (routeKey: string): number => orderedKeys.indexOf(routeKey);
expect(indexOf("GET /projects/across-nodes")).toBeGreaterThan(-1);
expect(indexOf("POST /projects/detect")).toBeGreaterThan(-1);
expect(indexOf("GET /projects/:id")).toBeGreaterThan(-1);
expect(indexOf("GET /projects/across-nodes")).toBeLessThan(indexOf("GET /projects/:id"));
expect(indexOf("POST /projects/detect")).toBeLessThan(indexOf("GET /projects/:id"));
expect(indexOf("GET /nodes/:id/settings")).toBeLessThan(indexOf("POST /nodes/:id/settings/push"));
expect(indexOf("GET /nodes/:id/settings")).toBeLessThan(indexOf("POST /nodes/:id/settings/pull"));
expect(indexOf("GET /nodes/:id/settings")).toBeLessThan(indexOf("GET /nodes/:id/settings/sync-status"));
expect(indexOf("GET /nodes/:id/settings")).toBeLessThan(indexOf("POST /nodes/:id/auth/sync"));
expect(indexOf("GET /mesh/state")).toBeLessThan(indexOf("POST /mesh/sync"));
expect(indexOf("GET /discovery/status")).toBeGreaterThan(indexOf("POST /mesh/sync"));
});
});
describe("routes/context project scoping helpers", () => {
it("prefers query.projectId over body.projectId", () => {
const req = {

File diff suppressed because it is too large Load Diff

View File

@@ -24,7 +24,14 @@ The context provides core cross-cutting plumbing:
## Registrar module map
- `register-settings-memory-routes.ts` — settings APIs and memory backend/file/insight routes
- `register-settings-memory-routes.ts` — settings APIs and memory backend/file/insight routes (excluding node-to-node sync endpoints)
- `register-project-routes.ts``/projects` CRUD + `/projects/across-nodes`, `/projects/detect`, health/config/pause/resume routes
- `register-node-routes.ts``/nodes` CRUD + operational endpoints (`/health-check`, `/metrics`, `/version`, `/sync-plugins`, `/compatibility`)
- `register-settings-sync-routes.ts` — node settings/auth sync routes (`/nodes/:id/settings*`, `/nodes/:id/auth/sync`)
- `register-mesh-routes.ts` — mesh topology routes (`/mesh/state`, `/mesh/sync`)
- `register-discovery-routes.ts` — discovery routes (`/discovery/status|start|stop|nodes|connect`) with `options?.centralCore` reuse
- `register-settings-sync-inbound-routes.ts` — inbound sync/auth endpoints (`/settings/sync-receive`, `/settings/auth-receive`, `/settings/auth-export`)
- `register-settings-sync-helpers.ts` — shared sync-domain helpers (`fetchFromRemoteNode`, `readStoredAuthProvidersFromDisk`)
- `register-task-workflow-routes.ts` — task/workflow domain (`/tasks*`, `/documents`, task comments/docs/checkout/spec/attachments, PR+issue status, task file/diff endpoints)
- `register-planning-subtask-routes.ts` — planning sessions and subtask breakdown routes
- `register-chat-routes.ts` — chat session/list/mutation/stream routes
@@ -46,7 +53,13 @@ Express matches in registration order. Keep registrar and in-registrar route ord
1. **Specific operation routes before generic parameterized routes** (`/runs`, `/runs/:id`, `/copy`, `/delete` before `/:id` style handlers)
2. **Specific operation routes before wildcard paths** (`/files/{*filepath}/copy|move|delete` before catch-all file write routes)
3. **Do not move proxy/script/message/file wildcards ahead of specific routes**
4. **Agent ordering constraints must stay intact**:
4. **Project/node/sync/discovery ordering constraints must stay intact**:
- `/projects/across-nodes` and `/projects/detect` must be registered before `/projects/:id`
- `/nodes/:id/settings` must be registered before `/nodes/:id/settings/push|pull|sync-status` and before `/nodes/:id/auth/sync`
- `/mesh/state` must be registered before `/mesh/sync`
- Discovery routes stay grouped after mesh routes
- Inbound `/settings/sync-receive|auth-receive|auth-export` routes mount after discovery routes
5. **Agent ordering constraints must stay intact**:
- `/agents/stats`, `/agents/org-tree`, `/agents/resolve/:shortname` before `/agents/:id`
- `/agents/:id/runs/stop` before `/agents/:id/runs/:runId`
- `/agents/:id/reflections/latest` before `/agents/:id/reflections`

View File

@@ -0,0 +1,200 @@
import { ApiError, badRequest } from "../api-error.js";
import type { ApiRouteRegistrar } from "./types.js";
export const registerDiscoveryRoutes: ApiRouteRegistrar = (ctx) => {
const { router, options, rethrowAsApiError } = ctx;
// ── Node Discovery Routes (mDNS / DNS-SD) ────────────────────────────────
/**
* GET /api/discovery/status
* Returns whether discovery is active and the current config.
*/
router.get("/discovery/status", async (_req, res) => {
try {
const central = options?.centralCore ?? new (await import("@fusion/core")).CentralCore();
const shouldClose = !options?.centralCore;
if (shouldClose) await central.init();
const active = central.isDiscoveryActive();
const config = central.getDiscoveryConfig();
if (shouldClose) await central.close();
res.json({ active, config });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* POST /api/discovery/start
* Body: { broadcast?: boolean, listen?: boolean, port?: number, serviceType?: string }
*/
router.post("/discovery/start", async (req, res) => {
try {
const broadcast = req.body?.broadcast ?? true;
const listen = req.body?.listen ?? true;
const requestPort = req.body?.port;
const serviceType = typeof req.body?.serviceType === "string" && req.body.serviceType.trim().length > 0
? req.body.serviceType.trim()
: "_fusion._tcp";
if (typeof broadcast !== "boolean") {
throw badRequest("broadcast must be a boolean");
}
if (typeof listen !== "boolean") {
throw badRequest("listen must be a boolean");
}
if (
requestPort !== undefined
&& (typeof requestPort !== "number" || !Number.isFinite(requestPort) || requestPort < 1)
) {
throw badRequest("port must be a number >= 1");
}
const localPort = typeof req.socket.localPort === "number" && req.socket.localPort > 0
? req.socket.localPort
: 4040;
const port = requestPort ?? localPort;
const config: import("@fusion/core").DiscoveryConfig = {
broadcast,
listen,
serviceType,
port,
staleTimeoutMs: 300_000,
};
const central = options?.centralCore ?? new (await import("@fusion/core")).CentralCore();
const shouldClose = !options?.centralCore;
if (shouldClose) await central.init();
await central.startDiscovery(config);
if (shouldClose) await central.close();
res.json({ success: true, config });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* POST /api/discovery/stop
* Stops active mDNS discovery.
*/
router.post("/discovery/stop", async (_req, res) => {
try {
const central = options?.centralCore ?? new (await import("@fusion/core")).CentralCore();
const shouldClose = !options?.centralCore;
if (shouldClose) await central.init();
central.stopDiscovery();
if (shouldClose) await central.close();
res.json({ success: true });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* GET /api/discovery/nodes
* List currently discovered nodes.
*/
router.get("/discovery/nodes", async (_req, res) => {
try {
const central = options?.centralCore ?? new (await import("@fusion/core")).CentralCore();
const shouldClose = !options?.centralCore;
if (shouldClose) await central.init();
const nodes = central.getDiscoveredNodes();
if (shouldClose) await central.close();
res.json(nodes);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* POST /api/discovery/connect
* Register a discovered node in the node registry.
* Body: { name: string, host: string, port: number, apiKey?: string }
*/
router.post("/discovery/connect", async (req, res) => {
try {
const { name, host, port, apiKey } = req.body as {
name?: unknown;
host?: unknown;
port?: unknown;
apiKey?: unknown;
};
if (typeof name !== "string" || !name.trim()) {
throw badRequest("name is required and must be a non-empty string");
}
if (typeof host !== "string" || !host.trim()) {
throw badRequest("host is required and must be a non-empty string");
}
if (typeof port !== "number" || !Number.isFinite(port) || port < 1) {
throw badRequest("port is required and must be a number >= 1");
}
if (apiKey !== undefined && typeof apiKey !== "string") {
throw badRequest("apiKey must be a string");
}
let normalizedHost = host.trim();
try {
const url = new URL(normalizedHost);
normalizedHost = url.hostname;
} catch {
normalizedHost = normalizedHost.replace(/^https?:\/\//, "");
}
normalizedHost = normalizedHost.split("/")[0] ?? normalizedHost;
const normalizedUrl = `http://${normalizedHost}:${port}`;
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const node = await central.registerNode({
name: name.trim(),
type: "remote",
url: normalizedUrl,
apiKey: typeof apiKey === "string" && apiKey.length > 0 ? apiKey : undefined,
});
try {
await central.checkNodeHealth(node.id);
} catch {
// Best effort only; registration itself succeeded.
}
await central.close();
res.json(node);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
const status = (err instanceof Error ? err.message : String(err)).includes("already exists")
? 409
: (err instanceof Error ? err.message : String(err)).includes("must")
? 400
: 500;
throw new ApiError(status, err instanceof Error ? err.message : String(err));
}
});
};

View File

@@ -0,0 +1,210 @@
import { ApiError, badRequest } from "../api-error.js";
import type { ApiRouteRegistrar } from "./types.js";
export const registerMeshRoutes: ApiRouteRegistrar = (ctx) => {
const { router, store, emitRemoteRouteDiagnostic, rethrowAsApiError } = ctx;
// ── Mesh Topology Routes ────────────────────────────────────────────────
/**
* GET /api/mesh/state
* Returns the full mesh topology state with peer connections between nodes.
*/
router.get("/mesh/state", async (_req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const nodes = await central.listNodes();
const remoteNodes = nodes.filter((n) => n.type === "remote");
const meshState: unknown[] = [];
for (const node of nodes) {
const state = typeof (central as InstanceType<typeof CentralCore>).getMeshState === "function"
? await (central as InstanceType<typeof CentralCore>).getMeshState(node.id)
: null;
if (state) {
meshState.push(state);
} else {
const connections =
node.type === "local"
? remoteNodes.map((peer) => ({
peerId: peer.id,
peerName: peer.name,
peerUrl: peer.url ?? null,
status: peer.status,
}))
: [];
meshState.push({
nodeId: node.id,
nodeName: node.name,
nodeUrl: node.url ?? null,
type: node.type,
status: node.status,
metrics: null,
lastSeen: node.updatedAt ?? null,
connectedAt: node.createdAt ?? null,
knownPeers: connections,
connections,
});
}
}
await central.close();
res.json(meshState);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* POST /api/mesh/sync
* Exchange peer information with another node for gossip protocol.
*
* Request body: PeerSyncRequest (may include optional settings field)
* Response body: PeerSyncResponse (may include optional settings field)
*/
router.post("/mesh/sync", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
// Validate required fields
const senderNodeId = req.body?.senderNodeId;
if (!senderNodeId) {
throw badRequest("senderNodeId is required");
}
const knownPeers = req.body?.knownPeers;
if (!Array.isArray(knownPeers)) {
throw badRequest("knownPeers must be an array");
}
// Optional: validate knownPeers entries have required fields
for (const peer of knownPeers) {
if (!peer?.nodeId || !peer?.nodeName || typeof peer?.status !== "string") {
throw badRequest("Each knownPeers entry must have nodeId, nodeName, and status");
}
}
// Get sender node from registry to validate auth
const senderNode = await central.getNode(senderNodeId);
// Auth validation: if sender is registered with an apiKey, validate it
if (senderNode?.apiKey) {
const authHeader = req.headers.authorization;
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : undefined;
if (!token || token !== senderNode.apiKey) {
await central.close();
res.status(401).json({ error: "Unauthorized" });
return;
}
}
// Merge incoming peer data
await central.mergePeers(knownPeers);
// Update sender node status to online (it sent us a request, so it's alive)
try {
await central.updateNode(senderNodeId, { status: "online" });
} catch {
// Silently skip if sender node not found in local registry
}
// Get all known peers
const allKnownPeers = await central.getAllKnownPeerInfo();
// Calculate newPeers - peers the sender doesn't know about
const senderKnownIds = new Set(knownPeers.map((p: { nodeId: string }) => p.nodeId));
const newPeers = allKnownPeers.filter((peer) => !senderKnownIds.has(peer.nodeId));
// Get local node info
const localPeer = await central.getLocalPeerInfo();
// ── Settings sync: handle incoming settings and prepare response ──
let responseSettings: import("@fusion/core").SettingsSyncPayload | undefined;
const remoteSettings = req.body?.settings;
if (remoteSettings) {
try {
// Get local settings from the dashboard's GlobalSettingsStore
const localGlobal = await store.getGlobalSettingsStore().getSettings();
const localPayload = await central.getSettingsForSync(localGlobal);
const localChecksum = localPayload.checksum;
// Apply remote settings if checksum differs (remote is newer/different)
if (remoteSettings.checksum !== localChecksum) {
const applyResult = await central.applyRemoteSettings(remoteSettings);
if (applyResult.success) {
emitRemoteRouteDiagnostic({
route: "mesh-sync",
message: "Applied remote settings payload",
nodeId: senderNodeId,
upstreamPath: "/api/mesh/sync",
operationStage: "apply-remote-settings",
level: "info",
context: {
globalCount: applyResult.globalCount,
projectCount: applyResult.projectCount,
authCount: applyResult.authCount,
},
});
} else {
emitRemoteRouteDiagnostic({
route: "mesh-sync",
message: "Failed to apply remote settings payload",
nodeId: senderNodeId,
upstreamPath: "/api/mesh/sync",
operationStage: "apply-remote-settings",
level: "warn",
error: new Error(applyResult.error ?? "Unknown applyRemoteSettings failure"),
});
}
}
// Always respond with our settings if sender included theirs
responseSettings = localPayload;
} catch (err) {
// Log but don't fail the sync - peers are more important
emitRemoteRouteDiagnostic({
route: "mesh-sync",
message: "Settings sync operation failed",
nodeId: senderNodeId,
upstreamPath: "/api/mesh/sync",
operationStage: "settings-sync",
error: err,
});
}
}
await central.close();
// Return sync response
const response: Record<string, unknown> = {
senderNodeId: localPeer.nodeId,
senderNodeUrl: localPeer.nodeUrl,
knownPeers: allKnownPeers,
newPeers,
timestamp: new Date().toISOString(),
};
// Include settings in response if sender sent settings
if (responseSettings) {
response.settings = responseSettings;
}
res.json(response);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
};

View File

@@ -0,0 +1,364 @@
import { ApiError, badRequest, notFound } from "../api-error.js";
import type { ApiRouteRegistrar } from "./types.js";
export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
const { router, rethrowAsApiError } = ctx;
// ── Node Management Routes (Multi-Node Support) ───────────────────────────
/**
* GET /api/nodes
* List all registered nodes.
* Returns: NodeConfig[]
*/
router.get("/nodes", async (_req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const nodes = await central.listNodes();
await central.close();
nodes.sort((a, b) => a.name.localeCompare(b.name));
res.json(nodes);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* POST /api/nodes
* Register a new node.
* Body: { name, type, url?, apiKey?, maxConcurrent?, capabilities? }
*/
router.post("/nodes", async (req, res) => {
try {
const { name, type, url, apiKey, maxConcurrent, capabilities } = req.body;
if (!name || typeof name !== "string" || !name.trim()) {
throw badRequest("name is required and must be a non-empty string");
}
// Default to "remote" for backward compatibility with frontend API calls
const nodeType = type === "local" || type === "remote" ? type : "remote";
if (nodeType === "remote" && (!url || typeof url !== "string" || !url.trim())) {
throw badRequest("url is required for remote nodes");
}
if (
maxConcurrent !== undefined
&& (typeof maxConcurrent !== "number" || !Number.isFinite(maxConcurrent) || maxConcurrent < 1)
) {
throw badRequest("maxConcurrent must be a number >= 1");
}
if (
capabilities !== undefined
&& (!Array.isArray(capabilities) || capabilities.some((capability) => typeof capability !== "string"))
) {
throw badRequest("capabilities must be an array of strings");
}
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const node = await central.registerNode({
name: name.trim(),
type: nodeType,
url: typeof url === "string" ? url.trim() : undefined,
apiKey: typeof apiKey === "string" ? apiKey : undefined,
maxConcurrent,
capabilities,
});
await central.close();
res.status(201).json(node);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
const status = (err instanceof Error ? err.message : String(err)).includes("already exists")
? 409
: (err instanceof Error ? err.message : String(err)).includes("must")
? 400
: 500;
throw new ApiError(status, err instanceof Error ? err.message : String(err));
}
});
/**
* GET /api/nodes/:id
* Get node details by ID.
*/
router.get("/nodes/:id", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const node = await central.getNode(req.params.id);
await central.close();
if (!node) {
throw notFound("Node not found");
}
res.json(node);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* PATCH /api/nodes/:id
* Update node config.
*/
router.patch("/nodes/:id", async (req, res) => {
try {
const { name, url, apiKey, maxConcurrent, status, capabilities } = req.body;
const updates: Partial<Omit<import("@fusion/core").NodeConfig, "id" | "createdAt">> = {};
if (name !== undefined) updates.name = name;
if (url !== undefined) updates.url = url;
if (apiKey !== undefined) updates.apiKey = apiKey;
if (maxConcurrent !== undefined) updates.maxConcurrent = maxConcurrent;
if (status !== undefined) updates.status = status as import("@fusion/core").NodeStatus;
if (capabilities !== undefined) updates.capabilities = capabilities;
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const node = await central.updateNode(req.params.id, updates);
await central.close();
res.json(node);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
const status = (err instanceof Error ? err.message : String(err)).includes("not found")
? 404
: (err instanceof Error ? err.message : String(err)).includes("must")
? 400
: 500;
throw new ApiError(status, err instanceof Error ? err.message : String(err));
}
});
/**
* DELETE /api/nodes/:id
* Unregister a node.
*/
router.delete("/nodes/:id", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const existing = await central.getNode(req.params.id);
if (!existing) {
await central.close();
throw notFound("Node not found");
}
await central.unregisterNode(req.params.id);
await central.close();
res.status(204).end();
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* POST /api/nodes/:id/health-check
* Trigger health check for a node.
*/
router.post("/nodes/:id/health-check", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const healthStatus = await central.checkNodeHealth(req.params.id);
await central.close();
res.json({ status: healthStatus });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
const status = (err instanceof Error ? err.message : String(err)).includes("not found") ? 404 : 500;
throw new ApiError(status, err instanceof Error ? err.message : String(err));
}
});
/**
* GET /api/nodes/:id/metrics
* Get node runtime metrics (SystemMetrics from node's systemMetrics field).
*/
router.get("/nodes/:id/metrics", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const node = await central.getNode(req.params.id);
await central.close();
if (!node) {
throw notFound("Node not found");
}
// Return the systemMetrics field which contains SystemMetrics or null
res.json(node.systemMetrics ?? null);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* GET /api/nodes/:id/version
* Get version information for a node.
* Returns NodeVersionInfo when present, null when no version info has been stored yet.
*/
router.get("/nodes/:id/version", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const node = await central.getNode(req.params.id);
await central.close();
if (!node) {
throw notFound("Node not found");
}
// Return versionInfo if present, null if not yet stored
res.json(node.versionInfo ?? null);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* POST /api/nodes/:id/sync-plugins
* Compare plugin versions between the local node and a remote node.
* Returns PluginSyncResult with recommendations for each plugin.
*/
router.post("/nodes/:id/sync-plugins", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
// Validate target node exists
const targetNode = await central.getNode(req.params.id);
if (!targetNode) {
await central.close();
throw notFound("Node not found");
}
// Reject local target nodes - sync-plugins is for remote nodes only
if (targetNode.type === "local") {
await central.close();
throw badRequest("Cannot sync plugins to a local node - sync-plugins is for remote nodes only");
}
// Find the local node
const nodes = await central.listNodes();
const localNode = nodes.find((n) => n.type === "local");
if (!localNode) {
await central.close();
throw badRequest("Local node not registered - cannot perform sync");
}
// Perform plugin sync comparison
const result = await central.syncPlugins(localNode.id, targetNode.id);
await central.close();
res.json(result);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* GET /api/nodes/:id/compatibility
* Check version compatibility between the local node and a target node.
* Returns VersionCompatibilityResult based on app version comparison.
*/
router.get("/nodes/:id/compatibility", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
// Validate target node exists
const targetNode = await central.getNode(req.params.id);
if (!targetNode) {
await central.close();
throw notFound("Node not found");
}
// Find the local node
const nodes = await central.listNodes();
const localNode = nodes.find((n) => n.type === "local");
if (!localNode) {
await central.close();
throw badRequest("Local node not registered - cannot check compatibility");
}
// Get version info for both nodes
const localVersionInfo = await central.getNodeVersionInfo(localNode.id);
const targetVersionInfo = await central.getNodeVersionInfo(targetNode.id);
// Validate both have version info
if (!localVersionInfo) {
await central.close();
throw badRequest("Local node has no version info yet");
}
if (!targetVersionInfo) {
await central.close();
throw badRequest("Target node has no version info yet");
}
// Check compatibility using version strings
const result = central.checkVersionCompatibility(
localVersionInfo.appVersion,
targetVersionInfo.appVersion,
);
await central.close();
res.json(result);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
};

View File

@@ -0,0 +1,711 @@
import { execFile } from "node:child_process";
import * as fsPromises from "node:fs/promises";
import { dirname, isAbsolute, join } from "node:path";
import { promisify } from "node:util";
import { ensureMemoryFileWithBackend } from "@fusion/core";
import { ApiError, badRequest, notFound } from "../api-error.js";
import { getOrCreateProjectStore } from "../project-store-resolver.js";
import type { ApiRouteRegistrar } from "./types.js";
const {
access,
stat,
mkdir,
readdir,
rm,
} = fsPromises;
const execFileAsync = promisify(execFile);
export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => {
const { router, options, runtimeLogger, prioritizeProjectsForCurrentDirectory, rethrowAsApiError } = ctx;
// ── Project Management Routes (Multi-Project Support) ───────────────────────
// These routes require CentralCore which is imported dynamically to avoid
// circular dependencies and ensure the central database is initialized.
/**
* GET /api/projects
* List all registered projects with their basic info.
* Returns: ProjectInfo[]
*/
router.get("/projects", async (_req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
// Reconcile stale "initializing" projects before listing so the
// dashboard never shows permanent loading spinners for legacy records.
await central.reconcileProjectStatuses();
const projects = prioritizeProjectsForCurrentDirectory(await central.listProjects());
await central.close();
res.json(projects);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* GET /api/projects/across-nodes
* List all registered projects from all nodes (local + remote).
* Fetches projects from online remote nodes and merges with local projects.
* Returns: Array of projects with nodeId and _sourceNodeName for remote projects.
*/
router.get("/projects/across-nodes", async (_req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
// Reconcile stale "initializing" projects before listing
await central.reconcileProjectStatuses();
// Get local projects and registered nodes in parallel
const [localProjects, allNodes] = await Promise.all([
central.listProjects(),
central.listNodes(),
]);
// Filter to online remote nodes with URLs
const remoteNodes = allNodes.filter(
(node) => node.type === "remote" && node.status === "online" && node.url,
);
// Short-circuit: zero remote nodes means we behave exactly like /projects.
// Skip the Promise.allSettled machinery entirely so local-only setups pay
// no cross-node aggregation overhead.
if (remoteNodes.length === 0) {
const prioritizedProjects = prioritizeProjectsForCurrentDirectory(localProjects);
await central.close();
res.json(prioritizedProjects);
return;
}
// Fetch projects from all remote nodes in parallel
const remoteProjectArrays = await Promise.allSettled(
remoteNodes.map(async (node) => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(`${node.url}/api/projects`, {
headers: {
Authorization: `Bearer ${node.apiKey}`,
},
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const projects = (await response.json()) as Array<{
id: string;
name: string;
path: string;
status: "active" | "paused" | "errored" | "initializing";
isolationMode: "in-process" | "child-process";
nodeId?: string;
createdAt: string;
updatedAt: string;
lastActivityAt?: string;
}>;
// Tag each remote project with the source node info
return projects.map((project) => ({
...project,
nodeId: node.id,
_sourceNodeName: node.name,
}));
} finally {
clearTimeout(timeoutId);
}
}),
);
// Collect successful remote projects, log failures
type RemoteProject = {
id: string;
name: string;
path: string;
status: "active" | "paused" | "errored" | "initializing";
isolationMode: "in-process" | "child-process";
nodeId: string;
_sourceNodeName: string;
createdAt: string;
updatedAt: string;
lastActivityAt?: string;
};
const remoteProjects = remoteProjectArrays
.filter((result): result is PromiseFulfilledResult<RemoteProject[]> => result.status === "fulfilled")
.flatMap((result) => result.value);
// Log failures for any unreachable nodes
remoteProjectArrays.forEach((result, index) => {
if (result.status === "rejected") {
const node = remoteNodes[index];
runtimeLogger.child("projects:across-nodes").warn(
`Failed to fetch projects from node ${node?.id}: ${result.reason?.message ?? result.reason}`,
);
}
});
// Merge local and remote projects
const mergedProjects = [...localProjects, ...remoteProjects];
// Apply directory prioritization
const prioritizedProjects = prioritizeProjectsForCurrentDirectory(mergedProjects);
await central.close();
res.json(prioritizedProjects);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* POST /api/projects
* Register a new project.
* Body: {
* name: string,
* path: string,
* isolationMode?: "in-process" | "child-process",
* nodeId?: string,
* cloneUrl?: string
* }
* Returns: RegisteredProject
*/
router.post("/projects", async (req, res) => {
try {
const { name, path, isolationMode = "in-process", nodeId, cloneUrl } = req.body;
if (!name || typeof name !== "string" || !name.trim()) {
throw badRequest("name is required and must be a non-empty string");
}
if (!path || typeof path !== "string" || !path.trim()) {
throw badRequest("path is required and must be a non-empty string");
}
if (!["in-process", "child-process"].includes(isolationMode)) {
throw badRequest("isolationMode must be 'in-process' or 'child-process'");
}
const normalizedName = name.trim();
const normalizedPath = path.trim();
let normalizedCloneUrl: string | undefined;
if (normalizedPath.includes("\0")) {
throw badRequest("path cannot contain null bytes");
}
if (!isAbsolute(normalizedPath)) {
throw badRequest("path must be an absolute path");
}
if (cloneUrl !== undefined) {
if (typeof cloneUrl !== "string") {
throw badRequest("cloneUrl must be a non-empty string when provided");
}
const trimmedCloneUrl = cloneUrl.trim();
if (trimmedCloneUrl.length === 0) {
throw badRequest("cloneUrl must be a non-empty string when provided");
}
if (trimmedCloneUrl.includes("\0")) {
throw badRequest("cloneUrl cannot contain null bytes");
}
normalizedCloneUrl = trimmedCloneUrl;
}
const isCloneMode = normalizedCloneUrl !== undefined;
let destinationCreatedForClone = false;
if (!isCloneMode) {
// Existing-directory mode: path must already exist.
try {
await access(normalizedPath);
} catch {
throw badRequest("Project path does not exist");
}
} else {
// Clone mode: parent directory must exist.
const destinationParent = dirname(normalizedPath);
try {
await access(destinationParent);
} catch {
throw badRequest("Clone destination parent directory does not exist");
}
// Destination must either not exist yet, or be an empty directory.
let destinationExists = false;
try {
const destinationStats = await stat(normalizedPath);
destinationExists = true;
if (!destinationStats.isDirectory()) {
throw badRequest("Clone destination must be a directory path");
}
} catch (err) {
if ((err as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") {
throw err;
}
}
if (destinationExists) {
const entries = await readdir(normalizedPath);
if (entries.length > 0) {
throw badRequest("Clone destination must be empty");
}
} else {
await mkdir(normalizedPath, { recursive: false });
destinationCreatedForClone = true;
}
const cloneSource = normalizedCloneUrl;
if (!cloneSource) {
throw badRequest("cloneUrl must be a non-empty string when provided");
}
try {
await execFileAsync("git", ["clone", cloneSource, normalizedPath], {
timeout: 90_000,
maxBuffer: 10 * 1024 * 1024,
encoding: "utf-8",
});
} catch (cloneError) {
if (destinationCreatedForClone) {
try {
await rm(normalizedPath, { recursive: true, force: true });
} catch {
// Best-effort cleanup only.
}
}
const cloneErrorInfo = cloneError as Error & { stderr?: string; stdout?: string };
const details = [cloneErrorInfo.stderr, cloneErrorInfo.stdout, cloneErrorInfo.message]
.find((value) => typeof value === "string" && value.trim().length > 0)
?.toString()
.trim();
throw badRequest(`Git clone failed${details ? `: ${details}` : ""}`);
}
}
let hasFusionDir = false;
const fusionDirPath = join(normalizedPath, ".fusion");
try {
await access(fusionDirPath);
hasFusionDir = true;
} catch {
hasFusionDir = false;
}
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const project = await central.registerProject({
name: normalizedName,
path: normalizedPath,
isolationMode,
nodeId,
});
// Activate the project (registration sets it to 'initializing')
const activeProject = await central.updateProject(project.id, { status: "active" });
// Bootstrap memory files (non-blocking, non-fatal)
ensureMemoryFileWithBackend(normalizedPath).catch(() => {
// Memory bootstrap failure is non-fatal - project registration succeeded
});
// Notify the host (serve.ts/daemon.ts) so it can run project-setup
// side-effects like installing the fusion Claude-skill into
// .claude/skills/fusion when pi-claude-cli is configured. The callback
// is responsible for catching its own errors — a failure here must not
// fail the registration response.
if (options?.onProjectRegistered) {
try {
options.onProjectRegistered({
id: activeProject.id,
name: activeProject.name,
path: activeProject.path,
});
} catch (hookErr) {
runtimeLogger.warn(
`onProjectRegistered callback threw: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`,
);
}
}
await central.close();
res.status(201).json({ ...activeProject, _meta: { hasFusionDir: hasFusionDir ? undefined : false } });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
const status = (err instanceof Error ? err.message : String(err)).includes("already registered")
? 409
: (err instanceof Error ? err.message : String(err)).includes("Duplicate path")
? 409
: 500;
throw new ApiError(status, err instanceof Error ? err.message : String(err));
}
});
/**
* POST /api/projects/detect
* Auto-detect fn projects in a directory.
* Body: { basePath?: string }
* Returns: { projects: DetectedProject[] }
*/
router.post("/projects/detect", async (req, res) => {
try {
const { basePath } = req.body;
// Default to home directory if no basePath provided
const searchPath = basePath || process.env.HOME || process.env.USERPROFILE || ".";
// Check search path exists (async to avoid blocking event loop)
try {
await access(searchPath);
} catch {
throw badRequest("Base path does not exist");
}
// Get list of existing projects to check for duplicates
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const existingProjects = await central.listProjects();
await central.close();
const existingPaths = new Set(existingProjects.map((p: { path: string }) => p.path));
// Scan for .fusion/fusion.db or .fusion/fusion.db files (indicating fn projects)
const detected: Array<{ path: string; suggestedName: string; existing: boolean }> = [];
try {
const entries = await readdir(searchPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const dirPath = join(searchPath, entry.name);
// Check for .fusion/fusion.db or .fusion directory (async to avoid blocking event loop)
let hasKbDb = false;
let hasFusionDir = false;
try {
await access(join(dirPath, ".fusion", "fusion.db"));
hasKbDb = true;
} catch {
hasKbDb = false;
}
if (!hasKbDb) {
try {
await access(join(dirPath, ".fusion"));
hasFusionDir = true;
} catch {
hasFusionDir = false;
}
}
if (hasKbDb || hasFusionDir) {
detected.push({
path: dirPath,
suggestedName: entry.name,
existing: existingPaths.has(dirPath),
});
}
}
} catch {
// Ignore read errors
}
res.json({ projects: detected });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* GET /api/projects/:id
* Get a single project by ID.
*/
router.get("/projects/:id", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const project = await central.getProject(req.params.id);
await central.close();
if (!project) {
throw notFound("Project not found");
}
res.json(project);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* PATCH /api/projects/:id
* Update a project.
*/
router.patch("/projects/:id", async (req, res) => {
try {
const { name, status, isolationMode, nodeId } = req.body;
const updates: Partial<import("@fusion/core").RegisteredProject> = {};
if (name !== undefined) updates.name = name;
if (status !== undefined) updates.status = status as import("@fusion/core").ProjectStatus;
if (isolationMode !== undefined) updates.isolationMode = isolationMode as "in-process" | "child-process";
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const project = await central.updateProject(req.params.id, updates);
if (!project) {
await central.close();
throw notFound("Project not found");
}
let resultProject = project;
if (nodeId !== undefined) {
if (nodeId === null) {
resultProject = await central.unassignProjectFromNode(req.params.id);
} else if (typeof nodeId === "string" && nodeId.trim()) {
resultProject = await central.assignProjectToNode(req.params.id, nodeId.trim());
} else {
await central.close();
throw badRequest("nodeId must be a non-empty string or null");
}
}
await central.close();
res.json(resultProject);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
const status = (err instanceof Error ? err.message : String(err)).includes("not found") ? 404 : 500;
throw new ApiError(status, err instanceof Error ? err.message : String(err));
}
});
/**
* DELETE /api/projects/:id
* Unregister a project.
*/
router.delete("/projects/:id", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
await central.unregisterProject(req.params.id);
await central.close();
res.json({ success: true });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
const status = (err instanceof Error ? err.message : String(err)).includes("not found") ? 404 : 500;
throw new ApiError(status, err instanceof Error ? err.message : String(err));
}
});
/**
* GET /api/projects/:id/health
* Get health metrics for a specific project.
* Computes live task counts from the project-scoped task store to ensure
* accurate stats for all projects, not just the default/first project.
* Returns: ProjectHealth
*/
router.get("/projects/:id/health", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const project = await central.getProject(req.params.id);
if (!project) {
await central.close();
throw notFound("Project not found");
}
// Use the project-scoped store resolver to get the correct store for
// this project. This ensures we compute counts from the right project,
// regardless of which project is the dashboard's default.
const projectStore = await getOrCreateProjectStore(req.params.id);
// Compute live task counts from the project-specific store
const tasks = await projectStore.listTasks({ slim: true });
const activeCols = new Set(["triage", "todo", "in-progress", "in-review"]);
const activeTaskCount = tasks.filter((t) => activeCols.has(t.column)).length;
const inFlightAgentCount = tasks.filter((t) => t.column === "in-progress").length;
const totalTasksCompleted = tasks.filter((t) => t.column === "done" || t.column === "archived").length;
// Get central health metadata (if available) to preserve non-count fields
const centralHealth = await central.getProjectHealth(req.params.id);
await central.close();
// Build response: use central health as base if available, otherwise synthesize
const healthBase = centralHealth ?? {
projectId: req.params.id,
status: project.status ?? "active",
activeTaskCount: 0,
inFlightAgentCount: 0,
totalTasksCompleted: 0,
totalTasksFailed: 0,
updatedAt: new Date().toISOString(),
};
res.json({
...healthBase,
activeTaskCount,
inFlightAgentCount,
totalTasksCompleted,
});
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* GET /api/projects/:id/config
* Get project-specific configuration.
* Returns: { maxConcurrent: number, rootDir: string }
*/
router.get("/projects/:id/config", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const project = await central.getProject(req.params.id);
await central.close();
if (!project) {
throw notFound("Project not found");
}
res.json({
maxConcurrent: 2,
rootDir: project.path,
});
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* POST /api/projects/:id/pause
* Pause a project.
*/
router.post("/projects/:id/pause", async (req, res) => {
try {
const projectId = req.params.id;
// Use engineManager if available (production mode)
if (options?.engineManager) {
await options.engineManager.pauseProject(projectId);
} else {
// Fallback: update CentralCore directly (dev mode)
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
await central.updateProject(projectId, { status: "paused" });
await central.updateProjectHealth(projectId, { status: "paused" });
await central.close();
}
// Fetch and return the updated project
const { CentralCore: CentralCore2 } = await import("@fusion/core");
const central = new CentralCore2();
await central.init();
const project = await central.getProject(projectId);
await central.close();
if (!project) {
throw new ApiError(404, `Project ${projectId} not found`);
}
res.json(project);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
const status = (err instanceof Error ? err.message : String(err)).includes("not found") ? 404 : 500;
throw new ApiError(status, err instanceof Error ? err.message : String(err));
}
});
/**
* POST /api/projects/:id/resume
* Resume a paused project.
*/
router.post("/projects/:id/resume", async (req, res) => {
try {
const projectId = req.params.id;
// Use engineManager if available (production mode)
if (options?.engineManager) {
await options.engineManager.resumeProject(projectId);
} else {
// Fallback: update CentralCore directly (dev mode)
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
await central.updateProject(projectId, { status: "active" });
await central.updateProjectHealth(projectId, { status: "active" });
await central.close();
}
// Fetch and return the updated project
const { CentralCore: CentralCore2 } = await import("@fusion/core");
const central = new CentralCore2();
await central.init();
const project = await central.getProject(projectId);
await central.close();
if (!project) {
throw new ApiError(404, `Project ${projectId} not found`);
}
res.json(project);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
const status = (err instanceof Error ? err.message : String(err)).includes("not found") ? 404 : 500;
throw new ApiError(status, err instanceof Error ? err.message : String(err));
}
});
};

View File

@@ -34,9 +34,7 @@ import {
writeProjectMemoryFile,
updatePiExtensionDisabledIds,
} from "@fusion/core";
import { readFile as fsReadFile } from "node:fs/promises";
import { ApiError, badRequest } from "../api-error.js";
import { getAuthFileCandidates, getFusionAuthPath, type StoredAuthProvider } from "../auth-paths.js";
import { generateRemoteToken, issueRemoteAuthToken, maskRemoteToken } from "../remote-auth.js";
import { invalidateAllGlobalSettingsCaches } from "../project-store-resolver.js";
import type { ApiRoutesContext } from "./types.js";
@@ -48,20 +46,8 @@ interface SettingsMemoryRouteDeps {
discoverDashboardPiExtensions: (cwd: string) => Promise<PiExtensionSettings>;
}
async function readStoredAuthProvidersFromDisk(): Promise<Record<string, StoredAuthProvider>> {
for (const authJsonPath of getAuthFileCandidates()) {
try {
const authContent = await fsReadFile(authJsonPath, "utf-8");
return JSON.parse(authContent) as Record<string, StoredAuthProvider>;
} catch {
// Try next candidate.
}
}
return {};
}
export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: SettingsMemoryRouteDeps): void {
const { router, options, store, runtimeLogger, getProjectContext, rethrowAsApiError, emitAuthSyncAuditLog } = ctx;
const { router, options, store, runtimeLogger, getProjectContext, rethrowAsApiError } = ctx;
const { githubToken, validateModelPresets, sanitizeOverlapIgnorePaths, discoverDashboardPiExtensions } = deps;
function resolveRemoteBaseUrl(remoteAccess: NonNullable<Awaited<ReturnType<typeof store.getSettings>>["remoteAccess"]>): URL {
@@ -1132,218 +1118,6 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
}
});
// ── Inbound Settings Sync Endpoints ────────────────────────────────
// These endpoints are called by remote nodes to deliver settings or request auth data.
// They validate apiKey auth before accepting data.
/**
* POST /api/settings/sync-receive
* Receive pushed settings from a remote node.
* Body: SettingsSyncPayload with global, projects, exportedAt, checksum, version
* Returns: { success: true, appliedFields: string[], skippedFields: string[] }
*/
router.post("/settings/sync-receive", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore(store.getFusionDir());
await central.init();
// Validate auth - find local node and check apiKey
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
await central.close();
throw new ApiError(401, "Missing or invalid Authorization header");
}
const token = authHeader.slice(7);
const nodes = await central.listNodes();
const localNode = nodes.find((n: import("@fusion/core").NodeConfig) => n.type === "local");
if (!localNode) {
await central.close();
throw new ApiError(401, "Local node not configured");
}
if (localNode.apiKey !== token) {
await central.close();
throw new ApiError(401, "Invalid apiKey");
}
const payload = req.body;
// Validate required fields
if (!payload?.sourceNodeId) {
await central.close();
throw badRequest("Missing required field: sourceNodeId");
}
if (!payload?.exportedAt) {
await central.close();
throw badRequest("Missing required field: exportedAt");
}
// Apply remote settings
const result = await central.applyRemoteSettings(payload);
// Build applied/skipped field lists
const appliedFields = [
...Object.keys(payload.global || {}),
...Object.keys(payload.projects || {}),
];
const skippedFields = result.error ? appliedFields : [];
await central.close();
res.json({
success: result.success,
appliedFields,
skippedFields,
error: result.error,
});
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* POST /api/settings/auth-receive
* Receive auth credentials from a remote node.
* Body: { providers: Record<string, { type: string; key: string }>, sourceNodeId: string, timestamp: string }
* Returns: { success: true, receivedProviders: string[] }
*/
router.post("/settings/auth-receive", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore(store.getFusionDir());
await central.init();
// Validate auth
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
await central.close();
throw new ApiError(401, "Missing or invalid Authorization header");
}
const token = authHeader.slice(7);
const nodes = await central.listNodes();
const localNode = nodes.find((n: import("@fusion/core").NodeConfig) => n.type === "local");
if (!localNode) {
await central.close();
throw new ApiError(401, "Local node not configured");
}
if (localNode.apiKey !== token) {
await central.close();
throw new ApiError(401, "Invalid apiKey");
}
const { providers, sourceNodeId, timestamp } = req.body || {};
// Validate required fields
if (!providers || typeof providers !== "object") {
await central.close();
throw badRequest("Missing required field: providers");
}
if (!sourceNodeId) {
await central.close();
throw badRequest("Missing required field: sourceNodeId");
}
if (!timestamp) {
await central.close();
throw badRequest("Missing required field: timestamp");
}
// Import AuthStorage and write credentials
const { AuthStorage } = await import("@mariozechner/pi-coding-agent");
const authStorage = AuthStorage.create(getFusionAuthPath());
const receivedProviders: string[] = [];
for (const [providerId, credential] of Object.entries(providers)) {
if (typeof credential === "object" && credential !== null) {
const cred = credential as { type: string; key?: string; access?: string; refresh?: string; expires?: number; accountId?: string };
if (cred.type === "api_key" && cred.key) {
authStorage.set(providerId, { type: "api_key", key: cred.key });
receivedProviders.push(providerId);
}
}
}
emitAuthSyncAuditLog({
operation: "receive",
direction: "receive",
route: "/settings/auth-receive",
sourceNodeId,
providerNames: receivedProviders,
});
await central.close();
res.json({ success: true, receivedProviders });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* GET /api/settings/auth-export
* Export local auth credentials for a requesting remote node.
* Returns: { providers: Record<string, { type: string; key: string }>, sourceNodeId: string, timestamp: string }
*/
router.get("/settings/auth-export", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore(store.getFusionDir());
await central.init();
// Validate auth
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
await central.close();
throw new ApiError(401, "Missing or invalid Authorization header");
}
const token = authHeader.slice(7);
const nodes = await central.listNodes();
const localNode = nodes.find((n: import("@fusion/core").NodeConfig) => n.type === "local");
if (!localNode) {
await central.close();
throw new ApiError(401, "Local node not configured");
}
if (localNode.apiKey !== token) {
await central.close();
throw new ApiError(401, "Invalid apiKey");
}
// Get local node ID
const localPeerInfo = await central.getLocalPeerInfo();
const allProviders = await readStoredAuthProvidersFromDisk();
// Filter to only API-key-based providers (skip OAuth)
const apiKeyProviders: Record<string, { type: string; key: string }> = {};
for (const [providerId, cred] of Object.entries(allProviders)) {
if (cred.type === "api_key" && cred.key) {
apiKeyProviders[providerId] = { type: "api_key", key: cred.key };
}
}
await central.close();
res.json({
providers: apiKeyProviders,
sourceNodeId: localPeerInfo.nodeId,
timestamp: new Date().toISOString(),
});
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
// ── Global Settings Routes ─────────────────────────────────────
/**

View File

@@ -0,0 +1,97 @@
import { readFile as fsReadFile } from "node:fs/promises";
import type { NodeConfig } from "@fusion/core";
import { ApiError } from "../api-error.js";
import { getAuthFileCandidates, type StoredAuthProvider } from "../auth-paths.js";
export async function readStoredAuthProvidersFromDisk(): Promise<Record<string, StoredAuthProvider>> {
for (const authJsonPath of getAuthFileCandidates()) {
try {
const authContent = await fsReadFile(authJsonPath, "utf-8");
return JSON.parse(authContent) as Record<string, StoredAuthProvider>;
} catch {
// Try next candidate.
}
}
return {};
}
/**
* Validate node and make an authenticated fetch call to a remote node.
* Returns parsed JSON on success, throws ApiError on failure.
*/
export async function fetchFromRemoteNode(
node: NodeConfig,
path: string,
options?: { method?: string; body?: unknown; timeoutMs?: number },
): Promise<unknown> {
// Validate node has URL (can't fetch from local node or node without URL)
if (!node.url) {
throw new ApiError(400, "Node has no URL configured");
}
// Validate node has apiKey (secure sync requires node authentication)
if (!node.apiKey) {
throw new ApiError(400, "Remote node requires an apiKey for authenticated sync");
}
const method = options?.method ?? "GET";
const timeoutMs = options?.timeoutMs ?? 15_000;
// Construct full URL
const targetUrl = new URL(path, node.url).toString();
// Build headers with auth
const headers: Record<string, string> = {
Authorization: `Bearer ${node.apiKey}`,
"Content-Type": "application/json",
};
// Build fetch options
const fetchOptions: RequestInit = {
method,
headers,
};
if (options?.body !== undefined && method !== "GET") {
fetchOptions.body = JSON.stringify(options.body);
}
// Create AbortController for timeout
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
fetchOptions.signal = controller.signal;
try {
const response = await fetch(targetUrl, fetchOptions);
clearTimeout(timeout);
// Handle auth failures from remote
if (response.status === 401 || response.status === 403) {
throw new ApiError(502, "Remote node authentication failed");
}
// Handle other non-200 responses
if (!response.ok) {
throw new ApiError(502, `Remote node returned ${response.status}`);
}
// Parse and return JSON
return await response.json();
} catch (err: unknown) {
clearTimeout(timeout);
if (err instanceof ApiError) {
throw err;
}
if (err instanceof Error) {
if (err.name === "AbortError") {
throw new ApiError(504, "Remote node unreachable");
}
// Network errors (DNS, connection refused, etc.)
throw new ApiError(504, "Remote node unreachable");
}
throw new ApiError(502, "Remote node request failed");
}
}

View File

@@ -0,0 +1,220 @@
import { ApiError, badRequest } from "../api-error.js";
import { getFusionAuthPath } from "../auth-paths.js";
import { readStoredAuthProvidersFromDisk } from "./register-settings-sync-helpers.js";
import type { ApiRouteRegistrar } from "./types.js";
export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => {
const { router, store, emitAuthSyncAuditLog, rethrowAsApiError } = ctx;
// ── Inbound Settings Sync Endpoints ────────────────────────────────
// These endpoints are called by remote nodes to deliver settings or request auth data.
// They validate apiKey auth before accepting data.
/**
* POST /api/settings/sync-receive
* Receive pushed settings from a remote node.
* Body: SettingsSyncPayload with global, projects, exportedAt, checksum, version
* Returns: { success: true, appliedFields: string[], skippedFields: string[] }
*/
router.post("/settings/sync-receive", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore(store.getFusionDir());
await central.init();
// Validate auth - find local node and check apiKey
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
await central.close();
throw new ApiError(401, "Missing or invalid Authorization header");
}
const token = authHeader.slice(7);
const nodes = await central.listNodes();
const localNode = nodes.find((n: import("@fusion/core").NodeConfig) => n.type === "local");
if (!localNode) {
await central.close();
throw new ApiError(401, "Local node not configured");
}
if (localNode.apiKey !== token) {
await central.close();
throw new ApiError(401, "Invalid apiKey");
}
const payload = req.body;
// Validate required fields
if (!payload?.sourceNodeId) {
await central.close();
throw badRequest("Missing required field: sourceNodeId");
}
if (!payload?.exportedAt) {
await central.close();
throw badRequest("Missing required field: exportedAt");
}
// Apply remote settings
const result = await central.applyRemoteSettings(payload);
// Build applied/skipped field lists
const appliedFields = [
...Object.keys(payload.global || {}),
...Object.keys(payload.projects || {}),
];
const skippedFields = result.error ? appliedFields : [];
await central.close();
res.json({
success: result.success,
appliedFields,
skippedFields,
error: result.error,
});
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* POST /api/settings/auth-receive
* Receive auth credentials from a remote node.
* Body: { providers: Record<string, { type: string; key: string }>, sourceNodeId: string, timestamp: string }
* Returns: { success: true, receivedProviders: string[] }
*/
router.post("/settings/auth-receive", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore(store.getFusionDir());
await central.init();
// Validate auth
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
await central.close();
throw new ApiError(401, "Missing or invalid Authorization header");
}
const token = authHeader.slice(7);
const nodes = await central.listNodes();
const localNode = nodes.find((n: import("@fusion/core").NodeConfig) => n.type === "local");
if (!localNode) {
await central.close();
throw new ApiError(401, "Local node not configured");
}
if (localNode.apiKey !== token) {
await central.close();
throw new ApiError(401, "Invalid apiKey");
}
const { providers, sourceNodeId, timestamp } = req.body || {};
// Validate required fields
if (!providers || typeof providers !== "object") {
await central.close();
throw badRequest("Missing required field: providers");
}
if (!sourceNodeId) {
await central.close();
throw badRequest("Missing required field: sourceNodeId");
}
if (!timestamp) {
await central.close();
throw badRequest("Missing required field: timestamp");
}
// Import AuthStorage and write credentials
const { AuthStorage } = await import("@mariozechner/pi-coding-agent");
const authStorage = AuthStorage.create(getFusionAuthPath());
const receivedProviders: string[] = [];
for (const [providerId, credential] of Object.entries(providers)) {
if (typeof credential === "object" && credential !== null) {
const cred = credential as { type: string; key?: string; access?: string; refresh?: string; expires?: number; accountId?: string };
if (cred.type === "api_key" && cred.key) {
authStorage.set(providerId, { type: "api_key", key: cred.key });
receivedProviders.push(providerId);
}
}
}
emitAuthSyncAuditLog({
operation: "receive",
direction: "receive",
route: "/settings/auth-receive",
sourceNodeId,
providerNames: receivedProviders,
});
await central.close();
res.json({ success: true, receivedProviders });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* GET /api/settings/auth-export
* Export local auth credentials for a requesting remote node.
* Returns: { providers: Record<string, { type: string; key: string }>, sourceNodeId: string, timestamp: string }
*/
router.get("/settings/auth-export", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore(store.getFusionDir());
await central.init();
// Validate auth
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
await central.close();
throw new ApiError(401, "Missing or invalid Authorization header");
}
const token = authHeader.slice(7);
const nodes = await central.listNodes();
const localNode = nodes.find((n: import("@fusion/core").NodeConfig) => n.type === "local");
if (!localNode) {
await central.close();
throw new ApiError(401, "Local node not configured");
}
if (localNode.apiKey !== token) {
await central.close();
throw new ApiError(401, "Invalid apiKey");
}
// Get local node ID
const localPeerInfo = await central.getLocalPeerInfo();
const allProviders = await readStoredAuthProvidersFromDisk();
// Filter to only API-key-based providers (skip OAuth)
const apiKeyProviders: Record<string, { type: string; key: string }> = {};
for (const [providerId, cred] of Object.entries(allProviders)) {
if (cred.type === "api_key" && cred.key) {
apiKeyProviders[providerId] = { type: "api_key", key: cred.key };
}
}
await central.close();
res.json({
providers: apiKeyProviders,
sourceNodeId: localPeerInfo.nodeId,
timestamp: new Date().toISOString(),
});
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
};

View File

@@ -0,0 +1,425 @@
import type { ProjectSettings } from "@fusion/core";
import { ApiError, badRequest, notFound } from "../api-error.js";
import { getFusionAuthPath } from "../auth-paths.js";
import { fetchFromRemoteNode, readStoredAuthProvidersFromDisk } from "./register-settings-sync-helpers.js";
import type { ApiRouteRegistrar } from "./types.js";
export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
const { router, store, emitAuthSyncAuditLog, rethrowAsApiError } = ctx;
// ── Node Settings Sync Routes ────────────────────────────────────────────
/**
* GET /api/nodes/:id/settings
* Fetch settings from a remote node by proxying to the remote's /api/settings/scopes endpoint.
* Returns: { global: GlobalSettings, project: Partial<ProjectSettings> }
*/
router.get("/nodes/:id/settings", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const node = await central.getNode(req.params.id);
await central.close();
if (!node) {
throw notFound("Node not found");
}
if (node.type === "local") {
throw badRequest("Cannot fetch settings from a local node");
}
const result = await fetchFromRemoteNode(node, "/api/settings/scopes");
res.json(result);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* POST /api/nodes/:id/settings/push
* Push local settings to a remote node.
* Body: {} (empty, uses local settings automatically)
* Returns: { success: true, syncedFields: string[] }
*/
router.post("/nodes/:id/settings/push", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const node = await central.getNode(req.params.id);
if (!node) {
await central.close();
throw notFound("Node not found");
}
if (node.type === "local") {
await central.close();
throw badRequest("Cannot push settings to a local node");
}
// Get local project settings
const projectSettings = await store.getSettingsByScope();
// Get local global settings
const globalSettingsStore = store.getGlobalSettingsStore();
const globalSettings = await globalSettingsStore.getSettings();
// Build sync payload
const payload = {
global: globalSettings,
projects: { [store.getRootDir().split("/").pop()!]: projectSettings.project },
exportedAt: new Date().toISOString(),
version: 1 as const,
};
// Compute checksum
const { createHash } = await import("node:crypto");
const checksum = createHash("sha256").update(JSON.stringify(payload)).digest("hex");
// Send to remote node
await fetchFromRemoteNode(node, "/api/settings/sync-receive", {
method: "POST",
body: { ...payload, checksum },
});
// Record sync
await central.updateSettingsSyncState(node.id, {
lastSyncedAt: new Date().toISOString(),
localChecksum: checksum,
});
await central.close();
// Collect synced field names
const syncedFields = [
...Object.keys(globalSettings),
...Object.keys(projectSettings.project),
];
res.json({ success: true, syncedFields });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* POST /api/nodes/:id/settings/pull
* Pull settings from a remote node and apply locally.
* Body: { conflictResolution?: "last-write-wins" | "manual" }
* Returns (last-write-wins): { success: true, appliedFields: string[], skippedFields: string[] }
* Returns (manual): { diff: { global: string[], project: string[] }, remoteSettings, localSettings }
*/
router.post("/nodes/:id/settings/pull", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const node = await central.getNode(req.params.id);
if (!node) {
await central.close();
throw notFound("Node not found");
}
if (node.type === "local") {
await central.close();
throw badRequest("Cannot pull settings from a local node");
}
const conflictResolution = req.body?.conflictResolution ?? "last-write-wins";
if (conflictResolution !== "last-write-wins" && conflictResolution !== "manual") {
await central.close();
throw badRequest("conflictResolution must be 'last-write-wins' or 'manual'");
}
// Fetch remote settings
const remoteSettings = await fetchFromRemoteNode(node, "/api/settings/scopes") as {
global: Record<string, unknown>;
project: Record<string, unknown>;
};
if (conflictResolution === "manual") {
// Get local settings for diff comparison
const localProjectSettings = await store.getSettingsByScope();
const localGlobalSettings = await store.getGlobalSettingsStore().getSettings();
// Compute diff: field names that differ between local and remote
const diffGlobal = Object.keys(remoteSettings.global || {}).filter(
(key) => JSON.stringify(remoteSettings.global?.[key]) !== JSON.stringify(localGlobalSettings[key as keyof typeof localGlobalSettings]),
);
const diffProject = Object.keys(remoteSettings.project || {}).filter(
(key) => JSON.stringify(remoteSettings.project?.[key]) !== JSON.stringify(localProjectSettings.project?.[key as keyof typeof localProjectSettings.project]),
);
await central.close();
res.json({
diff: { global: diffGlobal, project: diffProject },
remoteSettings,
localSettings: { global: localGlobalSettings, project: localProjectSettings.project },
});
return;
}
// last-write-wins: apply remote settings
// Build payload with checksum
const { createHash } = await import("node:crypto");
const exportedAt = new Date().toISOString();
const payloadWithoutChecksum = {
global: remoteSettings.global,
projects: remoteSettings.project as Record<string, ProjectSettings>,
exportedAt,
version: 1 as const,
};
const checksum = createHash("sha256").update(JSON.stringify(payloadWithoutChecksum)).digest("hex");
const result = await central.applyRemoteSettings({
...payloadWithoutChecksum,
checksum,
});
// Record sync
await central.updateSettingsSyncState(node.id, {
lastSyncedAt: new Date().toISOString(),
remoteChecksum: checksum,
});
await central.close();
// Build applied/skipped field lists
const appliedFields = [
...Object.keys(remoteSettings.global || {}),
...Object.keys(remoteSettings.project || {}),
];
const skippedFields = result.error ? Object.keys(remoteSettings.global || {}) : [];
res.json({
success: result.success,
appliedFields,
skippedFields,
error: result.error,
});
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* GET /api/nodes/:id/settings/sync-status
* Returns last sync timestamp and diff summary between local and remote.
* Returns: {
* lastSyncAt: string | null,
* lastSyncDirection: string | null,
* localUpdatedAt: string,
* remoteReachable: boolean,
* diff: { global: string[], project: string[] }
* }
*/
router.get("/nodes/:id/settings/sync-status", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const node = await central.getNode(req.params.id);
if (!node) {
await central.close();
throw notFound("Node not found");
}
if (node.type === "local") {
await central.close();
throw badRequest("Cannot check sync status for a local node");
}
// Get sync state
const syncState = await central.getSettingsSyncState(node.id);
// Get local settings for comparison
const localProjectSettings = await store.getSettingsByScope();
const localGlobalSettings = await store.getGlobalSettingsStore().getSettings();
// Try to fetch remote settings
let remoteReachable = false;
let remoteSettings: { global: Record<string, unknown>; project: Record<string, unknown> } | null = null;
let diffGlobal: string[] = [];
let diffProject: string[] = [];
try {
remoteSettings = await fetchFromRemoteNode(node, "/api/settings/scopes") as {
global: Record<string, unknown>;
project: Record<string, unknown>;
};
remoteReachable = true;
// Compute diff
const rs = remoteSettings;
diffGlobal = Object.keys(rs.global || {}).filter(
(key) => JSON.stringify(rs.global?.[key]) !== JSON.stringify(localGlobalSettings[key as keyof typeof localGlobalSettings]),
);
diffProject = Object.keys(rs.project || {}).filter(
(key) => JSON.stringify(rs.project?.[key]) !== JSON.stringify(localProjectSettings.project?.[key as keyof typeof localProjectSettings.project]),
);
} catch {
// Remote unreachable - diff will be empty arrays
}
await central.close();
res.json({
lastSyncAt: syncState?.lastSyncedAt ?? null,
lastSyncDirection: syncState ? "sync" : null, // Direction not tracked in new schema
localUpdatedAt: syncState?.updatedAt ?? new Date().toISOString(),
remoteReachable,
diff: { global: diffGlobal, project: diffProject },
});
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* POST /api/nodes/:id/auth/sync
* Synchronize model auth credentials with a remote node.
* Body: { direction?: "push" | "pull" }
* Returns: { success: true, syncedProviders: string[] }
*/
router.post("/nodes/:id/auth/sync", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const node = await central.getNode(req.params.id);
if (!node) {
await central.close();
throw notFound("Node not found");
}
if (node.type === "local") {
await central.close();
throw badRequest("Cannot sync auth with a local node");
}
if (!node.apiKey) {
await central.close();
throw badRequest("Remote node requires an apiKey for auth sync");
}
const direction = req.body?.direction ?? "push";
if (direction !== "push" && direction !== "pull") {
await central.close();
throw badRequest("direction must be 'push' or 'pull'");
}
// Get local node ID
const localPeerInfo = await central.getLocalPeerInfo();
const timestamp = new Date().toISOString();
// Import AuthStorage
const { AuthStorage } = await import("@mariozechner/pi-coding-agent");
const authStorage = AuthStorage.create(getFusionAuthPath());
if (direction === "push") {
// Get OAuth provider IDs to exclude
const oauthProviders = authStorage.getOAuthProviders();
const oauthIds = new Set(oauthProviders.map((p) => p.id));
const allProviders = await readStoredAuthProvidersFromDisk();
// Filter to only API-key-based providers (skip OAuth)
const apiKeyProviders: Record<string, { type: string; key: string }> = {};
for (const [providerId, cred] of Object.entries(allProviders)) {
if (oauthIds.has(providerId)) continue;
if (cred.type === "api_key" && cred.key) {
apiKeyProviders[providerId] = { type: "api_key", key: cred.key };
}
}
// Send to remote
await fetchFromRemoteNode(node, "/api/settings/auth-receive", {
method: "POST",
body: {
providers: apiKeyProviders,
sourceNodeId: localPeerInfo.nodeId,
timestamp,
},
});
// Record sync
await central.updateSettingsSyncState(node.id, {
lastSyncedAt: timestamp,
});
await central.close();
const providerNames = Object.keys(apiKeyProviders);
emitAuthSyncAuditLog({
operation: "sync",
direction: "push",
route: "/nodes/:id/auth/sync",
sourceNodeId: localPeerInfo.nodeId,
targetNodeId: node.id,
providerNames,
});
res.json({ success: true, syncedProviders: providerNames });
} else {
// Pull: fetch remote auth and apply locally
const remoteAuth = await fetchFromRemoteNode(node, "/api/settings/auth-export") as {
providers: Record<string, { type: string; key: string }>;
sourceNodeId: string;
timestamp: string;
};
// Write received credentials to local AuthStorage
const syncedProviders: string[] = [];
for (const [providerId, credential] of Object.entries(remoteAuth.providers || {})) {
if (credential.type === "api_key" && credential.key) {
authStorage.set(providerId, { type: "api_key", key: credential.key });
syncedProviders.push(providerId);
}
}
// Record sync
await central.updateSettingsSyncState(node.id, {
lastSyncedAt: timestamp,
});
await central.close();
emitAuthSyncAuditLog({
operation: "sync",
direction: "pull",
route: "/nodes/:id/auth/sync",
sourceNodeId: remoteAuth.sourceNodeId,
targetNodeId: localPeerInfo.nodeId,
providerNames: syncedProviders,
});
res.json({ success: true, syncedProviders });
}
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
};