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/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, 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)}