FN-7747: derive dashboard authStorage fallback from engine.getAuthStorage()

Fixes desktop provider API keys not persisting when a host wires an engine into createServer() but forgets to pass its own authStorage, which previously caused register-auth-routes.ts to throw "Authentication is not configured".

- Add ProjectEngine.getAuthStorage() exposing the OAuth subsystem's raw createFusionAuthStorage() instance
- In createServer(), derive options.authStorage from engine.getAuthStorage() when not explicitly provided (mirrors existing engine-derivation pattern for onMerge/automationStore/etc.); explicit authStorage still overrides
- Add regression tests covering the fallback-derivation and explicit-override behavior
- Add changeset (patch) documenting the fix for @runfusion/fusion

Files changed:
 .changeset/fn-7747-derive-authstorage-from-engine.md |   7 ++
 packages/dashboard/src/__tests__/server.test.ts      | 119 +++++++++++++++++++++
 packages/dashboard/src/server.ts                     |  28 ++++-
 packages/engine/src/project-engine.ts                |  25 +++++
 4 files changed, 178 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-7747

Fusion-Task-Lineage: f8e72b15-d084-4e8d-89db-47453d57b41b

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-09 13:43:00 -07:00
parent 2ff8e2e13e
commit 1fa4a69dde
4 changed files with 178 additions and 1 deletions

View File

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

View File

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

View File

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

View File

@@ -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<typeof createFusionAuthStorage>;
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<typeof createFusionAuthStorage> | undefined {
return this.authStorage;
}
/**
* Get the automation subsystem health for diagnostics and status reporting.
*/