phase3: local persistence — IndexedDB conversations, keys + AES-GCM passphrase vault, settings, export/import; 13/13 gate checks
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
12 changed files +694 −3
modified
docs/PLAN.md
+17 −1
@@ -74,7 +74,23 @@ live OpenAI from headless Chromium: token-by-token rendering, usage events, and | |||||
| 74 | AbortController cancellation all verified. Playwright added (dev-only) as the | 74 | AbortController cancellation all verified. Playwright added (dev-only) as the | ||
| 75 | browser harness that Phase 7 will reuse for the full matrix. | 75 | browser harness that Phase 7 will reuse for the full matrix. | ||
| 76 | 76 | ||||
| 77 | −## Phase 3 — Local persistence — pending | 77 | +## Phase 3 — Local persistence | ||
| 78 | + | ||||
| 79 | +- [x] 3.A `storage/keys.ts` — namespaced localStorage map, get/set/remove, masked display, in-memory overlay for vault mode; `providers/testKey.ts` Test action (/models or min completion, ms latency) | ||||
| 80 | +- [x] 3.A `storage/vault.ts` — opt-in passphrase lock: PBKDF2 (310k, SHA-256) → AES-GCM; enable/unlock/lock/disable; honest framing (obfuscation-at-rest, not a secure vault) | ||||
| 81 | +- [x] 3.B `storage/db.ts` (dependency-free IndexedDB promise wrapper) + `storage/conversations.ts` — CRUD, updatedAt sorting, schema versioning + migrate(), title+message search | ||||
| 82 | +- [x] 3.C `storage/settings.ts` — theme (light default), default model, per-provider proxy URLs, UI prefs | ||||
| 83 | +- [x] 3.C `storage/backup.ts` — export (keys opt-in with warning) / import / clear-everything | ||||
| 84 | +- [x] Dev harness (`features/devHarness.ts`, DEV-only) exposing storage for browser gate scripts | ||||
| 85 | +- [x] PHASE GATE: 13/13 checks in real Chromium (scripts/gate-phase3.mjs) — reload survival, **browser-restart survival** (persistent context), export→clear→import exact restore, vault encrypt-at-rest / wrong-pass rejection / unlock / disable | ||||
| 86 | + | ||||
| 87 | +**Phase 3 summary.** All state is now local and durable: keys in | ||||
| 88 | +`zyquo.cloud.web.keys`, conversations in IndexedDB with schema versioning, | ||||
| 89 | +settings in `zyquo.cloud.web.settings`. Export/import is the backup story; | ||||
| 90 | +clear-data wipes everything. The optional passphrase lock encrypts the key map | ||||
| 91 | +with WebCrypto AES-GCM and is honestly framed. Gate ran in a real browser with | ||||
| 92 | +a persistent profile: reload AND restart survival, exact export round-trip, and | ||||
| 93 | +vault behavior all green. | ||||
| 78 | 94 | ||||
| 79 | ## Phase 4 — Design system & UI — pending | 95 | ## Phase 4 — Design system & UI — pending | ||
| 80 | 96 | ||||
modified
eslint.config.js
+4 −1
@@ -78,7 +78,10 @@ export default [ | |||||
| 78 | ...tsPlugin.configs.recommended.rules, | 78 | ...tsPlugin.configs.recommended.rules, | ||
| 79 | ...reactHooks.configs.recommended.rules, | 79 | ...reactHooks.configs.recommended.rules, | ||
| 80 | 'no-unused-vars': 'off', | 80 | 'no-unused-vars': 'off', | ||
| 81 | − '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], | 81 | + '@typescript-eslint/no-unused-vars': [ | ||
| 82 | + 'error', | ||||
| 83 | + { argsIgnorePattern: '^_', varsIgnorePattern: '^_', destructuredArrayIgnorePattern: '^_' }, | ||||
| 84 | + ], | ||||
| 82 | 'no-undef': 'off', | 85 | 'no-undef': 'off', | ||
| 83 | }, | 86 | }, | ||
| 84 | }, | 87 | }, | ||
added
scripts/gate-phase3.mjs
+155 −0
@@ -0,0 +1,155 @@ | |||||
| 1 | +/* | ||||
| 2 | + * gate-phase3.mjs | ||||
| 3 | + * Zyquo Cloud Web | ||||
| 4 | + * | ||||
| 5 | + * Author: Simon-Pierre Boucher | ||||
| 6 | + * Mail: contact@spboucher.ai | ||||
| 7 | + * | ||||
| 8 | + * Phase 3 gate: in a real browser, verify that (1) keys + conversations | ||||
| 9 | + * survive a page reload AND a full browser restart (fresh context, same | ||||
| 10 | + * storage dir), (2) export → clear → import restores exactly, (3) the | ||||
| 11 | + * passphrase vault encrypts at rest and unlocks correctly. | ||||
| 12 | + * Usage: node scripts/gate-phase3.mjs [baseURL] | ||||
| 13 | + */ | ||||
| 14 | + | ||||
| 15 | +import { chromium } from 'playwright' | ||||
| 16 | +import { mkdtempSync } from 'node:fs' | ||||
| 17 | +import { tmpdir } from 'node:os' | ||||
| 18 | +import { join } from 'node:path' | ||||
| 19 | + | ||||
| 20 | +const baseURL = process.argv[2] ?? 'http://localhost:5173' | ||||
| 21 | +const userDataDir = mkdtempSync(join(tmpdir(), 'zyquo-gate3-')) | ||||
| 22 | +const results = [] | ||||
| 23 | +const check = (name, ok) => { | ||||
| 24 | + results.push([name, ok]) | ||||
| 25 | + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}`) | ||||
| 26 | +} | ||||
| 27 | + | ||||
| 28 | +const conversationFixture = { | ||||
| 29 | + id: 'gate3-conv', | ||||
| 30 | + title: 'Gate 3 conversation', | ||||
| 31 | + createdAt: 1000, | ||||
| 32 | + updatedAt: 2000, | ||||
| 33 | + modelID: 'gpt-5.6-terra', | ||||
| 34 | + provider: 'openai', | ||||
| 35 | + parameters: { temperature: 0.7 }, | ||||
| 36 | + messages: [ | ||||
| 37 | + { id: 'm1', role: 'user', text: 'hello persistence', createdAt: 1000 }, | ||||
| 38 | + { id: 'm2', role: 'assistant', text: 'hello back', createdAt: 1500, usage: { inputTokens: 3, outputTokens: 2 } }, | ||||
| 39 | + ], | ||||
| 40 | + pinned: true, | ||||
| 41 | + hasAutoTitle: false, | ||||
| 42 | +} | ||||
| 43 | + | ||||
| 44 | +// --- Session 1: write data --- | ||||
| 45 | +let browser = await chromium.launchPersistentContext(userDataDir, { baseURL }) | ||||
| 46 | +let page = await browser.newPage() | ||||
| 47 | +await page.goto(baseURL) | ||||
| 48 | +await page.evaluate(async (conv) => { | ||||
| 49 | + const z = window.__zyquo | ||||
| 50 | + z.keys.setKey('openai', 'sk-test-persistence-1234') | ||||
| 51 | + z.settings.saveSettings({ ...z.settings.loadSettings(), chatFontSize: 15, theme: 'light' }) | ||||
| 52 | + await z.conversations.saveConversation(conv) | ||||
| 53 | +}, conversationFixture) | ||||
| 54 | + | ||||
| 55 | +// Reload (same session) | ||||
| 56 | +await page.reload() | ||||
| 57 | +const afterReload = await page.evaluate(async () => { | ||||
| 58 | + const z = window.__zyquo | ||||
| 59 | + const convs = await z.conversations.loadConversations() | ||||
| 60 | + return { | ||||
| 61 | + key: z.keys.getKey('openai'), | ||||
| 62 | + fontSize: z.settings.loadSettings().chatFontSize, | ||||
| 63 | + conv: convs.find((c) => c.id === 'gate3-conv') ?? null, | ||||
| 64 | + } | ||||
| 65 | +}) | ||||
| 66 | +check('keys survive reload', afterReload.key === 'sk-test-persistence-1234') | ||||
| 67 | +check('settings survive reload', afterReload.fontSize === 15) | ||||
| 68 | +check( | ||||
| 69 | + 'conversation survives reload (full round-trip)', | ||||
| 70 | + afterReload.conv !== null && | ||||
| 71 | + afterReload.conv.messages.length === 2 && | ||||
| 72 | + afterReload.conv.messages[1].usage.outputTokens === 2 && | ||||
| 73 | + afterReload.conv.pinned === true | ||||
| 74 | +) | ||||
| 75 | + | ||||
| 76 | +// --- Browser restart (close everything, reopen same profile) --- | ||||
| 77 | +await browser.close() | ||||
| 78 | +browser = await chromium.launchPersistentContext(userDataDir, { baseURL }) | ||||
| 79 | +page = await browser.newPage() | ||||
| 80 | +await page.goto(baseURL) | ||||
| 81 | +const afterRestart = await page.evaluate(async () => { | ||||
| 82 | + const z = window.__zyquo | ||||
| 83 | + const convs = await z.conversations.loadConversations() | ||||
| 84 | + return { key: z.keys.getKey('openai'), count: convs.length, title: convs[0]?.title } | ||||
| 85 | +}) | ||||
| 86 | +check('keys survive browser restart', afterRestart.key === 'sk-test-persistence-1234') | ||||
| 87 | +check( | ||||
| 88 | + 'conversations survive browser restart', | ||||
| 89 | + afterRestart.count === 1 && afterRestart.title === 'Gate 3 conversation' | ||||
| 90 | +) | ||||
| 91 | + | ||||
| 92 | +// --- Export → clear → import --- | ||||
| 93 | +const roundTrip = await page.evaluate(async () => { | ||||
| 94 | + const z = window.__zyquo | ||||
| 95 | + const exported = await z.backup.exportAll(true) | ||||
| 96 | + await z.backup.clearEverything() | ||||
| 97 | + const emptyAfterClear = | ||||
| 98 | + (await z.conversations.loadConversations()).length === 0 && z.keys.getKey('openai') === undefined | ||||
| 99 | + const imported = await z.backup.importAll(exported) | ||||
| 100 | + const convs = await z.conversations.loadConversations() | ||||
| 101 | + return { | ||||
| 102 | + emptyAfterClear, | ||||
| 103 | + imported, | ||||
| 104 | + key: z.keys.getKey('openai'), | ||||
| 105 | + conv: convs.find((c) => c.id === 'gate3-conv') ?? null, | ||||
| 106 | + fontSize: z.settings.loadSettings().chatFontSize, | ||||
| 107 | + } | ||||
| 108 | +}) | ||||
| 109 | +check('clear empties everything', roundTrip.emptyAfterClear) | ||||
| 110 | +check( | ||||
| 111 | + 'export→clear→import restores exactly', | ||||
| 112 | + roundTrip.key === 'sk-test-persistence-1234' && | ||||
| 113 | + roundTrip.conv !== null && | ||||
| 114 | + roundTrip.conv.messages.length === 2 && | ||||
| 115 | + roundTrip.fontSize === 15 && | ||||
| 116 | + roundTrip.imported.conversations === 1 | ||||
| 117 | +) | ||||
| 118 | + | ||||
| 119 | +// --- Passphrase vault --- | ||||
| 120 | +const vaultResult = await page.evaluate(async () => { | ||||
| 121 | + const z = window.__zyquo | ||||
| 122 | + await z.vault.enableVault('correct horse battery staple') | ||||
| 123 | + const raw = z.keys.rawKeysRecord() | ||||
| 124 | + const encryptedAtRest = raw.includes('__zyquoVault') && !raw.includes('sk-test-persistence-1234') | ||||
| 125 | + const readableWhileUnlocked = z.keys.getKey('openai') === 'sk-test-persistence-1234' | ||||
| 126 | + z.vault.lockVault() | ||||
| 127 | + const lockedNow = z.vault.isLocked() | ||||
| 128 | + let wrongPassFailed = false | ||||
| 129 | + try { | ||||
| 130 | + await z.vault.unlockVault('wrong passphrase') | ||||
| 131 | + } catch { | ||||
| 132 | + wrongPassFailed = true | ||||
| 133 | + } | ||||
| 134 | + await z.vault.unlockVault('correct horse battery staple') | ||||
| 135 | + const unlockedKey = z.keys.getKey('openai') | ||||
| 136 | + await z.vault.disableVault('correct horse battery staple') | ||||
| 137 | + const plaintextAgain = z.keys.rawKeysRecord().includes('sk-test-persistence-1234') | ||||
| 138 | + return { encryptedAtRest, readableWhileUnlocked, lockedNow, wrongPassFailed, unlockedKey, plaintextAgain } | ||||
| 139 | +}) | ||||
| 140 | +check('vault encrypts keys at rest (AES-GCM)', vaultResult.encryptedAtRest) | ||||
| 141 | +check('keys readable while unlocked', vaultResult.readableWhileUnlocked) | ||||
| 142 | +check('lock drops the in-memory overlay', vaultResult.lockedNow) | ||||
| 143 | +check('wrong passphrase rejected', vaultResult.wrongPassFailed) | ||||
| 144 | +check('correct passphrase unlocks', vaultResult.unlockedKey === 'sk-test-persistence-1234') | ||||
| 145 | +check('disable restores plaintext', vaultResult.plaintextAgain) | ||||
| 146 | + | ||||
| 147 | +await browser.close() | ||||
| 148 | + | ||||
| 149 | +const failures = results.filter(([, ok]) => !ok) | ||||
| 150 | +if (failures.length === 0) { | ||||
| 151 | + console.log(`GATE PASS: ${results.length}/${results.length} persistence checks green`) | ||||
| 152 | +} else { | ||||
| 153 | + console.error(`GATE FAIL: ${failures.length} failures`) | ||||
| 154 | + process.exit(1) | ||||
| 155 | +} | ||||
added
src/features/devHarness.ts
+36 −0
@@ -0,0 +1,36 @@ | |||||
| 1 | +/* | ||||
| 2 | + * devHarness.ts | ||||
| 3 | + * Zyquo Cloud Web | ||||
| 4 | + * | ||||
| 5 | + * Author: Simon-Pierre Boucher | ||||
| 6 | + * Mail: contact@spboucher.ai | ||||
| 7 | + * | ||||
| 8 | + * Dev-only test harness: exposes the storage layer on window so the | ||||
| 9 | + * Playwright gate scripts (Phases 3 & 7) can drive real browser round-trips. | ||||
| 10 | + * Excluded from production builds via the import.meta.env.DEV guard. | ||||
| 11 | + */ | ||||
| 12 | + | ||||
| 13 | +import * as conversations from '../storage/conversations' | ||||
| 14 | +import * as keys from '../storage/keys' | ||||
| 15 | +import * as settings from '../storage/settings' | ||||
| 16 | +import * as vault from '../storage/vault' | ||||
| 17 | +import * as backup from '../storage/backup' | ||||
| 18 | +import { testKey } from '../providers/testKey' | ||||
| 19 | +import { clientFor } from '../providers/registry' | ||||
| 20 | +import { cheapestModel, findModel, CATALOG } from '../providers/catalog' | ||||
| 21 | + | ||||
| 22 | +export function installDevHarness(): void { | ||||
| 23 | + if (!import.meta.env.DEV) return | ||||
| 24 | + ;(window as unknown as Record<string, unknown>).__zyquo = { | ||||
| 25 | + conversations, | ||||
| 26 | + keys, | ||||
| 27 | + settings, | ||||
| 28 | + vault, | ||||
| 29 | + backup, | ||||
| 30 | + testKey, | ||||
| 31 | + clientFor, | ||||
| 32 | + cheapestModel, | ||||
| 33 | + findModel, | ||||
| 34 | + CATALOG, | ||||
| 35 | + } | ||||
| 36 | +} | ||||
modified
src/main.tsx
+3 −0
@@ -9,9 +9,12 @@ | |||||
| 9 | import { StrictMode } from 'react' | 9 | import { StrictMode } from 'react' | ||
| 10 | import { createRoot } from 'react-dom/client' | 10 | import { createRoot } from 'react-dom/client' | ||
| 11 | import App from './App' | 11 | import App from './App' | ||
| 12 | +import { installDevHarness } from './features/devHarness' | ||||
| 12 | import './design/tokens.css' | 13 | import './design/tokens.css' | ||
| 13 | import './design/global.css' | 14 | import './design/global.css' | ||
| 14 | 15 | ||||
| 16 | +installDevHarness() | ||||
| 17 | + | ||||
| 15 | const container = document.getElementById('root') | 18 | const container = document.getElementById('root') | ||
| 16 | if (!container) throw new Error('Zyquo Cloud Web: #root container missing') | 19 | if (!container) throw new Error('Zyquo Cloud Web: #root container missing') | ||
| 17 | 20 | ||||
added
src/providers/testKey.ts
+46 −0
@@ -0,0 +1,46 @@ | |||||
| 1 | +/* | ||||
| 2 | + * testKey.ts | ||||
| 3 | + * Zyquo Cloud Web | ||||
| 4 | + * | ||||
| 5 | + * Author: Simon-Pierre Boucher | ||||
| 6 | + * Mail: contact@spboucher.ai | ||||
| 7 | + * | ||||
| 8 | + * Key validation, ported from the native ProviderClient extension: the | ||||
| 9 | + * cheapest authenticated call available — /models where supported, otherwise | ||||
| 10 | + * (Perplexity) a minimal non-streaming completion against the provider's | ||||
| 11 | + * cheapest catalog model. Returns round-trip latency in ms. | ||||
| 12 | + */ | ||||
| 13 | + | ||||
| 14 | +import type { Provider } from '../types' | ||||
| 15 | +import { cheapestModel } from './catalog' | ||||
| 16 | +import { clientFor, PROVIDER_META } from './registry' | ||||
| 17 | +import { ProviderError } from './types' | ||||
| 18 | + | ||||
| 19 | +export async function testKey( | ||||
| 20 | + provider: Provider, | ||||
| 21 | + apiKey: string, | ||||
| 22 | + baseURLOverride?: string | ||||
| 23 | +): Promise<number> { | ||||
| 24 | + const client = clientFor(provider) | ||||
| 25 | + const start = performance.now() | ||||
| 26 | + if (PROVIDER_META[provider].supportsModelListing) { | ||||
| 27 | + await client.listModelIDs(apiKey, baseURLOverride) | ||||
| 28 | + } else { | ||||
| 29 | + const model = cheapestModel(provider) | ||||
| 30 | + if (!model) throw ProviderError.noModelAvailable(provider) | ||||
| 31 | + await client.complete( | ||||
| 32 | + { | ||||
| 33 | + model, | ||||
| 34 | + messages: [ | ||||
| 35 | + { id: 'key-test', role: 'user', text: 'Reply with exactly: OK', createdAt: 0 }, | ||||
| 36 | + ], | ||||
| 37 | + // Perplexity requires max_tokens ≥ 16 (probe-verified, docs/CORS-MATRIX.md). | ||||
| 38 | + parameters: { maxTokens: 16 }, | ||||
| 39 | + stream: false, | ||||
| 40 | + ...(baseURLOverride !== undefined ? { baseURLOverride } : {}), | ||||
| 41 | + }, | ||||
| 42 | + apiKey | ||||
| 43 | + ) | ||||
| 44 | + } | ||||
| 45 | + return performance.now() - start | ||||
| 46 | +} | ||||
added
src/storage/backup.ts
+70 −0
@@ -0,0 +1,70 @@ | |||||
| 1 | +/* | ||||
| 2 | + * backup.ts | ||||
| 3 | + * Zyquo Cloud Web | ||||
| 4 | + * | ||||
| 5 | + * Author: Simon-Pierre Boucher | ||||
| 6 | + * Mail: contact@spboucher.ai | ||||
| 7 | + * | ||||
| 8 | + * Export / import of all local data — the only "backup" mechanism since there | ||||
| 9 | + * is no cloud. Exports may contain API keys ONLY when the user explicitly | ||||
| 10 | + * opts in (the UI warns about this). Clear-data controls live here too. | ||||
| 11 | + */ | ||||
| 12 | + | ||||
| 13 | +import type { Conversation, Settings } from '../types' | ||||
| 14 | +import { clearConversations, loadConversations, saveConversation } from './conversations' | ||||
| 15 | +import { clearAllKeys, readKeyMap, replaceKeyMap, type KeyMap } from './keys' | ||||
| 16 | +import { clearSettings, loadSettings, saveSettings } from './settings' | ||||
| 17 | + | ||||
| 18 | +const EXPORT_VERSION = 1 | ||||
| 19 | + | ||||
| 20 | +export interface ExportedData { | ||||
| 21 | + app: 'zyquo-cloud-web' | ||||
| 22 | + version: number | ||||
| 23 | + exportedAt: string | ||||
| 24 | + settings: Settings | ||||
| 25 | + conversations: Conversation[] | ||||
| 26 | + /** Present only when the user opted in to exporting keys. */ | ||||
| 27 | + keys?: KeyMap | ||||
| 28 | +} | ||||
| 29 | + | ||||
| 30 | +export async function exportAll(includeKeys: boolean): Promise<ExportedData> { | ||||
| 31 | + const data: ExportedData = { | ||||
| 32 | + app: 'zyquo-cloud-web', | ||||
| 33 | + version: EXPORT_VERSION, | ||||
| 34 | + exportedAt: new Date().toISOString(), | ||||
| 35 | + settings: loadSettings(), | ||||
| 36 | + conversations: await loadConversations(), | ||||
| 37 | + } | ||||
| 38 | + if (includeKeys) data.keys = readKeyMap() | ||||
| 39 | + return data | ||||
| 40 | +} | ||||
| 41 | + | ||||
| 42 | +export function isExportedData(value: unknown): value is ExportedData { | ||||
| 43 | + return ( | ||||
| 44 | + typeof value === 'object' && | ||||
| 45 | + value !== null && | ||||
| 46 | + (value as ExportedData).app === 'zyquo-cloud-web' && | ||||
| 47 | + typeof (value as ExportedData).version === 'number' && | ||||
| 48 | + Array.isArray((value as ExportedData).conversations) | ||||
| 49 | + ) | ||||
| 50 | +} | ||||
| 51 | + | ||||
| 52 | +/** Restores an export. Existing conversations with the same id are overwritten. */ | ||||
| 53 | +export async function importAll(data: ExportedData): Promise<{ conversations: number; keys: number }> { | ||||
| 54 | + if (data.settings) saveSettings({ ...loadSettings(), ...data.settings }) | ||||
| 55 | + for (const conversation of data.conversations) { | ||||
| 56 | + await saveConversation(conversation) | ||||
| 57 | + } | ||||
| 58 | + let keyCount = 0 | ||||
| 59 | + if (data.keys) { | ||||
| 60 | + replaceKeyMap({ ...readKeyMap(), ...data.keys }) | ||||
| 61 | + keyCount = Object.keys(data.keys).length | ||||
| 62 | + } | ||||
| 63 | + return { conversations: data.conversations.length, keys: keyCount } | ||||
| 64 | +} | ||||
| 65 | + | ||||
| 66 | +export async function clearEverything(): Promise<void> { | ||||
| 67 | + await clearConversations() | ||||
| 68 | + clearAllKeys() | ||||
| 69 | + clearSettings() | ||||
| 70 | +} | ||||
added
src/storage/conversations.ts
+63 −0
@@ -0,0 +1,63 @@ | |||||
| 1 | +/* | ||||
| 2 | + * conversations.ts | ||||
| 3 | + * Zyquo Cloud Web | ||||
| 4 | + * | ||||
| 5 | + * Author: Simon-Pierre Boucher | ||||
| 6 | + * Mail: contact@spboucher.ai | ||||
| 7 | + * | ||||
| 8 | + * Conversation persistence — IndexedDB (namespace zyquo.cloud.web), one | ||||
| 9 | + * record per conversation, autosaved on every mutation by the store layer. | ||||
| 10 | + * Includes schema versioning/migration for stored records. | ||||
| 11 | + */ | ||||
| 12 | + | ||||
| 13 | +import type { Conversation } from '../types' | ||||
| 14 | +import { dbClear, dbDelete, dbGetAll, dbPut, STORE_CONVERSATIONS } from './db' | ||||
| 15 | + | ||||
| 16 | +/** Bump when the stored Conversation shape changes; migrate() upgrades old records. */ | ||||
| 17 | +const SCHEMA_VERSION = 1 | ||||
| 18 | + | ||||
| 19 | +interface StoredConversation extends Conversation { | ||||
| 20 | + schemaVersion?: number | ||||
| 21 | +} | ||||
| 22 | + | ||||
| 23 | +/** Upgrades a stored record from any older schema to the current one. */ | ||||
| 24 | +function migrate(record: StoredConversation): Conversation { | ||||
| 25 | + const version = record.schemaVersion ?? 1 | ||||
| 26 | + // v1 is current — future migrations chain here (v1 → v2 → …). | ||||
| 27 | + void version | ||||
| 28 | + const { schemaVersion: _schemaVersion, ...conversation } = record | ||||
| 29 | + return { | ||||
| 30 | + ...conversation, | ||||
| 31 | + parameters: conversation.parameters ?? {}, | ||||
| 32 | + messages: conversation.messages ?? [], | ||||
| 33 | + pinned: conversation.pinned ?? false, | ||||
| 34 | + hasAutoTitle: conversation.hasAutoTitle ?? true, | ||||
| 35 | + } | ||||
| 36 | +} | ||||
| 37 | + | ||||
| 38 | +/** All conversations, newest-first by updatedAt. */ | ||||
| 39 | +export async function loadConversations(): Promise<Conversation[]> { | ||||
| 40 | + const records = await dbGetAll<StoredConversation>(STORE_CONVERSATIONS) | ||||
| 41 | + return records.map(migrate).sort((a, b) => b.updatedAt - a.updatedAt) | ||||
| 42 | +} | ||||
| 43 | + | ||||
| 44 | +export async function saveConversation(conversation: Conversation): Promise<void> { | ||||
| 45 | + const record: StoredConversation = { ...conversation, schemaVersion: SCHEMA_VERSION } | ||||
| 46 | + await dbPut(STORE_CONVERSATIONS, record) | ||||
| 47 | +} | ||||
| 48 | + | ||||
| 49 | +export async function deleteConversation(id: string): Promise<void> { | ||||
| 50 | + await dbDelete(STORE_CONVERSATIONS, id) | ||||
| 51 | +} | ||||
| 52 | + | ||||
| 53 | +export async function clearConversations(): Promise<void> { | ||||
| 54 | + await dbClear(STORE_CONVERSATIONS) | ||||
| 55 | +} | ||||
| 56 | + | ||||
| 57 | +/** Case-insensitive search across titles and all message text. */ | ||||
| 58 | +export function matchesSearch(conversation: Conversation, query: string): boolean { | ||||
| 59 | + const q = query.trim().toLowerCase() | ||||
| 60 | + if (q === '') return true | ||||
| 61 | + if (conversation.title.toLowerCase().includes(q)) return true | ||||
| 62 | + return conversation.messages.some((message) => message.text.toLowerCase().includes(q)) | ||||
| 63 | +} | ||||
added
src/storage/db.ts
+75 −0
@@ -0,0 +1,75 @@ | |||||
| 1 | +/* | ||||
| 2 | + * db.ts | ||||
| 3 | + * Zyquo Cloud Web | ||||
| 4 | + * | ||||
| 5 | + * Author: Simon-Pierre Boucher | ||||
| 6 | + * Mail: contact@spboucher.ai | ||||
| 7 | + * | ||||
| 8 | + * Tiny promise wrapper around IndexedDB — no external dependency (every | ||||
| 9 | + * dependency is key-theft surface). One database, versioned schema. | ||||
| 10 | + */ | ||||
| 11 | + | ||||
| 12 | +const DB_NAME = 'zyquo.cloud.web' | ||||
| 13 | +const DB_VERSION = 1 | ||||
| 14 | +export const STORE_CONVERSATIONS = 'conversations' | ||||
| 15 | + | ||||
| 16 | +let dbPromise: Promise<IDBDatabase> | null = null | ||||
| 17 | + | ||||
| 18 | +export function openDB(): Promise<IDBDatabase> { | ||||
| 19 | + if (dbPromise) return dbPromise | ||||
| 20 | + dbPromise = new Promise((resolve, reject) => { | ||||
| 21 | + const request = indexedDB.open(DB_NAME, DB_VERSION) | ||||
| 22 | + request.onupgradeneeded = () => { | ||||
| 23 | + const db = request.result | ||||
| 24 | + // v1: conversations store keyed by id, indexed by updatedAt for sorting. | ||||
| 25 | + if (!db.objectStoreNames.contains(STORE_CONVERSATIONS)) { | ||||
| 26 | + const store = db.createObjectStore(STORE_CONVERSATIONS, { keyPath: 'id' }) | ||||
| 27 | + store.createIndex('updatedAt', 'updatedAt') | ||||
| 28 | + } | ||||
| 29 | + } | ||||
| 30 | + request.onsuccess = () => { | ||||
| 31 | + request.result.onversionchange = () => request.result.close() | ||||
| 32 | + resolve(request.result) | ||||
| 33 | + } | ||||
| 34 | + request.onerror = () => reject(request.error ?? new Error('IndexedDB open failed')) | ||||
| 35 | + request.onblocked = () => reject(new Error('IndexedDB open blocked')) | ||||
| 36 | + }) | ||||
| 37 | + return dbPromise | ||||
| 38 | +} | ||||
| 39 | + | ||||
| 40 | +function requestToPromise<T>(request: IDBRequest<T>): Promise<T> { | ||||
| 41 | + return new Promise((resolve, reject) => { | ||||
| 42 | + request.onsuccess = () => resolve(request.result) | ||||
| 43 | + request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed')) | ||||
| 44 | + }) | ||||
| 45 | +} | ||||
| 46 | + | ||||
| 47 | +export async function dbGet<T>(store: string, key: string): Promise<T | undefined> { | ||||
| 48 | + const db = await openDB() | ||||
| 49 | + const tx = db.transaction(store, 'readonly') | ||||
| 50 | + return requestToPromise(tx.objectStore(store).get(key) as IDBRequest<T | undefined>) | ||||
| 51 | +} | ||||
| 52 | + | ||||
| 53 | +export async function dbGetAll<T>(store: string): Promise<T[]> { | ||||
| 54 | + const db = await openDB() | ||||
| 55 | + const tx = db.transaction(store, 'readonly') | ||||
| 56 | + return requestToPromise(tx.objectStore(store).getAll() as IDBRequest<T[]>) | ||||
| 57 | +} | ||||
| 58 | + | ||||
| 59 | +export async function dbPut(store: string, value: unknown): Promise<void> { | ||||
| 60 | + const db = await openDB() | ||||
| 61 | + const tx = db.transaction(store, 'readwrite') | ||||
| 62 | + await requestToPromise(tx.objectStore(store).put(value)) | ||||
| 63 | +} | ||||
| 64 | + | ||||
| 65 | +export async function dbDelete(store: string, key: string): Promise<void> { | ||||
| 66 | + const db = await openDB() | ||||
| 67 | + const tx = db.transaction(store, 'readwrite') | ||||
| 68 | + await requestToPromise(tx.objectStore(store).delete(key)) | ||||
| 69 | +} | ||||
| 70 | + | ||||
| 71 | +export async function dbClear(store: string): Promise<void> { | ||||
| 72 | + const db = await openDB() | ||||
| 73 | + const tx = db.transaction(store, 'readwrite') | ||||
| 74 | + await requestToPromise(tx.objectStore(store).clear()) | ||||
| 75 | +} | ||||
added
src/storage/settings.ts
+63 −0
@@ -0,0 +1,63 @@ | |||||
| 1 | +/* | ||||
| 2 | + * settings.ts | ||||
| 3 | + * Zyquo Cloud Web | ||||
| 4 | + * | ||||
| 5 | + * Author: Simon-Pierre Boucher | ||||
| 6 | + * Mail: contact@spboucher.ai | ||||
| 7 | + * | ||||
| 8 | + * App settings — localStorage under zyquo.cloud.web.settings. Light theme is | ||||
| 9 | + * the flagship default (never dark-auto). Proxy base-URL overrides per | ||||
| 10 | + * provider live here (CORS fallback / Zyquo Router). | ||||
| 11 | + */ | ||||
| 12 | + | ||||
| 13 | +import type { Settings } from '../types' | ||||
| 14 | +import { defaultModel } from '../providers/catalog' | ||||
| 15 | + | ||||
| 16 | +const SETTINGS_KEY = 'zyquo.cloud.web.settings' | ||||
| 17 | + | ||||
| 18 | +export function defaultSettings(): Settings { | ||||
| 19 | + const model = defaultModel() | ||||
| 20 | + return { | ||||
| 21 | + theme: 'light', | ||||
| 22 | + accent: 'indigo', | ||||
| 23 | + chatFontSize: 13.5, | ||||
| 24 | + density: 'comfortable', | ||||
| 25 | + defaultModelID: model.id, | ||||
| 26 | + defaultProvider: model.provider, | ||||
| 27 | + defaultSystemPrompt: '', | ||||
| 28 | + defaultParameters: {}, | ||||
| 29 | + proxyBaseURLs: {}, | ||||
| 30 | + streamingEnabled: true, | ||||
| 31 | + aliases: {}, | ||||
| 32 | + favoriteModelIDs: [], | ||||
| 33 | + recentModelIDs: [], | ||||
| 34 | + firstRunAcknowledged: false, | ||||
| 35 | + focusMode: false, | ||||
| 36 | + } | ||||
| 37 | +} | ||||
| 38 | + | ||||
| 39 | +export function loadSettings(): Settings { | ||||
| 40 | + try { | ||||
| 41 | + const raw = localStorage.getItem(SETTINGS_KEY) | ||||
| 42 | + if (!raw) return defaultSettings() | ||||
| 43 | + const parsed: unknown = JSON.parse(raw) | ||||
| 44 | + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { | ||||
| 45 | + return { ...defaultSettings(), ...(parsed as Partial<Settings>) } | ||||
| 46 | + } | ||||
| 47 | + return defaultSettings() | ||||
| 48 | + } catch { | ||||
| 49 | + return defaultSettings() | ||||
| 50 | + } | ||||
| 51 | +} | ||||
| 52 | + | ||||
| 53 | +export function saveSettings(settings: Settings): void { | ||||
| 54 | + localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)) | ||||
| 55 | +} | ||||
| 56 | + | ||||
| 57 | +export function clearSettings(): void { | ||||
| 58 | + localStorage.removeItem(SETTINGS_KEY) | ||||
| 59 | +} | ||||
| 60 | + | ||||
| 61 | +export function rawSettingsRecord(): string | null { | ||||
| 62 | + return localStorage.getItem(SETTINGS_KEY) | ||||
| 63 | +} | ||||
added
src/storage/vault.ts
+161 −0
@@ -0,0 +1,161 @@ | |||||
| 1 | +/* | ||||
| 2 | + * vault.ts | ||||
| 3 | + * Zyquo Cloud Web | ||||
| 4 | + * | ||||
| 5 | + * Author: Simon-Pierre Boucher | ||||
| 6 | + * Mail: contact@spboucher.ai | ||||
| 7 | + * | ||||
| 8 | + * Opt-in passphrase lock for the provider-key map: AES-GCM with a key derived | ||||
| 9 | + * from the user's passphrase via PBKDF2 (WebCrypto). Honesty by design: this | ||||
| 10 | + * is obfuscation-at-rest, NOT a secure vault — without a passphrase keys sit | ||||
| 11 | + * in plaintext localStorage, and even with one, a compromised page can read | ||||
| 12 | + * them once unlocked. The UI says so. | ||||
| 13 | + */ | ||||
| 14 | + | ||||
| 15 | +import { | ||||
| 16 | + isOverlayActive, | ||||
| 17 | + rawKeysRecord, | ||||
| 18 | + readKeyMap, | ||||
| 19 | + setRawKeysRecord, | ||||
| 20 | + setUnlockedOverlay, | ||||
| 21 | + type KeyMap, | ||||
| 22 | +} from './keys' | ||||
| 23 | + | ||||
| 24 | +const PBKDF2_ITERATIONS = 310_000 | ||||
| 25 | + | ||||
| 26 | +interface VaultBlob { | ||||
| 27 | + __zyquoVault: 1 | ||||
| 28 | + salt: string | ||||
| 29 | + iv: string | ||||
| 30 | + ciphertext: string | ||||
| 31 | +} | ||||
| 32 | + | ||||
| 33 | +function isVaultBlob(value: unknown): value is VaultBlob { | ||||
| 34 | + return ( | ||||
| 35 | + typeof value === 'object' && | ||||
| 36 | + value !== null && | ||||
| 37 | + (value as VaultBlob).__zyquoVault === 1 && | ||||
| 38 | + typeof (value as VaultBlob).salt === 'string' && | ||||
| 39 | + typeof (value as VaultBlob).iv === 'string' && | ||||
| 40 | + typeof (value as VaultBlob).ciphertext === 'string' | ||||
| 41 | + ) | ||||
| 42 | +} | ||||
| 43 | + | ||||
| 44 | +/** Whether the stored key record is passphrase-encrypted. */ | ||||
| 45 | +export function isVaultEnabled(): boolean { | ||||
| 46 | + const raw = rawKeysRecord() | ||||
| 47 | + if (!raw) return false | ||||
| 48 | + try { | ||||
| 49 | + return isVaultBlob(JSON.parse(raw)) | ||||
| 50 | + } catch { | ||||
| 51 | + return false | ||||
| 52 | + } | ||||
| 53 | +} | ||||
| 54 | + | ||||
| 55 | +/** Vault enabled and not yet unlocked this session. */ | ||||
| 56 | +export function isLocked(): boolean { | ||||
| 57 | + return isVaultEnabled() && !isOverlayActive() | ||||
| 58 | +} | ||||
| 59 | + | ||||
| 60 | +function toBase64(bytes: Uint8Array): string { | ||||
| 61 | + let binary = '' | ||||
| 62 | + for (const b of bytes) binary += String.fromCharCode(b) | ||||
| 63 | + return btoa(binary) | ||||
| 64 | +} | ||||
| 65 | + | ||||
| 66 | +function fromBase64(base64: string): Uint8Array { | ||||
| 67 | + const binary = atob(base64) | ||||
| 68 | + const bytes = new Uint8Array(binary.length) | ||||
| 69 | + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) | ||||
| 70 | + return bytes | ||||
| 71 | +} | ||||
| 72 | + | ||||
| 73 | +async function deriveKey(passphrase: string, salt: Uint8Array): Promise<CryptoKey> { | ||||
| 74 | + const material = await crypto.subtle.importKey( | ||||
| 75 | + 'raw', | ||||
| 76 | + new TextEncoder().encode(passphrase), | ||||
| 77 | + 'PBKDF2', | ||||
| 78 | + false, | ||||
| 79 | + ['deriveKey'] | ||||
| 80 | + ) | ||||
| 81 | + return crypto.subtle.deriveKey( | ||||
| 82 | + { name: 'PBKDF2', salt: salt as BufferSource, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' }, | ||||
| 83 | + material, | ||||
| 84 | + { name: 'AES-GCM', length: 256 }, | ||||
| 85 | + false, | ||||
| 86 | + ['encrypt', 'decrypt'] | ||||
| 87 | + ) | ||||
| 88 | +} | ||||
| 89 | + | ||||
| 90 | +async function encryptMap(map: KeyMap, passphrase: string): Promise<VaultBlob> { | ||||
| 91 | + const salt = crypto.getRandomValues(new Uint8Array(16)) | ||||
| 92 | + const iv = crypto.getRandomValues(new Uint8Array(12)) | ||||
| 93 | + const key = await deriveKey(passphrase, salt) | ||||
| 94 | + const plaintext = new TextEncoder().encode(JSON.stringify(map)) | ||||
| 95 | + const ciphertext = await crypto.subtle.encrypt( | ||||
| 96 | + { name: 'AES-GCM', iv: iv as BufferSource }, | ||||
| 97 | + key, | ||||
| 98 | + plaintext as BufferSource | ||||
| 99 | + ) | ||||
| 100 | + return { | ||||
| 101 | + __zyquoVault: 1, | ||||
| 102 | + salt: toBase64(salt), | ||||
| 103 | + iv: toBase64(iv), | ||||
| 104 | + ciphertext: toBase64(new Uint8Array(ciphertext)), | ||||
| 105 | + } | ||||
| 106 | +} | ||||
| 107 | + | ||||
| 108 | +async function decryptBlob(blob: VaultBlob, passphrase: string): Promise<KeyMap> { | ||||
| 109 | + const key = await deriveKey(passphrase, fromBase64(blob.salt)) | ||||
| 110 | + const plaintext = await crypto.subtle.decrypt( | ||||
| 111 | + { name: 'AES-GCM', iv: fromBase64(blob.iv) as BufferSource }, | ||||
| 112 | + key, | ||||
| 113 | + fromBase64(blob.ciphertext) as BufferSource | ||||
| 114 | + ) | ||||
| 115 | + return JSON.parse(new TextDecoder().decode(plaintext)) as KeyMap | ||||
| 116 | +} | ||||
| 117 | + | ||||
| 118 | +/** Encrypts the current plaintext key map with a passphrase and keeps it unlocked. */ | ||||
| 119 | +export async function enableVault(passphrase: string): Promise<void> { | ||||
| 120 | + const map = readKeyMap() | ||||
| 121 | + const blob = await encryptMap(map, passphrase) | ||||
| 122 | + setRawKeysRecord(JSON.stringify(blob)) | ||||
| 123 | + setUnlockedOverlay(map) | ||||
| 124 | +} | ||||
| 125 | + | ||||
| 126 | +/** Decrypts into the in-memory overlay for this session. Throws on wrong passphrase. */ | ||||
| 127 | +export async function unlockVault(passphrase: string): Promise<void> { | ||||
| 128 | + const raw = rawKeysRecord() | ||||
| 129 | + if (!raw) throw new Error('No vault present') | ||||
| 130 | + const blob: unknown = JSON.parse(raw) | ||||
| 131 | + if (!isVaultBlob(blob)) throw new Error('Keys are not encrypted') | ||||
| 132 | + const map = await decryptBlob(blob, passphrase) | ||||
| 133 | + setUnlockedOverlay(map) | ||||
| 134 | +} | ||||
| 135 | + | ||||
| 136 | +/** Re-encrypts the current (unlocked) map — call after key edits while locked mode is on. */ | ||||
| 137 | +export async function persistVault(passphrase: string): Promise<void> { | ||||
| 138 | + const map = readKeyMap() | ||||
| 139 | + const blob = await encryptMap(map, passphrase) | ||||
| 140 | + setRawKeysRecord(JSON.stringify(blob)) | ||||
| 141 | +} | ||||
| 142 | + | ||||
| 143 | +/** Turns the lock off: writes the decrypted map back as plaintext. */ | ||||
| 144 | +export async function disableVault(passphrase: string): Promise<void> { | ||||
| 145 | + const raw = rawKeysRecord() | ||||
| 146 | + if (!raw) { | ||||
| 147 | + setUnlockedOverlay(null) | ||||
| 148 | + return | ||||
| 149 | + } | ||||
| 150 | + const blob: unknown = JSON.parse(raw) | ||||
| 151 | + if (isVaultBlob(blob)) { | ||||
| 152 | + const map = await decryptBlob(blob, passphrase) | ||||
| 153 | + setRawKeysRecord(JSON.stringify(map)) | ||||
| 154 | + } | ||||
| 155 | + setUnlockedOverlay(null) | ||||
| 156 | +} | ||||
| 157 | + | ||||
| 158 | +/** Locks the session (drops the in-memory overlay; ciphertext stays at rest). */ | ||||
| 159 | +export function lockVault(): void { | ||||
| 160 | + setUnlockedOverlay(null) | ||||
| 161 | +} | ||||
modified
tsconfig.app.tsbuildinfo
+1 −1
@@ -1 +1 @@ | |||||
| 1 | −{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/features/streamtestview.tsx","./src/providers/anthropic.ts","./src/providers/catalog.ts","./src/providers/openaicompatible.ts","./src/providers/registry.ts","./src/providers/sse.ts","./src/providers/types.ts","./src/storage/keys.ts","./src/types/index.ts"],"version":"5.9.3"} | ||||
| \ No newline at end of file | |||||
| 1 | +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/features/streamtestview.tsx","./src/features/devharness.ts","./src/providers/anthropic.ts","./src/providers/catalog.ts","./src/providers/openaicompatible.ts","./src/providers/registry.ts","./src/providers/sse.ts","./src/providers/testkey.ts","./src/providers/types.ts","./src/storage/backup.ts","./src/storage/conversations.ts","./src/storage/db.ts","./src/storage/keys.ts","./src/storage/settings.ts","./src/storage/vault.ts","./src/types/index.ts"],"version":"5.9.3"} | ||||
| \ No newline at end of file | |||||
| 2 | 2 | ||||