feat(keys): free-form model-provider keys — pi-ai provider table on /api/keys
Provider-keys interlude (owner spec, 2026-09-19): any pi-compatible
provider's key is manageable, not just the curated set.
GET /api/keys now also returns providers: {id, envVar, label}[] — every
pi-ai provider that takes a plain api-key env var (33 today), derived
from pi-ai's own provider→env-var table. No-drift mechanism: the table
is generated from pi-ai (builtinProviders + findEnvKeys over a
truthy-for-every-var proxy env) and pinned by
tests/auth-provider-keys.test.ts, which re-derives the truth at test
time and asserts EXACT equality — a pi-ai upgrade fails the gate loudly.
Owner-spec exclusions: github-copilot (OAuth), amazon-bedrock (AWS),
google-vertex (ADC), azure-openai-responses (base URL), the cloudflare
pair; anthropic keeps ANTHROPIC_API_KEY only. OPENROUTER_API_KEY
graduates from the curated catalog to the provider table (dedupe — the
UI pins it always-visible); curated stays Deepgram + the Google search
pair. Statuses: curated + present vars + unknown file vars — absent
provider keys never spam rows; the dropdown is discovery.
Write side: PUT/DELETE accept provider env vars (generic truncation
guard — trim, no whitespace, ≥12; family checks stay on the curated
three). Present-in-env provider vars surface as status rows so
env-wins/shadow semantics stay honest.
This commit is contained in:
@@ -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) });
|
||||
|
||||
+16
-8
@@ -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() });
|
||||
}
|
||||
|
||||
+88
-14
@@ -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<string> = 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). */
|
||||
|
||||
+38
-7
@@ -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<string>([...Object.keys(fileVars), ...AUTH_KEYS.map((k) => k.id)]);
|
||||
const managed = new Set<string>([
|
||||
...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<string, string>,
|
||||
env: Record<string, string | undefined>,
|
||||
realEnv: ReadonlySet<string>,
|
||||
/** 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<string> = new Set(),
|
||||
): AuthKeyStatus[] {
|
||||
const ids = new Set<string>([...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);
|
||||
|
||||
+32
-15
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, string>, {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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)", () => {
|
||||
|
||||
Reference in New Issue
Block a user