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:
7
.changeset/fn-7747-derive-authstorage-from-engine.md
Normal file
7
.changeset/fn-7747-derive-authstorage-from-engine.md
Normal 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.
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user