feat(FN-3707): implement mesh auth snapshot replication across core, engine

The merge delivers two major features: mesh auth snapshot synchronization across nodes (FN-3707 steps 2/4/5, including core types, central-core plumbing, peer-exchange updates, and settings/node sync route helpers) and a bundled roadmap plugin with chat UX improvements (FN-3162, FN-3756, FN-3771, FN

Fusion-Task-Id: FN-3707
This commit is contained in:
Fusion
2026-05-08 18:15:15 -07:00
committed by gsxdsm
parent 992771b14b
commit a087aa4e57
14 changed files with 226 additions and 76 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Sync mesh auth credentials using explicit checksummed auth snapshots across node sync and mesh shared-state channels, including secure apply/export handling for API-key and OAuth provider credentials.

View File

@@ -244,7 +244,7 @@ Lifecycle contract (`types.ts` `isValidApprovalRequestTransition`):
- `ActivityLogSnapshot` (`entries`) - `ActivityLogSnapshot` (`entries`)
- `RunAuditSnapshot` (`entries`) - `RunAuditSnapshot` (`entries`)
- `ProjectSettingsSnapshot` (`global`, `projects`) - `ProjectSettingsSnapshot` (`global`, `projects`)
- `AuthMaterialSnapshot` (`providerAuth`) - `AuthMaterialSnapshot` (`providerAuth`, with API-key and OAuth credential shapes)
Intentional exclusions from shared snapshots: Intentional exclusions from shared snapshots:
- Task/agent blob contents (`PROMPT.md`, task document bodies, attachment bytes, JSONL run logs) - Task/agent blob contents (`PROMPT.md`, task document bodies, attachment bytes, JSONL run logs)
@@ -799,10 +799,10 @@ The client treats mapping persistence as part of onboarding success. If mapping
| POST | `/api/nodes/:id/settings/push` | Push local settings to a remote node. | | POST | `/api/nodes/:id/settings/push` | Push local settings to a remote node. |
| POST | `/api/nodes/:id/settings/pull` | Pull settings from a remote node. | | POST | `/api/nodes/:id/settings/pull` | Pull settings from a remote node. |
| GET | `/api/nodes/:id/settings/sync-status` | Get sync status and diff summary. | | GET | `/api/nodes/:id/settings/sync-status` | Get sync status and diff summary. |
| POST | `/api/nodes/:id/auth/sync` | Sync model auth credentials. | | POST | `/api/nodes/:id/auth/sync` | Sync model auth snapshots (push/pull, checksum/version validated). |
| POST | `/api/settings/sync-receive` | Receive pushed settings (inbound). | | POST | `/api/settings/sync-receive` | Receive pushed settings (inbound). |
| POST | `/api/settings/auth-receive` | Receive auth credentials (inbound). | | POST | `/api/settings/auth-receive` | Receive `AuthMaterialSnapshot` and persist via auth storage. |
| GET | `/api/settings/auth-export` | Export local auth credentials. | | GET | `/api/settings/auth-export` | Export local `AuthMaterialSnapshot`. |
| GET | `/api/update-check` | Read cached/TTL-guarded npm update status for `@runfusion/fusion` (respects `updateCheckEnabled`). | | GET | `/api/update-check` | Read cached/TTL-guarded npm update status for `@runfusion/fusion` (respects `updateCheckEnabled`). |
| POST | `/api/update-check/refresh` | Clear cached update data and force a fresh npm update check. | | POST | `/api/update-check/refresh` | Clear cached update data and force a fresh npm update check. |
| GET | `/api/updates/check` | Perform an on-demand npm registry check for the latest `@runfusion/fusion` version (no cache). | | GET | `/api/updates/check` | Perform an on-demand npm registry check for the latest `@runfusion/fusion` version (no cache). |

View File

@@ -71,7 +71,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
| `daemonPort` | `number` | `4040` | Port for daemon/serve mode binding. | | `daemonPort` | `number` | `4040` | Port for daemon/serve mode binding. |
| `daemonHost` | `string` | `"127.0.0.1"` | Host for daemon/serve mode binding. Defaults to localhost only; pass `"0.0.0.0"` to expose on all interfaces. | | `daemonHost` | `string` | `"127.0.0.1"` | Host for daemon/serve mode binding. Defaults to localhost only; pass `"0.0.0.0"` to expose on all interfaces. |
| `settingsSyncEnabled` | `boolean` | `false` | Enable automatic settings synchronization between nodes. | | `settingsSyncEnabled` | `boolean` | `false` | Enable automatic settings synchronization between nodes. |
| `settingsSyncAuth` | `boolean` | `false` | Include model auth credentials in settings sync operations. | | `settingsSyncAuth` | `boolean` | `false` | Include auth-material snapshots (`sharedState.authMaterial` and auth sync endpoints) when settings sync is enabled. Ignored when `settingsSyncEnabled` is `false`. |
| `settingsSyncInterval` | `number` | `900000` | Automatic sync interval in ms. Valid values: `300000`, `900000`, `1800000`, `3600000`. | | `settingsSyncInterval` | `number` | `900000` | Automatic sync interval in ms. Valid values: `300000`, `900000`, `1800000`, `3600000`. |
| `settingsSyncConflictResolution` | `"last-write-wins" \| "always-ask" \| "keep-local" \| "keep-remote"` | `"last-write-wins"` | Conflict strategy for divergent synced settings. | | `settingsSyncConflictResolution` | `"last-write-wins" \| "always-ask" \| "keep-local" \| "keep-remote"` | `"last-write-wins"` | Conflict strategy for divergent synced settings. |
| `dashboardCurrentNodeId` | `string` | `undefined` | Currently selected dashboard node ID. Restores the last-viewed node on fresh browser/PWA sessions. `undefined` means viewing the local node. | | `dashboardCurrentNodeId` | `string` | `undefined` | Currently selected dashboard node ID. Restores the last-viewed node on fresh browser/PWA sessions. `undefined` means viewing the local node. |

View File

@@ -46,7 +46,7 @@ This document is the canonical contract for Fusion multi-leader mesh replication
| Agent definitions/configuration | Strongly coordinated | Durable config replicated; runtime process handles excluded | | Agent definitions/configuration | Strongly coordinated | Durable config replicated; runtime process handles excluded |
| Agent runtime state (heartbeat ticks, local process internals, worktree paths) | Node-local only | Exposed as local telemetry, not global truth | | Agent runtime state (heartbeat ticks, local process internals, worktree paths) | Node-local only | Exposed as local telemetry, not global truth |
| Project settings | Strongly coordinated | Existing settings payloads remain canonical payload shape | | Project settings | Strongly coordinated | Existing settings payloads remain canonical payload shape |
| Auth material / provider credentials | Queued-for-later (secured transport only) | Explicit auth channel; never merged as ordinary settings data | | Auth material / provider credentials | Queued-for-later (secured transport only) | Explicit auth snapshot channel (`sharedState.authMaterial`); never merged as ordinary settings payload |
| Execution runs / live activity streams | Node-local + queued summary | Live events local; durable run outcomes appended later | | Execution runs / live activity streams | Node-local + queued summary | Live events local; durable run outcomes appended later |
| Audit / event streams (`activityLog`, `runAuditEvents`) | Append-only replicated | Immutable event replication with origin metadata | | Audit / event streams (`activityLog`, `runAuditEvents`) | Append-only replicated | Immutable event replication with origin metadata |
| Filesystem blobs (`.fusion/tasks/*` prompts/logs/attachments) | Queued-for-later | Metadata in replicated records, blob transfer out-of-band | | Filesystem blobs (`.fusion/tasks/*` prompts/logs/attachments) | Queued-for-later | Metadata in replicated records, blob transfer out-of-band |
@@ -73,6 +73,23 @@ Every replicated record uses:
`PeerSyncRequest` / `PeerSyncResponse` remain mesh exchange carriers. v1 envelopes are payloads exchanged through current mesh sync infrastructure and follow-on sync endpoints. `PeerSyncRequest` / `PeerSyncResponse` remain mesh exchange carriers. v1 envelopes are payloads exchanged through current mesh sync infrastructure and follow-on sync endpoints.
### Auth snapshot contract (v1)
Auth replication uses `AuthMaterialSnapshot` (`version`, `exportedAt`, `checksum`, `payload`) with:
- `payload.providerAuth: Record<string, ProviderAuthEntry>`
- `ProviderAuthEntry.type`: `api_key | oauth`
- `api_key` fields: `key`
- `oauth` fields: `accessToken`, `refreshToken`, `expires`, optional `accountId`
Transport paths:
- Mesh shared-state channel: `POST /api/mesh/sync` (`sharedState.authMaterial`)
- Explicit node auth channel: `POST /api/nodes/:id/auth/sync` and inbound `POST /api/settings/auth-receive` / `GET /api/settings/auth-export`
Security/redaction rules:
- Auth snapshots are only exchanged over API-key-authenticated node links.
- Raw secrets (`key`, `accessToken`, `refreshToken`, bearer headers) MUST NOT be logged.
- Route diagnostics may emit provider names/counts only.
## 7. Quorum and acknowledgements ## 7. Quorum and acknowledgements
For `strong` writes: For `strong` writes:

View File

@@ -2917,12 +2917,22 @@ describe("CentralCore", () => {
const legacy = await syncCentral.getSettingsForSync({}); const legacy = await syncCentral.getSettingsForSync({});
const snapshot = await syncCentral.getProjectSettingsSnapshot({}); const snapshot = await syncCentral.getProjectSettingsSnapshot({});
const result = await syncCentral.applyProjectSettingsSnapshot(snapshot); const result = await syncCentral.applyProjectSettingsSnapshot(snapshot);
const authSnapshot = syncCentral.getAuthMaterialSnapshot({ foo: { providerId: "foo", accountLabel: "acct" } as any }); const authSnapshot = syncCentral.getAuthMaterialSnapshot({
foo: {
type: "oauth",
accessToken: "access-token",
refreshToken: "refresh-token",
expires: Date.now() + 60_000,
accountId: "acct",
},
});
expect(snapshot.payload.global).toEqual(legacy.global); expect(snapshot.payload.global).toEqual(legacy.global);
expect(snapshot.payload.projects).toEqual(legacy.projects); expect(snapshot.payload.projects).toEqual(legacy.projects);
expect(typeof result.success).toBe("boolean"); expect(typeof result.success).toBe("boolean");
expect(syncCentral.applyAuthMaterialSnapshot(authSnapshot).foo.providerId).toBe("foo"); const authApplyResult = syncCentral.applyAuthMaterialSnapshot(authSnapshot);
expect(authApplyResult.authCount).toBe(1);
expect(authApplyResult.providerAuth.foo.accountId).toBe("acct");
} finally { } finally {
await syncCentral.close(); await syncCentral.close();
rmSync(tempDir + "-snapshot", { recursive: true, force: true }); rmSync(tempDir + "-snapshot", { recursive: true, force: true });

View File

@@ -56,7 +56,16 @@ describe("shared-mesh-state", () => {
const activitySnapshot = createActivityLogSnapshot([], exportedAt); const activitySnapshot = createActivityLogSnapshot([], exportedAt);
const auditSnapshot = createRunAuditSnapshot([], exportedAt); const auditSnapshot = createRunAuditSnapshot([], exportedAt);
const settingsSnapshot = createProjectSettingsSnapshot({ global: {} }, exportedAt); const settingsSnapshot = createProjectSettingsSnapshot({ global: {} }, exportedAt);
const authSnapshot = createAuthMaterialSnapshot({}, exportedAt); const authSnapshot = createAuthMaterialSnapshot({
anthropic: { type: "api_key", key: "sk-ant" },
"openai-codex": {
type: "oauth",
accessToken: "access-token",
refreshToken: "refresh-token",
expires: 1_900_000_000_000,
accountId: "acct-1",
},
}, exportedAt);
for (const snapshot of [ for (const snapshot of [
taskSnapshot, taskSnapshot,

View File

@@ -3171,9 +3171,14 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
return createAuthMaterialSnapshot(providerAuth); return createAuthMaterialSnapshot(providerAuth);
} }
applyAuthMaterialSnapshot(snapshot: AuthMaterialSnapshot): Record<string, ProviderAuthEntry> { applyAuthMaterialSnapshot(snapshot: AuthMaterialSnapshot): { success: true; authCount: number; providerAuth: Record<string, ProviderAuthEntry> } {
validateSnapshotEnvelope(snapshot); validateSnapshotEnvelope(snapshot);
return { ...(snapshot.payload.providerAuth ?? {}) }; const providerAuth = { ...(snapshot.payload.providerAuth ?? {}) };
return {
success: true,
authCount: Object.keys(providerAuth).length,
providerAuth,
};
} }
// ── Settings Sync API ───────────────────────────────────────────────── // ── Settings Sync API ─────────────────────────────────────────────────

View File

@@ -2829,6 +2829,12 @@ export interface ProviderAuthEntry {
key?: string; key?: string;
/** OAuth access token (for "oauth" type). Omitted for API key providers. */ /** OAuth access token (for "oauth" type). Omitted for API key providers. */
accessToken?: string; accessToken?: string;
/** OAuth refresh token (for "oauth" type). */
refreshToken?: string;
/** OAuth credential expiry epoch milliseconds. */
expires?: number;
/** Optional OAuth account identifier. */
accountId?: string;
/** Whether this credential has been validated. */ /** Whether this credential has been validated. */
authenticated?: boolean; authenticated?: boolean;
} }

View File

@@ -31,6 +31,8 @@ const mockGetSettingsSyncState = vi.fn();
const mockUpdateSettingsSyncState = vi.fn(); const mockUpdateSettingsSyncState = vi.fn();
const mockApplyRemoteSettings = vi.fn(); const mockApplyRemoteSettings = vi.fn();
const mockGetSettingsForSync = vi.fn(); const mockGetSettingsForSync = vi.fn();
const mockGetAuthMaterialSnapshot = vi.fn();
const mockApplyAuthMaterialSnapshot = vi.fn();
const mockChatStoreInit = vi.fn().mockResolvedValue(undefined); const mockChatStoreInit = vi.fn().mockResolvedValue(undefined);
const mockAgentStoreInit = vi.fn().mockResolvedValue(undefined); const mockAgentStoreInit = vi.fn().mockResolvedValue(undefined);
const mockAgentStoreGetAgent = vi.fn().mockResolvedValue(null); const mockAgentStoreGetAgent = vi.fn().mockResolvedValue(null);
@@ -47,6 +49,8 @@ vi.mock("@fusion/core", () => {
updateSettingsSyncState = mockUpdateSettingsSyncState; updateSettingsSyncState = mockUpdateSettingsSyncState;
applyRemoteSettings = mockApplyRemoteSettings; applyRemoteSettings = mockApplyRemoteSettings;
getSettingsForSync = mockGetSettingsForSync; getSettingsForSync = mockGetSettingsForSync;
getAuthMaterialSnapshot = mockGetAuthMaterialSnapshot;
applyAuthMaterialSnapshot = mockApplyAuthMaterialSnapshot;
}, },
ChatStore: class MockChatStore { ChatStore: class MockChatStore {
init = mockChatStoreInit; init = mockChatStoreInit;
@@ -173,6 +177,17 @@ describe("Node settings sync routes", () => {
mockUpdateSettingsSyncState.mockResolvedValue({}); mockUpdateSettingsSyncState.mockResolvedValue({});
mockApplyRemoteSettings.mockResolvedValue({ success: true, globalCount: 1, projectCount: 1, authCount: 0 }); mockApplyRemoteSettings.mockResolvedValue({ success: true, globalCount: 1, projectCount: 1, authCount: 0 });
mockGetSettingsForSync.mockResolvedValue({}); mockGetSettingsForSync.mockResolvedValue({});
mockGetAuthMaterialSnapshot.mockReturnValue({
version: 1,
exportedAt: "2026-04-14T10:00:00.000Z",
checksum: "auth-checksum",
payload: { providerAuth: { anthropic: { type: "api_key", key: "sk-ant-test" } } },
});
mockApplyAuthMaterialSnapshot.mockReturnValue({
success: true,
authCount: 1,
providerAuth: { anthropic: { type: "api_key", key: "sk-ant-received" } },
});
mockAuthStorageSet.mockResolvedValue(undefined); mockAuthStorageSet.mockResolvedValue(undefined);
mockAuthStorageGetOAuthProviders.mockReturnValue([]); mockAuthStorageGetOAuthProviders.mockReturnValue([]);
@@ -636,11 +651,25 @@ describe("Node settings sync routes", () => {
it("emits structured redacted diagnostics for pull-mode auth sync", async () => { it("emits structured redacted diagnostics for pull-mode auth sync", async () => {
const remoteNode = createMockRemoteNode(); const remoteNode = createMockRemoteNode();
mockGetNode.mockResolvedValue(remoteNode); mockGetNode.mockResolvedValue(remoteNode);
mockApplyAuthMaterialSnapshot.mockReturnValueOnce({
success: true,
authCount: 1,
providerAuth: {
google: { type: "api_key", key: "sk-pull-secret-123" },
},
});
mockFetch.mockResolvedValue({ mockFetch.mockResolvedValue({
ok: true, ok: true,
json: () => Promise.resolve({ json: () => Promise.resolve({
providers: { authMaterial: {
google: { type: "api_key", key: "sk-pull-secret-123" }, version: 1,
exportedAt: "2026-04-14T10:00:00.000Z",
checksum: "auth-checksum",
payload: {
providerAuth: {
google: { type: "api_key", key: "sk-pull-secret-123" },
},
},
}, },
sourceNodeId: "node-other", sourceNodeId: "node-other",
timestamp: "2026-04-14T10:00:00.000Z", timestamp: "2026-04-14T10:00:00.000Z",
@@ -781,8 +810,11 @@ describe("Node settings sync routes", () => {
"POST", "POST",
"/api/settings/auth-receive", "/api/settings/auth-receive",
JSON.stringify({ JSON.stringify({
providers: { authMaterial: {
anthropic: { type: "api_key", key: "sk-ant-received" }, version: 1,
exportedAt: "2026-04-14T10:00:00.000Z",
checksum: "auth-checksum",
payload: { providerAuth: { anthropic: { type: "api_key", key: "sk-ant-received" } } },
}, },
sourceNodeId: "node-remote-001", sourceNodeId: "node-remote-001",
timestamp: "2026-04-14T10:00:00.000Z", timestamp: "2026-04-14T10:00:00.000Z",
@@ -801,7 +833,12 @@ describe("Node settings sync routes", () => {
"POST", "POST",
"/api/settings/auth-receive", "/api/settings/auth-receive",
JSON.stringify({ JSON.stringify({
providers: { anthropic: { type: "api_key", key: "sk-ant" } }, authMaterial: {
version: 1,
exportedAt: "2026-04-14T10:00:00.000Z",
checksum: "auth-checksum",
payload: { providerAuth: { anthropic: { type: "api_key", key: "sk-ant" } } },
},
sourceNodeId: "node-remote-001", sourceNodeId: "node-remote-001",
timestamp: "2026-04-14T10:00:00.000Z", timestamp: "2026-04-14T10:00:00.000Z",
}), }),
@@ -819,7 +856,7 @@ describe("Node settings sync routes", () => {
app, app,
"POST", "POST",
"/api/settings/auth-receive", "/api/settings/auth-receive",
JSON.stringify({ providers: "not-an-object" }), JSON.stringify({ authMaterial: "not-an-object" }),
{ "content-type": "application/json", "Authorization": `Bearer ${localNode.apiKey}` }, { "content-type": "application/json", "Authorization": `Bearer ${localNode.apiKey}` },
); );
@@ -835,7 +872,12 @@ describe("Node settings sync routes", () => {
"POST", "POST",
"/api/settings/auth-receive", "/api/settings/auth-receive",
JSON.stringify({ JSON.stringify({
providers: { anthropic: { type: "api_key", key: "sk-ant-secret" } }, authMaterial: {
version: 1,
exportedAt: "2026-04-14T10:00:00.000Z",
checksum: "auth-checksum",
payload: { providerAuth: { anthropic: { type: "api_key", key: "sk-ant-secret" } } },
},
sourceNodeId: "node-remote-001", sourceNodeId: "node-remote-001",
timestamp: "2026-04-14T10:00:00.000Z", timestamp: "2026-04-14T10:00:00.000Z",
}), }),
@@ -890,11 +932,11 @@ describe("Node settings sync routes", () => {
); );
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body.providers).toBeDefined(); expect(res.body.authMaterial).toBeDefined();
expect(res.body.sourceNodeId).toBe("node-local-001"); expect(res.body.sourceNodeId).toBe("node-local-001");
// The actual providers depend on what's in ~/.pi/agent/auth.json // The actual providers depend on what's in ~/.pi/agent/auth.json
// Just verify we got a providers object // Just verify we got a providerAuth snapshot payload
expect(typeof res.body.providers).toBe("object"); expect(typeof res.body.authMaterial.payload.providerAuth).toBe("object");
}); });
it("returns 401 when auth header is missing", async () => { it("returns 401 when auth header is missing", async () => {

View File

@@ -458,16 +458,29 @@ export const registerMeshRoutes: ApiRouteRegistrar = (ctx) => {
} }
}); });
if (sharedState.authMaterial) { await applyDomain("auth-material", async () => {
emitRemoteRouteDiagnostic({ if (!sharedState.authMaterial) return;
route: "mesh-sync", validateSnapshotEnvelope(sharedState.authMaterial);
message: "Ignoring sharedState.authMaterial; use dedicated auth sync routes (/api/nodes/:id/auth/*)", const applied = central.applyAuthMaterialSnapshot(sharedState.authMaterial as Parameters<typeof central.applyAuthMaterialSnapshot>[0]);
nodeId: senderNodeId, const { AuthStorage } = await import("@mariozechner/pi-coding-agent");
upstreamPath: "/api/mesh/sync", const { getFusionAuthPath } = await import("../auth-paths.js");
operationStage: "apply-shared-state-auth-material", const authStorage = AuthStorage.create(getFusionAuthPath());
level: "info", for (const [providerId, credential] of Object.entries(applied.providerAuth)) {
}); if (credential.type === "api_key" && credential.key) {
} authStorage.set(providerId, { type: "api_key", key: credential.key });
continue;
}
if (credential.type === "oauth" && credential.accessToken && credential.refreshToken && typeof credential.expires === "number") {
authStorage.set(providerId, {
type: "oauth",
access: credential.accessToken,
refresh: credential.refreshToken,
expires: credential.expires,
...(credential.accountId ? { accountId: credential.accountId } : {}),
});
}
}
});
// Intentionally do not close this per-request AgentStore wrapper. // Intentionally do not close this per-request AgentStore wrapper.
// AgentStore uses a process-wide DB cache by rootDir; closing here would // AgentStore uses a process-wide DB cache by rootDir; closing here would
@@ -518,6 +531,11 @@ export const registerMeshRoutes: ApiRouteRegistrar = (ctx) => {
const localGlobal = await store.getGlobalSettingsStore().getSettings(); const localGlobal = await store.getGlobalSettingsStore().getSettings();
return central.getProjectSettingsSnapshot(localGlobal); return central.getProjectSettingsSnapshot(localGlobal);
}); });
await collectSnapshot("authMaterial", async () => {
const authPathsModule = await import("./register-settings-sync-helpers.js");
const allProviders = await authPathsModule.readStoredAuthProvidersFromDisk();
return central.getAuthMaterialSnapshot(authPathsModule.toProviderAuthEntries(allProviders));
});
await central.close(); await central.close();

View File

@@ -1,5 +1,5 @@
import { readFile as fsReadFile } from "node:fs/promises"; import { readFile as fsReadFile } from "node:fs/promises";
import type { NodeConfig } from "@fusion/core"; import type { AuthMaterialSnapshot, NodeConfig, ProviderAuthEntry } from "@fusion/core";
import { ApiError } from "../api-error.js"; import { ApiError } from "../api-error.js";
import { getAuthFileCandidates, type StoredAuthProvider } from "../auth-paths.js"; import { getAuthFileCandidates, type StoredAuthProvider } from "../auth-paths.js";
@@ -19,6 +19,37 @@ export async function readStoredAuthProvidersFromDisk(): Promise<Record<string,
return merged; return merged;
} }
export function toProviderAuthEntries(
providers: Record<string, StoredAuthProvider>,
): Record<string, ProviderAuthEntry> {
const providerAuth: Record<string, ProviderAuthEntry> = {};
for (const [providerId, credential] of Object.entries(providers)) {
if (credential?.type === "api_key" && credential.key) {
providerAuth[providerId] = { type: "api_key", key: credential.key };
continue;
}
if (
credential?.type === "oauth"
&& typeof credential.access === "string"
&& typeof credential.refresh === "string"
&& typeof credential.expires === "number"
) {
providerAuth[providerId] = {
type: "oauth",
accessToken: credential.access,
refreshToken: credential.refresh,
expires: credential.expires,
accountId: credential.accountId,
};
}
}
return providerAuth;
}
export function getProviderNamesFromAuthSnapshot(snapshot: AuthMaterialSnapshot): string[] {
return Object.keys(snapshot.payload.providerAuth ?? {});
}
/** /**
* Validate node and make an authenticated fetch call to a remote node. * Validate node and make an authenticated fetch call to a remote node.
* Returns parsed JSON on success, throws ApiError on failure. * Returns parsed JSON on success, throws ApiError on failure.

View File

@@ -1,6 +1,6 @@
import { ApiError, badRequest } from "../api-error.js"; import { ApiError, badRequest } from "../api-error.js";
import { getFusionAuthPath } from "../auth-paths.js"; import { getFusionAuthPath } from "../auth-paths.js";
import { readStoredAuthProvidersFromDisk } from "./register-settings-sync-helpers.js"; import { readStoredAuthProvidersFromDisk, toProviderAuthEntries } from "./register-settings-sync-helpers.js";
import type { ApiRouteRegistrar } from "./types.js"; import type { ApiRouteRegistrar } from "./types.js";
export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => { export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => {
@@ -110,12 +110,12 @@ export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => {
throw new ApiError(401, "Invalid apiKey"); throw new ApiError(401, "Invalid apiKey");
} }
const { providers, sourceNodeId, timestamp } = req.body || {}; const { authMaterial, sourceNodeId, timestamp } = req.body || {};
// Validate required fields // Validate required fields
if (!providers || typeof providers !== "object") { if (!authMaterial || typeof authMaterial !== "object") {
await central.close(); await central.close();
throw badRequest("Missing required field: providers"); throw badRequest("Missing required field: authMaterial");
} }
if (!sourceNodeId) { if (!sourceNodeId) {
await central.close(); await central.close();
@@ -130,14 +130,23 @@ export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => {
const { AuthStorage } = await import("@mariozechner/pi-coding-agent"); const { AuthStorage } = await import("@mariozechner/pi-coding-agent");
const authStorage = AuthStorage.create(getFusionAuthPath()); const authStorage = AuthStorage.create(getFusionAuthPath());
const applyResult = central.applyAuthMaterialSnapshot(authMaterial);
const receivedProviders: string[] = []; const receivedProviders: string[] = [];
for (const [providerId, credential] of Object.entries(providers)) { for (const [providerId, credential] of Object.entries(applyResult.providerAuth)) {
if (typeof credential === "object" && credential !== null) { if (credential.type === "api_key" && credential.key) {
const cred = credential as { type: string; key?: string; access?: string; refresh?: string; expires?: number; accountId?: string }; authStorage.set(providerId, { type: "api_key", key: credential.key });
if (cred.type === "api_key" && cred.key) { receivedProviders.push(providerId);
authStorage.set(providerId, { type: "api_key", key: cred.key }); continue;
receivedProviders.push(providerId); }
} if (credential.type === "oauth" && credential.accessToken && credential.refreshToken && typeof credential.expires === "number") {
authStorage.set(providerId, {
type: "oauth",
access: credential.accessToken,
refresh: credential.refreshToken,
expires: credential.expires,
...(credential.accountId ? { accountId: credential.accountId } : {}),
});
receivedProviders.push(providerId);
} }
} }
@@ -194,19 +203,12 @@ export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => {
const localPeerInfo = await central.getLocalPeerInfo(); const localPeerInfo = await central.getLocalPeerInfo();
const allProviders = await readStoredAuthProvidersFromDisk(); const allProviders = await readStoredAuthProvidersFromDisk();
const authMaterial = central.getAuthMaterialSnapshot(toProviderAuthEntries(allProviders));
// 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(); await central.close();
res.json({ res.json({
providers: apiKeyProviders, authMaterial,
sourceNodeId: localPeerInfo.nodeId, sourceNodeId: localPeerInfo.nodeId,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });

View File

@@ -2,7 +2,7 @@ import type { ProjectSettings } from "@fusion/core";
import { basename } from "node:path"; import { basename } from "node:path";
import { ApiError, badRequest, notFound } from "../api-error.js"; import { ApiError, badRequest, notFound } from "../api-error.js";
import { getFusionAuthPath } from "../auth-paths.js"; import { getFusionAuthPath } from "../auth-paths.js";
import { fetchFromRemoteNode, readStoredAuthProvidersFromDisk } from "./register-settings-sync-helpers.js"; import { fetchFromRemoteNode, readStoredAuthProvidersFromDisk, toProviderAuthEntries } from "./register-settings-sync-helpers.js";
import type { ApiRouteRegistrar } from "./types.js"; import type { ApiRouteRegistrar } from "./types.js";
export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => { export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
@@ -338,26 +338,14 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
const authStorage = AuthStorage.create(getFusionAuthPath()); const authStorage = AuthStorage.create(getFusionAuthPath());
if (direction === "push") { if (direction === "push") {
// Get OAuth provider IDs to exclude
const oauthProviders = authStorage.getOAuthProviders();
const oauthIds = new Set(oauthProviders.map((p) => p.id));
const allProviders = await readStoredAuthProvidersFromDisk(); const allProviders = await readStoredAuthProvidersFromDisk();
const authMaterial = central.getAuthMaterialSnapshot(toProviderAuthEntries(allProviders));
// 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 // Send to remote
await fetchFromRemoteNode(node, "/api/settings/auth-receive", { await fetchFromRemoteNode(node, "/api/settings/auth-receive", {
method: "POST", method: "POST",
body: { body: {
providers: apiKeyProviders, authMaterial,
sourceNodeId: localPeerInfo.nodeId, sourceNodeId: localPeerInfo.nodeId,
timestamp, timestamp,
}, },
@@ -370,7 +358,7 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
await central.close(); await central.close();
const providerNames = Object.keys(apiKeyProviders); const providerNames = Object.keys(authMaterial.payload.providerAuth ?? {});
emitAuthSyncAuditLog({ emitAuthSyncAuditLog({
operation: "sync", operation: "sync",
direction: "push", direction: "push",
@@ -384,17 +372,30 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
} else { } else {
// Pull: fetch remote auth and apply locally // Pull: fetch remote auth and apply locally
const remoteAuth = await fetchFromRemoteNode(node, "/api/settings/auth-export") as { const remoteAuth = await fetchFromRemoteNode(node, "/api/settings/auth-export") as {
providers: Record<string, { type: string; key: string }>; authMaterial: import("@fusion/core").AuthMaterialSnapshot;
sourceNodeId: string; sourceNodeId: string;
timestamp: string; timestamp: string;
}; };
const applied = central.applyAuthMaterialSnapshot(remoteAuth.authMaterial);
// Write received credentials to local AuthStorage // Write received credentials to local AuthStorage
const syncedProviders: string[] = []; const syncedProviders: string[] = [];
for (const [providerId, credential] of Object.entries(remoteAuth.providers || {})) { for (const [providerId, credential] of Object.entries(applied.providerAuth)) {
if (credential.type === "api_key" && credential.key) { if (credential.type === "api_key" && credential.key) {
authStorage.set(providerId, { type: "api_key", key: credential.key }); authStorage.set(providerId, { type: "api_key", key: credential.key });
syncedProviders.push(providerId); syncedProviders.push(providerId);
continue;
}
if (credential.type === "oauth" && credential.accessToken && credential.refreshToken && typeof credential.expires === "number") {
authStorage.set(providerId, {
type: "oauth",
access: credential.accessToken,
refresh: credential.refreshToken,
expires: credential.expires,
...(credential.accountId ? { accountId: credential.accountId } : {}),
});
syncedProviders.push(providerId);
} }
} }

View File

@@ -18,6 +18,8 @@ export interface PeerExchangeServiceOptions {
settingsSyncThrottleMs?: number; settingsSyncThrottleMs?: number;
/** Global settings to include in settings sync. Required when settingsSyncEnabled is true. */ /** Global settings to include in settings sync. Required when settingsSyncEnabled is true. */
globalSettings?: GlobalSettings; globalSettings?: GlobalSettings;
/** When true, include auth material in shared-state exchanges. Default: false. */
settingsSyncAuth?: boolean;
/** Provider auth credentials to include in settings sync. */ /** Provider auth credentials to include in settings sync. */
providerAuth?: Record<string, { type: "api_key" | "oauth"; key?: string; accessToken?: string; authenticated?: boolean }>; providerAuth?: Record<string, { type: "api_key" | "oauth"; key?: string; accessToken?: string; authenticated?: boolean }>;
} }
@@ -66,6 +68,8 @@ export class PeerExchangeService {
private cachedSharedStatePayload: SharedMeshStatePayload | null = null; private cachedSharedStatePayload: SharedMeshStatePayload | null = null;
/** Global settings provided via options. */ /** Global settings provided via options. */
private globalSettings?: GlobalSettings; private globalSettings?: GlobalSettings;
/** Whether auth snapshot exchange is enabled. */
private settingsSyncAuth: boolean;
/** Provider auth credentials provided via options. */ /** Provider auth credentials provided via options. */
private providerAuth?: Record<string, { type: "api_key" | "oauth"; key?: string; accessToken?: string; authenticated?: boolean }>; private providerAuth?: Record<string, { type: "api_key" | "oauth"; key?: string; accessToken?: string; authenticated?: boolean }>;
@@ -81,6 +85,7 @@ export class PeerExchangeService {
this.settingsSyncEnabled = options.settingsSyncEnabled ?? false; this.settingsSyncEnabled = options.settingsSyncEnabled ?? false;
this.settingsSyncThrottleMs = options.settingsSyncThrottleMs ?? 300_000; // 5 minutes default this.settingsSyncThrottleMs = options.settingsSyncThrottleMs ?? 300_000; // 5 minutes default
this.globalSettings = options.globalSettings; this.globalSettings = options.globalSettings;
this.settingsSyncAuth = options.settingsSyncAuth ?? false;
this.providerAuth = options.providerAuth; this.providerAuth = options.providerAuth;
} }
@@ -457,7 +462,7 @@ export class PeerExchangeService {
return undefined; return undefined;
} }
const projectSettings = await core.getProjectSettingsSnapshot(globalSettings); const projectSettings = await core.getProjectSettingsSnapshot(globalSettings);
const authMaterial = core.getAuthMaterialSnapshot(this.providerAuth); const authMaterial = this.settingsSyncAuth ? core.getAuthMaterialSnapshot(this.providerAuth) : undefined;
this.cachedSharedStatePayload = { projectSettings, authMaterial }; this.cachedSharedStatePayload = { projectSettings, authMaterial };
return this.cachedSharedStatePayload; return this.cachedSharedStatePayload;
} }
@@ -468,17 +473,16 @@ export class PeerExchangeService {
): Promise<{ success: boolean; globalCount: number; projectCount: number; authCount: number; error?: string }> { ): Promise<{ success: boolean; globalCount: number; projectCount: number; authCount: number; error?: string }> {
const core = this.centralCore as CentralCore & { const core = this.centralCore as CentralCore & {
applyProjectSettingsSnapshot?: (snapshot: NonNullable<SharedMeshStatePayload["projectSettings"]>) => Promise<{ success: boolean; globalCount: number; projectCount: number; authCount: number; error?: string }>; applyProjectSettingsSnapshot?: (snapshot: NonNullable<SharedMeshStatePayload["projectSettings"]>) => Promise<{ success: boolean; globalCount: number; projectCount: number; authCount: number; error?: string }>;
applyAuthMaterialSnapshot?: (snapshot: NonNullable<SharedMeshStatePayload["authMaterial"]>) => { success?: boolean; authCount?: number; error?: string } | Record<string, unknown>; applyAuthMaterialSnapshot?: (snapshot: NonNullable<SharedMeshStatePayload["authMaterial"]>) => { success?: boolean; authCount?: number; error?: string; providerAuth?: Record<string, unknown> };
}; };
if (sharedState?.projectSettings && core.applyProjectSettingsSnapshot) { if (sharedState?.projectSettings && core.applyProjectSettingsSnapshot) {
const result = await core.applyProjectSettingsSnapshot(sharedState.projectSettings); const result = await core.applyProjectSettingsSnapshot(sharedState.projectSettings);
if (sharedState.authMaterial && core.applyAuthMaterialSnapshot) { if (this.settingsSyncAuth && sharedState.authMaterial && core.applyAuthMaterialSnapshot) {
const authResult = core.applyAuthMaterialSnapshot(sharedState.authMaterial); const authResult = core.applyAuthMaterialSnapshot(sharedState.authMaterial);
const authResultWithCount = authResult as { authCount?: number };
const authCount = const authCount =
typeof authResultWithCount.authCount === "number" typeof authResult.authCount === "number"
? authResultWithCount.authCount ? authResult.authCount
: Object.keys(sharedState.authMaterial.payload.providerAuth ?? {}).length; : Object.keys(sharedState.authMaterial.payload.providerAuth ?? {}).length;
return { ...result, authCount: Math.max(result.authCount, authCount) }; return { ...result, authCount: Math.max(result.authCount, authCount) };
} }