LLM: keep API key in localStorage only, default to muse-spark-1.3

Secrets must never be baked into the static build. Stop reading
VITE_AI_API_KEY from env — key comes only from localStorage via the
⚙️ config dialog (flow-state:llm). Gateway URL / model may still come
from env as non-secret defaults.

- src/llm/config.js: DEFAULTS model → meta/muse-spark-1.3-contributor
  (cheap, per user request), readEnv no longer touches apiKey,
  getLlmConfig resolves key only from stored.apiKey.
- .env.example: drop VITE_AI_API_KEY=, document browser-only key flow.
- src/main.js: dialog copy now says key is browser-only, never baked.

Build + lint:scenes + tests still green. No VITE_AI_API_KEY string
remains in source.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Dejvino
2026-09-13 20:08:06 +02:00
co-authored by Claude
parent b7c3537fcb
commit 904ae09486
3 changed files with 17 additions and 16 deletions
+4 -4
View File
@@ -1,5 +1,5 @@
# Vercel AI Gateway — copy to .env.local and fill VITE_AI_API_KEY # Vercel AI Gateway (non-secret defaults). The API key is NOT set here —
# Get a token at https://vercel.com/dashboard/ai-gateway # it lives only in the browser's localStorage, entered via the ⚙️ dialog.
# Copy to .env.local to override defaults for gateway / model if needed.
VITE_AI_GATEWAY_URL=https://ai-gateway.vercel.sh/v1 VITE_AI_GATEWAY_URL=https://ai-gateway.vercel.sh/v1
VITE_AI_MODEL=openai/gpt-4o-mini VITE_AI_MODEL=meta/muse-spark-1.3-contributor
VITE_AI_API_KEY=
+11 -10
View File
@@ -1,14 +1,16 @@
// LLM gateway config — localStorage overrides > VITE env > defaults. // LLM gateway config — localStorage for the secret, env only for non-secrets.
// //
// The app is a static Vite site with no server; the gateway credential lives // The app is a static Vite site with no server. The API key is a secret and
// in the browser. Never commit a key — paste it in the panel (→ localStorage) // must never be baked into the build — it lives only in the browser's
// or in an untracked .env.local. // localStorage (→ ⚙️ dialog). Gateway URL and model are non-secret and may
// come from VITE env as defaults with a localStorage override; the key never
// does.
const STORAGE_KEY = 'flow-state:llm'; const STORAGE_KEY = 'flow-state:llm';
const DEFAULTS = { const DEFAULTS = {
gatewayUrl: 'https://ai-gateway.vercel.sh/v1', gatewayUrl: 'https://ai-gateway.vercel.sh/v1',
model: 'openai/gpt-4o-mini', model: 'meta/muse-spark-1.3-contributor',
apiKey: '', apiKey: '',
}; };
@@ -18,10 +20,10 @@ function readEnv() {
return { return {
gatewayUrl: env.VITE_AI_GATEWAY_URL || null, gatewayUrl: env.VITE_AI_GATEWAY_URL || null,
model: env.VITE_AI_MODEL || null, model: env.VITE_AI_MODEL || null,
apiKey: env.VITE_AI_API_KEY || null, // apiKey intentionally NOT read from env — see module comment.
}; };
} catch { } catch {
return { gatewayUrl: null, model: null, apiKey: null }; return { gatewayUrl: null, model: null };
} }
} }
@@ -36,14 +38,13 @@ function readStorage() {
} }
} }
/** Resolved config — localStorage wins, then env, then defaults. */ /** Resolved config — key from localStorage only (never env); URL/model from localStorage > env > defaults. */
export function getLlmConfig() { export function getLlmConfig() {
const env = readEnv(); const env = readEnv();
const stored = readStorage(); const stored = readStorage();
const gatewayUrl = stored.gatewayUrl || env.gatewayUrl || DEFAULTS.gatewayUrl; const gatewayUrl = stored.gatewayUrl || env.gatewayUrl || DEFAULTS.gatewayUrl;
const model = stored.model || env.model || DEFAULTS.model; const model = stored.model || env.model || DEFAULTS.model;
const apiKey = stored.apiKey != null ? stored.apiKey : (env.apiKey != null ? env.apiKey : DEFAULTS.apiKey); const key = typeof stored.apiKey === 'string' ? stored.apiKey.trim() : '';
const key = typeof apiKey === 'string' ? apiKey.trim() : '';
return { return {
gatewayUrl: String(gatewayUrl || DEFAULTS.gatewayUrl).trim() || DEFAULTS.gatewayUrl, gatewayUrl: String(gatewayUrl || DEFAULTS.gatewayUrl).trim() || DEFAULTS.gatewayUrl,
model: String(model || DEFAULTS.model).trim() || DEFAULTS.model, model: String(model || DEFAULTS.model).trim() || DEFAULTS.model,
+2 -2
View File
@@ -902,8 +902,8 @@ function openLlmConfigDialog() {
<div class="hint" style="margin-bottom:10px;">Stored in <code>localStorage</code> (<code>flow-state:llm</code>). Never committed. Leave API key empty to disable.</div> <div class="hint" style="margin-bottom:10px;">Stored in <code>localStorage</code> (<code>flow-state:llm</code>). Never committed. Leave API key empty to disable.</div>
<label class="llm-field"><span>Gateway URL</span><input id="llm-gw" type="text" placeholder="${cfg.defaults.gatewayUrl}" value="${escapeHtml(cfg.stored.gatewayUrl || '')}"></label> <label class="llm-field"><span>Gateway URL</span><input id="llm-gw" type="text" placeholder="${cfg.defaults.gatewayUrl}" value="${escapeHtml(cfg.stored.gatewayUrl || '')}"></label>
<label class="llm-field"><span>Model</span><input id="llm-model" type="text" placeholder="${cfg.defaults.model}" value="${escapeHtml(cfg.stored.model || '')}"></label> <label class="llm-field"><span>Model</span><input id="llm-model" type="text" placeholder="${cfg.defaults.model}" value="${escapeHtml(cfg.stored.model || '')}"></label>
<label class="llm-field"><span>API key</span><input id="llm-key" type="password" autocomplete="off" placeholder="(env or empty)" value="${escapeHtml(cfg.stored.apiKey || '')}"></label> <label class="llm-field"><span>API key</span><input id="llm-key" type="password" autocomplete="off" placeholder="(empty to disable — stored locally)" value="${escapeHtml(cfg.stored.apiKey || '')}"></label>
<div class="hint">Env fallback: <code>VITE_AI_GATEWAY_URL</code> / <code>VITE_AI_MODEL</code> / <code>VITE_AI_API_KEY</code> in <code>.env.local</code>. See <code>.env.example</code>.</div> <div class="hint">Gateway URL / model may also come from <code>.env.local</code> (<code>VITE_AI_GATEWAY_URL</code> / <code>VITE_AI_MODEL</code>); API key is browser-only via this dialog and never baked into the build. See <code>.env.example</code>.</div>
<div class="hint">Budget: ${counts.loadCount}/${counts.perLoad} this load · ${counts.sessionCount}/${counts.perSession} this session. Counters reset on reload / tab close.</div> <div class="hint">Budget: ${counts.loadCount}/${counts.perLoad} this load · ${counts.sessionCount}/${counts.perSession} this session. Counters reset on reload / tab close.</div>
<div class="llm-actions"> <div class="llm-actions">
<button id="llm-cancel">Cancel</button> <button id="llm-cancel">Cancel</button>