FN-7647: route dashboard sync/mesh AuthStorage writes through createFusionAuthStorage

Route node settings-sync and mesh credential writes through the coordinated @fusion/engine auth store to prevent concurrent clobbers of ~/.fusion/agent/auth.json.

- register-settings-sync-routes.ts, register-settings-sync-inbound-routes.ts, and register-mesh-routes.ts now persist received credentials via createFusionAuthStorage() instead of raw AuthStorage.create(getFusionAuthPath())
- Shares FN-7646's reload-before-persist + per-provider locked-merge path, avoiding lost writes from concurrent Fusion processes
- Uses a static top-level import of @fusion/engine (not dynamic import) per FN-3049's bundler-safety rule
- Adds route-level regression coverage in mesh-routes.test.ts, routes-nodes-sync-contract.test.ts, and routes-nodes-sync.test.ts
- Adds a patch changeset for @runfusion/fusion

Files changed:
 .../fn-7647-auth-storage-routes-coordination.md    |   7 ++
 .../dashboard/src/__tests__/mesh-routes.test.ts    | 118 +++++++++++++++++++++
 .../__tests__/routes-nodes-sync-contract.test.ts   |  27 ++++-
 .../src/__tests__/routes-nodes-sync.test.ts        | 110 ++++++++++++++++++-
 .../dashboard/src/routes/register-mesh-routes.ts   |  14 ++-
 .../register-settings-sync-inbound-routes.ts       |  15 ++-
 .../src/routes/register-settings-sync-routes.ts    |  15 ++-
 7 files changed, 290 insertions(+), 16 deletions(-)

Fusion-Task-Id: FN-7647

Fusion-Task-Lineage: cfab9679-0baa-4eca-8c7a-23db017aa54f

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-07 14:42:29 -07:00
parent bec8987ce9
commit 563a8c6b7c
7 changed files with 289 additions and 15 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Route node settings-sync and mesh credential writes through the coordinated auth store to prevent concurrent clobbers.
category: fix
dev: register-settings-sync-routes.ts, register-settings-sync-inbound-routes.ts, and register-mesh-routes.ts now persist received credentials via @fusion/engine createFusionAuthStorage() instead of raw AuthStorage.create(getFusionAuthPath()), sharing FN-7646's reload-before-persist + per-provider locked-merge path over ~/.fusion/agent/auth.json. Adds route-level regression coverage.

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { EventEmitter } from "node:events";
import type { Task, TaskStore } from "@fusion/core";
import { createAuthMaterialSnapshot } from "@fusion/core";
import { request } from "../test-request.js";
import { createServer } from "../server.js";
import type { RuntimeLogger } from "../runtime-logger.js";
@@ -31,6 +32,7 @@ const mockCommitDistributedTaskIdReservation = vi.fn();
const mockAbortDistributedTaskIdReservation = vi.fn();
const mockGetDistributedTaskIdState = vi.fn();
const mockApplyReplicatedTaskCreate = vi.fn();
const mockApplyAuthMaterialSnapshot = vi.fn();
// Mock GlobalSettingsStore
const mockGetSettings = vi.fn().mockResolvedValue({});
@@ -55,10 +57,38 @@ vi.mock("@fusion/core", async () => {
getLocalMeshSnapshot: mockGetLocalMeshSnapshot,
getSettingsForSync: mockGetSettingsForSync,
applyRemoteSettings: mockApplyRemoteSettings,
applyAuthMaterialSnapshot: mockApplyAuthMaterialSnapshot,
}; }),
};
});
// FNXC:ProviderAuth 2026-07-07-00:00: FN-7647 routed register-mesh-routes.ts's inline
// auth-material shared-state domain through @fusion/engine's createFusionAuthStorage() instead of
// a raw AuthStorage.create(getFusionAuthPath()). Mock the factory so mesh sync auth-material writes
// are observable and never touch the real ~/.fusion/agent/auth.json during tests; preserve every
// other real @fusion/engine export other routers rely on at module load time. vi.mock factories are
// hoisted above top-level const declarations, so the referenced mocks must be created via
// vi.hoisted to avoid a temporal-dead-zone ReferenceError.
const { mockMeshAuthStorageSet, mockCreateFusionAuthStorage } = vi.hoisted(() => {
const mockMeshAuthStorageSet = vi.fn().mockResolvedValue(undefined);
const mockCreateFusionAuthStorage = vi.fn(() => ({
set: mockMeshAuthStorageSet,
get: vi.fn(),
getApiKey: vi.fn(),
getOAuthProviders: vi.fn().mockReturnValue([]),
reload: vi.fn(),
}));
return { mockMeshAuthStorageSet, mockCreateFusionAuthStorage };
});
vi.mock("@fusion/engine", async (importOriginal) => {
const actual = await importOriginal<typeof import("@fusion/engine")>();
return {
...actual,
createFusionAuthStorage: mockCreateFusionAuthStorage,
};
});
class MockStore extends EventEmitter {
getRootDir(): string {
return "/tmp/fn-1224";
@@ -703,6 +733,94 @@ describe("POST /api/mesh/sync", () => {
);
});
});
// ── FN-7647 Symptom Verification: auth-material shared-state sync ─────────────
// Original symptom (FN-7646 class): a raw independent AuthStorage instance can persist a stale
// in-memory snapshot over ~/.fusion/agent/auth.json, wiping a key another process just saved.
// Asserts the mesh sync auth-material domain writes via the coordinated createFusionAuthStorage()
// proxy (not a raw instance) and that an unrelated provider's saved credential survives the write.
describe("auth-material shared-state sync", () => {
beforeEach(() => {
mockApplyAuthMaterialSnapshot.mockReset();
mockCreateFusionAuthStorage.mockClear();
mockMeshAuthStorageSet.mockReset().mockResolvedValue(undefined);
});
it("writes received auth-material credentials via the coordinated createFusionAuthStorage() proxy", async () => {
mockApplyAuthMaterialSnapshot.mockReturnValue({
success: true,
authCount: 1,
providerAuth: { anthropic: { type: "api_key", key: "sk-ant-mesh-received" } },
});
const authMaterial = createAuthMaterialSnapshot({
anthropic: { type: "api_key", key: "sk-ant-mesh-received" },
});
const response = await request(
app,
"POST",
"/api/mesh/sync",
JSON.stringify({
senderNodeId: "node_remote",
senderNodeUrl: "https://remote.example.com",
knownPeers: [],
timestamp: "2026-04-01T12:00:00.000Z",
sharedState: { authMaterial },
}),
{ "Content-Type": "application/json" }
);
expect(response.status).toBe(200);
expect(mockApplyAuthMaterialSnapshot).toHaveBeenCalledWith(authMaterial);
// Proves the write path is the coordinated proxy, not a raw independent AuthStorage instance.
expect(mockCreateFusionAuthStorage).toHaveBeenCalled();
expect(mockMeshAuthStorageSet).toHaveBeenCalledWith("anthropic", { type: "api_key", key: "sk-ant-mesh-received" });
});
it("concurrent-writer survival: an unrelated provider's saved key is never clobbered by the mesh sync write", async () => {
mockApplyAuthMaterialSnapshot.mockReturnValue({
success: true,
authCount: 1,
providerAuth: { anthropic: { type: "api_key", key: "sk-ant-mesh-received" } },
});
// Simulate another Fusion instance's provider credential already persisted on disk by having
// the mocked coordinated proxy merge per-provider (as the real reload-before-persist proxy
// does) rather than overwrite the whole file.
const diskState: Record<string, unknown> = {
openai: { type: "api_key", key: "sk-openai-from-other-instance" },
};
mockMeshAuthStorageSet.mockImplementation(async (providerId: string, credential: unknown) => {
diskState[providerId] = credential;
});
const authMaterial = createAuthMaterialSnapshot({
anthropic: { type: "api_key", key: "sk-ant-mesh-received" },
});
const response = await request(
app,
"POST",
"/api/mesh/sync",
JSON.stringify({
senderNodeId: "node_remote",
senderNodeUrl: "https://remote.example.com",
knownPeers: [],
timestamp: "2026-04-01T12:00:00.000Z",
sharedState: { authMaterial },
}),
{ "Content-Type": "application/json" }
);
expect(response.status).toBe(200);
// The unrelated provider saved by another instance before this handler ran must still be present.
expect(diskState.openai).toEqual({ type: "api_key", key: "sk-openai-from-other-instance" });
// And the received provider was merged in alongside it — no full-snapshot clobber.
expect(diskState.anthropic).toEqual({ type: "api_key", key: "sk-ant-mesh-received" });
expect(mockMeshAuthStorageSet).toHaveBeenCalledTimes(1);
});
});
});
describe("/api/mesh/task-ids routes", () => {

View File

@@ -62,8 +62,23 @@ vi.mock("@fusion/core", async (importOriginal) => {
};
});
const mockAuthStorageSet = vi.fn();
const mockAuthStorageGetOAuthProviders = vi.fn().mockReturnValue([]);
// FNXC:ProviderAuth 2026-07-07-00:00: FN-7647 routed register-settings-sync-routes.ts and
// register-settings-sync-inbound-routes.ts through @fusion/engine's createFusionAuthStorage()
// instead of a raw AuthStorage.create(getFusionAuthPath()). Mock the factory (not just the raw
// pi-coding-agent AuthStorage) so this contract matrix never falls through to the real proxy and
// touches the developer's actual ~/.fusion/agent/auth.json during the auth-sync/auth-receive cases.
const { mockAuthStorageSet, mockAuthStorageGetOAuthProviders, mockCreateFusionAuthStorage } = vi.hoisted(() => {
const mockAuthStorageSet = vi.fn();
const mockAuthStorageGetOAuthProviders = vi.fn().mockReturnValue([]);
const mockCreateFusionAuthStorage = vi.fn(() => ({
set: mockAuthStorageSet,
get: vi.fn(),
getApiKey: vi.fn(),
getOAuthProviders: mockAuthStorageGetOAuthProviders,
reload: vi.fn(),
}));
return { mockAuthStorageSet, mockAuthStorageGetOAuthProviders, mockCreateFusionAuthStorage };
});
vi.mock("@earendil-works/pi-coding-agent", () => {
return {
@@ -79,6 +94,14 @@ vi.mock("@earendil-works/pi-coding-agent", () => {
};
});
vi.mock("@fusion/engine", async (importOriginal) => {
const actual = await importOriginal<typeof import("@fusion/engine")>();
return {
...actual,
createFusionAuthStorage: mockCreateFusionAuthStorage,
};
});
class MockStore extends EventEmitter {
getRootDir(): string {
return "/tmp/fn-4755-test";

View File

@@ -71,9 +71,24 @@ vi.mock("@fusion/core", async (importOriginal) => {
});
// ── Mock AuthStorage ───────────────────────────────────────────────────
// FNXC:ProviderAuth 2026-07-07-00:00: FN-7647 routed register-settings-sync-routes.ts and
// register-settings-sync-inbound-routes.ts through @fusion/engine's createFusionAuthStorage()
// instead of a raw AuthStorage.create(getFusionAuthPath()). Mock the factory (not the raw
// pi-coding-agent AuthStorage) so mockAuthStorageSet still observes the coordinated write path,
// and preserve every other real @fusion/engine export other routers rely on at module load time.
const mockAuthStorageSet = vi.fn();
const mockAuthStorageGetOAuthProviders = vi.fn().mockReturnValue([]);
const { mockAuthStorageSet, mockAuthStorageGetOAuthProviders, mockCreateFusionAuthStorage } = vi.hoisted(() => {
const mockAuthStorageSet = vi.fn();
const mockAuthStorageGetOAuthProviders = vi.fn().mockReturnValue([]);
const mockCreateFusionAuthStorage = vi.fn(() => ({
set: mockAuthStorageSet,
get: vi.fn(),
getApiKey: vi.fn(),
getOAuthProviders: mockAuthStorageGetOAuthProviders,
reload: vi.fn(),
}));
return { mockAuthStorageSet, mockAuthStorageGetOAuthProviders, mockCreateFusionAuthStorage };
});
vi.mock("@earendil-works/pi-coding-agent", () => {
return {
@@ -89,6 +104,14 @@ vi.mock("@earendil-works/pi-coding-agent", () => {
};
});
vi.mock("@fusion/engine", async (importOriginal) => {
const actual = await importOriginal<typeof import("@fusion/engine")>();
return {
...actual,
createFusionAuthStorage: mockCreateFusionAuthStorage,
};
});
// ── Mock Store ────────────────────────────────────────────────────────
class MockStore extends EventEmitter {
@@ -1072,6 +1095,11 @@ describe("Node settings sync routes", () => {
lastSyncedAt: expect.any(String),
}),
);
// FN-7647: pull-mode credential writes go through the coordinated createFusionAuthStorage()
// proxy, not a raw independent AuthStorage instance. (applyAuthMaterialSnapshot resolves the
// default beforeEach mock here — the anthropic credential — since this test doesn't override it.)
expect(mockCreateFusionAuthStorage).toHaveBeenCalled();
expect(mockAuthStorageSet).toHaveBeenCalledWith("anthropic", { type: "api_key", key: "sk-ant-received" });
});
it("emits structured redacted diagnostics for pull-mode auth sync", async () => {
@@ -1420,6 +1448,82 @@ describe("Node settings sync routes", () => {
expect(res.body.receivedProviders).toContain("anthropic");
});
// ── FN-7647 Symptom Verification ─────────────────────────────────
// Original symptom (FN-7646 class): a raw independent AuthStorage instance can persist a
// stale in-memory snapshot over ~/.fusion/agent/auth.json, wiping a key another process just
// saved. This asserts the inbound handler writes through the coordinated
// createFusionAuthStorage() proxy (not a raw instance) and that per-provider writes never
// clobber an unrelated provider's credential already present on disk.
it("FN-7647: persists received credentials via the coordinated createFusionAuthStorage() proxy", async () => {
const localNode = createMockLocalNode();
mockListNodes.mockResolvedValue([localNode]);
const res = await request(
app,
"POST",
"/api/settings/auth-receive",
JSON.stringify({
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",
}),
{ "content-type": "application/json", "Authorization": `Bearer ${localNode.apiKey}` },
);
expect(res.status).toBe(200);
// Proves the write path is the coordinated proxy, not a raw independent AuthStorage instance.
expect(mockCreateFusionAuthStorage).toHaveBeenCalled();
expect(mockAuthStorageSet).toHaveBeenCalledWith("anthropic", { type: "api_key", key: "sk-ant-received" });
});
it("FN-7647: concurrent-writer survival — an unrelated provider's saved key is never clobbered by this handler's write", async () => {
const localNode = createMockLocalNode();
mockListNodes.mockResolvedValue([localNode]);
// Simulate another Fusion instance's provider credential already persisted on disk by having
// the mocked coordinated proxy merge per-provider (as the real reload-before-persist proxy does)
// rather than overwrite the whole file. `openai` was saved by a concurrent instance and must
// still be observable after this handler's write — this handler only ever calls .set() for the
// providers it received (anthropic), never a whole-snapshot overwrite that would drop `openai`.
const diskState: Record<string, unknown> = {
openai: { type: "api_key", key: "sk-openai-from-other-instance" },
};
mockAuthStorageSet.mockImplementation(async (providerId: string, credential: unknown) => {
diskState[providerId] = credential;
});
const res = await request(
app,
"POST",
"/api/settings/auth-receive",
JSON.stringify({
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",
}),
{ "content-type": "application/json", "Authorization": `Bearer ${localNode.apiKey}` },
);
expect(res.status).toBe(200);
// The unrelated provider saved by another instance before this handler ran must still be present.
expect(diskState.openai).toEqual({ type: "api_key", key: "sk-openai-from-other-instance" });
// And the received provider was merged in alongside it — no full-snapshot clobber.
expect(diskState.anthropic).toEqual({ type: "api_key", key: "sk-ant-received" });
// .set() was only invoked for the received provider — never a whole-file overwrite call.
expect(mockAuthStorageSet).toHaveBeenCalledTimes(1);
expect(mockAuthStorageSet).toHaveBeenCalledWith("anthropic", { type: "api_key", key: "sk-ant-received" });
});
it("returns 401 when auth header is missing", async () => {
const res = await request(
app,

View File

@@ -1,3 +1,4 @@
import { createFusionAuthStorage } from "@fusion/engine";
import { ApiError, badRequest } from "../api-error.js";
import type { ApiRouteRegistrar } from "./types.js";
import { fetchFromRemoteNode } from "./register-settings-sync-helpers.js";
@@ -531,9 +532,16 @@ export const registerMeshRoutes: ApiRouteRegistrar = (ctx) => {
if (!sharedState.authMaterial) return;
validateSnapshotEnvelope(sharedState.authMaterial);
const applied = central.applyAuthMaterialSnapshot(sharedState.authMaterial as Parameters<typeof central.applyAuthMaterialSnapshot>[0]);
const { AuthStorage } = await import("@earendil-works/pi-coding-agent");
const { getFusionAuthPath } = await import("../auth-paths.js");
const authStorage = AuthStorage.create(getFusionAuthPath());
/*
* FNXC:ProviderAuth 2026-07-07-00:00:
* Dashboard sync/mesh credential writes must go through the coordinated createFusionAuthStorage()
* proxy (reload-before-persist, supplemental-credential sync, logout suppression, Anthropic aliasing)
* instead of a raw AuthStorage.create(getFusionAuthPath()) instance, so concurrent Fusion processes
* sharing ~/.fusion/agent/auth.json do not clobber each other's saved provider keys. FN-7647,
* follow-up to FN-7646's engine-side hardening. Uses a static top-level import (not dynamic
* import) per FN-3049's bundler-safety rule against dynamic engine imports (`await` + `import(...)` of the engine package).
*/
const authStorage = createFusionAuthStorage();
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 });

View File

@@ -1,7 +1,7 @@
import { isMovedSettingsKey } from "@fusion/core";
import { createFusionAuthStorage } from "@fusion/engine";
import { ApiError, badRequest } from "../api-error.js";
import { invalidateAllGlobalSettingsCaches } from "../project-store-resolver.js";
import { getFusionAuthPath } from "../auth-paths.js";
import { readStoredAuthProvidersFromDisk, toProviderAuthEntries } from "./register-settings-sync-helpers.js";
import type { ApiRouteRegistrar } from "./types.js";
@@ -228,9 +228,16 @@ export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => {
throw badRequest("Missing required field: timestamp");
}
// Import AuthStorage and write credentials
const { AuthStorage } = await import("@earendil-works/pi-coding-agent");
const authStorage = AuthStorage.create(getFusionAuthPath());
/*
* FNXC:ProviderAuth 2026-07-07-00:00:
* Dashboard sync/mesh credential writes must go through the coordinated createFusionAuthStorage()
* proxy (reload-before-persist, supplemental-credential sync, logout suppression, Anthropic aliasing)
* instead of a raw AuthStorage.create(getFusionAuthPath()) instance, so concurrent Fusion processes
* sharing ~/.fusion/agent/auth.json do not clobber each other's saved provider keys. FN-7647,
* follow-up to FN-7646's engine-side hardening. Uses a static top-level import (not dynamic
* import) per FN-3049's bundler-safety rule against dynamic engine imports (`await` + `import(...)` of the engine package).
*/
const authStorage = createFusionAuthStorage();
const applyResult = central.applyAuthMaterialSnapshot(authMaterial);
const receivedProviders: string[] = [];

View File

@@ -1,8 +1,8 @@
import type { ProjectSettings } from "@fusion/core";
import { isMovedSettingsKey } from "@fusion/core";
import { createFusionAuthStorage } from "@fusion/engine";
import { basename } from "node:path";
import { ApiError, badRequest, notFound } from "../api-error.js";
import { getFusionAuthPath } from "../auth-paths.js";
import { invalidateAllGlobalSettingsCaches } from "../project-store-resolver.js";
import {
classifySyncStatusDenialReason,
@@ -501,9 +501,16 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
const localPeerInfo = await central.getLocalPeerInfo();
const timestamp = new Date().toISOString();
// Import AuthStorage
const { AuthStorage } = await import("@earendil-works/pi-coding-agent");
const authStorage = AuthStorage.create(getFusionAuthPath());
/*
* FNXC:ProviderAuth 2026-07-07-00:00:
* Dashboard sync/mesh credential writes must go through the coordinated createFusionAuthStorage()
* proxy (reload-before-persist, supplemental-credential sync, logout suppression, Anthropic aliasing)
* instead of a raw AuthStorage.create(getFusionAuthPath()) instance, so concurrent Fusion processes
* sharing ~/.fusion/agent/auth.json do not clobber each other's saved provider keys. FN-7647,
* follow-up to FN-7646's engine-side hardening. Uses a static top-level import (not dynamic
* import) per FN-3049's bundler-safety rule against dynamic engine imports (`await` + `import(...)` of the engine package).
*/
const authStorage = createFusionAuthStorage();
if (direction === "push") {
const allProviders = await readStoredAuthProvidersFromDisk();