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`)
- `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). |

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. |
| `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. |

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 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:

View File

@@ -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 });

View File

@@ -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,

View File

@@ -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 ─────────────────────────────────────────────────

View File

@@ -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;
}

View File

@@ -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 () => {

View File

@@ -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();

View File

@@ -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.

View File

@@ -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(),
});

View File

@@ -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);
}
}

View File

@@ -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) };
}