FN-7576: delegate anthropic-subscription getApiKey to engine authStorage

Reads for the anthropic-subscription provider now delegate to the real engine authStorage so expired OAuth tokens are refreshed instead of silently failing.

- mergeAuthStorageReads getApiKey("anthropic-subscription") now calls target.getApiKey(providerId) directly, triggering the engine's refresh-token HTTP round trip, instead of a local static Date.now() >= credential.expires check
- Falls back to the read-only fallback storages' local resolution only when the primary engine call yields no key
- Added regression tests exercising the wrapper directly against the engine delegation and fallback behavior
- Added a patch changeset documenting the fix for @runfusion/fusion

Files changed:
 .changeset/fn-7576-cli-wrapper-subscription-getapikey-delegation.md               |   7 +
 packages/cli/src/commands/__tests__/provider-auth.test.ts                         | 148 +++++++++++++++++++++
 packages/cli/src/commands/provider-auth.ts                                        |  12 +-
 3 files changed, 166 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-7576

Fusion-Task-Lineage: 31e495ec-8487-468b-9b80-36e8d0f53806

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-05 09:14:03 -07:00
parent ce9df297eb
commit 196abb5af9
3 changed files with 166 additions and 1 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Anthropic subscription reads now refresh the OAuth token automatically instead of silently failing when expired.
category: fix
dev: mergeAuthStorageReads getApiKey("anthropic-subscription") now delegates to the underlying engine authStorage.getApiKey (the only refresh-token HTTP round trip) instead of a local static expiry check; regression tests drive the wrapper directly. No token material logged.

View File

@@ -585,4 +585,152 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(merged.has("anthropic")).toBe(false);
});
});
describe("anthropic-subscription getApiKey delegation (FN-7576)", () => {
/*
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.
*/
const PAST_EXPIRY = Date.now() - 60_000;
it("delegates to the underlying engine authStorage.getApiKey and returns the refreshed key even when the stored credential is expired", async () => {
const fusionAuth = makeAuthStorage({
"anthropic-subscription": {
type: "oauth",
access: "stale-oauth-access",
refresh: "stale-oauth-refresh",
expires: PAST_EXPIRY,
},
});
fusionAuth.getApiKey = vi.fn(async (provider: string) =>
provider === "anthropic-subscription" ? "refreshed-oauth-access" : undefined,
);
const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry);
const apiKey = await wrapped.getApiKey("anthropic-subscription");
expect(fusionAuth.getApiKey).toHaveBeenCalledWith("anthropic-subscription");
expect(apiKey).toBe("refreshed-oauth-access");
});
it("delegates via the mergeAuthStorageReads proxy directly", async () => {
const fusionAuth = makeAuthStorage({
"anthropic-subscription": {
type: "oauth",
access: "stale-oauth-access",
refresh: "stale-oauth-refresh",
expires: PAST_EXPIRY,
},
});
fusionAuth.getApiKey = vi.fn(async (provider: string) =>
provider === "anthropic-subscription" ? "refreshed-oauth-access" : undefined,
);
const merged = mergeAuthStorageReads(fusionAuth);
const apiKey = await merged.getApiKey("anthropic-subscription");
expect(fusionAuth.getApiKey).toHaveBeenCalledWith("anthropic-subscription");
expect(apiKey).toBe("refreshed-oauth-access");
});
it("delegates and returns the refreshed key when OAuth is stored under the legacy anthropic row", async () => {
const fusionAuth = makeAuthStorage({
anthropic: {
type: "oauth",
access: "legacy-stale-oauth-access",
refresh: "legacy-stale-oauth-refresh",
expires: PAST_EXPIRY,
},
});
fusionAuth.getApiKey = vi.fn(async (provider: string) =>
provider === "anthropic-subscription" ? "refreshed-oauth-access" : undefined,
);
const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry);
const apiKey = await wrapped.getApiKey("anthropic-subscription");
expect(fusionAuth.getApiKey).toHaveBeenCalledWith("anthropic-subscription");
expect(apiKey).toBe("refreshed-oauth-access");
});
it("does not call the delegated engine getApiKey and returns undefined once the subscription is logged out", async () => {
const fusionAuth = makeAuthStorage({
"anthropic-subscription": {
type: "oauth",
access: "oauth-access",
refresh: "oauth-refresh",
expires: Date.now() + 60_000,
},
});
fusionAuth.getApiKey = vi.fn(async (provider: string) =>
provider === "anthropic-subscription" ? "refreshed-oauth-access" : undefined,
);
const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry);
wrapped.logout("anthropic-subscription");
fusionAuth.getApiKey.mockClear();
const apiKey = await wrapped.getApiKey("anthropic-subscription");
expect(apiKey).toBeUndefined();
expect(fusionAuth.getApiKey).not.toHaveBeenCalledWith("anthropic-subscription");
});
it("returns undefined when no credential is stored and the engine authStorage has nothing to refresh", async () => {
const fusionAuth = makeAuthStorage();
const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry);
const apiKey = await wrapped.getApiKey("anthropic-subscription");
expect(apiKey).toBeUndefined();
});
it("falls back to a read-only fallback storage's local resolution only when the primary engine yields no key", async () => {
const fusionAuth = makeAuthStorage({
"anthropic-subscription": {
type: "oauth",
access: "stale-oauth-access",
refresh: "stale-oauth-refresh",
expires: PAST_EXPIRY,
},
});
// Primary engine authStorage genuinely cannot refresh (e.g. refresh token invalid/absent upstream).
fusionAuth.getApiKey = vi.fn(async () => undefined);
const fallbackAuth = makeAuthStorage({
"anthropic-subscription": {
type: "oauth",
access: "fallback-still-valid-access",
refresh: "fallback-refresh",
expires: Date.now() + 60_000,
},
});
fallbackAuth.getApiKey = vi.fn(async (provider: string) =>
provider === "anthropic-subscription" ? "fallback-still-valid-access" : undefined,
);
const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry, [fallbackAuth]);
const apiKey = await wrapped.getApiKey("anthropic-subscription");
expect(fusionAuth.getApiKey).toHaveBeenCalledWith("anthropic-subscription");
expect(apiKey).toBe("fallback-still-valid-access");
});
it("still resolves non-subscription providers exactly as before (no regression)", async () => {
const fusionAuth = makeAuthStorage({
openrouter: { type: "api_key", key: "fusion-openrouter-key" },
anthropic: { type: "api_key", key: "sk-ant-api03-raw-key" },
});
const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry);
expect(await wrapped.getApiKey("openrouter")).toBe("fusion-openrouter-key");
expect(await wrapped.getApiKey("anthropic-api-key")).toBe("sk-ant-api03-raw-key");
});
});
});

View File

@@ -415,7 +415,17 @@ export function mergeAuthStorageReads(
return credential?.type === "api_key" ? resolveStoredApiKey(credential.key) : undefined;
}
if (providerId === ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID && credential) {
return resolveStoredCredentialApiKey(providerId, credential);
/*
FNXC:ProviderAuth 2026-07-05-09:10:
Reading `anthropic-subscription` through this merge proxy must delegate to the underlying real engine `authStorage.getApiKey(...)` (the `target` primary storage) so the refresh-token HTTP round trip in packages/engine/src/auth-storage.ts actually runs. The prior local static `Date.now() >= credential.expires` check (`resolveStoredCredentialApiKey`/`resolveOAuthApiKey`) never called the real engine and silently no-oped the refresh in production, e.g. the dashboard status route's best-effort refresh-on-expiry read (register-auth-routes.ts). `target.getApiKey` internally handles both the separated `anthropic-subscription` row and the legacy `anthropic` OAuth row, so this single delegated call covers both storage permutations without duplicating that logic here. Only fall back to the read-only fallback storages' local (non-refreshing) resolution when the primary engine yields no key; a logged-out subscription is already excluded above and must never reach this delegated call.
*/
const engineApiKey = await target.getApiKey(providerId);
if (engineApiKey) return engineApiKey;
for (const fallbackStorage of readFallbackAuthStorages) {
const fallbackApiKey = await fallbackStorage.getApiKey(providerId);
if (fallbackApiKey) return fallbackApiKey;
}
return undefined;
}
for (const storage of readAuthStorages) {
const apiKey = await storage.getApiKey(providerId);