FN-8205: fix shared auth credential lock retries

Serialize shared auth credential writes and await durable persistence under contention.

- Replace synchronous auth-file locking with queued asynchronous retries.
- Propagate asynchronous credential mutations through CLI, dashboard, and provider wrappers.
- Cover held-lock persistence, queue recovery, and awaited caller behavior.

Files changed:
 .changeset/fn-8205-auth-lock-retry.md              |   7 ++
 .../cli/src/commands/__tests__/onboard.test.ts     |  17 +++
 .../src/commands/__tests__/provider-auth.test.ts   |  78 ++++++------
 packages/cli/src/commands/onboard.ts               |   2 +-
 packages/dashboard/src/routes.ts                   |   6 +-
 .../dashboard/src/routes/register-auth-routes.ts   |  10 +-
 .../dashboard/src/routes/register-mesh-routes.ts   |   4 +-
 .../register-settings-sync-inbound-routes.ts       |   4 +-
 .../src/routes/register-settings-sync-routes.ts    |   4 +-
 .../src/__tests__/auth-storage-concurrency.test.ts |  84 ++++++++++---
 packages/engine/src/__tests__/auth-storage.test.ts |  48 ++++----
 packages/engine/src/auth-storage.ts                | 137 +++++++++++++--------
 packages/engine/src/provider-auth.ts               |  56 ++++-----
 13 files changed, 284 insertions(+), 173 deletions(-)

Fusion-Task-Id: FN-8205

Fusion-Task-Lineage: b932b9ce-9aee-4f0a-9155-63b026c6bde4

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-17 09:47:40 -07:00
parent 4293826b5a
commit 7fc4b439ee
13 changed files with 284 additions and 173 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Prevent transient credential-file lock contention from terminating provider runs.
category: fix
dev: Uses queued async auth writes with a shared proper-lockfile retry budget.

View File

@@ -154,6 +154,23 @@ describe("onboard", () => {
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Central DB already exists"));
});
it("waits for API-key persistence before reporting onboarding success", async () => {
const providerAuth = makeProviderAuth();
let persisted = false;
providerAuth.setApiKey.mockImplementation(async () => {
await Promise.resolve();
persisted = true;
});
mockProviderAuthFactory.mockReturnValue(providerAuth);
const logSpy = vi.spyOn(console, "log").mockImplementation((message: string) => {
if (message.includes("Stored API key")) expect(persisted).toBe(true);
});
await runOnboard({ input: inputFrom(["y", "y", "1", "test-key", "y", "y", "n", "y"]) });
expect(providerAuth.setApiKey).toHaveBeenCalledWith("openrouter", "test-key");
expect(logSpy).toHaveBeenCalledWith("✓ Stored API key for openrouter");
});
it("stores API key, runs init, persists global testMode and completion marker", async () => {
const providerAuth = makeProviderAuth();
mockProviderAuthFactory.mockReturnValue(providerAuth);

View File

@@ -26,7 +26,7 @@ function makeAuthStorage(credentials: Record<string, { type: string; key?: strin
} as any;
}
describe("wrapAuthStorageWithApiKeyProviders", () => {
describe("wrapAuthStorageWithApiKeyProviders", async () => {
it("reads API keys from Fusion auth first and legacy auth fallbacks second", async () => {
const fusionAuth = makeAuthStorage({
openrouter: { type: "api_key", key: "fusion-key" },
@@ -45,7 +45,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(wrapped.get("minimax")).toEqual({ type: "api_key", key: "legacy-minimax-key" });
});
it("writes API keys only to Fusion auth storage", () => {
it("writes API keys only to Fusion auth storage", async () => {
const fusionAuth = makeAuthStorage();
const legacyAuth = makeAuthStorage({
openrouter: { type: "api_key", key: "legacy-key" },
@@ -53,13 +53,13 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry, [legacyAuth]);
wrapped.setApiKey("openrouter", "fusion-key");
await wrapped.setApiKey("openrouter", "fusion-key");
expect(fusionAuth.set).toHaveBeenCalledWith("openrouter", { type: "api_key", key: "fusion-key" });
expect(legacyAuth.set).not.toHaveBeenCalled();
});
it("reloads all read stores so status reflects both locations", () => {
it("reloads all read stores so status reflects both locations", async () => {
const fusionAuth = makeAuthStorage();
const legacyAuth = makeAuthStorage();
const modelRegistry = { getAll: vi.fn(() => []) } as any;
@@ -87,7 +87,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(merged.list()).toEqual(expect.arrayContaining(["openrouter", "minimax"]));
});
it("excludes pi-claude-cli models from API key providers", () => {
it("excludes pi-claude-cli models from API key providers", async () => {
const fusionAuth = makeAuthStorage();
const modelRegistry = {
getAll: vi.fn(() => [
@@ -103,7 +103,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(providerIds).not.toContain("pi-claude-cli");
});
it("includes research-only API-key providers", () => {
it("includes research-only API-key providers", async () => {
const fusionAuth = makeAuthStorage();
const modelRegistry = { getAll: vi.fn(() => []) } as any;
@@ -114,7 +114,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(providerIds).toContain("tavily");
});
it("always includes opencode-go when registry has no opencode models", () => {
it("always includes opencode-go when registry has no opencode models", async () => {
const fusionAuth = makeAuthStorage();
const modelRegistry = { getAll: vi.fn(() => []) } as any;
@@ -124,7 +124,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(providerIds).toContain("opencode-go");
});
it("keeps explicit API-key aliases when OAuth provider ids collide", () => {
it("keeps explicit API-key aliases when OAuth provider ids collide", async () => {
const fusionAuth = makeAuthStorage();
fusionAuth.getOAuthProviders = vi.fn(() => [
{ id: "anthropic", name: "Anthropic OAuth" },
@@ -182,8 +182,8 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(await storage.getApiKey("anthropic")).toBeUndefined();
});
describe("Anthropic provider classification", () => {
it("exposes Anthropic subscription OAuth under anthropic and API-key auth under a separate alias", () => {
describe("Anthropic provider classification", async () => {
it("exposes Anthropic subscription OAuth under anthropic and API-key auth under a separate alias", async () => {
const fusionAuth = makeAuthStorage();
fusionAuth.getOAuthProviders = vi.fn(() => [
{ id: "anthropic", name: "Anthropic" },
@@ -203,7 +203,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(apiKeyProviders).toContainEqual({ id: "anthropic-api-key", name: "Anthropic API Key" });
});
it("keeps OpenAI API-key provider id unchanged when OAuth uses openai-codex", () => {
it("keeps OpenAI API-key provider id unchanged when OAuth uses openai-codex", async () => {
const fusionAuth = makeAuthStorage();
fusionAuth.getOAuthProviders = vi.fn(() => [
{ id: "openai-codex", name: "OpenAI Codex" },
@@ -219,7 +219,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(apiKeyProviders.some((p) => p.id === "openai-codex")).toBe(false);
});
it("keeps only explicit built-ins when a model-registry-derived provider is also OAuth-backed", () => {
it("keeps only explicit built-ins when a model-registry-derived provider is also OAuth-backed", async () => {
const fusionAuth = makeAuthStorage();
fusionAuth.getOAuthProviders = vi.fn(() => [
{ id: "openai", name: "OpenAI OAuth" },
@@ -265,7 +265,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(await wrapped.getApiKey("anthropic")).toBe("sk-ant-api03-existing");
});
it("logs out Anthropic subscription alias without clearing the raw API key", () => {
it("logs out Anthropic subscription alias without clearing the raw API key", async () => {
const fusionAuth = makeAuthStorage({
anthropic: { type: "api_key", key: "sk-ant-api03-existing" },
"anthropic-subscription": {
@@ -281,14 +281,14 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry);
wrapped.logout("anthropic-subscription");
await wrapped.logout("anthropic-subscription");
expect(fusionAuth.logout).toHaveBeenCalledWith("anthropic-subscription");
expect(fusionAuth.logout).not.toHaveBeenCalledWith("anthropic");
expect(wrapped.get("anthropic-api-key")).toEqual({ type: "api_key", key: "sk-ant-api03-existing" });
});
it("logs out legacy Anthropic OAuth stored under the raw anthropic id", () => {
it("logs out legacy Anthropic OAuth stored under the raw anthropic id", async () => {
const fusionAuth = makeAuthStorage({
anthropic: {
type: "oauth",
@@ -305,7 +305,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry);
expect(wrapped.get("anthropic")?.type).toBe("oauth");
wrapped.logout("anthropic");
await wrapped.logout("anthropic");
wrapped.reload();
expect(fusionAuth.get).toHaveBeenCalledWith("anthropic");
@@ -324,7 +324,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry);
wrapped.setApiKey("anthropic-api-key", "sk-ant-api03-test-key");
await wrapped.setApiKey("anthropic-api-key", "sk-ant-api03-test-key");
expect(fusionAuth.set).toHaveBeenCalledWith("anthropic", {
type: "api_key",
@@ -336,7 +336,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(await wrapped.getApiKey("anthropic")).toBe("sk-ant-api03-test-key");
expect(wrapped.get("anthropic-api-key")).toEqual({ type: "api_key", key: "sk-ant-api03-test-key" });
wrapped.clearApiKey("anthropic-api-key");
await wrapped.clearApiKey("anthropic-api-key");
expect(fusionAuth.remove).toHaveBeenCalledWith("anthropic");
expect(wrapped.hasApiKey("anthropic-api-key")).toBe(false);
@@ -359,7 +359,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry);
wrapped.setApiKey("anthropic-api-key", "sk-ant-api03-new-key");
await wrapped.setApiKey("anthropic-api-key", "sk-ant-api03-new-key");
const legacyReadCallIndex = fusionAuth.get.mock.calls.findIndex(([provider]) => provider === "anthropic");
const apiKeyWriteCallIndex = fusionAuth.set.mock.calls.findIndex(([provider]) => provider === "anthropic");
@@ -393,7 +393,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry);
wrapped.clearApiKey("anthropic-api-key");
await wrapped.clearApiKey("anthropic-api-key");
expect(fusionAuth.set).toHaveBeenCalledWith("anthropic-subscription", expect.objectContaining({ type: "oauth" }));
expect(fusionAuth.remove).toHaveBeenCalledWith("anthropic");
@@ -453,8 +453,8 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
});
});
describe("logout with fallback credentials", () => {
it("hides fallback credentials after logout", () => {
describe("logout with fallback credentials", async () => {
it("hides fallback credentials after logout", async () => {
const fusionAuth = makeAuthStorage();
const fallbackAuth = makeAuthStorage({
anthropic: { type: "api_key", key: "claude-access-token" },
@@ -468,7 +468,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(merged.get("anthropic")).toEqual({ type: "api_key", key: "claude-access-token" });
// Log out
merged.logout("anthropic");
await merged.logout("anthropic");
// After logout, fallback credentials are hidden
expect(merged.has("anthropic")).toBe(false);
@@ -476,14 +476,14 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(merged.get("anthropic")).toBeUndefined();
});
it("does not resurrect fallback credentials on reload after logout", () => {
it("does not resurrect fallback credentials on reload after logout", async () => {
const fusionAuth = makeAuthStorage();
const fallbackAuth = makeAuthStorage({
anthropic: { type: "api_key", key: "claude-access-token" },
});
const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]);
merged.logout("anthropic");
await merged.logout("anthropic");
// reload() should NOT bring back the fallback credential
merged.reload();
@@ -492,7 +492,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(merged.hasAuth("anthropic")).toBe(false);
});
it("excludes logged-out providers from getAll()", () => {
it("excludes logged-out providers from getAll()", async () => {
const fusionAuth = makeAuthStorage();
const fallbackAuth = makeAuthStorage({
anthropic: { type: "api_key", key: "claude-access-token" },
@@ -500,14 +500,14 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
});
const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]);
merged.logout("anthropic");
await merged.logout("anthropic");
const all = merged.getAll();
expect("anthropic" in all).toBe(false);
expect("openrouter" in all).toBe(true);
});
it("excludes logged-out providers from list()", () => {
it("excludes logged-out providers from list()", async () => {
const fusionAuth = makeAuthStorage();
const fallbackAuth = makeAuthStorage({
anthropic: { type: "api_key", key: "claude-access-token" },
@@ -515,7 +515,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
});
const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]);
merged.logout("anthropic");
await merged.logout("anthropic");
expect(merged.list()).not.toContain("anthropic");
expect(merged.list()).toContain("openrouter");
@@ -531,28 +531,28 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(await merged.getApiKey("anthropic")).toBe("claude-access-token");
merged.logout("anthropic");
await merged.logout("anthropic");
expect(await merged.getApiKey("anthropic")).toBeUndefined();
});
it("re-enables fallback credentials after re-authentication via set()", () => {
it("re-enables fallback credentials after re-authentication via set()", async () => {
const fusionAuth = makeAuthStorage();
const fallbackAuth = makeAuthStorage({
anthropic: { type: "api_key", key: "claude-access-token" },
});
const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]);
merged.logout("anthropic");
await merged.logout("anthropic");
// Re-authenticate
merged.set("anthropic", { type: "api_key", key: "new-key" });
await merged.set("anthropic", { type: "api_key", key: "new-key" });
// Provider is visible again (from primary storage)
expect(merged.has("anthropic")).toBe(true);
});
it("only hides the logged-out provider, not other fallback providers", () => {
it("only hides the logged-out provider, not other fallback providers", async () => {
const fusionAuth = makeAuthStorage();
const fallbackAuth = makeAuthStorage({
anthropic: { type: "api_key", key: "claude-access-token" },
@@ -560,7 +560,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
});
const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]);
merged.logout("anthropic");
await merged.logout("anthropic");
// anthropic is hidden
expect(merged.hasAuth("anthropic")).toBe(false);
@@ -568,7 +568,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(merged.hasAuth("openrouter")).toBe(true);
});
it("returns false for hasAuth even when underlying storage reports auth via env var", () => {
it("returns false for hasAuth even when underlying storage reports auth via env var", async () => {
// Simulate the real AuthStorage which checks env vars in hasAuth
const fusionAuth = makeAuthStorage();
fusionAuth.hasAuth = vi.fn(() => true); // env var would make this true
@@ -577,7 +577,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
});
const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]);
merged.logout("anthropic");
await merged.logout("anthropic");
// Even though the underlying storage reports hasAuth=true (env var),
// the logged-out provider must still return false
@@ -586,7 +586,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
});
});
describe("anthropic-subscription getApiKey delegation (FN-7576)", () => {
describe("anthropic-subscription getApiKey delegation (FN-7576)", async () => {
/*
FNXC:ProviderAuth 2026-07-05-09:15:
These tests drive `wrapAuthStorageWithApiKeyProviders`/`mergeAuthStorageReads` directly against an instrumented fake engine `authStorage.getApiKey`, not a mocked dashboard/engine `AuthStorageLike`, per FN-7576's "fix the invariant, not the repro" requirement. They assert the wrapper actually DELEGATES the anthropic-subscription read to the real engine authStorage (so the refresh HTTP round trip in packages/engine/src/auth-storage.ts executes) rather than short-circuiting to a local static `Date.now() >= credential.expires` check.
@@ -670,7 +670,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry);
wrapped.logout("anthropic-subscription");
await wrapped.logout("anthropic-subscription");
fusionAuth.getApiKey.mockClear();
const apiKey = await wrapped.getApiKey("anthropic-subscription");

View File

@@ -207,7 +207,7 @@ export async function runOnboard(options: OnboardOptions = {}): Promise<void> {
}
const apiKey = await prompts.prompt("Enter API key");
providerAuth.setApiKey(selectedProvider, apiKey);
await providerAuth.setApiKey(selectedProvider, apiKey);
console.log(`✓ Stored API key for ${selectedProvider}`);
});

View File

@@ -249,13 +249,13 @@ export interface AuthStorageLike {
signal?: AbortSignal;
},
): Promise<void>;
logout(provider: string): void;
logout(provider: string): Promise<void>;
/** Get providers that accept API keys (non-OAuth). Returns provider id and name. */
getApiKeyProviders?(): Array<{ id: string; name: string }>;
/** Save an API key for a provider. Creates or overwrites the existing key. */
setApiKey?(providerId: string, apiKey: string): void;
setApiKey?(providerId: string, apiKey: string): Promise<void>;
/** Remove the stored API key for a provider. No-op if not set. */
clearApiKey?(providerId: string): void;
clearApiKey?(providerId: string): Promise<void>;
/** Check if a provider has an API key configured. */
hasApiKey?(providerId: string): boolean;
/** Get the configured API key for usage providers. */

View File

@@ -1739,7 +1739,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
* Body: { provider: string }
* Response: { success: true }
*/
router.post("/auth/logout", (req, res) => {
router.post("/auth/logout", async (req, res) => {
try {
const { provider } = req.body;
if (!provider || typeof provider !== "string") {
@@ -1747,7 +1747,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
}
const storage = getAuthStorage();
storage.logout(toOauthCredentialProviderId(provider));
await storage.logout(toOauthCredentialProviderId(provider));
clearUsageCache();
res.json({ success: true });
} catch (err: unknown) {
@@ -1791,7 +1791,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
throw badRequest(`Unknown API key provider: ${provider}`);
}
storage.setApiKey(provider, apiKey.trim());
await storage.setApiKey(provider, apiKey.trim());
let modelsRefreshed: number | undefined;
let refreshReason: "no-models-from-cli" | "cli-failed" | "disabled-by-settings" | undefined;
@@ -1829,7 +1829,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
* Body: { provider: string }
* Response: { success: true }
*/
router.delete("/auth/api-key", (req, res) => {
router.delete("/auth/api-key", async (req, res) => {
try {
const { provider } = req.body;
if (!provider || typeof provider !== "string") {
@@ -1851,7 +1851,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
throw badRequest(`Unknown API key provider: ${provider}`);
}
storage.clearApiKey(provider);
await storage.clearApiKey(provider);
// No model refresh needed on delete: removing the key leaves nothing to sync.
clearUsageCache();
res.json({ success: true });

View File

@@ -381,11 +381,11 @@ export const registerMeshRoutes: ApiRouteRegistrar = (ctx) => {
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 });
await authStorage.set(providerId, { type: "api_key", key: credential.key });
continue;
}
if (credential.type === "oauth" && credential.accessToken && credential.refreshToken && typeof credential.expires === "number") {
authStorage.set(providerId, {
await authStorage.set(providerId, {
type: "oauth",
access: credential.accessToken,
refresh: credential.refreshToken,

View File

@@ -256,12 +256,12 @@ export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => {
const receivedProviders: string[] = [];
for (const [providerId, credential] of Object.entries(applyResult.providerAuth)) {
if (credential.type === "api_key" && credential.key) {
authStorage.set(providerId, { type: "api_key", key: credential.key });
await authStorage.set(providerId, { type: "api_key", key: credential.key });
receivedProviders.push(providerId);
continue;
}
if (credential.type === "oauth" && credential.accessToken && credential.refreshToken && typeof credential.expires === "number") {
authStorage.set(providerId, {
await authStorage.set(providerId, {
type: "oauth",
access: credential.accessToken,
refresh: credential.refreshToken,

View File

@@ -581,12 +581,12 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
const syncedProviders: string[] = [];
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 });
await authStorage.set(providerId, { type: "api_key", key: credential.key });
syncedProviders.push(providerId);
continue;
}
if (credential.type === "oauth" && credential.accessToken && credential.refreshToken && typeof credential.expires === "number") {
authStorage.set(providerId, {
await authStorage.set(providerId, {
type: "oauth",
access: credential.accessToken,
refresh: credential.refreshToken,

View File

@@ -3,7 +3,8 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createFusionAuthStorage, getFusionAuthPath } from "../auth-storage.js";
import lockfile from "proper-lockfile";
import { createFusionAuthStorage, createFusionCredentialStore, getFusionAuthPath } from "../auth-storage.js";
/*
FNXC:ProviderAuth 2026-07-07-00:00:
@@ -48,16 +49,63 @@ describe("createFusionAuthStorage — concurrent cross-process coordination", ()
vi.restoreAllMocks();
});
async function releaseHeldLockAfter(path: string, holdMs = 40): Promise<void> {
const release = await lockfile.lock(path, { realpath: false });
setTimeout(() => { void release(); }, holdMs);
}
it("waits for an independently held lock before set persists the credential", async () => {
const storage = createFusionAuthStorage();
const authPath = getFusionAuthPath(homeDir);
await releaseHeldLockAfter(authPath);
await expect(storage.set("openrouter", { type: "api_key", key: "held-lock-key" })).resolves.toBeUndefined();
const onDisk = readAuthFile(homeDir);
expect((onDisk.openrouter as { type?: string } | undefined)?.type).toBe("api_key");
const freshStorage = createFusionAuthStorage();
expect(freshStorage.has("openrouter")).toBe(true);
});
it("waits for held locks before remove and CredentialStore.delete", async () => {
const storage = createFusionAuthStorage();
const authPath = getFusionAuthPath(homeDir);
await storage.set("openrouter", { type: "api_key", key: "present" });
await storage.set("groq", { type: "api_key", key: "present" });
await releaseHeldLockAfter(authPath);
await expect(storage.remove("openrouter")).resolves.toBeUndefined();
const credentialStore = createFusionCredentialStore(storage);
await releaseHeldLockAfter(authPath);
await expect(credentialStore.delete("groq")).resolves.toBeUndefined();
const onDisk = readAuthFile(homeDir);
expect(onDisk.openrouter).toBeUndefined();
expect(onDisk.groq).toBeUndefined();
});
it("releases the per-path queue after a failed lock acquisition", async () => {
const storage = createFusionAuthStorage();
const lockSpy = vi.spyOn(lockfile, "lock").mockRejectedValueOnce(new Error("injected lock failure"));
await expect(storage.set("openrouter", { type: "api_key", key: "fails" })).rejects.toThrow("injected lock failure");
lockSpy.mockRestore();
await expect(storage.set("openrouter", { type: "api_key", key: "succeeds" })).resolves.toBeUndefined();
expect(storage.has("openrouter")).toBe(true);
});
it("survives an unrelated oauth set from a second instance (missing-file baseline)", async () => {
// Baseline: no auth.json exists yet when both instances are constructed.
const instanceA = createFusionAuthStorage();
instanceA.set("openai", { type: "api_key", key: "web-openai-key" });
instanceA.set("openrouter", { type: "api_key", key: "web-openrouter-key" });
await instanceA.set("openai", { type: "api_key", key: "web-openai-key" });
await instanceA.set("openrouter", { type: "api_key", key: "web-openrouter-key" });
// Instance B (e.g. the desktop app) constructs its own storage AFTER A's writes
// are already on disk, then writes an unrelated provider's OAuth credential.
const instanceB = createFusionAuthStorage();
instanceB.set("anthropic-subscription", {
await instanceB.set("anthropic-subscription", {
type: "oauth",
access: "desktop-access",
refresh: "desktop-refresh",
@@ -76,18 +124,18 @@ describe("createFusionAuthStorage — concurrent cross-process coordination", ()
it("survives instance B writing a NEW provider after loading a stale snapshot", async () => {
const instanceA = createFusionAuthStorage();
instanceA.set("openai", { type: "api_key", key: "web-openai-key" });
await instanceA.set("openai", { type: "api_key", key: "web-openai-key" });
// B constructs (and thus snapshots) BEFORE A's second write below.
const instanceB = createFusionAuthStorage();
// A saves a new provider key while B is alive holding a stale in-memory snapshot —
// this is the exact "web app writing while the desktop process is alive" scenario.
instanceA.set("openrouter", { type: "api_key", key: "web-openrouter-key" });
await instanceA.set("openrouter", { type: "api_key", key: "web-openrouter-key" });
// B performs its own write for a THIRD, different provider. Historically a
// whole-file snapshot flush from B would wipe out A's mid-session write.
instanceB.set("groq", { type: "api_key", key: "desktop-groq-key" });
await instanceB.set("groq", { type: "api_key", key: "desktop-groq-key" });
const onDisk = readAuthFile(homeDir);
expect(onDisk.openai).toEqual({ type: "api_key", key: "web-openai-key" });
@@ -102,15 +150,15 @@ describe("createFusionAuthStorage — concurrent cross-process coordination", ()
it("survives instance B's logout(\"anthropic\") for unrelated providers", async () => {
const instanceA = createFusionAuthStorage();
instanceA.set("openai", { type: "api_key", key: "web-openai-key" });
instanceA.set("openrouter", { type: "api_key", key: "web-openrouter-key" });
await instanceA.set("openai", { type: "api_key", key: "web-openai-key" });
await instanceA.set("openrouter", { type: "api_key", key: "web-openrouter-key" });
const instanceB = createFusionAuthStorage();
instanceA.set("groq", { type: "api_key", key: "web-groq-key" });
await instanceA.set("groq", { type: "api_key", key: "web-groq-key" });
// B logs out of a provider it never touched via A — this exercises the remove()
// proxy trap's persistProviderChange path.
instanceB.logout("anthropic");
await instanceB.logout("anthropic");
const onDisk = readAuthFile(homeDir);
expect(onDisk.openai).toEqual({ type: "api_key", key: "web-openai-key" });
@@ -130,18 +178,18 @@ describe("createFusionAuthStorage — concurrent cross-process coordination", ()
writeFileSync(getFusionAuthPath(homeDir), "{}");
const instanceA = createFusionAuthStorage();
instanceA.set("openai", { type: "api_key", key: "web-openai-key" });
await instanceA.set("openai", { type: "api_key", key: "web-openai-key" });
const instanceB = createFusionAuthStorage();
instanceA.set("mistral", { type: "api_key", key: "web-mistral-key" });
await instanceA.set("mistral", { type: "api_key", key: "web-mistral-key" });
instanceB.set("anthropic-subscription", {
await instanceB.set("anthropic-subscription", {
type: "oauth",
access: "desktop-access",
refresh: "desktop-refresh",
expires: Date.now() + 3_600_000,
});
instanceB.remove("anthropic-subscription");
await instanceB.remove("anthropic-subscription");
const onDisk = readAuthFile(homeDir);
expect(onDisk.openai).toEqual({ type: "api_key", key: "web-openai-key" });
@@ -170,7 +218,7 @@ describe("createFusionAuthStorage — concurrent cross-process coordination", ()
);
const instanceA = createFusionAuthStorage();
instanceA.set("openai", { type: "api_key", key: "web-openai-key" });
await instanceA.set("openai", { type: "api_key", key: "web-openai-key" });
// B constructs after A's write; construction runs syncSupplementalOauthCredentials(),
// which itself calls primary.set() for the hydrated legacy OAuth provider — this is
@@ -189,7 +237,7 @@ describe("createFusionAuthStorage — concurrent cross-process coordination", ()
// A logs in to Anthropic OAuth with a credential that is already due for refresh
// (past the 5-minute proactive-refresh buffer).
const instanceA = createFusionAuthStorage();
instanceA.set("anthropic", {
await instanceA.set("anthropic", {
type: "oauth",
access: "old-access",
refresh: "old-refresh",
@@ -200,7 +248,7 @@ describe("createFusionAuthStorage — concurrent cross-process coordination", ()
const instanceB = createFusionAuthStorage();
// The user re-logs in via A with a fresh, long-lived credential AFTER B snapshotted.
instanceA.set("anthropic", {
await instanceA.set("anthropic", {
type: "oauth",
access: "new-access-from-relogin",
refresh: "new-refresh-from-relogin",

View File

@@ -60,7 +60,7 @@ describe("createFusionAuthStorage", () => {
);
const authStorage = createFusionAuthStorage();
authStorage.set("openrouter", { type: "api_key", key: "fusion-openrouter-key" });
await authStorage.set("openrouter", { type: "api_key", key: "fusion-openrouter-key" });
expect(await authStorage.getApiKey("openrouter")).toBe("fusion-openrouter-key");
expect(await authStorage.getApiKey("minimax")).toBe("legacy-minimax-key");
@@ -200,7 +200,7 @@ describe("createFusionAuthStorage", () => {
expect(await authStorage.getApiKey("anthropic")).toBe("fallback-anthropic-runtime-key");
authStorage.logout("anthropic");
await authStorage.logout("anthropic");
expect(authStorage.hasAuth("anthropic")).toBe(false);
expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
@@ -327,11 +327,11 @@ describe("createFusionAuthStorage", () => {
// reporting "Login did not complete" despite a valid stored credential.
const authStorage = createFusionAuthStorage();
authStorage.logout("anthropic-subscription");
await authStorage.logout("anthropic-subscription");
expect(authStorage.hasAuth("anthropic-subscription")).toBe(false);
// `set` under the legacy id mirrors what interactive login persists.
authStorage.set("anthropic", {
await authStorage.set("anthropic", {
type: "oauth",
access: "relogin-access-token",
refresh: "relogin-refresh-token",
@@ -345,10 +345,10 @@ describe("createFusionAuthStorage", () => {
it("restores the subscription card when re-auth writes under the subscription id", async () => {
const authStorage = createFusionAuthStorage();
authStorage.logout("anthropic-subscription");
await authStorage.logout("anthropic-subscription");
expect(authStorage.hasAuth("anthropic-subscription")).toBe(false);
authStorage.set("anthropic-subscription", {
await authStorage.set("anthropic-subscription", {
type: "oauth",
access: "subscription-relogin-token",
refresh: "subscription-relogin-refresh",
@@ -363,8 +363,8 @@ describe("createFusionAuthStorage", () => {
// the subscription's logged-out state — only OAuth credentials do.
const authStorage = createFusionAuthStorage();
authStorage.logout("anthropic-subscription");
authStorage.set("anthropic", { type: "api_key", key: "sk-ant-api03-raw-key" });
await authStorage.logout("anthropic-subscription");
await authStorage.set("anthropic", { type: "api_key", key: "sk-ant-api03-raw-key" });
expect(authStorage.hasAuth("anthropic-subscription")).toBe(false);
});
@@ -603,7 +603,7 @@ describe("createFusionAuthStorage", () => {
});
const authStorage = createFusionAuthStorage();
authStorage.logout("anthropic");
await authStorage.logout("anthropic");
// Raw-key logout removes only the raw slot; subscription OAuth still powers
// the direct `anthropic` runtime provider.
@@ -621,10 +621,10 @@ describe("createFusionAuthStorage", () => {
});
const authStorage = createFusionAuthStorage();
authStorage.logout("anthropic");
await authStorage.logout("anthropic");
expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
authStorage.set("anthropic-subscription", {
await authStorage.set("anthropic-subscription", {
type: "oauth",
access: "subscription-access-token",
refresh: "subscription-refresh-token",
@@ -648,7 +648,7 @@ describe("createFusionAuthStorage", () => {
});
const authStorage = createFusionAuthStorage();
authStorage.logout("anthropic-subscription");
await authStorage.logout("anthropic-subscription");
expect(authStorage.get("anthropic-subscription")).toBeUndefined();
expect(authStorage.get("anthropic")).toEqual({ type: "api_key", key: "sk-ant-api03-runtime-key" });
@@ -669,7 +669,7 @@ describe("createFusionAuthStorage", () => {
// Before logout the legacy OAuth row drives direct runtime auth…
expect(await authStorage.getApiKey("anthropic")).toBe("legacy-subscription-access-token");
authStorage.logout("anthropic-subscription");
await authStorage.logout("anthropic-subscription");
// …and subscription logout suppresses the legacy OAuth alias everywhere.
expect(authStorage.get("anthropic")).toBeUndefined();
@@ -699,7 +699,7 @@ describe("createFusionAuthStorage", () => {
expect(authStorage.hasAuth("anthropic")).toBe(true);
expect(authStorage.list()).toContain("anthropic");
authStorage.logout("anthropic-subscription");
await authStorage.logout("anthropic-subscription");
expect(authStorage.get("anthropic")).toBeUndefined();
expect(authStorage.has("anthropic")).toBe(false);
@@ -726,7 +726,7 @@ describe("createFusionAuthStorage", () => {
});
const authStorage = createFusionAuthStorage();
authStorage.logout("anthropic-subscription");
await authStorage.logout("anthropic-subscription");
expect(authStorage.has("anthropic")).toBe(true);
expect(authStorage.hasAuth("anthropic")).toBe(true);
@@ -747,7 +747,7 @@ describe("createFusionAuthStorage", () => {
expect(authStorage.has("anthropic")).toBe(true);
expect(await authStorage.getApiKey("anthropic")).toBe("models-runtime-key");
authStorage.logout("anthropic");
await authStorage.logout("anthropic");
expect(authStorage.has("anthropic")).toBe(false);
expect(authStorage.hasAuth("anthropic")).toBe(false);
@@ -958,7 +958,7 @@ describe("createFusionAuthStorage", () => {
const pendingRefresh = authStorage.getApiKey("anthropic");
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
authStorage.set("anthropic", {
await authStorage.set("anthropic", {
type: "oauth",
access: "fresh-login-access-token",
refresh: "fresh-login-refresh-token",
@@ -1235,7 +1235,7 @@ describe("createFusionAuthStorage", () => {
expect(await authStorage.getApiKey("anthropic")).toBe("claude-access-token");
// Log out
authStorage.logout("anthropic");
await authStorage.logout("anthropic");
// After logout, supplemental credentials are hidden
expect(authStorage.has("anthropic")).toBe(false);
@@ -1259,7 +1259,7 @@ describe("createFusionAuthStorage", () => {
);
const authStorage = createFusionAuthStorage();
authStorage.logout("anthropic");
await authStorage.logout("anthropic");
// reload() should NOT bring back the supplemental credential
authStorage.reload();
@@ -1284,7 +1284,7 @@ describe("createFusionAuthStorage", () => {
);
const authStorage = createFusionAuthStorage();
authStorage.logout("anthropic");
await authStorage.logout("anthropic");
const all = authStorage.getAll();
expect("anthropic" in all).toBe(false);
@@ -1305,7 +1305,7 @@ describe("createFusionAuthStorage", () => {
);
const authStorage = createFusionAuthStorage();
authStorage.logout("anthropic");
await authStorage.logout("anthropic");
expect(authStorage.list()).not.toContain("anthropic");
});
@@ -1325,10 +1325,10 @@ describe("createFusionAuthStorage", () => {
);
const authStorage = createFusionAuthStorage();
authStorage.logout("anthropic");
await authStorage.logout("anthropic");
// Re-authenticate
authStorage.set("anthropic", { type: "api_key", key: "new-key" });
await authStorage.set("anthropic", { type: "api_key", key: "new-key" });
// Provider is visible again
expect(authStorage.has("anthropic")).toBe(true);
@@ -1359,7 +1359,7 @@ describe("createFusionAuthStorage", () => {
);
const authStorage = createFusionAuthStorage();
authStorage.logout("anthropic");
await authStorage.logout("anthropic");
// anthropic is hidden
expect(authStorage.hasAuth("anthropic")).toBe(false);

View File

@@ -1,6 +1,6 @@
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { dirname, join, resolve } from "node:path";
import lockfile from "proper-lockfile";
import {
choosePreferredStoredCredential,
@@ -22,9 +22,9 @@ export interface FusionAuthStorage {
list(): string[];
has(provider: string): boolean;
hasAuth(provider: string): boolean;
set(provider: string, credential: StoredCredential): void;
remove(provider: string): void;
logout(provider: string): void;
set(provider: string, credential: StoredCredential): Promise<void>;
remove(provider: string): Promise<void>;
logout(provider: string): Promise<void>;
getApiKey(provider: string): Promise<string | undefined>;
getOAuthProviders(): Array<{ id: string; name: string }>;
login(provider: string, callbacks: unknown): Promise<void>;
@@ -32,6 +32,42 @@ export interface FusionAuthStorage {
setModelRuntime(modelRuntime: ModelRuntime): void;
}
/*
FNXC:ProviderAuth 2026-07-17-08:30:
FN-8205 replaces the immediate-fail `lockSync` write path: proper-lockfile rejects
synchronous retries with ESYNC, so a held shared auth.json lock used to surface ELOCKED
straight through set/remove/logout. Writes now share this asynchronous retry policy with
modify() and queue per resolved auth path before taking the cross-process file lock. This
prevents same-engine sessions from self-contending while preserving fresh read-modify-merge
under the lock for independent Fusion processes. The vendored pi-coding-agent synchronous
lock defect from Runfusion/Fusion#2167 remains a separately tracked upstream follow-up.
*/
const AUTH_LOCK_OPTIONS = {
realpath: false,
stale: 30_000,
retries: {
retries: 10,
factor: 2,
minTimeout: 20,
maxTimeout: 10_000,
randomize: true,
},
} as const;
const authWriteQueues = new Map<string, Promise<void>>();
function enqueueAuthWrite<T>(authPath: string, write: () => Promise<T>): Promise<T> {
const queueKey = resolve(authPath);
const previous = authWriteQueues.get(queueKey) ?? Promise.resolve();
const operation = previous.catch(() => undefined).then(write);
const tail = operation.then(() => undefined, () => undefined);
authWriteQueues.set(queueKey, tail);
void tail.finally(() => {
if (authWriteQueues.get(queueKey) === tail) authWriteQueues.delete(queueKey);
});
return operation;
}
class FusionFileAuthStorage implements FusionAuthStorage {
private data: Record<string, StoredCredential> = {};
private modelRuntime: ModelRuntime | undefined;
@@ -57,20 +93,22 @@ class FusionFileAuthStorage implements FusionAuthStorage {
}
}
private withLock<T>(fn: (current: Record<string, StoredCredential>) => T): T {
this.ensureFile();
// proper-lockfile's synchronous API cannot retry; writes remain serialized by the lock.
const release = lockfile.lockSync(this.authPath, { realpath: false });
try {
const current = this.readCurrent();
const result = fn(current);
this.data = current;
writeFileSync(this.authPath, JSON.stringify(current, null, 2), { encoding: "utf-8", mode: 0o600 });
chmodSync(this.authPath, 0o600);
return result;
} finally {
release();
}
private async withLock<T>(fn: (current: Record<string, StoredCredential>) => Promise<T>): Promise<T> {
return enqueueAuthWrite(this.authPath, async () => {
this.ensureFile();
const release = await lockfile.lock(this.authPath, AUTH_LOCK_OPTIONS);
try {
// Always merge the on-disk state observed after acquiring the lock, never this.data.
const current = this.readCurrent();
const result = await fn(current);
writeFileSync(this.authPath, JSON.stringify(current, null, 2), { encoding: "utf-8", mode: 0o600 });
chmodSync(this.authPath, 0o600);
this.data = current;
return result;
} finally {
await release();
}
});
}
reload(): void {
@@ -83,13 +121,13 @@ class FusionFileAuthStorage implements FusionAuthStorage {
list(): string[] { return Object.keys(this.data); }
has(provider: string): boolean { return Boolean(this.data[provider]); }
hasAuth(provider: string): boolean { return this.has(provider); }
set(provider: string, credential: StoredCredential): void {
this.withLock((current) => { current[provider] = credential; });
async set(provider: string, credential: StoredCredential): Promise<void> {
await this.withLock(async (current) => { current[provider] = credential; });
}
remove(provider: string): void {
this.withLock((current) => { delete current[provider]; });
async remove(provider: string): Promise<void> {
await this.withLock(async (current) => { delete current[provider]; });
}
logout(provider: string): void { this.remove(provider); }
async logout(provider: string): Promise<void> { await this.remove(provider); }
async getApiKey(provider: string): Promise<string | undefined> {
return resolveStoredCredentialApiKey(provider, this.get(provider));
}
@@ -128,19 +166,11 @@ class FusionFileAuthStorage implements FusionAuthStorage {
this.reload();
}
async modify(provider: string, fn: (current: StoredCredential | undefined) => Promise<StoredCredential | undefined>): Promise<StoredCredential | undefined> {
this.ensureFile();
const release = await lockfile.lock(this.authPath, { realpath: false, retries: { retries: 10, minTimeout: 20 } });
try {
const current = this.readCurrent();
return this.withLock(async (current) => {
const next = await fn(current[provider]);
if (next !== undefined) current[provider] = next;
this.data = current;
writeFileSync(this.authPath, JSON.stringify(current, null, 2), { encoding: "utf-8", mode: 0o600 });
chmodSync(this.authPath, 0o600);
return current[provider];
} finally {
await release();
}
});
}
}
@@ -164,7 +194,7 @@ export function createFusionCredentialStore(authStorage: FusionAuthStorage): Cre
: [];
}),
modify: async (providerId, fn) => authStorage.modify(providerId, async (current) => fn(current as Credential | undefined) as Promise<StoredCredential | undefined>) as Promise<Credential | undefined>,
delete: async (providerId) => { authStorage.remove(providerId); },
delete: async (providerId) => { await authStorage.remove(providerId); },
};
}
@@ -433,6 +463,7 @@ export function createFusionAuthStorage(): FusionAuthStorage {
*/
const oauthRefreshInFlight = new Map<string, Promise<StoredCredential | undefined>>();
const oauthRefreshCooldownUntil = new Map<string, number>();
let supplementalHydration = Promise.resolve();
// Providers the user has explicitly logged out from. These should not be
// "resurrected" from supplemental credential files (e.g. ~/.claude/.credentials.json).
@@ -459,7 +490,14 @@ export function createFusionAuthStorage(): FusionAuthStorage {
}
};
const syncSupplementalOauthCredentials = () => {
/*
FNXC:ProviderAuth 2026-07-17-08:45:
Supplemental hydration persists through the same per-path queue as direct writes. Construction
and lock-free reload intentionally start it without blocking reads; callers that require a
hydrated credential use getApiKey(), which awaits its own persistence. Other reads observe
hydrated state after this queue drains rather than racing an un-awaited file write.
*/
const syncSupplementalOauthCredentials = async (): Promise<void> => {
for (const [provider, credential] of Object.entries(supplementalCredentials)) {
if (loggedOutProviders.has(provider)) {
continue;
@@ -472,11 +510,11 @@ export function createFusionAuthStorage(): FusionAuthStorage {
if (typeof credential.expires !== "number" || Date.now() >= credential.expires) {
continue;
}
primary.set(provider, credential as StoredCredential);
await primary.set(provider, credential as StoredCredential);
continue;
}
if (credential.type === "api_key") {
primary.set(provider, credential as StoredCredential);
await primary.set(provider, credential as StoredCredential);
}
}
};
@@ -650,7 +688,7 @@ export function createFusionAuthStorage(): FusionAuthStorage {
return resolveStoredCredentialApiKey(storageProvider, latestCredential);
}
}
primary.set(storageProvider, refreshedCredential as StoredCredential);
await primary.set(storageProvider, refreshedCredential as StoredCredential);
loggedOutProviders.delete(storageProvider);
return resolveStoredCredentialApiKey(storageProvider, refreshedCredential);
}
@@ -706,7 +744,7 @@ export function createFusionAuthStorage(): FusionAuthStorage {
return undefined;
};
syncSupplementalOauthCredentials();
supplementalHydration = syncSupplementalOauthCredentials();
return new Proxy(primary, {
// Forward property writes to the target so that methods like
@@ -720,26 +758,26 @@ export function createFusionAuthStorage(): FusionAuthStorage {
get(target, prop, receiver) {
if (prop === "logout") {
return (provider: string) => {
target.logout(provider);
return async (provider: string): Promise<void> => {
await target.logout(provider);
loggedOutProviders.add(provider);
if (provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) {
const legacyAnthropicCredential = target.get(ANTHROPIC_PROVIDER_ID) as StoredCredential | undefined;
if (legacyAnthropicCredential?.type === "oauth") {
target.logout(ANTHROPIC_PROVIDER_ID);
await target.logout(ANTHROPIC_PROVIDER_ID);
}
}
};
}
if (prop === "remove") {
return (provider: string) => {
target.remove(provider);
return async (provider: string): Promise<void> => {
await target.remove(provider);
loggedOutProviders.add(provider);
if (provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) {
const legacyAnthropicCredential = target.get(ANTHROPIC_PROVIDER_ID) as StoredCredential | undefined;
if (legacyAnthropicCredential?.type === "oauth") {
target.remove(ANTHROPIC_PROVIDER_ID);
await target.remove(ANTHROPIC_PROVIDER_ID);
}
}
};
@@ -771,8 +809,8 @@ export function createFusionAuthStorage(): FusionAuthStorage {
}
if (prop === "set") {
return (provider: string, credential: StoredCredential) => {
target.set(provider, credential);
return async (provider: string, credential: StoredCredential): Promise<void> => {
await target.set(provider, credential);
clearReauthenticatedLogoutState(provider, (credential as StoredCredential | undefined)?.type);
};
}
@@ -781,7 +819,7 @@ export function createFusionAuthStorage(): FusionAuthStorage {
return () => {
target.reload();
supplementalCredentials = readSupplementalCredentials();
syncSupplementalOauthCredentials();
supplementalHydration = syncSupplementalOauthCredentials();
modelsJsonApiKeys = readModelsJsonApiKeys();
};
}
@@ -873,6 +911,7 @@ export function createFusionAuthStorage(): FusionAuthStorage {
if (prop === "getApiKey") {
return async (provider: string) => {
await supplementalHydration;
if (provider === ANTHROPIC_PROVIDER_ID) {
return resolveAnthropicRuntimeApiKey();
}
@@ -891,7 +930,7 @@ export function createFusionAuthStorage(): FusionAuthStorage {
FNXC:ProviderAuth 2026-07-01-12:34:
Legacy Anthropic OAuth rows are subscription credentials, not raw API keys. Hydrate them into `anthropic-subscription` before refresh so status, usage, and banner clearing share the same provider id without overwriting a raw `anthropic` API-key credential.
*/
primary.set(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, subscriptionCredential as StoredCredential);
await primary.set(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, subscriptionCredential as StoredCredential);
loggedOutProviders.delete(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
}
return resolveRefreshableCredentialApiKey(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, subscriptionCredential);

View File

@@ -35,10 +35,10 @@ export interface DashboardAuthStorage {
getOAuthProviders(): Array<{ id: string; name: string }>;
hasAuth(provider: string): boolean;
login(providerId: string, callbacks: LoginCallbacks): Promise<void>;
logout(provider: string): void;
logout(provider: string): Promise<void>;
getApiKeyProviders(): Array<{ id: string; name: string }>;
setApiKey(providerId: string, apiKey: string): void;
clearApiKey(providerId: string): void;
setApiKey(providerId: string, apiKey: string): Promise<void>;
clearApiKey(providerId: string): Promise<void>;
hasApiKey(providerId: string): boolean;
getApiKey(providerId: string): Promise<string | undefined>;
get(providerId: string): { type?: string; key?: string } | undefined;
@@ -105,7 +105,7 @@ export function wrapAuthStorageWithApiKeyProviders(
return legacyCredential?.type === "oauth" ? legacyCredential : undefined;
};
const migrateStoredAnthropicSubscriptionCredential = () => {
const migrateStoredAnthropicSubscriptionCredential = async () => {
const existingSubscription = authStorage.get(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID) as StoredCredential | undefined;
if (existingSubscription?.type === "oauth") {
return existingSubscription;
@@ -121,7 +121,7 @@ export function wrapAuthStorageWithApiKeyProviders(
Saving or clearing the separated `anthropic-api-key` provider overwrites the raw `anthropic` storage slot used by model execution.
Read the primary auth storage directly and migrate legacy subscription OAuth from `anthropic` to `anthropic-subscription` before that write, because merged Anthropic reads intentionally expose `anthropic` as API-key-only.
*/
mergedAuthStorage.set(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID, legacySubscription as StoredCredential);
await mergedAuthStorage.set(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID, legacySubscription as StoredCredential);
return legacySubscription;
};
@@ -157,20 +157,20 @@ export function wrapAuthStorageWithApiKeyProviders(
Anthropic subscription OAuth and raw Anthropic API-key auth must be separate UI providers: OAuth stays `anthropic`, while the UI/API key card uses `anthropic-api-key` and maps back to the `anthropic` model credential.
Store subscription OAuth under an internal key after upstream login because the OAuth library writes through the same `anthropic` id used by model API-key execution.
*/
mergedAuthStorage.set(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID, oauthCredential as StoredCredential);
await mergedAuthStorage.set(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID, oauthCredential as StoredCredential);
if (existingApiKey?.type === "api_key") {
mergedAuthStorage.set(ANTHROPIC_STORAGE_PROVIDER_ID, existingApiKey as StoredCredential);
await mergedAuthStorage.set(ANTHROPIC_STORAGE_PROVIDER_ID, existingApiKey as StoredCredential);
} else {
authStorage.remove(ANTHROPIC_STORAGE_PROVIDER_ID);
await authStorage.remove(ANTHROPIC_STORAGE_PROVIDER_ID);
}
}
},
logout: (provider) => {
logout: async (provider) => {
if (provider !== ANTHROPIC_STORAGE_PROVIDER_ID && provider !== ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID) {
mergedAuthStorage.logout(provider);
await mergedAuthStorage.logout(provider);
return;
}
mergedAuthStorage.logout(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID);
await mergedAuthStorage.logout(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID);
/*
FNXC:ProviderAuth 2026-06-29-23:59:
Logging out Anthropic subscription auth must also remove pre-split OAuth credentials still stored under `anthropic`.
@@ -178,7 +178,7 @@ export function wrapAuthStorageWithApiKeyProviders(
*/
const legacyAnthropicCredential = authStorage.get(ANTHROPIC_STORAGE_PROVIDER_ID) as StoredCredential | undefined;
if (legacyAnthropicCredential?.type === "oauth") {
mergedAuthStorage.logout(ANTHROPIC_STORAGE_PROVIDER_ID);
await mergedAuthStorage.logout(ANTHROPIC_STORAGE_PROVIDER_ID);
}
},
getApiKeyProviders: () => {
@@ -215,19 +215,19 @@ export function wrapAuthStorageWithApiKeyProviders(
a.name.localeCompare(b.name),
);
},
setApiKey: (providerId, apiKey) => {
setApiKey: async (providerId, apiKey) => {
const storageProviderId = toApiKeyStorageProviderId(providerId);
if (storageProviderId === ANTHROPIC_STORAGE_PROVIDER_ID) {
migrateStoredAnthropicSubscriptionCredential();
await migrateStoredAnthropicSubscriptionCredential();
}
mergedAuthStorage.set(storageProviderId, { type: "api_key", key: apiKey });
await mergedAuthStorage.set(storageProviderId, { type: "api_key", key: apiKey });
},
clearApiKey: (providerId) => {
clearApiKey: async (providerId) => {
const storageProviderId = toApiKeyStorageProviderId(providerId);
if (storageProviderId === ANTHROPIC_STORAGE_PROVIDER_ID) {
migrateStoredAnthropicSubscriptionCredential();
await migrateStoredAnthropicSubscriptionCredential();
}
mergedAuthStorage.remove(storageProviderId);
await mergedAuthStorage.remove(storageProviderId);
},
hasApiKey: (providerId) => {
const credential = mergedAuthStorage.get(toApiKeyStorageProviderId(providerId));
@@ -303,7 +303,7 @@ export function mergeAuthStorageReads(
return selectCredential(providerId, readAuthStorages);
};
const syncFallbackOauthCredentials = () => {
const syncFallbackOauthCredentials = async (): Promise<void> => {
const providerIds = new Set(readFallbackAuthStorages.flatMap((storage) => storage.list()));
for (const providerId of providerIds) {
const storageProviderId = providerId === ANTHROPIC_STORAGE_PROVIDER_ID
@@ -322,32 +322,32 @@ export function mergeAuthStorageReads(
FNXC:ProviderAuth 2026-06-29-23:48:
Legacy Anthropic OAuth files may still store subscription credentials under `anthropic`; hydrate those as `anthropic-subscription` so Anthropic model/API-key reads only trust `api_key` credentials under `anthropic`.
*/
authStorage.set(storageProviderId, candidate as StoredCredential);
await authStorage.set(storageProviderId, candidate as StoredCredential);
}
}
};
syncFallbackOauthCredentials();
void syncFallbackOauthCredentials();
return new Proxy(authStorage, {
get(target, prop, receiver) {
if (prop === "logout") {
return (provider: string) => {
target.logout(provider);
return async (provider: string): Promise<void> => {
await target.logout(provider);
loggedOutProviders.add(provider);
};
}
if (prop === "remove") {
return (provider: string) => {
target.remove(provider);
return async (provider: string): Promise<void> => {
await target.remove(provider);
loggedOutProviders.add(provider);
};
}
if (prop === "set") {
return (provider: string, credential: StoredCredential) => {
target.set(provider, credential);
return async (provider: string, credential: StoredCredential): Promise<void> => {
await target.set(provider, credential);
loggedOutProviders.delete(provider);
};
}
@@ -357,7 +357,7 @@ export function mergeAuthStorageReads(
for (const storage of readAuthStorages) {
storage.reload();
}
syncFallbackOauthCredentials();
void syncFallbackOauthCredentials();
};
}