feat(FN-1821): add settings and auth sync API routes for mesh nodes

- Add GET /api/nodes/:id/settings endpoint to fetch remote node settings
- Add POST /api/nodes/:id/settings/push to push local settings to a remote node
- Add POST /api/nodes/:id/settings/pull to pull settings from a remote node
- Add GET /api/nodes/:id/settings/sync-status for sync diff summary
- Add POST /api/nodes/:id/auth/sync to sync model auth credentials
- Add POST /api/settings/sync-receive inbound settings endpoint
- Add POST /api/settings/auth-receive inbound auth endpoint
- Add GET /api/settings/auth-export to export local auth credentials
- Add comprehensive route tests for all sync endpoints
- Update AGENTS.md with Node Settings Sync API reference
This commit is contained in:
Fusion
2026-04-16 06:25:37 -07:00
committed by gsxdsm
parent 261bfff9ae
commit e0266565f3
5 changed files with 1559 additions and 1 deletions

View File

@@ -39,6 +39,7 @@
"@codemirror/view": "^6.36.4",
"@fusion/core": "workspace:*",
"@fusion/engine": "workspace:*",
"@mariozechner/pi-coding-agent": "^0.62.0",
"@types/multer": "^2.1.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",

View File

@@ -0,0 +1,818 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { request, get } from "../test-request.js";
// ── Mock @fusion/core for node routes ─────────────────────────────────
const mockInit = vi.fn().mockResolvedValue(undefined);
const mockClose = vi.fn().mockResolvedValue(undefined);
const mockListNodes = vi.fn();
const mockGetNode = vi.fn();
const mockGetLocalPeerInfo = vi.fn();
const mockGetSettingsSyncState = vi.fn();
const mockUpdateSettingsSyncState = vi.fn();
const mockApplyRemoteSettings = vi.fn();
const mockGetSettingsForSync = vi.fn();
const mockChatStoreInit = vi.fn().mockResolvedValue(undefined);
vi.mock("@fusion/core", () => {
return {
CentralCore: class MockCentralCore {
init = mockInit;
close = mockClose;
listNodes = mockListNodes;
getNode = mockGetNode;
getLocalPeerInfo = mockGetLocalPeerInfo;
getSettingsSyncState = mockGetSettingsSyncState;
updateSettingsSyncState = mockUpdateSettingsSyncState;
applyRemoteSettings = mockApplyRemoteSettings;
getSettingsForSync = mockGetSettingsForSync;
},
ChatStore: class MockChatStore {
init = mockChatStoreInit;
},
};
});
// ── Mock AuthStorage ───────────────────────────────────────────────────
const mockAuthStorageSet = vi.fn();
const mockAuthStorageGetOAuthProviders = vi.fn().mockReturnValue([]);
vi.mock("@mariozechner/pi-coding-agent", () => {
return {
AuthStorage: {
create: vi.fn(() => ({
set: mockAuthStorageSet,
get: vi.fn(),
getApiKey: vi.fn(),
getOAuthProviders: mockAuthStorageGetOAuthProviders,
reload: vi.fn(),
})),
},
};
});
// ── Mock Store ────────────────────────────────────────────────────────
class MockStore extends EventEmitter {
getRootDir(): string {
return "/tmp/fn-1821-test";
}
getFusionDir(): string {
return "/tmp/fn-1821-test/.fusion";
}
getDatabase() {
return {
exec: vi.fn(),
prepare: vi.fn().mockReturnValue({
run: vi.fn().mockReturnValue({ changes: 0 }),
get: vi.fn(),
all: vi.fn().mockReturnValue([]),
}),
};
}
async getSettingsByScope() {
return {
global: { defaultProvider: "anthropic", defaultModelId: "claude-3-5-sonnet" },
project: { maxConcurrent: 2 },
};
}
getGlobalSettingsStore() {
return {
async getSettings() {
return { defaultProvider: "anthropic", defaultModelId: "claude-3-5-sonnet" };
},
};
}
}
// ── Test helpers ──────────────────────────────────────────────────────
function createMockRemoteNode(overrides: Record<string, unknown> = {}) {
return {
id: "node-remote-001",
name: "Remote Node",
type: "remote" as const,
status: "online" as const,
url: "http://192.168.1.100:3001",
apiKey: "test-api-key-123",
maxConcurrent: 2,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
function createMockLocalNode(overrides: Record<string, unknown> = {}) {
return {
id: "node-local-001",
name: "Local Node",
type: "local" as const,
status: "online" as const,
url: null,
apiKey: "local-api-key-456",
maxConcurrent: 2,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
// ── Tests ─────────────────────────────────────────────────────────────
describe("Node settings sync routes", () => {
let store: MockStore;
let app: ReturnType<typeof import("../server.js").createServer>;
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(async () => {
vi.clearAllMocks();
mockInit.mockResolvedValue(undefined);
mockClose.mockResolvedValue(undefined);
mockListNodes.mockResolvedValue([]);
mockGetNode.mockResolvedValue(null);
mockGetLocalPeerInfo.mockResolvedValue({ nodeId: "node-local-001", nodeName: "Local Node" });
mockGetSettingsSyncState.mockResolvedValue(null);
mockUpdateSettingsSyncState.mockResolvedValue({});
mockApplyRemoteSettings.mockResolvedValue({ success: true, globalCount: 1, projectCount: 1, authCount: 0 });
mockGetSettingsForSync.mockResolvedValue({});
mockAuthStorageSet.mockResolvedValue(undefined);
mockAuthStorageGetOAuthProviders.mockReturnValue([]);
// Mock global fetch for remote node calls
mockFetch = vi.fn();
global.fetch = mockFetch;
store = new MockStore();
const { createServer } = await import("../server.js");
app = createServer(store as any);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
// ── GET /api/nodes/:id/settings ──────────────────────────────────────
describe("GET /api/nodes/:id/settings", () => {
it("returns remote settings scopes for valid remote node", async () => {
const remoteNode = createMockRemoteNode();
mockGetNode.mockResolvedValue(remoteNode);
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ global: { test: "global" }, project: { test: "project" } }),
});
const res = await get(app, "/api/nodes/node-remote-001/settings");
expect(res.status).toBe(200);
expect(res.body).toEqual({ global: { test: "global" }, project: { test: "project" } });
expect(mockFetch).toHaveBeenCalledWith(
"http://192.168.1.100:3001/api/settings/scopes",
expect.objectContaining({
method: "GET",
headers: { Authorization: "Bearer test-api-key-123", "Content-Type": "application/json" },
}),
);
});
it("returns 404 for unknown node", async () => {
mockGetNode.mockResolvedValue(null);
const res = await get(app, "/api/nodes/unknown/settings");
expect(res.status).toBe(404);
expect(res.body.error).toContain("not found");
});
it("returns 400 for local node", async () => {
const localNode = createMockLocalNode();
mockGetNode.mockResolvedValue(localNode);
const res = await get(app, "/api/nodes/node-local-001/settings");
expect(res.status).toBe(400);
expect(res.body.error).toContain("local node");
});
it("returns 502 when remote returns non-200", async () => {
const remoteNode = createMockRemoteNode();
mockGetNode.mockResolvedValue(remoteNode);
mockFetch.mockResolvedValue({
ok: false,
status: 500,
});
const res = await get(app, "/api/nodes/node-remote-001/settings");
expect(res.status).toBe(502);
expect(res.body.error).toContain("500");
});
it("returns 504 when remote is unreachable", async () => {
const remoteNode = createMockRemoteNode();
mockGetNode.mockResolvedValue(remoteNode);
mockFetch.mockRejectedValue(new Error("Network error"));
const res = await get(app, "/api/nodes/node-remote-001/settings");
expect(res.status).toBe(504);
expect(res.body.error).toContain("unreachable");
});
});
// ── POST /api/nodes/:id/settings/push ────────────────────────────────
describe("POST /api/nodes/:id/settings/push", () => {
it("successfully pushes local settings to remote", async () => {
const remoteNode = createMockRemoteNode();
mockGetNode.mockResolvedValue(remoteNode);
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true }),
});
const res = await request(
app,
"POST",
"/api/nodes/node-remote-001/settings/push",
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.syncedFields).toContain("defaultProvider");
expect(mockFetch).toHaveBeenCalledWith(
"http://192.168.1.100:3001/api/settings/sync-receive",
expect.objectContaining({
method: "POST",
headers: { Authorization: "Bearer test-api-key-123", "Content-Type": "application/json" },
}),
);
});
it("returns 404 for unknown node", async () => {
mockGetNode.mockResolvedValue(null);
const res = await request(
app,
"POST",
"/api/nodes/unknown/settings/push",
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(res.status).toBe(404);
});
it("returns 400 for local node", async () => {
const localNode = createMockLocalNode();
mockGetNode.mockResolvedValue(localNode);
const res = await request(
app,
"POST",
"/api/nodes/node-local-001/settings/push",
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("local node");
});
it("returns 400 for remote node without apiKey", async () => {
const remoteNode = createMockRemoteNode({ apiKey: undefined });
mockGetNode.mockResolvedValue(remoteNode);
const res = await request(
app,
"POST",
"/api/nodes/node-remote-001/settings/push",
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("apiKey");
});
it("records sync state after successful push", async () => {
const remoteNode = createMockRemoteNode();
mockGetNode.mockResolvedValue(remoteNode);
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true }),
});
const res = await request(
app,
"POST",
"/api/nodes/node-remote-001/settings/push",
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect(mockUpdateSettingsSyncState).toHaveBeenCalledWith(
"node-remote-001",
expect.objectContaining({
lastSyncedAt: expect.any(String),
}),
);
});
});
// ── POST /api/nodes/:id/settings/pull ───────────────────────────────
describe("POST /api/nodes/:id/settings/pull", () => {
it("successfully pulls and applies remote settings with default conflict resolution", async () => {
const remoteNode = createMockRemoteNode();
mockGetNode.mockResolvedValue(remoteNode);
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({
global: { defaultProvider: "openai" },
project: { maxConcurrent: 3 },
}),
});
const res = await request(
app,
"POST",
"/api/nodes/node-remote-001/settings/pull",
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(mockApplyRemoteSettings).toHaveBeenCalled();
});
it("returns diff without applying when conflictResolution is manual", async () => {
const remoteNode = createMockRemoteNode();
mockGetNode.mockResolvedValue(remoteNode);
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({
global: { defaultProvider: "openai" },
project: { maxConcurrent: 3 },
}),
});
const res = await request(
app,
"POST",
"/api/nodes/node-remote-001/settings/pull",
JSON.stringify({ conflictResolution: "manual" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body.diff).toBeDefined();
expect(res.body.remoteSettings).toBeDefined();
expect(res.body.localSettings).toBeDefined();
expect(mockApplyRemoteSettings).not.toHaveBeenCalled();
});
it("returns 404 for unknown node", async () => {
mockGetNode.mockResolvedValue(null);
const res = await request(
app,
"POST",
"/api/nodes/unknown/settings/pull",
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(res.status).toBe(404);
});
it("records sync state after successful pull", async () => {
const remoteNode = createMockRemoteNode();
mockGetNode.mockResolvedValue(remoteNode);
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({
global: { defaultProvider: "openai" },
project: { maxConcurrent: 3 },
}),
});
const res = await request(
app,
"POST",
"/api/nodes/node-remote-001/settings/pull",
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect(mockUpdateSettingsSyncState).toHaveBeenCalledWith(
"node-remote-001",
expect.objectContaining({
lastSyncedAt: expect.any(String),
}),
);
});
});
// ── GET /api/nodes/:id/settings/sync-status ─────────────────────────
describe("GET /api/nodes/:id/settings/sync-status", () => {
it("returns sync status with diff summary when remote is reachable", async () => {
const remoteNode = createMockRemoteNode();
mockGetNode.mockResolvedValue(remoteNode);
mockGetSettingsSyncState.mockResolvedValue({
nodeId: "node-local-001",
remoteNodeId: "node-remote-001",
lastSyncedAt: "2026-04-14T10:00:00.000Z",
localChecksum: "abc123",
remoteChecksum: "def456",
syncCount: 5,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-04-14T10:00:00.000Z",
});
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({
global: { defaultProvider: "openai" },
project: { maxConcurrent: 3 },
}),
});
const res = await get(app, "/api/nodes/node-remote-001/settings/sync-status");
expect(res.status).toBe(200);
expect(res.body.lastSyncAt).toBe("2026-04-14T10:00:00.000Z");
expect(res.body.remoteReachable).toBe(true);
expect(res.body.diff).toBeDefined();
});
it("returns remoteReachable false with empty diff when remote is down", async () => {
const remoteNode = createMockRemoteNode();
mockGetNode.mockResolvedValue(remoteNode);
mockGetSettingsSyncState.mockResolvedValue(null);
mockFetch.mockRejectedValue(new Error("Network error"));
const res = await get(app, "/api/nodes/node-remote-001/settings/sync-status");
expect(res.status).toBe(200);
expect(res.body.remoteReachable).toBe(false);
expect(res.body.diff.global).toEqual([]);
expect(res.body.diff.project).toEqual([]);
});
it("returns 404 for unknown node", async () => {
mockGetNode.mockResolvedValue(null);
const res = await get(app, "/api/nodes/unknown/settings/sync-status");
expect(res.status).toBe(404);
});
it("returns null timestamps when no sync has occurred", async () => {
const remoteNode = createMockRemoteNode();
mockGetNode.mockResolvedValue(remoteNode);
mockGetSettingsSyncState.mockResolvedValue(null);
mockFetch.mockRejectedValue(new Error("Network error"));
const res = await get(app, "/api/nodes/node-remote-001/settings/sync-status");
expect(res.status).toBe(200);
expect(res.body.lastSyncAt).toBe(null);
});
});
// ── POST /api/nodes/:id/auth/sync ───────────────────────────────────
describe("POST /api/nodes/:id/auth/sync", () => {
beforeEach(() => {
// Setup mock fs.readFileSync for auth.json
vi.doMock("node:fs", () => ({
readFileSync: vi.fn().mockReturnValue(JSON.stringify({
anthropic: { type: "api_key", key: "sk-ant-test123" },
openai: { type: "api_key", key: "sk-test456" },
})),
existsSync: vi.fn().mockReturnValue(true),
}));
});
it("successfully pushes auth credentials to remote (push mode)", async () => {
const remoteNode = createMockRemoteNode();
mockGetNode.mockResolvedValue(remoteNode);
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true }),
});
const res = await request(
app,
"POST",
"/api/nodes/node-remote-001/auth/sync",
JSON.stringify({ direction: "push" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.syncedProviders).toContain("anthropic");
expect(res.body.syncedProviders).toContain("openai");
});
it("returns 404 for unknown node", async () => {
mockGetNode.mockResolvedValue(null);
const res = await request(
app,
"POST",
"/api/nodes/unknown/auth/sync",
JSON.stringify({ direction: "push" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(404);
});
it("returns 400 for remote node without apiKey", async () => {
const remoteNode = createMockRemoteNode({ apiKey: undefined });
mockGetNode.mockResolvedValue(remoteNode);
const res = await request(
app,
"POST",
"/api/nodes/node-remote-001/auth/sync",
JSON.stringify({ direction: "push" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("apiKey");
});
it("logs provider names but not credentials", async () => {
const remoteNode = createMockRemoteNode();
mockGetNode.mockResolvedValue(remoteNode);
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true }),
});
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
await request(
app,
"POST",
"/api/nodes/node-remote-001/auth/sync",
JSON.stringify({ direction: "push" }),
{ "content-type": "application/json" },
);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("anthropic"),
);
expect(consoleSpy).not.toHaveBeenCalledWith(
expect.stringContaining("sk-ant-test123"),
);
consoleSpy.mockRestore();
});
it("successfully pulls auth credentials from remote", async () => {
const remoteNode = createMockRemoteNode();
mockGetNode.mockResolvedValue(remoteNode);
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({
providers: {
google: { type: "api_key", key: "AIzaTest123" },
},
sourceNodeId: "node-other",
timestamp: "2026-04-14T10:00:00.000Z",
}),
});
const res = await request(
app,
"POST",
"/api/nodes/node-remote-001/auth/sync",
JSON.stringify({ direction: "pull" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.syncedProviders).toContain("google");
});
});
// ── POST /api/settings/sync-receive ─────────────────────────────────
describe("POST /api/settings/sync-receive", () => {
it("successfully receives and applies settings", async () => {
const localNode = createMockLocalNode();
mockListNodes.mockResolvedValue([localNode]);
mockApplyRemoteSettings.mockResolvedValue({
success: true,
globalCount: 2,
projectCount: 1,
authCount: 0,
});
const payload = {
global: { defaultProvider: "anthropic" },
projects: {},
exportedAt: "2026-04-14T10:00:00.000Z",
checksum: "abc123",
version: 1,
};
const res = await request(
app,
"POST",
"/api/settings/sync-receive",
JSON.stringify({ ...payload, sourceNodeId: "node-remote-001" }),
{ "content-type": "application/json", "Authorization": `Bearer ${localNode.apiKey}` },
);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(mockApplyRemoteSettings).toHaveBeenCalled();
});
it("returns 401 when auth header is missing", async () => {
const res = await request(
app,
"POST",
"/api/settings/sync-receive",
JSON.stringify({ sourceNodeId: "node-remote-001", exportedAt: "2026-04-14T10:00:00.000Z" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(401);
});
it("returns 401 when apiKey doesn't match", async () => {
const localNode = createMockLocalNode();
mockListNodes.mockResolvedValue([localNode]);
const res = await request(
app,
"POST",
"/api/settings/sync-receive",
JSON.stringify({ sourceNodeId: "node-remote-001", exportedAt: "2026-04-14T10:00:00.000Z" }),
{ "content-type": "application/json", "Authorization": "Bearer wrong-key" },
);
expect(res.status).toBe(401);
});
it("returns 400 when payload is missing sourceNodeId", async () => {
const localNode = createMockLocalNode();
mockListNodes.mockResolvedValue([localNode]);
const res = await request(
app,
"POST",
"/api/settings/sync-receive",
JSON.stringify({ exportedAt: "2026-04-14T10:00:00.000Z" }),
{ "content-type": "application/json", "Authorization": `Bearer ${localNode.apiKey}` },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("sourceNodeId");
});
});
// ── POST /api/settings/auth-receive ──────────────────────────────────
describe("POST /api/settings/auth-receive", () => {
it("successfully receives auth credentials", async () => {
const localNode = createMockLocalNode();
mockListNodes.mockResolvedValue([localNode]);
const res = await request(
app,
"POST",
"/api/settings/auth-receive",
JSON.stringify({
providers: {
anthropic: { type: "api_key", key: "sk-ant-received" },
},
sourceNodeId: "node-remote-001",
timestamp: "2026-04-14T10:00:00.000Z",
}),
{ "content-type": "application/json", "Authorization": `Bearer ${localNode.apiKey}` },
);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.receivedProviders).toContain("anthropic");
});
it("returns 401 when auth header is missing", async () => {
const res = await request(
app,
"POST",
"/api/settings/auth-receive",
JSON.stringify({
providers: { anthropic: { type: "api_key", key: "sk-ant" } },
sourceNodeId: "node-remote-001",
timestamp: "2026-04-14T10:00:00.000Z",
}),
{ "content-type": "application/json" },
);
expect(res.status).toBe(401);
});
it("returns 400 when payload is malformed", async () => {
const localNode = createMockLocalNode();
mockListNodes.mockResolvedValue([localNode]);
const res = await request(
app,
"POST",
"/api/settings/auth-receive",
JSON.stringify({ providers: "not-an-object" }),
{ "content-type": "application/json", "Authorization": `Bearer ${localNode.apiKey}` },
);
expect(res.status).toBe(400);
});
it("logs provider names but not credentials", async () => {
const localNode = createMockLocalNode();
mockListNodes.mockResolvedValue([localNode]);
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
await request(
app,
"POST",
"/api/settings/auth-receive",
JSON.stringify({
providers: { anthropic: { type: "api_key", key: "sk-ant-secret" } },
sourceNodeId: "node-remote-001",
timestamp: "2026-04-14T10:00:00.000Z",
}),
{ "content-type": "application/json", "Authorization": `Bearer ${localNode.apiKey}` },
);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("anthropic"),
);
expect(consoleSpy).not.toHaveBeenCalledWith(
expect.stringContaining("sk-ant-secret"),
);
consoleSpy.mockRestore();
});
});
// ── GET /api/settings/auth-export ────────────────────────────────────
describe("GET /api/settings/auth-export", () => {
beforeEach(() => {
vi.doMock("node:fs", () => ({
readFileSync: vi.fn().mockReturnValue(JSON.stringify({
anthropic: { type: "api_key", key: "sk-ant-local" },
google: { type: "oauth", access: "ya29.token", refresh: "refresh.token" },
})),
existsSync: vi.fn().mockReturnValue(true),
}));
});
it("returns auth credentials for authenticated request", async () => {
const localNode = createMockLocalNode();
mockListNodes.mockResolvedValue([localNode]);
mockGetLocalPeerInfo.mockResolvedValue({ nodeId: "node-local-001", nodeName: "Local Node" });
const res = await request(
app,
"GET",
"/api/settings/auth-export",
undefined,
{ "Authorization": `Bearer ${localNode.apiKey}` },
);
expect(res.status).toBe(200);
expect(res.body.providers).toBeDefined();
expect(res.body.sourceNodeId).toBe("node-local-001");
expect(res.body.providers).toHaveProperty("anthropic");
// OAuth providers should be filtered out
expect(res.body.providers).not.toHaveProperty("google");
});
it("returns 401 when auth header is missing", async () => {
const res = await get(app, "/api/settings/auth-export");
expect(res.status).toBe(401);
});
});
});

View File

@@ -17,7 +17,7 @@ import { tmpdir } from "node:os";
import * as nodeFs from "node:fs";
import { promisify } from "node:util";
import type { TaskStore, Column, ScheduleType, ActivityEventType, ModelPreset, MessageType, ParticipantType, RoutineTriggerType } from "@fusion/core";
import type { TaskStore, Column, ScheduleType, ActivityEventType, ModelPreset, MessageType, ParticipantType, RoutineTriggerType, ProjectSettings } from "@fusion/core";
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, type Task, getCurrentRepo, isGhAuthenticated, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupAutomation, exportSettings, importSettings, validateImportData, MessageStore, RoutineStore, isWebhookTrigger, resolveMemoryBackend, getMemoryBackendCapabilities, listMemoryBackendTypes, readMemory, writeMemory, MemoryBackendError } from "@fusion/core";
import type { ServerOptions } from "./server.js";
import { GitHubClient, parseBadgeUrl } from "./github.js";
@@ -2524,6 +2524,227 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
// ── 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();
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);
}
}
}
// Log without actual credentials
console.error(`[settings-sync] Auth credentials received: providers=${receivedProviders.join(",")}, source=${sourceNodeId}`);
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();
// Import AuthStorage and read credentials
const { AuthStorage } = await import("@mariozechner/pi-coding-agent");
const authStorage = AuthStorage.create();
// Read auth.json directly
const authJsonPath = `${process.env.HOME || process.env.USERPROFILE}/.pi/agent/auth.json`;
let allProviders: Record<string, { type: string; key?: string; access?: string; refresh?: string; expires?: number; accountId?: string }> = {};
try {
const { readFileSync } = await import("node:fs");
const authContent = readFileSync(authJsonPath, "utf-8");
allProviders = JSON.parse(authContent);
} catch {
// Auth file doesn't exist - export empty
}
// 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 ─────────────────────────────────────
/**
@@ -13750,6 +13971,89 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
// ── Remote Node Settings Sync Helpers ─────────────────────────────────────
/**
* Helper: Validate node and make an authenticated fetch call to a remote node.
* Returns parsed JSON on success, throws ApiError on failure.
*/
async function fetchFromRemoteNode(
node: import("@fusion/core").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");
}
}
// ── Node Management Routes (Multi-Node Support) ───────────────────────────
/**
@@ -14100,6 +14404,423 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
// ── 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();
// Get local node ID for source tracking
const localPeerInfo = await central.getLocalPeerInfo();
// 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();
if (direction === "push") {
// Get OAuth provider IDs to exclude
const oauthProviders = authStorage.getOAuthProviders();
const oauthIds = new Set(oauthProviders.map((p) => p.id));
// Read auth.json directly to get all providers
const authJsonPath = `${process.env.HOME || process.env.USERPROFILE}/.pi/agent/auth.json`;
let allProviders: Record<string, { type: string; key?: string; access?: string; refresh?: string; expires?: number; accountId?: string }> = {};
try {
const { readFileSync } = await import("node:fs");
const authContent = readFileSync(authJsonPath, "utf-8");
allProviders = JSON.parse(authContent);
} catch {
// Auth file doesn't exist or is unreadable - sync empty
}
// 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();
// Log without actual credentials
const providerNames = Object.keys(apiKeyProviders);
console.error(`[settings-sync] Auth sync completed: direction=push, providers=${providerNames.join(",")}, targetNode=${node.id}`);
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();
// Log without actual credentials
console.error(`[settings-sync] Auth sync completed: direction=pull, providers=${syncedProviders.join(",")}, targetNode=${node.id}`);
res.json({ success: true, syncedProviders });
}
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
// ── Mesh Topology Routes ────────────────────────────────────────────────
/**