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(engineStore.listTasks).toHaveBeenCalledWith({ slim: true });
|
||||||
expect(store.listTasks).not.toHaveBeenCalled();
|
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", () => {
|
describe("createServer AI session startup cleanup diagnostics", () => {
|
||||||
|
|||||||
@@ -252,7 +252,20 @@ export interface ServerOptions {
|
|||||||
maxConcurrent?: number;
|
maxConcurrent?: number;
|
||||||
/** Optional GitHub token for PR operations — falls back to GITHUB_TOKEN env var */
|
/** Optional GitHub token for PR operations — falls back to GITHUB_TOKEN env var */
|
||||||
githubToken?: string;
|
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;
|
authStorage?: AuthStorageLike;
|
||||||
/** Optional ModelRegistry instance for the models API — if not provided, the endpoint returns an empty list */
|
/** Optional ModelRegistry instance for the models API — if not provided, the endpoint returns an empty list */
|
||||||
modelRegistry?: ModelRegistryLike;
|
modelRegistry?: ModelRegistryLike;
|
||||||
@@ -778,6 +791,19 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
|||||||
if (!options!.automationStore) {
|
if (!options!.automationStore) {
|
||||||
options = { ...options, automationStore: engine.getAutomationStore() };
|
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) {
|
if (!options!.missionAutopilot) {
|
||||||
const ma = engine.getRuntime().getMissionAutopilot();
|
const ma = engine.getRuntime().getMissionAutopilot();
|
||||||
if (ma) options = { ...options, missionAutopilot: ma };
|
if (ma) options = { ...options, missionAutopilot: ma };
|
||||||
|
|||||||
@@ -398,6 +398,14 @@ export class ProjectEngine {
|
|||||||
private oauthExpiryMonitor?: OAuthExpiryMonitor;
|
private oauthExpiryMonitor?: OAuthExpiryMonitor;
|
||||||
private oauthRefreshScheduler?: OAuthRefreshScheduler;
|
private oauthRefreshScheduler?: OAuthRefreshScheduler;
|
||||||
private oauthValidityLogger?: OAuthValidityLogger;
|
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 gridlockDetector?: GridlockDetector;
|
||||||
private cronRunner?: CronRunner;
|
private cronRunner?: CronRunner;
|
||||||
private automationStore?: AutomationStoreType;
|
private automationStore?: AutomationStoreType;
|
||||||
@@ -713,6 +721,7 @@ export class ProjectEngine {
|
|||||||
});
|
});
|
||||||
await this.notificationService.start();
|
await this.notificationService.start();
|
||||||
const authStorage = createFusionAuthStorage();
|
const authStorage = createFusionAuthStorage();
|
||||||
|
this.authStorage = authStorage;
|
||||||
const oauthAlertState = new OAuthAlertStateStore({
|
const oauthAlertState = new OAuthAlertStateStore({
|
||||||
statePath: getFusionOAuthAlertStatePath(),
|
statePath: getFusionOAuthAlertStatePath(),
|
||||||
});
|
});
|
||||||
@@ -1530,6 +1539,22 @@ export class ProjectEngine {
|
|||||||
return this.automationStore;
|
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.
|
* Get the automation subsystem health for diagnostics and status reporting.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user