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:
gsxdsm
2026-07-07 08:32:58 -07:00
parent 6bf0090a47
commit a6c60e1592
4 changed files with 291 additions and 14 deletions

View 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.

View File

@@ -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", () => {

View 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];
}

View File

@@ -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) {