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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user