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:
@@ -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",
|
||||
|
||||
818
packages/dashboard/src/__tests__/routes-nodes-sync.test.ts
Normal file
818
packages/dashboard/src/__tests__/routes-nodes-sync.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user