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>
Showing 12 changed files with +694 and −3
modified
docs/PLAN.md
+17 −1
@@ -74,7 +74,23 @@ live OpenAI from headless Chromium: token-by-token rendering, usage events, and | ||
| 74 | 74 | AbortController cancellation all verified. Playwright added (dev-only) as the |
| 75 | 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 | 95 | ## Phase 4 — Design system & UI — pending |
| 80 | 96 | |
modified
eslint.config.js
+4 −1
@@ -78,7 +78,10 @@ export default [ | ||
| 78 | 78 | ...tsPlugin.configs.recommended.rules, |
| 79 | 79 | ...reactHooks.configs.recommended.rules, |
| 80 | 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 | 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 | 9 | import { StrictMode } from 'react' |
| 10 | 10 | import { createRoot } from 'react-dom/client' |
| 11 | 11 | import App from './App' |
| 12 | +import { installDevHarness } from './features/devHarness' | |
| 12 | 13 | import './design/tokens.css' |
| 13 | 14 | import './design/global.css' |
| 14 | 15 | |
| 16 | +installDevHarness() | |
| 17 | + | |
| 15 | 18 | const container = document.getElementById('root') |
| 16 | 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 | |