From 13bed1e3df79ae168dbf830272861b5c128538e2 Mon Sep 17 00:00:00 2001 From: Omkar Bhad <60899563+omkarbhad@users.noreply.github.com> Date: Sat, 9 May 2026 00:53:38 -0400 Subject: [PATCH 01/13] fix(security): stop guests from using global AI keys The chat resolver loaded provider API keys from a global app_settings row (category='ai_provider', user_id IS NULL), which let any guest chat through whichever key the admin had stored. That's a real cost leak. Each *load*ApiKey now consults env vars only. Per-user keys (read by getUserScopedKey) still work; guests with no key now get a clean "API key is not set" 400 from the harness's hasProviderCredentialFor gate instead of silently riding the global key. The encrypted global row in app_settings is now dead data and can be scrubbed at any time. --- src/lib/server/chat/model.ts | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/src/lib/server/chat/model.ts b/src/lib/server/chat/model.ts index 91cfdda..7958c8b 100644 --- a/src/lib/server/chat/model.ts +++ b/src/lib/server/chat/model.ts @@ -2,7 +2,6 @@ import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { createAnthropic } from '@ai-sdk/anthropic'; import { createOpenAI } from '@ai-sdk/openai'; import { getDb } from '$lib/server/db'; -import { settingsManager } from '$lib/server/state-manager'; import type { LanguageModel, ProviderMetadata } from 'ai'; let runtimeOpenRouterApiKey = ''; @@ -14,36 +13,29 @@ export type ChatProvider = 'openrouter' | 'openai' | 'anthropic'; export type ProviderCredentialType = 'api_key' | 'auth_token'; const anthropicOAuthBetaHeader = 'oauth-2025-04-20'; +// Provider keys are NOT loaded from a global DB setting. Each user supplies +// their own key (per-user KV row written via Settings) — guests included. +// Env vars remain only as a developer escape hatch; they are intentionally not +// the production source of truth. + export async function loadOpenRouterApiKey() { - runtimeOpenRouterApiKey = - process.env.OPENROUTER_API_KEY || - (await settingsManager.get(null, 'ai_provider', 'openrouter_api_key', null)) || - ''; + runtimeOpenRouterApiKey = process.env.OPENROUTER_API_KEY || ''; return runtimeOpenRouterApiKey; } export async function loadOpenAiApiKey() { - runtimeOpenAiApiKey = - process.env.OPENAI_API_KEY || - (await settingsManager.get(null, 'ai_provider', 'openai_api_key', null)) || - ''; + runtimeOpenAiApiKey = process.env.OPENAI_API_KEY || ''; return runtimeOpenAiApiKey; } export async function loadAnthropicApiKey() { - runtimeAnthropicApiKey = - process.env.ANTHROPIC_API_KEY || - (await settingsManager.get(null, 'ai_provider', 'anthropic_api_key', null)) || - ''; + runtimeAnthropicApiKey = process.env.ANTHROPIC_API_KEY || ''; return runtimeAnthropicApiKey; } export async function loadAnthropicAuthToken() { runtimeAnthropicAuthToken = - process.env.ANTHROPIC_AUTH_TOKEN || - process.env.ANTHROPIC_OAUTH_TOKEN || - (await settingsManager.get(null, 'ai_provider', 'anthropic_auth_token', null)) || - ''; + process.env.ANTHROPIC_AUTH_TOKEN || process.env.ANTHROPIC_OAUTH_TOKEN || ''; return runtimeAnthropicAuthToken; } From 3206d3531d233588b4623886ba53cff003954b88 Mon Sep 17 00:00:00 2001 From: Omkar Bhad <60899563+omkarbhad@users.noreply.github.com> Date: Sat, 9 May 2026 00:57:05 -0400 Subject: [PATCH 02/13] fix(security): gate purchases, require auth + rate limit on upload/audio Three High-severity findings from the security audit: 1. /api/credits/purchase had no payment verification - db.addCredits ran directly on any signed-in user's POST, letting them mint gems for free. Returns 501 until a real payment provider is wired up. 2. /api/upload had no auth and no rate limit. Anonymous requests could trigger paid vision/PDF processing on 20MB uploads. Now requires a session (validateSessionOrGuest) and runs through uploadLimiter. 3. /api/audio had no auth, no rate limit, and no size cap. Anonymous requests could submit arbitrary audio to paid providers and OOM the worker via base64-encoded buffers. Now: auth + new audioLimiter (10/min) + 10MB size cap before arrayBuffer(). Includes incidental lint cleanup (sort-keys, catch-unknown) in the two endpoint files so the pre-commit hook passes; no behavior change. --- src/lib/server/rate-limit.ts | 162 +++++++++++---------- src/routes/api/audio/+server.ts | 68 ++++++--- src/routes/api/credits/purchase/+server.ts | 53 ++----- src/routes/api/upload/+server.ts | 52 ++++--- 4 files changed, 176 insertions(+), 159 deletions(-) diff --git a/src/lib/server/rate-limit.ts b/src/lib/server/rate-limit.ts index 8f48b5c..77117b0 100644 --- a/src/lib/server/rate-limit.ts +++ b/src/lib/server/rate-limit.ts @@ -3,73 +3,70 @@ import { getLogger } from './logger'; const log = getLogger('rate-limit'); interface RateLimitConfig { - /** Maximum number of requests allowed in the window */ - maxRequests: number; - /** Window size in milliseconds */ - windowMs: number; + /** Maximum number of requests allowed in the window */ + maxRequests: number; + /** Window size in milliseconds */ + windowMs: number; } interface RateLimitResult { - allowed: boolean; - retryAfterMs?: number; + allowed: boolean; + retryAfterMs?: number; } interface SlidingWindowEntry { - timestamps: number[]; + timestamps: number[]; } function createRateLimiter(name: string, config: RateLimitConfig) { - const store = new Map(); - - // Cleanup stale entries every 60s - const cleanupInterval = setInterval( - () => { - const now = Date.now(); - let cleaned = 0; - for (const [key, entry] of store) { - // Remove timestamps outside the window - entry.timestamps = entry.timestamps.filter((t) => now - t < config.windowMs); - if (entry.timestamps.length === 0) { - store.delete(key); - cleaned++; - } - } - if (cleaned > 0) { - log.debug(`Cleaned ${cleaned} stale entries from ${name} limiter`); - } - }, - 60 * 1000 - ); - - // Allow cleanup interval to not prevent process exit - if (cleanupInterval.unref) { - cleanupInterval.unref(); - } - - return { - check(key: string): RateLimitResult { - const now = Date.now(); - let entry = store.get(key); - - if (!entry) { - entry = { timestamps: [] }; - store.set(key, entry); - } - - // Remove timestamps outside the sliding window - entry.timestamps = entry.timestamps.filter((t) => now - t < config.windowMs); - - if (entry.timestamps.length >= config.maxRequests) { - const oldest = entry.timestamps[0]; - const retryAfterMs = oldest + config.windowMs - now; - log.warn(`Rate limit exceeded for ${name}: key=${key}`); - return { allowed: false, retryAfterMs }; - } - - entry.timestamps.push(now); - return { allowed: true }; - } - }; + const store = new Map(); + + // Cleanup stale entries every 60s + const cleanupInterval = setInterval(() => { + const now = Date.now(); + let cleaned = 0; + for (const [key, entry] of store) { + // Remove timestamps outside the window + entry.timestamps = entry.timestamps.filter((t) => now - t < config.windowMs); + if (entry.timestamps.length === 0) { + store.delete(key); + cleaned++; + } + } + if (cleaned > 0) { + log.debug(`Cleaned ${cleaned} stale entries from ${name} limiter`); + } + }, 60 * 1000); + + // Allow cleanup interval to not prevent process exit + if (cleanupInterval.unref) { + cleanupInterval.unref(); + } + + return { + check(key: string): RateLimitResult { + const now = Date.now(); + let entry = store.get(key); + + if (!entry) { + entry = { timestamps: [] }; + store.set(key, entry); + } + + // Remove timestamps outside the sliding window + entry.timestamps = entry.timestamps.filter((t) => now - t < config.windowMs); + + if (entry.timestamps.length >= config.maxRequests) { + const oldest = entry.timestamps[0]; + const retryAfterMs = oldest + config.windowMs - now; + log.warn(`Rate limit exceeded for ${name}: key=${key}`); + return { allowed: false, retryAfterMs }; + } + + entry.timestamps.push(now); + return { allowed: true }; + } + }; } /** 30 requests per minute */ @@ -78,6 +75,13 @@ export const chatLimiter = createRateLimiter('chat', { maxRequests: 30, windowMs /** 20 requests per minute */ export const uploadLimiter = createRateLimiter('upload', { maxRequests: 20, windowMs: 60_000 }); +/** + * Audio transcription is heavier per request (multi-MB upload, paid speech + * model) so it gets a tighter cap than upload. + * + * 10 requests per minute */ +export const audioLimiter = createRateLimiter('audio', { maxRequests: 10, windowMs: 60_000 }); + /** 120 requests per minute */ export const apiLimiter = createRateLimiter('api', { maxRequests: 120, windowMs: 60_000 }); @@ -89,31 +93,31 @@ export const authLimiter = createRateLimiter('auth', { maxRequests: 10, windowMs * Falls back to 'unknown' if no forwarded header is present. */ export function getClientKey(request: Request): string { - const forwarded = request.headers.get('x-forwarded-for'); - if (forwarded) { - // Take the first IP in the chain (original client) - return forwarded.split(',')[0].trim(); - } - return 'unknown'; + const forwarded = request.headers.get('x-forwarded-for'); + if (forwarded) { + // Take the first IP in the chain (original client) + return forwarded.split(',')[0].trim(); + } + return 'unknown'; } /** * Return a 429 Too Many Requests JSON response. */ export function rateLimitResponse(retryAfterMs: number): Response { - const retryAfterSeconds = Math.ceil(retryAfterMs / 1000); - return new Response( - JSON.stringify({ - error: 'Too many requests', - retryAfterMs, - retryAfterSeconds - }), - { - headers: { - 'Content-Type': 'application/json', - 'Retry-After': String(retryAfterSeconds) - }, - status: 429 - } - ); + const retryAfterSeconds = Math.ceil(retryAfterMs / 1000); + return new Response( + JSON.stringify({ + error: 'Too many requests', + retryAfterMs, + retryAfterSeconds + }), + { + headers: { + 'Content-Type': 'application/json', + 'Retry-After': String(retryAfterSeconds) + }, + status: 429 + } + ); } diff --git a/src/routes/api/audio/+server.ts b/src/routes/api/audio/+server.ts index 6304dc3..3f1204b 100644 --- a/src/routes/api/audio/+server.ts +++ b/src/routes/api/audio/+server.ts @@ -6,12 +6,21 @@ import { getDb } from '$lib/server/db'; import { loadOpenRouterApiKey } from '$lib/server/chat/model'; import { settingsManager, stateManager } from '$lib/server/state-manager'; +import { validateSessionOrGuest } from '$lib/server/auth'; +import { audioLimiter, getClientKey, rateLimitResponse } from '$lib/server/rate-limit'; import { json, type RequestHandler } from '@sveltejs/kit'; import dotenv from 'dotenv'; dotenv.config({ path: '.env.local' }); dotenv.config(); +/** + * Cap audio uploads at 10MB. Audio is base64-encoded in memory before being + * forwarded to the speech model — without a cap, a single 100MB upload OOMs + * the worker (the encoded form is ~1.33x the binary size). + */ +const MAX_AUDIO_SIZE = 10 * 1024 * 1024; + const GEMINI_API_KEY = process.env.GEMINI_API_KEY || ''; const DEFAULT_VOICE_MODEL = 'google/gemini-2.0-flash-001'; const GEMINI_FALLBACK_MODEL = 'gemini-2.0-flash-lite'; @@ -40,32 +49,32 @@ async function ensureInternalModelsRegistered() { const db = getDb(); const internalModels = [ { - model_id: 'google/gemini-2.0-flash-001', - model_name: 'Gemini 2.0 Flash (Audio/Vision)', - provider: 'openrouter', category: 'Internal', description: 'Used for audio transcription and image processing', + gems_per_message: 0, is_enabled: true, is_free: false, - gems_per_message: 0, max_tokens: 8192, - tool_support: false, metadata: {}, - sort_order: 900 + model_id: 'google/gemini-2.0-flash-001', + model_name: 'Gemini 2.0 Flash (Audio/Vision)', + provider: 'openrouter', + sort_order: 900, + tool_support: false }, { - model_id: 'gemini-2.0-flash-lite', - model_name: 'Gemini 2.0 Flash Lite (Audio)', - provider: 'google', category: 'Internal', description: 'Used for audio transcription via Gemini API', + gems_per_message: 0, is_enabled: true, is_free: true, - gems_per_message: 0, max_tokens: 2048, - tool_support: false, metadata: {}, - sort_order: 901 + model_id: 'gemini-2.0-flash-lite', + model_name: 'Gemini 2.0 Flash Lite (Audio)', + provider: 'google', + sort_order: 901, + tool_support: false } ]; for (const m of internalModels) { @@ -111,8 +120,8 @@ async function transcribeWithGemini( } const data = await res.json(); return data?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || ''; - } catch (e: any) { - console.error('[Audio API] Gemini exception:', e?.message); + } catch (e: unknown) { + console.error('[Audio API] Gemini exception:', e instanceof Error ? e.message : e); return null; } } @@ -156,13 +165,23 @@ async function transcribeWithOpenRouter( } const data = await res.json(); return data?.choices?.[0]?.message?.content?.trim() || ''; - } catch (e: any) { - console.error('[Audio API] OpenRouter exception:', e?.message); + } catch (e: unknown) { + console.error('[Audio API] OpenRouter exception:', e instanceof Error ? e.message : e); return null; } } export const POST: RequestHandler = async ({ request }) => { + // Auth + rate limit before reading the body so anonymous floods get + // rejected before we pay the multipart parsing / paid-provider costs. + const rl = audioLimiter.check(getClientKey(request)); + if (!rl.allowed) return rateLimitResponse(rl.retryAfterMs ?? 0); + + const user = await validateSessionOrGuest(request).catch(() => null); + if (!user) { + return json({ error: 'Authentication required to transcribe audio.' }, { status: 401 }); + } + // Register internal models in admin panel (fire-and-forget, runs once) ensureInternalModelsRegistered(); try { @@ -173,6 +192,10 @@ export const POST: RequestHandler = async ({ request }) => { return json({ error: 'No audio file provided' }, { status: 400 }); } + if (audioFile.size > MAX_AUDIO_SIZE) { + return json({ error: 'Audio file too large. Max 10MB allowed.' }, { status: 413 }); + } + if (!GEMINI_API_KEY && !(await loadOpenRouterApiKey())) { return json({ error: 'No transcription API key configured' }, { status: 500 }); } @@ -200,13 +223,16 @@ export const POST: RequestHandler = async ({ request }) => { } return json({ text, success: true }); - } catch (e: any) { - console.error('[Audio API] Error:', e?.message || e); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : 'Audio transcription failed'; + console.error('[Audio API] Error:', message); stateManager - .logError(e instanceof Error ? e : new Error(e?.message || 'Audio transcription failed'), { + .logError(e instanceof Error ? e : new Error(message), { metadata: { endpoint: '/api/audio' } }) - .catch(() => {}); - return json({ error: 'Transcription failed', details: e?.message }, { status: 500 }); + .catch(() => { + // Best-effort error logging; swallow secondary failures. + }); + return json({ error: 'Transcription failed', details: message }, { status: 500 }); } }; diff --git a/src/routes/api/credits/purchase/+server.ts b/src/routes/api/credits/purchase/+server.ts index 5bc4e01..0713f27 100644 --- a/src/routes/api/credits/purchase/+server.ts +++ b/src/routes/api/credits/purchase/+server.ts @@ -1,43 +1,20 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { validateSession } from '$lib/server/auth'; -import { getDb } from '$lib/server/db'; -const PLANS: Record = { - starter: { gems: 100, price: 4.99 }, - pro: { gems: 500, price: 19.99 }, - unlimited: { gems: 2000, price: 49.99 } -}; - -export const POST: RequestHandler = async ({ request }) => { - try { - const user = await validateSession(request); - if (!user) return json({ error: 'Unauthorized' }, { status: 401 }); - - const body = await request.json(); - const { plan_id } = body; - - const plan = PLANS[plan_id]; - if (!plan) return json({ error: 'Invalid plan' }, { status: 400 }); - - // TODO: Integrate real payment provider (Stripe, etc.) - // For now, directly add gems to the user's balance - const db = getDb(); - await db.addCredits( - user.id, - plan.gems, - 'purchase', - `Purchased ${plan.gems} gems (${plan_id} plan)` - ); - - const balance = await db.getCreditBalance(user.id); +// Purchases are intentionally disabled until a real payment provider (Stripe, +// Lemon Squeezy, ...) is wired up. The previous handler called db.addCredits +// directly with no payment verification, which let any authenticated user +// mint gems for free. Re-enabling this endpoint requires verifying a charge +// (e.g. a Stripe checkout session id) before crediting the account. +// +// PLANS catalog kept colocated for whoever wires the integration: +// starter: { gems: 100, price: 4.99 } +// pro: { gems: 500, price: 19.99 } +// unlimited: { gems: 2000, price: 49.99 } - return json({ - success: true, - gems_added: plan.gems, - new_balance: balance?.balance ?? plan.gems - }); - } catch (err: any) { - return json({ error: err?.message || 'Purchase failed' }, { status: 500 }); - } +export const POST: RequestHandler = async () => { + return json( + { error: 'Purchases are disabled until payment verification is implemented.' }, + { status: 501 } + ); }; diff --git a/src/routes/api/upload/+server.ts b/src/routes/api/upload/+server.ts index a94ef71..f61324d 100644 --- a/src/routes/api/upload/+server.ts +++ b/src/routes/api/upload/+server.ts @@ -1,5 +1,7 @@ import { storeFile } from '$lib/server/file-store'; import { loadOpenRouterApiKey } from '$lib/server/chat/model'; +import { validateSessionOrGuest } from '$lib/server/auth'; +import { getClientKey, rateLimitResponse, uploadLimiter } from '$lib/server/rate-limit'; import { error, json } from '@sveltejs/kit'; import dotenv from 'dotenv'; import type { RequestHandler } from './$types'; @@ -78,6 +80,14 @@ Be thorough and precise. This description will be used to recreate or reference export const POST: RequestHandler = async ({ request }) => { try { + // Auth + rate limit before reading the body so anonymous floods get + // rejected before we pay the multipart parsing / vision-model costs. + const rl = uploadLimiter.check(getClientKey(request)); + if (!rl.allowed) return rateLimitResponse(rl.retryAfterMs ?? 0); + + const user = await validateSessionOrGuest(request).catch(() => null); + if (!user) return error(401, 'Authentication required to upload files.'); + const formData = await request.formData(); const file = formData.get('file') as File | null; @@ -115,20 +125,20 @@ export const POST: RequestHandler = async ({ request }) => { const storedFile = storeFile({ buffer: Buffer.from(buffer), extractedText: `[Image: ${filename}]\n${description}`, - sessionId, filename, mimeType: mediaType, - originalName: filename + originalName: filename, + sessionId }); return json({ - url: dataUrl, - mediaType, - filename, - type: 'image', extractedText: `[Image: ${filename}]\n${description}`, fileId: storedFile.id, - size: file.size + filename, + mediaType, + size: file.size, + type: 'image', + url: dataUrl }); } @@ -146,20 +156,20 @@ export const POST: RequestHandler = async ({ request }) => { const storedFile = storeFile({ buffer: Buffer.from(text), extractedText, - sessionId, filename, mimeType: mediaType, - originalName: filename + originalName: filename, + sessionId }); return json({ - url: null, - mediaType, - filename, - type: 'document', extractedText, fileId: storedFile.id, - size: file.size + filename, + mediaType, + size: file.size, + type: 'document', + url: null }); } @@ -190,10 +200,10 @@ export const POST: RequestHandler = async ({ request }) => { const storedFile = storeFile({ buffer, extractedText: extractedText || `[PDF: ${filename}]`, - sessionId, filename, mimeType: mediaType, - originalName: filename + originalName: filename, + sessionId }); const summary = @@ -202,14 +212,14 @@ export const POST: RequestHandler = async ({ request }) => { : `[PDF: ${filename}, ${(file.size / 1024).toFixed(1)}KB]`; return json({ - url: null, - mediaType, - filename, - type: 'pdf', extractedText: extractedText ? `${summary}\n\n${extractedText}` : summary, fileId: storedFile.id, + filename, + mediaType, pageCount, - size: file.size + size: file.size, + type: 'pdf', + url: null }); } From 8ae47c60dc1e18b2647f6f8723df030cdc5cffcb Mon Sep 17 00:00:00 2001 From: Omkar Bhad <60899563+omkarbhad@users.noreply.github.com> Date: Sat, 9 May 2026 00:59:17 -0400 Subject: [PATCH 03/13] fix(security): safe returnTo, trust-only-proxy IP, drop wildcard CORS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Medium-severity findings from the security audit: S4 — Open redirect on login. /api/auth/login?returnTo=https://attacker.example would redirect authenticated users (302) and propagate the absolute URL into the magnova-auth handoff. New safeReturnTo() coerces the value to a same-origin relative path, falling back to '/'. getAuthUrl is reworked to always re-anchor on the request origin. S5 — Spoofable rate-limit keys. getClientKey trusted x-forwarded-for unconditionally — any client could send arbitrary IPs to evade rate limits. New priority: cf-connecting-ip → x-real-ip → x-forwarded-for (only if TRUST_PROXY=true) → 'unknown'. .env.example documents the var. S6 — Permissive CORS on chat. /api/chat returned Access-Control-Allow-Origin: * with credentialed cookies. Cross-origin sites could trigger chat actions on behalf of signed-in users. The OPTIONS handler and CORS headers on the stream response are removed; chat is now same-origin only (which matches the actual consumer — the app shell). --- .env.example | 7 +++++ src/lib/server/auth/index.ts | 45 ++++++++++++++++++++-------- src/lib/server/chat/harness/index.ts | 6 +--- src/lib/server/rate-limit.ts | 27 +++++++++++++---- src/routes/api/auth/login/+server.ts | 7 ++++- src/routes/api/chat/+server.ts | 14 +++------ 6 files changed, 72 insertions(+), 34 deletions(-) diff --git a/.env.example b/.env.example index f87fcf0..22856c7 100644 --- a/.env.example +++ b/.env.example @@ -48,3 +48,10 @@ CACHE_DEFAULT_TTL_SECONDS=3600 # State Management STATE_CLEANUP_INTERVAL_HOURS=1 STATE_RETENTION_DAYS=7 + +# Rate-limit IP trust +# Set to 'true' ONLY when the app is fronted by a proxy that overwrites +# x-forwarded-for (Vercel, Cloudflare, nginx with proxy_set_header). When the +# app is reachable directly from the public Internet, leave unset — clients +# can otherwise spoof their rate-limit identity via x-forwarded-for. +# TRUST_PROXY=true diff --git a/src/lib/server/auth/index.ts b/src/lib/server/auth/index.ts index bafb477..46523f9 100644 --- a/src/lib/server/auth/index.ts +++ b/src/lib/server/auth/index.ts @@ -339,24 +339,45 @@ export function clearLocalSessionCookie(secure = false): string { // ── magnova-auth URLs ───────────────────────────────────────────────────── +/** + * Coerce a `returnTo` query parameter to a safe relative path. + * + * Accepts only same-origin URLs. Anything else — absolute external URLs, + * protocol-relative URLs, or malformed values — collapses to `/`. This + * prevents `?returnTo=https://attacker.example` from turning the login flow + * into an open redirect. + * + * Always returns a leading-slash relative path (`pathname + search + hash`). + */ +export function safeReturnTo(value: string | null | undefined, requestUrl: URL): string { + if (!value) return '/'; + try { + const parsed = new URL(value, requestUrl); + if (parsed.origin !== requestUrl.origin) return '/'; + if (!parsed.pathname.startsWith('/')) return '/'; + return `${parsed.pathname}${parsed.search}${parsed.hash}`; + } catch { + return '/'; + } +} + /** * Get the magnova-auth login URL for graphini-branded login page. - * The redirect must be an absolute URL — relative paths resolve against - * auth.magnova.ai, not graphini.magnova.ai. + * + * `returnTo` is treated as a same-origin relative path. The handoff to + * magnova-auth needs an absolute URL (relative paths there resolve against + * auth.magnova.ai, not back to us), so we anchor it on `requestUrl.origin`. + * Callers should pre-validate via `safeReturnTo` before calling. */ export function getAuthUrl(returnTo?: string, requestUrl?: URL): string { const baseUrl = env.MAGNOVA_AUTH_URL || 'https://auth.magnova.ai'; const loginUrl = `${baseUrl}/graphini`; - if (returnTo) { - const absoluteRedirect = - returnTo.startsWith('http://') || returnTo.startsWith('https://') - ? returnTo - : requestUrl - ? `${requestUrl.origin}${returnTo}` - : returnTo; - return `${loginUrl}?redirect=${encodeURIComponent(absoluteRedirect)}`; - } - return loginUrl; + if (!returnTo || !requestUrl) return loginUrl; + // Drop absolute URLs supplied by callers — they're either same-origin + // (in which case we re-anchor on origin) or hostile (drop to '/'). + const safe = safeReturnTo(returnTo, requestUrl); + const absoluteRedirect = `${requestUrl.origin}${safe}`; + return `${loginUrl}?redirect=${encodeURIComponent(absoluteRedirect)}`; } export function getSignoutUrl(redirectTo?: string): string { diff --git a/src/lib/server/chat/harness/index.ts b/src/lib/server/chat/harness/index.ts index d27e78d..7abf91d 100644 --- a/src/lib/server/chat/harness/index.ts +++ b/src/lib/server/chat/harness/index.ts @@ -218,11 +218,7 @@ export async function runChatTurn(request: Request): Promise { }); return result.toUIMessageStreamResponse({ - headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST', - 'Access-Control-Allow-Headers': 'Content-Type' - }, + // No CORS headers — same-origin only. See /api/chat/+server.ts comment. sendReasoning: true, // The AI SDK's default error handler scrubs provider errors to a generic // "An error occurred" string. Surface the real message instead — most diff --git a/src/lib/server/rate-limit.ts b/src/lib/server/rate-limit.ts index 77117b0..ffdb2cf 100644 --- a/src/lib/server/rate-limit.ts +++ b/src/lib/server/rate-limit.ts @@ -89,14 +89,29 @@ export const apiLimiter = createRateLimiter('api', { maxRequests: 120, windowMs: export const authLimiter = createRateLimiter('auth', { maxRequests: 10, windowMs: 60_000 }); /** - * Extract client IP from request headers. - * Falls back to 'unknown' if no forwarded header is present. + * Extract a stable client identifier for rate-limit keying. + * + * Priority order: + * 1. cf-connecting-ip — Cloudflare-set, not client-controllable. + * 2. x-real-ip — set by trusted reverse proxies (Vercel, nginx). + * 3. x-forwarded-for, *only if* TRUST_PROXY=true. Falls through otherwise + * because clients can spoof XFF when the app is reachable directly. + * 4. 'unknown' fallback. Effectively keys all unknown clients into the + * same bucket — strict but safe; legitimate clients are identified by + * one of the headers above when behind a proxy. + * + * Set TRUST_PROXY=true only when the deployment is fronted by a proxy that + * overwrites x-forwarded-for. Do NOT set it when the app is reachable + * directly from the public Internet. */ export function getClientKey(request: Request): string { - const forwarded = request.headers.get('x-forwarded-for'); - if (forwarded) { - // Take the first IP in the chain (original client) - return forwarded.split(',')[0].trim(); + const cf = request.headers.get('cf-connecting-ip')?.trim(); + if (cf) return cf; + const real = request.headers.get('x-real-ip')?.trim(); + if (real) return real; + if (process.env.TRUST_PROXY === 'true') { + const forwarded = request.headers.get('x-forwarded-for'); + if (forwarded) return forwarded.split(',')[0].trim(); } return 'unknown'; } diff --git a/src/routes/api/auth/login/+server.ts b/src/routes/api/auth/login/+server.ts index 73b7793..28245df 100644 --- a/src/routes/api/auth/login/+server.ts +++ b/src/routes/api/auth/login/+server.ts @@ -8,6 +8,7 @@ import { getAuthUrl, getDevBypassEmail, localSessionCookie, + safeReturnTo, validateSession } from '$lib/server/auth'; import { getDb } from '$lib/server/db'; @@ -49,7 +50,11 @@ function withCookies(headers: Record, ...cookies: (string | null * redirect back to the app instead of magnova-auth. */ export const GET: RequestHandler = async ({ request, url }) => { - const returnTo = url.searchParams.get('returnTo') || '/'; + // Coerce returnTo to a same-origin relative path. Without this, + // /api/auth/login?returnTo=https://attacker.example becomes an open + // redirect — both for already-authenticated users (302 below) and for + // unauthenticated users via getAuthUrl()'s redirect param. + const returnTo = safeReturnTo(url.searchParams.get('returnTo'), url); // If dev bypass is active, user is already "logged in" — just go back const user = await validateSession(request); diff --git a/src/routes/api/chat/+server.ts b/src/routes/api/chat/+server.ts index ed38529..5e13fa7 100644 --- a/src/routes/api/chat/+server.ts +++ b/src/routes/api/chat/+server.ts @@ -58,13 +58,7 @@ export const POST: RequestHandler = async ({ request }) => { } }; -export const OPTIONS: RequestHandler = async () => { - return new Response(null, { - status: 200, - headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type' - } - }); -}; +// CORS preflight handler intentionally removed. The chat endpoint is +// only consumed by the same-origin app shell. A wildcard CORS preflight +// combined with credentialed cookies would let cross-origin sites trigger +// chat actions on behalf of signed-in users. From 58d93adc18d5cfa90ce05a7f7290693b1b84b130 Mon Sep 17 00:00:00 2001 From: Omkar Bhad <60899563+omkarbhad@users.noreply.github.com> Date: Sat, 9 May 2026 01:02:03 -0400 Subject: [PATCH 04/13] fix(workspace): unify content validation, sanitize uploaded filenames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S7 — workspace file content validation was only enforced in the chat tool path (fileSystem.ts). The REST routes (POST /api/workspace-files and PATCH /api/workspace-files/[id]) accepted any content for any kind, so a client could store markdown in a .mermaid (renders as raw text) or a Mermaid diagram declaration in a .md (breaks the prose renderer). Extracted the validator to $lib/server/workspace-content-validation.ts and applied it to both REST handlers; the chat tool path now imports the same function. S8 — file-store.ts pasted the user-supplied filename into a tmp path under os.tmpdir(). UUID prefix prevents collisions but doesn't stop weird chars (path separators, control chars, shell metacharacters) from leaking into the filesystem. New sanitizeFilenameSegment strips to [a-zA-Z0-9._-] and caps at 80 chars; UUID prefix stays. Includes incidental sort-keys cleanup in file-store; no behavior change beyond what's described above. --- src/lib/server/chat/tools/fileSystem.ts | 55 +------------ src/lib/server/file-store.ts | 36 ++++++--- .../server/workspace-content-validation.ts | 80 +++++++++++++++++++ src/routes/api/workspace-files/+server.ts | 8 ++ .../api/workspace-files/[id]/+server.ts | 27 ++++++- 5 files changed, 139 insertions(+), 67 deletions(-) create mode 100644 src/lib/server/workspace-content-validation.ts diff --git a/src/lib/server/chat/tools/fileSystem.ts b/src/lib/server/chat/tools/fileSystem.ts index 4933d91..52693b3 100644 --- a/src/lib/server/chat/tools/fileSystem.ts +++ b/src/lib/server/chat/tools/fileSystem.ts @@ -33,11 +33,8 @@ import { getDb } from '$lib/server/db'; import type { NeonAdapter } from '$lib/server/db/neon-adapter'; import { workspaceFiles, users as usersTable } from '$lib/server/db/schema'; import { PATH_RE, FOLDER_RE, deriveKind, type FileKind } from '$lib/server/workspace-paths'; -import { - MERMAID_DIAGRAM_DECLARATION, - findMermaidDeclarations, - validateSingleMermaidDocument -} from '$lib/server/chat/mermaid'; +import { MERMAID_DIAGRAM_DECLARATION, findMermaidDeclarations } from '$lib/server/chat/mermaid'; +import { validateContentForKind } from '$lib/server/workspace-content-validation'; import type { FileSystemTurnGuard, ToolContext } from './context'; const drizzleDb = () => (getDb() as NeonAdapter).db; @@ -45,7 +42,6 @@ const drizzleDb = () => (getDb() as NeonAdapter).db; const GUEST_QUOTA = 15; const USER_QUOTA = 30; -const MARKDOWN_SIGNALS = /^(#{1,6}\s|\*\*|__|\[.*\]\(.*\)|^>\s|^-{3,}$|^\*{3,}$|^```)/m; const MERMAID_EDGE_PATTERN = /(<-->|<-\.->|<==>|<---|-->|-\.->|==>|---|\.\.>|--x|--o)/; async function userQuota(userId: string): Promise { @@ -68,53 +64,6 @@ function countMermaidEdgeLines(source: string): number { }).length; } -function validateContentForKind( - kind: FileKind, - content: string -): { ok: true } | { ok: false; error: string; hint?: string } { - if (kind === 'mermaid') { - const trimmed = content.trim(); - if (!trimmed) return { ok: true }; - if (MARKDOWN_SIGNALS.test(trimmed)) { - return { - ok: false, - error: - 'REJECTED: .mermaid content contains markdown formatting. Save markdown to a .md file instead.', - hint: 'Mermaid files only accept diagram syntax (graph/flowchart/sequenceDiagram/...).' - }; - } - const v = validateSingleMermaidDocument(trimmed); - if (!v.valid) return { ok: false, error: v.error ?? 'Invalid Mermaid', hint: v.hint }; - return { ok: true }; - } - if (kind === 'md') { - const trimmed = content.trim(); - if (!trimmed) return { ok: true }; - if (MERMAID_DIAGRAM_DECLARATION.test(trimmed)) { - return { - ok: false, - error: 'REJECTED: .md content starts with a Mermaid diagram declaration.', - hint: 'Save diagram code to a .mermaid file. Use .md for prose only.' - }; - } - return { ok: true }; - } - if (kind === 'json') { - if (!content.trim()) return { ok: true }; - try { - JSON.parse(content); - return { ok: true }; - } catch (err) { - return { - ok: false, - error: `REJECTED: .json failed to parse: ${err instanceof Error ? err.message : String(err)}` - }; - } - } - // yaml: no bundled parser, accept as-is. - return { ok: true }; -} - function applyLinePatch( current: string, startLine: number, diff --git a/src/lib/server/file-store.ts b/src/lib/server/file-store.ts index 3693936..466dd90 100644 --- a/src/lib/server/file-store.ts +++ b/src/lib/server/file-store.ts @@ -32,6 +32,18 @@ export interface StoredFile { const fileStore = new Map(); +/** + * Strip anything that could be interpreted as a path separator, control char, + * or shell metacharacter from a user-supplied filename before joining it onto + * UPLOAD_DIR. The UUID prefix already guarantees uniqueness; this just makes + * sure the suffix can't escape the directory or break tooling that later + * reads the path back. 80-char cap keeps temp filenames bounded. + */ +function sanitizeFilenameSegment(name: string): string { + const cleaned = name.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 80); + return cleaned || 'file'; +} + export function storeFile(data: { sessionId: string; filename: string; @@ -41,22 +53,22 @@ export function storeFile(data: { extractedText?: string; }): StoredFile { const id = crypto.randomUUID(); - const filePath = path.join(UPLOAD_DIR, `${id}-${data.filename}`); + const filePath = path.join(UPLOAD_DIR, `${id}-${sanitizeFilenameSegment(data.filename)}`); fs.writeFileSync(filePath, data.buffer); const stored: StoredFile = { - id, - sessionId: data.sessionId, + createdAt: Date.now(), + extractedText: data.extractedText, filename: data.filename, - originalName: data.originalName, - mimeType: data.mimeType, + id, mediaType: data.mimeType, - type: data.mimeType.split('/')[0] || 'unknown', - size: data.buffer.length, + mimeType: data.mimeType, + originalName: data.originalName, path: filePath, - extractedText: data.extractedText, - createdAt: Date.now(), - storedAt: Date.now() + sessionId: data.sessionId, + size: data.buffer.length, + storedAt: Date.now(), + type: data.mimeType.split('/')[0] || 'unknown' }; fileStore.set(id, stored); @@ -76,7 +88,9 @@ export function deleteFile(id: string): boolean { if (!file) return false; try { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); - } catch { /* ignore */ } + } catch { + /* ignore */ + } fileStore.delete(id); return true; } diff --git a/src/lib/server/workspace-content-validation.ts b/src/lib/server/workspace-content-validation.ts new file mode 100644 index 0000000..b62cae6 --- /dev/null +++ b/src/lib/server/workspace-content-validation.ts @@ -0,0 +1,80 @@ +/** + * Shared content validator for workspace files. + * + * Used by: + * - the chat tool path (src/lib/server/chat/tools/fileSystem.ts) where + * the model creates / patches files via tool calls + * - the REST API (src/routes/api/workspace-files/*) where the client + * creates / updates files directly + * + * Both paths must enforce the same contract or files saved through one + * surface render incorrectly through the other (e.g. markdown stored as + * .mermaid, raw mermaid stored as .md). + * + * Validation per kind: + * .mermaid rejects markdown formatting; runs full Mermaid parse. + * .md rejects content that starts with a Mermaid diagram + * declaration (looks like a misfiled .mermaid). + * .json must JSON.parse cleanly. + * .yaml/.yml stored as-is (no bundled parser). + * + * Empty content is accepted for every kind so callers can write the file + * and fill it in subsequent updates. + */ + +import { + MERMAID_DIAGRAM_DECLARATION, + validateSingleMermaidDocument +} from '$lib/server/chat/mermaid'; +import type { FileKind } from '$lib/server/workspace-paths'; + +const MARKDOWN_SIGNALS = /^(#{1,6}\s|\*\*|__|\[.*\]\(.*\)|^>\s|^-{3,}$|^\*{3,}$|^```)/m; + +export type WorkspaceContentValidation = { ok: true } | { ok: false; error: string; hint?: string }; + +export function validateContentForKind( + kind: FileKind, + content: string +): WorkspaceContentValidation { + if (kind === 'mermaid') { + const trimmed = content.trim(); + if (!trimmed) return { ok: true }; + if (MARKDOWN_SIGNALS.test(trimmed)) { + return { + error: + 'REJECTED: .mermaid content contains markdown formatting. Save markdown to a .md file instead.', + hint: 'Mermaid files only accept diagram syntax (graph/flowchart/sequenceDiagram/...).', + ok: false + }; + } + const v = validateSingleMermaidDocument(trimmed); + if (!v.valid) return { error: v.error ?? 'Invalid Mermaid', hint: v.hint, ok: false }; + return { ok: true }; + } + if (kind === 'md') { + const trimmed = content.trim(); + if (!trimmed) return { ok: true }; + if (MERMAID_DIAGRAM_DECLARATION.test(trimmed)) { + return { + error: 'REJECTED: .md content starts with a Mermaid diagram declaration.', + hint: 'Save diagram code to a .mermaid file. Use .md for prose only.', + ok: false + }; + } + return { ok: true }; + } + if (kind === 'json') { + if (!content.trim()) return { ok: true }; + try { + JSON.parse(content); + return { ok: true }; + } catch (err) { + return { + error: `REJECTED: .json failed to parse: ${err instanceof Error ? err.message : String(err)}`, + ok: false + }; + } + } + // yaml: no bundled parser, accept as-is. + return { ok: true }; +} diff --git a/src/routes/api/workspace-files/+server.ts b/src/routes/api/workspace-files/+server.ts index c3358c8..4c39f73 100644 --- a/src/routes/api/workspace-files/+server.ts +++ b/src/routes/api/workspace-files/+server.ts @@ -14,6 +14,7 @@ import type { NeonAdapter } from '$lib/server/db/neon-adapter'; import { workspaceFiles } from '$lib/server/db/schema'; import { apiLimiter, getClientKey, rateLimitResponse } from '$lib/server/rate-limit'; import { PATH_RE, deriveKind } from '$lib/server/workspace-paths'; +import { validateContentForKind } from '$lib/server/workspace-content-validation'; import { json } from '@sveltejs/kit'; import { asc, eq, sql } from 'drizzle-orm'; import { z } from 'zod'; @@ -147,6 +148,13 @@ export const POST: RequestHandler = async ({ request }) => { { status: 400 } ); } + // Same content rules as the chat tool path. Without this guard a client + // could POST markdown into a `.mermaid` (rendering as raw text) or a + // mermaid declaration into `.md` (breaking the prose renderer). + const validation = validateContentForKind(kind, content); + if (!validation.ok) { + return json({ error: validation.error, hint: validation.hint }, { status: 400 }); + } try { const [inserted] = await db diff --git a/src/routes/api/workspace-files/[id]/+server.ts b/src/routes/api/workspace-files/[id]/+server.ts index 1d31b07..7e7f0c0 100644 --- a/src/routes/api/workspace-files/[id]/+server.ts +++ b/src/routes/api/workspace-files/[id]/+server.ts @@ -8,6 +8,7 @@ import type { NeonAdapter } from '$lib/server/db/neon-adapter'; import { workspaceFiles } from '$lib/server/db/schema'; import { apiLimiter, getClientKey, rateLimitResponse } from '$lib/server/rate-limit'; import { PATH_RE, deriveKind } from '$lib/server/workspace-paths'; +import { validateContentForKind } from '$lib/server/workspace-content-validation'; import { json } from '@sveltejs/kit'; import { and, eq } from 'drizzle-orm'; import { z } from 'zod'; @@ -42,22 +43,42 @@ export const PATCH: RequestHandler = async ({ request, params }) => { const update: Record = { updated_at: new Date() }; if (content !== undefined) update.content = content; + let nextKind: ReturnType = null; if (path !== undefined) { if (!PATH_RE.test(path)) { return json({ error: 'Invalid path' }, { status: 400 }); } - const kind = deriveKind(path); - if (!kind) { + nextKind = deriveKind(path); + if (!nextKind) { return json( { error: 'Unsupported file kind. Allowed: .md, .json, .yaml/.yml, .mermaid/.mmd' }, { status: 400 } ); } update.path = path; - update.kind = kind; + update.kind = nextKind; } const db = drizzleDb(); + + // Same content rules as the chat tool path. When only content is changing + // we need the current row's kind to validate against; when path is also + // changing, we use the new kind. Either way: validate before writing. + if (content !== undefined) { + const [current] = await db + .select({ kind: workspaceFiles.kind }) + .from(workspaceFiles) + .where(and(eq(workspaceFiles.id, id), eq(workspaceFiles.user_id, user.id))); + if (!current) return json({ error: 'Not found' }, { status: 404 }); + const effectiveKind = nextKind ?? (current.kind as ReturnType); + if (effectiveKind) { + const validation = validateContentForKind(effectiveKind, content); + if (!validation.ok) { + return json({ error: validation.error, hint: validation.hint }, { status: 400 }); + } + } + } + try { const result = await db .update(workspaceFiles) From f1edea1fca723a475f18ce0f7f8ddbfc9f2935c8 Mon Sep 17 00:00:00 2001 From: Omkar Bhad <60899563+omkarbhad@users.noreply.github.com> Date: Sat, 9 May 2026 01:10:24 -0400 Subject: [PATCH 05/13] feat(provider-keys): add per-request key plumbing primitives Foundation for the local-settings revamp. Two thin helpers: src/lib/server/auth/provider-keys.ts - ProviderKeys type (openrouter, openai, anthropic + anthropicAuthToken, gemini, braveSearch, tavily) - extractProviderKeys(request) reads x-provider-* headers - missingProviderKeyError(field) throws a 400 with a Settings-routable message src/lib/client/util/provider-keys.ts - providerKeyHeaders(aiSettings) builds the x-provider-* header bag from the localStorage-backed AI settings store - Empty/missing values are dropped so the server's missing-key guard fires deterministically AISettings interface gains braveSearchApiKey + tavilyApiKey fields so the search tools can travel through the same per-request channel. No call sites use these yet; subsequent passes (B1+) wire them in. --- src/lib/client/stores/settings.svelte.ts | 5 ++ src/lib/client/util/provider-keys.ts | 54 ++++++++++++++ src/lib/server/auth/provider-keys.ts | 91 ++++++++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 src/lib/client/util/provider-keys.ts create mode 100644 src/lib/server/auth/provider-keys.ts diff --git a/src/lib/client/stores/settings.svelte.ts b/src/lib/client/stores/settings.svelte.ts index 1dc5d48..ab23606 100644 --- a/src/lib/client/stores/settings.svelte.ts +++ b/src/lib/client/stores/settings.svelte.ts @@ -88,6 +88,9 @@ export interface AISettings { openrouterApiKey?: string; kiloApiKey?: string; geminiApiKey?: string; + /** Search-provider keys for the webSearch / iconSearch tools. */ + braveSearchApiKey?: string; + tavilyApiKey?: string; } export const aiSettings = @@ -95,6 +98,7 @@ export const aiSettings = new PersistentSetting('ai_settings', { anthropicApiKey: '', anthropicAuthToken: '', + braveSearchApiKey: '', favoriteModels: ['gpt-4o', 'anthropic/claude-3.5-sonnet', 'gemini-3-flash-preview'], geminiApiKey: '', kiloApiKey: '', @@ -106,6 +110,7 @@ export const aiSettings = provider: 'openai', providerModel: 'gpt-4o', streamResponse: true, + tavilyApiKey: '', temperature: 0.9 }); hmrPreserve('aiSettings', () => aiSettings); diff --git a/src/lib/client/util/provider-keys.ts b/src/lib/client/util/provider-keys.ts new file mode 100644 index 0000000..668c4d7 --- /dev/null +++ b/src/lib/client/util/provider-keys.ts @@ -0,0 +1,54 @@ +/** + * Build the `x-provider-*` header bag the server expects. + * + * Reads keys from the user's localStorage-backed AI settings and emits one + * header per non-empty value. Used by every client call that hits a paid + * provider (chat, audio, upload, model-lab). + * + * Header names mirror src/lib/server/auth/provider-keys.ts. Any change here + * must be matched there. + * + * Why headers (not body fields): + * - Logging middleware can redact by header name without touching JSON. + * - Works uniformly for JSON, multipart, and streaming bodies. + * - Keys never appear in URL query strings or referrer headers. + */ + +interface AiSettingsLike { + openaiApiKey?: string; + anthropicApiKey?: string; + anthropicAuthToken?: string; + openrouterApiKey?: string; + geminiApiKey?: string; + braveSearchApiKey?: string; + tavilyApiKey?: string; +} + +/** + * Build a Record of x-provider-* headers from local AI settings. + * + * Empty / missing keys are omitted entirely so the server's missing-key + * guard fires deterministically; sending empty headers would still register + * as "key present" to a careless reader on the server. + */ +export function providerKeyHeaders( + settings: AiSettingsLike | null | undefined +): Record { + const headers: Record = {}; + if (!settings) return headers; + + const add = (name: string, value: string | undefined) => { + const trimmed = (value ?? '').trim(); + if (trimmed) headers[name] = trimmed; + }; + + add('x-provider-openrouter', settings.openrouterApiKey); + add('x-provider-openai', settings.openaiApiKey); + add('x-provider-anthropic', settings.anthropicApiKey); + add('x-provider-anthropic-auth', settings.anthropicAuthToken); + add('x-provider-gemini', settings.geminiApiKey); + add('x-provider-brave-search', settings.braveSearchApiKey); + add('x-provider-tavily', settings.tavilyApiKey); + + return headers; +} diff --git a/src/lib/server/auth/provider-keys.ts b/src/lib/server/auth/provider-keys.ts new file mode 100644 index 0000000..fd52c34 --- /dev/null +++ b/src/lib/server/auth/provider-keys.ts @@ -0,0 +1,91 @@ +/** + * Per-request provider key plumbing. + * + * The server holds zero AI provider credentials. Each user keeps their own + * keys in browser localStorage and sends them on every request via x-provider-* + * headers. This module reads those headers, returns a typed `ProviderKeys` + * object, and exposes a clean error helper for missing-key cases. + * + * Keys travel as headers (not body fields) so request logging middleware can + * redact them by header name without touching JSON bodies, and so the same + * helper works across REST/JSON, multipart uploads, and streaming responses. + */ + +import { error } from '@sveltejs/kit'; + +export interface ProviderKeys { + openrouter: string; + openai: string; + anthropic: string; + /** + * Magnova/Anthropic OAuth-style token (`sk-ant-oat*` / `sk-ant-oauth*`). + * Takes precedence over `anthropic` when present, since OAuth tokens can be + * issued per-user with narrower scope than a raw API key. + */ + anthropicAuthToken: string; + gemini: string; + braveSearch: string; + tavily: string; +} + +const HEADER_BY_FIELD: Record = { + anthropic: 'x-provider-anthropic', + anthropicAuthToken: 'x-provider-anthropic-auth', + braveSearch: 'x-provider-brave-search', + gemini: 'x-provider-gemini', + openai: 'x-provider-openai', + openrouter: 'x-provider-openrouter', + tavily: 'x-provider-tavily' +}; + +/** Pull every supported `x-provider-*` header off the request. */ +export function extractProviderKeys(request: Request): ProviderKeys { + const read = (name: string) => (request.headers.get(name) ?? '').trim(); + return { + anthropic: read(HEADER_BY_FIELD.anthropic), + anthropicAuthToken: read(HEADER_BY_FIELD.anthropicAuthToken), + braveSearch: read(HEADER_BY_FIELD.braveSearch), + gemini: read(HEADER_BY_FIELD.gemini), + openai: read(HEADER_BY_FIELD.openai), + openrouter: read(HEADER_BY_FIELD.openrouter), + tavily: read(HEADER_BY_FIELD.tavily) + }; +} + +/** Empty object used when an endpoint doesn't take a request (testing, etc). */ +export function emptyProviderKeys(): ProviderKeys { + return { + anthropic: '', + anthropicAuthToken: '', + braveSearch: '', + gemini: '', + openai: '', + openrouter: '', + tavily: '' + }; +} + +/** Friendly label for missing-key error messages. */ +const LABEL_BY_FIELD: Record = { + anthropic: 'Anthropic', + anthropicAuthToken: 'Anthropic OAuth', + braveSearch: 'Brave Search', + gemini: 'Gemini', + openai: 'OpenAI', + openrouter: 'OpenRouter', + tavily: 'Tavily' +}; + +/** + * Throw a SvelteKit 400 carrying a structured error a client can detect and + * route to "open Settings, add the key" UX. The shape stays in the message + * field because SvelteKit's `error()` doesn't carry custom payloads through + * its standard handler, but the prefix is stable. + */ +export function missingProviderKeyError(field: keyof ProviderKeys): never { + const label = LABEL_BY_FIELD[field]; + throw error( + 400, + `${label} API key is not set. Add your key in Settings > Models & Keys, then retry.` + ); +} From 8e421f2f6e347f0b86f7800b96476ea074b6eb4a Mon Sep 17 00:00:00 2001 From: Omkar Bhad <60899563+omkarbhad@users.noreply.github.com> Date: Sat, 9 May 2026 01:14:04 -0400 Subject: [PATCH 06/13] refactor(chat/model): take provider keys from request, no DB/env fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit model.ts is now a pure resolver. Every function takes the credentials it needs as a parameter: resolveChatModelFor(modelId, providerHint, keys) buildOpenRouterChat(modelId, apiKey) exported for subagent tools buildOpenAiChat(modelId, apiKey) buildAnthropicChat(modelId, apiKey, authToken) hasProviderKey(provider, keys) Removed (dead after this pass — replaced by ProviderKeys threading): - load*ApiKey / loadProviderApiKeys - runtime*ApiKey module-level globals - setRuntime*ApiKey setters - getUserScopedKey / getOpenRouterKeyFor / getOpenAiKeyFor / getAnthropicKeyFor - hasProviderCredentialFor / missingProviderCredentialMessage - getAnthropicAuthHeaders / getAnthropicAuthToken / getAnthropicApiKey / getOpenRouterApiKey / getOpenAiApiKey - openrouterFastChat (subagent helper) — subagents now call buildOpenRouterChat directly with their threaded keys - resolveChatModel (deprecated env-only resolver) ToolContext now carries `keys: ProviderKeys`; createDiagramTools takes keys as its sixth argument so subagent tools can see them. The harness reads keys via extractProviderKeys(request) at the chat boundary and threads them through createDiagramTools, buildChatContext (for the summarizer model), and resolveChatModelFor. Cascade errors are intentional in this commit: - tools/diagram*.ts and tools/markdown*.ts still import openrouterFastChat - api/audio/+server.ts and api/upload/+server.ts still call loadOpenRouterApiKey - api/admin/+server.ts still imports setRuntime*ApiKey - lib/server/model-lab.ts still imports the load* family Pass B2 fixes the subagent tools; pass B3 fixes the rest. The chat endpoint itself works end-to-end at this commit because the harness boundary is closed. --- src/lib/server/chat/harness/context-window.ts | 8 +- src/lib/server/chat/harness/index.ts | 28 +- src/lib/server/chat/model.ts | 277 +++++------------- src/lib/server/chat/tools/context.ts | 10 +- src/lib/server/chat/tools/index.ts | 14 +- 5 files changed, 107 insertions(+), 230 deletions(-) diff --git a/src/lib/server/chat/harness/context-window.ts b/src/lib/server/chat/harness/context-window.ts index 90a6e05..f7eab5f 100644 --- a/src/lib/server/chat/harness/context-window.ts +++ b/src/lib/server/chat/harness/context-window.ts @@ -1,7 +1,8 @@ import { getDb } from '$lib/server/db'; import { settingsManager } from '$lib/server/state-manager'; import { generateText } from 'ai'; -import { getChatProviderOptions, resolveChatModel } from '$lib/server/chat/model'; +import { getChatProviderOptions, resolveChatModelFor } from '$lib/server/chat/model'; +import type { ProviderKeys } from '$lib/server/auth/provider-keys'; export const DEFAULT_CONTEXT_WINDOW_TOKENS = 128000; const MIN_RECENT_CONTEXT_TOKENS = 12000; @@ -43,6 +44,7 @@ function transcriptForSummary(messages: { content: string; role: unknown }[]): s async function summarizeOverflowingHistory(options: { fallbackModel: string; messages: { content: string; role: unknown }[]; + keys: ProviderKeys; }): Promise { if (options.messages.length === 0) return ''; @@ -55,7 +57,7 @@ async function summarizeOverflowingHistory(options: { const result = await generateText({ maxOutputTokens: SUMMARY_TARGET_TOKENS, - model: resolveChatModel(summaryModel, providerHint), + model: resolveChatModelFor(summaryModel, providerHint, options.keys), prompt: `Summarize this older conversation history so a later assistant can continue seamlessly.\n\n${transcript}`, providerOptions: getChatProviderOptions(summaryModel, providerHint), system: @@ -83,6 +85,7 @@ export async function buildChatContext( contextWindowTokens: number; fallbackModel: string; systemPromptTokens: number; + keys: ProviderKeys; } ): Promise<{ messages: Record[]; summary: string }> { let history = Array.isArray(uiMessages) ? uiMessages : []; @@ -141,6 +144,7 @@ export async function buildChatContext( try { const summary = await summarizeOverflowingHistory({ fallbackModel: options.fallbackModel, + keys: options.keys, messages: olderMessages }); return { messages: recentMessages, summary }; diff --git a/src/lib/server/chat/harness/index.ts b/src/lib/server/chat/harness/index.ts index 7abf91d..7000c57 100644 --- a/src/lib/server/chat/harness/index.ts +++ b/src/lib/server/chat/harness/index.ts @@ -1,14 +1,9 @@ import { validateSessionOrGuest } from '$lib/server/auth'; +import { extractProviderKeys, missingProviderKeyError } from '$lib/server/auth/provider-keys'; import { getDb } from '$lib/server/db'; import type { NeonAdapter } from '$lib/server/db/neon-adapter'; import { workspaceFiles } from '$lib/server/db/schema'; -import { - hasProviderCredentialFor, - loadProviderApiKeys, - missingProviderCredentialMessage, - normalizeChatModelId, - resolveChatModelFor -} from '$lib/server/chat/model'; +import { hasProviderKey, normalizeChatModelId, resolveChatModelFor } from '$lib/server/chat/model'; import type { ChatProvider } from '$lib/server/chat/model'; import { isToolInventoryRequest } from '$lib/server/chat/tool-gating'; import { createDiagramTools } from '$lib/server/chat/tools'; @@ -130,10 +125,17 @@ export async function runChatTurn(request: Request): Promise { const providerHint = enabledModel.provider || undefined; const normalizedModel = normalizeChatModelId(model, providerHint); const { modelId: actualModelId } = normalizedModel; - await loadProviderApiKeys(); + const keys = extractProviderKeys(request); const normalizedProvider = normalizedModel.provider as ChatProvider; - if (!(await hasProviderCredentialFor(normalizedProvider, userId))) { - throw error(400, missingProviderCredentialMessage(normalizedProvider)); + if (!hasProviderKey(normalizedProvider, keys)) { + // Throws a 400 with a Settings-routable message keyed by provider. + missingProviderKeyError( + normalizedProvider === 'anthropic' + ? 'anthropic' + : normalizedProvider === 'openai' + ? 'openai' + : 'openrouter' + ); } const { activeEngine, workspaceContext } = buildWorkspaceContext(body); @@ -163,7 +165,8 @@ export async function runChatTurn(request: Request): Promise { actualModelId, workspaceContext, userId, - fileSystemGuard + fileSystemGuard, + keys ); // Denylist semantics: any tool the user has explicitly disabled is // filtered out. Tools that are missing from the persisted config (because @@ -194,13 +197,14 @@ export async function runChatTurn(request: Request): Promise { const chatContext = await buildChatContext(uiMessages, message, { contextWindowTokens: contextWindowForModel(enabledModel), fallbackModel: model, + keys, systemPromptTokens: estimateTokens(systemPrompt) }); const system = chatContext.summary ? `${systemPrompt}\n\nCompacted prior chat history:\n${chatContext.summary}` : systemPrompt; - const resolvedModel = await resolveChatModelFor(userId, model, providerHint); + const resolvedModel = resolveChatModelFor(model, providerHint, keys); const result = runChatStream({ abortSignal: request.signal, messages: chatContext.messages, diff --git a/src/lib/server/chat/model.ts b/src/lib/server/chat/model.ts index 7958c8b..42adcf5 100644 --- a/src/lib/server/chat/model.ts +++ b/src/lib/server/chat/model.ts @@ -1,172 +1,35 @@ +/** + * Chat model resolver. + * + * Pure: every function takes the credentials it needs as a parameter. No DB + * lookups, no environment-variable fallbacks, no module-level state. This is + * the contract for the local-settings revamp — provider keys travel from the + * user's localStorage to the server in `x-provider-*` request headers, get + * pulled out via `extractProviderKeys`, and are passed in by the caller. + * + * If a key is missing, `resolveChatModelFor` throws via + * `missingProviderKeyError`, which surfaces a 400 with a Settings-routable + * message. + */ + import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { createAnthropic } from '@ai-sdk/anthropic'; import { createOpenAI } from '@ai-sdk/openai'; -import { getDb } from '$lib/server/db'; +import { missingProviderKeyError, type ProviderKeys } from '$lib/server/auth/provider-keys'; import type { LanguageModel, ProviderMetadata } from 'ai'; -let runtimeOpenRouterApiKey = ''; -let runtimeOpenAiApiKey = ''; -let runtimeAnthropicApiKey = ''; -let runtimeAnthropicAuthToken = ''; - export type ChatProvider = 'openrouter' | 'openai' | 'anthropic'; -export type ProviderCredentialType = 'api_key' | 'auth_token'; -const anthropicOAuthBetaHeader = 'oauth-2025-04-20'; - -// Provider keys are NOT loaded from a global DB setting. Each user supplies -// their own key (per-user KV row written via Settings) — guests included. -// Env vars remain only as a developer escape hatch; they are intentionally not -// the production source of truth. - -export async function loadOpenRouterApiKey() { - runtimeOpenRouterApiKey = process.env.OPENROUTER_API_KEY || ''; - return runtimeOpenRouterApiKey; -} - -export async function loadOpenAiApiKey() { - runtimeOpenAiApiKey = process.env.OPENAI_API_KEY || ''; - return runtimeOpenAiApiKey; -} - -export async function loadAnthropicApiKey() { - runtimeAnthropicApiKey = process.env.ANTHROPIC_API_KEY || ''; - return runtimeAnthropicApiKey; -} - -export async function loadAnthropicAuthToken() { - runtimeAnthropicAuthToken = - process.env.ANTHROPIC_AUTH_TOKEN || process.env.ANTHROPIC_OAUTH_TOKEN || ''; - return runtimeAnthropicAuthToken; -} - -export async function loadProviderApiKeys() { - await Promise.all([ - loadOpenRouterApiKey(), - loadOpenAiApiKey(), - loadAnthropicApiKey(), - loadAnthropicAuthToken() - ]); -} - -/** Per-user API key lookup. Falls back to env/global when the user hasn't set one. */ -async function getUserScopedKey( - userId: string | null, - provider: ChatProvider | 'gemini', - fallback: string -): Promise { - if (!userId) return fallback; - try { - const value = await getDb().kvGet(userId, 'ai_provider', `${provider}_api_key`); - if (typeof value === 'string' && value.trim().length > 0) return value.trim(); - } catch (err) { - console.error(`[chat/model] failed to read per-user ${provider} key:`, err); - } - return fallback; -} - -async function getOpenRouterKeyFor(userId: string | null): Promise { - return getUserScopedKey(userId, 'openrouter', await loadOpenRouterApiKey()); -} -async function getOpenAiKeyFor(userId: string | null): Promise { - return getUserScopedKey(userId, 'openai', await loadOpenAiApiKey()); -} -async function getAnthropicKeyFor(userId: string | null): Promise { - return getUserScopedKey(userId, 'anthropic', await loadAnthropicApiKey()); -} - -export async function hasProviderCredentialFor( - provider: ChatProvider, - userId: string | null -): Promise { - if (provider === 'openrouter') return Boolean(await getOpenRouterKeyFor(userId)); - if (provider === 'openai') return Boolean(await getOpenAiKeyFor(userId)); - // Anthropic: per-user api_key OR global auth_token (OAuth) OR env api_key. - if (await getAnthropicKeyFor(userId)) return true; - return Boolean(await loadAnthropicAuthToken()); -} - -export function missingProviderCredentialMessage(provider: ChatProvider) { - const labelByProvider: Record = { - anthropic: 'Anthropic', - openai: 'OpenAI', - openrouter: 'OpenRouter' - }; - return `${labelByProvider[provider]} API key is not set. Add your key in Settings > Models & Keys.`; -} - -export function setRuntimeOpenRouterApiKey(apiKey: string) { - runtimeOpenRouterApiKey = apiKey.trim(); -} - -export function setRuntimeOpenAiApiKey(apiKey: string) { - runtimeOpenAiApiKey = apiKey.trim(); -} - -export function setRuntimeAnthropicApiKey(apiKey: string) { - runtimeAnthropicApiKey = apiKey.trim(); -} - -export function setRuntimeAnthropicAuthToken(authToken: string) { - runtimeAnthropicAuthToken = authToken.trim(); -} - -function getOpenRouterApiKey() { - const apiKey = process.env.OPENROUTER_API_KEY || runtimeOpenRouterApiKey; - if (!apiKey) { - throw new Error('OPENROUTER_API_KEY is not set. Add it in Settings > Model Access.'); - } - return apiKey; -} - -function getOpenAiApiKey() { - const apiKey = process.env.OPENAI_API_KEY || runtimeOpenAiApiKey; - if (!apiKey) { - throw new Error('OPENAI_API_KEY is not set. Add it in Settings > Model Access.'); - } - return apiKey; -} -function getAnthropicApiKey() { - const apiKey = process.env.ANTHROPIC_API_KEY || runtimeAnthropicApiKey; - if (!apiKey) { - throw new Error('ANTHROPIC_API_KEY is not set. Add it in Settings > Model Access.'); - } - return apiKey; -} - -function getAnthropicAuthToken() { - return ( - process.env.ANTHROPIC_AUTH_TOKEN || - process.env.ANTHROPIC_OAUTH_TOKEN || - runtimeAnthropicAuthToken - ); -} +const ANTHROPIC_OAUTH_BETA_HEADER = 'oauth-2025-04-20'; function isAnthropicOAuthToken(token: string): boolean { return token.startsWith('sk-ant-oat') || token.startsWith('sk-ant-oauth'); } -export function getAnthropicAuthHeaders(): Record { - const authToken = getAnthropicAuthToken(); - if (authToken) { - return { - 'anthropic-beta': anthropicOAuthBetaHeader, - Authorization: `Bearer ${authToken}` - }; - } - - const apiKey = getAnthropicApiKey(); - if (isAnthropicOAuthToken(apiKey)) { - return { - 'anthropic-beta': anthropicOAuthBetaHeader, - Authorization: `Bearer ${apiKey}` - }; - } - - return { 'x-api-key': apiKey }; -} - -export function normalizeChatModelId(modelId: string, providerHint?: string) { +export function normalizeChatModelId( + modelId: string, + providerHint?: string +): { modelId: string; provider: ChatProvider } { const provider = providerHint?.toLowerCase(); if (modelId.startsWith('openrouter/')) { return { modelId: modelId.slice('openrouter/'.length), provider: 'openrouter' }; @@ -192,7 +55,14 @@ export function normalizeChatModelId(modelId: string, providerHint?: string) { return { modelId, provider: 'openrouter' }; } -function buildOpenRouterChat(modelId: string, apiKey: string) { +/** + * Build an OpenRouter language model with an explicit API key. + * + * Exported because subagent tools (diagramPatch, markdownWrite, ...) need to + * construct lightweight chat clients with the same key the originating + * request supplied. They get the key via `ToolContext.keys`. + */ +export function buildOpenRouterChat(modelId: string, apiKey: string): LanguageModel { const openrouter = createOpenRouter({ apiKey, appName: 'Graphini', @@ -201,86 +71,75 @@ function buildOpenRouterChat(modelId: string, apiKey: string) { return openrouter.chat(modelId, { includeReasoning: true, reasoning: { + effort: 'medium', enabled: true, - exclude: false, - effort: 'medium' + exclude: false } }); } -function buildOpenAiChat(modelId: string, apiKey: string) { +export function buildOpenAiChat(modelId: string, apiKey: string): LanguageModel { const openai = createOpenAI({ apiKey }); return openai(modelId); } -function buildAnthropicChat(modelId: string, apiKey: string, authToken: string) { - // Mirror the precedence of getAnthropicAuthHeaders: an OAuth-style token - // wins over an api_key, since OAuth tokens can be issued per-user with - // narrower scope. An api_key string starting with sk-ant-oauth IS an OAuth - // token even when it lives in the api_key slot. +/** + * Build an Anthropic language model. + * + * Auth precedence: + * 1. authToken (the dedicated OAuth slot) wins, since OAuth tokens can be + * issued per-user with narrower scope than a raw api_key. + * 2. apiKey, treated as OAuth if it has the `sk-ant-oauth` / `sk-ant-oat` + * prefix (some flows only have one slot, the token lands there). + * 3. apiKey, treated as a regular API key otherwise. + */ +export function buildAnthropicChat( + modelId: string, + apiKey: string, + authToken: string +): LanguageModel { const explicitToken = authToken || (apiKey && isAnthropicOAuthToken(apiKey) ? apiKey : ''); const anthropic = createAnthropic({ ...(explicitToken ? { authToken: explicitToken } : { apiKey }), - headers: explicitToken ? { 'anthropic-beta': anthropicOAuthBetaHeader } : undefined + headers: explicitToken ? { 'anthropic-beta': ANTHROPIC_OAUTH_BETA_HEADER } : undefined }); return anthropic(modelId); } /** - * Env-only OpenRouter constructor used by subagent tool calls (fileManager, - * markdownWrite, iconSearch, ...). Subagents intentionally use the global key - * rather than per-user keys today; per-user wiring would require threading - * userId through every tool's session context. + * Whether `keys` carries enough credential to use `provider`. Used by the + * harness to fail fast with a Settings-routable 400 before model construction. */ -export function openrouterFastChat(modelId: string) { - return buildOpenRouterChat(modelId, getOpenRouterApiKey()); -} - -function openaiChat(modelId: string) { - return buildOpenAiChat(modelId, getOpenAiApiKey()); -} - -function anthropicChat(modelId: string) { - return buildAnthropicChat(modelId, getAnthropicApiKey(), getAnthropicAuthToken()); +export function hasProviderKey(provider: ChatProvider, keys: ProviderKeys): boolean { + if (provider === 'openrouter') return Boolean(keys.openrouter); + if (provider === 'openai') return Boolean(keys.openai); + // Anthropic accepts either a regular API key or an OAuth token. + return Boolean(keys.anthropic) || Boolean(keys.anthropicAuthToken); } /** - * Per-user model resolution. The user's stored API key (if any) takes - * priority over the environment/global key, which lets guests run their own - * provider account with their own quota and billing. + * Resolve a `LanguageModel` for the given (modelId, providerHint) pair using + * the request's provider keys. Throws `missingProviderKeyError` if the + * required key is empty. */ -export async function resolveChatModelFor( - userId: string | null, +export function resolveChatModelFor( modelId: string, - providerHint?: string -): Promise { + providerHint: string | undefined, + keys: ProviderKeys +): LanguageModel { const normalized = normalizeChatModelId(modelId, providerHint); if (normalized.provider === 'openai') { - const apiKey = await getOpenAiKeyFor(userId); - if (!apiKey) throw new Error('OPENAI_API_KEY is not set. Add it in Settings > Models & Keys.'); - return buildOpenAiChat(normalized.modelId, apiKey); + if (!keys.openai) missingProviderKeyError('openai'); + return buildOpenAiChat(normalized.modelId, keys.openai); } if (normalized.provider === 'anthropic') { - const apiKey = await getAnthropicKeyFor(userId); - const authToken = await loadAnthropicAuthToken(); - if (!apiKey && !authToken) { - throw new Error('ANTHROPIC_API_KEY is not set. Add it in Settings > Models & Keys.'); + if (!keys.anthropic && !keys.anthropicAuthToken) { + missingProviderKeyError('anthropic'); } - return buildAnthropicChat(normalized.modelId, apiKey, authToken); + return buildAnthropicChat(normalized.modelId, keys.anthropic, keys.anthropicAuthToken); } - const apiKey = await getOpenRouterKeyFor(userId); - if (!apiKey) { - throw new Error('OPENROUTER_API_KEY is not set. Add it in Settings > Models & Keys.'); - } - return buildOpenRouterChat(normalized.modelId, apiKey); -} - -/** @deprecated env-only resolver; use resolveChatModelFor with a userId. */ -export function resolveChatModel(modelId: string, providerHint?: string): LanguageModel { - const normalized = normalizeChatModelId(modelId, providerHint); - if (normalized.provider === 'openai') return openaiChat(normalized.modelId); - if (normalized.provider === 'anthropic') return anthropicChat(normalized.modelId); - return openrouterFastChat(normalized.modelId); + if (!keys.openrouter) missingProviderKeyError('openrouter'); + return buildOpenRouterChat(normalized.modelId, keys.openrouter); } function supportsAnthropicAdaptiveThinking(modelId: string): boolean { diff --git a/src/lib/server/chat/tools/context.ts b/src/lib/server/chat/tools/context.ts index c39db5d..477f679 100644 --- a/src/lib/server/chat/tools/context.ts +++ b/src/lib/server/chat/tools/context.ts @@ -3,6 +3,7 @@ import { and, eq } from 'drizzle-orm'; import { getDb } from '$lib/server/db'; import type { NeonAdapter } from '$lib/server/db/neon-adapter'; import { workspaceFiles } from '$lib/server/db/schema'; +import type { ProviderKeys } from '$lib/server/auth/provider-keys'; export interface WorkspaceToolTab { engine: string; @@ -39,10 +40,17 @@ export interface FileSystemTurnGuard { export interface ToolContext { modelId?: string; sessionId: string; - /** Authenticated user (or guest) id. Tools that need per-user keys read this. */ + /** Authenticated user (or guest) id. Tools that need per-user data read this. */ userId?: string; target?: WorkspaceToolTarget; fileSystemGuard?: FileSystemTurnGuard; + /** + * Provider keys carried by the originating chat request (extracted from + * `x-provider-*` headers at the request boundary). Subagent tools that + * make their own LLM / search calls use these so the user's quota — not + * a global key — is consumed. + */ + keys: ProviderKeys; } export const targetTabNameSchema = z diff --git a/src/lib/server/chat/tools/index.ts b/src/lib/server/chat/tools/index.ts index 76f58b1..22e8be7 100644 --- a/src/lib/server/chat/tools/index.ts +++ b/src/lib/server/chat/tools/index.ts @@ -19,16 +19,18 @@ import { createIconSearchTool } from './iconSearch'; import { createStyleSearchTool } from './styleSearch'; import { createThinkingTool } from './thinking'; import { createWebSearchTool } from './webSearch'; -import type { FileSystemTurnGuard, WorkspaceToolTarget } from './context'; +import type { FileSystemTurnGuard, ToolContext, WorkspaceToolTarget } from './context'; +import type { ProviderKeys } from '$lib/server/auth/provider-keys'; export function createDiagramTools( sessionId: string, - modelId?: string, - target?: WorkspaceToolTarget, - userId?: string, - fileSystemGuard?: FileSystemTurnGuard + modelId: string | undefined, + target: WorkspaceToolTarget | undefined, + userId: string | undefined, + fileSystemGuard: FileSystemTurnGuard | undefined, + keys: ProviderKeys ) { - const context = { fileSystemGuard, modelId, sessionId, target, userId }; + const context: ToolContext = { fileSystemGuard, keys, modelId, sessionId, target, userId }; return { askQuestions: createAskQuestionsTool(context), autoStyler: createAutoStylerTool(context), From 82835a68a73b2f530182f03b942aa719caee2fe6 Mon Sep 17 00:00:00 2001 From: Omkar Bhad <60899563+omkarbhad@users.noreply.github.com> Date: Sat, 9 May 2026 01:19:56 -0400 Subject: [PATCH 07/13] refactor(chat/tools): subagents drop dead key imports, search keys flow via ToolContext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The diagram*/markdown* tools each imported openrouterFastChat + generateText from earlier subagent days, but those imports were dead — the tools no longer make their own LLM calls (file-centric refactor in 1598085 moved that work elsewhere). Removed the dead imports. Tools' /* eslint-disable @typescript-eslint/no-unused-vars */ directives stay; other entries in those files are still legitimately unused. webSearch.ts is the one tool that does need provider keys: it calls Tavily and Brave directly. resolveSearchKeyFor (DB-backed) is replaced by resolveSearchKey(keys: ProviderKeys), and webSearch reads keys from ToolContext now. Same shape as model.ts — keys come in by parameter, no DB lookup. Includes incidental sort-keys cleanup so pre-commit lint passes; no behavior change beyond the import/key-source rewires above. This commit completes Pass B2. Pass B3 fixes the remaining cascade in model-lab.ts, /api/audio, /api/upload, /api/admin. --- src/lib/server/chat/search.ts | 47 +++++----------------- src/lib/server/chat/tools/diagramDelete.ts | 3 +- src/lib/server/chat/tools/diagramPatch.ts | 6 +-- src/lib/server/chat/tools/diagramRead.ts | 3 +- src/lib/server/chat/tools/diagramWrite.ts | 5 +-- src/lib/server/chat/tools/markdownRead.ts | 3 +- src/lib/server/chat/tools/markdownWrite.ts | 3 +- src/lib/server/chat/tools/webSearch.ts | 44 ++++++++++---------- 8 files changed, 42 insertions(+), 72 deletions(-) diff --git a/src/lib/server/chat/search.ts b/src/lib/server/chat/search.ts index 39d7f7e..aebaee1 100644 --- a/src/lib/server/chat/search.ts +++ b/src/lib/server/chat/search.ts @@ -1,54 +1,29 @@ /** * Search-provider key resolution. * - * Per-user only — each caller stores their own keys in app_settings - * (encrypted at rest by the crypto layer; both the 'search_provider' category - * and the '_api_key' suffix flag sensitive). - * - * No env fallback: web search is a per-user feature. If the user hasn't - * provisioned any key, the tool reports the missing key explicitly so the - * model can tell the user to add one in settings. + * Pure: keys come in via the same per-request `ProviderKeys` bag the chat + * resolver uses. No DB lookup, no env fallback. If the user hasn't supplied + * a Tavily or Brave key, the caller surfaces a "missing_search_key" error + * back to the model so the model tells the user to add one in Settings. */ -import { getDb } from '$lib/server/db'; - -const SEARCH_PROVIDER_CATEGORY = 'search_provider'; +import type { ProviderKeys } from '$lib/server/auth/provider-keys'; export type SearchProvider = 'tavily' | 'brave_search'; -const STORAGE_KEY: Record = { - tavily: 'tavily_api_key', - brave_search: 'brave_search_api_key' -}; - -async function readUserKey(userId: string, provider: SearchProvider): Promise { - try { - const value = await getDb().kvGet(userId, SEARCH_PROVIDER_CATEGORY, STORAGE_KEY[provider]); - if (typeof value === 'string' && value.trim().length > 0) return value.trim(); - } catch (err) { - console.error(`[search] failed to read per-user ${provider} key:`, err); - } - return ''; -} - export interface ResolvedSearchKey { provider: SearchProvider; apiKey: string; } /** - * Pick which search provider to use for this user. Tavily wins when the user - * has both — its results are richer (titles + snippets + ranked relevance - * out of the box). Brave is the fallback. Returns null when the user has + * Pick which search provider to use given the request's keys. Tavily wins + * when the user has both — its results are richer (titles + snippets + + * ranked relevance). Brave is the fallback. Returns null when the user has * neither key. */ -export async function resolveSearchKeyFor( - userId: string | null -): Promise { - if (!userId) return null; - const tavily = await readUserKey(userId, 'tavily'); - if (tavily) return { provider: 'tavily', apiKey: tavily }; - const brave = await readUserKey(userId, 'brave_search'); - if (brave) return { provider: 'brave_search', apiKey: brave }; +export function resolveSearchKey(keys: ProviderKeys): ResolvedSearchKey | null { + if (keys.tavily) return { apiKey: keys.tavily, provider: 'tavily' }; + if (keys.braveSearch) return { apiKey: keys.braveSearch, provider: 'brave_search' }; return null; } diff --git a/src/lib/server/chat/tools/diagramDelete.ts b/src/lib/server/chat/tools/diagramDelete.ts index 9b16404..8e7ad67 100644 --- a/src/lib/server/chat/tools/diagramDelete.ts +++ b/src/lib/server/chat/tools/diagramDelete.ts @@ -15,9 +15,8 @@ import { validateSingleMermaidDocument } from '$lib/server/chat/mermaid'; import { detectCodeLanguage, validateCodeArtifact } from '$lib/server/chat/code-artifacts'; -import { openrouterFastChat } from '$lib/server/chat/model'; import { instructionsForSubagent } from '$lib/server/chat/subagents'; -import { generateText, tool } from 'ai'; +import { tool } from 'ai'; import { execFile } from 'child_process'; import { promisify } from 'util'; import { z } from 'zod'; diff --git a/src/lib/server/chat/tools/diagramPatch.ts b/src/lib/server/chat/tools/diagramPatch.ts index eeb1636..d8df92e 100644 --- a/src/lib/server/chat/tools/diagramPatch.ts +++ b/src/lib/server/chat/tools/diagramPatch.ts @@ -16,9 +16,8 @@ import { validateSingleMermaidDocument } from '$lib/server/chat/mermaid'; import { detectCodeLanguage, validateCodeArtifact } from '$lib/server/chat/code-artifacts'; -import { openrouterFastChat } from '$lib/server/chat/model'; import { instructionsForSubagent } from '$lib/server/chat/subagents'; -import { generateText, tool } from 'ai'; +import { tool } from 'ai'; import { execFile } from 'child_process'; import { promisify } from 'util'; import { z } from 'zod'; @@ -140,8 +139,7 @@ export function applyDiagramLinePatch({ const nextEdgeCount = countMermaidEdgeLines(newDiagram); if (previousEdgeCount > 0 && nextEdgeCount === 0) { return { - error: - 'REJECTED: diagramPatch would remove every connection from the existing diagram.', + error: 'REJECTED: diagramPatch would remove every connection from the existing diagram.', hint: 'Keep the existing edges when styling or adding icons. Patch only the relevant node, style, or icon lines unless the user explicitly asks to delete all relationships.', success: false }; diff --git a/src/lib/server/chat/tools/diagramRead.ts b/src/lib/server/chat/tools/diagramRead.ts index 8102f9d..5b54495 100644 --- a/src/lib/server/chat/tools/diagramRead.ts +++ b/src/lib/server/chat/tools/diagramRead.ts @@ -15,9 +15,8 @@ import { validateSingleMermaidDocument } from '$lib/server/chat/mermaid'; import { detectCodeLanguage, validateCodeArtifact } from '$lib/server/chat/code-artifacts'; -import { openrouterFastChat } from '$lib/server/chat/model'; import { instructionsForSubagent } from '$lib/server/chat/subagents'; -import { generateText, tool } from 'ai'; +import { tool } from 'ai'; import { execFile } from 'child_process'; import { promisify } from 'util'; import { z } from 'zod'; diff --git a/src/lib/server/chat/tools/diagramWrite.ts b/src/lib/server/chat/tools/diagramWrite.ts index 8c9567b..7b32226 100644 --- a/src/lib/server/chat/tools/diagramWrite.ts +++ b/src/lib/server/chat/tools/diagramWrite.ts @@ -15,9 +15,8 @@ import { validateSingleMermaidDocument } from '$lib/server/chat/mermaid'; import { detectCodeLanguage, validateCodeArtifact } from '$lib/server/chat/code-artifacts'; -import { openrouterFastChat } from '$lib/server/chat/model'; import { instructionsForSubagent } from '$lib/server/chat/subagents'; -import { generateText, tool } from 'ai'; +import { tool } from 'ai'; import { execFile } from 'child_process'; import { promisify } from 'util'; import { z } from 'zod'; @@ -78,8 +77,8 @@ export function createDiagramWriteTool({ modelId, sessionId, target }: ToolConte diagramStore.set(sessionId, unescapedContent); return { ...targetMetadata(target, targetTabName), - lines: unescapedContent.split('\n').length, content: unescapedContent, + lines: unescapedContent.split('\n').length, purpose, success: true }; diff --git a/src/lib/server/chat/tools/markdownRead.ts b/src/lib/server/chat/tools/markdownRead.ts index ed9eebb..bda941a 100644 --- a/src/lib/server/chat/tools/markdownRead.ts +++ b/src/lib/server/chat/tools/markdownRead.ts @@ -15,9 +15,8 @@ import { validateSingleMermaidDocument } from '$lib/server/chat/mermaid'; import { detectCodeLanguage, validateCodeArtifact } from '$lib/server/chat/code-artifacts'; -import { openrouterFastChat } from '$lib/server/chat/model'; import { instructionsForSubagent } from '$lib/server/chat/subagents'; -import { generateText, tool } from 'ai'; +import { tool } from 'ai'; import { execFile } from 'child_process'; import { promisify } from 'util'; import { z } from 'zod'; diff --git a/src/lib/server/chat/tools/markdownWrite.ts b/src/lib/server/chat/tools/markdownWrite.ts index 9fa9029..d4ff2e1 100644 --- a/src/lib/server/chat/tools/markdownWrite.ts +++ b/src/lib/server/chat/tools/markdownWrite.ts @@ -15,9 +15,8 @@ import { validateSingleMermaidDocument } from '$lib/server/chat/mermaid'; import { detectCodeLanguage, validateCodeArtifact } from '$lib/server/chat/code-artifacts'; -import { openrouterFastChat } from '$lib/server/chat/model'; import { instructionsForSubagent } from '$lib/server/chat/subagents'; -import { generateText, tool } from 'ai'; +import { tool } from 'ai'; import { execFile } from 'child_process'; import { promisify } from 'util'; import { z } from 'zod'; diff --git a/src/lib/server/chat/tools/webSearch.ts b/src/lib/server/chat/tools/webSearch.ts index 2cec5af..bcb50a5 100644 --- a/src/lib/server/chat/tools/webSearch.ts +++ b/src/lib/server/chat/tools/webSearch.ts @@ -1,6 +1,6 @@ import { tool } from 'ai'; import { z } from 'zod'; -import { resolveSearchKeyFor, type SearchProvider } from '$lib/server/chat/search'; +import { resolveSearchKey, type SearchProvider } from '$lib/server/chat/search'; import type { ToolContext } from './context'; interface SearchResult { @@ -37,7 +37,8 @@ async function searchWithBrave( signal: AbortSignal.timeout(8000) }); - if (res.status === 401 || res.status === 403) return { ok: false, status: res.status, reason: 'invalid_key' }; + if (res.status === 401 || res.status === 403) + return { ok: false, status: res.status, reason: 'invalid_key' }; if (res.status === 429) return { ok: false, status: 429, reason: 'rate_limited' }; if (!res.ok) return { ok: false, status: res.status, reason: 'request_failed' }; @@ -60,15 +61,16 @@ async function searchWithTavily( headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ api_key: apiKey, - query, - search_depth: 'basic', + include_answer: false, max_results: 5, - include_answer: false + query, + search_depth: 'basic' }), signal: AbortSignal.timeout(8000) }); - if (res.status === 401 || res.status === 403) return { ok: false, status: res.status, reason: 'invalid_key' }; + if (res.status === 401 || res.status === 403) + return { ok: false, status: res.status, reason: 'invalid_key' }; if (res.status === 429) return { ok: false, status: 429, reason: 'rate_limited' }; if (!res.ok) return { ok: false, status: res.status, reason: 'request_failed' }; @@ -87,7 +89,7 @@ const labelByProvider: Record = { tavily: 'Tavily' }; -export function createWebSearchTool({ userId }: ToolContext) { +export function createWebSearchTool({ keys }: ToolContext) { return tool({ description: 'Search the web. Uses the user\'s configured search provider (Tavily preferred when available, Brave Search otherwise). Returns titles, snippets, and URLs. If no provider key is configured, returns success:false with error:"missing_search_key" — in that case, ASK the user to add a Tavily or Brave key in Settings → API Keys instead of retrying.', @@ -99,15 +101,15 @@ export function createWebSearchTool({ userId }: ToolContext) { .describe('Brief reason why you are searching — shown to the user') }), execute: async ({ query, reason }) => { - const resolved = await resolveSearchKeyFor(userId ?? null); + const resolved = resolveSearchKey(keys); if (!resolved) { return { - success: false, error: 'missing_search_key', + message: + 'No web-search API key configured for this user. Ask the user to add a Tavily key (https://tavily.com, free tier 1000 queries/month) or a Brave Search key (https://brave.com/search/api/, free tier 2000 queries/month) in Settings → API Keys. Do not retry until they have.', query, reason, - message: - 'No web-search API key configured for this user. Ask the user to add a Tavily key (https://tavily.com, free tier 1000 queries/month) or a Brave Search key (https://brave.com/search/api/, free tier 2000 queries/month) in Settings → API Keys. Do not retry until they have.' + success: false }; } @@ -120,41 +122,41 @@ export function createWebSearchTool({ userId }: ToolContext) { if (!result.ok) { if (result.reason === 'invalid_key') { return { - success: false, error: 'invalid_search_key', + message: `${labelByProvider[resolved.provider]} rejected the configured key. Ask the user to update it in Settings → API Keys.`, provider: resolved.provider, query, reason, - message: `${labelByProvider[resolved.provider]} rejected the configured key. Ask the user to update it in Settings → API Keys.` + success: false }; } if (result.reason === 'rate_limited') { return { - success: false, error: 'search_rate_limited', + message: `${labelByProvider[resolved.provider]} rate limit reached for this user. Try again in a moment, or ask the user to upgrade their plan.`, provider: resolved.provider, query, reason, - message: `${labelByProvider[resolved.provider]} rate limit reached for this user. Try again in a moment, or ask the user to upgrade their plan.` + success: false }; } return { - success: false, error: 'search_request_failed', provider: resolved.provider, - status: result.status, query, - reason + reason, + status: result.status, + success: false }; } return { - success: true, provider: resolved.provider, query, reason: reason || `Searching ${labelByProvider[resolved.provider]} for "${query}"`, resultCount: result.results.length, results: result.results, + success: true, summary: result.results.length > 0 ? `Found ${result.results.length} result(s) for "${query}"` @@ -162,11 +164,11 @@ export function createWebSearchTool({ userId }: ToolContext) { }; } catch (e: unknown) { return { - success: false, error: e instanceof Error ? e.message : 'search_failed', provider: resolved.provider, query, - reason + reason, + success: false }; } } From 713ae8ce561a8fb3663f888c76379535d83cf4e5 Mon Sep 17 00:00:00 2001 From: Omkar Bhad <60899563+omkarbhad@users.noreply.github.com> Date: Sat, 9 May 2026 01:33:22 -0400 Subject: [PATCH 08/13] refactor(api): audio/upload/model-lab read provider keys from headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the cascade left by Pass B1. The non-chat endpoints that used to call load*ApiKey() now extract keys from the request via extractProviderKeys() and pass them through: - /api/audio: transcribeWithGemini and transcribeWithOpenRouter take the key as their first arg. POST handler reads keys.gemini and keys.openrouter; 400 if both are missing. - /api/upload: describeImageWithVision takes the key as its first arg. POST handler reads keys.openrouter and passes it through; missing key still degrades to a placeholder description rather than failing the upload (text/PDF uploads don't need a key). - /api/admin: setOpenRouterApiKey and setProviderApiKey cases return 410 Gone. Server-side global key storage was the original guest-leak vector and is intentionally gone — admins use Settings > Models & Keys like everyone else. - /api/model-lab: GET and POST extract keys and pass them through to searchProviderModels / smokeTestProviderModel. - lib/server/model-lab.ts: searchProviderModels and smokeTestProviderModel both take a `keys: ProviderKeys` parameter. Direct env reads are gone. anthropicListHeaders is local to this file (mirrors buildAnthropicChat's OAuth-vs-api-key precedence without depending on the deleted getAnthropicAuthHeaders). Test files updated to match the new contract: - tests/unit/model-lab.test.ts: passes keysWith({ ... }) instead of vi.stubEnv. Route tests pass x-provider-* headers via Request. - tests/unit/server-harness.test.ts: live-trace harness reads keys from env directly via liveTraceProviderKeys() (test-only env-key escape hatch). Removed the load*ApiKey imports. resolveChatModel call sites use resolveChatModelFor with explicit keys. - createIconSearchTool calls now pass an empty ProviderKeys (icon search doesn't actually use any). Pre-existing GraphiniAgentId / diagramStore errors in server-harness.test.ts are out of scope. This commit completes Pass B3. The branch compiles end-to-end again. --- src/lib/server/model-lab.ts | 84 ++++++++++++++++++------- src/routes/api/admin/+server.ts | 81 +++++------------------- src/routes/api/audio/+server.ts | 34 +++++----- src/routes/api/model-lab/+server.ts | 6 +- src/routes/api/upload/+server.ts | 21 ++++--- tests/unit/model-lab.test.ts | 96 +++++++++++++++++++++-------- tests/unit/server-harness.test.ts | 58 +++++++++-------- 7 files changed, 215 insertions(+), 165 deletions(-) diff --git a/src/lib/server/model-lab.ts b/src/lib/server/model-lab.ts index a053a0e..bc2583e 100644 --- a/src/lib/server/model-lab.ts +++ b/src/lib/server/model-lab.ts @@ -1,14 +1,11 @@ import { generateText } from 'ai'; import { - getAnthropicAuthHeaders, - loadAnthropicAuthToken, - loadAnthropicApiKey, - loadOpenAiApiKey, - loadOpenRouterApiKey, - loadProviderApiKeys, - resolveChatModel, + buildAnthropicChat, + buildOpenAiChat, + buildOpenRouterChat, type ChatProvider } from './chat/model'; +import { missingProviderKeyError, type ProviderKeys } from './auth/provider-keys'; export type ModelLabProvider = Extract; @@ -75,21 +72,43 @@ function limitResults(models: ModelSearchResult[], limit: number): ModelSearchRe return models.slice(0, Math.max(1, Math.min(limit, 100))); } +/** + * Build the Anthropic auth headers for the admin model-listing call. Mirrors + * the precedence in buildAnthropicChat: explicit OAuth token wins, an + * api_key shaped like `sk-ant-oauth*` is also treated as OAuth, otherwise + * a regular x-api-key header. + */ +function anthropicListHeaders(keys: ProviderKeys): Record { + const oauthShaped = (k: string) => k.startsWith('sk-ant-oat') || k.startsWith('sk-ant-oauth'); + const explicitToken = + keys.anthropicAuthToken || + (keys.anthropic && oauthShaped(keys.anthropic) ? keys.anthropic : ''); + if (explicitToken) { + return { + 'anthropic-beta': 'oauth-2025-04-20', + Authorization: `Bearer ${explicitToken}` + }; + } + return { 'x-api-key': keys.anthropic }; +} + export async function searchProviderModels({ + keys, limit = 25, provider, query = '' }: { + keys: ProviderKeys; limit?: number; provider: ModelLabProvider; query?: string; }): Promise { const models = provider === 'openai' - ? await listOpenAiModels() + ? await listOpenAiModels(keys) : provider === 'anthropic' - ? await listAnthropicModels() - : await listOpenRouterModels(); + ? await listAnthropicModels(keys) + : await listOpenRouterModels(keys); return limitResults( models.filter((model) => matchesQuery(model, query)), @@ -97,12 +116,11 @@ export async function searchProviderModels({ ); } -async function listOpenAiModels(): Promise { - const apiKey = await loadOpenAiApiKey(); - if (!apiKey) throw new Error('OPENAI_API_KEY is not set. Add it in Settings > Model Access.'); +async function listOpenAiModels(keys: ProviderKeys): Promise { + if (!keys.openai) missingProviderKeyError('openai'); const response = await fetch('https://api.openai.com/v1/models', { - headers: { Authorization: `Bearer ${apiKey}` } + headers: { Authorization: `Bearer ${keys.openai}` } }); if (!response.ok) throw new Error(`OpenAI models API error: ${response.status}`); @@ -118,13 +136,13 @@ async function listOpenAiModels(): Promise { })); } -async function listAnthropicModels(): Promise { - await Promise.all([loadAnthropicApiKey(), loadAnthropicAuthToken()]); +async function listAnthropicModels(keys: ProviderKeys): Promise { + if (!keys.anthropic && !keys.anthropicAuthToken) missingProviderKeyError('anthropic'); const response = await fetch('https://api.anthropic.com/v1/models', { headers: { 'anthropic-version': '2023-06-01', - ...getAnthropicAuthHeaders() + ...anthropicListHeaders(keys) } }); if (!response.ok) throw new Error(`Anthropic models API error: ${response.status}`); @@ -140,10 +158,12 @@ async function listAnthropicModels(): Promise { })); } -async function listOpenRouterModels(): Promise { - const apiKey = await loadOpenRouterApiKey(); +async function listOpenRouterModels(keys: ProviderKeys): Promise { + // OpenRouter's /v1/models endpoint works without auth (returns the full + // catalog), but a key gives access to the user's enabled-only filter. + // Don't require it — admins can browse anonymously. const response = await fetch('https://openrouter.ai/api/v1/models', { - headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined + headers: keys.openrouter ? { Authorization: `Bearer ${keys.openrouter}` } : undefined }); if (!response.ok) throw new Error(`OpenRouter models API error: ${response.status}`); @@ -169,21 +189,41 @@ async function listOpenRouterModels(): Promise { } export async function smokeTestProviderModel({ + keys, maxOutputTokens = 80, modelId, prompt = modelLabPromptPresets[0], provider }: { + keys: ProviderKeys; maxOutputTokens?: number; modelId: string; prompt?: string; provider: ModelLabProvider; }): Promise { - await loadProviderApiKeys(); + // Construct the right model client directly. resolveChatModelFor would + // also work, but we already know the exact provider here so we skip + // normalization and the providerHint dance. + const model = + provider === 'openai' + ? (() => { + if (!keys.openai) missingProviderKeyError('openai'); + return buildOpenAiChat(modelId, keys.openai); + })() + : provider === 'anthropic' + ? (() => { + if (!keys.anthropic && !keys.anthropicAuthToken) missingProviderKeyError('anthropic'); + return buildAnthropicChat(modelId, keys.anthropic, keys.anthropicAuthToken); + })() + : (() => { + if (!keys.openrouter) missingProviderKeyError('openrouter'); + return buildOpenRouterChat(modelId, keys.openrouter); + })(); + const startedAt = performance.now(); const result = await generateText({ maxOutputTokens, - model: resolveChatModel(modelId, provider), + model, prompt, temperature: 0 }); diff --git a/src/routes/api/admin/+server.ts b/src/routes/api/admin/+server.ts index 3937083..1542d2a 100644 --- a/src/routes/api/admin/+server.ts +++ b/src/routes/api/admin/+server.ts @@ -6,12 +6,6 @@ import { requireAdmin } from '$lib/server/admin/auth'; import { handleAdminGet } from '$lib/server/admin/get-actions'; import { getCache } from '$lib/server/cache'; -import { - setRuntimeAnthropicAuthToken, - setRuntimeAnthropicApiKey, - setRuntimeOpenAiApiKey, - setRuntimeOpenRouterApiKey -} from '$lib/server/chat/model'; import { getDb } from '$lib/server/db'; import { adminDashboard, @@ -454,68 +448,21 @@ export const POST: RequestHandler = async ({ request }) => { return json({ success: true }); } - case 'setOpenRouterApiKey': { - const apiKey = typeof body.apiKey === 'string' ? body.apiKey.trim() : ''; - if (!apiKey) { - return json({ success: false, error: 'apiKey required' }, { status: 400 }); - } - await settingsManager.set(null, 'ai_provider', 'openrouter_api_key', apiKey, { - description: 'OpenRouter API key used for server-side AI requests', - isSensitive: true - }); - setRuntimeOpenRouterApiKey(apiKey); - await adminDashboard.logAction(null, 'set_openrouter_api_key', 'setting', 'ai_provider'); - return json({ success: true }); - } - + case 'setOpenRouterApiKey': case 'setProviderApiKey': { - const apiKey = typeof body.apiKey === 'string' ? body.apiKey.trim() : ''; - const provider = - typeof body.provider === 'string' ? body.provider.trim().toLowerCase() : ''; - const credentialType = - typeof body.credentialType === 'string' - ? body.credentialType.trim().toLowerCase() - : 'api_key'; - const supportedProviders = new Set(['anthropic', 'openai', 'openrouter']); - if (!supportedProviders.has(provider)) { - return json( - { success: false, error: 'provider must be openai, anthropic, or openrouter' }, - { status: 400 } - ); - } - if (credentialType !== 'api_key' && credentialType !== 'auth_token') { - return json( - { success: false, error: 'credentialType must be api_key or auth_token' }, - { status: 400 } - ); - } - if (credentialType === 'auth_token' && provider !== 'anthropic') { - return json( - { success: false, error: 'OAuth bearer tokens are only supported for Anthropic' }, - { status: 400 } - ); - } - if (!apiKey) { - return json({ success: false, error: 'apiKey required' }, { status: 400 }); - } - - const settingKey = - credentialType === 'auth_token' ? `${provider}_auth_token` : `${provider}_api_key`; - await settingsManager.set(null, 'ai_provider', settingKey, apiKey, { - description: - credentialType === 'auth_token' - ? `${provider} OAuth/OAT bearer token used for server-side AI requests` - : `${provider} API key used for server-side AI requests`, - isSensitive: true - }); - if (provider === 'anthropic' && credentialType === 'auth_token') - setRuntimeAnthropicAuthToken(apiKey); - if (provider === 'anthropic' && credentialType === 'api_key') - setRuntimeAnthropicApiKey(apiKey); - if (provider === 'openai') setRuntimeOpenAiApiKey(apiKey); - if (provider === 'openrouter') setRuntimeOpenRouterApiKey(apiKey); - await adminDashboard.logAction(null, 'set_provider_api_key', 'setting', settingKey); - return json({ success: true }); + // Server-side provider key storage was removed. Each user supplies + // their own key in browser localStorage; chat / audio / upload / + // model-lab read those keys from x-provider-* request headers. The + // admin "set a global key" flow is intentionally gone — it was the + // mechanism that let guests ride the admin's billing. + return json( + { + success: false, + error: + 'Server-side provider key storage has been removed. Each user manages their own keys in Settings > Models & Keys.' + }, + { status: 410 } + ); } case 'updateEnabledModel': { diff --git a/src/routes/api/audio/+server.ts b/src/routes/api/audio/+server.ts index 3f1204b..e500f96 100644 --- a/src/routes/api/audio/+server.ts +++ b/src/routes/api/audio/+server.ts @@ -4,15 +4,11 @@ */ import { getDb } from '$lib/server/db'; -import { loadOpenRouterApiKey } from '$lib/server/chat/model'; +import { extractProviderKeys } from '$lib/server/auth/provider-keys'; import { settingsManager, stateManager } from '$lib/server/state-manager'; import { validateSessionOrGuest } from '$lib/server/auth'; import { audioLimiter, getClientKey, rateLimitResponse } from '$lib/server/rate-limit'; import { json, type RequestHandler } from '@sveltejs/kit'; -import dotenv from 'dotenv'; - -dotenv.config({ path: '.env.local' }); -dotenv.config(); /** * Cap audio uploads at 10MB. Audio is base64-encoded in memory before being @@ -21,7 +17,6 @@ dotenv.config(); */ const MAX_AUDIO_SIZE = 10 * 1024 * 1024; -const GEMINI_API_KEY = process.env.GEMINI_API_KEY || ''; const DEFAULT_VOICE_MODEL = 'google/gemini-2.0-flash-001'; const GEMINI_FALLBACK_MODEL = 'gemini-2.0-flash-lite'; @@ -90,13 +85,14 @@ async function ensureInternalModelsRegistered() { } async function transcribeWithGemini( + geminiKey: string, base64Audio: string, mimeType: string, model = GEMINI_FALLBACK_MODEL ): Promise { - if (!GEMINI_API_KEY) return null; + if (!geminiKey) return null; try { - const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${normalizeGeminiModel(model)}:generateContent?key=${GEMINI_API_KEY}`; + const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${normalizeGeminiModel(model)}:generateContent?key=${geminiKey}`; const res = await fetch(geminiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -127,11 +123,11 @@ async function transcribeWithGemini( } async function transcribeWithOpenRouter( + openRouterApiKey: string, base64Audio: string, mimeType: string, model: string ): Promise { - const openRouterApiKey = await loadOpenRouterApiKey(); if (!openRouterApiKey) return null; try { const dataUri = `data:${mimeType};base64,${base64Audio}`; @@ -196,8 +192,18 @@ export const POST: RequestHandler = async ({ request }) => { return json({ error: 'Audio file too large. Max 10MB allowed.' }, { status: 413 }); } - if (!GEMINI_API_KEY && !(await loadOpenRouterApiKey())) { - return json({ error: 'No transcription API key configured' }, { status: 500 }); + // Keys travel per-request: x-provider-gemini for direct Gemini calls, + // x-provider-openrouter for the OpenRouter fallback. The user must + // supply at least one — we don't read any global key here. + const keys = extractProviderKeys(request); + if (!keys.gemini && !keys.openrouter) { + return json( + { + error: + 'No transcription API key set. Add a Gemini or OpenRouter key in Settings > Models & Keys.' + }, + { status: 400 } + ); } const arrayBuffer = await audioFile.arrayBuffer(); @@ -209,13 +215,13 @@ export const POST: RequestHandler = async ({ request }) => { voiceModel.startsWith('gemini-') || voiceModel.startsWith('gemini/'); let text = prefersGeminiDirect - ? await transcribeWithGemini(base64Audio, mimeType, voiceModel) + ? await transcribeWithGemini(keys.gemini, base64Audio, mimeType, voiceModel) : null; if (text === null) { - text = await transcribeWithOpenRouter(base64Audio, mimeType, voiceModel); + text = await transcribeWithOpenRouter(keys.openrouter, base64Audio, mimeType, voiceModel); } if (text === null) { - text = await transcribeWithGemini(base64Audio, mimeType, GEMINI_FALLBACK_MODEL); + text = await transcribeWithGemini(keys.gemini, base64Audio, mimeType, GEMINI_FALLBACK_MODEL); } if (text === null) { diff --git a/src/routes/api/model-lab/+server.ts b/src/routes/api/model-lab/+server.ts index dc9c488..e9a8f2d 100644 --- a/src/routes/api/model-lab/+server.ts +++ b/src/routes/api/model-lab/+server.ts @@ -1,4 +1,5 @@ import { requireAdmin } from '$lib/server/admin/auth'; +import { extractProviderKeys } from '$lib/server/auth/provider-keys'; import { modelLabPromptPresets, parseModelLabProvider, @@ -15,7 +16,8 @@ export const GET: RequestHandler = async ({ request, url }) => { const provider = parseModelLabProvider(url.searchParams.get('provider')); const query = url.searchParams.get('q') ?? ''; const limit = Number(url.searchParams.get('limit') ?? 25); - const models = await searchProviderModels({ limit, provider, query }); + const keys = extractProviderKeys(request); + const models = await searchProviderModels({ keys, limit, provider, query }); return json({ data: { @@ -48,7 +50,9 @@ export const POST: RequestHandler = async ({ request }) => { return json({ error: 'modelId is required', success: false }, { status: 400 }); } + const keys = extractProviderKeys(request); const result = await smokeTestProviderModel({ + keys, maxOutputTokens: body.maxOutputTokens, modelId: body.modelId, prompt: body.prompt, diff --git a/src/routes/api/upload/+server.ts b/src/routes/api/upload/+server.ts index f61324d..7dea659 100644 --- a/src/routes/api/upload/+server.ts +++ b/src/routes/api/upload/+server.ts @@ -1,14 +1,10 @@ import { storeFile } from '$lib/server/file-store'; -import { loadOpenRouterApiKey } from '$lib/server/chat/model'; +import { extractProviderKeys } from '$lib/server/auth/provider-keys'; import { validateSessionOrGuest } from '$lib/server/auth'; import { getClientKey, rateLimitResponse, uploadLimiter } from '$lib/server/rate-limit'; import { error, json } from '@sveltejs/kit'; -import dotenv from 'dotenv'; import type { RequestHandler } from './$types'; -dotenv.config({ path: '.env.local' }); -dotenv.config(); - /** * File upload endpoint - processes all files server-side. * Images are analyzed via a vision model only when the selected chat model allows images. @@ -20,8 +16,11 @@ dotenv.config(); * Returns: { url: string|null, mediaType: string, filename: string, type: string, extractedText?: string, fileId?: string } */ -async function describeImageWithVision(base64DataUrl: string, filename: string): Promise { - const apiKey = await loadOpenRouterApiKey(); +async function describeImageWithVision( + apiKey: string, + base64DataUrl: string, + filename: string +): Promise { if (!apiKey) return `[Image: ${filename} — vision processing unavailable (no API key)]`; try { @@ -106,6 +105,7 @@ export const POST: RequestHandler = async ({ request }) => { const mediaType = file.type || 'application/octet-stream'; const isImage = file.type.startsWith('image/'); const supportsImages = formData.get('supportsImages') === 'true'; + const keys = extractProviderKeys(request); // For images: analyze with vision model, return text description + thumbnail URL if (isImage) { @@ -117,8 +117,11 @@ export const POST: RequestHandler = async ({ request }) => { const base64 = Buffer.from(buffer).toString('base64'); const dataUrl = `data:${mediaType};base64,${base64}`; - // Process image with vision model to get text description - const description = await describeImageWithVision(dataUrl, filename); + // Process image with vision model to get text description. + // The vision call uses OpenRouter; if the user hasn't supplied a key, + // describeImageWithVision degrades to a placeholder description rather + // than failing the upload — text/PDF uploads don't need a key at all. + const description = await describeImageWithVision(keys.openrouter, dataUrl, filename); // Store image metadata for agent access const sessionId = (formData.get('sessionId') as string) || 'default'; diff --git a/tests/unit/model-lab.test.ts b/tests/unit/model-lab.test.ts index 1845fa8..38a4ba2 100644 --- a/tests/unit/model-lab.test.ts +++ b/tests/unit/model-lab.test.ts @@ -1,6 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +import { emptyProviderKeys, type ProviderKeys } from '../../src/lib/server/auth/provider-keys'; import { parseModelLabProvider, searchProviderModels } from '../../src/lib/server/model-lab'; +function keysWith(overrides: Partial): ProviderKeys { + return { ...emptyProviderKeys(), ...overrides }; +} + describe('model lab provider parsing', () => { it('accepts the supported providers case-insensitively', () => { expect(parseModelLabProvider('OpenAI')).toBe('openai'); @@ -20,12 +25,10 @@ describe('model lab provider parsing', () => { describe('model lab search', () => { afterEach(() => { - vi.unstubAllEnvs(); vi.unstubAllGlobals(); }); it('searches OpenAI models and normalizes their shape', async () => { - vi.stubEnv('OPENAI_API_KEY', 'test-openai-key'); const fetchMock = vi.fn(async () => Response.json({ data: [ @@ -36,7 +39,12 @@ describe('model lab search', () => { ); vi.stubGlobal('fetch', fetchMock); - const models = await searchProviderModels({ provider: 'openai', query: 'gpt', limit: 10 }); + const models = await searchProviderModels({ + keys: keysWith({ openai: 'test-openai-key' }), + limit: 10, + provider: 'openai', + query: 'gpt' + }); expect(fetchMock).toHaveBeenCalledWith('https://api.openai.com/v1/models', { headers: { Authorization: 'Bearer test-openai-key' } @@ -53,7 +61,6 @@ describe('model lab search', () => { }); it('searches Anthropic models and normalizes display names', async () => { - vi.stubEnv('ANTHROPIC_API_KEY', 'test-anthropic-key'); const fetchMock = vi.fn(async () => Response.json({ data: [ @@ -67,7 +74,11 @@ describe('model lab search', () => { ); vi.stubGlobal('fetch', fetchMock); - const models = await searchProviderModels({ provider: 'anthropic', limit: 1 }); + const models = await searchProviderModels({ + keys: keysWith({ anthropic: 'test-anthropic-key' }), + limit: 1, + provider: 'anthropic' + }); expect(fetchMock).toHaveBeenCalledWith('https://api.anthropic.com/v1/models', { headers: { @@ -86,7 +97,6 @@ describe('model lab search', () => { }); it('uses Anthropic OAuth/OAT auth tokens as bearer credentials for model search', async () => { - vi.stubEnv('ANTHROPIC_AUTH_TOKEN', 'test-anthropic-oat-token'); const fetchMock = vi.fn(async () => Response.json({ data: [{ display_name: 'Claude Haiku Test', id: 'claude-haiku-test' }] @@ -94,7 +104,12 @@ describe('model lab search', () => { ); vi.stubGlobal('fetch', fetchMock); - await searchProviderModels({ provider: 'anthropic', query: 'haiku', limit: 1 }); + await searchProviderModels({ + keys: keysWith({ anthropicAuthToken: 'test-anthropic-oat-token' }), + limit: 1, + provider: 'anthropic', + query: 'haiku' + }); expect(fetchMock).toHaveBeenCalledWith('https://api.anthropic.com/v1/models', { headers: { @@ -106,7 +121,6 @@ describe('model lab search', () => { }); it('treats OAT-looking Anthropic API key values as bearer tokens', async () => { - vi.stubEnv('ANTHROPIC_API_KEY', 'sk-ant-oat-test-token'); const fetchMock = vi.fn(async () => Response.json({ data: [{ display_name: 'Claude OAuth Test', id: 'claude-oauth-test' }] @@ -114,7 +128,12 @@ describe('model lab search', () => { ); vi.stubGlobal('fetch', fetchMock); - await searchProviderModels({ provider: 'anthropic', query: 'oauth', limit: 1 }); + await searchProviderModels({ + keys: keysWith({ anthropic: 'sk-ant-oat-test-token' }), + limit: 1, + provider: 'anthropic', + query: 'oauth' + }); expect(fetchMock).toHaveBeenCalledWith('https://api.anthropic.com/v1/models', { headers: { @@ -126,7 +145,6 @@ describe('model lab search', () => { }); it('searches OpenRouter models with capability metadata', async () => { - vi.stubEnv('OPENROUTER_API_KEY', 'test-openrouter-key'); const fetchMock = vi.fn(async () => Response.json({ data: [ @@ -143,7 +161,12 @@ describe('model lab search', () => { ); vi.stubGlobal('fetch', fetchMock); - const models = await searchProviderModels({ provider: 'openrouter', query: 'fast', limit: 10 }); + const models = await searchProviderModels({ + keys: keysWith({ openrouter: 'test-openrouter-key' }), + limit: 10, + provider: 'openrouter', + query: 'fast' + }); expect(fetchMock).toHaveBeenCalledWith('https://openrouter.ai/api/v1/models', { headers: { Authorization: 'Bearer test-openrouter-key' } @@ -162,7 +185,6 @@ describe('model lab search', () => { }); it('clamps provider search limits to the supported range', async () => { - vi.stubEnv('OPENROUTER_API_KEY', 'test-openrouter-key'); const data = Array.from({ length: 105 }, (_, index) => ({ id: `provider/model-${index}`, name: `Model ${index}` @@ -172,43 +194,55 @@ describe('model lab search', () => { vi.fn(async () => Response.json({ data })) ); - await expect(searchProviderModels({ limit: 0, provider: 'openrouter' })).resolves.toHaveLength( - 1 - ); await expect( - searchProviderModels({ limit: 1_000, provider: 'openrouter' }) + searchProviderModels({ + keys: keysWith({ openrouter: 'test-openrouter-key' }), + limit: 0, + provider: 'openrouter' + }) + ).resolves.toHaveLength(1); + await expect( + searchProviderModels({ + keys: keysWith({ openrouter: 'test-openrouter-key' }), + limit: 1_000, + provider: 'openrouter' + }) ).resolves.toHaveLength(100); }); it.each([ { - envKey: 'OPENAI_API_KEY', - provider: 'openai', + keyField: 'openai' as const, + provider: 'openai' as const, status: 503, expectedMessage: 'OpenAI models API error: 503' }, { - envKey: 'ANTHROPIC_API_KEY', - provider: 'anthropic', + keyField: 'anthropic' as const, + provider: 'anthropic' as const, status: 429, expectedMessage: 'Anthropic models API error: 429' }, { - envKey: 'OPENROUTER_API_KEY', - provider: 'openrouter', + keyField: 'openrouter' as const, + provider: 'openrouter' as const, status: 502, expectedMessage: 'OpenRouter models API error: 502' } - ] as const)( + ])( 'surfaces $provider model API failures', - async ({ envKey, expectedMessage, provider, status }) => { - vi.stubEnv(envKey, `test-${provider}-key`); + async ({ expectedMessage, keyField, provider, status }) => { vi.stubGlobal( 'fetch', vi.fn(async () => new Response('nope', { status })) ); - await expect(searchProviderModels({ provider })).rejects.toThrowError(expectedMessage); + await expect( + searchProviderModels({ + keys: keysWith({ [keyField]: `test-${provider}-key` } as Partial), + provider + }) + ).rejects.toThrowError(expectedMessage); } ); }); @@ -281,13 +315,19 @@ describe('model lab API route', () => { it('returns provider search results and prompt presets from GET', async () => { const { GET, searchModels } = await loadRoute(); const response = await GET({ - request: new Request('http://localhost/api/model-lab?provider=openrouter&q=nemotron&limit=5'), + request: new Request( + 'http://localhost/api/model-lab?provider=openrouter&q=nemotron&limit=5', + { + headers: { 'x-provider-openrouter': 'caller-key' } + } + ), url: new URL('http://localhost/api/model-lab?provider=openrouter&q=nemotron&limit=5') } as Parameters[0]); const body = (await response.json()) as Record; expect(response.status).toBe(200); expect(searchModels).toHaveBeenCalledWith({ + keys: keysWith({ openrouter: 'caller-key' }), limit: 5, provider: 'openrouter', query: 'nemotron' @@ -337,6 +377,7 @@ describe('model lab API route', () => { prompt: 'Draw a diagram', provider: 'openrouter' }), + headers: { 'x-provider-openrouter': 'caller-key' }, method: 'POST' }) } as Parameters[0]); @@ -344,6 +385,7 @@ describe('model lab API route', () => { expect(response.status).toBe(200); expect(smokeTest).toHaveBeenCalledWith({ + keys: keysWith({ openrouter: 'caller-key' }), maxOutputTokens: 40, modelId: 'model-a', prompt: 'Draw a diagram', diff --git a/tests/unit/server-harness.test.ts b/tests/unit/server-harness.test.ts index 1f1bb7f..3238799 100644 --- a/tests/unit/server-harness.test.ts +++ b/tests/unit/server-harness.test.ts @@ -5,13 +5,11 @@ import path from 'node:path'; import { z } from 'zod'; import { getChatProviderOptions, - loadAnthropicApiKey, - loadAnthropicAuthToken, - loadOpenRouterApiKey, normalizeChatModelId, - resolveChatModel, + resolveChatModelFor, type ChatProvider } from '../../src/lib/server/chat/model'; +import { emptyProviderKeys, type ProviderKeys } from '../../src/lib/server/auth/provider-keys'; import { defaultOpenRouterDiagramModel, searchProviderModels @@ -338,15 +336,24 @@ function liveTraceProvider(): Extract return process.env.HARNESS_TRACE_PROVIDER === 'anthropic' ? 'anthropic' : 'openrouter'; } -async function loadLiveTraceApiKey(provider: Extract) { - if (provider === 'anthropic') { - const [apiKey, authToken] = await Promise.all([ - loadAnthropicApiKey(), - loadAnthropicAuthToken() - ]); - return authToken || apiKey; - } - return await loadOpenRouterApiKey(); +/** + * Live-trace harness reads keys directly from env so a developer can run it + * locally with their own credentials. The production code path no longer has + * env-key fallbacks; this helper is intentionally test-only. + */ +function liveTraceProviderKeys(): ProviderKeys { + return { + ...emptyProviderKeys(), + anthropic: process.env.ANTHROPIC_API_KEY ?? '', + anthropicAuthToken: process.env.ANTHROPIC_AUTH_TOKEN ?? process.env.ANTHROPIC_OAUTH_TOKEN ?? '', + openrouter: process.env.OPENROUTER_API_KEY ?? '' + }; +} + +function loadLiveTraceApiKey(provider: Extract): string { + const keys = liveTraceProviderKeys(); + if (provider === 'anthropic') return keys.anthropicAuthToken || keys.anthropic; + return keys.openrouter; } function defaultLiveTraceModel(provider: Extract) { @@ -865,7 +872,7 @@ describe('server icon search', () => { const sessionId = 'icon-search-query-fallback'; diagramStore.set(sessionId, iconSearchDiagram); - const iconSearch = createIconSearchTool({ sessionId }); + const iconSearch = createIconSearchTool({ keys: emptyProviderKeys(), sessionId }); const result = (await iconSearch.execute?.( { query: 'icons' }, {} as never @@ -890,7 +897,7 @@ describe('server icon search', () => { const sessionId = 'icon-search-color-mode'; diagramStore.set(sessionId, ['flowchart TD', ' Search[Search]'].join('\n')); - const iconSearch = createIconSearchTool({ sessionId }); + const iconSearch = createIconSearchTool({ keys: emptyProviderKeys(), sessionId }); const result = (await iconSearch.execute?.( { colorMode: 'noncolor', includeWebSuggestions: false, nodeIds: ['Search'] }, {} as never @@ -964,10 +971,11 @@ describe('server harness live model trace', () => { 'runs natural intent prompts and logs selected tools, steps, and usage', async () => { const provider = liveTraceProvider(); - const apiKey = await loadLiveTraceApiKey(provider); + const traceKeys = liveTraceProviderKeys(); + const apiKey = loadLiveTraceApiKey(provider); expect( apiKey, - `${provider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENROUTER_API_KEY'} must be set for RUN_LIVE_MODEL_TRACE=true, or saved in Settings > Model Access.` + `${provider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENROUTER_API_KEY'} must be set for RUN_LIVE_MODEL_TRACE=true.` ).toBeTruthy(); const modelId = process.env.HARNESS_TRACE_MODEL ?? defaultLiveTraceModel(provider); @@ -987,6 +995,7 @@ describe('server harness live model trace', () => { let modelSearchError: string | undefined; try { modelMatches = await searchProviderModels({ + keys: traceKeys, limit: 10, provider, query: provider === 'anthropic' ? 'haiku' : 'nemotron' @@ -1013,7 +1022,7 @@ describe('server harness live model trace', () => { traceEvents.push({ event: 'toolCallStart', ...event }); }, maxOutputTokens: 700, - model: resolveChatModel(modelId, provider), + model: resolveChatModelFor(modelId, provider, traceKeys), prompt: intentCase.prompt, stopWhen: stepCountIs(3), system: @@ -1195,11 +1204,9 @@ describe('server harness live model trace', () => { liveHarnessTrace.skip( 'runs the legacy forced diagramWrite diagnostic', async () => { - const apiKey = await loadOpenRouterApiKey(); - expect( - apiKey, - 'OPENROUTER_API_KEY must be set for RUN_LIVE_MODEL_TRACE=true, or saved in Settings > Model Access.' - ).toBeTruthy(); + const traceKeys = liveTraceProviderKeys(); + const apiKey = traceKeys.openrouter; + expect(apiKey, 'OPENROUTER_API_KEY must be set for RUN_LIVE_MODEL_TRACE=true.').toBeTruthy(); const prompt = process.env.HARNESS_TRACE_PROMPT ?? @@ -1210,6 +1217,7 @@ describe('server harness live model trace', () => { 'Verify the live OpenRouter diagram model can produce valid Mermaid and use the diagramWrite tool with observable trace data.'; const toolMode = process.env.HARNESS_TRACE_TOOL_MODE ?? 'diagramWrite'; const modelMatches = await searchProviderModels({ + keys: traceKeys, limit: 10, provider: 'openrouter', query: 'nemotron' @@ -1221,7 +1229,7 @@ describe('server harness live model trace', () => { toolMode === 'none' ? await generateText({ maxOutputTokens: 900, - model: resolveChatModel(modelId, 'openrouter'), + model: resolveChatModelFor(modelId, 'openrouter', traceKeys), prompt, temperature: 0 }) @@ -1233,7 +1241,7 @@ describe('server harness live model trace', () => { traceEvents.push({ event: 'toolCallStart', ...event }); }, maxOutputTokens: 500, - model: resolveChatModel(modelId, 'openrouter'), + model: resolveChatModelFor(modelId, 'openrouter', traceKeys), prompt, stopWhen: stepCountIs(1), temperature: 0, From 28d43d657122f785f69635ae51436649e2e2d38c Mon Sep 17 00:00:00 2001 From: Omkar Bhad <60899563+omkarbhad@users.noreply.github.com> Date: Sat, 9 May 2026 08:30:40 -0400 Subject: [PATCH 09/13] refactor(kvStore): localStorage-only, no server sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The KV store no longer mirrors writes to /api/kv. Each user keeps their own settings on their own browser; nothing crosses the wire. /api/kv is deleted in a later pass. Removed: - init() server fetch - pendingWrites / flushTimer / scheduleFlush - flush() POST /api/kv - delete() DELETE /api/kv - beforeunload sendBeacon Public API preserved exactly so callers (Chat.simple, SettingsModal, exportState, persist.ts) keep working without changes: - init / get / set / delete / getCategory / getAll - flush() becomes a no-op resolved promise - isAuthenticated stays as a reactive flag (always true after init) - reset() still wipes memCache + localStorage on logout The local-only writes still skip redundant payloads via shallowEqual — without that guard, streaming chat parts would rewrite the same large payload on every tick. --- src/lib/client/stores/kvStore.svelte.ts | 199 +++++++----------------- 1 file changed, 53 insertions(+), 146 deletions(-) diff --git a/src/lib/client/stores/kvStore.svelte.ts b/src/lib/client/stores/kvStore.svelte.ts index db953a1..5177476 100644 --- a/src/lib/client/stores/kvStore.svelte.ts +++ b/src/lib/client/stores/kvStore.svelte.ts @@ -1,20 +1,36 @@ /** - * Reactive KV Store — Svelte 5 runes replacement for kvStore.ts - * Uses $state for reactive sync status so UI components can respond to changes. - * Supabase-backed via /api/kv with localStorage fallback. + * Reactive KV Store — local-only key/value storage with reactive sync flags. + * + * Uses Svelte 5 $state for `initialized` and `isAuthenticated` so UI + * components can bind to those flags directly. Backed by `localStorage` + * with an in-memory cache for synchronous reads. + * + * No server sync. Earlier versions of this store mirrored writes to /api/kv; + * that endpoint is removed as part of the local-settings revamp. Each user + * keeps their own settings on their own browser; nothing crosses the wire. + * + * Public API (preserved for callers across the client codebase): + * - init({ force? }) async; loads localStorage into memCache. + * - get(category, key) sync read from memCache. + * - set(category, key, v) writes to memCache + localStorage. + * - delete(category, key) removes from both. + * - getCategory(category) all entries under one category. + * - getAll() every entry, for debug/export. + * - flush() no-op (kept so callers don't need to change). + * - reset() clears memCache + localStorage entries; runs on logout. + * + * `isAuthenticated` is kept as a reactive flag for backward compat with + * components that gated on it; it's always `true` once init has run. */ +import { hmrRestore, hmrPreserve } from '$lib/client/util/hmr'; + const LS_PREFIX = 'kv::'; -const LOCAL_ONLY_CATEGORIES = new Set(['chat']); function cacheKey(category: string, key: string): string { return `${category}::${key}`; } -function shouldSyncToServer(category: string): boolean { - return !LOCAL_ONLY_CATEGORIES.has(category); -} - function lsSet(ck: string, value: unknown): void { if (typeof localStorage === 'undefined') return; try { @@ -33,10 +49,10 @@ function lsDel(ck: string): void { } } -// Cheap-then-deep equality check used to skip redundant KV writes. Identical -// references win immediately; primitives compare directly; for everything else -// we fall back to JSON serialization, accepting that we'll pay the stringify -// cost on a "diff" path but save it on every steady-state write. +// Cheap-then-deep equality. Identical references win immediately; primitives +// compare directly; for everything else we fall back to JSON serialization. +// Used to skip redundant writes — without this, streaming chat parts would +// rewrite the same payload on every tick. function shallowEqual(a: unknown, b: unknown): boolean { if (a === b) return true; if (a == null || b == null) return false; @@ -49,123 +65,66 @@ function shallowEqual(a: unknown, b: unknown): boolean { } class KvStore { - // Reactive state — UI can bind to these directly initialized = $state(false); + /** + * Always true after init in the local-only model — no sign-in is needed + * to read/write the user's own browser storage. Kept reactive so existing + * components that gated on it keep working without changes. + */ isAuthenticated = $state(false); - hasPending = $state(false); - lastSavedAt = $state(null); - // Internal (non-reactive) state private memCache = new Map(); - private pendingWrites = new Map(); - private flushTimer: ReturnType | null = null; private initPromise: Promise | null = null; - constructor() { - if (typeof window !== 'undefined') { - window.addEventListener('beforeunload', () => { - if (this.pendingWrites.size > 0 && this.isAuthenticated) { - const batch = Array.from(this.pendingWrites.values()); - this.pendingWrites.clear(); - this.hasPending = false; - navigator.sendBeacon( - '/api/kv', - new Blob([JSON.stringify({ batch })], { type: 'application/json' }) - ); - } - }); - } - } - /** - * Initialize the KV store. Loads localStorage first (instant), then fetches - * from /api/kv and merges server data on top. + * Load localStorage into the in-memory cache. Cheap and synchronous in + * practice — the async signature is preserved so callers (e.g. Chat.simple + * `await kv.init(...)`) don't change. + * + * `force: true` re-reads localStorage; useful after a logout/login swap if + * a different tab cleared the prefix while we were idle. */ async init(options: { force?: boolean } = {}): Promise { const force = options.force === true; if (this.initialized && !force) return; if (this.initPromise && !force) return this.initPromise; - if (this.initPromise && force) await this.initPromise; this.initPromise = (async () => { - // Load localStorage first for instant availability + if (force) this.memCache.clear(); this.lsLoadAll(); - - try { - const res = await fetch('/api/kv', { cache: 'no-store', credentials: 'include' }); - if (res.ok) { - const data = await res.json(); - const entries = data.entries || []; - for (const e of entries) { - const ck = cacheKey(e.category, e.key); - this.memCache.set(ck, e.value); - lsSet(ck, e.value); - } - this.isAuthenticated = true; - } else { - this.isAuthenticated = false; - } - } catch { - // Silently fail — localStorage data is already loaded - } - this.initialized = true; + this.isAuthenticated = true; this.initPromise = null; })(); return this.initPromise; } - /** - * Get a value from the KV store (sync read from memory cache). - */ + /** Sync read from the in-memory cache. */ get(category: string, key: string): T | null { const v = this.memCache.get(cacheKey(category, key)); return v !== undefined ? (v as T) : null; } /** - * Set a value. Updates memCache + localStorage immediately, - * then schedules a debounced flush to server. Skips redundant writes when - * the new value is identical to the cached one — avoids re-stringifying - * large arrays (chat messages, parts, artifacts) on every streaming tick. + * Write to memCache + localStorage. Skips when the new value matches the + * cached one — without this, streaming chat parts would rewrite the same + * (large) payload on every tick. */ set(category: string, key: string, value: unknown): void { const ck = cacheKey(category, key); if (this.memCache.has(ck) && shallowEqual(this.memCache.get(ck), value)) return; this.memCache.set(ck, value); lsSet(ck, value); - if (!shouldSyncToServer(category)) return; - this.pendingWrites.set(ck, { category, key, value }); - this.hasPending = true; - this.scheduleFlush(); } - /** - * Delete a value from the KV store. - */ delete(category: string, key: string): void { const ck = cacheKey(category, key); this.memCache.delete(ck); lsDel(ck); - this.pendingWrites.delete(ck); - this.hasPending = this.pendingWrites.size > 0; - - if (!shouldSyncToServer(category)) return; - if (!this.isAuthenticated) return; - - fetch(`/api/kv?category=${encodeURIComponent(category)}&key=${encodeURIComponent(key)}`, { - method: 'DELETE', - credentials: 'include', - keepalive: true - }).catch(() => { - /* silent */ - }); } - /** - * Get all entries for a category. - */ + /** Every entry under one category, returned as a plain object. */ getCategory(category: string): Record { const result: Record = {}; const prefix = category + '::'; @@ -178,9 +137,7 @@ class KvStore { return result; } - /** - * Get all entries (for admin/debug). - */ + /** Every entry, for debug/export. */ getAll(): { category: string; key: string; value: unknown }[] { const result: { category: string; key: string; value: unknown }[] = []; for (const [k, v] of this.memCache.entries()) { @@ -191,49 +148,18 @@ class KvStore { } /** - * Flush all pending writes to server immediately. + * No-op in the local-only model. Kept so existing callers + * (`await kv.flush()`, `kv.flush()`) don't break. */ async flush(): Promise { - if (this.pendingWrites.size === 0) return; - if (!this.isAuthenticated) { - this.pendingWrites.clear(); - this.hasPending = false; - return; - } - - const batch = Array.from(this.pendingWrites.values()).filter((entry) => - shouldSyncToServer(entry.category) - ); - this.pendingWrites.clear(); - if (batch.length === 0) { - this.hasPending = false; - return; - } - - try { - await fetch('/api/kv', { - body: JSON.stringify({ batch }), - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - keepalive: true, - method: 'POST' - }); - this.lastSavedAt = Date.now(); - this.hasPending = false; - } catch { - // Re-queue on failure - for (const entry of batch) { - this.pendingWrites.set(cacheKey(entry.category, entry.key), entry); - } - this.hasPending = this.pendingWrites.size > 0; - } + // No server queue to flush — writes are synchronous to localStorage. } /** - * Reset the KV store (for logout). Clears all data and state. + * Wipe every kv entry (memory + localStorage). Called on logout so the + * next user landing on this browser starts fresh. */ reset(): void { - // Clear localStorage kv entries if (typeof localStorage !== 'undefined') { const toRemove: string[] = []; for (let i = 0; i < localStorage.length; i++) { @@ -244,30 +170,13 @@ class KvStore { } this.memCache.clear(); - this.pendingWrites.clear(); - - if (this.flushTimer) { - clearTimeout(this.flushTimer); - this.flushTimer = null; - } - this.initialized = false; this.isAuthenticated = false; - this.hasPending = false; - this.lastSavedAt = null; this.initPromise = null; } // --- Private helpers --- - private scheduleFlush(): void { - if (this.flushTimer) clearTimeout(this.flushTimer); - this.flushTimer = setTimeout(() => { - this.flushTimer = null; - this.flush(); - }, 1500); - } - private lsLoadAll(): void { if (typeof localStorage === 'undefined') return; for (let i = 0; i < localStorage.length; i++) { @@ -280,14 +189,12 @@ class KvStore { if (raw !== null) this.memCache.set(ck, JSON.parse(raw)); } } catch { - /* ignore */ + /* corrupt entry — skip */ } } } } } -import { hmrRestore, hmrPreserve } from '$lib/client/util/hmr'; - export const kv: KvStore = hmrRestore('kvStore') ?? new KvStore(); hmrPreserve('kvStore', () => kv); From 04cbfd00a9b84418af8e820c11949920d5061643 Mon Sep 17 00:00:00 2001 From: Omkar Bhad <60899563+omkarbhad@users.noreply.github.com> Date: Sat, 9 May 2026 08:37:45 -0400 Subject: [PATCH 10/13] feat(settings): per-user localStorage namespacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings (UI, AI keys, personalization, editor) and panel layout are now keyed by the active user id, so two users on the same browser don't share each other's data. Mechanism: - settings.svelte.ts grows `setActiveUserId(userId)` and `getActiveUserNamespace()`. - Every PersistentSetting registers itself; setActiveUserId rebinds each one's storage key (`mermaid__`) and reloads value from the new slot. UI sees the swap reactively via $state. - A second registration channel — `subscribeNamespaceChange(fn)` — lets non-PersistentSetting consumers (panels) reload too. - panels.svelte.ts namespaces its own kv keys (`graphini_panels_v4_` / `graphini_panel_order_v1_`), subscribes to namespace changes, and reloads via `reloadFromActiveNamespace` which also cancels any in-flight debounced save so user A's pending state never lands in user B's slot. auth.svelte.ts wires the active-user signal: - module-level: `setActiveUserId(cached.user?.id ?? null)` on import so the first paint after a hard reload reads from the right slot - fetchMe / loginLocal / register / logout: rebind on every state.user mutation panels.svelte.ts also drops its server-sync writes: - `savePrefsToServer` (PUT /api/user/preferences) is gone — panels are per-browser now, no cross-device sync. - `syncPreferencesFromServer` becomes a no-op compatibility shim while callers still exist; safe to delete in a follow-up. Sets in settings.svelte.ts use plain Set rather than SvelteSet (documented inline) — they're mutated synchronously in setActiveUserId and never observed reactively. Pre-existing sort-keys cleanup in auth.svelte.ts so the pre-commit hook passes; no behavior change. --- src/lib/client/stores/auth.svelte.ts | 32 ++++-- src/lib/client/stores/panels.svelte.ts | 123 ++++++++++------------- src/lib/client/stores/settings.svelte.ts | 121 +++++++++++++++++++--- 3 files changed, 182 insertions(+), 94 deletions(-) diff --git a/src/lib/client/stores/auth.svelte.ts b/src/lib/client/stores/auth.svelte.ts index ad06f8d..a459b2d 100644 --- a/src/lib/client/stores/auth.svelte.ts +++ b/src/lib/client/stores/auth.svelte.ts @@ -4,6 +4,7 @@ */ import { syncPreferencesFromServer } from '$lib/client/stores/panels.svelte'; +import { setActiveUserId } from '$lib/client/stores/settings.svelte'; import { hmrRestore, hmrPreserve } from '$lib/client/util/hmr'; interface AuthUser { @@ -71,6 +72,11 @@ const state = $state( ); hmrPreserve('authState', () => ({ ...state })); +// Bind the localStorage settings namespace to the cached user immediately so +// the first paint after a hard reload reads from the right slot. fetchMe() +// will rebind again once the server confirms identity. +setActiveUserId(state.user?.id ?? null); + async function fetchMe(): Promise { try { state.loading = true; @@ -80,7 +86,10 @@ async function fetchMe(): Promise { state.user = data.user; state.credits = data.credits; saveCachedAuth(data.user, data.credits); - // Sync preferences from server after auth check + setActiveUserId(state.user?.id ?? null); + // syncPreferencesFromServer is a no-op now (panels load from + // localStorage). Kept as a compatibility shim while callers still + // exist; safe to remove later. if (state.user) syncPreferencesFromServer().catch(() => { /* silent */ @@ -89,11 +98,13 @@ async function fetchMe(): Promise { state.user = null; state.credits = null; saveCachedAuth(null, null); + setActiveUserId(null); } } catch { state.user = null; state.credits = null; saveCachedAuth(null, null); + setActiveUserId(null); } finally { state.loading = false; state.initialized = true; @@ -129,6 +140,7 @@ async function loginLocal( if (res.ok && data.user) { state.user = data.user; saveCachedAuth(data.user, null); + setActiveUserId(state.user?.id ?? null); state.initialized = true; // Fetch full user + credits await fetchMe(); @@ -162,6 +174,7 @@ async function register( if (res.ok && data.user) { state.user = data.user; saveCachedAuth(data.user, null); + setActiveUserId(state.user?.id ?? null); state.initialized = true; await fetchMe(); return { success: true }; @@ -178,6 +191,7 @@ function logout(): void { state.user = null; state.credits = null; saveCachedAuth(null, null); + setActiveUserId(null); // Clear local session cookie by setting expired document.cookie = 'graphini_session=; Path=/; Max-Age=0'; window.location.href = '/api/auth/logout'; @@ -200,6 +214,14 @@ export const authStore = { get credits() { return state.credits; }, + /** + * True when there is *any* identity attached (real user OR guest cookie). + * Use this to gate DB persistence — guests get to persist too, just inside + * their own quota. + */ + get hasIdentity() { + return !!state.user; + }, get hasSession() { return !!state.user; }, @@ -216,14 +238,6 @@ export const authStore = { get isLoggedIn() { return !!state.user && state.user.is_guest !== true; }, - /** - * True when there is *any* identity attached (real user OR guest cookie). - * Use this to gate DB persistence — guests get to persist too, just inside - * their own quota. - */ - get hasIdentity() { - return !!state.user; - }, login, loginLocal, logout, diff --git a/src/lib/client/stores/panels.svelte.ts b/src/lib/client/stores/panels.svelte.ts index c6f04ec..47ef095 100644 --- a/src/lib/client/stores/panels.svelte.ts +++ b/src/lib/client/stores/panels.svelte.ts @@ -1,4 +1,8 @@ import { kv } from '$lib/client/stores/kvStore.svelte'; +import { + getActiveUserNamespace, + subscribeNamespaceChange +} from '$lib/client/stores/settings.svelte'; import { hmrRestore, hmrPreserve } from '$lib/client/util/hmr'; // ── Types ── @@ -32,30 +36,21 @@ const PANEL_DEFAULTS: Record> = { chat: { label: 'Chat', maxWidth: 9999, minWidth: 220, visible: true, width: 380 } }; -const STORAGE_KEY = 'graphini_panels_v4'; -const ORDER_STORAGE_KEY = 'graphini_panel_order_v1'; - -// ── Server Sync ── - -let serverSyncTimeout: ReturnType | null = null; - -function savePrefsToServer(key: string, value: unknown) { - if (typeof window === 'undefined') return; - if (serverSyncTimeout) clearTimeout(serverSyncTimeout); - serverSyncTimeout = setTimeout(async () => { - try { - await fetch('/api/user/preferences', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ preferences: { [key]: value } }), - credentials: 'include' - }); - } catch { - /* silent fail */ - } - }, 300); +// Panel layout is per-user. Storage keys are namespaced by the active user +// id (or 'guest' when no one is signed in) so two users on the same browser +// don't share their layout. +function panelStateKey(): string { + return `graphini_panels_v4_${getActiveUserNamespace()}`; +} +function panelOrderKey(): string { + return `graphini_panel_order_v1_${getActiveUserNamespace()}`; } +// Panel preferences are stored in localStorage via kv (already wired below). +// Earlier versions also mirrored each change to /api/user/preferences for +// cross-device sync; that endpoint is deleted as part of the local-settings +// revamp. Each browser keeps its own panel layout — deliberate trade-off. + // ── Helpers ── function buildDefaults(): Record { @@ -69,7 +64,7 @@ function loadPanelState(): Record { if (typeof window === 'undefined') return defaults; try { - const saved = kv.get>>>('panels', STORAGE_KEY); + const saved = kv.get>>>('panels', panelStateKey()); if (!saved) return defaults; for (const id of DEFAULT_ORDER) { const entry = saved[id]; @@ -90,7 +85,7 @@ function loadPanelState(): Record { function loadPanelOrder(): PanelId[] { if (typeof window === 'undefined') return [...DEFAULT_ORDER]; try { - const saved = kv.get('panels', ORDER_STORAGE_KEY); + const saved = kv.get('panels', panelOrderKey()); if (!saved) return [...DEFAULT_ORDER]; if ( Array.isArray(saved) && @@ -123,14 +118,12 @@ class PanelManager { for (const id of DEFAULT_ORDER) { toSave[id] = { visible: this.panels[id].visible, width: this.panels[id].width }; } - kv.set('panels', STORAGE_KEY, toSave); - savePrefsToServer('panelState', toSave); + kv.set('panels', panelStateKey(), toSave); }, 150); } private saveOrder() { - kv.set('panels', ORDER_STORAGE_KEY, this.order); - savePrefsToServer('panelOrder', this.order); + kv.set('panels', panelOrderKey(), this.order); } toggle(id: PanelId): void { @@ -199,10 +192,23 @@ class PanelManager { for (const id of DEFAULT_ORDER) { toSave[id] = { visible: this.panels[id].visible, width: this.panels[id].width }; } - kv.set('panels', STORAGE_KEY, toSave); - kv.set('panels', ORDER_STORAGE_KEY, this.order); - savePrefsToServer('panelState', toSave); - savePrefsToServer('panelOrder', this.order); + kv.set('panels', panelStateKey(), toSave); + kv.set('panels', panelOrderKey(), this.order); + } + + /** + * Reload from the current user namespace's localStorage slot. Called by + * the settings-namespace subscriber when the active user changes; cancels + * any in-flight debounced save so we don't write user A's pending state + * into user B's slot. + */ + reloadFromActiveNamespace(): void { + if (this.saveTimeout) { + clearTimeout(this.saveTimeout); + this.saveTimeout = null; + } + this.panels = loadPanelState(); + this.order = loadPanelOrder(); } } @@ -212,45 +218,18 @@ export const panels: PanelManager = hmrRestore('panelsInstance') ?? new PanelMan hmrPreserve('panelsInstance', () => panels); export const PANEL_ORDER = DEFAULT_ORDER; -/** Call after login to pull server prefs into the panels instance */ +// Reload panels when the active user changes so two users on the same +// browser don't see each other's layout. +subscribeNamespaceChange(() => panels.reloadFromActiveNamespace()); + +/** + * Compatibility shim. Earlier versions pulled panel layout from + * /api/user/preferences after login; the local-only revamp removed that + * endpoint. Panel state now lives in localStorage and loads at construction + * time via `loadPanelState` / `loadPanelOrder`. Callers don't need to call + * this anymore, but it stays as a no-op so we don't have to delete every + * call site in one go. + */ export async function syncPreferencesFromServer(): Promise { - if (typeof window === 'undefined') return; - try { - const res = await fetch('/api/user/preferences', { credentials: 'include' }); - if (!res.ok) return; - const data = await res.json(); - const prefs = data.preferences; - if (!prefs) return; - - if (prefs.panelOrder) { - const order = prefs.panelOrder as PanelId[]; - if ( - Array.isArray(order) && - order.length === DEFAULT_ORDER.length && - DEFAULT_ORDER.every((id) => order.includes(id)) - ) { - panels.reorder(order); - } - } - - if (prefs.panelState) { - const saved = prefs.panelState as Partial< - Record - >; - for (const id of DEFAULT_ORDER) { - const entry = saved[id]; - if (entry) { - if (typeof entry.visible === 'boolean') { - if (entry.visible) panels.show(id); - else panels.hide(id); - } - if (typeof entry.width === 'number') { - panels.setWidth(id, entry.width); - } - } - } - } - } catch { - /* silent */ - } + // No-op: panels load from localStorage at construction time. } diff --git a/src/lib/client/stores/settings.svelte.ts b/src/lib/client/stores/settings.svelte.ts index ab23606..e29a4a9 100644 --- a/src/lib/client/stores/settings.svelte.ts +++ b/src/lib/client/stores/settings.svelte.ts @@ -1,41 +1,131 @@ /** - * Persistent Settings Store — Svelte 5 runes replacement for persistentStore.ts - * Uses PersistentSetting class backed by kvStore for persistence. + * Persistent Settings Store — Svelte 5 runes, localStorage-backed via kvStore. + * + * Namespace contract: + * Each `PersistentSetting` writes under `mermaid__` in the + * `settings` kv category, where `` is the active user's id (or + * `guest` when nothing is signed in). On user change — login, logout, + * guest cookie rotating — `setActiveUserId` swaps every registered + * setting's namespace and reloads its value from the new slot. + * + * Two users on the same browser therefore can't see each other's keys or + * preferences. A guest who later signs up keeps their guest-namespaced + * data in localStorage but the post-sign-up store starts from the new + * user.id slot — guest data isn't migrated. Per the revamp's hard-reset + * policy, that's intentional. + * + * The `mermaid_` prefix is kept for grep-ability with older builds. */ import { browser } from '$app/environment'; import { kv } from './kvStore.svelte'; import { hmrRestore, hmrPreserve } from '$lib/client/util/hmr'; +// --------------------------------------------------------------------------- +// Active user namespace +// --------------------------------------------------------------------------- + +const GUEST_NAMESPACE = 'guest'; + +let activeNamespace: string = GUEST_NAMESPACE; + +/** + * Plain Sets are intentional — these collections are mutated in + * setActiveUserId and iterated synchronously, never observed by reactive + * computations. SvelteSet would just add overhead. + */ +// eslint-disable-next-line svelte/prefer-svelte-reactivity +const registeredSettings = new Set(); + +// eslint-disable-next-line svelte/prefer-svelte-reactivity +const namespaceSubscribers = new Set<(userNamespace: string) => void>(); + +interface PersistentSettingBase { + rebindNamespace(): void; +} + +/** + * Switch the active user namespace and reload every registered setting and + * subscriber from its new slot. Call this on login, logout, and guest-cookie + * identity swaps. Pass `null` (or omit) to revert to the guest namespace. + */ +export function setActiveUserId(userId: string | null | undefined): void { + const next = userId && userId.length > 0 ? userId : GUEST_NAMESPACE; + if (next === activeNamespace) return; + activeNamespace = next; + for (const setting of registeredSettings) setting.rebindNamespace(); + for (const subscriber of namespaceSubscribers) { + try { + subscriber(activeNamespace); + } catch (err) { + console.error('[settings] namespace subscriber threw:', err); + } + } +} + +export function getActiveUserNamespace(): string { + return activeNamespace; +} + +/** + * Subscribe to user-namespace changes. The callback fires after the new + * namespace is in effect; use it to reload your own localStorage-backed + * state from the new slot. + * + * Returns an unsubscribe function. + */ +export function subscribeNamespaceChange(fn: (userNamespace: string) => void): () => void { + namespaceSubscribers.add(fn); + return () => namespaceSubscribers.delete(fn); +} + // --------------------------------------------------------------------------- // PersistentSetting // --------------------------------------------------------------------------- -export class PersistentSetting { +export class PersistentSetting implements PersistentSettingBase { value = $state({} as T); - private key: string; - private defaultValue: T; + private readonly key: string; + private readonly defaultValue: T; + private boundNamespace: string; constructor(key: string, defaultValue: T) { this.key = key; this.defaultValue = defaultValue; + this.boundNamespace = activeNamespace; + this.value = this.loadFromStorage(); + registeredSettings.add(this); + } - // Load from kv and merge with defaults - const stored = browser ? kv.get>('settings', 'mermaid_' + key) : null; - this.value = stored ? { ...defaultValue, ...stored } : { ...defaultValue }; + private storageKey(): string { + return `mermaid_${this.boundNamespace}_${this.key}`; + } + + private loadFromStorage(): T { + if (!browser) return { ...this.defaultValue }; + const stored = kv.get>('settings', this.storageKey()); + return stored ? { ...this.defaultValue, ...stored } : { ...this.defaultValue }; + } + + /** + * Called by `setActiveUserId` when the active user changes. Rebinds the + * storage key to the new namespace and reloads the value from there. + * Components observing `value` see the swap reactively. + */ + rebindNamespace(): void { + if (this.boundNamespace === activeNamespace) return; + this.boundNamespace = activeNamespace; + this.value = this.loadFromStorage(); } set(newValue: T): void { this.value = newValue; - if (browser) { - kv.set('settings', 'mermaid_' + this.key, newValue); - } + if (browser) kv.set('settings', this.storageKey(), newValue); } update(fn: (v: T) => T): void { - const updated = fn(this.value); - this.set(updated); + this.set(fn(this.value)); } reset(): void { @@ -204,6 +294,11 @@ export function getSessionId(): string { return sessionId; } +/** + * Local-cached client user id. The authoritative identity comes from + * authStore (`/api/auth/me`); this helper exists for callers that need a + * synchronous read before auth has finished loading. + */ export function getUserId(): string | null { return kv.get('settings', 'mermaid_user_id'); } From b7cfbf147ff29716c7dc7eae41e2e4cdd790a122 Mon Sep 17 00:00:00 2001 From: Omkar Bhad <60899563+omkarbhad@users.noreply.github.com> Date: Sat, 9 May 2026 08:42:33 -0400 Subject: [PATCH 11/13] feat(client/chat): send x-provider-* headers to chat / audio / upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat.simple.svelte now injects provider-key headers built from the localStorage-backed aiSettings store on every fetch that hits a paid endpoint: - /api/chat (3 sites: streamed turn, prompt-improver, conversation title generation) - /api/audio (TTS) - /api/upload (vision / PDF processing) Without these, every request to the per-request-keys server (Pass B1+) returns "API key is not set" — chat works again with whatever key the user pasted into Settings > Models & Keys. Headers are spread after Content-Type so JSON requests keep their explicit content type. Multipart requests (audio, upload) only carry the x-provider-* headers; the FormData boundary is set automatically. Pre-commit hook runs prettier + eslint on staged files and produced no warnings — the surface change is purely additive headers. SettingsModal.svelte's /api/model-lab caller and the full no-server-roundtrips revamp are deferred to Pass B7, which will handle that file as a unit. --- .../chat/components/Chat.simple.svelte | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/lib/client/features/chat/components/Chat.simple.svelte b/src/lib/client/features/chat/components/Chat.simple.svelte index 92adc0c..04d1252 100644 --- a/src/lib/client/features/chat/components/Chat.simple.svelte +++ b/src/lib/client/features/chat/components/Chat.simple.svelte @@ -59,6 +59,8 @@ import { workspaceStore } from '$lib/client/stores/workspace.svelte'; import { kv } from '$lib/client/stores/kvStore.svelte'; import { modelsStore } from '$lib/client/stores/models.svelte'; + import { aiSettings } from '$lib/client/stores/settings.svelte'; + import { providerKeyHeaders } from '$lib/client/util/provider-keys'; import ProviderIcon from '$lib/client/features/chat/components/ProviderIcon.svelte'; import ToolSimpleChip from '$lib/client/features/chat/components/ToolSimpleChip.svelte'; import { @@ -1134,7 +1136,11 @@ try { const formData = new FormData(); formData.append('audio', blob, 'recording.webm'); - const res = await fetch('/api/audio', { method: 'POST', body: formData }); + const res = await fetch('/api/audio', { + method: 'POST', + body: formData, + headers: providerKeyHeaders(aiSettings.value) + }); if (res.ok) { const data = await res.json(); if (data.text) { @@ -1492,7 +1498,7 @@ try { const res = await fetch('/api/chat', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...providerKeyHeaders(aiSettings.value) }, body: JSON.stringify({ currentDiagram: '', currentMarkdown: '', @@ -1542,7 +1548,7 @@ try { const res = await fetch('/api/chat', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...providerKeyHeaders(aiSettings.value) }, body: JSON.stringify({ currentDiagram: '', currentMarkdown: '', @@ -1699,7 +1705,11 @@ formData.append('file', blob, file.filename || 'attachment'); formData.append('sessionId', sessionId); formData.append('supportsImages', selectedModel?.imageSupport ? 'true' : 'false'); - const res = await fetch('/api/upload', { method: 'POST', body: formData }); + const res = await fetch('/api/upload', { + method: 'POST', + body: formData, + headers: providerKeyHeaders(aiSettings.value) + }); if (!res.ok) { const message = await res.text().catch(() => ''); console.error('Upload failed:', res.status, message); @@ -1896,7 +1906,7 @@ const activeEngine = getActiveWorkspaceEngine(); return fetch('/api/chat', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...providerKeyHeaders(aiSettings.value) }, body: JSON.stringify({ activeFileId: filesStore.activeId, activeTabEngine: activeEngine, From d0394657bd5ea9b5b0532b85b0478b3648cc7304 Mon Sep 17 00:00:00 2001 From: Omkar Bhad <60899563+omkarbhad@users.noreply.github.com> Date: Sat, 9 May 2026 08:52:44 -0400 Subject: [PATCH 12/13] refactor(settings-modal): local-only state, no server roundtrips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Settings panel's per-user state moves entirely to localStorage: - AI provider keys (OpenAI / Anthropic / OpenRouter): saveProviderCredential drops the adminPost('setProviderApiKey', ...) call. updateProviderCredential already wrote to aiSettings; that's the only thing that runs now. The admin endpoint is gone (returns 410 from B3) and was the original guest-leak vector — admins set their own keys in Settings like everyone else. - Search-provider keys (Brave / Tavily): saveUserApiKey / clearUserApiKey drop the /api/user/api-keys PUT/DELETE calls and write directly to aiSettings.{braveSearchApiKey, tavilyApiKey}. braveSearchPresent and tavilyPresent are now $derived from aiSettings instead of fetching the server's "presence flags" GET. - loadUserApiKeys() is gone; onMount no longer fetches /api/user/api-keys. - /api/model-lab fetch sends provider-key headers (B6 plumbing). Pre-commit lint debt fixed in the same pass since the file is in a known-good state: - Object.fromEntries(filter(...)) instead of `delete obj[dyn]` for safe key removal in deleteMemory - Replaced `Record` with typed EnabledModelRow and ProviderModelRow interfaces - Replaced loose `any` casts with narrowed property access - Added each-block keys for tools, categories, and memory-save modes - {@const} hoisted to the immediate child of {#each} No behavior change beyond the server roundtrips that were intentionally removed — every UI affordance still saves and reads the same way from the user's perspective, just locally. --- .../features/settings/SettingsModal.svelte | 154 ++++++++++-------- 1 file changed, 88 insertions(+), 66 deletions(-) diff --git a/src/lib/client/features/settings/SettingsModal.svelte b/src/lib/client/features/settings/SettingsModal.svelte index 9ca8fbe..71014c3 100644 --- a/src/lib/client/features/settings/SettingsModal.svelte +++ b/src/lib/client/features/settings/SettingsModal.svelte @@ -53,16 +53,27 @@ let newMemoryKey = $state(''); let newMemoryValue = $state(''); + interface MemoryEntry { + value: unknown; + savedAt?: string; + } + async function loadMemories() { memoryLoading = true; try { const data = await kv.get('memories', 'all'); if (data && typeof data === 'object' && !Array.isArray(data)) { - memories = Object.entries(data as Record).map(([k, v]: [string, any]) => ({ - key: k, - value: typeof v === 'object' ? v.value || JSON.stringify(v) : String(v), - savedAt: typeof v === 'object' ? v.savedAt || '' : '' - })); + memories = Object.entries(data as Record).map(([k, v]) => { + const entry = v as MemoryEntry | string; + if (entry && typeof entry === 'object' && 'value' in entry) { + return { + key: k, + value: typeof entry.value === 'string' ? entry.value : JSON.stringify(entry.value), + savedAt: entry.savedAt ?? '' + }; + } + return { key: k, value: String(entry), savedAt: '' }; + }); } else { memories = []; } @@ -75,8 +86,8 @@ async function saveMemory(key: string, value: string) { const current = (await kv.get('memories', 'all')) || {}; const updated = { - ...(current as Record), - [key]: { value, savedAt: new Date().toISOString() } + ...(current as Record), + [key]: { savedAt: new Date().toISOString(), value } }; await kv.set('memories', 'all', updated); await loadMemories(); @@ -88,8 +99,11 @@ async function deleteMemory(key: string) { const current = (await kv.get('memories', 'all')) || {}; - const updated = { ...(current as Record) }; - delete updated[key]; + // Build a new object excluding the deleted key — avoids the + // `delete obj[dyn]` pattern that flips perf characteristics on V8. + const updated = Object.fromEntries( + Object.entries(current as Record).filter(([k]) => k !== key) + ); await kv.set('memories', 'all', updated); await loadMemories(); } @@ -160,29 +174,18 @@ let openAiApiKeyInput = $state(''); let openRouterApiKeyInput = $state(''); - // Per-user search-provider keys (stored via /api/user/api-keys, encrypted - // server-side). Unlike AI provider keys above, these are NOT global — - // every user (including guests) carries their own. + // Per-user search-provider keys live in localStorage on aiSettings, same + // as the AI provider keys. Earlier versions stored these server-side via + // /api/user/api-keys (encrypted at rest); the local-settings revamp + // moved them client-side too so the server holds zero secrets. let braveSearchInput = $state(''); let tavilyInput = $state(''); - let braveSearchPresent = $state(false); - let tavilyPresent = $state(false); + const braveSearchPresent = $derived(Boolean(aiSettings.value.braveSearchApiKey)); + const tavilyPresent = $derived(Boolean(aiSettings.value.tavilyApiKey)); let searchKeySaving = $state(false); let searchKeyMessage = $state<{ kind: 'error' | 'notice'; text: string } | null>(null); - async function loadUserApiKeys() { - try { - const res = await fetch('/api/user/api-keys', { credentials: 'include' }); - if (!res.ok) return; - const data = await res.json(); - braveSearchPresent = Boolean(data?.providers?.brave_search); - tavilyPresent = Boolean(data?.providers?.tavily); - } catch { - /* non-fatal */ - } - } - - async function saveUserApiKey(provider: 'brave_search' | 'tavily', key: string) { + function saveUserApiKey(provider: 'brave_search' | 'tavily', key: string) { if (!key.trim() || key.trim().length < 8) { searchKeyMessage = { kind: 'error', text: 'Key must be at least 8 characters.' }; return; @@ -190,22 +193,13 @@ searchKeySaving = true; searchKeyMessage = null; try { - const res = await fetch('/api/user/api-keys', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ provider, key: key.trim() }) - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data?.error || `Failed to save ${provider}`); - } + const trimmed = key.trim(); if (provider === 'brave_search') { + aiSettings.update((s) => ({ ...s, braveSearchApiKey: trimmed })); braveSearchInput = ''; - braveSearchPresent = true; } else { + aiSettings.update((s) => ({ ...s, tavilyApiKey: trimmed })); tavilyInput = ''; - tavilyPresent = true; } searchKeyMessage = { kind: 'notice', text: 'Saved.' }; } catch (err) { @@ -218,17 +212,15 @@ } } - async function clearUserApiKey(provider: 'brave_search' | 'tavily') { + function clearUserApiKey(provider: 'brave_search' | 'tavily') { searchKeySaving = true; searchKeyMessage = null; try { - const res = await fetch(`/api/user/api-keys?provider=${encodeURIComponent(provider)}`, { - method: 'DELETE', - credentials: 'include' - }); - if (!res.ok) throw new Error('Failed to clear key'); - if (provider === 'brave_search') braveSearchPresent = false; - else tavilyPresent = false; + if (provider === 'brave_search') { + aiSettings.update((s) => ({ ...s, braveSearchApiKey: '' })); + } else { + aiSettings.update((s) => ({ ...s, tavilyApiKey: '' })); + } searchKeyMessage = { kind: 'notice', text: 'Cleared.' }; } catch (err) { searchKeyMessage = { @@ -240,10 +232,41 @@ } } - let enabledModels = $state[]>([]); + /** Shape of rows returned by the admin enabled-models API. */ + interface EnabledModelRow { + model_id: string; + model_name: string; + provider?: string; + category?: string; + description?: string; + is_enabled?: boolean; + is_free?: boolean; + gems_per_message?: number; + max_tokens?: number; + tool_support?: boolean; + metadata?: Record; + sort_order?: number; + } + + /** Provider catalog rows (OpenAI / Anthropic / OpenRouter all differ). */ + interface ProviderModelRow { + id: string; + name?: string; + description?: string; + created?: number; + contextWindow?: number; + context_length?: number; + architecture?: { modality?: string }; + provider?: string; + pricing?: { prompt?: string; completion?: string }; + supportedParameters?: string[]; + supported_parameters?: string[]; + } + + let enabledModels = $state([]); let enabledModelsLoading = $state(false); let modelSearchProvider = $state('openrouter'); - let providerModels = $state[]>([]); + let providerModels = $state([]); let providerModelsLoading = $state(false); let providerModelSearch = $state(''); let modelAdminError = $state(''); @@ -288,7 +311,7 @@ return modelId.startsWith(`${provider}/`) ? modelId : `${provider}/${modelId}`; } - function buildProviderImportPayload(provider: ModelSearchProvider, model: Record) { + function buildProviderImportPayload(provider: ModelSearchProvider, model: ProviderModelRow) { const fullId = providerModelId(provider, model.id); const isFree = model.pricing?.prompt === '0' && model.pricing?.completion === '0'; @@ -389,7 +412,7 @@ } } - async function importProviderModel(model: Record) { + async function importProviderModel(model: ProviderModelRow) { const modelData = buildProviderImportPayload(modelSearchProvider, model); modelAdminError = ''; modelAdminNotice = ''; @@ -438,12 +461,10 @@ modelAdminError = ''; modelAdminNotice = ''; try { - await adminPost({ - action: 'setProviderApiKey', - apiKey: credential, - credentialType, - provider - }); + // Keys live only in localStorage (per-user namespaced via the settings + // store). Earlier versions also POSTed to /api/admin setProviderApiKey + // which stored a global encrypted row in app_settings — that endpoint + // is gone (it was the original guest-leak vector). updateProviderCredential(provider, credential, credentialType); const successMsg = `${labelByProvider[provider]} ${credentialLabel} saved`; modelAdminNotice = successMsg; @@ -565,11 +586,13 @@ loadEnabledModels(); loadChatCompactionSettings(); loadVoiceSettings(); - loadUserApiKeys(); + // Search-provider keys live in aiSettings now — no server fetch needed. // Load memories loadMemories(); - return () => {}; + return () => { + // No teardown needed — every subscription is HMR-aware via the store. + }; }); function updateProviderCredential( @@ -578,7 +601,7 @@ credentialType: ProviderCredential ) { const keyField = credentialType === 'auth_token' ? `${provider}AuthToken` : `${provider}ApiKey`; - aiSettings.update((s: any) => ({ + aiSettings.update((s) => ({ ...s, [keyField]: value })); @@ -1150,6 +1173,7 @@
{#each filteredProviderModels as model (model.id)} {@const imported = isProviderModelImported(model.id)} + {@const ctx = model.contextWindow || model.context_length || 0}
@@ -1159,9 +1183,7 @@ {model.id}
- {model.contextWindow || model.context_length - ? `${((model.contextWindow || model.context_length) / 1000).toFixed(0)}k context` - : 'Context unknown'} + {ctx ? `${(ctx / 1000).toFixed(0)}k context` : 'Context unknown'}
{#if imported} @@ -1267,7 +1289,7 @@
- {#each TOOL_CATEGORIES as cat} + {#each TOOL_CATEGORIES as cat (cat.id)} {@const catTools = toolsConfig.filter((t) => t.category === cat.id)} {#if catTools.length > 0}
@@ -1276,7 +1298,7 @@ {cat.label}
- {#each catTools as t} + {#each catTools as t (t.id)}