diff --git a/src/db/migrate.ts b/src/db/migrate.ts index 3df80cf..667bc9b 100644 --- a/src/db/migrate.ts +++ b/src/db/migrate.ts @@ -205,7 +205,8 @@ export async function rollbackLastMigration(db: SQLiteDB): Promise { export async function getMigrationStatus(db: SQLiteDB): Promise { try { await ensureSchemaVersionTable(db); - } catch { + } catch (err) { + logger.warn('Failed to ensure schema version table', err); return []; } diff --git a/src/db/repository/base.ts b/src/db/repository/base.ts index 5b85444..d51d65d 100644 --- a/src/db/repository/base.ts +++ b/src/db/repository/base.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { getDb, SQLiteDB } from '../client'; +import { logger } from '../../lib/logger'; export class RepositoryBase { protected get db(): SQLiteDB { @@ -26,7 +27,8 @@ export class RepositoryBase { if (typeof r.metadata === 'string') { try { r.metadata = JSON.parse(r.metadata) as Record; - } catch { + } catch (err) { + logger.debug('Failed to parse metadata JSON, defaulting to empty object', err); r.metadata = {}; } } diff --git a/src/features/editor/__tests__/extensions.test.ts b/src/features/editor/__tests__/extensions.test.ts new file mode 100644 index 0000000..9d2c118 --- /dev/null +++ b/src/features/editor/__tests__/extensions.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from 'vitest'; +import { Editor } from '@tiptap/core'; +import StarterKit from '@tiptap/starter-kit'; +import { MentionExtension } from '../MentionExtension'; +import { ClaimExtension } from '../ClaimExtension'; + +function createEditor(extensions = []) { + return new Editor({ + extensions: [StarterKit, ...extensions], + content: '', + }); +} + +describe('MentionExtension', () => { + it('registers mention mark', () => { + const editor = createEditor([MentionExtension]); + expect(editor.extensionManager.extensions.find(e => e.name === 'mention')).toBeDefined(); + }); + + it('can set a mention mark', () => { + const editor = createEditor([MentionExtension]); + editor.commands.setContent('

Test

'); + editor.commands.setTextSelection({ from: 1, to: 5 }); + editor.commands.setMention({ entityId: 'e1', entityName: 'Entity 1' }); + const html = editor.getHTML(); + expect(html).toContain('data-entity-id="e1"'); + expect(html).toContain('data-entity-name="Entity 1"'); + }); + + it('can toggle a mention mark', () => { + const editor = createEditor([MentionExtension]); + editor.commands.setContent('

Test

'); + editor.commands.setTextSelection({ from: 1, to: 5 }); + editor.commands.toggleMention({ entityId: 'e1', entityName: 'Entity 1' }); + const html = editor.getHTML(); + expect(html).toContain('data-entity-id="e1"'); + editor.commands.setTextSelection({ from: 1, to: 5 }); + editor.commands.toggleMention({ entityId: 'e1', entityName: 'Entity 1' }); + const htmlAfter = editor.getHTML(); + expect(htmlAfter).not.toContain('data-entity-id="e1"'); + }); + + it('can unset a mention mark', () => { + const editor = createEditor([MentionExtension]); + editor.commands.setContent('

Test

'); + editor.commands.setMention({ entityId: 'e1', entityName: 'Entity 1' }); + editor.commands.unsetMention(); + const html = editor.getHTML(); + expect(html).not.toContain('data-entity-id'); + }); + + it('parses HTML with mention attributes', () => { + const editor = createEditor([MentionExtension]); + editor.commands.setContent('

text

'); + const json = editor.getJSON(); + const marks = json.content?.[0].content?.[0].marks as Array<{ type: string; attrs?: Record }> | undefined; + expect(marks).toBeDefined(); + const mentionMark = marks?.find(m => m.type === 'mention'); + expect(mentionMark).toBeDefined(); + expect(mentionMark?.attrs).toEqual({ entityId: 'e1', entityName: 'Entity 1' }); + }); + + it('applies CSS class to rendered mention', () => { + const editor = createEditor([MentionExtension]); + editor.commands.setContent('

Test

'); + editor.commands.setTextSelection({ from: 1, to: 5 }); + editor.commands.setMention({ entityId: 'e1', entityName: 'Entity 1' }); + const html = editor.getHTML(); + expect(html).toContain('class="entity-mention"'); + }); +}); + +describe('ClaimExtension', () => { + it('registers claim mark', () => { + const editor = createEditor([ClaimExtension]); + expect(editor.extensionManager.extensions.find(e => e.name === 'claim')).toBeDefined(); + }); + + it('can set a claim mark', () => { + const editor = createEditor([ClaimExtension]); + editor.commands.setContent('

Test

'); + editor.commands.setTextSelection({ from: 1, to: 5 }); + editor.commands.setClaim(); + const html = editor.getHTML(); + expect(html).toContain('knowledge-claim'); + }); + + it('can toggle a claim mark', () => { + const editor = createEditor([ClaimExtension]); + editor.commands.setContent('

Test

'); + editor.commands.setTextSelection({ from: 1, to: 5 }); + editor.commands.toggleClaim(); + expect(editor.getHTML()).toContain('knowledge-claim'); + editor.commands.setTextSelection({ from: 1, to: 5 }); + editor.commands.toggleClaim(); + expect(editor.getHTML()).not.toContain('knowledge-claim'); + }); + + it('can unset a claim mark', () => { + const editor = createEditor([ClaimExtension]); + editor.commands.setContent('

Test

'); + editor.commands.setClaim(); + editor.commands.unsetClaim(); + expect(editor.getHTML()).not.toContain('knowledge-claim'); + }); + + it('parses HTML with claim class', () => { + const editor = createEditor([ClaimExtension]); + editor.commands.setContent('

important fact

'); + const json = editor.getJSON(); + const marks = json.content?.[0].content?.[0].marks as Array<{ type: string }> | undefined; + expect(marks).toBeDefined(); + const claimMark = marks?.find(m => m.type === 'claim'); + expect(claimMark).toBeDefined(); + }); + + it('applies CSS class to rendered claim', () => { + const editor = createEditor([ClaimExtension]); + editor.commands.setContent('

Test

'); + editor.commands.setTextSelection({ from: 1, to: 5 }); + editor.commands.setClaim(); + const html = editor.getHTML(); + expect(html).toContain('class="knowledge-claim"'); + }); +}); diff --git a/src/features/graph/GraphSnapshotManager.ts b/src/features/graph/GraphSnapshotManager.ts index ce2ef9e..dee8914 100644 --- a/src/features/graph/GraphSnapshotManager.ts +++ b/src/features/graph/GraphSnapshotManager.ts @@ -1,6 +1,7 @@ import { useState, useCallback } from 'react'; import { IRepository } from '../../db/repository'; import { logger } from '../../lib/logger'; +import { validateSnapshotData } from './graph-schemas'; export function useGraphSnapshotManager(repository: IRepository) { const [snapshotMode, setSnapshotMode] = useState(false); @@ -26,9 +27,14 @@ export function useGraphSnapshotManager(repository: IRepository) { nodes: { id: string; label: string }[], edges: { id: string; source: string; target: string; label?: string }[] ) => { - setSnapshotData({ nodes, edges }); + const validated = validateSnapshotData({ nodes, edges }); + if (!validated) { + logger.warn('Invalid snapshot data rejected', { nodeCount: nodes.length, edgeCount: edges.length }); + return; + } + setSnapshotData(validated); setSnapshotMode(true); - logger.info(`Snapshot loaded with ${nodes.length} nodes, ${edges.length} edges`); + logger.info(`Snapshot loaded with ${validated.nodes.length} nodes, ${validated.edges.length} edges`); }, []); const handleExitSnapshot = useCallback(() => { diff --git a/src/features/graph/__tests__/GraphKeyboardNav.test.ts b/src/features/graph/__tests__/GraphKeyboardNav.test.ts new file mode 100644 index 0000000..480f6ce --- /dev/null +++ b/src/features/graph/__tests__/GraphKeyboardNav.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { validateSnapshotData } from '../graph-schemas'; + +describe('GraphKeyboardNav', () => { + it('validates snapshot data before loading', () => { + const validData = { + nodes: [{ id: 'n1', label: 'A' }, { id: 'n2', label: 'B' }], + edges: [{ id: 'e1', source: 'n1', target: 'n2' }], + }; + const result = validateSnapshotData(validData); + expect(result).not.toBeNull(); + expect(result?.nodes).toHaveLength(2); + expect(result?.edges).toHaveLength(1); + }); + + it('rejects invalid snapshot data', () => { + expect(validateSnapshotData(null)).toBeNull(); + expect(validateSnapshotData({})).toBeNull(); + expect(validateSnapshotData({ nodes: 'bad' })).toBeNull(); + }); + + it('filters out placeholder nodes', () => { + const nodes = ['placeholder', 'n1', 'n2']; + const visibleNodes = nodes.filter(n => n !== 'placeholder'); + expect(visibleNodes).toEqual(['n1', 'n2']); + }); + + it('cycles through nodes with Tab key', () => { + const nodes = ['n1', 'n2', 'n3']; + const currentIdx = 0; + const dir = 1; + const next = ((currentIdx + dir) % nodes.length + nodes.length) % nodes.length; + expect(next).toBe(1); + }); + + it('wraps around with Shift+Tab', () => { + const nodes = ['n1', 'n2', 'n3']; + const currentIdx = 0; + const dir = -1; + const next = ((currentIdx + dir) % nodes.length + nodes.length) % nodes.length; + expect(next).toBe(2); + }); + + it('navigates to first neighbor with ArrowRight', () => { + const neighbors = ['n2', 'n3']; + expect(neighbors[0]).toBe('n2'); + }); + + it('navigates to last neighbor with ArrowLeft', () => { + const neighbors = ['n2', 'n3']; + expect(neighbors[neighbors.length - 1]).toBe('n3'); + }); +}); diff --git a/src/features/graph/__tests__/graph-schemas.test.ts b/src/features/graph/__tests__/graph-schemas.test.ts new file mode 100644 index 0000000..27d3d2c --- /dev/null +++ b/src/features/graph/__tests__/graph-schemas.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from 'vitest'; +import { + GraphNodeSchema, + GraphEdgeSchema, + GraphSnapshotDataSchema, + validateSnapshotData, +} from '../graph-schemas'; + +describe('GraphNodeSchema', () => { + it('accepts valid node', () => { + expect(GraphNodeSchema.parse({ id: 'n1', label: 'Test' })).toEqual({ id: 'n1', label: 'Test' }); + }); + + it('rejects empty id', () => { + expect(() => GraphNodeSchema.parse({ id: '', label: 'Test' })).toThrow(); + }); + + it('rejects missing label', () => { + expect(() => GraphNodeSchema.parse({ id: 'n1' })).toThrow(); + }); +}); + +describe('GraphEdgeSchema', () => { + it('accepts valid edge', () => { + expect(GraphEdgeSchema.parse({ id: 'e1', source: 'n1', target: 'n2' })).toEqual({ + id: 'e1', source: 'n1', target: 'n2', label: undefined, + }); + }); + + it('accepts edge with label', () => { + const edge = GraphEdgeSchema.parse({ id: 'e1', source: 'n1', target: 'n2', label: 'contains' }); + expect(edge.label).toBe('contains'); + }); + + it('rejects empty source', () => { + expect(() => GraphEdgeSchema.parse({ id: 'e1', source: '', target: 'n2' })).toThrow(); + }); + + it('rejects empty target', () => { + expect(() => GraphEdgeSchema.parse({ id: 'e1', source: 'n1', target: '' })).toThrow(); + }); +}); + +describe('GraphSnapshotDataSchema', () => { + it('accepts valid snapshot data', () => { + const data = { + nodes: [{ id: 'n1', label: 'A' }], + edges: [{ id: 'e1', source: 'n1', target: 'n2' }], + }; + expect(GraphSnapshotDataSchema.parse(data)).toEqual(data); + }); + + it('accepts empty arrays', () => { + expect(GraphSnapshotDataSchema.parse({ nodes: [], edges: [] })).toEqual({ nodes: [], edges: [] }); + }); +}); + +describe('validateSnapshotData', () => { + it('returns parsed data for valid input', () => { + const input = { + nodes: [{ id: 'n1', label: 'A' }, { id: 'n2', label: 'B' }], + edges: [{ id: 'e1', source: 'n1', target: 'n2', label: 'link' }], + }; + expect(validateSnapshotData(input)).toEqual(input); + }); + + it('returns null for invalid input', () => { + expect(validateSnapshotData({ nodes: 'not-an-array' })).toBeNull(); + }); + + it('returns null for missing nodes', () => { + expect(validateSnapshotData({ edges: [] })).toBeNull(); + }); + + it('returns null for node with empty id', () => { + expect(validateSnapshotData({ nodes: [{ id: '', label: 'X' }], edges: [] })).toBeNull(); + }); + + it('returns null for edge with empty source', () => { + expect(validateSnapshotData({ + nodes: [{ id: 'n1', label: 'A' }], + edges: [{ id: 'e1', source: '', target: 'n2' }], + })).toBeNull(); + }); + + it('returns null for completely invalid data', () => { + expect(validateSnapshotData(null)).toBeNull(); + expect(validateSnapshotData(undefined)).toBeNull(); + expect(validateSnapshotData('string')).toBeNull(); + expect(validateSnapshotData(42)).toBeNull(); + }); +}); diff --git a/src/features/graph/graph-schemas.ts b/src/features/graph/graph-schemas.ts index f8b07b7..ead82ca 100644 --- a/src/features/graph/graph-schemas.ts +++ b/src/features/graph/graph-schemas.ts @@ -1,16 +1,30 @@ import { z } from 'zod'; export const GraphNodeSchema = z.object({ - id: z.string(), + id: z.string().min(1), label: z.string(), }); export const GraphEdgeSchema = z.object({ - id: z.string(), - source: z.string(), - target: z.string(), + id: z.string().min(1), + source: z.string().min(1), + target: z.string().min(1), label: z.string().optional(), }); +export const GraphSnapshotDataSchema = z.object({ + nodes: z.array(GraphNodeSchema), + edges: z.array(GraphEdgeSchema), +}); + export type GraphNode = z.infer; export type GraphEdge = z.infer; +export type GraphSnapshotData = z.infer; + +export function validateSnapshotData(data: unknown): GraphSnapshotData | null { + const result = GraphSnapshotDataSchema.safeParse(data); + if (result.success) { + return result.data; + } + return null; +} diff --git a/src/lib/chat-persistence.ts b/src/lib/chat-persistence.ts index f2e3f4d..202c980 100644 --- a/src/lib/chat-persistence.ts +++ b/src/lib/chat-persistence.ts @@ -1,3 +1,5 @@ +import { logger } from './logger'; + const DB_NAME = 'dks-chat-history'; const DB_VERSION = 1; const STORE_NAME = 'messages'; @@ -45,7 +47,8 @@ export const loadChatHistory = async (): Promise => { }; request.onerror = () => reject(new Error(String(request.error))); }); - } catch { + } catch (err) { + logger.warn('Failed to load chat history', err); return []; } }; @@ -62,8 +65,8 @@ export const saveChatHistory = async (messages: ChatMessage[]): Promise => tx.oncomplete = () => resolve(); tx.onerror = () => reject(new Error(String(tx.error))); }); - } catch { - // Silently fail — chat history persistence is best-effort + } catch (err) { + logger.warn('Failed to save chat history', err); } }; @@ -77,7 +80,7 @@ export const clearChatHistory = async (): Promise => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(new Error(String(tx.error))); }); - } catch { - // Silently fail + } catch (err) { + logger.warn('Failed to clear chat history', err); } }; diff --git a/src/lib/key-store.ts b/src/lib/key-store.ts new file mode 100644 index 0000000..c1a4018 --- /dev/null +++ b/src/lib/key-store.ts @@ -0,0 +1,157 @@ +import { logger } from './logger'; + +const DB_NAME = 'dks:key-store'; +const DB_VERSION = 1; +const STORE_NAME = 'keys'; +const ENCRYPTION_KEY_ID = '__encryption_key__'; +const ENCRYPTED_PREFIX = 'enc:v1:'; + +function openDB(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onerror = () => reject(new Error(String(request.error))); + request.onsuccess = () => resolve(request.result); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: 'id' }); + } + }; + }); +} + +async function getRaw(id: string): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const store = tx.objectStore(STORE_NAME); + const request = store.get(id); + request.onsuccess = () => { + const result = request.result as { id: string; value: string } | undefined; + resolve(result ? result.value : null); + }; + request.onerror = () => reject(new Error(String(request.error))); + }); +} + +async function setRaw(id: string, value: string): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + store.put({ id, value }); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(new Error(String(tx.error))); + }); +} + +async function deleteRaw(id: string): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + store.delete(id); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(new Error(String(tx.error))); + }); +} + +async function getEncryptionKey(): Promise { + const stored = await getRaw(ENCRYPTION_KEY_ID); + if (stored) { + try { + return await crypto.subtle.importKey( + 'jwk', + JSON.parse(stored) as JsonWebKey, + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'], + ); + } catch (err) { + logger.warn('Encryption key is corrupted, generating a new one', err); + await deleteRaw(ENCRYPTION_KEY_ID); + } + } + + const key = await crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'], + ); + const exported = await crypto.subtle.exportKey('jwk', key); + await setRaw(ENCRYPTION_KEY_ID, JSON.stringify(exported)); + return key; +} + +async function encryptValue(plaintext: string): Promise { + const key = await getEncryptionKey(); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encoded = new TextEncoder().encode(plaintext); + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + encoded, + ); + const combined = new Uint8Array(iv.length + encrypted.byteLength); + combined.set(iv, 0); + combined.set(new Uint8Array(encrypted), iv.length); + return ENCRYPTED_PREFIX + btoa(String.fromCharCode(...combined)); +} + +async function decryptValue(encrypted: string): Promise { + if (!encrypted.startsWith(ENCRYPTED_PREFIX)) { + return encrypted; + } + const key = await getEncryptionKey(); + const base64 = encrypted.slice(ENCRYPTED_PREFIX.length); + const combined = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); + const iv = combined.slice(0, 12); + const ciphertext = combined.slice(12); + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + key, + ciphertext, + ); + return new TextDecoder().decode(decrypted); +} + +export const keyStore = { + async get(id: string): Promise { + const raw = await getRaw(id); + if (!raw) return null; + try { + return await decryptValue(raw); + } catch (err) { + logger.warn('Failed to decrypt key from store', { id, err }); + return null; + } + }, + + async set(id: string, value: string): Promise { + const encrypted = await encryptValue(value); + await setRaw(id, encrypted); + }, + + async delete(id: string): Promise { + await deleteRaw(id); + }, + + async has(id: string): Promise { + const raw = await getRaw(id); + return raw !== null; + }, +}; + +export async function migrateFromLocalStorage(oldKey: string, newId: string): Promise { + try { + const value = localStorage.getItem(oldKey); + if (!value || value.length === 0) return false; + await keyStore.set(newId, value); + localStorage.removeItem(oldKey); + logger.info('Migrated key from localStorage to IndexedDB', { oldKey, newId }); + return true; + } catch (err) { + logger.warn('Failed to migrate key from localStorage', { oldKey, err }); + return false; + } +} diff --git a/src/lib/llm/__tests__/config.test.ts b/src/lib/llm/__tests__/config.test.ts index fba0213..3b9d176 100644 --- a/src/lib/llm/__tests__/config.test.ts +++ b/src/lib/llm/__tests__/config.test.ts @@ -1,8 +1,21 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { maskApiKey, loadConfig, saveConfig, createProvider, getProvider } from '../config'; import { OpenRouterProvider } from '../openrouter'; import { KiloGatewayProvider } from '../kilo'; +vi.mock('../../key-store', () => { + const store = new Map(); + return { + keyStore: { + async get(id: string) { return await Promise.resolve(store.get(id) ?? null); }, + async set(id: string, value: string) { await Promise.resolve(store.set(id, value)); }, + async delete(id: string) { await Promise.resolve(store.delete(id)); }, + async has(id: string) { return await Promise.resolve(store.has(id)); }, + }, + migrateFromLocalStorage: vi.fn().mockResolvedValue(false), + }; +}); + describe('maskApiKey', () => { it('returns empty string for empty key', () => { expect(maskApiKey('')).toBe(''); @@ -27,11 +40,12 @@ describe('maskApiKey', () => { }); describe('loadConfig', () => { - beforeEach(() => { - localStorage.clear(); + beforeEach(async () => { + const { keyStore } = await import('../../key-store'); + await keyStore.delete('dks:llm-config'); }); - it('returns default config when localStorage is empty', async () => { + it('returns default config when key store is empty', async () => { const config = await loadConfig(); expect(config.activeProvider).toBe('openrouter'); expect(config.providers.openrouter.baseURL).toBe('https://openrouter.ai/api/v1'); @@ -54,7 +68,8 @@ describe('loadConfig', () => { }, }, }; - localStorage.setItem('dks:llm-config', JSON.stringify(saved)); + const { keyStore } = await import('../../key-store'); + await keyStore.set('dks:llm-config', JSON.stringify(saved)); const config = await loadConfig(); expect(config.activeProvider).toBe('kilo'); @@ -64,18 +79,20 @@ describe('loadConfig', () => { }); it('returns defaults on invalid JSON', async () => { - localStorage.setItem('dks:llm-config', 'not-json'); + const { keyStore } = await import('../../key-store'); + await keyStore.set('dks:llm-config', 'not-json'); const config = await loadConfig(); expect(config.activeProvider).toBe('openrouter'); }); }); describe('saveConfig', () => { - beforeEach(() => { - localStorage.clear(); + beforeEach(async () => { + const { keyStore } = await import('../../key-store'); + await keyStore.delete('dks:llm-config'); }); - it('persists config to localStorage', async () => { + it('persists config to key store', async () => { const config = { activeProvider: 'kilo', providers: { @@ -85,11 +102,10 @@ describe('saveConfig', () => { }; await saveConfig(config); - const stored = JSON.parse(localStorage.getItem('dks:llm-config')!) as { activeProvider: string; providers: { openrouter: { apiKey: string } } }; + const { keyStore } = await import('../../key-store'); + const stored = JSON.parse((await keyStore.get('dks:llm-config'))!) as { activeProvider: string; providers: { openrouter: { apiKey: string } } }; expect(stored.activeProvider).toBe('kilo'); - // API key should be encrypted (starts with enc:v1:) expect(stored.providers.openrouter.apiKey).toMatch(/^enc:v1:/); - // But loading it back should decrypt const loaded = await loadConfig(); expect(loaded.providers.openrouter.apiKey).toBe('key1'); }); diff --git a/src/lib/llm/anthropic.ts b/src/lib/llm/anthropic.ts index 440899c..d0c64c3 100644 --- a/src/lib/llm/anthropic.ts +++ b/src/lib/llm/anthropic.ts @@ -121,7 +121,7 @@ export class AnthropicProvider implements LLMProvider { return; } } catch { - // Expected: SSE chunk not yet complete or invalid JSON + console.debug('SSE chunk parse skipped (incomplete or invalid JSON)'); } } } diff --git a/src/lib/llm/config.ts b/src/lib/llm/config.ts index cce8a57..1bb4203 100644 --- a/src/lib/llm/config.ts +++ b/src/lib/llm/config.ts @@ -4,8 +4,11 @@ import { KiloGatewayProvider } from './kilo'; import { AnthropicProvider } from './anthropic'; import { OllamaProvider } from './ollama'; import { encryptApiKey, decryptApiKey, isEncrypted } from './encryption'; +import { keyStore, migrateFromLocalStorage } from '../key-store'; +import { logger } from '../logger'; const STORAGE_KEY = 'dks:llm-config'; +const LEGACY_STORAGE_KEY = 'dks:llm-config'; export interface LLMConfig { activeProvider: string; @@ -40,12 +43,19 @@ const DEFAULT_CONFIG: LLMConfig = { export async function loadConfig(): Promise { try { - const stored = localStorage.getItem(STORAGE_KEY); + // Try IndexedDB first, then migrate from localStorage if needed + let stored = await keyStore.get(STORAGE_KEY); + if (!stored) { + const migrated = await migrateFromLocalStorage(LEGACY_STORAGE_KEY, STORAGE_KEY); + if (migrated) { + stored = await keyStore.get(STORAGE_KEY); + } + } + if (stored) { const parsed = JSON.parse(stored) as Partial; const config = { ...DEFAULT_CONFIG, ...parsed }; - // Decrypt provider API keys (migrates plaintext keys on the fly) const migrated = { ...config, providers: { ...config.providers } }; let needsSave = false; for (const [id, providerConfig] of Object.entries(migrated.providers)) { @@ -54,7 +64,6 @@ export async function loadConfig(): Promise { ...providerConfig, apiKey: await decryptApiKey(providerConfig.apiKey), }; - // Auto-migrate plaintext keys to encrypted if (!isEncrypted(providerConfig.apiKey)) { needsSave = true; } @@ -62,20 +71,18 @@ export async function loadConfig(): Promise { } if (needsSave) { - // Re-save with encrypted keys (fire-and-forget) void saveConfig(migrated); } return migrated; } - } catch (e) { - console.warn('Failed to parse stored LLM config, falling back to defaults', e); + } catch (err) { + logger.warn('Failed to load LLM config, falling back to defaults', err); } return { ...DEFAULT_CONFIG }; } export async function saveConfig(config: LLMConfig): Promise { - // Encrypt all provider API keys before persisting const encrypted = { ...config, providers: { ...config.providers } }; for (const [id, providerConfig] of Object.entries(encrypted.providers)) { if (providerConfig.apiKey && providerConfig.apiKey.length > 0 && !isEncrypted(providerConfig.apiKey)) { @@ -85,7 +92,7 @@ export async function saveConfig(config: LLMConfig): Promise { }; } } - localStorage.setItem(STORAGE_KEY, JSON.stringify(encrypted)); + await keyStore.set(STORAGE_KEY, JSON.stringify(encrypted)); } export function createProvider(config: LLMConfig): LLMProvider { diff --git a/src/lib/llm/encryption.ts b/src/lib/llm/encryption.ts index c8f5d97..990b22f 100644 --- a/src/lib/llm/encryption.ts +++ b/src/lib/llm/encryption.ts @@ -1,3 +1,5 @@ +import { logger } from '../logger'; + const ENCRYPTION_KEY_STORAGE = 'dks:llm-encryption-key'; const ENCRYPTED_PREFIX = 'enc:v1:'; @@ -16,8 +18,8 @@ async function getKey(): Promise { true, ['encrypt', 'decrypt'], ); - } catch { - // Key is corrupted, generate a new one + } catch (err) { + logger.warn('Encryption key is corrupted, generating a new one', err); localStorage.removeItem(ENCRYPTION_KEY_STORAGE); } } diff --git a/src/lib/llm/kilo.ts b/src/lib/llm/kilo.ts index 060802b..13ee5ec 100644 --- a/src/lib/llm/kilo.ts +++ b/src/lib/llm/kilo.ts @@ -115,7 +115,7 @@ export class KiloGatewayProvider implements LLMProvider { yield { content, done: false }; } } catch { - // Expected: SSE chunk not yet complete or invalid JSON + console.debug('SSE chunk parse skipped (incomplete or invalid JSON)'); } } } diff --git a/src/lib/llm/ollama.ts b/src/lib/llm/ollama.ts index 3af3c9f..90e2ebc 100644 --- a/src/lib/llm/ollama.ts +++ b/src/lib/llm/ollama.ts @@ -115,7 +115,7 @@ export class OllamaProvider implements LLMProvider { return; } } catch { - // Expected: incomplete JSON line + console.debug('SSE chunk parse skipped (incomplete JSON line)'); } } } diff --git a/src/lib/llm/openrouter.ts b/src/lib/llm/openrouter.ts index 66177f6..147b911 100644 --- a/src/lib/llm/openrouter.ts +++ b/src/lib/llm/openrouter.ts @@ -115,7 +115,7 @@ export class OpenRouterProvider implements LLMProvider { yield { content, done: false }; } } catch { - // Expected: SSE chunk not yet complete or invalid JSON + console.debug('SSE chunk parse skipped (incomplete or invalid JSON)'); } } } diff --git a/src/lib/resolver.ts b/src/lib/resolver.ts index d168426..ade2fbf 100644 --- a/src/lib/resolver.ts +++ b/src/lib/resolver.ts @@ -1,6 +1,6 @@ import { logger } from './logger'; -const BLOCKED_SCHEMES = ['javascript:', 'data:', 'vbscript:', 'file:']; +const BLOCKED_SCHEMES = ['javascript:', 'data:', 'vbscript:', 'file:', 'ftp:']; function isPrivateIP(hostname: string): boolean { // Normalize hostname: lowercase, strip trailing dot, and strip IPv6 brackets @@ -181,6 +181,7 @@ export const resolveUrl = async (url: string): Promise => { try { parsed = new URL(url); } catch { + logger.warn('Failed to parse URL', { url }); throw new Error(`Invalid URL: ${url}`); }