diff --git a/.changeset/fn-7747-derive-authstorage-from-engine.md b/.changeset/fn-7747-derive-authstorage-from-engine.md new file mode 100644 index 0000000000..739b84d5c9 --- /dev/null +++ b/.changeset/fn-7747-derive-authstorage-from-engine.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Harden the dashboard server so provider API keys keep persisting even if a host forgets to wire auth storage. +category: fix +dev: createServer() now derives a fallback authStorage from engine.getAuthStorage() (new ProjectEngine getter exposing its createFusionAuthStorage() instance) when options.authStorage is absent, mirroring the existing engine-derivation of onMerge/automationStore/etc. Explicit authStorage still overrides. Prevents regression of the desktop "keys don't persist / Authentication is not configured" gap (#1948); the desktop path's wrapped authStorage (FN-7622) is unchanged. diff --git a/packages/dashboard/src/__tests__/server.test.ts b/packages/dashboard/src/__tests__/server.test.ts index 0c73680a8e..8fe8973763 100644 --- a/packages/dashboard/src/__tests__/server.test.ts +++ b/packages/dashboard/src/__tests__/server.test.ts @@ -276,6 +276,125 @@ describe("createServer options", () => { expect(engineStore.listTasks).toHaveBeenCalledWith({ slim: true }); expect(store.listTasks).not.toHaveBeenCalled(); }); + + /* + FNXC:ProviderAuth 2026-07-09-00:00: + FN-7747 / #1948 regression coverage: createServer() must derive a fallback `authStorage` + from `engine.getAuthStorage()` when a host wires an `engine` but does not pass its own + `authStorage`, so auth routes resolve through the derived storage instead of + register-auth-routes.ts's "Authentication is not configured" throw. Explicit + `options.authStorage` must still win over the engine-derived value. + */ + it("derives authStorage from engine.getAuthStorage() when not explicitly provided", async () => { + const mockAuthStorage = { + reload: vi.fn(), + getOAuthProviders: vi.fn().mockReturnValue([]), + hasAuth: vi.fn().mockReturnValue(false), + login: vi.fn(), + logout: vi.fn(), + getApiKeyProviders: vi.fn().mockReturnValue([{ id: "openai", name: "OpenAI" }]), + setApiKey: vi.fn(), + clearApiKey: vi.fn(), + hasApiKey: vi.fn().mockReturnValue(false), + getApiKey: vi.fn(), + get: vi.fn(), + }; + const engineBase = { + onMerge: vi.fn(), + getAutomationStore: vi.fn().mockReturnValue(undefined), + getRuntime: vi.fn().mockReturnValue({ + getMissionAutopilot: vi.fn().mockReturnValue(undefined), + getMissionExecutionLoop: vi.fn().mockReturnValue(undefined), + getMessageStore: vi.fn().mockReturnValue(undefined), + }), + getAuthStorage: vi.fn().mockReturnValue(mockAuthStorage), + getHeartbeatMonitor: vi.fn().mockReturnValue(undefined), + getSelfHealingManager: vi.fn().mockReturnValue(undefined), + getRoutineStore: vi.fn().mockReturnValue(undefined), + getRoutineRunner: vi.fn().mockReturnValue(undefined), + getWorkingDirectory: vi.fn().mockReturnValue("/fake/root"), + getMessageStore: vi.fn().mockReturnValue(undefined), + }; + // Proxy: any other engine method createServer happens to probe defensively + // (e.g. optional subsystem getters not exercised by this scenario) resolves + // to a no-op returning undefined, so this test only asserts the authStorage + // derivation behavior under test rather than enumerating every engine getter. + const engine = new Proxy(engineBase, { + get(target, prop, receiver) { + if (prop in target) return Reflect.get(target, prop, receiver); + return vi.fn(); + }, + }); + + const store = createMockStore(); + const app = createServer(store, { engine: engine as unknown as import("@fusion/engine").ProjectEngine }); + + expect(engineBase.getAuthStorage).toHaveBeenCalled(); + + const res = await REQUEST(app, "POST", "/api/auth/api-key", JSON.stringify({ + provider: "openai", + apiKey: "sk-test-not-a-real-secret", + }), { "Content-Type": "application/json" }); + + // Must NOT be the register-auth-routes.ts "Authentication is not configured" failure. + expect(res.status).toBe(200); + expect(res.body).toEqual(expect.objectContaining({ success: true })); + expect(mockAuthStorage.setApiKey).toHaveBeenCalledWith("openai", "sk-test-not-a-real-secret"); + }); + + it("prefers an explicit authStorage over the engine-derived one", async () => { + const explicitAuthStorage = { + reload: vi.fn(), + getOAuthProviders: vi.fn().mockReturnValue([]), + hasAuth: vi.fn().mockReturnValue(false), + login: vi.fn(), + logout: vi.fn(), + getApiKeyProviders: vi.fn().mockReturnValue([{ id: "openai", name: "OpenAI" }]), + setApiKey: vi.fn(), + clearApiKey: vi.fn(), + hasApiKey: vi.fn().mockReturnValue(false), + getApiKey: vi.fn(), + get: vi.fn(), + }; + const engineAuthStorage = { ...explicitAuthStorage, setApiKey: vi.fn() }; + const engineBase = { + onMerge: vi.fn(), + getAutomationStore: vi.fn().mockReturnValue(undefined), + getRuntime: vi.fn().mockReturnValue({ + getMissionAutopilot: vi.fn().mockReturnValue(undefined), + getMissionExecutionLoop: vi.fn().mockReturnValue(undefined), + getMessageStore: vi.fn().mockReturnValue(undefined), + }), + getAuthStorage: vi.fn().mockReturnValue(engineAuthStorage), + getHeartbeatMonitor: vi.fn().mockReturnValue(undefined), + getSelfHealingManager: vi.fn().mockReturnValue(undefined), + getRoutineStore: vi.fn().mockReturnValue(undefined), + getRoutineRunner: vi.fn().mockReturnValue(undefined), + getWorkingDirectory: vi.fn().mockReturnValue("/fake/root"), + getMessageStore: vi.fn().mockReturnValue(undefined), + }; + const engine = new Proxy(engineBase, { + get(target, prop, receiver) { + if (prop in target) return Reflect.get(target, prop, receiver); + return vi.fn(); + }, + }); + + const store = createMockStore(); + const app = createServer(store, { + engine: engine as unknown as import("@fusion/engine").ProjectEngine, + authStorage: explicitAuthStorage as any, + }); + + const res = await REQUEST(app, "POST", "/api/auth/api-key", JSON.stringify({ + provider: "openai", + apiKey: "sk-test-not-a-real-secret", + }), { "Content-Type": "application/json" }); + + expect(res.status).toBe(200); + expect(explicitAuthStorage.setApiKey).toHaveBeenCalledWith("openai", "sk-test-not-a-real-secret"); + expect(engineAuthStorage.setApiKey).not.toHaveBeenCalled(); + }); }); describe("createServer AI session startup cleanup diagnostics", () => { diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts index 3ed2acf858..8309be8942 100644 --- a/packages/dashboard/src/server.ts +++ b/packages/dashboard/src/server.ts @@ -252,7 +252,20 @@ export interface ServerOptions { maxConcurrent?: number; /** Optional GitHub token for PR operations — falls back to GITHUB_TOKEN env var */ githubToken?: string; - /** Optional AuthStorage instance for auth routes — if not provided, one is created internally */ + /** + * Optional AuthStorage instance for auth routes. If not provided explicitly and an `engine` + * is provided, one is derived from `engine.getAuthStorage()` (see the engine-derivation + * block below); explicit `authStorage` always overrides the engine-derived value. + * + * FNXC:ProviderAuth 2026-07-09-00:00: + * FN-7747 / #1948: the engine-derived instance is the RAW createFusionAuthStorage() (no + * API-key/custom-provider wrapping), so it restores credential *persistence* but not the + * full provider catalog — hosts needing the full catalog (e.g. the desktop app's + * seedDashboardProviders() output) must still pass their own wrapped `authStorage` here, + * exactly as packages/desktop already does. This fallback exists so that a host which + * wires an `engine` but forgets `authStorage` does not silently regress into + * register-auth-routes.ts's "Authentication is not configured" throw. + */ authStorage?: AuthStorageLike; /** Optional ModelRegistry instance for the models API — if not provided, the endpoint returns an empty list */ modelRegistry?: ModelRegistryLike; @@ -778,6 +791,19 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT if (!options!.automationStore) { options = { ...options, automationStore: engine.getAutomationStore() }; } + /* + FNXC:ProviderAuth 2026-07-09-00:00: + FN-7747 / #1948: derive a fallback authStorage from the engine (mirroring the other + subsystem derivations here) so a host that wires an `engine` but forgets to pass its own + `authStorage` still gets a working, persisting credential store instead of + register-auth-routes.ts's "Authentication is not configured" throw. Explicit + options.authStorage always overrides. Optional chaining tolerates engine test doubles + without getAuthStorage(). + */ + if (!options!.authStorage) { + const as = engine.getAuthStorage?.(); + if (as) options = { ...options, authStorage: as }; + } if (!options!.missionAutopilot) { const ma = engine.getRuntime().getMissionAutopilot(); if (ma) options = { ...options, missionAutopilot: ma }; diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index ecab1a4007..b4c26daf89 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -398,6 +398,14 @@ export class ProjectEngine { private oauthExpiryMonitor?: OAuthExpiryMonitor; private oauthRefreshScheduler?: OAuthRefreshScheduler; private oauthValidityLogger?: OAuthValidityLogger; + /* + FNXC:ProviderAuth 2026-07-09-00:00: + FN-7747: hold the OAuth subsystem's raw createFusionAuthStorage() instance so createServer + can derive a persistence fallback (see getAuthStorage() below) instead of silently regressing + the "desktop provider API keys don't persist" bug (#1948) if a host wires this engine but + forgets to pass its own authStorage. + */ + private authStorage?: ReturnType; private gridlockDetector?: GridlockDetector; private cronRunner?: CronRunner; private automationStore?: AutomationStoreType; @@ -713,6 +721,7 @@ export class ProjectEngine { }); await this.notificationService.start(); const authStorage = createFusionAuthStorage(); + this.authStorage = authStorage; const oauthAlertState = new OAuthAlertStateStore({ statePath: getFusionOAuthAlertStatePath(), }); @@ -1530,6 +1539,22 @@ export class ProjectEngine { return this.automationStore; } + /** + * Get the engine's raw createFusionAuthStorage() instance (if the OAuth subsystem has + * started; undefined when skipNotifier suppressed it). + * + * FNXC:ProviderAuth 2026-07-09-00:00: + * FN-7747 / #1948: createServer() derives a fallback `authStorage` from this getter when a + * host wires an engine but forgets to pass its own `authStorage`, so credential persistence + * degrades gracefully instead of throwing "Authentication is not configured". This is the + * RAW storage (no API-key/custom-provider wrapping) — hosts needing the full wrapped + * provider catalog (e.g. desktop's seedDashboardProviders() output) must still pass their + * own wrapped authStorage explicitly, exactly as packages/desktop already does. + */ + getAuthStorage(): ReturnType | undefined { + return this.authStorage; + } + /** * Get the automation subsystem health for diagnostics and status reporting. */