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:
5
.changeset/fn-3707-mesh-auth-sync.md
Normal file
5
.changeset/fn-3707-mesh-auth-sync.md
Normal 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.
|
||||
@@ -244,7 +244,7 @@ Lifecycle contract (`types.ts` `isValidApprovalRequestTransition`):
|
||||
- `ActivityLogSnapshot` (`entries`)
|
||||
- `RunAuditSnapshot` (`entries`)
|
||||
- `ProjectSettingsSnapshot` (`global`, `projects`)
|
||||
- `AuthMaterialSnapshot` (`providerAuth`)
|
||||
- `AuthMaterialSnapshot` (`providerAuth`, with API-key and OAuth credential shapes)
|
||||
|
||||
Intentional exclusions from shared snapshots:
|
||||
- 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/pull` | Pull settings from a remote node. |
|
||||
| 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/auth-receive` | Receive auth credentials (inbound). |
|
||||
| GET | `/api/settings/auth-export` | Export local auth credentials. |
|
||||
| POST | `/api/settings/auth-receive` | Receive `AuthMaterialSnapshot` and persist via auth storage. |
|
||||
| GET | `/api/settings/auth-export` | Export local `AuthMaterialSnapshot`. |
|
||||
| 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. |
|
||||
| GET | `/api/updates/check` | Perform an on-demand npm registry check for the latest `@runfusion/fusion` version (no cache). |
|
||||
|
||||
@@ -71,7 +71,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
|
||||
| `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. |
|
||||
| `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`. |
|
||||
| `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. |
|
||||
|
||||
@@ -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 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
For `strong` writes:
|
||||
|
||||
@@ -2917,12 +2917,22 @@ describe("CentralCore", () => {
|
||||
const legacy = await syncCentral.getSettingsForSync({});
|
||||
const snapshot = await syncCentral.getProjectSettingsSnapshot({});
|
||||
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.projects).toEqual(legacy.projects);
|
||||
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 {
|
||||
await syncCentral.close();
|
||||
rmSync(tempDir + "-snapshot", { recursive: true, force: true });
|
||||
|
||||
@@ -56,7 +56,16 @@ describe("shared-mesh-state", () => {
|
||||
const activitySnapshot = createActivityLogSnapshot([], exportedAt);
|
||||
const auditSnapshot = createRunAuditSnapshot([], 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 [
|
||||
taskSnapshot,
|
||||
|
||||
@@ -3171,9 +3171,14 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
return createAuthMaterialSnapshot(providerAuth);
|
||||
}
|
||||
|
||||
applyAuthMaterialSnapshot(snapshot: AuthMaterialSnapshot): Record<string, ProviderAuthEntry> {
|
||||
applyAuthMaterialSnapshot(snapshot: AuthMaterialSnapshot): { success: true; authCount: number; providerAuth: Record<string, ProviderAuthEntry> } {
|
||||
validateSnapshotEnvelope(snapshot);
|
||||
return { ...(snapshot.payload.providerAuth ?? {}) };
|
||||
const providerAuth = { ...(snapshot.payload.providerAuth ?? {}) };
|
||||
return {
|
||||
success: true,
|
||||
authCount: Object.keys(providerAuth).length,
|
||||
providerAuth,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Settings Sync API ─────────────────────────────────────────────────
|
||||
|
||||
@@ -2829,6 +2829,12 @@ export interface ProviderAuthEntry {
|
||||
key?: string;
|
||||
/** OAuth access token (for "oauth" type). Omitted for API key providers. */
|
||||
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. */
|
||||
authenticated?: boolean;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ const mockGetSettingsSyncState = vi.fn();
|
||||
const mockUpdateSettingsSyncState = vi.fn();
|
||||
const mockApplyRemoteSettings = vi.fn();
|
||||
const mockGetSettingsForSync = vi.fn();
|
||||
const mockGetAuthMaterialSnapshot = vi.fn();
|
||||
const mockApplyAuthMaterialSnapshot = vi.fn();
|
||||
const mockChatStoreInit = vi.fn().mockResolvedValue(undefined);
|
||||
const mockAgentStoreInit = vi.fn().mockResolvedValue(undefined);
|
||||
const mockAgentStoreGetAgent = vi.fn().mockResolvedValue(null);
|
||||
@@ -47,6 +49,8 @@ vi.mock("@fusion/core", () => {
|
||||
updateSettingsSyncState = mockUpdateSettingsSyncState;
|
||||
applyRemoteSettings = mockApplyRemoteSettings;
|
||||
getSettingsForSync = mockGetSettingsForSync;
|
||||
getAuthMaterialSnapshot = mockGetAuthMaterialSnapshot;
|
||||
applyAuthMaterialSnapshot = mockApplyAuthMaterialSnapshot;
|
||||
},
|
||||
ChatStore: class MockChatStore {
|
||||
init = mockChatStoreInit;
|
||||
@@ -173,6 +177,17 @@ describe("Node settings sync routes", () => {
|
||||
mockUpdateSettingsSyncState.mockResolvedValue({});
|
||||
mockApplyRemoteSettings.mockResolvedValue({ success: true, globalCount: 1, projectCount: 1, authCount: 0 });
|
||||
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);
|
||||
mockAuthStorageGetOAuthProviders.mockReturnValue([]);
|
||||
|
||||
@@ -636,11 +651,25 @@ describe("Node settings sync routes", () => {
|
||||
it("emits structured redacted diagnostics for pull-mode auth sync", async () => {
|
||||
const remoteNode = createMockRemoteNode();
|
||||
mockGetNode.mockResolvedValue(remoteNode);
|
||||
mockApplyAuthMaterialSnapshot.mockReturnValueOnce({
|
||||
success: true,
|
||||
authCount: 1,
|
||||
providerAuth: {
|
||||
google: { type: "api_key", key: "sk-pull-secret-123" },
|
||||
},
|
||||
});
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
providers: {
|
||||
google: { type: "api_key", key: "sk-pull-secret-123" },
|
||||
authMaterial: {
|
||||
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",
|
||||
timestamp: "2026-04-14T10:00:00.000Z",
|
||||
@@ -781,8 +810,11 @@ describe("Node settings sync routes", () => {
|
||||
"POST",
|
||||
"/api/settings/auth-receive",
|
||||
JSON.stringify({
|
||||
providers: {
|
||||
anthropic: { type: "api_key", key: "sk-ant-received" },
|
||||
authMaterial: {
|
||||
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",
|
||||
timestamp: "2026-04-14T10:00:00.000Z",
|
||||
@@ -801,7 +833,12 @@ describe("Node settings sync routes", () => {
|
||||
"POST",
|
||||
"/api/settings/auth-receive",
|
||||
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",
|
||||
timestamp: "2026-04-14T10:00:00.000Z",
|
||||
}),
|
||||
@@ -819,7 +856,7 @@ describe("Node settings sync routes", () => {
|
||||
app,
|
||||
"POST",
|
||||
"/api/settings/auth-receive",
|
||||
JSON.stringify({ providers: "not-an-object" }),
|
||||
JSON.stringify({ authMaterial: "not-an-object" }),
|
||||
{ "content-type": "application/json", "Authorization": `Bearer ${localNode.apiKey}` },
|
||||
);
|
||||
|
||||
@@ -835,7 +872,12 @@ describe("Node settings sync routes", () => {
|
||||
"POST",
|
||||
"/api/settings/auth-receive",
|
||||
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",
|
||||
timestamp: "2026-04-14T10:00:00.000Z",
|
||||
}),
|
||||
@@ -890,11 +932,11 @@ describe("Node settings sync routes", () => {
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.providers).toBeDefined();
|
||||
expect(res.body.authMaterial).toBeDefined();
|
||||
expect(res.body.sourceNodeId).toBe("node-local-001");
|
||||
// The actual providers depend on what's in ~/.pi/agent/auth.json
|
||||
// Just verify we got a providers object
|
||||
expect(typeof res.body.providers).toBe("object");
|
||||
// Just verify we got a providerAuth snapshot payload
|
||||
expect(typeof res.body.authMaterial.payload.providerAuth).toBe("object");
|
||||
});
|
||||
|
||||
it("returns 401 when auth header is missing", async () => {
|
||||
|
||||
@@ -458,16 +458,29 @@ export const registerMeshRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
}
|
||||
});
|
||||
|
||||
if (sharedState.authMaterial) {
|
||||
emitRemoteRouteDiagnostic({
|
||||
route: "mesh-sync",
|
||||
message: "Ignoring sharedState.authMaterial; use dedicated auth sync routes (/api/nodes/:id/auth/*)",
|
||||
nodeId: senderNodeId,
|
||||
upstreamPath: "/api/mesh/sync",
|
||||
operationStage: "apply-shared-state-auth-material",
|
||||
level: "info",
|
||||
});
|
||||
}
|
||||
await applyDomain("auth-material", async () => {
|
||||
if (!sharedState.authMaterial) return;
|
||||
validateSnapshotEnvelope(sharedState.authMaterial);
|
||||
const applied = central.applyAuthMaterialSnapshot(sharedState.authMaterial as Parameters<typeof central.applyAuthMaterialSnapshot>[0]);
|
||||
const { AuthStorage } = await import("@mariozechner/pi-coding-agent");
|
||||
const { getFusionAuthPath } = await import("../auth-paths.js");
|
||||
const authStorage = AuthStorage.create(getFusionAuthPath());
|
||||
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.
|
||||
// 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();
|
||||
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();
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { getAuthFileCandidates, type StoredAuthProvider } from "../auth-paths.js";
|
||||
|
||||
@@ -19,6 +19,37 @@ export async function readStoredAuthProvidersFromDisk(): Promise<Record<string,
|
||||
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.
|
||||
* Returns parsed JSON on success, throws ApiError on failure.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ApiError, badRequest } from "../api-error.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";
|
||||
|
||||
export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
@@ -110,12 +110,12 @@ export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
throw new ApiError(401, "Invalid apiKey");
|
||||
}
|
||||
|
||||
const { providers, sourceNodeId, timestamp } = req.body || {};
|
||||
const { authMaterial, sourceNodeId, timestamp } = req.body || {};
|
||||
|
||||
// Validate required fields
|
||||
if (!providers || typeof providers !== "object") {
|
||||
if (!authMaterial || typeof authMaterial !== "object") {
|
||||
await central.close();
|
||||
throw badRequest("Missing required field: providers");
|
||||
throw badRequest("Missing required field: authMaterial");
|
||||
}
|
||||
if (!sourceNodeId) {
|
||||
await central.close();
|
||||
@@ -130,14 +130,23 @@ export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
const { AuthStorage } = await import("@mariozechner/pi-coding-agent");
|
||||
const authStorage = AuthStorage.create(getFusionAuthPath());
|
||||
|
||||
const applyResult = central.applyAuthMaterialSnapshot(authMaterial);
|
||||
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);
|
||||
}
|
||||
for (const [providerId, credential] of Object.entries(applyResult.providerAuth)) {
|
||||
if (credential.type === "api_key" && credential.key) {
|
||||
authStorage.set(providerId, { type: "api_key", key: credential.key });
|
||||
receivedProviders.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 } : {}),
|
||||
});
|
||||
receivedProviders.push(providerId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,19 +203,12 @@ export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
const localPeerInfo = await central.getLocalPeerInfo();
|
||||
|
||||
const allProviders = await readStoredAuthProvidersFromDisk();
|
||||
|
||||
// Filter to only API-key-based providers (skip OAuth)
|
||||
const apiKeyProviders: Record<string, { type: string; key: string }> = {};
|
||||
for (const [providerId, cred] of Object.entries(allProviders)) {
|
||||
if (cred.type === "api_key" && cred.key) {
|
||||
apiKeyProviders[providerId] = { type: "api_key", key: cred.key };
|
||||
}
|
||||
}
|
||||
const authMaterial = central.getAuthMaterialSnapshot(toProviderAuthEntries(allProviders));
|
||||
|
||||
await central.close();
|
||||
|
||||
res.json({
|
||||
providers: apiKeyProviders,
|
||||
authMaterial,
|
||||
sourceNodeId: localPeerInfo.nodeId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ProjectSettings } from "@fusion/core";
|
||||
import { basename } from "node:path";
|
||||
import { ApiError, badRequest, notFound } from "../api-error.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";
|
||||
|
||||
export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
@@ -338,26 +338,14 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
const authStorage = AuthStorage.create(getFusionAuthPath());
|
||||
|
||||
if (direction === "push") {
|
||||
// Get OAuth provider IDs to exclude
|
||||
const oauthProviders = authStorage.getOAuthProviders();
|
||||
const oauthIds = new Set(oauthProviders.map((p) => p.id));
|
||||
|
||||
const allProviders = await readStoredAuthProvidersFromDisk();
|
||||
|
||||
// Filter to only API-key-based providers (skip OAuth)
|
||||
const apiKeyProviders: Record<string, { type: string; key: string }> = {};
|
||||
for (const [providerId, cred] of Object.entries(allProviders)) {
|
||||
if (oauthIds.has(providerId)) continue;
|
||||
if (cred.type === "api_key" && cred.key) {
|
||||
apiKeyProviders[providerId] = { type: "api_key", key: cred.key };
|
||||
}
|
||||
}
|
||||
const authMaterial = central.getAuthMaterialSnapshot(toProviderAuthEntries(allProviders));
|
||||
|
||||
// Send to remote
|
||||
await fetchFromRemoteNode(node, "/api/settings/auth-receive", {
|
||||
method: "POST",
|
||||
body: {
|
||||
providers: apiKeyProviders,
|
||||
authMaterial,
|
||||
sourceNodeId: localPeerInfo.nodeId,
|
||||
timestamp,
|
||||
},
|
||||
@@ -370,7 +358,7 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
|
||||
await central.close();
|
||||
|
||||
const providerNames = Object.keys(apiKeyProviders);
|
||||
const providerNames = Object.keys(authMaterial.payload.providerAuth ?? {});
|
||||
emitAuthSyncAuditLog({
|
||||
operation: "sync",
|
||||
direction: "push",
|
||||
@@ -384,17 +372,30 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
} 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 }>;
|
||||
authMaterial: import("@fusion/core").AuthMaterialSnapshot;
|
||||
sourceNodeId: string;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
const applied = central.applyAuthMaterialSnapshot(remoteAuth.authMaterial);
|
||||
|
||||
// Write received credentials to local AuthStorage
|
||||
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) {
|
||||
authStorage.set(providerId, { type: "api_key", key: credential.key });
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface PeerExchangeServiceOptions {
|
||||
settingsSyncThrottleMs?: number;
|
||||
/** Global settings to include in settings sync. Required when settingsSyncEnabled is true. */
|
||||
globalSettings?: GlobalSettings;
|
||||
/** When true, include auth material in shared-state exchanges. Default: false. */
|
||||
settingsSyncAuth?: boolean;
|
||||
/** Provider auth credentials to include in settings sync. */
|
||||
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;
|
||||
/** Global settings provided via options. */
|
||||
private globalSettings?: GlobalSettings;
|
||||
/** Whether auth snapshot exchange is enabled. */
|
||||
private settingsSyncAuth: boolean;
|
||||
/** Provider auth credentials provided via options. */
|
||||
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.settingsSyncThrottleMs = options.settingsSyncThrottleMs ?? 300_000; // 5 minutes default
|
||||
this.globalSettings = options.globalSettings;
|
||||
this.settingsSyncAuth = options.settingsSyncAuth ?? false;
|
||||
this.providerAuth = options.providerAuth;
|
||||
}
|
||||
|
||||
@@ -457,7 +462,7 @@ export class PeerExchangeService {
|
||||
return undefined;
|
||||
}
|
||||
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 };
|
||||
return this.cachedSharedStatePayload;
|
||||
}
|
||||
@@ -468,17 +473,16 @@ export class PeerExchangeService {
|
||||
): Promise<{ success: boolean; globalCount: number; projectCount: number; authCount: number; error?: string }> {
|
||||
const core = this.centralCore as CentralCore & {
|
||||
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) {
|
||||
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 authResultWithCount = authResult as { authCount?: number };
|
||||
const authCount =
|
||||
typeof authResultWithCount.authCount === "number"
|
||||
? authResultWithCount.authCount
|
||||
typeof authResult.authCount === "number"
|
||||
? authResult.authCount
|
||||
: Object.keys(sharedState.authMaterial.payload.providerAuth ?? {}).length;
|
||||
return { ...result, authCount: Math.max(result.authCount, authCount) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user