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:
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user