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:
@@ -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
@@ -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`
|
||||
|
||||
200
packages/dashboard/src/routes/register-discovery-routes.ts
Normal file
200
packages/dashboard/src/routes/register-discovery-routes.ts
Normal 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));
|
||||
}
|
||||
});
|
||||
};
|
||||
210
packages/dashboard/src/routes/register-mesh-routes.ts
Normal file
210
packages/dashboard/src/routes/register-mesh-routes.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
};
|
||||
364
packages/dashboard/src/routes/register-node-routes.ts
Normal file
364
packages/dashboard/src/routes/register-node-routes.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
};
|
||||
711
packages/dashboard/src/routes/register-project-routes.ts
Normal file
711
packages/dashboard/src/routes/register-project-routes.ts
Normal 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));
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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 ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
};
|
||||
425
packages/dashboard/src/routes/register-settings-sync-routes.ts
Normal file
425
packages/dashboard/src/routes/register-settings-sync-routes.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user