FN-7625: make auth provider list a static catalog, not runtime-derived
Authentication settings previously enumerated providers straight from pi AuthStorage's live runtime registry, so connecting a runtime plugin (e.g. Hermes Runtime) could narrow/collapse the visible provider list. This adds a static, hand-maintained supported-provider catalog and unions it with storage-reported providers so presence in the list is deterministic while status stays live. - Add packages/dashboard/src/routes/auth-provider-catalog.ts with STATIC_OAUTH_PROVIDER_CATALOG, STATIC_API_KEY_PROVIDER_CATALOG, and unionProviderCatalog() (catalog always wins on presence; runtime-only extras still surface; runtime name wins on name conflicts). - Update register-auth-routes.ts's GET /api/auth/status to union the static catalogs with storage.getOAuthProviders()/getApiKeyProviders() instead of relying solely on runtime-reported providers. - Extend routes-auth.test.ts coverage for the new catalog-union behavior (provider presence stable across narrowed runtime registries, extras preserved, name precedence). - Add changeset fn-7625-static-auth-provider-catalog.md (patch, fix). Files changed: .changeset/fn-7625-static-auth-provider-catalog.md | 7 + .../dashboard/src/__tests__/routes-auth.test.ts | 180 +++++++++++++++++++-- .../dashboard/src/routes/auth-provider-catalog.ts | 93 +++++++++++ .../dashboard/src/routes/register-auth-routes.ts | 25 ++- 4 files changed, 291 insertions(+), 14 deletions(-) Fusion-Task-Id: FN-7625 Fusion-Task-Lineage: be984497-b881-4a8f-9860-544617e2b5f7 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7625-static-auth-provider-catalog.md
Normal file
7
.changeset/fn-7625-static-auth-provider-catalog.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Authentication settings always lists all supported providers, regardless of connected runtime plugins.
|
||||
category: fix
|
||||
dev: GET /api/auth/status now enumerates a static supported-provider catalog (union with storage-reported providers) and uses runtime/auth state only to annotate per-provider status; connecting a runtime plugin (e.g. Hermes Runtime) no longer collapses the provider list.
|
||||
@@ -932,11 +932,34 @@ describe("GET /auth/status", () => {
|
||||
// Filter out synthetic CLI providers — they have dedicated route tests.
|
||||
// Structural assertions here are about OAuth + API-key paths only.
|
||||
const providers = res.body.providers.filter((p: any) => p.id !== "claude-cli" && p.id !== "droid-cli" && p.id !== "cursor-cli" && p.id !== "llama-cpp");
|
||||
expect(providers).toEqual([
|
||||
{ id: "github-copilot", name: "GitHub Copilot", authenticated: true, type: "oauth", expired: false, loginInProgress: false },
|
||||
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" },
|
||||
{ id: "kimi-coding", name: "Kimi", authenticated: false, type: "api_key" },
|
||||
/*
|
||||
FN-7625: the static catalog (anthropic-subscription/github-copilot/openai-codex
|
||||
OAuth + the full API-key catalog) is always present, unioned with whatever the
|
||||
mocked storage additionally reports — even when storage only reports a narrow
|
||||
subset (github-copilot + openrouter + kimi-coding here).
|
||||
*/
|
||||
expect(providers.map((p: any) => p.id)).toEqual([
|
||||
"anthropic-subscription",
|
||||
"github-copilot",
|
||||
"openai-codex",
|
||||
"anthropic-api-key",
|
||||
"brave",
|
||||
"kimi-coding",
|
||||
"minimax",
|
||||
"openrouter",
|
||||
"opencode-go",
|
||||
"tavily",
|
||||
"zai",
|
||||
]);
|
||||
const githubCopilot = providers.find((p: any) => p.id === "github-copilot");
|
||||
expect(githubCopilot).toEqual({ id: "github-copilot", name: "GitHub Copilot", authenticated: true, type: "oauth", expired: false, loginInProgress: false });
|
||||
const openrouter = providers.find((p: any) => p.id === "openrouter");
|
||||
expect(openrouter).toEqual({ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" });
|
||||
const kimiCoding = providers.find((p: any) => p.id === "kimi-coding");
|
||||
expect(kimiCoding).toEqual({ id: "kimi-coding", name: "Kimi", authenticated: false, type: "api_key" });
|
||||
// Catalog-only entries (not reported by storage) still surface, present-but-unauthenticated.
|
||||
const brave = providers.find((p: any) => p.id === "brave");
|
||||
expect(brave).toEqual({ id: "brave", name: "Brave Search", authenticated: false, type: "api_key" });
|
||||
expect(authStorage.reload).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1026,13 +1049,35 @@ describe("GET /auth/status", () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const providers = res.body.providers.filter((p: any) => p.id !== "claude-cli" && p.id !== "droid-cli" && p.id !== "cursor-cli" && p.id !== "llama-cpp");
|
||||
expect(providers).toEqual([
|
||||
{ id: "github-copilot", name: "GitHub Copilot", authenticated: true, type: "oauth", expired: false, loginInProgress: false },
|
||||
{ id: "openai-codex", name: "OpenAI Codex", authenticated: false, type: "oauth", expired: false, loginInProgress: false, requiresManualCode: true },
|
||||
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" },
|
||||
{ id: "kimi-coding", name: "Kimi", authenticated: false, type: "api_key" },
|
||||
{ id: "acme-extension", name: "Acme Extension", authenticated: true, type: "api_key" },
|
||||
/*
|
||||
FN-7625: catalog ids remain present even though storage only reported a
|
||||
narrow subset, and a storage-reported id NOT in the catalog ("acme-extension")
|
||||
still surfaces — union, never intersection, with runtime state.
|
||||
*/
|
||||
expect(providers.map((p: any) => p.id)).toEqual([
|
||||
"anthropic-subscription",
|
||||
"github-copilot",
|
||||
"openai-codex",
|
||||
"anthropic-api-key",
|
||||
"brave",
|
||||
"kimi-coding",
|
||||
"minimax",
|
||||
"openrouter",
|
||||
"opencode-go",
|
||||
"tavily",
|
||||
"zai",
|
||||
"acme-extension",
|
||||
]);
|
||||
const githubCopilot = providers.find((p: any) => p.id === "github-copilot");
|
||||
expect(githubCopilot).toEqual({ id: "github-copilot", name: "GitHub Copilot", authenticated: true, type: "oauth", expired: false, loginInProgress: false });
|
||||
const openaiCodex = providers.find((p: any) => p.id === "openai-codex");
|
||||
expect(openaiCodex).toEqual({ id: "openai-codex", name: "OpenAI Codex", authenticated: false, type: "oauth", expired: false, loginInProgress: false, requiresManualCode: true });
|
||||
const openrouter = providers.find((p: any) => p.id === "openrouter");
|
||||
expect(openrouter).toEqual({ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" });
|
||||
const kimiCoding = providers.find((p: any) => p.id === "kimi-coding");
|
||||
expect(kimiCoding).toEqual({ id: "kimi-coding", name: "Kimi", authenticated: false, type: "api_key" });
|
||||
const acmeExtension = providers.find((p: any) => p.id === "acme-extension");
|
||||
expect(acmeExtension).toEqual({ id: "acme-extension", name: "Acme Extension", authenticated: true, type: "api_key" });
|
||||
});
|
||||
|
||||
it.each(["https://my-host.example.com", undefined])(
|
||||
@@ -1462,6 +1507,121 @@ describe("GET /auth/status", () => {
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toBe("storage error");
|
||||
});
|
||||
|
||||
/*
|
||||
FN-7625 symptom verification: connecting a runtime plugin (e.g. Hermes Runtime)
|
||||
can narrow pi AuthStorage's live provider registry — storage.getOAuthProviders()
|
||||
and storage.getApiKeyProviders() collapse to whatever that plugin still exposes.
|
||||
Before the fix, GET /auth/status enumerated `providers` directly from those live
|
||||
reads, so the response's provider set collapsed along with the narrowed registry.
|
||||
The static catalog fix must keep provider *presence* deterministic — identical
|
||||
full-catalog ids — across a full registry, a narrowed/empty registry (simulating
|
||||
a connected runtime plugin), AND a registry that reports ids outside the catalog
|
||||
(union, never intersection). Only per-provider `authenticated`/`expired` may vary.
|
||||
*/
|
||||
describe("FN-7625: provider list is a static catalog independent of runtime/plugin connection state", () => {
|
||||
const FULL_OAUTH_CATALOG_IDS = ["anthropic-subscription", "github-copilot", "openai-codex"];
|
||||
const FULL_API_KEY_CATALOG_IDS = [
|
||||
"anthropic-api-key",
|
||||
"brave",
|
||||
"kimi-coding",
|
||||
"minimax",
|
||||
"openrouter",
|
||||
"opencode-go",
|
||||
"tavily",
|
||||
"zai",
|
||||
];
|
||||
const FULL_CATALOG_IDS = [...FULL_OAUTH_CATALOG_IDS, ...FULL_API_KEY_CATALOG_IDS];
|
||||
|
||||
function nonCliProviderIds(res: any): string[] {
|
||||
return res.body.providers
|
||||
.filter((p: any) => p.id !== "claude-cli" && p.id !== "droid-cli" && p.id !== "cursor-cli" && p.id !== "llama-cpp")
|
||||
.map((p: any) => p.id);
|
||||
}
|
||||
|
||||
it("enumerates the identical full catalog whether storage reports the full set or a narrowed/empty set (Hermes Runtime connection simulation)", async () => {
|
||||
// Permutation 1: no runtime plugin connected — storage reports the full
|
||||
// upstream registry.
|
||||
(authStorage.getOAuthProviders as ReturnType<typeof vi.fn>).mockReturnValue([
|
||||
{ id: "anthropic", name: "Anthropic" },
|
||||
{ id: "github-copilot", name: "GitHub Copilot" },
|
||||
{ id: "openai-codex", name: "OpenAI Codex" },
|
||||
]);
|
||||
(authStorage.getApiKeyProviders as ReturnType<typeof vi.fn>).mockReturnValue([
|
||||
{ id: "anthropic-api-key", name: "Anthropic API Key" },
|
||||
{ id: "brave", name: "Brave Search" },
|
||||
{ id: "kimi-coding", name: "Kimi" },
|
||||
{ id: "minimax", name: "Minimax" },
|
||||
{ id: "openrouter", name: "OpenRouter" },
|
||||
{ id: "opencode-go", name: "Opencode (Go)" },
|
||||
{ id: "tavily", name: "Tavily" },
|
||||
{ id: "zai", name: "Zai" },
|
||||
]);
|
||||
const fullRes = await GET(app, "/api/auth/status");
|
||||
expect(fullRes.status).toBe(200);
|
||||
expect(nonCliProviderIds(fullRes)).toEqual(FULL_CATALOG_IDS);
|
||||
|
||||
// Permutation 2: a runtime plugin (e.g. Hermes Runtime) connects and
|
||||
// narrows the live registry down to almost nothing — the exact
|
||||
// reproduction from the task's Symptom Verification section.
|
||||
(authStorage.getOAuthProviders as ReturnType<typeof vi.fn>).mockReturnValue([
|
||||
{ id: "github-copilot", name: "GitHub Copilot" },
|
||||
]);
|
||||
(authStorage.getApiKeyProviders as ReturnType<typeof vi.fn>).mockReturnValue([]);
|
||||
const narrowedRes = await GET(app, "/api/auth/status");
|
||||
expect(narrowedRes.status).toBe(200);
|
||||
expect(nonCliProviderIds(narrowedRes)).toEqual(FULL_CATALOG_IDS);
|
||||
|
||||
// Permutation 3: another runtime plugin narrows the registry to a
|
||||
// completely empty set (Paperclip / OpenClaw / Droid Runtime scenario).
|
||||
(authStorage.getOAuthProviders as ReturnType<typeof vi.fn>).mockReturnValue([]);
|
||||
(authStorage.getApiKeyProviders as ReturnType<typeof vi.fn>).mockReturnValue([]);
|
||||
const emptyRes = await GET(app, "/api/auth/status");
|
||||
expect(emptyRes.status).toBe(200);
|
||||
expect(nonCliProviderIds(emptyRes)).toEqual(FULL_CATALOG_IDS);
|
||||
});
|
||||
|
||||
it("still tracks authenticated/expired from storage while presence stays static", async () => {
|
||||
(authStorage.getOAuthProviders as ReturnType<typeof vi.fn>).mockReturnValue([]);
|
||||
(authStorage.getApiKeyProviders as ReturnType<typeof vi.fn>).mockReturnValue([]);
|
||||
(authStorage.hasAuth as ReturnType<typeof vi.fn>).mockImplementation((provider: string) => provider === "github-copilot");
|
||||
(authStorage.hasApiKey as ReturnType<typeof vi.fn>).mockImplementation((provider: string) => provider === "openrouter");
|
||||
|
||||
const res = await GET(app, "/api/auth/status");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Catalog provider missing from storage still appears, present-but-unauthenticated.
|
||||
const anthropicSubscription = res.body.providers.find((p: any) => p.id === "anthropic-subscription");
|
||||
expect(anthropicSubscription).toMatchObject({ authenticated: false });
|
||||
// Catalog provider storage marks authenticated stays authenticated.
|
||||
const githubCopilot = res.body.providers.find((p: any) => p.id === "github-copilot");
|
||||
expect(githubCopilot).toMatchObject({ authenticated: true });
|
||||
const openrouter = res.body.providers.find((p: any) => p.id === "openrouter");
|
||||
expect(openrouter).toMatchObject({ authenticated: true });
|
||||
const brave = res.body.providers.find((p: any) => p.id === "brave");
|
||||
expect(brave).toMatchObject({ authenticated: false });
|
||||
});
|
||||
|
||||
it("surfaces a storage-reported provider absent from the catalog (union, never intersection) and keeps the Anthropic alias de-duplicated", async () => {
|
||||
(authStorage.getOAuthProviders as ReturnType<typeof vi.fn>).mockReturnValue([
|
||||
{ id: "anthropic", name: "Anthropic" },
|
||||
{ id: "a-brand-new-upstream-oauth-provider", name: "Brand New Provider" },
|
||||
]);
|
||||
(authStorage.getApiKeyProviders as ReturnType<typeof vi.fn>).mockReturnValue([]);
|
||||
|
||||
const res = await GET(app, "/api/auth/status");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const providerIds = res.body.providers.map((p: any) => p.id);
|
||||
// Union: the new upstream provider surfaces alongside the static catalog.
|
||||
expect(providerIds).toContain("a-brand-new-upstream-oauth-provider");
|
||||
// The anthropic OAuth id is exposed only as the synthetic anthropic-subscription
|
||||
// id — no duplicate raw "anthropic" OAuth card.
|
||||
expect(providerIds).toContain("anthropic-subscription");
|
||||
expect(providerIds).not.toContain("anthropic");
|
||||
expect(providerIds.filter((id: string) => id === "anthropic-subscription")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /auth/claude-cli", () => {
|
||||
|
||||
93
packages/dashboard/src/routes/auth-provider-catalog.ts
Normal file
93
packages/dashboard/src/routes/auth-provider-catalog.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Static supported-provider catalog for the Authentication settings page.
|
||||
*
|
||||
* FNXC:ProviderAuth 2026-07-07-00:00:
|
||||
* FN-7625: the Authentication page (Settings → Authentication) must render a
|
||||
* fixed catalog of every provider Fusion supports, independent of which pi
|
||||
* runtime plugins are currently connected. `GET /api/auth/status` previously
|
||||
* enumerated providers straight from pi `AuthStorage`'s *live* runtime
|
||||
* registry (`storage.getOAuthProviders()` / `storage.getApiKeyProviders()`).
|
||||
* That registry is mutable at runtime — a connected runtime plugin (Hermes
|
||||
* Runtime, Paperclip, OpenClaw, Droid Runtime, ...) can narrow it, which
|
||||
* collapsed the visible provider list the moment such a plugin connected,
|
||||
* hiding providers Fusion otherwise fully supports.
|
||||
*
|
||||
* The fix: presence in `/auth/status`'s `providers` array must come from this
|
||||
* static catalog UNIONED with whatever storage reports — never an
|
||||
* intersection with runtime state. A provider's *status* (authenticated /
|
||||
* expired / keyHint) is still read live from storage per request; only its
|
||||
* *presence* in the list is made deterministic.
|
||||
*
|
||||
* Sourcing note: this mirrors two existing canonical lists that dashboard
|
||||
* cannot import directly —
|
||||
* - OAuth: pi-ai's built-in OAuth provider registry (anthropic,
|
||||
* github-copilot, openai-codex — see `@earendil-works/pi-ai`'s
|
||||
* `utils/oauth/index.ts` `BUILT_IN_OAUTH_PROVIDERS`), which is not
|
||||
* exported as a public catalog.
|
||||
* - API key: `packages/cli/src/commands/provider-auth.ts`'s
|
||||
* `BUILT_IN_API_KEY_PROVIDERS`, the existing canonical list used to
|
||||
* synthesize `getApiKeyProviders()` for the CLI/dashboard auth wrapper.
|
||||
* `packages/cli` depends on `packages/dashboard` (not the reverse), so
|
||||
* importing it here would introduce a circular workspace dependency.
|
||||
* Kept as a small hand-maintained mirror; update alongside
|
||||
* `provider-auth.ts`'s `BUILT_IN_API_KEY_PROVIDERS` when providers are
|
||||
* added/removed there. The synthetic CLI providers (`claude-cli`,
|
||||
* `droid-cli`, `cursor-cli`, `llama-cpp`) are NOT part of this catalog — they
|
||||
* stay on their existing dedicated injection path in
|
||||
* `register-auth-routes.ts`.
|
||||
*/
|
||||
|
||||
export interface AuthProviderCatalogEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Static catalog of OAuth-backed providers Fusion supports. */
|
||||
export const STATIC_OAUTH_PROVIDER_CATALOG: AuthProviderCatalogEntry[] = [
|
||||
// Raw upstream id; toAuthStatusProvider() maps this to the synthetic
|
||||
// `anthropic-subscription` UI id so it never collides with the
|
||||
// `anthropic-api-key` API-key card.
|
||||
{ id: "anthropic", name: "Anthropic (Claude Pro/Max)" },
|
||||
{ id: "github-copilot", name: "GitHub Copilot" },
|
||||
{ id: "openai-codex", name: "OpenAI (ChatGPT Plus/Pro)" },
|
||||
];
|
||||
|
||||
/** Static catalog of API-key-backed providers Fusion supports. */
|
||||
export const STATIC_API_KEY_PROVIDER_CATALOG: AuthProviderCatalogEntry[] = [
|
||||
{ id: "anthropic-api-key", name: "Anthropic API Key" },
|
||||
{ id: "brave", name: "Brave Search" },
|
||||
{ id: "kimi-coding", name: "Kimi" },
|
||||
{ id: "minimax", name: "Minimax" },
|
||||
{ id: "openrouter", name: "OpenRouter" },
|
||||
{ id: "opencode-go", name: "Opencode (Go)" },
|
||||
{ id: "tavily", name: "Tavily" },
|
||||
{ id: "zai", name: "Zai" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Union a static catalog with a live/runtime-reported provider list, keyed
|
||||
* by id. Catalog entries always survive regardless of what `runtime`
|
||||
* contains (never an intersection); a runtime-reported provider whose id is
|
||||
* NOT in the catalog still surfaces (a genuinely new upstream provider must
|
||||
* not be dropped). When both sides report the same id, the runtime-reported
|
||||
* `name` wins (it reflects the live registry's current display name), while
|
||||
* the catalog still guarantees the id's presence.
|
||||
*/
|
||||
export function unionProviderCatalog(
|
||||
catalog: AuthProviderCatalogEntry[],
|
||||
runtime: AuthProviderCatalogEntry[],
|
||||
): AuthProviderCatalogEntry[] {
|
||||
const byId = new Map<string, AuthProviderCatalogEntry>();
|
||||
for (const entry of catalog) {
|
||||
byId.set(entry.id, entry);
|
||||
}
|
||||
const extras: AuthProviderCatalogEntry[] = [];
|
||||
for (const entry of runtime) {
|
||||
if (byId.has(entry.id)) {
|
||||
byId.set(entry.id, entry);
|
||||
} else {
|
||||
extras.push(entry);
|
||||
}
|
||||
}
|
||||
return [...catalog.map((entry) => byId.get(entry.id) ?? entry), ...extras];
|
||||
}
|
||||
@@ -12,6 +12,11 @@ import { clearUsageCache } from "../usage.js";
|
||||
import { invalidateAllGlobalSettingsCaches } from "../project-store-resolver.js";
|
||||
import type { AuthStorageLike } from "../routes.js";
|
||||
import type { ApiRouteRegistrar } from "./types.js";
|
||||
import {
|
||||
STATIC_API_KEY_PROVIDER_CATALOG,
|
||||
STATIC_OAUTH_PROVIDER_CATALOG,
|
||||
unionProviderCatalog,
|
||||
} from "./auth-provider-catalog.js";
|
||||
|
||||
export type DeviceCodeInfo = {
|
||||
userCode: string;
|
||||
@@ -512,7 +517,15 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
const origin = typeof req.headers.origin === "string" ? req.headers.origin : undefined;
|
||||
const storage = getAuthStorage();
|
||||
storage.reload();
|
||||
const oauthProviders = storage.getOAuthProviders();
|
||||
/*
|
||||
FNXC:ProviderAuth 2026-07-07-00:00:
|
||||
FN-7625: enumerate OAuth + API-key providers from the static catalog
|
||||
UNIONED with whatever storage currently reports, so a connected
|
||||
runtime plugin narrowing storage.getOAuthProviders()/getApiKeyProviders()
|
||||
never removes a provider from the list — only per-provider status below
|
||||
may vary with runtime/auth state. See auth-provider-catalog.ts.
|
||||
*/
|
||||
const oauthProviders = unionProviderCatalog(STATIC_OAUTH_PROVIDER_CATALOG, storage.getOAuthProviders());
|
||||
const providers: {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -564,9 +577,13 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
};
|
||||
}));
|
||||
|
||||
// Include API-key-backed providers if supported
|
||||
if (storage.getApiKeyProviders) {
|
||||
const apiKeyProviders = storage.getApiKeyProviders();
|
||||
// Include API-key-backed providers. Presence is the static catalog
|
||||
// unioned with anything storage additionally reports (FN-7625) —
|
||||
// storage.getApiKeyProviders may be absent/narrowed, but the catalog
|
||||
// entries must still surface as present-but-unauthenticated.
|
||||
{
|
||||
const runtimeApiKeyProviders = storage.getApiKeyProviders ? storage.getApiKeyProviders() : [];
|
||||
const apiKeyProviders = unionProviderCatalog(STATIC_API_KEY_PROVIDER_CATALOG, runtimeApiKeyProviders);
|
||||
for (const p of apiKeyProviders) {
|
||||
let keyHint: string | undefined;
|
||||
if (storage.get) {
|
||||
|
||||
Reference in New Issue
Block a user