diff --git a/app/api/keys/[id]/route.ts b/app/api/keys/[id]/route.ts index 7eb5cc1..b3d6a0a 100644 --- a/app/api/keys/[id]/route.ts +++ b/app/api/keys/[id]/route.ts @@ -11,10 +11,11 @@ * * DELETE: gone from the file always; the env copy goes only when it was * the store's own reflection (a real env var stays — env wins). Idempotent - * (absent key = success). Unknown ids are 404 — the curated catalog is - * the writable surface; unknown file vars are preserved, not editable. + * (absent key = success). Writable ids are the CURATED CATALOG ∪ THE + * MODEL-PROVIDER env vars (provider-keys interlude); anything else is 404 + * — unknown file vars are preserved, not editable. */ -import { isKnownAuthKey } from "@/src/core"; +import { isWritableAuthKey } from "@/src/server"; import { deleteKey, saveKey } from "@/src/server"; export const dynamic = "force-dynamic"; @@ -23,7 +24,7 @@ type Params = { params: Promise<{ id: string }> }; export async function PUT(req: Request, { params }: Params) { const { id } = await params; - if (!isKnownAuthKey(id)) { + if (!isWritableAuthKey(id)) { return Response.json({ error: `unknown key "${id}"` }, { status: 404 }); } let body: unknown; @@ -42,7 +43,7 @@ export async function PUT(req: Request, { params }: Params) { export async function DELETE(_req: Request, { params }: Params) { const { id } = await params; - if (!isKnownAuthKey(id)) { + if (!isWritableAuthKey(id)) { return Response.json({ error: `unknown key "${id}"` }, { status: 404 }); } return Response.json({ status: deleteKey(id) }); diff --git a/app/api/keys/route.ts b/app/api/keys/route.ts index 454526b..9446d47 100644 --- a/app/api/keys/route.ts +++ b/app/api/keys/route.ts @@ -1,17 +1,25 @@ /** - * GET /api/keys — the auth page's key list (auth-keys wave, 2026-09-19). + * GET /api/keys — the auth page's key list (auth-keys wave, 2026-09-19; + * providers added by the provider-keys interlude same wave). * * Every curated key (present or not) plus unknown file vars surfaced as - * generic rows. Masked only — last 4 + length; PLAINTEXT NEVER LEAVES THE - * STORE THROUGH THIS API (owner decision). `source` tells the page whether - * the live value comes from the environment (dev .env.local / launch env) - * or the file, and `shadowedByEnv` explains why a saved key is not the - * live one (env always wins). + * generic rows — ABSENT provider keys deliberately do NOT spam status + * rows; the provider dropdown is the discovery surface. `providers` + * carries it: {id, envVar, label} for every pi-ai plain-key provider, + * derived from pi-ai's own provider→env-var table (no-drift pin in + * tests/auth-provider-keys.test.ts) — the settings UI reads it optionally + * with a graceful fallback. + * + * Masked only — last 4 + length; PLAINTEXT NEVER LEAVES THE STORE THROUGH + * THIS API (owner decision). `source` tells the page whether the live + * value comes from the environment (dev .env.local / launch env) or the + * file, and `shadowedByEnv` explains why a saved key is not the live one + * (env always wins). */ -import { keyStatuses } from "@/src/server"; +import { keyStatuses, providerKeyList } from "@/src/server"; export const dynamic = "force-dynamic"; export async function GET() { - return Response.json({ keys: keyStatuses() }); + return Response.json({ keys: keyStatuses(), providers: providerKeyList() }); } diff --git a/src/core/auth-keys.ts b/src/core/auth-keys.ts index afe3fd9..84ed016 100644 --- a/src/core/auth-keys.ts +++ b/src/core/auth-keys.ts @@ -30,12 +30,6 @@ export interface AuthKeyInfo { } export const AUTH_KEYS: readonly AuthKeyInfo[] = [ - { - id: "OPENROUTER_API_KEY", - label: "OpenRouter", - description: "The main model key — every teacher, prober and sub-agent call goes through it.", - docsUrl: "https://openrouter.ai/settings/keys", - }, { id: "DEEPGRAM_API_KEY", label: "Deepgram", @@ -62,6 +56,80 @@ export function isKnownAuthKey(id: string): boolean { return AUTH_KEY_IDS.has(id); } +/** One model-provider key row for the settings page's dropdown: which + * pi-ai provider, the env var its key rides in, and the human label + * (pi's own Provider.name). */ +export interface AuthProviderInfo { + id: string; + envVar: string; + label: string; +} + +/** + * THE MODEL-PROVIDER KEY TABLE (provider-keys interlude, 2026-09-19): + * every pi-ai provider that takes a PLAIN api-key env var — derived from + * pi-ai's own provider→env-var table at generation time, so it can never + * drift from what pi actually reads: tests/auth-provider-keys.test.ts + * re-derives the truth from pi-ai (builtinProviders + findEnvKeys via a + * truthy-for-every-var proxy env) and asserts EXACT equality with this + * table — a pi-ai upgrade that adds/renames a provider key fails the gate + * loudly instead of drifting silently. + * + * EXCLUDED by owner spec — providers whose auth is not a plain key this + * wave: github-copilot (OAuth), amazon-bedrock (AWS creds), google-vertex + * (ADC), azure-openai-responses (needs a base URL), the cloudflare + * account-scoped pair. Anthropic keeps ANTHROPIC_API_KEY only — its + * AUTH_TOKEN/OAUTH_TOKEN alternates stay env-only. openai-codex exposes + * no env-key (naturally absent). Providers sharing one env var (moonshotai + * / moonshotai-cn, opencode / opencode-go, the qwen-token-plan pair, + * xiaomi variants) each keep their own row — the UI groups as it wishes. + */ +export const AUTH_PROVIDER_KEYS: readonly AuthProviderInfo[] = [ + { id: "ant-ling", envVar: "ANT_LING_API_KEY", label: "Ant Ling" }, + { id: "anthropic", envVar: "ANTHROPIC_API_KEY", label: "Anthropic" }, + { id: "baseten", envVar: "BASETEN_API_KEY", label: "Baseten" }, + { id: "cerebras", envVar: "CEREBRAS_API_KEY", label: "Cerebras" }, + { id: "deepseek", envVar: "DEEPSEEK_API_KEY", label: "DeepSeek" }, + { id: "fireworks", envVar: "FIREWORKS_API_KEY", label: "Fireworks" }, + { id: "google", envVar: "GEMINI_API_KEY", label: "Google" }, + { id: "groq", envVar: "GROQ_API_KEY", label: "Groq" }, + { id: "huggingface", envVar: "HF_TOKEN", label: "Hugging Face" }, + { id: "kimi-coding", envVar: "KIMI_API_KEY", label: "Kimi For Coding" }, + { id: "minimax", envVar: "MINIMAX_API_KEY", label: "MiniMax" }, + { id: "minimax-cn", envVar: "MINIMAX_CN_API_KEY", label: "MiniMax CN" }, + { id: "mistral", envVar: "MISTRAL_API_KEY", label: "Mistral" }, + { id: "moonshotai", envVar: "MOONSHOT_API_KEY", label: "Moonshot AI" }, + { id: "moonshotai-cn", envVar: "MOONSHOT_API_KEY", label: "Moonshot AI CN" }, + { id: "nvidia", envVar: "NVIDIA_API_KEY", label: "NVIDIA" }, + { id: "openai", envVar: "OPENAI_API_KEY", label: "OpenAI" }, + { id: "opencode", envVar: "OPENCODE_API_KEY", label: "OpenCode Zen" }, + { id: "opencode-go", envVar: "OPENCODE_API_KEY", label: "OpenCode Go" }, + { id: "openrouter", envVar: "OPENROUTER_API_KEY", label: "OpenRouter" }, + { id: "qwen-token-plan", envVar: "QWEN_TOKEN_PLAN_API_KEY", label: "Qwen Token Plan" }, + { id: "qwen-token-plan-cn", envVar: "QWEN_TOKEN_PLAN_CN_API_KEY", label: "Qwen Token Plan CN" }, + { id: "qwen-token-plan-individual", envVar: "QWEN_TOKEN_PLAN_API_KEY", label: "Qwen Token Plan Individual" }, + { id: "radius", envVar: "RADIUS_API_KEY", label: "Radius" }, + { id: "together", envVar: "TOGETHER_API_KEY", label: "Together" }, + { id: "vercel-ai-gateway", envVar: "AI_GATEWAY_API_KEY", label: "Vercel AI Gateway" }, + { id: "xai", envVar: "XAI_API_KEY", label: "xAI" }, + { id: "xiaomi", envVar: "XIAOMI_API_KEY", label: "Xiaomi" }, + { id: "xiaomi-token-plan-ams", envVar: "XIAOMI_TOKEN_PLAN_AMS_API_KEY", label: "Xiaomi Token Plan AMS" }, + { id: "xiaomi-token-plan-cn", envVar: "XIAOMI_TOKEN_PLAN_CN_API_KEY", label: "Xiaomi Token Plan CN" }, + { id: "xiaomi-token-plan-sgp", envVar: "XIAOMI_TOKEN_PLAN_SGP_API_KEY", label: "Xiaomi Token Plan SGP" }, + { id: "zai", envVar: "ZAI_API_KEY", label: "Z.AI" }, + { id: "zai-coding-cn", envVar: "ZAI_CODING_CN_API_KEY", label: "Z.AI Coding CN" }, +] as const; + +/** Every distinct env var a model-provider key rides in (the write side's + * writability check — see isProviderEnvVar below; also drives which + * PRESENT provider vars surface as status rows). */ +export const AUTH_PROVIDER_ENV_VARS: ReadonlySet = new Set(AUTH_PROVIDER_KEYS.map((p) => p.envVar)); + +/** Is this env-var name the key of SOME pi-ai model provider? */ +export function isProviderEnvVar(envVar: string): boolean { + return AUTH_PROVIDER_ENV_VARS.has(envVar); +} + /** Shape-check result: ok, or a 400-ready message naming the expectation. */ export type KeyShapeResult = { ok: true } | { ok: false; error: string }; @@ -70,10 +138,12 @@ export type KeyShapeResult = { ok: true } | { ok: false; error: string }; * a truncated or wrong-family key can never land in the store). Values are * TRIMMED first (paste hygiene); empty-after-trim always fails. * - * Families: OpenRouter keys are `sk-or-…`; Google API keys are `AIza…`; - * Programmable Search Engine IDs are either the legacy `digits:digits` - * form or a plain alphanumeric token; Deepgram publishes no key format, so - * the check is a truncation guard (non-empty, ≥ 20 chars, no whitespace). + * CURATED keys carry family rules (Google AIza…, the CSE forms, + * Deepgram's length floor — a truncation guard; it publishes no format). + * MODEL-PROVIDER keys use genericProviderKeyGuard below instead (families + * vary by provider — only paste hygiene applies; owner spec: min length + * ~12, no whitespace). OPENROUTER_API_KEY moved to the generic guard with + * the provider-keys interlude — no sk-or family check anymore. */ export function validateKeyShape(id: string, rawValue: string): KeyShapeResult { const value = rawValue.trim(); @@ -82,10 +152,6 @@ export function validateKeyShape(id: string, rawValue: string): KeyShapeResult { ? { ok: true as const } : { ok: false as const, error: `${id} must be at least ${min} characters with no whitespace` }; switch (id) { - case "OPENROUTER_API_KEY": - return value.startsWith("sk-or-") - ? nonEmpty(12) - : { ok: false, error: `${id} must start with "sk-or-" (OpenRouter's key format)` }; case "GOOGLE_SEARCH_API_KEY": return value.startsWith("AIza") ? nonEmpty(10) @@ -104,6 +170,14 @@ export function validateKeyShape(id: string, rawValue: string): KeyShapeResult { } } +/** The generic truncation guard for MODEL-PROVIDER keys (families vary — + * only paste hygiene applies; owner spec: trim, no whitespace, min 12). */ +export function genericProviderKeyGuard(id: string, rawValue: string): KeyShapeResult { + const value = rawValue.trim(); + if (value.length >= 12 && !/\s/.test(value)) return { ok: true }; + return { ok: false, error: `${id} must be at least 12 characters with no whitespace` }; +} + /** A masked key never leaves the server as plaintext (owner decision: * last 4 chars + length — enough to identify which key is stored after * rotating between several, and to spot a mis-pasted family). */ diff --git a/src/server/keys.ts b/src/server/keys.ts index 87b9126..35cceb0 100644 --- a/src/server/keys.ts +++ b/src/server/keys.ts @@ -28,9 +28,13 @@ import { AUTH_KEYS, + AUTH_PROVIDER_KEYS, + AUTH_PROVIDER_ENV_VARS, type AuthKeyInfo, type AuthKeyStatus, + genericProviderKeyGuard, isKnownAuthKey, + isProviderEnvVar, maskKey, validateKeyShape, } from "../core/auth-keys.ts"; @@ -54,6 +58,20 @@ function boot(): AuthBoot { return g[BOOT_KEY]; } +/** Is `id` a WRITABLE key name — curated catalog or model-provider env + * var (provider-keys interlude: PUT/DELETE accept both). */ +export function isWritableAuthKey(id: string): boolean { + return isKnownAuthKey(id) || isProviderEnvVar(id); +} + +/** The settings page's provider dropdown: every pi-ai provider that takes + * a plain key ({id, envVar, label} — no-drift-pinned, see the table's doc + * in core/auth-keys.ts and tests/auth-provider-keys.test.ts). Sorted by + * id for a stable payload. */ +export function providerKeyList() { + return [...AUTH_PROVIDER_KEYS].sort((a, b) => a.id.localeCompare(b.id)); +} + /** * The boot fill-gap — idempotent. First call snapshots which managed names * are real env; later calls (HMR re-import, or after a hand-edit) only fill @@ -63,7 +81,14 @@ function boot(): AuthBoot { export function initAuthEnv(): void { const record = boot(); const fileVars = authStore.authVars(); - const managed = new Set([...Object.keys(fileVars), ...AUTH_KEYS.map((k) => k.id)]); + const managed = new Set([ + ...Object.keys(fileVars), + ...AUTH_KEYS.map((k) => k.id), + // Provider env vars are managed names too (provider-keys interlude): + // an env-exported OPENAI_API_KEY with a file copy gets honest shadow + // semantics, exactly like the curated vars. + ...AUTH_PROVIDER_KEYS.map((p) => p.envVar), + ]); if (record.realEnv.size === 0 && record.fillGap.size === 0) { // First init in this process: what's already in the env is REAL env. for (const name of managed) { @@ -79,11 +104,10 @@ export function initAuthEnv(): void { } } -/** The managed name-space: catalog keys + whatever the file holds (unknown - * vars are managed too — preserved, surfaced, env-reflected — just not - * editable through the curated API). */ +/** Is this env-var name one the keys system manages (curated, provider, + * or held in the file)? */ function isManagedName(name: string): boolean { - return isKnownAuthKey(name) || name in authStore.authVars(); + return isWritableAuthKey(name) || name in authStore.authVars(); } /** @@ -94,8 +118,15 @@ export function resolveKeyStatuses( fileVars: Record, env: Record, realEnv: ReadonlySet, + /** The model-provider env vars (provider-keys interlude): PRESENT ones + * join the status rows — absent ones never spam (the dropdown is the + * discovery surface). Curated keys always get rows, present or not. */ + providerEnvVars: ReadonlySet = new Set(), ): AuthKeyStatus[] { const ids = new Set([...AUTH_KEYS.map((k) => k.id), ...Object.keys(fileVars)]); + for (const envVar of providerEnvVars) { + if (env[envVar] !== undefined || fileVars[envVar] !== undefined) ids.add(envVar); + } const rows: AuthKeyStatus[] = []; for (const id of ids) { const envValue = env[id]; @@ -119,7 +150,7 @@ export function resolveKeyStatuses( /** The GET view: every curated key (present or not) + unknown file vars. */ export function keyStatuses(): AuthKeyStatus[] { - return resolveKeyStatuses(authStore.authVars(), process.env, boot().realEnv); + return resolveKeyStatuses(authStore.authVars(), process.env, boot().realEnv, AUTH_PROVIDER_ENV_VARS); } /** The live value for one var, env-first (the env-wins rule) — the canonical @@ -137,7 +168,7 @@ export function keyValue(id: string): string | undefined { * reports shadowedByEnv so the page can explain it). */ export function saveKey(id: string, value: string): { ok: true; status: AuthKeyStatus } | { ok: false; error: string } { - const shape = validateKeyShape(id, value); + const shape = isProviderEnvVar(id) ? genericProviderKeyGuard(id, value) : validateKeyShape(id, value); if (!shape.ok) return { ok: false, error: shape.error }; const trimmed = value.trim(); authStore.setAuthVar(id, trimmed); diff --git a/tests/auth-keys.test.ts b/tests/auth-keys.test.ts index 0f4cf84..304fc99 100644 --- a/tests/auth-keys.test.ts +++ b/tests/auth-keys.test.ts @@ -8,38 +8,35 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { AUTH_KEYS, isKnownAuthKey, maskKey, validateKeyShape } from "../src/core/auth-keys.ts"; +import { AUTH_KEYS, genericProviderKeyGuard, isKnownAuthKey, maskKey, validateKeyShape } from "../src/core/auth-keys.ts"; describe("the catalog", () => { - it("is exactly the four curated keys, ids = env-var names", () => { + it("is exactly the three CURATED app-service keys (provider keys live in the provider table)", () => { assert.deepEqual( AUTH_KEYS.map((k) => k.id), - ["OPENROUTER_API_KEY", "DEEPGRAM_API_KEY", "GOOGLE_SEARCH_API_KEY", "GOOGLE_CSE_ID"], + ["DEEPGRAM_API_KEY", "GOOGLE_SEARCH_API_KEY", "GOOGLE_CSE_ID"], ); for (const k of AUTH_KEYS) { assert.ok(k.label.length > 0, `${k.id} has a label`); assert.ok(k.description.length > 0, `${k.id} has a description`); assert.ok(k.docsUrl.startsWith("https://"), `${k.id} has a docs link`); } + // OPENROUTER_API_KEY graduated to the provider table (owner decision, + // provider-keys interlude) — not curated anymore. + assert.ok(!isKnownAuthKey("OPENROUTER_API_KEY")); }); it("isKnownAuthKey: catalog yes, everything else no", () => { - assert.ok(isKnownAuthKey("OPENROUTER_API_KEY")); + assert.ok(isKnownAuthKey("DEEPGRAM_API_KEY")); assert.ok(isKnownAuthKey("GOOGLE_CSE_ID")); - assert.ok(!isKnownAuthKey("OPENROUTER")); + assert.ok(!isKnownAuthKey("OPENROUTER_API_KEY")); + assert.ok(!isKnownAuthKey("OPENAI_API_KEY"), "provider vars are a separate surface"); assert.ok(!isKnownAuthKey("FUTURE_PLANS_TOKEN")); assert.ok(!isKnownAuthKey("")); }); }); -describe("validateKeyShape (the 400 gate)", () => { - it("OpenRouter: sk-or- prefix required, length floor past it", () => { - assert.deepEqual(validateKeyShape("OPENROUTER_API_KEY", "sk-or-v1-abc123def456ghi789jkl012"), { ok: true }); - assert.ok(!validateKeyShape("OPENROUTER_API_KEY", "sk-proj-abcdefghij").ok, "wrong family (OpenAI paste)"); - assert.ok(!validateKeyShape("OPENROUTER_API_KEY", "sk-or-").ok, "prefix alone is a truncation"); - assert.ok(!validateKeyShape("OPENROUTER_API_KEY", " ").ok, "empty fails"); - }); - +describe("validateKeyShape (the 400 gate — curated keys)", () => { it("Google API key: AIza prefix required", () => { assert.ok(validateKeyShape("GOOGLE_SEARCH_API_KEY", "AIzaSyD-abcdef1234").ok); assert.ok(!validateKeyShape("GOOGLE_SEARCH_API_KEY", "sk-or-v1-abcdefghij").ok, "wrong family"); @@ -61,13 +58,33 @@ describe("validateKeyShape (the 400 gate)", () => { }); it("trims surrounding paste whitespace before checking", () => { - assert.ok(validateKeyShape("OPENROUTER_API_KEY", " sk-or-v1-abc123def456 \n").ok); + assert.ok(validateKeyShape("DEEPGRAM_API_KEY", ` ${"d".repeat(40)} `).ok); }); it("unknown ids are rejected with the supported list", () => { const r = validateKeyShape("SOMETHING_ELSE", "whatever"); assert.ok(!r.ok); - if (!r.ok) assert.match(r.error, /SOMETHING_ELSE.*OPENROUTER_API_KEY/); + if (!r.ok) assert.match(r.error, /SOMETHING_ELSE.*DEEPGRAM_API_KEY/); + }); +}); + +describe("genericProviderKeyGuard (model-provider keys — paste hygiene only)", () => { + it("accepts any ≥12-char whitespace-free value (families vary)", () => { + assert.deepEqual(genericProviderKeyGuard("OPENROUTER_API_KEY", "sk-or-v1-abc123"), { ok: true }); + assert.deepEqual(genericProviderKeyGuard("OPENAI_API_KEY", "whaterver-value-here"), { ok: true }); + assert.deepEqual(genericProviderKeyGuard("HF_TOKEN", "hf_xxxxxxxx9999"), { ok: true }); + }); + + it("rejects truncations and whitespace — the only guards", () => { + const short = genericProviderKeyGuard("ZAI_API_KEY", "short"); + assert.ok(!short.ok); + if (!short.ok) assert.match(short.error, /at least 12/); + assert.ok(!genericProviderKeyGuard("OPENAI_API_KEY", "has whitespace now").ok); + assert.ok(!genericProviderKeyGuard("OPENAI_API_KEY", " ").ok); + }); + + it("trims before checking", () => { + assert.ok(genericProviderKeyGuard("OPENROUTER_API_KEY", " sk-or-v1-xyz999 ").ok); }); }); diff --git a/tests/auth-provider-keys.test.ts b/tests/auth-provider-keys.test.ts new file mode 100644 index 0000000..f4a6a39 --- /dev/null +++ b/tests/auth-provider-keys.test.ts @@ -0,0 +1,92 @@ +/** + * THE NO-DRIFT PIN (provider-keys interlude, 2026-09-19): core/auth-keys.ts + * AUTH_PROVIDER_KEYS claims to be exactly pi-ai's provider→env-var table + * (minus the owner-spec exclusions). This test re-derives the TRUTH from + * pi-ai itself and asserts exact equality — so a pi-ai upgrade that adds, + * renames, or re-labels a provider key FAILS the gate loudly instead of + * the table drifting silently from what pi actually reads. + * + * Derivation (same mechanism that generated the table): pi-ai's exports + * map doesn't expose env-api-keys.js as a subpath, so this test + * deep-imports it by RELATIVE path — tests may; runtime code never does. + * findEnvKeys with a Proxy env that resolves every var name truthy returns + * ALL the env vars a provider reads as API keys. + * + * Owner-spec exclusions (asserted ABSENT): github-copilot (OAuth), + * amazon-bedrock (AWS creds), google-vertex (ADC), azure-openai-responses + * (needs base URL), the cloudflare account-scoped pair; anthropic keeps + * ANTHROPIC_API_KEY only (AUTH_TOKEN/OAUTH_TOKEN stay env-only). + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { AUTH_PROVIDER_KEYS, isProviderEnvVar } from "../src/core/auth-keys.ts"; +import { builtinProviders } from "../node_modules/@earendil-works/pi-ai/dist/providers/all.js"; +import { findEnvKeys } from "../node_modules/@earendil-works/pi-ai/dist/env-api-keys.js"; + +/** pi-ai reads anthropic via AUTH_TOKEN/OAUTH_TOKEN alternates too — those + * stay env-only by owner spec; asserted absent below. */ +const ANTHROPIC_ALTERNATES = ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_OAUTH_TOKEN"]; + +const EXCLUDED_PROVIDERS = new Set([ + "github-copilot", + "amazon-bedrock", + "google-vertex", + "azure-openai-responses", + "cloudflare-workers-ai", + "cloudflare-ai-gateway", +]); + +/** A proxy env where EVERY var name resolves truthy — findEnvKeys then + * returns every env var the provider reads as an API-key source. */ +const truthyEnv = new Proxy({} as Record, { + get: (_t, key) => (typeof key === "string" ? "x" : undefined), + has: () => true, +}); + +/** The truth, derived from pi-ai at test time. */ +function deriveProviders() { + const rows: Array<{ id: string; envVar: string; label: string }> = []; + for (const p of builtinProviders()) { + const keys = findEnvKeys(p.id, truthyEnv); + if (!keys || EXCLUDED_PROVIDERS.has(p.id)) continue; + const first = keys[0]; + if (!first) continue; // unreachable with the truthy proxy — satisfies the checker + const envVar = p.id === "anthropic" ? "ANTHROPIC_API_KEY" : first; + rows.push({ id: p.id, envVar, label: p.name }); + } + rows.sort((a, b) => a.id.localeCompare(b.id)); + return rows; +} + +describe("AUTH_PROVIDER_KEYS — the no-drift pin against pi-ai", () => { + it("is EXACTLY pi-ai's plain-key provider table (id, envVar, label), exclusions applied", () => { + const truth = deriveProviders(); + const mine = [...AUTH_PROVIDER_KEYS].sort((a, b) => a.id.localeCompare(b.id)); + assert.deepEqual( + mine.map((p) => [p.id, p.envVar, p.label]), + truth.map((p) => [p.id, p.envVar, p.label]), + "pi-ai's provider env-key table changed — regenerate AUTH_PROVIDER_KEYS (src/core/auth-keys.ts) from the derivation in this test", + ); + }); + + it("the owner-spec exclusions are absent", () => { + const ids = new Set(AUTH_PROVIDER_KEYS.map((p) => p.id)); + for (const excluded of EXCLUDED_PROVIDERS) assert.ok(!ids.has(excluded), `${excluded} must not be listed`); + for (const p of AUTH_PROVIDER_KEYS) { + assert.ok(!ANTHROPIC_ALTERNATES.includes(p.envVar), `${p.envVar} must not appear`); + } + }); + + it("openrouter IS in the table (it graduated from the curated catalog)", () => { + const row = AUTH_PROVIDER_KEYS.find((p) => p.id === "openrouter"); + assert.ok(row); + assert.equal(row.envVar, "OPENROUTER_API_KEY"); + }); + + it("isProviderEnvVar: exactly the table's distinct env vars", () => { + for (const p of AUTH_PROVIDER_KEYS) assert.ok(isProviderEnvVar(p.envVar), `${p.envVar} is a provider var`); + assert.ok(!isProviderEnvVar("OPENROUTER_API_KEYS"), "typo'd name is not one"); + assert.ok(!isProviderEnvVar("DEEPGRAM_API_KEY"), "curated ≠ provider — separate surfaces"); + }); +}); diff --git a/tests/server-keys.test.ts b/tests/server-keys.test.ts index ad4f480..8b683e8 100644 --- a/tests/server-keys.test.ts +++ b/tests/server-keys.test.ts @@ -26,6 +26,7 @@ for (const varName of [ "DEEPGRAM_API_KEY", "GOOGLE_SEARCH_API_KEY", "GOOGLE_CSE_ID", + "OPENAI_API_KEY", "FUTURE_PLANS_TOKEN", ]) { delete process.env[varName]; @@ -48,7 +49,7 @@ fs.writeFileSync( process.env.OPENROUTER_API_KEY = "sk-or-v1-envvalue99"; // Import AFTER env setup — module init runs the boot fill-gap + snapshot. -const { deleteKey, initAuthEnv, keyStatuses, keyValue, resolveKeyStatuses, saveKey } = await import( +const { deleteKey, initAuthEnv, keyStatuses, keyValue, resolveKeyStatuses, saveKey, isWritableAuthKey } = await import( "../src/server/keys.ts" ); const { authStore } = await import("../src/store/auth.ts"); @@ -58,6 +59,7 @@ const ENV_OWNED = "sk-or-v1-envvalue99"; after(() => { delete process.env.OPENROUTER_API_KEY; delete process.env.DEEPGRAM_API_KEY; + delete process.env.OPENAI_API_KEY; fs.rmSync(TMP, { recursive: true, force: true }); }); @@ -146,6 +148,49 @@ describe("saveKey / deleteKey (immediate effect, no restart)", () => { initAuthEnv(); assert.equal(process.env.DEEPGRAM_API_KEY, undefined, "file entry gone → no re-fill"); }); + + describe("model-provider keys (provider-keys interlude)", () => { + it("absent provider keys do NOT spam status rows — the dropdown is discovery", () => { + const rows = keyStatuses().map((s) => s.id); + assert.ok(!rows.includes("OPENAI_API_KEY"), "never saved → no row"); + assert.ok(!rows.includes("ZAI_API_KEY")); + }); + + it("save via provider env var: generic guard, reflected immediately", () => { + const value = "sk-throwaway-openai-0123456789"; // never a real secret + const r = saveKey("OPENAI_API_KEY", value); + assert.ok(r.ok); + assert.equal(process.env.OPENAI_API_KEY, value, "env updated immediately"); + assert.equal(authStore.authVars().OPENAI_API_KEY, value, "file updated"); + const row = keyStatuses().find((s) => s.id === "OPENAI_API_KEY")!; + assert.ok(row.present); + assert.equal(row.source, "file", "fill-gap reflection — origin is the store"); + }); + + it("provider truncations/whitespace are 400s (paste hygiene only)", () => { + assert.ok(!saveKey("OPENAI_API_KEY", "short").ok); + assert.ok(!saveKey("OPENAI_API_KEY", "has whitespace now").ok); + // no family check anymore — any ≥12-char token passes + assert.ok(saveKey("OPENROUTER_API_KEY", "not-sk-or-but-12chars").ok); + }); + + it("isWritableAuthKey: curated ∪ provider env vars; unknown stays unwritable", () => { + assert.ok(isWritableAuthKey("DEEPGRAM_API_KEY")); + assert.ok(isWritableAuthKey("OPENROUTER_API_KEY")); + assert.ok(isWritableAuthKey("OPENAI_API_KEY")); + assert.ok(!isWritableAuthKey("FUTURE_PLANS_TOKEN")); + assert.ok(!isWritableAuthKey("ANTHROPIC_AUTH_TOKEN"), "anthropic alternates stay env-only"); + }); + + it("delete of a provider key: env + file both cleared", () => { + deleteKey("OPENAI_API_KEY"); + assert.equal(process.env.OPENAI_API_KEY, undefined); + assert.ok(!("OPENAI_API_KEY" in authStore.authVars())); + // cleanup the openrouter save from the guard test too + deleteKey("OPENROUTER_API_KEY"); + assert.ok(!("OPENROUTER_API_KEY" in authStore.authVars())); + }); + }); }); describe("resolveKeyStatuses (pure projection)", () => {