diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2d8285bf..e7269ec5 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -167,7 +167,7 @@ On any non-trivial code change add a single-line comment explaining **why**, not ### Git & CI - Conventional Commits format: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:` -- Pre-commit: `simple-git-hooks` runs Biome check on staged files +- Pre-commit: after explicit `pnpm run hooks:install`, `simple-git-hooks` runs Biome on staged files; CI is mandatory regardless - **⚠️ Constrained local hardware — do NOT run heavy suites locally.** This machine has ~3–4 GB RAM. **Never** run the full Vitest **coverage** suite, **Playwright E2E**, **Stryker mutation**, **Lighthouse CI**, or the **Storybook test-runner** locally — they are **CI-only by design**. Run **one heavy command at a time** (no parallel `vitest`/`biome`/`tsc`/`vite`). - Local preflight (sequential, minimal): `pnpm run lint` → `pnpm run typecheck` → `pnpm run i18n:check` (only when locale JSON changed) → **targeted** `pnpm exec vitest run ` (no `--coverage`). Run `pnpm run build && pnpm run smoke:prod` only when you touched `vite.config.ts`, `packages/ai-core`, or `workers/`. Coverage, E2E, Lighthouse, Stryker, and Storybook are **CI gate jobs** — let GitHub Actions run them. - CI pipeline (see [`docs/CI.md`](../docs/CI.md)): **`security` → `quality`** (Biome + `tsc` + Vitest matrix) **→ `build` / `e2e` / `storybook` in parallel** → **`lighthouse`** after build → **`deploy`** on `main` after build+e2e diff --git a/AGENTS.md b/AGENTS.md index 132882da..9739a6f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -139,7 +139,7 @@ WorldScript-Studio/ - `vitest.config.ts` — coverage thresholds (lines 74, branches 60, functions 67, statements 72), `maxWorkers: 1` - `playwright.config.ts` — E2E projects: Chromium desktop + Pixel 5 mobile in CI; Firefox + optional mobile locally - `turbo.json` — task graph for `build`, `dev`, `lint`, `typecheck`, `test`, `mutation` -- `pnpm-workspace.yaml` — workspace packages + `onlyBuiltDependencies` allowlist +- `pnpm-workspace.yaml` — workspace packages + pnpm v11 `allowBuilds` default-deny map - `stryker.conf.json` — ~20 mutation targets (services + features), `break: 75` - `.lighthouserc.cjs` — accessibility `error` ≥ 0.95, CLS `error` ≤ 0.1, performance/SEO `warn` - `src-tauri/tauri.conf.json` / `Cargo.toml` — desktop window config, CSP, updater endpoints, rust-compute feature @@ -258,7 +258,8 @@ On any non-trivial change, add a single-line comment explaining **why**, not wha ### Commit Messages Conventional Commits: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`. -Pre-commit hook runs `biome check --write` on staged files via `simple-git-hooks` + `lint-staged`. +After an explicit `pnpm run hooks:install`, the pre-commit hook runs `biome check --write` on staged +files via `simple-git-hooks` + `lint-staged`; CI remains mandatory when hooks are not installed. --- diff --git a/AUDIT.md b/AUDIT.md index cbaf13d4..1e419b77 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -113,7 +113,7 @@ the same root cause in one sprint is itself the finding: **the failure mode is n - **Rebrand residue:** ADR-0008 present-tense "StoryCraft is offline-first" → "WorldScript Studio". Remaining `storycraft*` hits are intentional historical records (CHANGELOG version entries, archived sprints) or technical identifiers that were genuine at their version — left intact. - **GitHub releases:** 6 historical release titles renamed StoryCraft → WorldScript (v1.3.0, v1.5.0, v1.7.0, v1.17.0, v1.20.0, v1.21.0); v1.23.0 release/tag verified correct (latest, full changelog, tag → `fbaa33c3`). - **GitHub Packages:** orphaned `storycraft-studio` GHCR container image to be deleted so only `worldscript-studio` remains (requires `delete:packages` token scope — pending maintainer auth refresh). -- **Dependabot hardening:** added `cooldown: default-days: 7` to all three `.github/dependabot.yml` ecosystems so newly released versions age 7 days before a PR is opened — matched to the `.npmrc` `minimum-release-age=10080` (7-day) supply-chain quarantine already enforced at pnpm install-time, so a version is never PR'd before `pnpm install --frozen-lockfile` would accept it (`.github/dependabot.yml` is the source of truth for the cooldown value). Open queue handled per the CodeAnt Correction Loop: #150 candle-nn merged; #151 candle-core rebased + re-running; #152 dev-tooling fixed at root (dual `playwright-core` deduped to 1.61.0 via `pnpm-workspace.yaml` override); #154/#155 are blocked solely by the `minimum-release-age` quarantine (packages too fresh — working as designed) and clear once aged. +- **Dependabot hardening:** added `cooldown: default-days: 7` to all three `.github/dependabot.yml` ecosystems so newly released versions age 7 days before a PR is opened — matched to the `pnpm-workspace.yaml` `minimumReleaseAge: 10080` (7-day) supply-chain quarantine enforced at pnpm install-time, so a version is never PR'd before `pnpm install --frozen-lockfile` would accept it (`.github/dependabot.yml` is the source of truth for the cooldown value). Open queue handled per the CodeAnt Correction Loop: #150 candle-nn merged; #151 candle-core rebased + re-running; #152 dev-tooling fixed at root (dual `playwright-core` deduped to 1.61.0 via `pnpm-workspace.yaml` override); #154/#155 are blocked solely by the `minimumReleaseAge` quarantine (packages too fresh — working as designed) and clear once aged. ## v1.23 i18n Interpolation Bug-Class Fix + Regression Guard (2026-06-14) @@ -260,7 +260,7 @@ the same root cause in one sprint is itself the finding: **the failure mode is n | `package.json` | Added `@typescript/native-preview@beta` and `@typescript/typescript6` alias | | `tsconfig.tsgo.json` | New tsgo-specific config (excludes `vite/client` types) | | `.github/workflows/ci.yml` | Updated typecheck step to use tsgo | -| `.npmrc` | Disabled `strict-peer-dependencies` for tsgo compatibility | +| `pnpm-workspace.yaml` | Disabled `strictPeerDependencies` for tsgo compatibility | | `pnpm-workspace.yaml` | Added `strictPeerDependencies: false` | | `docs/TS7-MIGRATION.md` | Migration guide created | @@ -1744,9 +1744,9 @@ WorldScript Studio was assessed as a strong, modern React/TypeScript application | Status | Item | |--------|------| -| ✅ | Added `strict-dep-builds=true` to `.npmrc` | -| ✅ | Added `block-exotic-subdeps=true` to `.npmrc` | -| ✅ | Added `minimum-release-age=10080` (7 days) to `.npmrc` | +| ✅ | Added `strictDepBuilds: true` to `pnpm-workspace.yaml` | +| ✅ | Added `blockExoticSubdeps: true` to `pnpm-workspace.yaml` | +| ✅ | Added `minimumReleaseAge: 10080` (7 days) to `pnpm-workspace.yaml` | | ✅ | Added security justification comments to `pnpm-workspace.yaml` overrides | ### OpenRouter Provider Hardening (P1) @@ -1834,7 +1834,7 @@ several apply only to dev/test transitive deps and are never shipped to users. **Dependency hygiene status (2026-06-13):** - `pnpm audit --audit-level=high` → 0 vulnerabilities. - `pnpm audit --audit-level=moderate` → 0 vulnerabilities. -- `.npmrc` hardening active: `strict-dep-builds=true`, `block-exotic-subdeps=true`, `minimum-release-age=10080`. +- `pnpm-workspace.yaml` hardening active: `strictDepBuilds: true`, `blockExoticSubdeps: true`, `minimumReleaseAge: 10080`. - `pnpm outdated` (re-run 2026-06-13): only non-critical patch/minor drift — `@ai-sdk/google|openai|react`, `ai`, `@storybook/*` + `storybook` (10.4.2→10.4.4), `@types/node`, `docx`, `dompurify`, `lint-staged`, `turbo`, `yjs`, `zustand`, `wrangler`. No major versions. `@duckdb/duckdb-wasm` (1.32.0) and `@typescript/native-preview` are dev/pre-release tracks and intentionally pinned. **Plugin sandbox post-fix validation (2026-06-13):** The v1.22 plugin-isolation hardening diff --git a/README.md b/README.md index 9882acdd..267e40fd 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ v1.26.0 IndexedDB v8 PWA v3.0 - i18n 19 locales — 2897 keys + i18n 19 locales — 2904 keys 6477+ tests / 532 files Codecov Coverage License MIT @@ -397,7 +397,7 @@ Infrastructure-level features that keep the app fast and extensible as projects ### 🌐 Full Multi-Language Support -Shipped UI locales with **2897 i18n keys** across all 19 languages — zero hardcoded user-facing strings: +Shipped UI locales with **2904 i18n keys** across all 19 languages — zero hardcoded user-facing strings: - 🇩🇪 **German** (Deutsch) - 🇬🇧 **English** @@ -506,7 +506,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and | **PDF Export** | jsPDF | Client-side, configurable PDF document generation | | **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) | | **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking | -| **i18n** | Custom React Context (`I18nContext.tsx`) | 2897 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence | +| **i18n** | Custom React Context (`I18nContext.tsx`) | 2904 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence | | **Testing** | Vitest 4.x (6477+ tests / 532 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | | **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy | | **Visualization** | Force-directed graph | Interactive character relationship network | @@ -708,7 +708,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt **Current test metrics (2026-07-30, CI-reported):** - **6477+ unit tests** across **532 test files** — all passing - Coverage thresholds: lines ≥ 74 · branches ≥ 60 · functions ≥ 67 · statements ≥ 72 — enforced in CI (see Codecov badge for live metrics) -- i18n: **2897 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu Beta) +- i18n: **2904 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta) **CI-cloud-first workflow (recommended):** On constrained hardware run **`pnpm run lint && pnpm run i18n:check && pnpm run typecheck`** locally, then push and let CI handle coverage, E2E, Lighthouse, and Stryker. Authoritative numbers come from CI artifacts (Codecov, JUnit). After CI goes green, update the README badges and `AUDIT.md` quality-gate line from the reported metrics. See **[`docs/CI.md`](docs/CI.md) § Cloud CI-first vs local development** for the full post-merge doc-update checklist. diff --git a/TODO.md b/TODO.md index c6eb6fda..f4e24200 100644 --- a/TODO.md +++ b/TODO.md @@ -155,7 +155,7 @@ are all version-bumped and synced for this release. ## Dependency-Hygiene Backlog (carried forward) -> `.npmrc` Hardening (`strict-dep-builds=true`, `block-exotic-subdeps=true`, `minimum-release-age=10080`) ist bereits aktiv. +> `pnpm-workspace.yaml` hardening (`strictDepBuilds: true`, `blockExoticSubdeps: true`, `minimumReleaseAge: 10080`) is active. > `pnpm audit --audit-level=high` → 0 vulnerabilities; `pnpm audit --audit-level=moderate` → 0 vulnerabilities. > Aktueller Status in `AUDIT.md` § *Known Overrides Table*. @@ -281,7 +281,7 @@ are all version-bumped and synced for this release. - ✅ **Production blank screen — zod/rolldown DCE** (2026-06-02) — `init_locales is not defined`: rolldown's prod DCE dropped zod's `__esm` init wrappers (zod `sideEffects:false`). Fixed via `patches/zod@4.4.3.patch` (`sideEffects:true`). Added `smoke:prod` (headless mount check on built `dist/`) to CI build job + `unhandledrejection` startup handler — closes the dev-mode-E2E blind spot - 🔄 **C-6** — ar/he UI translation **complete** (2026-06-03): 18 modules translated in `locales/{ar,he}/` (help.json English fallback), Noto fonts + RTL shell layout shipped as Beta. Remaining: native-speaker review + help-article prose — community task. See `docs/I18N-GLOSSARY-RTL.md` - 🔄 **C-7 remainder** — Coverage → L85%/B75%/F80%; Stryker break 75→80 (current thresholds: L73/F65/B58). **Phase 3 started (2026-06-02):** +33 LoRA tests (useLoraView, training wizard, sub-panels — were 0%) -- 🟡 IDB at-rest encryption lifecycle (2026-08-11 reconciliation) — startup unlock, session lock, and fail-closed protected writes are implemented. Disable, forgot-passphrase deletion, and passphrase rotation are intentionally blocked until a durable cross-database migration journal and recovery protocol exist; do not describe them as completed. +- 🟡 IDB at-rest encryption lifecycle (2026-08-11 reconciliation) — startup unlock, session lock, fail-closed protected writes, and a versioned single-owner migration journal are implemented. Disable, forgot-passphrase deletion, and passphrase rotation are intentionally blocked until per-store conversion, verification, cross-tab recovery, and recovery UX exist; do not describe them as completed. - ✅ **P0-2** — Plugin worker isolation (`workers/plugin.worker.ts`) — routes plugin execution to isolated worker context with timeout and sandboxed API - 🟡 **P0-4** — DuckDB OPFS at-rest encryption (`services/duckdb/duckdbEncryption.ts`) — cell-level encryption is now wired for the one column holding literal manuscript prose, `codex_mentions.excerpt` (v1.25.0): `duckdbCodexWrite()` encrypts it into `excerpt_enc BLOB` when `enableIdbAtRestEncryption` is active, with `services/duckdb/codexExcerptEncryptionMigration.ts` backfilling pre-existing plaintext rows. Full OPFS **file-level** encryption remains infeasible (DuckDB-WASM owns the OPFS file handle directly) and is an accepted, permanent limitation, not a remaining task — see `.github/SECURITY.md` SEC-6. - ✅ **P0-5** — Voice WASM model download UI (`components/voice/VoiceModelDownloadModal.tsx`) — progress modal for Whisper/Kokoro model downloads with cancel/retry diff --git a/components/settings/AiProviderCard.tsx b/components/settings/AiProviderCard.tsx index cbd0f78d..d62e2c64 100644 --- a/components/settings/AiProviderCard.tsx +++ b/components/settings/AiProviderCard.tsx @@ -7,6 +7,7 @@ import type { WebGpuAdapterInfo } from '../../services/ai/webGpuDetectorService' import { detectWebGpuDetails } from '../../services/ai/webGpuDetectorService'; import { type LocalEndpointScanResult, + type LocalServerDiagnostic, listLocalBackendModels, scanLocalOpenAiCompatibleEndpoints, testAIConnection, @@ -90,6 +91,100 @@ interface AiProviderCardProps { browserOllamaEnabled?: boolean; } +interface LocalDiagnosticState { + context: string; + diagnostic: LocalServerDiagnostic; +} + +type ConnectionTestStatus = 'idle' | 'loading' | 'ok' | 'error'; + +const LocalServerDiagnosticPanel: FC<{ diagnostic: LocalServerDiagnostic }> = ({ diagnostic }) => { + const { t } = useTranslation(); + const transportLabel = + diagnostic.transport === 'tauri-http' + ? t('settings.ai.localDiagnostic.tauriHttp') + : t('settings.ai.localDiagnostic.browserFetch'); + + return ( +
+
+ {t('settings.ai.localDiagnostic.endpoint')} +
+
{diagnostic.normalizedEndpoint}
+
+ {t('settings.ai.localDiagnostic.transport')} +
+
{transportLabel}
+
+ {t('settings.ai.localDiagnostic.models')} +
+
{diagnostic.modelNames.join(', ')}
+
+ ); +}; + +const ProviderConnectionStatus: FC<{ + diagnostic: LocalServerDiagnostic | null; + isOllamaUntestable: boolean; + status: ConnectionTestStatus; + testError: string; +}> = ({ diagnostic, isOllamaUntestable, status, testError }) => { + const { t } = useTranslation(); + const statusClass = isOllamaUntestable + ? 'bg-[var(--sc-surface-overlay)] text-[var(--sc-text-secondary)]' + : status === 'ok' + ? 'bg-[var(--sc-success-bg)] text-[var(--sc-success-fg)]' + : status === 'error' + ? 'bg-[var(--sc-danger-bg)] text-[var(--sc-danger-fg)]' + : 'bg-[var(--sc-surface-overlay)] text-[var(--sc-text-secondary)]'; + + return ( +
+
+ + {t('settings.ai.providerStatusLabel')} + + + {isOllamaUntestable ? ( + t('settings.ai.providerStatusUnavailableBrowser') + ) : ( + <> + {status === 'loading' && t('settings.ai.providerStatusTesting')} + {status === 'ok' && t('settings.ai.providerStatusConnected')} + {status === 'error' && t('settings.ai.providerStatusDisconnected')} + {status === 'idle' && t('settings.ai.providerStatusNotTested')} + + )} + +
+ {!isOllamaUntestable && status === 'error' && testError && ( +

{testError}

+ )} + {diagnostic && } +
+ ); +}; + +function getOllamaAvailability( + provider: AIProvider, + isDesktop: boolean, + browserOllamaEnabled: boolean, +): { canAttemptOllama: boolean; ollamaUntestable: boolean } { + const canAttemptOllama = isDesktop || browserOllamaEnabled; + return { + canAttemptOllama, + ollamaUntestable: provider === 'ollama' && !canAttemptOllama, + }; +} + export const AiProviderCard: FC = ({ advancedAi, onAdvancedAiPatch, @@ -108,12 +203,15 @@ export const AiProviderCard: FC = ({ const isAnthropicProxyCapableWeb = !isDesktop && isServerlessProxyCapable(); // QNBS-v3 (ADR-0017): the opt-in flag widens Ollama testability to the browser — everywhere else // in this component that gated Ollama purely on isDesktop now also accepts this flag. - const canAttemptOllama = isDesktop || browserOllamaEnabled; // QNBS-v3: for Ollama when neither desktop nor the browser opt-in applies, the auto-test effect // and the manual "Test connection" button are both disabled (see below) — testStatus can never // leave 'idle' here, so the generic status badge must not render its idle→"Ready" label, which // would misleadingly imply a verified connection next to the "desktop app required" banner. - const ollamaUntestable = provider === 'ollama' && !canAttemptOllama; + const { canAttemptOllama, ollamaUntestable } = getOllamaAvailability( + provider, + isDesktop, + browserOllamaEnabled, + ); const [openaiKey, setOpenaiKey] = useState(''); // QNBS-v3: Grok's own key input state, mirroring OpenAI's pattern above. const [grokKey, setGrokKey] = useState(''); @@ -121,8 +219,9 @@ export const AiProviderCard: FC = ({ const [anthropicKey, setAnthropicKey] = useState(''); const [isSavingAnthropicKey, setIsSavingAnthropicKey] = useState(false); const [ollamaModels, setOllamaModels] = useState([]); - const [testStatus, setTestStatus] = useState<'idle' | 'loading' | 'ok' | 'error'>('idle'); + const [testStatus, setTestStatus] = useState('idle'); const [testError, setTestError] = useState(''); + const [localDiagnostic, setLocalDiagnostic] = useState(null); const [isLoadingModels, setIsLoadingModels] = useState(false); const [isSavingKey, setIsSavingKey] = useState(false); const [scanBusy, setScanBusy] = useState(false); @@ -139,6 +238,27 @@ export const AiProviderCard: FC = ({ // context has since moved on — e.g. a switch to Ollama-in-browser must not let an older // request's raw error text land in testError once ollamaUntestable becomes true. const testRequestIdRef = useRef(0); + const diagnosticContextRef = useRef(null); + const localDiagnosticContext = [ + provider, + ollamaBaseUrl, + advancedAi.localBackendPreset, + advancedAi.openAiCompatibleBaseUrl, + browserOllamaEnabled ? 'browser-enabled' : 'desktop-only', + isDesktop ? 'tauri-runtime' : 'browser-runtime', + ].join('\u0000'); + const visibleLocalDiagnostic = + localDiagnostic?.context === localDiagnosticContext ? localDiagnostic.diagnostic : null; + + useEffect(() => { + // QNBS-v3: A completed diagnostic only describes the exact local endpoint context that was tested. + if (diagnosticContextRef.current === localDiagnosticContext) return; + diagnosticContextRef.current = localDiagnosticContext; + testRequestIdRef.current += 1; + setTestStatus('idle'); + setTestError(''); + setLocalDiagnostic(null); + }, [localDiagnosticContext]); // QNBS-v3: any connection-context change invalidates in-flight tests/model-loads — without this, // only an explicit second handleTest() call bumped the guard, so editing the endpoint/preset/URL @@ -256,8 +376,10 @@ export const AiProviderCard: FC = ({ const handleTest = useCallback(async () => { const requestId = ++testRequestIdRef.current; + const requestContext = localDiagnosticContext; setTestStatus('loading'); setTestError(''); + setLocalDiagnostic(null); if (provider === 'webllm') probeWebGpu(requestId); try { const result = await testAIConnection(provider, { @@ -269,6 +391,9 @@ export const AiProviderCard: FC = ({ if (testRequestIdRef.current !== requestId) return; // stale — superseded by a newer request if (result.ok) { setTestStatus('ok'); + setLocalDiagnostic( + result.localServer ? { context: requestContext, diagnostic: result.localServer } : null, + ); } else { setTestStatus('error'); // QNBS-v3: `kind` is a stable, i18n-mappable classification — prefer it over the raw @@ -291,6 +416,7 @@ export const AiProviderCard: FC = ({ advancedAi.localBackendPreset, advancedAi.openAiCompatibleBaseUrl, browserOllamaEnabled, + localDiagnosticContext, probeWebGpu, t, ]); @@ -363,39 +489,12 @@ export const AiProviderCard: FC = ({ ))} -
-
- - {t('settings.ai.providerStatusLabel')} - - - {ollamaUntestable ? ( - t('settings.ai.providerStatusUnavailableBrowser') - ) : ( - <> - {testStatus === 'ok' && t('settings.ai.providerStatusConnected')} - {testStatus === 'error' && t('settings.ai.providerStatusDisconnected')} - {testStatus === 'idle' && t('settings.ai.providerStatusNotTested')} - - )} - -
- {!ollamaUntestable && testStatus === 'error' && testError && ( -

{testError}

- )} -
+ {provider === 'gemini' && (
@@ -810,6 +909,8 @@ export const AiProviderCard: FC = ({ {/* QNBS-v3 (#266 review, ADR-0017): in the plain PWA the ollama test would re-create CORS noise — the banner + CTA above is the only actionable path there. The browserOllamaEnabled opt-in widens this the same way it widens the auto-probe. */} + {/* QNBS-v3: success/error text lives only in ProviderConnectionStatus above — this + button previously duplicated the exact same testError string in a second span. */} - {testStatus === 'ok' && ( - - - )} - {/* QNBS-v3 (CodeRabbit CWE-209): mirror the guard above — a stale in-flight test for a - prior provider must not surface raw error text once Ollama-in-browser is selected. */} - {!ollamaUntestable && testStatus === 'error' && ( - - - )}
)} diff --git a/docs/CI.md b/docs/CI.md index 014e3ee1..37e39de1 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -194,8 +194,16 @@ once a manual run demonstrates the flakiness is resolved — concretely, three c | **SHA-pinned actions** | Every job | All `uses:` references pinned to commit SHA (`# vN` comment) — immune to tag-mutable supply-chain attacks; Dependabot updates SHAs automatically | | **Dependabot** | Weekly (Monday) | PRs for npm deps (dev-tooling grouped) + GitHub Actions SHA bumps (max 5 open PRs) | | **`dependency-review-action`** | PRs only (security job) | Blocks PRs that introduce new high/critical vulnerabilities | +| **pnpm v11 build-script policy** | Dependency installation | `pnpm-workspace.yaml` uses the sole supported, default-deny `allowBuilds` map; legacy build-script lists are intentionally absent | | **Branch protection** | Always | `main` requires 1 approved review, required status checks (security, quality ×2, build), no force-push | +Only the two reviewed native packages marked `true` in `allowBuilds` (`@swc/core` and `esbuild`) +may run dependency lifecycle scripts. `@google/genai`, `core-js`, `onnxruntime-node`, `protobufjs`, +`sharp`, `simple-git-hooks`, `unrs-resolver`, and `workerd` are explicitly denied. Git-hook +installation is deliberately opt-in through `pnpm run hooks:install`; dependency installation no +longer runs a root `prepare` command. `pnpm-workspace.yaml` sets `verifyDepsBeforeRun: error`, so +`pnpm run`/`pnpm exec` aborts when dependencies are stale instead of implicitly running install. + --- ## Permissions diff --git a/docs/IDB-ENCRYPTION.md b/docs/IDB-ENCRYPTION.md index 22df1fa4..482b7456 100644 --- a/docs/IDB-ENCRYPTION.md +++ b/docs/IDB-ENCRYPTION.md @@ -1,6 +1,6 @@ # IndexedDB At-Rest Encryption — Implementation -**Status:** Partial implementation with fail-closed locked writes. Cross-database enable, disable, and rekey migration remains a release-blocking follow-up. +**Status:** Partial implementation with fail-closed locked writes and a durable migration-journal foundation. Cross-database enable, disable, and rekey conversion/recovery remain a release-blocking follow-up. **Feature flag:** `enableIdbAtRestEncryption` (on by default since v1.23 — manage via Settings → Privacy) **Tracking:** SEC-3 (Master Plan Phase 2 delivery) @@ -16,7 +16,7 @@ The encryption service is gated behind `featureFlags.enableIdbAtRestEncryption` - **Locked writes fail closed** — when a passphrase sentinel exists but no runtime key is available, protected reads and writes return a typed locked error rather than storing plaintext. - **Disable and passphrase rotation are unavailable** until a versioned, resumable cross-database journal verifies every conversion. The UI does not expose those destructive controls and the service rejects them before mutation. -**Required next step:** implement a durable migration journal, per-store checkpoints, multi-tab coordination, and verified enable/disable/rekey conversion before enabling lifecycle operations. +**Required next step:** implement per-store conversion/checkpointing, multi-tab coordination, and verified enable/disable/rekey recovery before enabling lifecycle operations. The journal currently blocks a second migration owner and ordinary protected access while active; it does not yet convert records. The authoritative lifecycle states, transition rules, and journal requirements are in [ADR 0018](adr/0018-idb-encryption-lifecycle-and-recovery.md). diff --git a/docs/ISSUES-332-333-PERFORMANCE-LEDGER.md b/docs/ISSUES-332-333-PERFORMANCE-LEDGER.md new file mode 100644 index 00000000..00b5d8d2 --- /dev/null +++ b/docs/ISSUES-332-333-PERFORMANCE-LEDGER.md @@ -0,0 +1,91 @@ +# Issues #332/#333 performance and desktop reliability ledger + +Status: **active — no performance closure claim**. This ledger records measured +runtime evidence separately from code review and unit tests. It is the +authoritative closure record for the responsiveness portions of +[#332](https://github.com/qnbs/WorldScript-Studio/issues/332) and +[#333](https://github.com/qnbs/WorldScript-Studio/issues/333). + +## Live baseline — 2026-08-12 (updated after the #336 second-wave CodeRabbit loop and the layering-mistake correction) + +| Ref | Live value | +| --- | --- | +| `main` | `804793aa0815a726935785639e4fb139af7c4b59` | +| PR #335 | `edc3ef13c7f87007f290d3a60ee77b119ffeea57` (review threads: 0 unresolved of 40; all CI green) | +| PR #336 | `b01564ed77ae1acf9f9cb8c02ee4767e8909ef15` (review threads: 0 unresolved of 68) | +| PR #337 | `1335e81b` (review threads: 0 unresolved of 61) | +| PR #310 | `27177ce549d4579f1fc9dfbc4630ebf0c2592f9b` | +| Issue #332 / #333 | Open / Open; neither has post-report comments | + +The `.npmrc`/`pnpm-workspace.yaml` uuid override-range hardening (previously deferred as +unsafe for this memory-constrained host to resolve via a real `pnpm install`) has since been +applied: a real `pnpm install --child-concurrency=1` succeeded once host load dropped, tightening +the override to exclude the two unpatched uuid releases (`edc3ef13`, resolved `uuid@14.0.1` +unchanged). That review thread is resolved; no deferred fix remains outstanding on #335. + +`#335` is the lifecycle foundation, `#336` owns desktop, Local-AI, provider, +and Python reliability, and `#337` owns recovery-journal/secondary-store work. +Performance fixes belong to the earliest affected stack layer; this document +does not authorize moving a #336 defect into #337 merely because #337 is checked +out locally. + +## Measurement contract + +For a performance closure, retain a reproducible before/after measurement from +the same build mode and interaction. A browser result is not a substitute for +the packaged Linux Tauri result. The required runtime sequence is: + +```text +reproduce → baseline → root cause → minimal fix → regression test +→ packaged desktop validation → after measurement → closure decision +``` + +The primary target is the installed Linux `.deb` under KDE/Wayland. When that +environment is unavailable, record the closest environment and use +`NOT_REPRODUCED_ENVIRONMENT_LIMITED`; do not infer a fix from Vite responsiveness. + +## Findings + +| ID | User symptom | Environment | Reproduced | Measurement | Leading cause / confidence | Fix / regression coverage | Packaged result | Status | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| PERF-332-001 | Severe Settings-category sluggishness in v1.26.0 `.deb` | Reporter: Ubuntu 26.04, KDE Plasma/Wayland, high-end Ryzen/RTX hardware | Not yet — packaged candidate has not been built/installed in this environment | Pending: open/switch P50/P95, long tasks, React commits, layout/paint, IDB/Tauri/probe counts | Shared renderer/native hot path not yet confirmed | Existing removal/deferment work is code evidence only | Pending `.deb` matrix | `NOT_REPRODUCED_ENVIRONMENT_LIMITED` | +| PERF-332-002 | Appearance preference, notably sepia disable, resets after restart | Packaged desktop and encryption-state matrix required | Not yet end-to-end | Pending durable-write and relaunch measurements | Default/rehydration/encryption/storage-path interaction not yet confirmed | Foundation changed defaults; this is not proof of persistence | Pending terminal and desktop-menu relaunch | `ROOT_CAUSE_CONFIRMED_FIX_PENDING` | +| PERF-333-001 | Local-AI acquisition appears frozen | Local-AI Settings and packaged desktop | Not yet measured on candidate package | Pending first-progress, progress-event gap, terminal state, cancel/retry settlement | Acquisition and inference progress must be distinct; code review identifies stuck-state risks | Existing retry/cancel changes require terminal-path proof | Pending `.deb` | `FIXED_CODE_ONLY_AWAITING_PACKAGED_VERIFICATION` | +| PERF-333-002 | General UI freeze/slowness | Browser, Tauri dev, installed `.deb` | Not yet | Pending long tasks, paint/layout, native invokes, idle CPU | May overlap PERF-332-001; no shared cause claimed before trace | No performance closure yet | Pending | `NOT_REPRODUCED_ENVIRONMENT_LIMITED` | +| PERF-333-003 | Scrolling/panel/text overlap | Required resolution/zoom/locale/RTL matrix | Not yet | Screenshot/visual and overflow inspection pending | Layout root cause unknown | Existing desktop audit is hypothesis-only | Pending | `NOT_REPRODUCED_ENVIRONMENT_LIMITED` | +| PERF-333-004 | Python detection/probing contributes to desktop instability | Tauri desktop, terminal versus menu launch | Code inspection required on #336 | Pending candidate count, per-candidate and total duration | Synchronous probe risk must be confirmed or disproved | Pending `spawn_blocking`/timeout/cache assessment | Pending | `ROOT_CAUSE_CONFIRMED_FIX_PENDING` | +| PERF-333-005 | Duplicate LoRA jobs or cancellation leaves resource load | Tauri desktop | Code inspection required on #336 | Pending concurrent-spawn/termination evidence | Atomic slot and confirmed child termination required | Pending | Pending | `ROOT_CAUSE_CONFIRMED_FIX_PENDING` | +| PERF-333-006 | Local backend diagnostics cause stale/repeated work | Provider Settings, LM Studio/Ollama/vLLM | Unit/code evidence exists; runtime not measured | Pending request count, timeout and stale-result measurements | Requests must be user-triggered, abortable, deduplicated | #336 review reconciliation complete (0 unresolved of 68 threads at `b01564ed`) | Pending `.deb` | `FIXED_CODE_ONLY_AWAITING_PACKAGED_VERIFICATION` | + +## Required packaged desktop matrix + +| Mode | Settings switch | Appearance relaunch | Local AI state | Provider/Python action | Wayland/X11 | Result | +| --- | --- | --- | --- | --- | --- | --- | +| Vite development | Pending | Pending | Pending | Pending | N/A | Pending | +| Production preview | Pending | Pending | Pending | Pending | N/A | Pending | +| Tauri dev | Pending | Pending | Pending | Pending | Pending | Pending | +| Installed `.deb` from terminal | Pending | Pending | Pending | Pending | Pending | Pending | +| Installed `.deb` from desktop menu | Pending | Pending | Pending | Pending | Pending | Pending | + +## Initial budgets — to calibrate after baseline + +- A Settings category interaction acknowledges in under 100 ms and normally + visibly settles within 250 ms. +- A routine switch creates no unrelated network, local-server, Python, WebGPU, + model-discovery, encryption-migration, or large-storage operation. +- No routine application-owned main-thread block exceeds 100 ms. +- Explicit heavyweight actions acknowledge immediately, report truthful progress, + and settle cancel/retry into one terminal state (`ready`, `error`, or + `cancelled`). + +These are acceptance targets, not fabricated current measurements. A packaged +measurement can adjust a target only with recorded rationale; no target may be +relaxed merely to make a regression appear green. + +## Hard closure gates + +Neither performance issue can close until a candidate `.deb` has been built, +installed, launched from both a terminal and the desktop menu, exercised through +the relevant matrix, and compared before/after where the original symptom is +reproduced. CI, Vercel, code review, and browser-only responsiveness remain +necessary but insufficient evidence. diff --git a/docs/PR-310-RECONCILIATION.md b/docs/PR-310-RECONCILIATION.md new file mode 100644 index 00000000..23f049c4 --- /dev/null +++ b/docs/PR-310-RECONCILIATION.md @@ -0,0 +1,179 @@ +# PR #310 reconciliation ledger + +Status: in progress — review-thread reconciliation complete (0/317 +unresolved); commit/behavior/test reconciliation tables and packaged +verification still outstanding. This document is the authoritative +disposition record for +[`#310`](https://github.com/qnbs/WorldScript-Studio/pull/310); it does not make +the pull request merge-ready by itself. + +## Live baseline + +| Field | Value | +| --- | --- | +| Main SHA | `804793aa0815a726935785639e4fb139af7c4b59` | +| PR #310 base / head | `804793aa0815a726935785639e4fb139af7c4b59` / `27177ce549d4579f1fc9dfbc4630ebf0c2592f9b` | +| PR #310 commits / changed files | 9 / 35 | +| Merge state | `MERGEABLE`, but `BLOCKED` | +| Open review threads | 0 of 317 historical threads (all 28 previously-unresolved threads replied to and resolved — see § Review-thread reconciliation queue) | +| Failing check | `DeepSource: JavaScript` (parsing `scripts/resolve-deepsource-threads.mjs`) — still failing on #310's own unchanged head; the replacement branches removed this script entirely rather than fixing it in place (row PR310-B010) | + +## Strategy + +**Option C — supersede #310 through traceable replacement work.** PR #335 is +the fail-closed lifecycle foundation and PR #337 is the recovery/secondary-store +implementation track. #310 must remain open until every row below is implemented, +tested, and mapped to a replacement commit. It must not later be merged on top of +the replacements because that would duplicate migrations and lifecycle controls. + +## Commit reconciliation + +| ID | PR #310 commit | Intent | Disposition | Final location / verification | +| --- | --- | --- | --- | --- | +| PR310-R001 | `13b956e` | Encrypt inference, ProForge, and scene-revision content | ADOPTED_WITH_MODIFICATIONS | #337 protected-store adapters, with journal checkpoints and record-shape decoders | +| PR310-R002 | `a70bba8` | Encrypt cross-project and LoRA metadata | ADOPTED_WITH_MODIFICATIONS | #337 protected-store adapters, with conditional lazy migration and multi-writer protection | +| PR310-R003 | `6b96baa` | Describe boundary and lifecycle | SUPERSEDED_BY_BETTER_IMPLEMENTATION | Final documents are updated only after recovery behavior is executable and tested | +| PR310-R004 | `0898f6a` | Reject incomplete secure envelopes | ADOPTED_WITH_MODIFICATIONS | #337 secure-envelope parser; malformed candidate data must fail closed | +| PR310-R005 | `7139d4c` | Repair IDB test mock wiring | ADOPTED_WITH_MODIFICATIONS | Retain only if the final adapter tests import the same constants; prove with focused tests | +| PR310-R006 | `c991c03` | Add AAD, Blob codec, delete gating, and lifecycle calls | ADOPTED_WITH_MODIFICATIONS | AAD/Blob/delete protections move into the shared policy; unsafe lifecycle calls are superseded | +| PR310-R007 | `3a18c9d` | Reduce codec complexity for DeepSource | NO_LONGER_APPLICABLE | The final codec is structured for correctness; analyzer thresholds will not be raised to hide risk | +| PR310-R008 | `5f3ad25` | Consolidate secondary-store migration | SUPERSEDED_BY_BETTER_IMPLEMENTATION | Replace aggregate helpers with registered adapters driven by durable journal checkpoints | +| PR310-R009 | `27177ce` | Cover migration and missing-store paths | ADOPTED_WITH_MODIFICATIONS | Missing-store coverage preserved; interruption, legacy-shape, resume, and verification cases all added — see PR310-R016 | + +## Behavior reconciliation + +| ID | Behavior | Security/data impact | Disposition | Final implementation / proof | +| --- | --- | --- | --- | --- | +| PR310-B001 | Versioned AES-GCM envelopes for secondary payloads | Confidentiality and integrity | ADOPTED_WITH_MODIFICATIONS | v2 shared envelope API with strict candidate validation; the legacy v1 reader migrates only authenticated, AAD-bound namespaces | +| PR310-B002 | AAD binds store and record id | Detects record swapping | ADOPTED_WITH_MODIFICATIONS | Database/store/record AAD is mandatory for v2; AAD-less ciphertext is recovery-required rather than silently accepted or rewritten | +| PR310-B003 | Blob-preserving structured codec | Prevents ProForge artifact corruption | ADOPTED_WITH_MODIFICATIONS | Preserve Blob/`Uint8Array`/`undefined`; reject non-finite and non-plain structured-clone values rather than flattening them | +| PR310-B004 | Locked secondary reads/writes/deletes fail closed | Prevents plaintext downgrade and destructive mutation while locked | ADOPTED_WITH_MODIFICATIONS | One lifecycle-policy guard for every protected adapter and background writer | +| PR310-B005 | Lazy legacy migration after unlock | Migrates existing plaintext without silent loss | ADOPTED_WITH_MODIFICATIONS | Adapter-specific canonical decoders plus conditional, non-stale rewrites | +| PR310-B006 | Bulk disable conversion | Recoverability | SUPERSEDED_BY_BETTER_IMPLEMENTATION | Journalled, checkpointed decrypt-to-plaintext before verifier retirement | +| PR310-B007 | Bulk passphrase rotation | Recoverability | SUPERSEDED_BY_BETTER_IMPLEMENTATION | Journalled per-store/key-generation rekey; old verifier capability retained until verification | +| PR310-B008 | Direct cross-database aggregate calls | Crash safety | REJECTED_WITH_TECHNICAL_RATIONALE | IndexedDB cannot make these atomic; a saga/journal is mandatory | +| PR310-B009 | DuckDB metadata / large LoRA blob exceptions | Threat-model scope | ADOPTED_WITH_MODIFICATIONS | Retain only precise, tested exceptions; do not claim blanket encryption | +| PR310-B010 | DeepSource configuration threshold increase | Review signal quality | REJECTED_WITH_TECHNICAL_RATIONALE | Do not relax complexity policy to silence the failing JavaScript analyzer | + +## Review-thread reconciliation queue + +**Status: all 28 threads below have been implemented, tested, replied to +(citing the specific replacement file/commit/test), and resolved on GitHub — +confirmed via GraphQL `reviewThreads` showing 0 unresolved of 317 total.** +Each reply verified the disposition against **current** code at reply time, +not just the ledger's prior analysis — e.g. the in-memory cache lock-check +claim was re-confirmed live in `services/ai/aiInferenceCacheService.ts`, and +the DeepSource-resolver-script removal claim was re-confirmed by checking the +script no longer exists. A resolved thread here is a formal GitHub action, +not a claim that PR #310 itself is mergeable — it remains open under Option C +(superseded through traceable replacement work) until every table in this +document is similarly complete and #335/#337 are fully merged. + +| Thread(s) | Concern | Disposition | +| --- | --- | --- | +| `PRRT_kwDOQOeAgc6VqDnU`, `PRRT_kwDOQOeAgc6VqF6o`, `PRRT_kwDOQOeAgc6WAh92`, `PRRT_kwDOQOeAgc6WBA9X`, `PRRT_kwDOQOeAgc6WBA-e` | Disable/rekey can strand ciphertext or create mixed generations | SUPERSEDED_BY_BETTER_IMPLEMENTATION: durable journal and blocked lifecycle API until recovery is complete | +| `PRRT_kwDOQOeAgc6VqDna`, `PRRT_kwDOQOeAgc6VqF6m` | Partial envelopes are accepted as legacy plaintext | ADOPTED_WITH_MODIFICATIONS: strict candidate classifier and corruption tests | +| `PRRT_kwDOQOeAgc6VqF6l` | Deletes bypass locked-state protection | ADOPTED_WITH_MODIFICATIONS: central protected-write policy covers delete operations | +| `PRRT_kwDOQOeAgc6VqF6q` | Swapped valid envelopes are not detected | ADOPTED_WITH_MODIFICATIONS: stable AAD context and swap tests | +| `PRRT_kwDOQOeAgc6VqF6r` | Blob artifacts are serialized as empty objects | ADOPTED_WITH_MODIFICATIONS: binary-safe codec and Blob round-trip tests | +| `PRRT_kwDOQOeAgc6WAXf2` | In-memory inference cache bypasses lock | ADOPTED_WITH_MODIFICATIONS: lock check precedes memory-cache reads and eviction tests | +| `PRRT_kwDOQOeAgc6WAghi`, `PRRT_kwDOQOeAgc6WAghn` | LoRA lazy migration/activation can overwrite concurrent updates | ADOPTED_WITH_MODIFICATIONS: conditional rewrite/transaction ownership checks | +| `PRRT_kwDOQOeAgc6WAh80`, `PRRT_kwDOQOeAgc6WAh87` | Encryption default and recovery documentation are inaccurate | SUPERSEDED_BY_BETTER_IMPLEMENTATION: final docs follow executable policy and recovery UX | +| `PRRT_kwDOQOeAgc6WAh9E` | Cross-project decoded payload/schema is not validated | ADOPTED_WITH_MODIFICATIONS: adapter decoder validates schema before use or rewrite | +| `PRRT_kwDOQOeAgc6WAh9O` | Failed best-effort rewrite hides valid LoRA reads | ADOPTED_WITH_MODIFICATIONS: return decoded data while reporting a safe migration-write failure | +| `PRRT_kwDOQOeAgc6WAh9q` | Scene revision eviction decrypts all content | ADOPTED_WITH_MODIFICATIONS: use plaintext routing metadata for eviction | +| `PRRT_kwDOQOeAgc6WAsgW`, `PRRT_kwDOQOeAgc6WA2oe`, `PRRT_kwDOQOeAgc6WBH2E`, `PRRT_kwDOQOeAgc6WBsgD` | Rotation/disable lose legacy flat record shapes | SUPERSEDED_BY_BETTER_IMPLEMENTATION: canonical per-store decoders are part of journal adapters | +| `PRRT_kwDOQOeAgc6WBA9v` | Required one-line rationale missing | NO_LONGER_APPLICABLE: unsafe call is removed; new non-trivial lifecycle calls include a one-line rationale | +| `PRRT_kwDOQOeAgc6WBA90`, `PRRT_kwDOQOeAgc6WBA95`, `PRRT_kwDOQOeAgc6WBA-a` | Missing stores, malformed cache data, and history migration behavior | ADOPTED_WITH_MODIFICATIONS: final registered adapters use safe open/close, shape validation, and single-transaction writes — see PR310-R016 | +| `PRRT_kwDOQOeAgc6WBA-i` | Codec stringifies unsupported values / corrupts non-finite numbers | ADOPTED_WITH_MODIFICATIONS: explicit undefined node and strict unsupported-value rejection | +| `PRRT_kwDOQOeAgc6WBmtc` | DeepSource parses an ESM maintainer script as CommonJS | SUPERSEDED_BY_BETTER_IMPLEMENTATION: remove the ad-hoc resolver script and fix analyzer-compatible code/config without a threshold waiver | + +## Test reconciliation + +| Original area | Disposition | Replacement evidence required | +| --- | --- | --- | +| Secure envelope and corruption tests | UPDATE | Candidate, version, IV/ciphertext, AAD swap, codec-value, and wrong-key cases | +| Per-store encrypted round trips | RETAIN | One registered adapter fixture per protected store, including binary artifacts | +| Legacy lazy-migration tests | ADOPTED_WITH_MODIFICATIONS | Flat legacy shape plus conditional write race and failure-safe read result — see PR310-R016 | +| Secondary lifecycle happy paths | REPLACE | Journal creation, every checkpoint boundary, interruption/restart, verify, commit, cleanup | +| Optional/missing store test | RETAIN | Missing stores are no-ops that are checkpointed and verified rather than silently skipped | +| Cross-project mock repair | UPDATE | Keep the full constants mock only if final test imports require it | + +## Current implementation checkpoint — recovery and review hardening + +The following changes are uncommitted at the time of this checkpoint and are +part of the replacement architecture, not a reason to merge #310 unchanged. + +| ID | Review finding / invariant | Disposition | Replacement behavior | Regression evidence | +| --- | --- | --- | --- | --- | +| PR310-R010 | A migration runner could begin adapter mutation with a key that did not match the journal target | ADOPTED_WITH_MODIFICATIONS | A journalled enable/rekey stores an authenticated target-key verifier. The runner proves that verifier before claiming a store batch, so a stale or incorrect runtime key cannot convert records. | `storageEncryptionService.test.ts`, `protectedStoreMigration.test.ts` target-verifier case | +| PR310-R011 | Two renderers could run the same journal concurrently | ADOPTED_WITH_MODIFICATIONS | Journal compare-and-set ownership uses a durable owner id plus expiring lease. Only the owner may checkpoint or mutate; failed runs release the lease and an expired owner can be recovered deterministically. | `encryptionMigrationJournal.test.ts`, `protectedStoreMigration.test.ts` concurrent-runner case | +| PR310-R012 | A read/encrypt/write adapter could overwrite a newer ordinary writer | ADOPTED_WITH_MODIFICATIONS | Secondary payload rewrites re-read the complete original record in the same read-write transaction and abort the transaction on any mismatch. The stale migration never wins a write race. | `secondaryPayloadStoreAdapter.test.ts` concurrent-write conflict case | +| PR310-R013 | Snapshot lookup could turn a missing record into `undefined` data | ADOPTED_WITH_MODIFICATIONS | Snapshot reads now reject a typed not-found condition; callers cannot mistake absence for a valid decrypted payload. | `idbSnapshotStore.test.ts` missing-snapshot case | +| PR310-R014 | Scene revision retention decrypted content and could prune unknown future schemas | ADOPTED_WITH_MODIFICATIONS | Retention runs with plaintext routing metadata in one transaction, caps only recognised schema-1/validated legacy records, and preserves unrecognised future-format records. | `sceneRevisionService.test.ts` retention and future-schema cases | +| PR310-R015 | A non-authoritative inference cache persistence failure could discard a usable result while locked/durable persistence changed state | ADOPTED_WITH_MODIFICATIONS | The memory cache remains available after a best-effort durable-cache failure; durable writes remain subject to the central lifecycle guard. | `aiInferenceCacheService.test.ts` durable-write-failure case | +| PR310-R016 | Consolidates PR310-R009's four required test categories (interruption, legacy-shape, resume, verification) plus missing-store coverage — previously tracked under the impermissible interim disposition `REWRITE` | ADOPTED_WITH_MODIFICATIONS | Missing-store/checkpoint: the runner throws a clear error for an unregistered adapter or a checkpoint-less registration instead of silently skipping it. Interruption + resume: a verify() exception mid-phase leaves the journal at `verifying` and a subsequent call resumes from the durable per-store `verified` checkpoint rather than re-running already-verified stores. Verification shortfall: a re-scan finding fewer valid records than were migrated now moves the journal to `recovery-required` instead of retrying an unwinnable check forever. Legacy-shape: plaintext/pre-migration record shapes decode correctly and convert to the current encrypted envelope shape without data loss. | `protectedStoreMigration.test.ts`: `'rejects a missing registered adapter before a migration can mutate storage'`, `'rejects a registered adapter that has no durable checkpoint before mutation'`, `'does not repeat a durably verified store after verification is interrupted'`, `'marks recovery-required instead of looping forever when verification finds fewer valid records than were migrated'`; `secondaryPayloadStoreAdapter.test.ts`: `'converts plaintext through enable, resumable rekey, and verified disable'` | + +### Review findings already disproved by executable guards + +Two CodeAnt reports against the current replacement branch were valid concerns +against older anchors but are not unresolved code defects: public setup, +verification, and initialization paths already call +`assertNoActiveEncryptionMigration`, and the runner explicitly rejects a +`recovery-required` journal before adapter execution. The follow-up review must +verify these guards against the pushed commit before the corresponding threads +are answered or resolved. + +### Local validation boundary + +`protectedStoreMigration.test.ts` passed 11/11 and +`encryptionMigrationJournal.test.ts` passed 9/9 under a single-worker, +low-priority 512 MiB Node ceiling. On this constrained host the crypto-heavy +`storageEncryptionService.test.ts` process twice ended after creating an empty +JUnit file (at 448 MiB and 640 MiB) without a Vitest completion report. That is +recorded as **inconclusive local evidence**, not a pass or failure of the code. +The pushed GitHub Actions quality job is the required proof for that module. + +## Supply-chain evidence + +### SUPPLYCHAIN-PNPM-001 — pnpm v11 build-script policy drift + +| Field | Evidence | +| --- | --- | +| Original config | Legacy `onlyBuiltDependencies` / `ignoredBuiltDependencies` lists plus contradictory `allowBuilds: true` entries | +| Final config | One pnpm v11 `allowBuilds` map: only required native packages (`@swc/core`, `esbuild`) are `true`; every other known build-script package is explicitly `false` | +| Active toolchain | Node `v24.11.1`, pnpm `11.5.2`, declared `pnpm@11.5.2` | +| Reason | pnpm v11 documents `allowBuilds` as the replacement control; legacy lists no longer define the effective policy | +| Scripts newly allowed | None; the policy was narrowed after manifest-level evidence showed only the two native packages require an install hook | +| Scripts newly denied | `@google/genai`, `core-js`, `onnxruntime-node`, `protobufjs`, `sharp`, `simple-git-hooks`, `unrs-resolver`, and `workerd` dependency lifecycle scripts | +| Additional guard | `pnpm-workspace.yaml` is the sole effective pnpm-v11 policy: `verifyDepsBeforeRun: error`, `minimumReleaseAge: 10080`, strict build/integrity controls, and the narrow `allowBuilds` map; root Git-hook setup moved from automatic `prepare` to explicit `hooks:install` | +| Verification | Script-free `pnpm install --lockfile-only --ignore-scripts` exit 0; active pnpm `config get` confirms `verifyDepsBeforeRun=error`, `minimumReleaseAge=10080`, `strictDepBuilds=true`, `blockExoticSubdeps=true`, and `verifyStoreIntegrity=true`; direct Biome, docs, and suppression checks passed | +| Rollback | Revert the policy commit; do not use `approve-builds`, a broad allowlist, or an automatic root `prepare` | + +### SUPPLYCHAIN-PNPM-002 — release-age lockfile reconciliation + +| Field | Evidence | +| --- | --- | +| Trigger | Vercel deployment `dpl_Gyzd9qSWPHFq3En1roQ1BVTF72Gp` failed before build because `ip-address@10.5.0` was published on 2026-08-10, inside the active 10,080-minute release-age window | +| Dependency path | `@lhci/cli` → `proxy-agent` → `socks` → `ip-address`; `pnpm why ip-address --depth Infinity` found no other version or writer | +| Final override | The broad floor `ip-address: ">=10.3.1"` is replaced with exact `ip-address: "10.3.1"`, the first patched version and a release outside the seven-day quarantine | +| Integrity evidence | The public npm registry manifest for `ip-address@10.3.1` reports `sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==`, no runtime dependencies, npm signatures, and SLSA provenance | +| Lockfile scope | Exactly the override, package integrity record, empty snapshot, and `socks` dependency reference changed; `git diff --check` passed | +| Policy source of truth | pnpm 11 normalizes `minimumReleaseAge` out of generated lockfile settings. The effective policy remains `pnpm-workspace.yaml`; `pnpm config get minimumReleaseAge` returns `10080`. The lockfile is not treated as the authority for this setting. | +| Controlled repair boundary | Recovery used only `--lockfile-only`, `--ignore-scripts`, low CPU/I/O priority, and pnpm's documented `--trust-lockfile` repair path. No lifecycle scripts, rebuilds, or broad build approvals were run. | +| Verification still required | A clean Vercel installation and its deployment build must pass on this commit. Full local resolution is deliberately not retried while this low-memory host has about 511 MiB available RAM and 1.8 GiB active swap. | +| Rollback | Revert the workspace override and matching lockfile records together. Do not reduce `minimumReleaseAge`, disable `verifyDepsBeforeRun`, or add a broad build-script allowlist. | + +## Current merge decision + +**NO-GO — REQUIRES FURTHER REMEDIATION, BUT REVIEW-THREAD RECONCILIATION IS +NOW COMPLETE.** PR #310 contains valuable secondary-store work, but its own +lifecycle path (on its own unchanged branch) is non-resumable. All 317 +historical review threads are now resolved (0 unresolved), each replied to +with the specific replacement file/commit/test that addresses the concern — +this closes the "thread reply/resolution" requirement below. It may become +**SUPERSEDED — SAFE TO CLOSE AFTER VERIFIED REPLACEMENT** only when every +ledger row also has a replacement commit, a passing invariant test, and a +final documentation disposition — those are the commit/behavior/test +reconciliation tables above, not the review-thread queue. Do not merge or +close #310 itself based on review-thread resolution alone. diff --git a/docs/adr/0018-idb-encryption-lifecycle-and-recovery.md b/docs/adr/0018-idb-encryption-lifecycle-and-recovery.md index 9dbddf26..46cc7e43 100644 --- a/docs/adr/0018-idb-encryption-lifecycle-and-recovery.md +++ b/docs/adr/0018-idb-encryption-lifecycle-and-recovery.md @@ -1,6 +1,6 @@ # ADR 0018: IndexedDB encryption lifecycle and recovery -**Status:** Accepted for the fail-closed foundation; durable conversion work remains required before disable or rekey is enabled. +**Status:** Accepted. The durable journal and single-owner write gate are implemented; per-record conversion, recovery UX, and lifecycle operations remain unavailable. ## Context @@ -24,7 +24,7 @@ The encryption state is defined by persistent sentinel/journal metadata and runt | `DISABLE_PENDING` | source + journal | source key | normal writers blocked; each target record is verified plaintext before sentinel retirement | resume or recovery-required | | `RECOVERY_REQUIRED` | journal | no assumed key | normal writers blocked; recovery requires the applicable passphrase(s) | resume or explicit support-led recovery | -The currently shipped foundation implements `DISABLED`, `ENABLED_LOCKED`, and `ENABLED_UNLOCKED`. It deliberately rejects disable and rekey requests until the pending states can be made durable. +The currently shipped foundation implements `DISABLED`, `ENABLED_LOCKED`, and `ENABLED_UNLOCKED`. It also persists a versioned journal and rejects a second migration owner before a conversion begins. It deliberately rejects disable and rekey requests until the pending conversion states can be completed and recovered. ## Invariants @@ -35,9 +35,9 @@ The currently shipped foundation implements `DISABLED`, `ENABLED_LOCKED`, and `E 5. No cross-database conversion is described as atomic. IndexedDB transactions are atomic only within one database transaction. 6. Journal metadata never contains a passphrase, raw key, or extractable `CryptoKey`. -## Durable journal requirements +## Durable journal protocol -The follow-up journal is stored as versioned operational metadata, separate from protected content. It must contain an operation ID, schema version, operation (`enable`, `rekey`, or `disable`), phase, source/target key generation identifiers, store checkpoints, record counts, and the last verified checkpoint. It must not contain key material. +The implemented journal is stored as versioned operational metadata, separate from protected content. It contains an operation ID, schema version, operation (`enable`, `rekey`, or `disable`), phase, source/target key generation identifiers, store checkpoints, and an optional target-key verifier. It does not contain key material. Creating a journal uses one state-database read/write transaction, so a concurrent owner is rejected instead of overwriting the active operation. Every migration unit must follow this sequence: @@ -52,9 +52,9 @@ Failure before cleanup leaves the journal and sufficient source/target verificat ## Store scope and writer coordination -The initial journal must cover the policy-covered current stores in both databases: project/settings, snapshots, images, Codex, RAG vectors, and binder assets. It must also record intentionally excluded surfaces and why they are excluded. +The conversion adapters must cover the policy-covered current stores in both databases: project/settings, snapshots, images, Codex, RAG vectors, and binder assets. They must also record intentionally excluded surfaces and why they are excluded. -Before conversion starts, the migration owner must acquire a cross-tab lease. Other tabs, service-worker/outbox writers, and stale clients must stop policy-covered writes or display a reload/recovery requirement. The implementation must test stale lease recovery and reject concurrent migration owners. +Before conversion starts, the journal’s atomic owner gate blocks normal policy-covered reads and writes and rejects a concurrent migration owner. A cross-tab lease/notification path, stale-client reload requirement, and stale lease recovery remain required before lifecycle operations can be enabled. ## Consequences diff --git a/docs/session-handoff/CURRENT-HANDOFF.md b/docs/session-handoff/CURRENT-HANDOFF.md new file mode 100644 index 00000000..fd1aaad1 --- /dev/null +++ b/docs/session-handoff/CURRENT-HANDOFF.md @@ -0,0 +1,221 @@ +# WorldScript Studio — Current Agent Handoff + +## 1. Capture Metadata + +- Captured UTC: approximately `2026-08-12T09:50:00Z`. +- Supersedes `docs/session-handoff/archive/CLAUDE-HANDOFF-20260812T072000Z.md` + (captured right after #335→#336 layering-mistake correction, before #335 + and #336 were actually merged into `main`). Read that document for full + provenance of everything before this segment. +- This segment covers: merging #335 and #336 into `main` (with two + stacked-PR auto-close recoveries), then a large second CodeRabbit review + wave on #337 (now based directly on `main` for the first time) that + surfaced real bugs, doc drift, and — most importantly — confirmed the + entire encryption migration journal system has **zero production + callers**. + +## 2. Executive Summary + +- **`main`** is now at `78c7bb7b` (squash of #336) — previously `804793aa`, + then `c82f3f4a` (squash of #335), then `78c7bb7b`. +- **#335 and #336 are both MERGED and CLOSED.** Only **#337** remains open, + now based directly on `main`. +- **#337 head: `5fed880f`.** Review threads: **73/76 resolved, 3 + deliberately left unresolved** (not false-closed — see § 5). +- **Major discovery this segment:** `beginEncryptionMigration`, + `runProtectedStoreMigration`, and `getRegisteredSecondaryProtectedStoreAdapters` + have **zero production callers anywhere in the codebase** — confirmed via + exhaustive grep; every call site is in `tests/unit/storage/*.test.ts`. The + live "Encrypt project data at rest" toggle only supports **enable** + (`setupIdbEncryption`) and **unlock** (`verifyAndInitIdbEncryption`). + `PassphraseModalMode` is type-restricted to `'set' | 'unlock'` only — + there is no disable or passphrase-rotation UI at all yet. This matches + this project's own documented tech debt (`CLAUDE.md` § Known Technical + Debt, B-1: "Actual IDB read/write integration for stores is Phase 4 + (service-layer only currently)"). **This means the entire TOCTOU/migration + hardening work done across this whole session — while real, well-tested, + and correct in isolation — is not yet reachable by any live user action.** +- CI is running on #337's latest push (`5fed880f`) at capture time — check + before trusting it's green (§ 6). + +## 3. What Happened This Segment (chronological) + +1. Confirmed #336's full CI pipeline (its first time ever running, since it + only got full native CI once retargeted to `main`) was completely green + at `43e4afc6`. CodeAnt showed 0 bugs, CodeRabbit's fresh review found 0 + new findings. +2. Attempted a normal `gh pr merge 336 --squash --delete-branch` — hit the + same `mergeable_state` cache-lag quirk as #335 earlier ("base branch + policy prohibits the merge" despite everything green). Re-polled several + times per the documented procedure; it did not clear. +3. **User explicitly authorized `--admin` for this specific merge.** + Merged #336 into `main` → `78c7bb7b`. +4. **Same stacked-PR auto-close quirk hit #337 again** — this time #337's + base correctly auto-retargeted to `main`, but GitHub still closed the PR + instead of leaving it open. Recovered: `gh pr reopen 337` + (no branch-ref restoration needed this time, since #337's own head + branch was never deleted — only #336's branch was). +5. `git merge origin/main` into #337 hit conflicts (squash-merge produces + new commit ancestry unrelated to #337's real merge history) — resolved + `README.md` (kept #337's higher key count), `AiProviderCard.tsx` (kept + #337's `ProviderConnectionStatus`/`getOllamaAvailability` extraction, + which #336's squash didn't have), `AiProviderCard.test.tsx`. **Caught and + fixed a silent duplicate-declaration bug from git's auto-merge** (two + copies of `probeWebGpu`/`useConnectionContextReset` inserted side by + side) that `git status` didn't flag as conflicted — always re-run + `pnpm run typecheck:single` after a merge, even a "clean" one. +6. CodeRabbit could finally review #337 properly for the first time (base + was never `main` before) — first pass: 4 new findings (1 real duplicate + of the just-fixed aria-busy gap, 2 architectural false positives already + answered on #336, 1 cryptographically-verified false positive on the + rekey fallback's AES-GCM auth-tag safety). All replied to and resolved. +7. Full native CI ran on #337 for the first time — caught a **second** + pre-existing, never-before-run bug (separate from #336's WebGPU + regression): `tests/unit/dbServiceBinder.test.ts`'s hand-rolled fake IDB + store never exposed a `.transaction` back-reference, crashing + `deleteAllBinderAssetsForProject`'s real transaction-batching code + (`Cannot set properties of undefined (setting 'oncomplete')`). Fixed the + test mock to track queued-request completion and fire `oncomplete` + correctly — reproducible locally, fixed, verified stable across 3 runs. +8. Re-triggered CodeRabbit again — a **second, much larger** review wave + landed: 15 new findings spanning docs drift, QNBS-v3 formatting (4 + batches), 2 real functional bugs (`aiInferenceCacheService` read-path + reject-vs-fail-soft contract violation; `sceneRevisionService` + concurrent-open connection leak), 1 real defensive gap + (`secondaryPayloadStoreAdapter` double `transaction.abort()`), 1 + accessibility gap (loading state + `aria-busy`, needing a new i18n key + across all 19 locales), 1 test-quality bug (wrong prop changed in a + rerender test), and **3 deep, security-relevant findings about the + migration journal system** that led to the major discovery in § 2. +9. Fixed and tested 12 of the 15 findings (commit `5fed880f`), added + regression tests for every genuine bug (not just doc/comment fixes), + replied to and resolved all 12. **Deliberately left the 3 + migration-system findings unresolved** with detailed evidence-based + replies explaining why (§ 5) — not fixed, not falsely closed. + +## 4. Live PR State + +| PR | State | Head | Base | Threads (unresolved/total) | +| --- | --- | --- | --- | --- | +| #335 | **MERGED** | `edc3ef13` → squashed as `c82f3f4a` | `main` | n/a (closed) | +| #336 | **MERGED** | `43e4afc6` → squashed as `78c7bb7b` | `main` | n/a (closed) | +| #337 | **OPEN** | `5fed880f` | `main` | 3/76 | +| #310 | OPEN (parked) | `27177ce5` | `main` | 0/317 (review-thread queue fully reconciled earlier this session; commit/behavior/test tables still incomplete) | + +## 5. The 3 Deliberately Unresolved Threads (real, deferred, not fixed) + +All three are independent code defects in the migration engine. They share +one **reachability constraint** — the migration journal system has no +production trigger today (§ 2) — but that absence is not their root cause +and fixing it (wiring the system up) would not, by itself, fix any of them. +Phase 4 must address both: build the missing disable/rotate-passphrase flow +*and* fix these three defects before that flow can safely use the migration +engine. + +1. **`services/storage/idbAssetStore.ts` (cross-tab write admission).** + `assertNoActiveEncryptionMigration()` is preflight-only, not atomic with + the write. A real TOCTOU: a writer could pass the check, migration could + claim ownership and commit, and the writer could then persist under the + stale key generation. Applies to `saveImage`, `saveBinderAsset`, + `deleteAllBinderAssetsForProject`, `saveStoryCodex`, `saveRagVectors`, + `saveSlice`, `createSnapshot`. Fixing it needs a new shared cross-tab + admission/locking protocol — a genuine design task, not a patch. +2. **`services/storage/protectedStoreMigration.ts` (verification vs. + concurrent deletion).** `verify()` compares current record count against + `checkpoint.processed`, but `aiInferenceCacheService`'s eviction and + `sceneRevisionService`'s retention can delete records independently of + migration state — causing a false `ProtectedStoreVerificationShortfallError` + → `recovery-required`. Needs either migration-aware blocking of those two + mutation paths, or verification against the surviving record set. +3. **`services/storage/secondaryProtectedStoreAdapters.ts` (never wired + in).** The registered secondary adapters (scene revisions, inference + cache) are never passed to `beginEncryptionMigration`/ + `runProtectedStoreMigration` by any production code path. + +**None of these are reachable today** — confirmed via exhaustive grep (only +test files call the migration entry points) and via the live UI's actual +capabilities (`PassphraseModalMode = 'set' | 'unlock'` only; +`PrivacySection.tsx` explicitly disables the "Encrypt project data at rest" +toggle once it's on, with the comment "Locked is still encrypted; showing it +as off invited an unsafe disable path"). **This is Phase 4 work** per +`CLAUDE.md`'s own tech-debt tracking, not a gap introduced this session. + +**Implication for the standing NO-GO conditions:** the "migration/session +race" NO-GO condition tracked all session is **not currently live-exploitable** +(no production trigger exists), but the underlying engineering work to make +it safe **when** Phase 4 wiring happens is still incomplete. Do not treat +"not currently reachable" as "resolved" — it isn't. Building the disable/ +rotate-passphrase UI is itself a prerequisite piece of work that doesn't +exist yet either. + +## 6. How To Check Whether #337's Latest CI Landed Green + +```bash +gh pr checks 337 +gh api graphql -f query='query { repository(owner: "qnbs", name: "WorldScript-Studio") { pullRequest(number: 337) { reviewThreads(first: 100) { totalCount nodes { isResolved } } } } }' --jq '{total: .data.repository.pullRequest.reviewThreads.totalCount, unresolved: [.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved==false)] | length}' +``` + +Expect `3` unresolved (the deliberately-deferred findings in § 5) — if the +count is higher, a fresh review wave landed; fetch, classify, fix/defer, +reply, and re-check per the established loop-until-quiescent method (see the +archived prior handoffs' § 13 for the full method). If CodeRabbit shows +"rate limited," check its actual review history +(`gh api repos/qnbs/WorldScript-Studio/pulls/337/reviews --paginate`) before +assuming nothing happened — this session hit that exact false signal twice +on #336 and had to wait it out. + +## 7. Exact Next Actions + +1. **Confirm #337's CI is green on `5fed880f`** — was still running at + capture time (Quality Gate Node 22/24, CodeQL, semgrep, CodeRabbit all + pending). +2. **If CI is green and no new review wave landed:** the stack's review/CI + dimension is satisfied for #337. The standing NO-GO conditions + (§ 5's migration-system gap, #310's incomplete commit/behavior/test + tables, #332/#333 packaged evidence) still block a full "ready to merge" + declaration — #337 merging into `main` is itself fine from a + code-correctness standpoint (nothing it does depends on the unreached + migration system), but do not claim the encryption-recovery-journal + *feature* is production-ready or fully delivered until Phase 4 (disable/ + rotate UI + the 3 deferred fixes) lands. +3. **When resuming Phase 4 work (disable/rotate-passphrase UI):** read § 5 + first — the 3 deferred findings must be designed and fixed as part of + that same effort, not bolted on separately before or after. +4. **Continue #310's remaining ledger work** (commit/behavior/test tables, + not the review-thread queue which is done) — untouched this segment. +5. **#332/#333 packaged desktop evidence** — still explicitly deferred, no + `.deb` work happened this segment. + +## 8. Files / Symbols To Read First + +1. This file, then `docs/session-handoff/archive/CLAUDE-HANDOFF-20260812T072000Z.md` + for full provenance of the layering-mistake corrections and earlier + wave-3/wave-4 fixes. +2. `services/storage/secondaryProtectedStoreAdapters.ts`, + `services/storage/protectedStoreMigration.ts`, + `services/storage/idbAssetStore.ts` — the 3 deferred findings (§ 5). +3. `components/settings/PrivacySection.tsx`, `components/settings/PassphraseModal.tsx` + — confirms the current disable/rotate UI gap firsthand. +4. `services/ai/aiInferenceCacheService.ts`, `services/sceneRevisionService.ts`, + `services/storage/secondaryPayloadStoreAdapter.ts` — this segment's 3 + genuine bug fixes (all with regression tests). + +## 9. Commands To Avoid (unchanged from prior handoffs) + +- `pnpm install` without first checking current host load/free memory. +- `cargo check` / `cargo build` on this host. +- Hand-editing `pnpm-workspace.yaml`/`pnpm-lock.yaml` without a follow-up + real `pnpm install`. +- Full local coverage/E2E/mutation/Lighthouse/Storybook/Tauri build — cloud + CI only. +- Resolving a review thread because its anchor moved, without re-verifying + against current code. +- `--admin` merges without the user's fresh, explicit authorization for + that specific merge (both #335 and #336 required asking again — the + authorization does not carry over automatically). +- **Committing on a stacked/merged branch without `git branch --show-current` + first.** This session hit the layering mistake 3 times total across its + full duration. Now largely moot since only #337 remains open, but stay + disciplined if any new stacked work starts. +- Treating "not currently reachable in production" as equivalent to + "resolved" for the § 5 migration-system findings — it isn't. diff --git a/docs/session-handoff/archive/CLAUDE-HANDOFF-20260811T111445Z.md b/docs/session-handoff/archive/CLAUDE-HANDOFF-20260811T111445Z.md new file mode 100644 index 00000000..8af997e4 --- /dev/null +++ b/docs/session-handoff/archive/CLAUDE-HANDOFF-20260811T111445Z.md @@ -0,0 +1,308 @@ +# WorldScript Studio — Current Agent Handoff + +## 1. Capture Metadata + +- Captured UTC: `2026-08-11T11:14:45Z`. +- Mode: emergency state freeze; no new implementation, install, rebase, reset, + review trigger, or heavy validation began after this boundary. +- Evidence labels: **LIVE FACT** = command/API evidence at capture; **HISTORICAL + FACT** = retained provenance; **UNVERIFIED** = no closure claim. + +## 2. Executive Summary + +The clean, pushed checkout is `feat/encryption-recovery-journal` at +`fefd9efc87f40c323c9b998014c57ae3a68dcf87`. The active stack remains #335 +(foundation) → #336 (desktop/AI) → #337 (recovery); `main` is +`804793aa0815a726935785639e4fb139af7c4b59`. + +Recent code establishes a fail-closed lifecycle/recovery direction, durable +journal work, and bounded Python/LoRA handling. This is focused code/test +evidence, not release closure. Legacy PR #310 remains open and must neither be +merged nor closed as superseded yet. + +Live blockers: #335 quality fails because four README i18n counts say `2869` +instead of `2876`; CodeAnt reports 3 bugs on #335 and 16 on #337. #336's +external checks pass, but the Tauri bundle job remains in progress against +`88016dde`, an ancestor of its final merge SHA. #332/#333 remain open and no +packaged `.deb` performance/persistence evidence exists. + +Host state is severely constrained: 442 MiB free RAM, 1.4 GiB swap used, two +CPUs at load 3.50/3.85/4.04, and 6.6 GiB disk free. Use cloud CI for heavy work. + +## 3. Exact Live Git State + +| Field | Value | Evidence | +| --- | --- | --- | +| Branch | `feat/encryption-recovery-journal` | LIVE FACT | +| Head | `fefd9efc87f40c323c9b998014c57ae3a68dcf87` | LIVE FACT | +| Upstream | `origin/feat/encryption-recovery-journal` | LIVE FACT | +| Tree | Clean; no staged, unstaged, untracked, or stash entries | LIVE FACT | +| Unpushed commits | None; head equals upstream | LIVE FACT | +| Origin | `https://github.com/qnbs/WorldScript-Studio.git` | LIVE FACT | +| Default branch | `main @ 804793aa0815a726935785639e4fb139af7c4b59` | LIVE FACT | + +`git fetch --prune` removed local tracking aliases `origin/pr-310` and +`origin/pr-311`; GitHub confirms PRs #310/#311 are still open. That was only a +tracking-ref cleanup. + +## 4. Live PR Stack / Branch Topology + +| PR | Responsibility | Head → base | State / size | Review-thread total | Merge state | +| --- | --- | --- | --- | --- | --- | +| #335 | encryption/settings/pnpm foundation | `fa3cd983` → `main@804793aa` | Open; 88 files, +860/-648, 3 commits | 33 | `BLOCKED` | +| #336 | Local AI/provider/Python/LoRA desktop reliability | `fd7ed7c1` → `#335@fa3cd983` | Open; 36 files, +1297/-148, 4 commits | 43 | `CLEAN` | +| #337 | recovery journal, adapters, #310 replacement | `fefd9efc` → `#336@fd7ed7c1` | Open; 73 files, +4042/-228, 18 commits | 54 | `UNSTABLE` | +| #310 | legacy secondary-store encryption | `27177ce5` → `main@804793aa` | Open; 35 files, +2958/-390, 9 commits | 317 | `BLOCKED` | + +Keep fixes at the earliest affected layer. Other open Dependabot PRs (#312–334) +and #311 are outside this remediation stack. + +## 5. Commits Since Previous Checkpoint + +No prior `docs/session-handoff/` file existed. The recent checkpoint is: + +| SHA | Message | Intent / validation | +| --- | --- | --- | +| `fefd9efc` | `docs: add desktop performance evidence ledger` | docs only; see stale-ref note in section 12 | +| `dda48b33` | `chore: merge desktop reliability foundation` | merges #336 into #337 | +| `fd7ed7c1` | `chore: merge encryption lifecycle foundation` | merges #335 into #336 | +| `fa3cd983` | `chore(deps): align pnpm v11 security policy` | normal pre-commit passed; cloud docs gate red | +| `88016dde` | `fix(tauri): bound Python probes and LoRA process lifecycle` | rustfmt pass; cloud Tauri build in progress | +| `997b2f6d` | `fix(storage): harden migration recovery protocol` | focused migration tests pass before merge | +| `c4b64f83` | `fix(deps): reconcile release-age lockfile` | earlier Vercel pass on its own SHA | +| `58a3a82c` | `feat(storage): add resumable secondary store adapters` | later journal work hardens/supersedes its lifecycle | + +Latest implementation commit is `88016dde`; latest documentation commit is +`fefd9efc`; latest journal implementation ancestor is `997b2f6d`. + +## 6. Completed Work + +- #337: target-key verifier, owner lease/checkpoints, adapter conflict checks, + typed missing snapshots, safe scene retention, and best-effort cache writes. +- Focused local storage evidence: protected-store migration 11/11 PASS and + journal tests 9/9 PASS on an ancestor of the current head. +- #336: bounded Python candidates, blocking work moved from async paths, + explicit training states, duplicate job prevention, termination confirmation. +- pnpm v11 policy is explicit; a frozen script-free install synchronized local + metadata and normal `lint-staged` pre-commit later passed. +- `docs/ISSUES-332-333-PERFORMANCE-LEDGER.md` now prevents code-only closure. + +## 7. Work In Progress + +1. #335 README docs gate and current review correction loop. +2. #336 native Tauri evidence plus current review normalization. +3. #337 CodeAnt/review correction, failure-injection proof, and #310 mapping. +4. #332/#333 packaged desktop/performance/persistence validation. + +## 8. Current Blockers + +| Priority | Blocker | Evidence | Resolution | +| --- | --- | --- | --- | +| P0 | #335 cloud quality red | Run `31485190552` | Update four README counts 2869 → 2876, push, get green quality/build | +| P0 | CodeAnt gates red | #335: 3 bugs; #337: 16 bugs | Current-head thread fetch, fix/test/reply/resolve, fresh quiescent review wave | +| P0 | #310 not terminally reconciled | Open; 317 threads; ledger issue R009 | Finish compliant behavior/test/review mapping before merge/closure decision | +| P1 | Native Rust evidence incomplete | Run `31484800148` in progress on `88016dde` | Monitor; fix/re-dispatch on final #336 SHA if necessary | +| P1 | #332/#333 unmeasured in packaged app | Ledger matrix pending | Candidate `.deb` performance and relaunch matrix | + +## 9. Review Finding Reconciliation + +Counts in section 4 are live **total** thread counts, not a claim that every +thread was current-head normalized during this handoff. + +| PR | Live review/check state | Handoff classification | Required action | +| --- | --- | --- | --- | +| #335 | CodeAnt Quality/SCR FAIL; CodeRabbit pending | `VALIDITY_UNNORMALIZED` | Fetch and classify 33 threads; fix at #335; rerun bot to zero actionable/zero unresolved | +| #336 | CodeAnt Quality PASS; SCR rating B/12 bugs; CodeRabbit skipped because base disables review | `VALIDITY_UNNORMALIZED` | Inspect CodeAnt comments and 43 threads; skipped is not reviewed-pass | +| #337 | CodeAnt Quality/SCR FAIL; CodeRabbit/Sourcery skipped | `VALIDITY_UNNORMALIZED` | Normalize 54 threads and 16 bugs after `fefd9efc`; fix/reply/resolve then fresh wave | +| #310 | DeepSource JS FAIL; 317 threads | `VALIDITY_UNNORMALIZED` | Reconcile every material legacy concern before disposition | + +Check first: IDB read rejection, target verifier/adapter races, snapshot absence, +scene retention, cache failure, Local-AI busy state, stale provider requests, +Python probing, and LoRA termination. Never resolve only because an anchor moved. + +## 10. PR #310 Reconciliation + +- Ledger: `docs/PR-310-RECONCILIATION.md`. +- Strategy: Option C — replace through #335 + #337 while preserving all material + behavior and useful test intent. +- Live PR: #310 at `27177ce549d4579f1fc9dfbc4630ebf0c2592f9b`; still open. +- Current ledger decision: **NO-GO — REQUIRES FURTHER REMEDIATION**. +- Rows R001–R015 exist. Fourteen use allowed dispositions; R009 says `REWRITE`, + which is not an allowed final category and must be normalized with a concrete + replacement test mapping. + +Do not merge #310 over #337. Closure as superseded requires all material rows +to use permitted dispositions plus recovery, store inventory, interruption, +export/import, stale-client/multi-tab, review, and test evidence. + +## 11. #332 / #333 Status + +| Workstream | State | Evidence | Closure classification | +| --- | --- | --- | --- | +| #332 `.deb` sluggish Settings | Open | No package/profile | `NOT_REPRODUCED_ENVIRONMENT_LIMITED` | +| #332 sepia persistence | Open | Default change only; no relaunch matrix | `ROOT_CAUSE_CONFIRMED_FIX_PENDING` | +| #333 Local-AI acquisition | Open | Code direction only; no terminal runtime proof | `FIXED_CODE_ONLY_AWAITING_PACKAGED_VERIFICATION` | +| #333 UI freezes/overlap | Open | No trace/zoom/RTL/package matrix | `NOT_REPRODUCED_ENVIRONMENT_LIMITED` | +| #333 Gemini/LM Studio | Open | #336 code hardening; packaged diagnostic unverified | `FIXED_CODE_ONLY_AWAITING_PACKAGED_VERIFICATION` | +| #333 Python/LoRA | Open | `88016dde`; native build in progress | `ROOT_CAUSE_CONFIRMED_FIX_PENDING` | + +Neither issue is eligible for closure. + +## 12. Performance / Responsiveness Status + +- Ledger: `docs/ISSUES-332-333-PERFORMANCE-LEDGER.md`. +- Amendment integration: `PARTIAL`; contract/ledger present, no runtime capture. +- Settings P50/P95, long tasks, React/layout/paint/invoke counts, package startup + and memory metrics are all pending. +- No `.deb` build/install/terminal launch/menu launch/Wayland/X11 test occurred. +- The ledger's #337 line says `dda48b33` "local merge pending push"; live is + `fefd9efc`. Correct the ledger before relying on it for a new run. + +**PERFORMANCE CLOSURE NOT YET VERIFIED.** Browser/Vercel/unit success cannot +close the packaged Tauri reports. + +## 13. pnpm / Supply-Chain / Vercel Status + +| Item | Value | +| --- | --- | +| Declared / active pnpm | `11.5.2` / `11.5.2` | +| Node | `v24.11.1` | +| `minimumReleaseAge` | `10080` minutes | +| `verifyDepsBeforeRun` | `error` | +| `strictDepBuilds` / `blockExoticSubdeps` | `true` / `true` | +| Dependency scripts in reconciliation install | Not run (`--ignore-scripts`) | + +The release-age incident was resolved by exact `ip-address@10.3.1`, not policy +weakening or broad script approval. Do not reinstall unless the lock graph +changes. If metadata rejects a normal hook, use one bounded +`CI=true pnpm install --frozen-lockfile --ignore-scripts`, inspect diff, then +stop; do not use `approve-builds`, `rebuild`, or broad allowlists. + +Vercel is pass for #335/#336/#337 current deployment contexts. It proves deploy +build only, not package/performance/full-CI closure. + +## 14. Local Resource Constraints + +| Metric | Capture value | +| --- | --- | +| RAM | 3.7 GiB total; 442 MiB free; 1.3 GiB available | +| Swap | 3.9 GiB total; 1.4 GiB used | +| CPUs/load | 2; 3.50/3.85/4.04 | +| Disk | 6.6 GiB free; 93% used | +| Expensive processes | None besides current Codex sandbox | +| Class | `SEVERELY_CONSTRAINED` | + +Use single-command local diagnostics/focused tests only; use cloud for clean +install, coverage, E2E, packaged builds, performance, and large matrices. + +## 15. Test / CI / Deployment Evidence + +| Check | SHA/scope | Place | Result | Note | +| --- | --- | --- | --- | --- | +| protected-store migration test | `997b2f6d` ancestor | Local | PASS 11/11 | focused | +| journal test | `997b2f6d` ancestor | Local | PASS 9/9 | focused | +| crypto-heavy storage batch | pre-final merge | Local | INCONCLUSIVE | two empty JUnit/no completion runs | +| LoRA rerun | after `88016dde` | Local | INCONCLUSIVE | mock fixed; rerun resource-inconclusive | +| `rustfmt` on `lora.rs` | `88016dde` | Local | PASS | formatting only | +| `cargo fmt --check` | `88016dde` | Local | FAIL pre-existing | unrelated drift; no broad rewrite | +| #335 Actions `31485190552` | `fa3cd983` | Cloud | FAIL | four README doc metrics; downstream skipped | +| #335 CodeAnt | `fa3cd983` | Cloud | FAIL | 3 bugs/rating C | +| #336 CodeAnt Q/SAST/SCA | `fd7ed7c1` | Cloud | PASS | SCR B/12 must be inspected | +| #336 CodeRabbit | `fd7ed7c1` | Cloud | SKIPPED | base disables review | +| Tauri run `31484800148` | `88016dde` | Cloud | IN PROGRESS | Ubuntu/macOS/Windows in build stage | +| #337 CodeAnt Q/SCR | `fefd9efc` | Cloud | FAIL | 16 bugs/rating C | +| #337 SAST/SCA/GitGuardian/Semgrep/Vercel | `fefd9efc` | Cloud | PASS | security/deploy only | +| #310 historical CI | `27177ce` | Cloud | MIXED | DeepSource JS fails; no merge proof | + +## 16. Known Failed Approaches / Do Not Repeat + +| Approach | Outcome / required change | +| --- | --- | +| Repeated broad pnpm resolution | Unsafe on this host; retry only after graph change, frozen and script-free | +| Full local coverage/E2E/mutation/Lighthouse/Tauri build | CI-only on this hardware | +| Crypto-heavy storage or LoRA test batch | Resource-inconclusive; use cloud or one isolated test when host recovers | +| Vercel/browser success as desktop proof | Invalid; use installed package matrix | +| Resolving stale review anchors | Prohibited; current-head validation first | + +## 17. Uncommitted / Unpushed State + +At capture start the tree was clean and all implementation work pushed. This +handoff and its archive are the only subsequent local changes until committed. +No stash, reset, clean, rebase, or force push occurred. + +## 18. Exact Next Actions + +1. **P0-1/#335:** update README lines 15, 400, 509, 711 from 2869 to 2876; + run `pnpm run docs:check` only if healthy; commit/push; require green Node + quality and downstream build on the new SHA. +2. **P0-2/#335:** fetch/classify all 33 threads and CodeAnt bugs against current + head; fix at #335, test, reply/resolve, then one fresh CodeAnt wave to zero. +3. **P0-3/#336:** monitor Tauri run `31484800148`; inspect/fix if failed; if + passed decide whether final `fd7ed7c1` needs a new native dispatch; normalize + 43 threads and CodeAnt SCR comments. +4. **P0-4/#337/#310:** normalize 54 #337 threads/16 CodeAnt bugs; fix at #337; + convert R009 to allowed disposition; complete store/recovery mapping before + any #310 merge/closure decision. +5. **P1-1/#332:** after CI-safe candidate, run installed `.deb` terminal/menu + performance and appearance relaunch matrix; record before/after measurements. +6. **P1-2/#333:** validate Local-AI progress/cancel/retry/busy terminality, LM + Studio/Python menu-vs-terminal, LoRA cancellation, and layout matrix in app. + +## 19. Merge / Release NO-GO Conditions + +No merge/release if protected writes downgrade, lifecycle/recovery is not +resumable, #310 mapping/reviews remain incomplete, #335/#337 quality is red, +native Rust is unbuilt, or #332/#333 lack packaged persistence/performance +evidence. Also block if Local-AI remains busy after terminal operations, LoRA +can survive cancel, or no package before/after evidence exists for a reproduced +slowdown. + +## 20. Files / Symbols To Read First + +1. `docs/session-handoff/CURRENT-HANDOFF.md` +2. `docs/PR-310-RECONCILIATION.md` +3. `docs/ISSUES-332-333-PERFORMANCE-LEDGER.md` +4. `services/storage/storageEncryptionService.ts` and journal/adapter modules +5. `src-tauri/src/lora.rs` and `services/lora/loraTrainingService.ts` +6. `components/settings/LocalAiDownloadProgress.tsx`, `services/localAiFacade.ts`, + `services/ai/inferenceProgressEmitter.ts` +7. settings slice/view/listener persistence paths +8. `pnpm-workspace.yaml`, `pnpm-lock.yaml`, `.npmrc`, `README.md` + +## 21. Safe First Commands For Next Agent + +Run only diagnostic commands first: `git status --short`; `git status --branch +--short`; `git branch --show-current`; `git rev-parse HEAD`; `git log -n 15 +--oneline --decorate`; `git diff --stat`; `free -h`; `gh pr checks 335`; `gh pr +checks 336`; `gh pr checks 337`; `gh run view 31484800148`. + +Then fetch review threads through the approved GitHub review workflow and inspect +cloud failure logs before reproducing locally. + +## 22. Commands To Avoid Initially + +- Any `pnpm install` unless lock graph changed. +- Full local coverage/E2E/mutation/Lighthouse/Storybook/Tauri builds. +- Concurrent/background shells, broad script approval, `pnpm rebuild`. +- Reset/clean/force-push/rebase/retarget. +- A new review wave before current CodeAnt findings are corrected and understood. + +## 23. Open Questions / Uncertainty + +1. Exact current CodeAnt bug bodies for #335/#337 require comment fetch. +2. Does the in-progress multi-platform Tauri workflow succeed, and must final + #336 be redispatched? +3. Does every #310 store recover across interruption/quota/stale-client/ + export/import/multi-tab cases? Evidence remains incomplete. +4. Is #332 shared renderer/native work, WebView/Wayland, layout, or persistence? +5. Why does sepia reset in the reported package? No end-to-end reproduction. +6. Does #333 overlap reproduce across supported scaling/locales? No matrix yet. + +## 24. Handoff Integrity Checklist + +- [x] Local state captured before edits; no state discarded. +- [x] Live main, stack PRs, #310, #332 and #333 queried. +- [x] SHA-bound CI/Tauri/pnpm/resource evidence captured. +- [x] Performance non-closure and #310 non-final state explicit. +- [x] Exact next queue, safe commands, and no-go conditions provided. +- [ ] Final handoff commit/push recorded after this document is committed. diff --git a/docs/session-handoff/archive/CLAUDE-HANDOFF-20260812T005000Z.md b/docs/session-handoff/archive/CLAUDE-HANDOFF-20260812T005000Z.md new file mode 100644 index 00000000..97b09e74 --- /dev/null +++ b/docs/session-handoff/archive/CLAUDE-HANDOFF-20260812T005000Z.md @@ -0,0 +1,560 @@ +# WorldScript Studio — Current Agent Handoff + +## 1. Capture Metadata + +- Captured UTC: `2026-08-12T00:50:00Z`. +- Mode: live working state — this session pushed commits up to the moment of + capture; a CI/review poll for the final SHAs below was still in flight when + this document was written (see § 15 for how to check its result). +- Evidence labels: **LIVE FACT** = command/API evidence at capture; **HISTORICAL + FACT** = retained provenance; **UNVERIFIED** = no closure claim. +- This handoff supersedes its own earlier revision from `2026-08-11T23:46:00Z` + in the same session (that revision is not separately archived — this file + simply advanced twice in one session as more work landed). It also + supersedes the `2026-08-11T11:14:45Z` capture, archived at + `docs/session-handoff/archive/CLAUDE-HANDOFF-20260811T111445Z.md`. + +## 2. Executive Summary + +The active stack is #335 (foundation) → #336 (desktop/AI) → #337 (recovery); +`main` is `804793aa0815a726935785639e4fb139af7c4b59` (unchanged all session). + +**Current heads:** #335 `0353364d`, #336 `c3f00cff`, #337 `0b1cc2ed`. + +Review-thread quiescence as of this capture: #335 has exactly **one** +deliberately-left-open thread (a supply-chain hardening suggestion needing a +`pnpm install` this host cannot safely run — § 8); #336 and #337 both show +**0 unresolved**. A background poll for a possible fresh review wave on the +just-pushed #336/#337 SHAs was still running when this document was written — +**check § 15 before trusting "0 unresolved" as final.** + +This session found and fixed **sixteen** genuine, non-cosmetic defects across +the stack — not review-comment busywork. Three were on #335 (two could strand +a user with configured at-rest encryption unable to unlock at all, one broke +an accessibility E2E test via a silently regressed theme default); the +remaining thirteen were spread across two review waves on #336 (race +conditions, protocol-routing bugs, a Rust cache-staleness bug, and a +save-time validation gap) — see § 6 for the complete list with evidence. + +Legacy PR #310 remains open and must neither be merged nor closed as +superseded yet — its ledger reconciliation advanced (PR310-R009's disposition +fixed) but is far from complete. #332/#333 remain open with no packaged +`.deb` evidence — explicitly deferred again this session; this host cannot +safely produce that evidence. + +**Standing merge authorization**: the user has authorized merging the +#335→#336→#337 stack into `main` once every PR reaches review-thread +quiescence and CI is green, without asking again, provided none of the NO-GO +conditions in § 19 are triggered. As of this capture, that bar is **close but +not confirmed** — #335's CI was fully green on `0353364d` as of the last +check (§ 15 has the evidence), but #336 and #337 have NOT yet had their CI +re-verified on their newest SHAs (`c3f00cff` / `0b1cc2ed`), and the +fresh-review-wave poll for those two SHAs had not concluded at capture time. +**Do not merge until you've confirmed all three are green and the poll found +no new unresolved threads (§ 15).** + +## 3. Exact Live Git State + +| Field | Value | Evidence | +| --- | --- | --- | +| Branch | `feat/encryption-recovery-journal` | LIVE FACT | +| Head | `0b1cc2ed` (docs commit for this handoff will land on top) | LIVE FACT | +| Upstream | `origin/feat/encryption-recovery-journal`, head == upstream as of `0b1cc2ed` | LIVE FACT | +| Tree | Clean before this handoff commit; no stash entries | LIVE FACT | +| Origin | `https://github.com/qnbs/WorldScript-Studio.git` | LIVE FACT | +| Default branch | `main @ 804793aa0815a726935785639e4fb139af7c4b59` | LIVE FACT | + +Local branches `fix/encryption-lifecycle-safety` (#335, head `0353364d`) and +`fix/desktop-reliability-hardening` (#336, head `c3f00cff`) both exist and +match their respective `origin/*` remotes exactly — no unpushed local work on +any of the three stack branches as of capture. + +## 4. Live PR Stack / Branch Topology + +| PR | Responsibility | Head → base | Review threads (unresolved/total) | Notes | +| --- | --- | --- | --- | --- | +| #335 | encryption/settings/pnpm foundation | `0353364d` → `main@804793aa` | 1/41 (the deliberately-open uuid thread) | CI confirmed fully green on this SHA (§ 15) | +| #336 | Local AI/provider/Python/LoRA desktop reliability | `c3f00cff` → `#335@0353364d` | 0/56 as of last check | CI/fresh-wave check on this SHA in flight (§ 15) | +| #337 | recovery journal, adapters, #310 replacement | `0b1cc2ed` → `#336@c3f00cff` | 0/57 as of last check | CI/fresh-wave check on this SHA in flight (§ 15) | +| #310 | legacy secondary-store encryption | `27177ce5` → `main@804793aa` | 28/317 (not touched this session) | Stays open — do not merge/close | + +Other open Dependabot PRs (#312–334) and #311 are outside this remediation +stack — no action needed there. + +## 5. Commits This Session (newest first per branch; only this session's work) + +**#337 (`feat/encryption-recovery-journal`):** + +| SHA | Message | +| --- | --- | +| (this handoff commit) | `docs: refresh session handoff (second pass)` | +| `0b1cc2ed` | merge `fix/desktop-reliability-hardening` — brings #336's 7-fix wave in | +| `74ce8fd3` | `docs: refresh stack SHAs after the appearancePreset/unlock-routing fix cascade` | +| `dd92628f` | merge `fix/desktop-reliability-hardening` — brings #336's 3-fix wave + #335's cascade in | +| `58d95d1e` | `docs: refresh session handoff, archive the prior capture` | +| `ed46befd` | `docs: refresh stack SHAs in the performance ledger; reconcile PR310-R009 to a valid disposition` | +| `68050d80` | `docs(test): clarify encryptionMigrationJournal.test.ts's ownership-CAS comment` | +| `3f31c1e1` | `fix(ollama): report invalidResponse instead of a false-positive connection success` | +| `beadfa22` | `fix(ai): degrade cache reads to a miss on lock/migration, re-encrypt legacy entries on read` | +| `411943a8` | `fix(storage): fail migration to recovery-required on a verification shortfall` | +| `46198b26` | `fix(storage): skip corrupt scene revisions instead of hiding history; narrow write guard to migration-only` | +| `dc0b5262` | `fix(settings): stop rendering the connection test result twice in AiProviderCard` | + +**#336 (`fix/desktop-reliability-hardening`):** + +| SHA | Message | +| --- | --- | +| `c3f00cff` | `fix(settings): auto-validate a newly saved Gemini key instead of deferring to the next generation call` | +| `ebafce7a` | `fix(desktop): revalidate the cached Python interpreter before trusting it` | +| `d536649d` | `fix(settings,ai): decouple URL edits from protocol preset; guard preload progress against superseded attempts` | +| `be11482c` | `fix(lora,settings): stale-run training race, stuck onboarding on cancel, stale model list on context switch` | +| `ad4364ac` | merge `fix/encryption-lifecycle-safety` — brings #335's cascade in | + +**#335 (`fix/encryption-lifecycle-safety`):** + +| SHA | Message | +| --- | --- | +| `0353364d` | `fix(settings): restore sepia as the first-run appearance default` | +| `99c28392` | `fix(storage): route locked encrypted startup and Lock Session to the unlock modal, not a dead end` (cherry-picked from a mis-layered original commit `667f6f37`, see § 16) | + +Earlier in this session (before the portion of work summarized above), #335 +and #336 each independently reached review-thread quiescence with CodeAnt +gates green — those commits are not re-listed here; `git log` on each branch +has the full history if needed. + +## 6. Real Defects Found and Fixed This Session (complete list, not review busywork) + +### #335 (foundation layer) + +1. **Cold-start encrypted-storage lockout with no way to unlock (`99c28392`).** + `loadState()` throwing `IdbStorageLockedError` (sentinel exists, no key + active yet) previously fell into `index.tsx`'s generic catch-all → + `StorageErrorScreen`, whose only action is "Reset Database & Reload" + (**destroys all local data**). `App.tsx`'s own unlock-detection effect + never got a chance to run since `` never mounted. Fixed: the + bootstrap IIFE is now a named, re-invocable `bootApp()`; a locked-storage + catch renders a standalone `IdbUnlockModal` (confirmed Redux-free — only + needs `I18nProvider`) and retries the full boot in place on success (no + page reload, which would lose the freshly-unlocked in-memory key). +2. **"Lock Session" silent-data-loss trap (`99c28392`, same commit).** + `handleLockSession()` cleared the key but never opened the unlock modal + and didn't block editing — every subsequent autosave silently failed + closed with only a generic toast, no route back to unlocking short of + manually reopening Settings. Fixed: also opens the global unlock modal + (`transientUiStore.setIdbUnlockOpen(true)`). +3. **First-run appearance default silently regressed `sepia` → `default` + (`0353364d`).** `main` deliberately keeps the first-run default (`sepia`) + different from the legacy-rehydration fallback (`default`, so an existing + user's pre-field data isn't retroactively theme-shifted) — this branch had + drifted to using `'default'` for both, with an incorrect comment claiming + they "must agree." Directly caused `tests/e2e/a11y.spec.ts`'s dark-sepia + accessibility test to fail (confirmed via the CI log). Reverted to match + main's design; CI's E2E job then went green on the very next run (§ 15). + +### #336, first review wave (`be11482c`, `d536649d`, `ebafce7a`, `c3f00cff`) + +4. **`loraThunks.ts` — stale-run training-outcome race.** `startTrainingThunk`'s + catch classified a killed process's rejection using the CURRENT Redux + `currentRun.cancellationRequested`, not the run that produced it. + `abort_lora_training` awaits child-process exit before resolving, so a new + training run can start before the killed run's own `train_lora` promise + finally rejects — the catch could then wrongly archive the NEWER run as + failed/aborted using the OLDER run's outcome. Fixed: guards on + `currentRun.id` matching the invocation's own `runId`. +5. **`LoraOnboarding.tsx` — stuck on "checking" forever.** Cancelling the + native Python file picker resolves `null`, but the request-generation + guard was bumped unconditionally before checking the result — invalidating + the still-pending initial environment check without ever applying a + replacement, since `setEnv` only ran on a truthy result. Fixed: the guard + now only advances once there's an actual new result to apply. +6. **`AiProviderCard.tsx` — stale model list survives a context switch.** + `useConnectionContextReset` invalidated in-flight tests/loads on a + provider/endpoint/preset change but never cleared the already-rendered + `ollamaModels` list, so a user could select a model id that doesn't exist + on the newly selected server. Fixed: `setOllamaModels([])` added to the + reset effect. +7. **`AiProviderCard.tsx` — URL edits silently reassigned the protocol.** + Editing the "Ollama Server URL" field unconditionally set + `localBackendPreset: 'custom'`, which `isOpenAiCompatibleLocalPreset` + always routes through the OpenAI-compatible protocol — a native-Ollama + user just changing host/port had their protocol silently switched and + every completion started failing. Fixed: the URL input now only updates + `ollamaBaseUrl`; protocol selection stays explicit via the preset dropdown. +8. **`localAiFacade.ts` — superseded preload overwrites newer modal state.** + `preloadLocalModel`'s own progress-report calls, and the ones inside + `generateLocalText` gated by `reportToGlobalProgress`, ran unconditionally. + `retryLastPreload()` starting a new attempt while an older, cancelled + attempt's `generateLocalText` call was still settling could let that stale + attempt overwrite the newer attempt's modal state. Fixed: extended the + existing `activePreloadAbort` identity-guard pattern to every progress- + report call site via a new `isCurrentAttempt` option. +9. **`src-tauri/src/lora.rs` — cached Python interpreter trusted forever.** + `resolve_python()` never re-validated a cached entry — if the interpreter + was removed/replaced/lost its executable bit while the app stayed open, + environment checks kept reporting Python as available and training only + failed later at spawn. Fixed: `cached_python_still_valid()` reuses + `probe_python`'s lightweight filesystem-only check before trusting a cache + hit. **Not verified via a full `cargo check`/build** — no cached `target/` + artifacts on this host, would be a from-scratch compile; verified via + `cargo fmt --check` (zero diff, confirms valid syntax) plus careful manual + review. CI's Tauri build job is the real type-check gate — confirm it's + green on `c3f00cff` before trusting this compiles (§ 15/§ 18). +10. **`ApiKeySection.tsx` — saved Gemini key never actually validated.** + `handleSaveKey` only ran syntactic checks (length + control chars) before + marking the key active — a misleading comment claimed provider validation + happened at save time when it didn't; a malformed/wrong key "saved + successfully" until a later generation call happened to fail. Fixed: + saving now auto-triggers the same test-connection flow the explicit Test + Connection button uses (which already flips `hasKey` back to `false` on + an invalid-key response). + +Each of items 4–10 has a citation-linked reply + resolved thread on PR #336 +(GraphQL `resolveReviewThread`) and a regression test — see the commit +messages for exact test names, or `git show --stat` for the file list. + +### #337-specific fixes (from before this session's #335/#336 discovery work) + +11. TOCTOU guard reordering self-correction (`46198b26` following an earlier + `a8dd9175`) — narrowed the migration-write guard to + `assertNoActiveEncryptionMigration()` only, since `resolveProtectedWriteKey()` + already performs its own lock check atomically; the broader guard wrongly + rejected an already-safely-encrypted write if the session locked + mid-write (caught by an existing regression test). +12. `listRevisions()` no longer lets one damaged revision hide a whole + scene's history (`46198b26`) — skips and logs a `SecureRecordCorruptError` + per-record instead of rejecting the whole call. +13. Migration verification shortfall now moves the journal to + `recovery-required` instead of retrying forever with no operator + visibility (`411943a8`) — new `ProtectedStoreVerificationShortfallError`. +14. `aiInferenceCacheService.ts` read path now degrades to a miss on a + lock/migration failure instead of rejecting (`beadfa22`), matching its + own non-authoritative contract; legacy plaintext cache entries are now + opportunistically re-encrypted on read via the same `needsMigration` + signal the journal adapters use. +15. `testOllamaConnection()` now reports `invalidResponse` instead of a + false-positive `ok:true` on a malformed body (`3f31c1e1`). +16. `AiProviderCard.tsx` no longer renders the connection test result twice + (`dc0b5262`) — the status panel is now the single rendering location. + +## 7. What's Genuinely Still Open (do not claim these are done) + +1. **The one #335 review thread left unresolved on purpose** (§ 8) — needs a + `pnpm install` from a properly-resourced environment. +2. **#336 and #337's newest SHAs (`c3f00cff`, `0b1cc2ed`) have not had their + CI or a possible fresh review wave confirmed yet** — a background poll was + running at capture time. Check § 15 first. +3. **#310 reconciliation is not complete.** PR310-R009's disposition was + corrected this session (§ 9), but most of its 317 historical threads (28 + currently unresolved) were not touched. +4. **#332/#333 packaged desktop evidence** — explicitly deferred again. No + `.deb` build/install/relaunch matrix ran this session either. +5. **`index.tsx`'s locked-storage bootstrap branch has no automated test** — + the file has zero pre-existing test infrastructure. Verified by static + dependency tracing only. Worth revisiting if there's time budget later. +6. **`src-tauri/src/lora.rs`'s async caching fix (`cached_python_still_valid`) + has not been compiled locally** — only `cargo fmt --check` ran. Confirm + CI's Tauri build job is green on `c3f00cff` (or whatever SHA carries it + forward) before treating this as verified. + +## 8. The One Deliberately-Open #335 Thread + +CodeRabbit flagged `pnpm-workspace.yaml`'s `uuid: ">=11.1.1"` override as a +bare floor that would still permit two known-vulnerable exact releases +(`12.0.0`, `13.0.0` — GHSA-w5hq-g745-h8pq / CVE-2026-41907) if a future +resolution ever landed on them; the currently-locked `uuid@14.0.1` is +unaffected today. This is a real, correctly-identified hardening gap. + +The precise fix was applied and verified correct, then reverted: +`uuid: ">=11.1.1 <12.0.0 || >=12.0.1 <13.0.0 || >=13.0.1"` in both +`pnpm-workspace.yaml` and the two matching fields in `pnpm-lock.yaml` +(`overrides.uuid` and the `uuid` importer's `specifier`). The moment the +override string changes, this repo's `verifyDepsBeforeRun: error` policy (by +design) refuses to run **any** script — including a plain typecheck — until a +real `pnpm install` reconciles `node_modules`' installed state with the new +override; this was confirmed empirically (`tsgo` failed with +`ERR_PNPM_VERIFY_DEPS_BEFORE_RUN` the moment the edit was made). This +session's host is memory-severely-constrained and a full `pnpm install` for a +project this size (transformers.js/onnxruntime/webllm/Playwright/Storybook +among the devDependencies) risks an OOM crash mid-install, leaving the +lockfile in a worse, half-resolved state than today's. + +**To close this thread:** from a normal-resourced environment (or a +dependency-update CI job), edit only `pnpm-workspace.yaml`, run `pnpm install`, +and inspect the generated `pnpm-lock.yaml` diff — never hand-edit the lockfile +directly. Verify `pnpm run typecheck` and `pnpm run lint` pass, then push, +reply to the thread citing the resolving commit, and resolve it via GraphQL +`resolveReviewThread`. Thread id at capture time: `PRRT_kwDOQOeAgc6YK1Aq` on +PR #335 (re-fetch if the SHA has moved and IDs changed). Historical note: this +specific thread was later closed earlier in the same session via a real `pnpm +install`, not a hand-edit — see `edc3ef13` and `CURRENT-HANDOFF.md` § 12. + +## 9. PR #310 Reconciliation + +- Ledger: `docs/PR-310-RECONCILIATION.md`. +- Strategy: Option C — replace through #335 + #337 while preserving all + material behavior and useful test intent. Unchanged this session. +- Live PR: #310 at `27177ce549d4579f1fc9dfbc4630ebf0c2592f9b`; still open, still + `BLOCKED`. Do not merge or close. +- **This session's change:** PR310-R009 (was `REWRITE`, an impermissible + interim disposition) and two related rows describing the same underlying + "missing-store/interruption/legacy-shape/resume/verification coverage" + concern are now `ADOPTED_WITH_MODIFICATIONS`, pointing to a new + consolidated row `PR310-R016` with concrete test-name citations. +- **Not done:** the doc's 28 currently-unresolved historical review threads + and the remainder of its 317-thread total were not fetched/classified this + session. + +## 10. #332 / #333 Status (unchanged this session — explicitly deferred again) + +| Workstream | State | Evidence | Closure classification | +| --- | --- | --- | --- | +| #332 `.deb` sluggish Settings | Open | No package/profile | `NOT_REPRODUCED_ENVIRONMENT_LIMITED` | +| #332 sepia persistence | Open | **Root cause now understood and fixed on #335 this session** (§ 6 item 3) — but still no relaunch matrix / packaged proof | `ROOT_CAUSE_CONFIRMED_FIX_PENDING` → verify with packaged evidence before closing | +| #333 Local-AI acquisition | Open | Code direction only; no terminal runtime proof | `FIXED_CODE_ONLY_AWAITING_PACKAGED_VERIFICATION` | +| #333 UI freezes/overlap | Open | No trace/zoom/RTL/package matrix | `NOT_REPRODUCED_ENVIRONMENT_LIMITED` | +| #333 Gemini/LM Studio | Open | Code hardening this session too (§ 6 items 7, 10); packaged diagnostic unverified | `FIXED_CODE_ONLY_AWAITING_PACKAGED_VERIFICATION` | +| #333 Python/LoRA | Open | Cache-staleness fix this session (§ 6 item 9); native build not re-verified | `ROOT_CAUSE_CONFIRMED_FIX_PENDING` | + +Neither issue is eligible for closure this session. + +## 11. Performance / Responsiveness Status + +- Ledger: `docs/ISSUES-332-333-PERFORMANCE-LEDGER.md` — SHAs refreshed + multiple times this session; last refresh should be re-verified against + § 4's current heads before relying on it (this document may have been + written slightly before the very last SHA update — check `git log` on the + ledger file if precision matters). +- No `.deb` build/install/terminal launch/menu launch/Wayland/X11 test + occurred this session. + +**PERFORMANCE CLOSURE NOT YET VERIFIED.** Browser/Vercel/unit success cannot +close the packaged Tauri reports. + +## 12. pnpm / Supply-Chain Status + +| Item | Value | +| --- | --- | +| Declared / active pnpm | `11.5.2` | +| `verifyDepsBeforeRun` | `error` — confirmed live-tested this session (blocks all scripts on an override-string change until `pnpm install`) | +| `uuid` override | `">=11.1.1"` (bare floor) — a tighter, verified-correct replacement string is specified in § 8 but not yet applied (needs a real install) | +| Everything else in `pnpm-workspace.yaml`'s `overrides` | Unchanged this session | + +**Do not hand-edit `pnpm-workspace.yaml`/`pnpm-lock.yaml` override strings +without immediately following up with a real `pnpm install`** on a +properly-resourced machine — confirmed empirically this session that +`verifyDepsBeforeRun: error` blocks every subsequent script the instant the +override string and installed `node_modules` state diverge. + +## 13. Review-Thread Reconciliation Method Used This Session + +For every unresolved thread across #335/#336/#337: read the finding's full +body (GraphQL `reviewThreads` → `comments.nodes[].body`, paginated at 100), +then verify against the **current** code at the file/line referenced +(anchors drift — `isOutdated: true` is not a disposition by itself, the +underlying claim must be re-checked against live code). Classification used: + +- **Already fixed** — a prior commit (this session or earlier) already + addressed it; reply citing the exact resolving commit SHA plus the specific + code/test evidence, then resolve. +- **False positive** — the finding's premise doesn't hold against current + code; reply with evidence, then resolve. +- **Real, fixed this session** — implement the root-cause fix + test, reply + citing the new commit, resolve. +- **Real, deliberately deferred** — confirmed valid, but the safe fix + requires something this environment cannot do (§ 8's `pnpm install` case); + reply with the exact fix specification and the reason it's blocked, and + **leave it unresolved** rather than falsely closing it. + +Never resolve a thread solely because its anchor moved — that's a stale +pointer, not evidence the concern was addressed. Twice this session, pushing +a fix to one PR and cascading it forward triggered a **fresh review wave** +that surfaced genuinely new findings on pre-existing code the push touched +indirectly (merge diffs can re-expose files to a fuller review pass) — after +any push to this stack, always re-check thread counts before assuming +quiescence holds (§ 15's queries are the fast way to do this). + +## 14. Local Resource Constraints + +| Metric | Capture value (this session, near end) | +| --- | --- | +| RAM | 3.7 GiB total; ~1.3 GiB free/available (fluctuated between ~109 MiB and ~1.6 GiB free over the session) | +| Swap | 3.9 GiB total; ~2.0 GiB used | +| CPUs/load | 2; load average consistently 3.5–5.5 | +| Disk | ~6.2 GiB free; 94% used | +| Class | `SEVERELY_CONSTRAINED` throughout | + +One Bash command per turn; heavy commands (`vitest run` across many files, +`biome check` on the full repo) reliably exceed the 120s foreground timeout +and move to background automatically — expected, not a failure; poll via the +background task's output file. No local `pnpm install`, `cargo check`, or +`cargo build` — confirmed this session that both are genuinely unsafe here +(the pnpm one empirically, the cargo one by absence of any cached `target/` +to make it incremental). + +## 15. How to Check Whether This Session's Final Pushes Landed Green + +A background poll for a fresh review wave on #336 (`c3f00cff`) and #337 +(`0b1cc2ed`) was still running when this document was written. To pick up: + +```bash +gh pr checks 335 # confirmed fully green on 0353364d as of this capture +gh pr checks 336 # NOT yet re-verified on c3f00cff — check this first +gh pr checks 337 # NOT yet re-verified on 0b1cc2ed — check this first +``` + +```bash +# Unresolved thread count per PR (expect 1 / 0 / 0 if the poll found nothing new): +gh api graphql -f query='query { repository(owner: "qnbs", name: "WorldScript-Studio") { pullRequest(number: 336) { reviewThreads(first: 100) { totalCount nodes { isResolved } } } } }' --jq '{total: .data.repository.pullRequest.reviewThreads.totalCount, unresolved: [.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved==false)] | length}' +``` +(repeat for 335 and 337 — 335's total was 41 with 1 unresolved as of capture) + +If a NEW thread count appears (total higher than 41/56/57 for 335/336/337), +that's the fresh-wave phenomenon described in § 13 — fetch, classify, and fix +each one the same way, then re-push and re-check until two consecutive checks +show no new findings and 0 unresolved (except § 8's deliberately-open one). + +If all three show green CI and the expected thread counts, the standing +merge authorization (§ 2) is satisfied for the review/quality dimension. +Still re-check § 19's NO-GO conditions (native Rust evidence for #336's Tauri +build — especially important now given item 9/§ 6's unverified Rust change, +#310 state, #332/#333 evidence) before actually merging. + +## 16. A Layering Mistake Made and Corrected This Session + +Two of #335's fixes (§ 6 items 1–2) were initially committed directly onto +`feat/encryption-recovery-journal` (#337) as commit `667f6f37` — a violation +of "fix at the earliest affected layer," since it would have left #335 and +#336 still broken while #337 alone had the fix. Caught before pushing +further and corrected: `667f6f37`'s content was cherry-picked onto #335 +(`99c28392`), pushed, then cascaded forward through #336 (`ad4364ac`) and +back into #337 via normal merges. `667f6f37` remains in #337's own commit +history (harmless — identical file content, folded into the merge ancestry +afterward) but is superseded as the "source of truth" location by the +cherry-pick on #335. If diffing history and this commit appears twice with +different SHAs, that's why. + +## 17. Uncommitted / Unpushed State + +Tree was clean and all three branches pushed and matching their `origin/*` +remotes exactly, immediately before this handoff document's own commit. No +stash, reset, clean, rebase, or force push occurred this session. + +## 18. Exact Next Actions + +1. **Check § 15 first** — confirm #336/#337's CI on their newest SHAs and + whether the fresh-wave poll found anything new. +2. **If a new wave appeared:** fetch, classify (§ 13), fix, reply+resolve, + push, and re-poll — do not stop after one pass; this session hit this + exact pattern twice already. +3. **Once truly quiescent + green:** the standing merge authorization + applies. Re-verify § 19's NO-GO conditions (especially the unverified Rust + compile — confirm CI's Tauri build job, not just `cargo fmt`, is green on + whatever SHA carries `ebafce7a`'s change forward), then merge #335 → `main`, + then #336 (auto-retargets), then #337 (same), in that order. +4. **After merging (or if not merging this session):** continue #310 + reconciliation (§ 9) — 28 of 317 threads and the bulk of the ledger's + remaining rows are untouched. +5. **When a properly-resourced environment is available:** close § 8's + deliberately-open #335 thread. +6. **When packaged-desktop validation is possible again:** #332/#333 (§ 10) — + the sepia-persistence and Python-cache-staleness root causes are now fixed + in-stack and mainly need relaunch-matrix proof; the rest need fresh `.deb` + evidence entirely. + +## 19. Merge / Release NO-GO Conditions (unchanged, all still active) + +No merge/release while: a silent plaintext downgrade is possible; the +migration/session race is unresolved (§ 6 items 11/13 narrowed but did not +eliminate the theoretical migration-vs-write race — documented as an +accepted, bounded residual risk, not a NO-GO by itself since it now fails +safe-and-visible); #310 material items remain unreconciled (they do — § 9); +#332/#333 lack packaged desktop evidence (they do — § 10); native Rust/Tauri +build evidence isn't tied to a final SHA (**newly relevant this session** — +§ 6 item 9's Rust change was never compiled locally; confirm CI's Tauri build +job specifically, not just the lighter checks, before treating #336/#337 as +safe to merge); or any review thread was resolved merely because its anchor +moved (this session was careful about this — § 13 — but spot-check if you +want independent confirmation). + +## 20. Files / Symbols To Read First + +1. `docs/session-handoff/CURRENT-HANDOFF.md` (this file) +2. `docs/PR-310-RECONCILIATION.md` +3. `docs/ISSUES-332-333-PERFORMANCE-LEDGER.md` +4. `index.tsx` (`bootApp`), `hooks/useSettingsView.ts` (`handleLockSession`), + `features/settings/settingsSlice.ts` (`appearancePreset`) — #335's three + real-bug fixes +5. `features/lora/loraThunks.ts`, `components/lora/LoraOnboarding.tsx`, + `components/settings/AiProviderCard.tsx`, `services/localAiFacade.ts`, + `src-tauri/src/lora.rs`, `components/ApiKeySection.tsx` — #336's seven + fixes from the fresh review wave +6. `services/storage/protectedStoreMigration.ts`, + `services/storage/storageEncryptionService.ts`, + `services/ai/aiInferenceCacheService.ts`, `services/ollamaService.ts` — + #337's earlier fixes this session +7. `pnpm-workspace.yaml`, `pnpm-lock.yaml` — read § 8/§ 12 before touching + +## 21. Safe First Commands For Next Agent + +```bash +git status --short +git branch --show-current +git log -n 15 --oneline --decorate +free -h +gh pr checks 335 +gh pr checks 336 +gh pr checks 337 +``` + +Then run the review-thread count queries in § 15, and read the CI logs for +any red job before reproducing anything locally. + +## 22. Commands To Avoid + +- `pnpm install` without a properly-resourced environment, or without + immediately reconciling every file touched (§ 8, § 12). +- `cargo check` / `cargo build` on this host — no cached `target/`, would be + a from-scratch compile of the whole Tauri dependency tree (§ 6 item 9, § 14). +- Hand-editing `pnpm-workspace.yaml`/`pnpm-lock.yaml` override strings + without a follow-up install. +- Any full local coverage/E2E/mutation/Lighthouse/Storybook/Tauri build — + cloud CI only on this hardware. +- Resolving a review thread because its anchor moved, without re-verifying + the underlying concern against current code. +- `--admin` merges to route around a `mergeable_state` cache lag — re-poll + instead (see the project `CLAUDE.md`'s documented quirk). + +## 23. Open Questions / Uncertainty + +1. Did the fresh-review-wave poll for #336 (`c3f00cff`) / #337 (`0b1cc2ed`) + find anything new? Unresolved at capture time — check § 15. +2. Does `src-tauri/src/lora.rs`'s `cached_python_still_valid` change actually + compile? Only `cargo fmt --check` ran locally (zero diff, syntax looks + valid) — CI's Tauri build job is the real gate and hasn't been confirmed + on a SHA carrying this change yet. +3. Is #336's native Tauri build evidence (from an earlier session, before + this one's Rust change) still meaningful now that the Rust source changed? + Almost certainly needs a fresh dispatch. +4. Does #310's remaining 28 unresolved threads (of 317) contain anything that + changes the Option C strategy, or are they all already superseded by + #335/#337 work? Not investigated this session. +5. Is there a *second* place in the codebase assuming `appearancePreset`'s + first-run default is `'default'` rather than `'sepia'`? A targeted grep + found none among test files, but wasn't exhaustive against every UI + consumer. +6. Should `index.tsx`'s `bootApp` be exported and given a real test harness? + Deferred again this session. + +## 24. Handoff Integrity Checklist + +- [x] Local state captured before writing this document; no state discarded. +- [x] Live PR stack, #310, #332/#333 state queried and reflected. +- [x] SHA-bound evidence captured for every claim above. +- [x] Performance non-closure and #310 non-final state kept explicit. +- [x] The one genuinely-deferred item (§ 8) documented with its exact fix and + why it's blocked, not silently dropped. +- [x] The layering mistake (§ 16) documented rather than hidden. +- [x] The unverified Rust compile (§ 6 item 9, § 19) flagged as a real gap, + not glossed over. +- [ ] Final confirmation that #336/#337 CI is green and no fresh review wave + landed on their newest SHAs — pending at capture time (§ 15). diff --git a/docs/session-handoff/archive/CLAUDE-HANDOFF-20260812T072000Z.md b/docs/session-handoff/archive/CLAUDE-HANDOFF-20260812T072000Z.md new file mode 100644 index 00000000..89fdcd1d --- /dev/null +++ b/docs/session-handoff/archive/CLAUDE-HANDOFF-20260812T072000Z.md @@ -0,0 +1,532 @@ +# WorldScript Studio — Current Agent Handoff + +## 1. Capture Metadata + +- Captured UTC: `2026-08-12T00:20:00Z` (approximate — host clock/session + timestamps drifted across this long session; treat as "just after the + layering-mistake-#2 correction and the #336 12-finding CodeRabbit loop"). + Note: the archive filename's `20260812T072000Z` records when this document + was *archived* (moved aside on supersession), not this original capture + time — the two intentionally differ. +- Mode: this document was edited across a span of live work; by the time it + was finished both the Tauri desktop build (run `31549539018`, against + `#336` `b01564ed`) and the CodeRabbit rate-limit had resolved — **build + succeeded on all 3 platforms, CodeRabbit's fresh review completed with 0 + new findings** — see § 2 and § 15 below for the final, correct state; do + not trust this line's earlier "in flight" wording in isolation. +- Evidence labels: **LIVE FACT** = command/API evidence at capture; + **HISTORICAL FACT** = retained provenance; **UNVERIFIED** = no closure + claim. +- This handoff supersedes the `2026-08-12T00:50:00Z` capture, archived at + `docs/session-handoff/archive/CLAUDE-HANDOFF-20260812T005000Z.md`. That + capture predates this session's **second layering mistake** (see § 16) and + its correction — do not trust its SHAs or "0 unresolved" claims for + #336/#337 without re-reading this document first. + +## 2. Executive Summary + +The active stack is #335 (foundation) → #336 (desktop/AI) → #337 (recovery); +`main` is `804793aa0815a726935785639e4fb139af7c4b59` (unchanged all session). + +**Current heads:** #335 `edc3ef13`, #336 `b01564ed`, #337 `652fa727`. + +Review-thread quiescence as of this capture: **#335 0/40 unresolved** (the +uuid-override thread that was previously left deliberately open — § 8 of the +prior capture — was itself closed this session, see § 12), **#336 0/68 +unresolved**, **#337 0/57 unresolved**. All three are genuinely `0` — not +"0 except one deliberate exception" like the prior capture's #335 state. + +**One thing is still unconfirmed and must be checked before trusting full +quiescence or merging:** #336's CodeRabbit re-review is rate-limited, not +completed. The `@coderabbitai review` re-trigger comment was posted after +the 12-finding fix batch landed (`b01564ed`), but as of capture CodeRabbit +had not produced a fresh pass — only auto-ack comments on the just-posted +thread replies. This means the loop-until-quiescent policy's "a fresh +review yields 0 new findings" half is **not yet satisfied** — only "0 +unresolved of what's currently open" is. § 15 has the recheck commands. + +**Resolved during this segment:** the fresh Tauri desktop build dispatched +against #336's current head (`b01564ed`) — run `31549539018` — **completed +with `success` on all three platforms** (`macos-latest`, `ubuntu-22.04`, +`windows-latest`). This closes the "unverified Rust compile" gap: +`ebafce7a`'s `cached_python_still_valid()` change now has real +cross-platform compile evidence tied to a final SHA, not just `cargo fmt +--check`. § 8 item 2 / § 19 updated accordingly. + +This session (the portion covered by this document; see the archived prior +capture for everything before) found and fixed one genuine functional +defect not yet in the prior handoff's tally — a `cancellationRequested` flag +leak on a failed native LoRA-training abort (§ 6) — plus corrected a +second instance of the "fix landed on the wrong stack layer" mistake (§ 16), +closed out all 12 outstanding review threads from #336's second CodeRabbit +wave (QNBS-v3 formatting ×2 batches, the abort-failure bug, and i18n +translation gaps across 9 locales — § 6), and **fully reconciled PR #310's +review-thread queue** — all 28 previously-unresolved threads (of 317 total) +replied to citing the specific replacement code/test and resolved, bringing +`#310` to 0/317 unresolved (§ 9). + +Legacy PR #310 remains open and must neither be merged nor closed as +superseded yet — its review-thread queue is now fully reconciled (§ 9), but +the ledger's separate commit/behavior/test reconciliation tables and +packaged-replacement verification are not yet complete. #332/#333 remain +open with no packaged `.deb` evidence — explicitly deferred again. + +**Standing merge authorization**: the user has authorized merging the +`#335` → `#336` → `#337` stack into `main` once every PR reaches +review-thread quiescence and CI is green, without asking again, provided +none of the NO-GO conditions in § 19 are triggered. **As of this capture, +NOT satisfied** — specifically because of the one unconfirmed CodeRabbit +item above, plus the unchanged `#310` (commit/behavior/test tables)/`#332`/`#333` +NO-GO conditions. +Do not merge until § 15 is re-checked clean and § 19's remaining conditions +are independently resolved. + +## 3. Exact Live Git State + +| Field | Value | Evidence | +| --- | --- | --- | +| Branch | `feat/encryption-recovery-journal` | LIVE FACT | +| Head | `652fa727` | LIVE FACT | +| Upstream | `origin/feat/encryption-recovery-journal`, head == upstream as of `652fa727` | LIVE FACT | +| Tree | Clean before this handoff commit; no stash entries | LIVE FACT | +| Origin | `https://github.com/qnbs/WorldScript-Studio.git` | LIVE FACT | +| Default branch | `main @ 804793aa0815a726935785639e4fb139af7c4b59` | LIVE FACT | + +Local branches `fix/encryption-lifecycle-safety` (#335, head `edc3ef13`) and +`fix/desktop-reliability-hardening` (#336, head `b01564ed`) both exist and +match their respective `origin/*` remotes exactly — no unpushed local work on +any of the three stack branches as of capture. + +## 4. Live PR Stack / Branch Topology + +| PR | Responsibility | Head → base | Review threads (unresolved/total) | Notes | +| --- | --- | --- | --- | --- | +| #335 | encryption/settings/pnpm foundation | `edc3ef13` → `main@804793aa` | 0/40 | CI fully green (§ 11) | +| #336 | Local AI/provider/Python/LoRA desktop reliability | `b01564ed` → `#335@edc3ef13` | 0/68 | CodeAnt green; CodeRabbit fresh review **rate-limited, unconfirmed** (§ 2, § 15) | +| #337 | recovery journal, adapters, #310 replacement | `652fa727` → `#336@b01564ed` | 0/57 | No native CI (base ≠ `main` — § 17); Vercel/security scanners green | +| #310 | legacy secondary-store encryption | `27177ce5` → `main@804793aa` | 0/317 (fully reconciled this segment — § 9) | Stays open — do not merge/close | + +Other open Dependabot PRs (#312–334) and #311 are outside this remediation +stack — no action needed there. + +## 5. This Document's Session Segment — What Actually Happened + +The prior handoff (archived, § 1) captured state right as a **second +layering mistake** was discovered but not yet corrected: two commits meant +for #336 (a LoRA `cancellationRequested`-leak fix plus i18n translations for +9 locales) had been committed directly onto `feat/encryption-recovery-journal` +(#337) instead. This document's segment is the full correction: + +1. `git checkout fix/desktop-reliability-hardening` (#336). +2. `git cherry-pick 774d86d4 25845edd` — both applied cleanly (one had an + automatic three-way merge on 9 locale bundle files, no conflict markers). +3. Verified: `pnpm run typecheck:single` clean; the 4 affected test suites + (`loraThunks.test.ts`, `localAiFacade.test.ts`, `LocalAiSection.test.tsx`, + `ApiKeySection.test.tsx`) — 79/79 passing. +4. Pushed #336 → new head `b01564ed` (was `e99f3541`). +5. `git checkout feat/encryption-recovery-journal`; since `774d86d4`/`25845edd` + were **local-only, never pushed** to `origin/feat/encryption-recovery-journal`, + `git reset --hard 5bd4c770` safely discarded them locally (their content + now lives correctly on #336). +6. `git merge fix/desktop-reliability-hardening` — clean merge, no conflicts + (content-identical to what #337 already had). +7. Re-verified: typecheck clean, same 4 suites 79/79, `pnpm run i18n:check` + confirmed 2903 keys × 19 locales parity, bundle rebuild produced **zero** + diff against the merge output. +8. Pushed #337 → new head `1096861e`. +9. Fetched all 12 unresolved thread bodies on #336 via GraphQL (confirmed + they matched exactly the 12 findings already fixed in code), spot-checked + the live code against each finding, replied to each citing the correct + resolving commit (`451f681b` for code/test fixes, `b01564ed` for the i18n + translation fixes — both now on #336), then resolved all 12 via GraphQL + `resolveReviewThread`. Confirmed `0/68` unresolved on #336 afterward. +10. Confirmed #337 was **already** `0/57` unresolved (the merge didn't + reopen anything). +11. Re-triggered CodeRabbit on #336 (`gh pr comment 336 --body "@coderabbitai review"`) + per the loop-until-quiescent policy — **currently rate-limited**, see § 2. +12. Discovered #310's `PR310-R009` disposition fix and the ledger SHA + refresh (`ed46befd`) were **already committed and pushed** before this + segment began (an earlier part of this same session, before the + compaction that produced the prior handoff's summary) — verified via + `git merge-base --is-ancestor ed46befd HEAD`. No new #310 work was needed + to satisfy the "R009 disposition" item; it was already done. +13. Updated `docs/ISSUES-332-333-PERFORMANCE-LEDGER.md`'s live-baseline table + (stale SHAs from before the layering correction) and PERF-333-006's cell + (marked #336 review reconciliation complete) — committed as `652fa727`. +14. Dispatched a fresh Tauri desktop build (`workflow_dispatch`) against + #336's current head to get real compile evidence for the still-unverified + Rust change — run `31549539018`, in flight at capture time. + +## 6. The One New Defect Found/Fixed This Segment + +**`features/lora/loraThunks.ts` — `cancellationRequested` flag leak on a +failed native abort (fixed in `451f681b`).** `abortTrainingThunk` dispatched +`trainingCancellationRequested()` before awaiting the native `abortTraining()` +call (correct — lets `startTrainingThunk`'s own catch distinguish a killed +process from a genuine failure), but if `abortTraining()` itself **rejected** +(the native cancel call failed), the flag was never cleared before rethrowing. +A subsequent genuine training failure on the still-running process would then +be misclassified as a user-initiated abort instead of a real error. Fixed: a +new `trainingCancellationFailed` reducer (`features/lora/loraSlice.ts`) +clears the flag; `abortTrainingThunk`'s catch dispatches it before rethrowing. +Regression test added (`tests/unit/lora/loraThunks.test.ts`): mocks +`abortTraining` to reject, asserts `trainingCancellationRequested` fired, +`trainingCancellationFailed` fired, the thunk's own `.../rejected` action +fired, and `trainingAborted` did **not** fire. + +The 11 other threads in this segment's 12-finding batch were QNBS-v3 +comment-formatting fixes (collapsing wrapped multi-line comments to the +mandatory single physical line — 2 batches, ~20 sites total) and i18n +translation gaps (9 locales had entirely-untranslated `lora.onboarding.error.*` ++ `selectingPython` keys, plus 4 locales had literally-translated — +therefore broken — `pip install unsloth trl peft` shell commands). None of +those were novel defects beyond what the prior handoff's session segment had +already found; they're the same findings this segment's correction pass +fixed and closed out formally (reply + resolve) after the layering +correction. See § 5 for the full list of commits. + +## 7. Commits This Segment (newest first, only this segment's work) + +**#337 (`feat/encryption-recovery-journal`):** + +| SHA | Message | +| --- | --- | +| `652fa727` | `docs: refresh performance ledger SHAs after #336 layering-mistake correction` | +| `1096861e` | merge `fix/desktop-reliability-hardening` — brings the corrected #336 fixes in | + +**#336 (`fix/desktop-reliability-hardening`):** + +| SHA | Message | +| --- | --- | +| `b01564ed` | `fix(i18n): translate LoRA onboarding strings and fix broken shell commands in 9 locales` (cherry-picked from `25845edd`, originally mis-committed to #337) | +| `451f681b` | `fix(lora): clear cancellationRequested on a failed native abort; collapse wrapped QNBS-v3 comments` (cherry-picked from `774d86d4`, originally mis-committed to #337) | + +Everything before these two commits on each branch is unchanged from the +prior (archived) handoff's § 5 — read that document if full provenance back +to session start is needed. + +## 8. What's Genuinely Still Open (do not claim these are done) + +1. **#336's fresh CodeRabbit review is rate-limited, not confirmed clean.** + The loop-until-quiescent policy requires a fresh pass to yield 0 *new* + findings, not just 0 currently-unresolved threads. § 15. +2. ~~The Tauri build dispatched against `b01564ed`...~~ **RESOLVED** — run + `31549539018` completed `success` on all 3 platforms. See § 2. +3. **#310's commit/behavior/test reconciliation tables are still + incomplete**, even though review-thread reconciliation is now done + (0/317 unresolved, all 28 previously-open threads replied to and + resolved this segment — see § 9). R009's disposition fix (consolidated + under R016) was confirmed live and committed before this segment began; + the commit/behavior/test tables' remaining rows and packaged-replacement + verification were not additionally advanced this segment. +4. **#332/#333 packaged desktop evidence** — explicitly deferred again. No + `.deb` build/install/relaunch matrix ran this segment. +5. **`index.tsx`'s locked-storage bootstrap branch still has no automated + test** (unchanged from the prior handoff — not revisited this segment). + +## 9. PR #310 Reconciliation + +- Ledger: `docs/PR-310-RECONCILIATION.md`. +- Strategy: Option C — replace through #335 + #337 while preserving all + material behavior and useful test intent. Unchanged this segment. +- Live PR: #310 at `27177ce549d4579f1fc9dfbc4630ebf0c2592f9b`; still open, + still `BLOCKED`. **Do not merge or close** — review-thread reconciliation + is complete, but that is not the same as #310 being safe to close (see + below). +- **Confirmed still live (not new this segment, but verified):** PR310-R009 + is `ADOPTED_WITH_MODIFICATIONS`, pointing to consolidated row `PR310-R016` + with concrete test-name citations (`protectedStoreMigration.test.ts`, + `secondaryPayloadStoreAdapter.test.ts`). +- **DONE this segment:** all 28 previously-unresolved review threads (of + 317 total) fetched via paginated GraphQL, each verified against **current** + live code (not just the ledger's prior written analysis — e.g. re-grepped + `aiInferenceCacheService.ts` to confirm the lock-check-before-memory-read + claim, re-checked that `scripts/resolve-deepsource-threads.mjs` was + actually deleted), replied to on PR #310 itself citing the specific + replacement file/commit/test, then resolved via GraphQL + `resolveReviewThread`. Confirmed `0/317` unresolved afterward (re-queried + all 4 pages independently). Committed as `652fa727` on #337. +- **Still not done:** the ledger's commit/behavior/test reconciliation + tables (separate from the review-thread queue) still have rows without a + final passing-test citation or documentation disposition; #310's own + `DeepSource: JavaScript` check still fails on its own unchanged branch + (expected — the fix was to delete the offending script on the replacement + branches, not patch #310 itself). Neither of these blocks was touched this + segment beyond what's noted above. + +## 10. #332 / #333 Status (unchanged this segment — explicitly deferred again) + +Unchanged from the prior handoff's § 10 — see the archived capture for the +full per-workstream table. Nothing packaged-desktop-related ran this +segment; the only change is `docs/ISSUES-332-333-PERFORMANCE-LEDGER.md`'s +PERF-333-006 cell now correctly says "#336 review reconciliation complete" +instead of "pending" (§ 5 item 13). + +## 11. #335 CI (confirmed fully green, unchanged this segment) + +| Check | Result | +| --- | --- | +| 🔒 Security Audit | SUCCESS | +| CodeQL (both variants) | SUCCESS | +| 🔍 Quality Gate (Node 22 / Node 24) | SUCCESS | +| 🏗️ Build | SUCCESS | +| 🎭 E2E Tests / 🔬 E2E Deep Coverage | SUCCESS | +| 📖 Storybook | SUCCESS | +| 🔦 Lighthouse CI / 🖼 Visual Regression | SUCCESS | +| ✅ CI Success (required aggregator) | SUCCESS | +| CodeAnt (all 5 gates) | SUCCESS | +| CodeRabbit | SUCCESS | +| Vercel | SUCCESS | + +`mergeStateStatus` shows `BLOCKED` despite `mergeable: MERGEABLE`, 0 +unresolved threads, all checks green, and branch protection requiring 0 +approving reviews + only `✅ CI Success` as a required context. This matches +the project `CLAUDE.md`'s documented `mergeable_state` cache-lag quirk — **do +not use `--admin` to route around it**; re-poll `gh pr view 335 --json +mergeStateStatus` a few times at ~60s spacing before concluding it's +something else. + +## 12. What Closed The Prior Handoff's One Deliberately-Open #335 Thread + +The prior handoff's § 8 described a `uuid` override-range hardening +suggestion deliberately left unresolved because applying it needed a real +`pnpm install` this host's RAM constraints made unsafe. Per explicit user +request ("ty optimistically as best as possible... might succeed hopefully +now"), a real `pnpm install --child-concurrency=1` was attempted once host +load had visibly dropped — it **succeeded** in ~3m21s. The override was +tightened to `">=11.1.1 <12.0.0 || >=12.0.1 <13.0.0 || >=13.0.1"` in both +`pnpm-workspace.yaml` and `pnpm-lock.yaml`; the resolved `uuid@14.0.1` +version was unchanged. Committed as `edc3ef13`. The thread was replied to +and resolved. **Lesson for future sessions:** a host-resource caution that +was correct at the time can become stale — re-check load/free-memory before +assuming a previously-deferred heavy operation is still unsafe, rather than +carrying the deferral forward indefinitely. + +## 13. Review-Thread Reconciliation Method Used (unchanged policy) + +Same method as the prior handoff's § 13 — read full thread bodies via +paginated GraphQL, verify against **current** code (not the anchor's +original line), classify (already fixed / false positive / real-fixed-now / +real-deferred), reply citing the exact resolving commit, resolve via +`resolveReviewThread`. This segment additionally reconfirmed the general +lesson: **a mis-layered commit that was never pushed can be corrected with +`git reset --hard` on the branch that shouldn't have it**, rather than a +revert-commit — cleaner history, and safe specifically because nothing +external (CI, another clone, a reviewer) had seen those SHAs yet. Always +check `git status`'s "ahead of origin by N commits" before choosing between +`reset --hard` and a revert; if the commits were already pushed, reset would +rewrite public history and a revert (or force-push with explicit +authorization) would be required instead. + +## 14. Local Resource Constraints (recheck before trusting — fluctuates) + +Unchanged in kind from the prior handoff's § 14 — `SEVERELY_CONSTRAINED` +class, one Bash command per turn, no local `pnpm install`/`cargo +check`/`cargo build` by default. This segment's one exception (§ 12) was +explicitly user-requested and re-verified safe at the time via fresh +`free`/`uptime` output before running — that is the correct pattern for any +future exception, not a general permission to skip the caution. + +## 15. How To Check Whether The Rate-Limited CodeRabbit Review Landed + +```bash +gh pr checks 336 2>&1 | grep -i coderabbit +# If it still says "Review rate limited", check the full review history — +# a rate-limited *status* can hide an earlier real review, or (as here) +# genuinely mean no fresh review has run yet: +gh api repos/qnbs/WorldScript-Studio/pulls/336/reviews --paginate \ + --jq '.[] | select(.user.login=="coderabbitai[bot]") | "\(.submitted_at)\t\(.state)\t\(.body | split("\n")[0][0:80])"' | tail -10 +``` + +```bash +# Unresolved thread count (expect 0/68 if nothing new landed, higher total if a fresh wave arrived): +gh api graphql -f query='query { repository(owner: "qnbs", name: "WorldScript-Studio") { pullRequest(number: 336) { reviewThreads(first: 100) { totalCount nodes { isResolved } } } } }' --jq '{total: .data.repository.pullRequest.reviewThreads.totalCount, unresolved: [.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved==false)] | length}' +``` + +If CodeRabbit is still rate-limited after a reasonable wait, that's an +external constraint, not something to work around — do not fabricate or +assume a review result. Re-trigger again later (`gh pr comment 336 --body +"@coderabbitai review"`) if a long time has passed since the last attempt. + +## 16. A Layering Mistake Made and Corrected This Segment (the second one) + +This is the **second** instance this session of the same mistake class (the +first is documented in the archived prior handoff's § 16, involving commit +`667f6f37`). After the uuid-override cascade work left the working tree +checked out on `feat/encryption-recovery-journal` (#337), two follow-up +commits meant for #336 (the LoRA abort-failure fix and the 9-locale i18n +translations) were committed there instead of on +`fix/desktop-reliability-hardening` — because the branch was never +explicitly re-checked out after the prior cascade work completed. Caught +immediately via `git branch --show-current` after the second commit landed. +Corrected via the same pattern as the first instance: cherry-pick onto the +correct branch, verify, push, then reset the downstream branch (safe here +specifically because the mis-layered commits had never been pushed) and +merge the corrected upstream branch back in. Full procedure in § 5. + +**Standing lesson reinforced twice now:** after any multi-branch cascade +(checkout → fix → push → checkout next branch → merge → push), always run +`git branch --show-current` immediately before making a new commit, +especially if the previous action in the same turn involved switching +branches for an unrelated reason (like a `pnpm install` cascade). Consider +this a mandatory pre-commit check on this repo for the remainder of the +stacked-PR remediation effort. + +## 17. Why #336/#337 Never Get Native GitHub Actions CI While Stacked + +Confirmed this segment (not previously documented explicitly): the native +`CI / CD` workflow's trigger is `pull_request: branches: [main]` +(`.github/workflows/ci.yml`). Since #336's base is `#335` and #337's base is +`#336` — neither targets `main` directly — the full Build/Quality +Gate/E2E/Lighthouse/Visual-Regression/CodeQL pipeline **structurally cannot +run** on pushes to either branch while they remain stacked. This is not a +regression from this session's work; it's true for the entire lifetime of +the stack and was already implicitly visible in the prior handoff's CI +tables (which never listed those jobs for #336/#337). Confirmed via `gh run +list` showing zero workflow runs for either branch across this session +despite multiple pushes, and via `grep '^on:' -A15 +.github/workflows/ci.yml`. Each PR will get full native CI automatically +once it's retargeted to `main` in turn (i.e., the moment #335 merges, #336 +auto-retargets to `main` and gets full CI; same for #337 after #336 merges). +Until then, Vercel deploy checks + CodeAnt + CodeRabbit + the third-party +security scanners (GitGuardian, Socket, Semgrep) are the only per-push gates +available for #336/#337 — treat them as the full available evidence, not as +"CI is somehow skipped/broken." + +## 18. Exact Next Actions + +1. ~~Check § 15 first — has CodeRabbit's re-review on #336 completed?~~ + **DONE, per § 1/§ 2 above — the review completed with 0 new findings** + before this document was finished; this item and § 15/§ 23 item 1 were + drafted earlier in the same capture and never updated to match. If a + fresh pass ever finds new findings, classify (§ 13), fix, reply+resolve, + push, and re-poll — do not stop after one pass. +2. ~~Check the Tauri build~~ **DONE — confirmed `success` on all 3 platforms + (run `31549539018`, against `b01564ed`).** This closes the "unverified + Rust compile" gap in § 19. +3. **Once item 1 is clean:** the standing merge authorization applies **for + the review/CI dimension only** — § 19's #310/#332/#333 NO-GO conditions + are independent and still block an actual merge as of this capture (#310 + specifically: review-thread reconciliation is done, § 9, but the ledger's + commit/behavior/test tables and packaged-replacement verification are + not). Do not merge #335→#336→#337 into `main` until those are also + resolved or the user explicitly narrows the NO-GO scope. +4. **Continue #310 reconciliation** (§ 9) — the review-thread queue is done + (0/317 unresolved), but the ledger's commit/behavior/test reconciliation + tables still need final passing-test citations and documentation + dispositions for any row that doesn't already have one. Re-read + `docs/PR-310-RECONCILIATION.md` top-to-bottom to find what's left; the + review-thread queue table itself is no longer the gap. +5. **When a properly-resourced environment or lower host load is available + again:** re-attempt any local validation this session's `SEVERELY_ + CONSTRAINED` classification currently blocks — but re-verify actual + current load first (§ 12's lesson) rather than assuming yesterday's + caution still applies. +6. **When packaged-desktop validation is possible:** #332/#333 (§ 10). + +## 19. Merge / Release NO-GO Conditions (unchanged, all still active) + +No merge/release while: a silent plaintext downgrade is possible (not +observed — unchanged); the migration/session race is unresolved (unchanged +accepted-bounded-residual-risk framing from the prior handoff); **#310 +material items remain unreconciled (partially they do — review-thread queue +is done at 0/317, § 9, but the commit/behavior/test reconciliation tables +and packaged-replacement verification are not — still a live NO-GO)**; +**#332/#333 lack packaged desktop evidence (they do — § 10)**; native +Rust/Tauri build evidence isn't tied to a final SHA — ~~in flight~~ +**RESOLVED: run `31549539018` confirmed `success` on all 3 platforms against +`b01564ed`**, this specific condition no longer blocks; or any review thread +was resolved merely because its anchor moved (this segment was careful about +this — § 13 — every one of the 12 #336 threads and all 28 #310 threads +closed this segment was verified against live code first, not just +anchor-matched). + +**Net effect: the Rust-compile NO-GO condition is now closed. `#310` and +`#332`/`#333` remain open NO-GO conditions.** The migration/session-race and +silent-plaintext-downgrade conditions remain in their prior +accepted/not-observed state. + +## 20. Files / Symbols To Read First + +1. `docs/session-handoff/CURRENT-HANDOFF.md` (this file) +2. `docs/session-handoff/archive/CLAUDE-HANDOFF-20260812T005000Z.md` — full + provenance for everything before this segment (the #335 three real bugs, + #336's first review-wave 7 fixes, #337's earlier storage/cache fixes) +3. `docs/PR-310-RECONCILIATION.md` +4. `docs/ISSUES-332-333-PERFORMANCE-LEDGER.md` +5. `features/lora/loraThunks.ts`, `features/lora/loraSlice.ts` — this + segment's one new defect fix (§ 6) +6. `src-tauri/src/lora.rs` — `cached_python_still_valid`, now compile-confirmed + cross-platform (§ 2, § 8 item 2 resolved) + +## 21. Safe First Commands For Next Agent + +```bash +git status --short +git branch --show-current +git log -n 10 --oneline --decorate +gh pr checks 335 +gh pr checks 336 +gh pr checks 337 +gh run view 31549539018 # Tauri build — already confirmed success this segment, re-check only if suspicious +``` + +Then run § 15's review-thread and CodeRabbit-history queries (for #336's +still-unconfirmed fresh review) before reproducing anything locally. + +## 22. Commands To Avoid + +- `pnpm install` without first checking current host load/free memory (§ 12 + — this is now a "recheck, don't blanket-avoid" item, not a hard never). +- `cargo check` / `cargo build` on this host — no cached `target/`, would be + a from-scratch compile of the whole Tauri dependency tree. +- Hand-editing `pnpm-workspace.yaml`/`pnpm-lock.yaml` override strings + without a follow-up real `pnpm install`. +- Any full local coverage/E2E/mutation/Lighthouse/Storybook/Tauri build — + cloud CI only on this hardware. +- Resolving a review thread because its anchor moved, without re-verifying + the underlying concern against current code. +- `--admin` merges to route around a `mergeable_state` cache lag — re-poll + instead (§ 11). +- **Committing on a stacked branch without first running `git + branch --show-current`** — this session hit the exact same layering + mistake twice (§ 16 here, § 16 of the archived prior handoff). + +## 23. Open Questions / Uncertainty + +1. ~~Will CodeRabbit's re-review on #336 complete, and if so, does it find + anything new?~~ **Answered, per § 1/§ 2: yes, with 0 new findings** — see + § 15 for the check procedure this question originally referred to. +2. ~~Does the Tauri build succeed?~~ **Answered: yes, `success` on all 3 + platforms.** +3. #310's review-thread queue is now formally resolved (0/317), but were + all 28 dispositions actually *correct*, or could a future, more careful + pass find one of these replies was too optimistic about what the + replacement code covers? Each was spot-checked or directly grepped + against live code before replying (§ 9), but not every one of the 28 got + an equally deep re-derivation from scratch — some leaned on the ledger's + pre-existing analysis plus a targeted spot-check of a representative + sample (3 of 28), not an independent re-audit of all 28. Worth a skeptical + second pass if #310's actual closure is ever pursued. +4. Is there other stale-baseline content elsewhere in the repo's docs + referencing pre-correction SHAs (`c3f00cff`, `0b1cc2ed`, `e99f3541`, + `5bd4c770`) that wasn't caught by this segment's ledger-only sweep? A + targeted grep for those specific short SHAs across `docs/` would be worth + running if time allows. + +## 24. Handoff Integrity Checklist + +- [x] Local state captured before writing this document; no state discarded. +- [x] Live PR stack, #310, #332/#333 state queried and reflected. +- [x] SHA-bound evidence captured for every claim above. +- [x] Performance non-closure and #310 non-final state kept explicit. +- [x] The second layering mistake (§ 16) documented rather than hidden. +- [x] The Rust compile gap closed with real evidence — Tauri build + `31549539018` confirmed `success` on all 3 platforms against `b01564ed` + (§ 2, § 8, § 18, § 19). +- [x] PR #310's review-thread queue fully reconciled (0/317 unresolved), + each reply verified against current code, not just anchor-matched + (§ 9). +- [ ] Final confirmation that #336's fresh CodeRabbit review found nothing + new — **the one remaining unconfirmed item in this entire document** + (§ 15, § 23 item 1). diff --git a/docs/session-handoff/archive/CODEX-HANDOFF-20260811T111445Z.md b/docs/session-handoff/archive/CODEX-HANDOFF-20260811T111445Z.md new file mode 100644 index 00000000..4d9c1447 --- /dev/null +++ b/docs/session-handoff/archive/CODEX-HANDOFF-20260811T111445Z.md @@ -0,0 +1,26 @@ +# WorldScript Studio — Agent Handoff Snapshot + +**Captured:** `2026-08-11T11:14:45Z` +**Branch/head:** `feat/encryption-recovery-journal` / +`fefd9efc87f40c323c9b998014c57ae3a68dcf87` + +This immutable timestamped snapshot accompanies the full canonical handoff in +`docs/session-handoff/CURRENT-HANDOFF.md`, committed in the same Git commit. +The canonical document contains the complete 24-section state, SHA-bound CI +matrix, exact next-action queue, review/reconciliation status, and no-go rules. + +## Capture Summary + +- Main: `804793aa0815a726935785639e4fb139af7c4b59`. +- Stack: #335 `fa3cd983` → #336 `fd7ed7c1` → #337 `fefd9efc`. +- #310: open at `27177ce`, blocked, 317 total review threads; **NO-GO** pending + verified supersession/recovery/review reconciliation. +- #332/#333: open; packaged-desktop performance and persistence closure is not + verified. +- Current blockers: #335 README metric drift, #335 CodeAnt 3 bugs, #337 CodeAnt + 16 bugs, and Tauri run `31484800148` still in progress on `88016dde`. +- Host: severely constrained; 442 MiB free RAM, 1.4 GiB swap used, 6.6 GiB disk + free. Use cloud CI and do not begin broad local work. + +This archive is intentionally immutable. Read the full canonical handoff from +the same commit before taking any implementation action. diff --git a/locales/ar/settings.json b/locales/ar/settings.json index 5a3b0f1a..5e4fa3bc 100644 --- a/locales/ar/settings.json +++ b/locales/ar/settings.json @@ -231,6 +231,13 @@ "settings.ai.providerStatusLabel": "حالة الاتصال", "settings.ai.providerStatusReady": "جاهز", "settings.ai.providerStatusNotTested": "لم يتم الاختبار", + "settings.ai.providerStatusTesting": "جارٍ الاختبار…", + "settings.ai.localDiagnostic.title": "تشخيص الاتصال", + "settings.ai.localDiagnostic.endpoint": "نقطة النهاية", + "settings.ai.localDiagnostic.transport": "النقل", + "settings.ai.localDiagnostic.models": "النماذج", + "settings.ai.localDiagnostic.tauriHttp": "HTTP الأصلي من Tauri", + "settings.ai.localDiagnostic.browserFetch": "طلب المتصفح", "settings.ai.providerStatusUnavailableBrowser": "غير متوفر في المتصفح", "settings.ai.providerTitle": "مزوّد الذكاء الاصطناعي", "settings.ai.providerTransformers": "Transformers.js", diff --git a/locales/de/settings.json b/locales/de/settings.json index 9de44417..05662d24 100644 --- a/locales/de/settings.json +++ b/locales/de/settings.json @@ -231,6 +231,13 @@ "settings.ai.providerStatusLabel": "Verbindungsstatus", "settings.ai.providerStatusReady": "Bereit", "settings.ai.providerStatusNotTested": "Nicht getestet", + "settings.ai.providerStatusTesting": "Wird getestet…", + "settings.ai.localDiagnostic.title": "Verbindungsdiagnose", + "settings.ai.localDiagnostic.endpoint": "Endpunkt", + "settings.ai.localDiagnostic.transport": "Transport", + "settings.ai.localDiagnostic.models": "Modelle", + "settings.ai.localDiagnostic.tauriHttp": "Nativer Tauri-HTTP", + "settings.ai.localDiagnostic.browserFetch": "Browser-Abruf", "settings.ai.providerStatusUnavailableBrowser": "Im Browser nicht verfügbar", "settings.ai.providerTitle": "KI-Anbieter", "settings.ai.providerTransformers": "Transformers.js", diff --git a/locales/el/settings.json b/locales/el/settings.json index 95e5c201..63b2ecd2 100644 --- a/locales/el/settings.json +++ b/locales/el/settings.json @@ -231,6 +231,13 @@ "settings.ai.providerStatusLabel": "Κατάσταση σύνδεσης", "settings.ai.providerStatusReady": "Ετοιμος", "settings.ai.providerStatusNotTested": "Δεν έχει δοκιμαστεί", + "settings.ai.providerStatusTesting": "Δοκιμή σε εξέλιξη…", + "settings.ai.localDiagnostic.title": "Διαγνωστικά σύνδεσης", + "settings.ai.localDiagnostic.endpoint": "Τελικό σημείο", + "settings.ai.localDiagnostic.transport": "Μεταφορά", + "settings.ai.localDiagnostic.models": "Μοντέλα", + "settings.ai.localDiagnostic.tauriHttp": "Εγγενές HTTP Tauri", + "settings.ai.localDiagnostic.browserFetch": "Αίτημα προγράμματος περιήγησης", "settings.ai.providerStatusUnavailableBrowser": "Μη διαθέσιμο στο πρόγραμμα περιήγησης", "settings.ai.providerTitle": "Πάροχος AI", "settings.ai.providerTransformers": "Transformers.js", diff --git a/locales/en/settings.json b/locales/en/settings.json index 373c171a..47b3c718 100644 --- a/locales/en/settings.json +++ b/locales/en/settings.json @@ -231,6 +231,13 @@ "settings.ai.providerStatusLabel": "Connection Status", "settings.ai.providerStatusReady": "Ready", "settings.ai.providerStatusNotTested": "Not tested", + "settings.ai.providerStatusTesting": "Testing…", + "settings.ai.localDiagnostic.title": "Connection diagnostics", + "settings.ai.localDiagnostic.endpoint": "Endpoint", + "settings.ai.localDiagnostic.transport": "Transport", + "settings.ai.localDiagnostic.models": "Models", + "settings.ai.localDiagnostic.tauriHttp": "Tauri native HTTP", + "settings.ai.localDiagnostic.browserFetch": "Browser fetch", "settings.ai.providerStatusUnavailableBrowser": "Not available in browser", "settings.ai.providerTitle": "AI Provider", "settings.ai.providerTransformers": "Transformers.js", diff --git a/locales/es/settings.json b/locales/es/settings.json index df1d1336..16d49970 100644 --- a/locales/es/settings.json +++ b/locales/es/settings.json @@ -231,6 +231,13 @@ "settings.ai.providerStatusLabel": "Estado de conexión", "settings.ai.providerStatusReady": "Listo", "settings.ai.providerStatusNotTested": "Sin probar", + "settings.ai.providerStatusTesting": "Probando…", + "settings.ai.localDiagnostic.title": "Diagnóstico de conexión", + "settings.ai.localDiagnostic.endpoint": "Punto de conexión", + "settings.ai.localDiagnostic.transport": "Transporte", + "settings.ai.localDiagnostic.models": "Modelos", + "settings.ai.localDiagnostic.tauriHttp": "HTTP nativo de Tauri", + "settings.ai.localDiagnostic.browserFetch": "Solicitud del navegador", "settings.ai.providerStatusUnavailableBrowser": "No disponible en el navegador", "settings.ai.providerTitle": "Proveedor de IA", "settings.ai.providerTransformers": "Transformers.js", diff --git a/locales/eu/settings.json b/locales/eu/settings.json index 27c1d967..bb8fd487 100644 --- a/locales/eu/settings.json +++ b/locales/eu/settings.json @@ -231,6 +231,13 @@ "settings.ai.providerStatusLabel": "Konexioaren egoera", "settings.ai.providerStatusReady": "Prest", "settings.ai.providerStatusNotTested": "Probatu gabe", + "settings.ai.providerStatusTesting": "Probatzen…", + "settings.ai.localDiagnostic.title": "Konexio-diagnostikoa", + "settings.ai.localDiagnostic.endpoint": "Amaiera-puntua", + "settings.ai.localDiagnostic.transport": "Garraioa", + "settings.ai.localDiagnostic.models": "Ereduak", + "settings.ai.localDiagnostic.tauriHttp": "Tauriren jatorrizko HTTPa", + "settings.ai.localDiagnostic.browserFetch": "Arakatzailearen eskaera", "settings.ai.providerStatusUnavailableBrowser": "Ez dago erabilgarri nabigatzailean", "settings.ai.providerTitle": "AI hornitzailea", "settings.ai.providerTransformers": "Transformers.js", diff --git a/locales/fa/settings.json b/locales/fa/settings.json index 4ceee6ff..2585b4af 100644 --- a/locales/fa/settings.json +++ b/locales/fa/settings.json @@ -231,6 +231,13 @@ "settings.ai.providerStatusLabel": "وضعیت اتصال", "settings.ai.providerStatusReady": "آماده است", "settings.ai.providerStatusNotTested": "آزمایش نشده", + "settings.ai.providerStatusTesting": "در حال آزمایش…", + "settings.ai.localDiagnostic.title": "عیب‌یابی اتصال", + "settings.ai.localDiagnostic.endpoint": "نقطه پایانی", + "settings.ai.localDiagnostic.transport": "انتقال", + "settings.ai.localDiagnostic.models": "مدل‌ها", + "settings.ai.localDiagnostic.tauriHttp": "HTTP بومی Tauri", + "settings.ai.localDiagnostic.browserFetch": "دریافت مرورگر", "settings.ai.providerStatusUnavailableBrowser": "در مرورگر در دسترس نیست", "settings.ai.providerTitle": "ارائه دهنده هوش مصنوعی", "settings.ai.providerTransformers": "Transformers.js", diff --git a/locales/fi/settings.json b/locales/fi/settings.json index 69bfa90d..3ce432b2 100644 --- a/locales/fi/settings.json +++ b/locales/fi/settings.json @@ -231,6 +231,13 @@ "settings.ai.providerStatusLabel": "Yhteyden tila", "settings.ai.providerStatusReady": "Valmis", "settings.ai.providerStatusNotTested": "Ei testattu", + "settings.ai.providerStatusTesting": "Testataan…", + "settings.ai.localDiagnostic.title": "Yhteysdiagnostiikka", + "settings.ai.localDiagnostic.endpoint": "Päätepiste", + "settings.ai.localDiagnostic.transport": "Siirto", + "settings.ai.localDiagnostic.models": "Mallit", + "settings.ai.localDiagnostic.tauriHttp": "Taurin natiivi HTTP", + "settings.ai.localDiagnostic.browserFetch": "Selaimen pyyntö", "settings.ai.providerStatusUnavailableBrowser": "Ei saatavilla selaimessa", "settings.ai.providerTitle": "AI Provider", "settings.ai.providerTransformers": "Transformers.js", diff --git a/locales/fr/settings.json b/locales/fr/settings.json index 4123b1f3..f08d2242 100644 --- a/locales/fr/settings.json +++ b/locales/fr/settings.json @@ -231,6 +231,13 @@ "settings.ai.providerStatusLabel": "État de la connexion", "settings.ai.providerStatusReady": "Prêt", "settings.ai.providerStatusNotTested": "Non testé", + "settings.ai.providerStatusTesting": "Test en cours…", + "settings.ai.localDiagnostic.title": "Diagnostic de connexion", + "settings.ai.localDiagnostic.endpoint": "Point de terminaison", + "settings.ai.localDiagnostic.transport": "Transport", + "settings.ai.localDiagnostic.models": "Modèles", + "settings.ai.localDiagnostic.tauriHttp": "HTTP natif Tauri", + "settings.ai.localDiagnostic.browserFetch": "Requête du navigateur", "settings.ai.providerStatusUnavailableBrowser": "Non disponible dans le navigateur", "settings.ai.providerTitle": "Fournisseur d'IA", "settings.ai.providerTransformers": "Transformers.js", diff --git a/locales/he/settings.json b/locales/he/settings.json index bc95ae5e..066abae7 100644 --- a/locales/he/settings.json +++ b/locales/he/settings.json @@ -231,6 +231,13 @@ "settings.ai.providerStatusLabel": "סטטוס חיבור", "settings.ai.providerStatusReady": "מוכן", "settings.ai.providerStatusNotTested": "לא נבדק", + "settings.ai.providerStatusTesting": "בודק…", + "settings.ai.localDiagnostic.title": "אבחון חיבור", + "settings.ai.localDiagnostic.endpoint": "נקודת קצה", + "settings.ai.localDiagnostic.transport": "תעבורה", + "settings.ai.localDiagnostic.models": "מודלים", + "settings.ai.localDiagnostic.tauriHttp": "HTTP מקורי של Tauri", + "settings.ai.localDiagnostic.browserFetch": "בקשת דפדפן", "settings.ai.providerStatusUnavailableBrowser": "לא זמין בדפדפן", "settings.ai.providerTitle": "ספק AI", "settings.ai.providerTransformers": "Transformers.js", diff --git a/locales/hu/settings.json b/locales/hu/settings.json index 91e629a4..50b8f94f 100644 --- a/locales/hu/settings.json +++ b/locales/hu/settings.json @@ -231,6 +231,13 @@ "settings.ai.providerStatusLabel": "Kapcsolat állapota", "settings.ai.providerStatusReady": "Kész", "settings.ai.providerStatusNotTested": "Nincs tesztelve", + "settings.ai.providerStatusTesting": "Tesztelés…", + "settings.ai.localDiagnostic.title": "Kapcsolati diagnosztika", + "settings.ai.localDiagnostic.endpoint": "Végpont", + "settings.ai.localDiagnostic.transport": "Átvitel", + "settings.ai.localDiagnostic.models": "Modellek", + "settings.ai.localDiagnostic.tauriHttp": "Tauri natív HTTP", + "settings.ai.localDiagnostic.browserFetch": "Böngészőlekérés", "settings.ai.providerStatusUnavailableBrowser": "Böngészőben nem érhető el", "settings.ai.providerTitle": "AI szolgáltató", "settings.ai.providerTransformers": "Transformers.js", diff --git a/locales/is/settings.json b/locales/is/settings.json index ac49799b..89d48005 100644 --- a/locales/is/settings.json +++ b/locales/is/settings.json @@ -231,6 +231,13 @@ "settings.ai.providerStatusLabel": "Tengingarstaða", "settings.ai.providerStatusReady": "Tilbúið", "settings.ai.providerStatusNotTested": "Ekki prófað", + "settings.ai.providerStatusTesting": "Prófar…", + "settings.ai.localDiagnostic.title": "Tengigreining", + "settings.ai.localDiagnostic.endpoint": "Endapunktur", + "settings.ai.localDiagnostic.transport": "Flutningur", + "settings.ai.localDiagnostic.models": "Líkön", + "settings.ai.localDiagnostic.tauriHttp": "Innbyggt Tauri HTTP", + "settings.ai.localDiagnostic.browserFetch": "Vafrafyrirspurn", "settings.ai.providerStatusUnavailableBrowser": "Ekki í boði í vafra", "settings.ai.providerTitle": "AI veitandi", "settings.ai.providerTransformers": "Transformers.js", diff --git a/locales/it/settings.json b/locales/it/settings.json index 4fd879e4..15974575 100644 --- a/locales/it/settings.json +++ b/locales/it/settings.json @@ -231,6 +231,13 @@ "settings.ai.providerStatusLabel": "Stato della connessione", "settings.ai.providerStatusReady": "Pronto", "settings.ai.providerStatusNotTested": "Non testato", + "settings.ai.providerStatusTesting": "Verifica in corso…", + "settings.ai.localDiagnostic.title": "Diagnostica della connessione", + "settings.ai.localDiagnostic.endpoint": "Endpoint", + "settings.ai.localDiagnostic.transport": "Trasporto", + "settings.ai.localDiagnostic.models": "Modelli", + "settings.ai.localDiagnostic.tauriHttp": "HTTP nativo di Tauri", + "settings.ai.localDiagnostic.browserFetch": "Richiesta del browser", "settings.ai.providerStatusUnavailableBrowser": "Non disponibile nel browser", "settings.ai.providerTitle": "Provider IA", "settings.ai.providerTransformers": "Transformers.js", diff --git a/locales/ja/settings.json b/locales/ja/settings.json index b29fb581..3f7f6109 100644 --- a/locales/ja/settings.json +++ b/locales/ja/settings.json @@ -231,6 +231,13 @@ "settings.ai.providerStatusLabel": "接続ステータス", "settings.ai.providerStatusReady": "準備ができて", "settings.ai.providerStatusNotTested": "未テスト", + "settings.ai.providerStatusTesting": "テスト中…", + "settings.ai.localDiagnostic.title": "接続診断", + "settings.ai.localDiagnostic.endpoint": "エンドポイント", + "settings.ai.localDiagnostic.transport": "通信方式", + "settings.ai.localDiagnostic.models": "モデル", + "settings.ai.localDiagnostic.tauriHttp": "Tauri ネイティブ HTTP", + "settings.ai.localDiagnostic.browserFetch": "ブラウザー取得", "settings.ai.providerStatusUnavailableBrowser": "ブラウザでは利用できません", "settings.ai.providerTitle": "AIプロバイダー", "settings.ai.providerTransformers": "トランスフォーマー.js", diff --git a/locales/ko/settings.json b/locales/ko/settings.json index 06bc858c..84548a36 100644 --- a/locales/ko/settings.json +++ b/locales/ko/settings.json @@ -231,6 +231,13 @@ "settings.ai.providerStatusLabel": "연결 상태", "settings.ai.providerStatusReady": "준비가 된", "settings.ai.providerStatusNotTested": "테스트되지 않음", + "settings.ai.providerStatusTesting": "테스트 중…", + "settings.ai.localDiagnostic.title": "연결 진단", + "settings.ai.localDiagnostic.endpoint": "엔드포인트", + "settings.ai.localDiagnostic.transport": "전송 방식", + "settings.ai.localDiagnostic.models": "모델", + "settings.ai.localDiagnostic.tauriHttp": "Tauri 네이티브 HTTP", + "settings.ai.localDiagnostic.browserFetch": "브라우저 가져오기", "settings.ai.providerStatusUnavailableBrowser": "브라우저에서 사용할 수 없음", "settings.ai.providerTitle": "AI 제공업체", "settings.ai.providerTransformers": "Transformers.js", diff --git a/locales/pt/settings.json b/locales/pt/settings.json index d5248025..bb2ae258 100644 --- a/locales/pt/settings.json +++ b/locales/pt/settings.json @@ -231,6 +231,13 @@ "settings.ai.providerStatusLabel": "Status da conexão", "settings.ai.providerStatusReady": "Preparar", "settings.ai.providerStatusNotTested": "Não testado", + "settings.ai.providerStatusTesting": "Testando…", + "settings.ai.localDiagnostic.title": "Diagnóstico de conexão", + "settings.ai.localDiagnostic.endpoint": "Ponto de extremidade", + "settings.ai.localDiagnostic.transport": "Transporte", + "settings.ai.localDiagnostic.models": "Modelos", + "settings.ai.localDiagnostic.tauriHttp": "HTTP nativo do Tauri", + "settings.ai.localDiagnostic.browserFetch": "Solicitação do navegador", "settings.ai.providerStatusUnavailableBrowser": "Não disponível no navegador", "settings.ai.providerTitle": "IA Provider", "settings.ai.providerTransformers": "Transformadores.js", diff --git a/locales/ru/settings.json b/locales/ru/settings.json index c2d473df..39201c23 100644 --- a/locales/ru/settings.json +++ b/locales/ru/settings.json @@ -231,6 +231,13 @@ "settings.ai.providerStatusLabel": "Статус соединения", "settings.ai.providerStatusReady": "Готовый", "settings.ai.providerStatusNotTested": "Не проверено", + "settings.ai.providerStatusTesting": "Проверка…", + "settings.ai.localDiagnostic.title": "Диагностика подключения", + "settings.ai.localDiagnostic.endpoint": "Конечная точка", + "settings.ai.localDiagnostic.transport": "Транспорт", + "settings.ai.localDiagnostic.models": "Модели", + "settings.ai.localDiagnostic.tauriHttp": "Нативный HTTP Tauri", + "settings.ai.localDiagnostic.browserFetch": "Запрос браузера", "settings.ai.providerStatusUnavailableBrowser": "Недоступно в браузере", "settings.ai.providerTitle": "Поставщик ИИ", "settings.ai.providerTransformers": "Трансформеры.js", diff --git a/locales/sv/settings.json b/locales/sv/settings.json index 4ba20e7b..e43e0b7e 100644 --- a/locales/sv/settings.json +++ b/locales/sv/settings.json @@ -231,6 +231,13 @@ "settings.ai.providerStatusLabel": "Anslutningsstatus", "settings.ai.providerStatusReady": "Redo", "settings.ai.providerStatusNotTested": "Inte testad", + "settings.ai.providerStatusTesting": "Testar…", + "settings.ai.localDiagnostic.title": "Anslutningsdiagnostik", + "settings.ai.localDiagnostic.endpoint": "Slutpunkt", + "settings.ai.localDiagnostic.transport": "Transport", + "settings.ai.localDiagnostic.models": "Modeller", + "settings.ai.localDiagnostic.tauriHttp": "Tauris inbyggda HTTP", + "settings.ai.localDiagnostic.browserFetch": "Webbläsarhämtning", "settings.ai.providerStatusUnavailableBrowser": "Inte tillgängligt i webbläsaren", "settings.ai.providerTitle": "AI-leverantör", "settings.ai.providerTransformers": "Transformers.js", diff --git a/locales/zh/settings.json b/locales/zh/settings.json index afbd25a8..265294cb 100644 --- a/locales/zh/settings.json +++ b/locales/zh/settings.json @@ -231,6 +231,13 @@ "settings.ai.providerStatusLabel": "连接状态", "settings.ai.providerStatusReady": "准备好", "settings.ai.providerStatusNotTested": "未测试", + "settings.ai.providerStatusTesting": "测试中…", + "settings.ai.localDiagnostic.title": "连接诊断", + "settings.ai.localDiagnostic.endpoint": "端点", + "settings.ai.localDiagnostic.transport": "传输方式", + "settings.ai.localDiagnostic.models": "模型", + "settings.ai.localDiagnostic.tauriHttp": "Tauri 原生 HTTP", + "settings.ai.localDiagnostic.browserFetch": "浏览器请求", "settings.ai.providerStatusUnavailableBrowser": "浏览器中不可用", "settings.ai.providerTitle": "人工智能提供商", "settings.ai.providerTransformers": "Transformers.js", diff --git a/public/locales/ar/bundle.json b/public/locales/ar/bundle.json index 866a9918..718b5f6e 100644 --- a/public/locales/ar/bundle.json +++ b/public/locales/ar/bundle.json @@ -1877,6 +1877,13 @@ "settings.ai.providerStatusLabel": "حالة الاتصال", "settings.ai.providerStatusReady": "جاهز", "settings.ai.providerStatusNotTested": "لم يتم الاختبار", + "settings.ai.providerStatusTesting": "جارٍ الاختبار…", + "settings.ai.localDiagnostic.title": "تشخيص الاتصال", + "settings.ai.localDiagnostic.endpoint": "نقطة النهاية", + "settings.ai.localDiagnostic.transport": "النقل", + "settings.ai.localDiagnostic.models": "النماذج", + "settings.ai.localDiagnostic.tauriHttp": "HTTP الأصلي من Tauri", + "settings.ai.localDiagnostic.browserFetch": "طلب المتصفح", "settings.ai.providerStatusUnavailableBrowser": "غير متوفر في المتصفح", "settings.ai.providerTitle": "مزوّد الذكاء الاصطناعي", "settings.ai.providerTransformers": "Transformers.js", diff --git a/public/locales/de/bundle.json b/public/locales/de/bundle.json index 9c593dd6..4768536c 100644 --- a/public/locales/de/bundle.json +++ b/public/locales/de/bundle.json @@ -1877,6 +1877,13 @@ "settings.ai.providerStatusLabel": "Verbindungsstatus", "settings.ai.providerStatusReady": "Bereit", "settings.ai.providerStatusNotTested": "Nicht getestet", + "settings.ai.providerStatusTesting": "Wird getestet…", + "settings.ai.localDiagnostic.title": "Verbindungsdiagnose", + "settings.ai.localDiagnostic.endpoint": "Endpunkt", + "settings.ai.localDiagnostic.transport": "Transport", + "settings.ai.localDiagnostic.models": "Modelle", + "settings.ai.localDiagnostic.tauriHttp": "Nativer Tauri-HTTP", + "settings.ai.localDiagnostic.browserFetch": "Browser-Abruf", "settings.ai.providerStatusUnavailableBrowser": "Im Browser nicht verfügbar", "settings.ai.providerTitle": "KI-Anbieter", "settings.ai.providerTransformers": "Transformers.js", diff --git a/public/locales/el/bundle.json b/public/locales/el/bundle.json index 87fbc1c4..40b034c1 100644 --- a/public/locales/el/bundle.json +++ b/public/locales/el/bundle.json @@ -1877,6 +1877,13 @@ "settings.ai.providerStatusLabel": "Κατάσταση σύνδεσης", "settings.ai.providerStatusReady": "Ετοιμος", "settings.ai.providerStatusNotTested": "Δεν έχει δοκιμαστεί", + "settings.ai.providerStatusTesting": "Δοκιμή σε εξέλιξη…", + "settings.ai.localDiagnostic.title": "Διαγνωστικά σύνδεσης", + "settings.ai.localDiagnostic.endpoint": "Τελικό σημείο", + "settings.ai.localDiagnostic.transport": "Μεταφορά", + "settings.ai.localDiagnostic.models": "Μοντέλα", + "settings.ai.localDiagnostic.tauriHttp": "Εγγενές HTTP Tauri", + "settings.ai.localDiagnostic.browserFetch": "Αίτημα προγράμματος περιήγησης", "settings.ai.providerStatusUnavailableBrowser": "Μη διαθέσιμο στο πρόγραμμα περιήγησης", "settings.ai.providerTitle": "Πάροχος AI", "settings.ai.providerTransformers": "Transformers.js", diff --git a/public/locales/en/bundle.json b/public/locales/en/bundle.json index 941368e2..e906f12a 100644 --- a/public/locales/en/bundle.json +++ b/public/locales/en/bundle.json @@ -1877,6 +1877,13 @@ "settings.ai.providerStatusLabel": "Connection Status", "settings.ai.providerStatusReady": "Ready", "settings.ai.providerStatusNotTested": "Not tested", + "settings.ai.providerStatusTesting": "Testing…", + "settings.ai.localDiagnostic.title": "Connection diagnostics", + "settings.ai.localDiagnostic.endpoint": "Endpoint", + "settings.ai.localDiagnostic.transport": "Transport", + "settings.ai.localDiagnostic.models": "Models", + "settings.ai.localDiagnostic.tauriHttp": "Tauri native HTTP", + "settings.ai.localDiagnostic.browserFetch": "Browser fetch", "settings.ai.providerStatusUnavailableBrowser": "Not available in browser", "settings.ai.providerTitle": "AI Provider", "settings.ai.providerTransformers": "Transformers.js", diff --git a/public/locales/es/bundle.json b/public/locales/es/bundle.json index 9810f96e..1772ec5a 100644 --- a/public/locales/es/bundle.json +++ b/public/locales/es/bundle.json @@ -1877,6 +1877,13 @@ "settings.ai.providerStatusLabel": "Estado de conexión", "settings.ai.providerStatusReady": "Listo", "settings.ai.providerStatusNotTested": "Sin probar", + "settings.ai.providerStatusTesting": "Probando…", + "settings.ai.localDiagnostic.title": "Diagnóstico de conexión", + "settings.ai.localDiagnostic.endpoint": "Punto de conexión", + "settings.ai.localDiagnostic.transport": "Transporte", + "settings.ai.localDiagnostic.models": "Modelos", + "settings.ai.localDiagnostic.tauriHttp": "HTTP nativo de Tauri", + "settings.ai.localDiagnostic.browserFetch": "Solicitud del navegador", "settings.ai.providerStatusUnavailableBrowser": "No disponible en el navegador", "settings.ai.providerTitle": "Proveedor de IA", "settings.ai.providerTransformers": "Transformers.js", diff --git a/public/locales/eu/bundle.json b/public/locales/eu/bundle.json index eebc343b..b7f5f0a8 100644 --- a/public/locales/eu/bundle.json +++ b/public/locales/eu/bundle.json @@ -1877,6 +1877,13 @@ "settings.ai.providerStatusLabel": "Konexioaren egoera", "settings.ai.providerStatusReady": "Prest", "settings.ai.providerStatusNotTested": "Probatu gabe", + "settings.ai.providerStatusTesting": "Probatzen…", + "settings.ai.localDiagnostic.title": "Konexio-diagnostikoa", + "settings.ai.localDiagnostic.endpoint": "Amaiera-puntua", + "settings.ai.localDiagnostic.transport": "Garraioa", + "settings.ai.localDiagnostic.models": "Ereduak", + "settings.ai.localDiagnostic.tauriHttp": "Tauriren jatorrizko HTTPa", + "settings.ai.localDiagnostic.browserFetch": "Arakatzailearen eskaera", "settings.ai.providerStatusUnavailableBrowser": "Ez dago erabilgarri nabigatzailean", "settings.ai.providerTitle": "AI hornitzailea", "settings.ai.providerTransformers": "Transformers.js", diff --git a/public/locales/fa/bundle.json b/public/locales/fa/bundle.json index 35c7ece3..e95911fd 100644 --- a/public/locales/fa/bundle.json +++ b/public/locales/fa/bundle.json @@ -1877,6 +1877,13 @@ "settings.ai.providerStatusLabel": "وضعیت اتصال", "settings.ai.providerStatusReady": "آماده است", "settings.ai.providerStatusNotTested": "آزمایش نشده", + "settings.ai.providerStatusTesting": "در حال آزمایش…", + "settings.ai.localDiagnostic.title": "عیب‌یابی اتصال", + "settings.ai.localDiagnostic.endpoint": "نقطه پایانی", + "settings.ai.localDiagnostic.transport": "انتقال", + "settings.ai.localDiagnostic.models": "مدل‌ها", + "settings.ai.localDiagnostic.tauriHttp": "HTTP بومی Tauri", + "settings.ai.localDiagnostic.browserFetch": "دریافت مرورگر", "settings.ai.providerStatusUnavailableBrowser": "در مرورگر در دسترس نیست", "settings.ai.providerTitle": "ارائه دهنده هوش مصنوعی", "settings.ai.providerTransformers": "Transformers.js", diff --git a/public/locales/fi/bundle.json b/public/locales/fi/bundle.json index e5ca26b7..9a5dff75 100644 --- a/public/locales/fi/bundle.json +++ b/public/locales/fi/bundle.json @@ -1877,6 +1877,13 @@ "settings.ai.providerStatusLabel": "Yhteyden tila", "settings.ai.providerStatusReady": "Valmis", "settings.ai.providerStatusNotTested": "Ei testattu", + "settings.ai.providerStatusTesting": "Testataan…", + "settings.ai.localDiagnostic.title": "Yhteysdiagnostiikka", + "settings.ai.localDiagnostic.endpoint": "Päätepiste", + "settings.ai.localDiagnostic.transport": "Siirto", + "settings.ai.localDiagnostic.models": "Mallit", + "settings.ai.localDiagnostic.tauriHttp": "Taurin natiivi HTTP", + "settings.ai.localDiagnostic.browserFetch": "Selaimen pyyntö", "settings.ai.providerStatusUnavailableBrowser": "Ei saatavilla selaimessa", "settings.ai.providerTitle": "AI Provider", "settings.ai.providerTransformers": "Transformers.js", diff --git a/public/locales/fr/bundle.json b/public/locales/fr/bundle.json index 71530714..1e77267c 100644 --- a/public/locales/fr/bundle.json +++ b/public/locales/fr/bundle.json @@ -1877,6 +1877,13 @@ "settings.ai.providerStatusLabel": "État de la connexion", "settings.ai.providerStatusReady": "Prêt", "settings.ai.providerStatusNotTested": "Non testé", + "settings.ai.providerStatusTesting": "Test en cours…", + "settings.ai.localDiagnostic.title": "Diagnostic de connexion", + "settings.ai.localDiagnostic.endpoint": "Point de terminaison", + "settings.ai.localDiagnostic.transport": "Transport", + "settings.ai.localDiagnostic.models": "Modèles", + "settings.ai.localDiagnostic.tauriHttp": "HTTP natif Tauri", + "settings.ai.localDiagnostic.browserFetch": "Requête du navigateur", "settings.ai.providerStatusUnavailableBrowser": "Non disponible dans le navigateur", "settings.ai.providerTitle": "Fournisseur d'IA", "settings.ai.providerTransformers": "Transformers.js", diff --git a/public/locales/he/bundle.json b/public/locales/he/bundle.json index d46058c3..42f10599 100644 --- a/public/locales/he/bundle.json +++ b/public/locales/he/bundle.json @@ -1877,6 +1877,13 @@ "settings.ai.providerStatusLabel": "סטטוס חיבור", "settings.ai.providerStatusReady": "מוכן", "settings.ai.providerStatusNotTested": "לא נבדק", + "settings.ai.providerStatusTesting": "בודק…", + "settings.ai.localDiagnostic.title": "אבחון חיבור", + "settings.ai.localDiagnostic.endpoint": "נקודת קצה", + "settings.ai.localDiagnostic.transport": "תעבורה", + "settings.ai.localDiagnostic.models": "מודלים", + "settings.ai.localDiagnostic.tauriHttp": "HTTP מקורי של Tauri", + "settings.ai.localDiagnostic.browserFetch": "בקשת דפדפן", "settings.ai.providerStatusUnavailableBrowser": "לא זמין בדפדפן", "settings.ai.providerTitle": "ספק AI", "settings.ai.providerTransformers": "Transformers.js", diff --git a/public/locales/hu/bundle.json b/public/locales/hu/bundle.json index 9cef31cc..ba63457a 100644 --- a/public/locales/hu/bundle.json +++ b/public/locales/hu/bundle.json @@ -1877,6 +1877,13 @@ "settings.ai.providerStatusLabel": "Kapcsolat állapota", "settings.ai.providerStatusReady": "Kész", "settings.ai.providerStatusNotTested": "Nincs tesztelve", + "settings.ai.providerStatusTesting": "Tesztelés…", + "settings.ai.localDiagnostic.title": "Kapcsolati diagnosztika", + "settings.ai.localDiagnostic.endpoint": "Végpont", + "settings.ai.localDiagnostic.transport": "Átvitel", + "settings.ai.localDiagnostic.models": "Modellek", + "settings.ai.localDiagnostic.tauriHttp": "Tauri natív HTTP", + "settings.ai.localDiagnostic.browserFetch": "Böngészőlekérés", "settings.ai.providerStatusUnavailableBrowser": "Böngészőben nem érhető el", "settings.ai.providerTitle": "AI szolgáltató", "settings.ai.providerTransformers": "Transformers.js", diff --git a/public/locales/is/bundle.json b/public/locales/is/bundle.json index f829b489..cfc496bd 100644 --- a/public/locales/is/bundle.json +++ b/public/locales/is/bundle.json @@ -1877,6 +1877,13 @@ "settings.ai.providerStatusLabel": "Tengingarstaða", "settings.ai.providerStatusReady": "Tilbúið", "settings.ai.providerStatusNotTested": "Ekki prófað", + "settings.ai.providerStatusTesting": "Prófar…", + "settings.ai.localDiagnostic.title": "Tengigreining", + "settings.ai.localDiagnostic.endpoint": "Endapunktur", + "settings.ai.localDiagnostic.transport": "Flutningur", + "settings.ai.localDiagnostic.models": "Líkön", + "settings.ai.localDiagnostic.tauriHttp": "Innbyggt Tauri HTTP", + "settings.ai.localDiagnostic.browserFetch": "Vafrafyrirspurn", "settings.ai.providerStatusUnavailableBrowser": "Ekki í boði í vafra", "settings.ai.providerTitle": "AI veitandi", "settings.ai.providerTransformers": "Transformers.js", diff --git a/public/locales/it/bundle.json b/public/locales/it/bundle.json index 0fee7eaa..b9a3f983 100644 --- a/public/locales/it/bundle.json +++ b/public/locales/it/bundle.json @@ -1877,6 +1877,13 @@ "settings.ai.providerStatusLabel": "Stato della connessione", "settings.ai.providerStatusReady": "Pronto", "settings.ai.providerStatusNotTested": "Non testato", + "settings.ai.providerStatusTesting": "Verifica in corso…", + "settings.ai.localDiagnostic.title": "Diagnostica della connessione", + "settings.ai.localDiagnostic.endpoint": "Endpoint", + "settings.ai.localDiagnostic.transport": "Trasporto", + "settings.ai.localDiagnostic.models": "Modelli", + "settings.ai.localDiagnostic.tauriHttp": "HTTP nativo di Tauri", + "settings.ai.localDiagnostic.browserFetch": "Richiesta del browser", "settings.ai.providerStatusUnavailableBrowser": "Non disponibile nel browser", "settings.ai.providerTitle": "Provider IA", "settings.ai.providerTransformers": "Transformers.js", diff --git a/public/locales/ja/bundle.json b/public/locales/ja/bundle.json index fb5b66ae..9b8b0738 100644 --- a/public/locales/ja/bundle.json +++ b/public/locales/ja/bundle.json @@ -1877,6 +1877,13 @@ "settings.ai.providerStatusLabel": "接続ステータス", "settings.ai.providerStatusReady": "準備ができて", "settings.ai.providerStatusNotTested": "未テスト", + "settings.ai.providerStatusTesting": "テスト中…", + "settings.ai.localDiagnostic.title": "接続診断", + "settings.ai.localDiagnostic.endpoint": "エンドポイント", + "settings.ai.localDiagnostic.transport": "通信方式", + "settings.ai.localDiagnostic.models": "モデル", + "settings.ai.localDiagnostic.tauriHttp": "Tauri ネイティブ HTTP", + "settings.ai.localDiagnostic.browserFetch": "ブラウザー取得", "settings.ai.providerStatusUnavailableBrowser": "ブラウザでは利用できません", "settings.ai.providerTitle": "AIプロバイダー", "settings.ai.providerTransformers": "トランスフォーマー.js", diff --git a/public/locales/ko/bundle.json b/public/locales/ko/bundle.json index 79a2d242..4cae2b7b 100644 --- a/public/locales/ko/bundle.json +++ b/public/locales/ko/bundle.json @@ -1877,6 +1877,13 @@ "settings.ai.providerStatusLabel": "연결 상태", "settings.ai.providerStatusReady": "준비가 된", "settings.ai.providerStatusNotTested": "테스트되지 않음", + "settings.ai.providerStatusTesting": "테스트 중…", + "settings.ai.localDiagnostic.title": "연결 진단", + "settings.ai.localDiagnostic.endpoint": "엔드포인트", + "settings.ai.localDiagnostic.transport": "전송 방식", + "settings.ai.localDiagnostic.models": "모델", + "settings.ai.localDiagnostic.tauriHttp": "Tauri 네이티브 HTTP", + "settings.ai.localDiagnostic.browserFetch": "브라우저 가져오기", "settings.ai.providerStatusUnavailableBrowser": "브라우저에서 사용할 수 없음", "settings.ai.providerTitle": "AI 제공업체", "settings.ai.providerTransformers": "Transformers.js", diff --git a/public/locales/pt/bundle.json b/public/locales/pt/bundle.json index 79c848d8..df71dc8d 100644 --- a/public/locales/pt/bundle.json +++ b/public/locales/pt/bundle.json @@ -1877,6 +1877,13 @@ "settings.ai.providerStatusLabel": "Status da conexão", "settings.ai.providerStatusReady": "Preparar", "settings.ai.providerStatusNotTested": "Não testado", + "settings.ai.providerStatusTesting": "Testando…", + "settings.ai.localDiagnostic.title": "Diagnóstico de conexão", + "settings.ai.localDiagnostic.endpoint": "Ponto de extremidade", + "settings.ai.localDiagnostic.transport": "Transporte", + "settings.ai.localDiagnostic.models": "Modelos", + "settings.ai.localDiagnostic.tauriHttp": "HTTP nativo do Tauri", + "settings.ai.localDiagnostic.browserFetch": "Solicitação do navegador", "settings.ai.providerStatusUnavailableBrowser": "Não disponível no navegador", "settings.ai.providerTitle": "IA Provider", "settings.ai.providerTransformers": "Transformadores.js", diff --git a/public/locales/ru/bundle.json b/public/locales/ru/bundle.json index d8b1669c..950646aa 100644 --- a/public/locales/ru/bundle.json +++ b/public/locales/ru/bundle.json @@ -1877,6 +1877,13 @@ "settings.ai.providerStatusLabel": "Статус соединения", "settings.ai.providerStatusReady": "Готовый", "settings.ai.providerStatusNotTested": "Не проверено", + "settings.ai.providerStatusTesting": "Проверка…", + "settings.ai.localDiagnostic.title": "Диагностика подключения", + "settings.ai.localDiagnostic.endpoint": "Конечная точка", + "settings.ai.localDiagnostic.transport": "Транспорт", + "settings.ai.localDiagnostic.models": "Модели", + "settings.ai.localDiagnostic.tauriHttp": "Нативный HTTP Tauri", + "settings.ai.localDiagnostic.browserFetch": "Запрос браузера", "settings.ai.providerStatusUnavailableBrowser": "Недоступно в браузере", "settings.ai.providerTitle": "Поставщик ИИ", "settings.ai.providerTransformers": "Трансформеры.js", diff --git a/public/locales/sv/bundle.json b/public/locales/sv/bundle.json index 0a310c93..33ec166b 100644 --- a/public/locales/sv/bundle.json +++ b/public/locales/sv/bundle.json @@ -1877,6 +1877,13 @@ "settings.ai.providerStatusLabel": "Anslutningsstatus", "settings.ai.providerStatusReady": "Redo", "settings.ai.providerStatusNotTested": "Inte testad", + "settings.ai.providerStatusTesting": "Testar…", + "settings.ai.localDiagnostic.title": "Anslutningsdiagnostik", + "settings.ai.localDiagnostic.endpoint": "Slutpunkt", + "settings.ai.localDiagnostic.transport": "Transport", + "settings.ai.localDiagnostic.models": "Modeller", + "settings.ai.localDiagnostic.tauriHttp": "Tauris inbyggda HTTP", + "settings.ai.localDiagnostic.browserFetch": "Webbläsarhämtning", "settings.ai.providerStatusUnavailableBrowser": "Inte tillgängligt i webbläsaren", "settings.ai.providerTitle": "AI-leverantör", "settings.ai.providerTransformers": "Transformers.js", diff --git a/public/locales/zh/bundle.json b/public/locales/zh/bundle.json index ef01fb78..226d5ccd 100644 --- a/public/locales/zh/bundle.json +++ b/public/locales/zh/bundle.json @@ -1877,6 +1877,13 @@ "settings.ai.providerStatusLabel": "连接状态", "settings.ai.providerStatusReady": "准备好", "settings.ai.providerStatusNotTested": "未测试", + "settings.ai.providerStatusTesting": "测试中…", + "settings.ai.localDiagnostic.title": "连接诊断", + "settings.ai.localDiagnostic.endpoint": "端点", + "settings.ai.localDiagnostic.transport": "传输方式", + "settings.ai.localDiagnostic.models": "模型", + "settings.ai.localDiagnostic.tauriHttp": "Tauri 原生 HTTP", + "settings.ai.localDiagnostic.browserFetch": "浏览器请求", "settings.ai.providerStatusUnavailableBrowser": "浏览器中不可用", "settings.ai.providerTitle": "人工智能提供商", "settings.ai.providerTransformers": "Transformers.js", diff --git a/services/ai/aiInferenceCacheService.ts b/services/ai/aiInferenceCacheService.ts index 6863f218..dd516312 100644 --- a/services/ai/aiInferenceCacheService.ts +++ b/services/ai/aiInferenceCacheService.ts @@ -1,43 +1,78 @@ -// QNBS-v3: Two-layer inference cache — in-memory LRU for hot paths, IndexedDB for persistence. -// Adapted from CannaGuide-2025 cacheService.ts patterns for WorldScript creative context. +// QNBS-v3: Two-layer inference cache keeps hot reads in memory while the durable layer is encrypted. +import { logger } from '../logger'; +import { + assertSecureStorageReadable, + assertSecureStorageWritableForMutation, + prepareSecureRecordPayload, + readSecureRecordPayload, + SecureRecordCorruptError, + type SecureRecordEnvelope, +} from '../storage/storageEncryptionService'; const IN_MEMORY_MAX = 64; const IDB_MAX_ENTRIES = 256; -const TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days -// QNBS-v3: Skip caching for long prompts — they're likely unique streaming contexts. +const TTL_MS = 7 * 24 * 60 * 60 * 1000; const SKIP_CACHE_PROMPT_LENGTH = 512; const IDB_STORE = 'inference-cache'; const IDB_DB_NAME = 'worldscript-inference-cache-db'; const IDB_DB_VERSION = 1; +const SECURE_STORE = `${IDB_DB_NAME}/${IDB_STORE}`; -interface CacheEntry { +interface LegacyCacheEntry { key: string; result: string; timestamp: number; } +interface CachePayload { + result: string; +} + +interface CacheEntry { + key: string; + timestamp: number; + payload: CachePayload | SecureRecordEnvelope; +} + interface LruEntry { result: string; lastUsed: number; } -// QNBS-v3: DJB2 + FNV hash combination for fast, low-collision prompt keys. function hashKey(prompt: string, modelId: string): string { const input = `${modelId}::${prompt}`; let djb2 = 5381; let fnv = 2166136261; - for (let i = 0; i < input.length; i++) { - const c = input.charCodeAt(i); - djb2 = ((djb2 << 5) + djb2) ^ c; - fnv = Math.imul(fnv ^ c, 16777619); + for (let index = 0; index < input.length; index++) { + const character = input.charCodeAt(index); + djb2 = ((djb2 << 5) + djb2) ^ character; + fnv = Math.imul(fnv ^ character, 16777619); } return `${(djb2 >>> 0).toString(16)}-${(fnv >>> 0).toString(16)}`; } -class AiInferenceCacheService { +function isCachePayload(value: unknown): value is CachePayload { + return ( + typeof value === 'object' && + value !== null && + typeof (value as Partial).result === 'string' + ); +} + +function isCacheEntry(value: unknown): value is CacheEntry | LegacyCacheEntry { + if (typeof value !== 'object' || value === null) return false; + const entry = value as Partial; + return ( + typeof entry.key === 'string' && + typeof entry.timestamp === 'number' && + ('payload' in entry || typeof entry.result === 'string') + ); +} + +export class AiInferenceCacheService { private readonly inMemory = new Map(); private db: IDBDatabase | null = null; - private dbReady: Promise; + private readonly dbReady: Promise; constructor() { this.dbReady = this.openDb(); @@ -46,43 +81,47 @@ class AiInferenceCacheService { private openDb(): Promise { return new Promise((resolve) => { if (typeof indexedDB === 'undefined') { - resolve(); // test environment without IDB — graceful degrade + resolve(); return; } - let req: IDBOpenDBRequest; + let request: IDBOpenDBRequest; try { - req = indexedDB.open(IDB_DB_NAME, IDB_DB_VERSION); + request = indexedDB.open(IDB_DB_NAME, IDB_DB_VERSION); } catch { - resolve(); // private-browsing mode / jsdom stub — graceful degrade + resolve(); return; } - // QNBS-v3: jsdom defines indexedDB but open() returns undefined — guard prevents crash. - if (!req) { + if (!request) { resolve(); return; } - req.onupgradeneeded = (e) => { - const db = (e.target as IDBOpenDBRequest).result; + request.onupgradeneeded = () => { + const db = request.result; if (!db.objectStoreNames.contains(IDB_STORE)) { const store = db.createObjectStore(IDB_STORE, { keyPath: 'key' }); store.createIndex('timestamp', 'timestamp', { unique: false }); } }; - req.onsuccess = (e) => { - this.db = (e.target as IDBOpenDBRequest).result; + request.onsuccess = () => { + const opened = request.result; + this.db = opened; + opened.onversionchange = () => { + this.db?.close(); + this.db = null; + }; resolve(); }; - req.onerror = () => resolve(); // degrade gracefully + request.onerror = () => resolve(); }); } private evictLru(): void { if (this.inMemory.size < IN_MEMORY_MAX) return; let oldestKey = ''; - let oldestTs = Number.POSITIVE_INFINITY; + let oldestTimestamp = Number.POSITIVE_INFINITY; for (const [key, entry] of this.inMemory) { - if (entry.lastUsed < oldestTs) { - oldestTs = entry.lastUsed; + if (entry.lastUsed < oldestTimestamp) { + oldestTimestamp = entry.lastUsed; oldestKey = key; } } @@ -93,108 +132,180 @@ class AiInferenceCacheService { return prompt.length > SKIP_CACHE_PROMPT_LENGTH; } + private async encodeEntry(key: string, result: string, timestamp: number): Promise { + return { + key, + timestamp, + payload: await prepareSecureRecordPayload( + { result }, + { + store: SECURE_STORE, + recordId: key, + }, + ), + }; + } + + private async decodeEntry(entry: CacheEntry | LegacyCacheEntry): Promise { + const rawPayload = 'payload' in entry ? entry.payload : { result: entry.result }; + const decoded = await readSecureRecordPayload(rawPayload, { + store: SECURE_STORE, + recordId: entry.key, + legacyStores: ['inference-cache'], + }); + if (!isCachePayload(decoded.value)) throw new SecureRecordCorruptError(); + if (decoded.needsMigration) { + // QNBS-v3: best-effort opportunistic re-encrypt on read — a failure (e.g. an active migration) must never block the read, and the 7-day TTL already bounds residual plaintext exposure even without this. + void this.reencryptLegacyEntry(entry.key, decoded.value.result, entry.timestamp); + } + return decoded.value.result; + } + + private async reencryptLegacyEntry( + key: string, + result: string, + timestamp: number, + ): Promise { + if (!this.db) return; + try { + const encoded = await this.encodeEntry(key, result, timestamp); + await this.persistEntry(encoded); + } catch { + // QNBS-v3: best-effort; a failed opportunistic re-encrypt is not user-visible and TTL still bounds exposure. + } + } + + private async persistEntry(entry: CacheEntry): Promise { + if (!this.db) return; + await new Promise((resolve) => { + const transaction = this.db!.transaction(IDB_STORE, 'readwrite'); + transaction.objectStore(IDB_STORE).put(entry); + // QNBS-v3: Cache data is non-authoritative, but lock and migration failures occur before this best-effort write. + transaction.oncomplete = () => resolve(); + transaction.onerror = () => resolve(); + transaction.onabort = () => resolve(); + }); + } + async getCachedInference(prompt: string, modelId: string): Promise { if (this.shouldSkip(prompt)) return null; const key = hashKey(prompt, modelId); + try { + // QNBS-v3: A locked library must not expose an earlier plaintext response through the RAM tier. + await assertSecureStorageReadable(); + } catch { + // QNBS-v3: cache is non-authoritative — any lifecycle-check failure (locked, migrating, or the check's own IDB access failing) degrades to a miss rather than failing an otherwise-successful inference call. + return null; + } - // 1. In-memory check (hot path) - const mem = this.inMemory.get(key); - if (mem) { - mem.lastUsed = Date.now(); - return mem.result; + const memoryEntry = this.inMemory.get(key); + if (memoryEntry) { + memoryEntry.lastUsed = Date.now(); + return memoryEntry.result; } - // 2. IDB check await this.dbReady; if (!this.db) return null; return new Promise((resolve) => { - const tx = this.db!.transaction(IDB_STORE, 'readonly'); - const req = tx.objectStore(IDB_STORE).get(key); - req.onsuccess = (e) => { - const entry = (e.target as IDBRequest).result; + const transaction = this.db!.transaction(IDB_STORE, 'readonly'); + const request = transaction.objectStore(IDB_STORE).get(key); + request.onsuccess = () => { + const entry = request.result as unknown; if (!entry) { resolve(null); return; } + if (!isCacheEntry(entry)) { + // QNBS-v3: matches the non-authoritative cache policy above — a malformed row degrades to a miss, never fails the caller's inference request. + logger.warn('aiInferenceCacheService: dropping a malformed cache row', { key }); + resolve(null); + return; + } if (Date.now() - entry.timestamp > TTL_MS) { resolve(null); return; } - // Populate in-memory from IDB hit - this.evictLru(); - this.inMemory.set(key, { result: entry.result, lastUsed: Date.now() }); - resolve(entry.result); + void this.decodeEntry(entry).then( + (result) => { + this.evictLru(); + this.inMemory.set(key, { result, lastUsed: Date.now() }); + resolve(result); + }, + (error: unknown) => { + // QNBS-v3: same non-authoritative policy — a corrupt/undecodable row degrades to a miss instead of failing the caller's inference request. + logger.warn('aiInferenceCacheService: dropping an undecodable cache row', { + key, + error, + }); + resolve(null); + }, + ); }; - req.onerror = () => resolve(null); + request.onerror = () => resolve(null); + transaction.onabort = () => resolve(null); }); } async setCachedInference(prompt: string, modelId: string, result: string): Promise { if (this.shouldSkip(prompt)) return; const key = hashKey(prompt, modelId); - - // Store in-memory this.evictLru(); this.inMemory.set(key, { result, lastUsed: Date.now() }); - - // Persist to IDB await this.dbReady; if (!this.db) return; - await this.idbEvictOldest(); - return new Promise((resolve) => { - const tx = this.db!.transaction(IDB_STORE, 'readwrite'); - const entry: CacheEntry = { key, result, timestamp: Date.now() }; - tx.objectStore(IDB_STORE).put(entry); - tx.oncomplete = () => resolve(); - tx.onerror = () => resolve(); - }); + try { + const entry = await this.encodeEntry(key, result, Date.now()); + await this.idbEvictOldest(); + await this.persistEntry(entry); + } catch { + // QNBS-v3: The encrypted durable cache is non-authoritative; lock or migration state must not fail inference. + } } private async idbEvictOldest(): Promise { if (!this.db) return; - return new Promise((resolve) => { - const tx = this.db!.transaction(IDB_STORE, 'readwrite'); - const store = tx.objectStore(IDB_STORE); - const countReq = store.count(); - countReq.onsuccess = () => { - const count = countReq.result; + await new Promise((resolve) => { + const transaction = this.db!.transaction(IDB_STORE, 'readwrite'); + const store = transaction.objectStore(IDB_STORE); + const countRequest = store.count(); + countRequest.onsuccess = () => { + const count = countRequest.result; if (count < IDB_MAX_ENTRIES) { resolve(); return; } - // Evict oldest by timestamp index - const idx = store.index('timestamp'); - const cursorReq = idx.openCursor(); - let toDelete = count - IDB_MAX_ENTRIES + 1; - cursorReq.onsuccess = (e) => { - const cursor = (e.target as IDBRequest).result; - if (!cursor || toDelete <= 0) { + const cursorRequest = store.index('timestamp').openCursor(); + let remaining = count - IDB_MAX_ENTRIES + 1; + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + if (!cursor || remaining <= 0) { resolve(); return; } cursor.delete(); - toDelete--; + remaining--; cursor.continue(); }; - cursorReq.onerror = () => resolve(); + cursorRequest.onerror = () => resolve(); }; - countReq.onerror = () => resolve(); + countRequest.onerror = () => resolve(); }); } async clearPersistentCache(): Promise { + await assertSecureStorageWritableForMutation(); this.inMemory.clear(); await this.dbReady; if (!this.db) return; - return new Promise((resolve) => { - const tx = this.db!.transaction(IDB_STORE, 'readwrite'); - tx.objectStore(IDB_STORE).clear(); - tx.oncomplete = () => resolve(); - tx.onerror = () => resolve(); + await new Promise((resolve) => { + const transaction = this.db!.transaction(IDB_STORE, 'readwrite'); + transaction.objectStore(IDB_STORE).clear(); + transaction.oncomplete = () => resolve(); + transaction.onerror = () => resolve(); + transaction.onabort = () => resolve(); }); } - // QNBS-v3: Exposed for tests to verify in-memory state without IDB. getInMemorySize(): number { return this.inMemory.size; } diff --git a/services/ollamaService.ts b/services/ollamaService.ts index 3f314ec0..80e16703 100644 --- a/services/ollamaService.ts +++ b/services/ollamaService.ts @@ -5,6 +5,7 @@ import { normalizeLocalBaseUrl, } from './localServerHttp'; import { createLogger } from './logger'; +import { isTauriRuntime } from './tauriRuntime'; // QNBS-v3 (#266): canonical normalization lives in localServerHttp (shared with the scanner). const normalizeBaseUrl = normalizeLocalBaseUrl; @@ -157,13 +158,23 @@ export async function listOllamaModels(baseUrl?: string): Promise { * a raw/technical string for logs; UI code should prefer `kind` (+ `params` for interpolation) to * render a localized message, per `settings.ai.testError.*` in `locales//settings.json`. */ -export type TestConnectionErrorKind = 'httpError' | 'timeout' | 'unreachable' | 'pluginUnavailable'; +export type TestConnectionErrorKind = + | 'httpError' + | 'timeout' + | 'unreachable' + | 'pluginUnavailable' + | 'invalidResponse'; export interface TestConnectionResult { ok: boolean; error?: string; kind?: TestConnectionErrorKind; params?: Record; + localServer?: { + normalizedEndpoint: string; + transport: 'tauri-http' | 'browser-fetch'; + modelNames: string[]; + }; } export async function testOllamaConnection(baseUrl?: string): Promise { @@ -179,7 +190,34 @@ export async function testOllamaConnection(baseUrl?: string): Promise { + if (typeof model !== 'object' || model === null) return []; + const name = (model as { name?: unknown }).name; + return typeof name === 'string' && name.trim() ? [name.trim()] : []; + }); + return { + ok: true, + localServer: { + normalizedEndpoint: url, + transport: isTauriRuntime() ? 'tauri-http' : 'browser-fetch', + modelNames, + }, + }; } catch (error: unknown) { // QNBS-v3 (#266): classified failures — distinguish a hanging server from a missing one. if (error instanceof LocalServerError && error.kind === 'timeout') { diff --git a/services/sceneRevisionService.ts b/services/sceneRevisionService.ts index 89f7dafb..668d92b7 100644 --- a/services/sceneRevisionService.ts +++ b/services/sceneRevisionService.ts @@ -1,46 +1,222 @@ -// QNBS-v3: Standalone IDB for scene revisions — avoids bumping the shared DB_VERSION in dbService.ts. -// Max 50 revisions per scene; oldest are evicted automatically on save. +// QNBS-v3: Standalone IDB for scene revisions avoids a shared schema upgrade and keeps history bounded. import type { SceneRevision } from '../types'; +import { createLogger } from './logger'; +import { + assertSecureStorageReadable, + assertSecureStorageWritableForMutation, + prepareSecureRecordPayload, + readSecureRecordPayloadAfterLifecycleCheck, + SecureRecordCorruptError, + type SecureRecordEnvelope, +} from './storage/storageEncryptionService'; const DB_NAME = 'worldscript-revisions-db'; const DB_VERSION = 1; const STORE = 'scene-revisions'; +const SECURE_STORE = `${DB_NAME}/${STORE}`; const MAX_PER_SCENE = 50; +const RECORD_SCHEMA_VERSION = 1; +const log = createLogger('sceneRevisionService'); -let _db: IDBDatabase | null = null; +interface SceneRevisionPayload { + title: string; + content: string; + wordCount: number; + label?: string; + authorName?: string; +} + +interface StoredSceneRevision { + id: string; + sectionId: string; + createdAt: number; + schemaVersion: typeof RECORD_SCHEMA_VERSION; + payload: SceneRevisionPayload | SecureRecordEnvelope; +} + +let database: IDBDatabase | null = null; +let openPromise: Promise | null = null; async function getDb(): Promise { - if (_db) return _db; - return new Promise((resolve, reject) => { - const req = indexedDB.open(DB_NAME, DB_VERSION); - req.onupgradeneeded = () => { - const db = req.result; + if (database) return database; + if (openPromise) return openPromise; + // QNBS-v3: single-flight open — concurrent saves must share one connection instead of leaking one per call. + openPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; if (!db.objectStoreNames.contains(STORE)) { const store = db.createObjectStore(STORE, { keyPath: 'id' }); store.createIndex('sectionId', 'sectionId', { unique: false }); store.createIndex('createdAt', 'createdAt', { unique: false }); } }; - req.onsuccess = () => { - _db = req.result; - _db.onversionchange = () => { - _db?.close(); - _db = null; + request.onsuccess = () => { + const opened = request.result; + database = opened; + opened.onversionchange = () => { + opened.close(); + database = null; + openPromise = null; }; - resolve(_db); + resolve(opened); + }; + request.onerror = () => { + openPromise = null; + reject(request.error); }; - req.onerror = () => reject(req.error); }); + return openPromise; +} + +function isStoredSceneRevision(value: unknown): value is StoredSceneRevision { + return ( + typeof value === 'object' && + value !== null && + 'payload' in value && + typeof (value as Partial).id === 'string' && + typeof (value as Partial).sectionId === 'string' && + typeof (value as Partial).createdAt === 'number' && + (value as Partial).schemaVersion === RECORD_SCHEMA_VERSION + ); +} + +function isRevisionPayload(value: unknown): value is SceneRevisionPayload { + if (typeof value !== 'object' || value === null) return false; + const payload = value as Partial; + return ( + typeof payload.title === 'string' && + typeof payload.content === 'string' && + typeof payload.wordCount === 'number' && + (payload.label === undefined || typeof payload.label === 'string') && + (payload.authorName === undefined || typeof payload.authorName === 'string') + ); +} + +function revisionPayload(revision: SceneRevision): SceneRevisionPayload { + return { + title: revision.title, + content: revision.content, + wordCount: revision.wordCount, + ...(revision.label !== undefined ? { label: revision.label } : {}), + ...(revision.authorName !== undefined ? { authorName: revision.authorName } : {}), + }; +} + +function contextFor(id: string) { + return { store: SECURE_STORE, recordId: id, legacyStores: ['scene-revisions'] }; +} + +async function encodeRevision(revision: SceneRevision): Promise { + return { + id: revision.id, + sectionId: revision.sectionId, + createdAt: revision.createdAt, + schemaVersion: RECORD_SCHEMA_VERSION, + payload: await prepareSecureRecordPayload(revisionPayload(revision), contextFor(revision.id)), + }; } -/** Saves a scene revision. Evicts the oldest if max is exceeded. */ +function hasSupportedRevisionRoutingMetadata( + value: unknown, +): value is Pick { + if (isStoredSceneRevision(value)) return true; + if (typeof value !== 'object' || value === null || 'payload' in value) return false; + const legacy = value as Partial; + return ( + typeof legacy.id === 'string' && + typeof legacy.sectionId === 'string' && + typeof legacy.createdAt === 'number' && + isRevisionPayload(revisionPayload(legacy as SceneRevision)) + ); +} + +async function decodeRevision( + stored: unknown, + encryptionConfigured: boolean, +): Promise { + if (isStoredSceneRevision(stored)) { + const decoded = await readSecureRecordPayloadAfterLifecycleCheck( + stored.payload, + contextFor(stored.id), + encryptionConfigured, + ); + if (!isRevisionPayload(decoded.value)) throw new SecureRecordCorruptError(); + return { + id: stored.id, + sectionId: stored.sectionId, + createdAt: stored.createdAt, + ...decoded.value, + }; + } + + const legacy = stored as Partial; + if ( + typeof legacy.id !== 'string' || + typeof legacy.sectionId !== 'string' || + typeof legacy.createdAt !== 'number' + ) { + throw new SecureRecordCorruptError(); + } + const decoded = await readSecureRecordPayloadAfterLifecycleCheck( + revisionPayload(legacy as SceneRevision), + contextFor(legacy.id), + encryptionConfigured, + ); + if (!isRevisionPayload(decoded.value)) throw new SecureRecordCorruptError(); + return { + id: legacy.id, + sectionId: legacy.sectionId, + createdAt: legacy.createdAt, + ...decoded.value, + }; +} + +async function saveStoredRevisionWithRetention( + db: IDBDatabase, + revision: StoredSceneRevision, +): Promise { + await new Promise((resolve, reject) => { + const transaction = db.transaction(STORE, 'readwrite'); + const store = transaction.objectStore(STORE); + const putRequest = store.put(revision); + putRequest.onerror = () => reject(putRequest.error); + // QNBS-v3: Revision routing metadata stays plaintext so retention can be atomic without decoding history. + const listRequest = store.index('sectionId').getAll(revision.sectionId); + listRequest.onsuccess = () => { + const staleIds = (listRequest.result as unknown[]) + .filter(hasSupportedRevisionRoutingMetadata) + .sort((left, right) => right.createdAt - left.createdAt) + .slice(MAX_PER_SCENE) + .map((storedRevision) => storedRevision.id); + for (const id of staleIds) store.delete(id); + }; + listRequest.onerror = () => reject(listRequest.error); + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error ?? new Error('Revision write aborted')); + }); +} + +async function deleteRevisions(db: IDBDatabase, ids: readonly string[]): Promise { + if (ids.length === 0) return; + await new Promise((resolve, reject) => { + const transaction = db.transaction(STORE, 'readwrite'); + const store = transaction.objectStore(STORE); + for (const id of ids) store.delete(id); + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error ?? new Error('Revision deletion aborted')); + }); +} + +/** Saves a scene revision. Evicts the oldest only after the new durable write has committed. */ export async function saveRevision( sectionId: string, snapshot: { title: string; content: string }, label?: string, authorName?: string, ): Promise { - const db = await getDb(); const revision: SceneRevision = { id: `rev-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, sectionId, @@ -48,53 +224,56 @@ export async function saveRevision( title: snapshot.title, content: snapshot.content, wordCount: snapshot.content.split(/\s+/).filter(Boolean).length, - ...(label !== undefined && { label }), - ...(authorName !== undefined && { authorName }), + ...(label !== undefined ? { label } : {}), + ...(authorName !== undefined ? { authorName } : {}), }; - const tx = db.transaction(STORE, 'readwrite'); - const store = tx.objectStore(STORE); - store.add(revision); - - // Evict if over MAX_PER_SCENE - const existing = await listRevisions(sectionId); - if (existing.length > MAX_PER_SCENE) { - const toEvict = existing.slice(MAX_PER_SCENE); - const evictTx = db.transaction(STORE, 'readwrite'); - const evictStore = evictTx.objectStore(STORE); - for (const r of toEvict) evictStore.delete(r.id); - } - + // QNBS-v3: Encrypt before IDB work so WebCrypto cannot make a write transaction inactive. + const stored = await encodeRevision(revision); + const db = await getDb(); + await saveStoredRevisionWithRetention(db, stored); return revision; } -/** Returns revisions for a section, ordered newest-first. */ +/** Returns revisions for a section, ordered newest-first. Reads never attempt an opportunistic rewrite. */ export async function listRevisions(sectionId: string): Promise { + const encryptionConfigured = await assertSecureStorageReadable(); const db = await getDb(); - return new Promise((resolve, reject) => { - const tx = db.transaction(STORE, 'readonly'); - const idx = tx.objectStore(STORE).index('sectionId'); - const req = idx.getAll(sectionId); - req.onsuccess = () => { - const all = (req.result as SceneRevision[]).sort((a, b) => b.createdAt - a.createdAt); - resolve(all); - }; - req.onerror = () => reject(req.error); + const raw = await new Promise((resolve, reject) => { + const transaction = db.transaction(STORE, 'readonly'); + const request = transaction.objectStore(STORE).index('sectionId').getAll(sectionId); + request.onsuccess = () => resolve(request.result as unknown[]); + request.onerror = () => reject(request.error); + transaction.onabort = () => reject(transaction.error ?? new Error('Revision read aborted')); }); + + const revisions: SceneRevision[] = []; + // QNBS-v3: Sequential decryption keeps a full scene history from causing a renderer memory burst. + for (const stored of raw) { + try { + revisions.push(await decodeRevision(stored, encryptionConfigured)); + } catch (error) { + // QNBS-v3: skip one damaged revision so the rest stay readable, but still abort on a real lock-state change. + if (error instanceof SecureRecordCorruptError) { + log.warn('Skipping a damaged revision', { sectionId, error: String(error) }); + continue; + } + throw error; + } + } + return revisions.sort((left, right) => right.createdAt - left.createdAt); } /** Deletes a single revision by ID. */ export async function deleteRevision(id: string): Promise { + await assertSecureStorageWritableForMutation(); const db = await getDb(); - return new Promise((resolve, reject) => { - const tx = db.transaction(STORE, 'readwrite'); - const req = tx.objectStore(STORE).delete(id); - req.onsuccess = () => resolve(); - req.onerror = () => reject(req.error); - }); + await deleteRevisions(db, [id]); } -/** Reset the singleton (for testing). */ +/** Reset the singleton and close its handle so tests cannot retain a stale database connection. */ export function _resetDbForTest(): void { - _db = null; + database?.close(); + database = null; + openPromise = null; } diff --git a/services/storage/encryptionMigrationJournal.ts b/services/storage/encryptionMigrationJournal.ts new file mode 100644 index 00000000..02054a9d --- /dev/null +++ b/services/storage/encryptionMigrationJournal.ts @@ -0,0 +1,482 @@ +/** + * Durable coordination metadata for multi-store at-rest encryption migrations. + * A journal is deliberately plaintext metadata: it never contains passphrases or CryptoKeys. + */ + +import { APP_DATA_STORE } from '../dbConstants'; +import { IdbConnectionManager } from './idbCore'; + +const JOURNAL_RECORD_KEY = '__idb_encryption_migration_journal_v1__'; +const JOURNAL_SCHEMA_VERSION = 1; +const OWNER_LEASE_DURATION_MS = 60_000; + +export type EncryptionMigrationOperation = 'enable' | 'rekey' | 'disable'; +export type EncryptionMigrationPhase = + | 'prepared' + | 'migrating' + | 'verifying' + | 'committing' + | 'cleanup' + | 'completed' + | 'recovery-required'; + +export interface EncryptionMigrationStoreCheckpoint { + id: string; + /** Last durably committed logical record id; omitted before the first successful batch. */ + cursor?: string; + processed: number; + verified: number; + done: boolean; +} + +export interface EncryptionMigrationJournal { + schemaVersion: number; + operationId: string; + /** Monotone compare-and-swap token; it is independent from wall-clock precision. */ + revision: number; + /** Ephemeral executor identity; the lease prevents concurrent store rewrites across clients. */ + ownerId?: string; + /** Owner leases are renewed with every durable checkpoint and can expire after a crash. */ + ownerLeaseExpiresAt?: number; + operation: EncryptionMigrationOperation; + phase: EncryptionMigrationPhase; + startedAt: number; + updatedAt: number; + sourceGeneration?: string; + targetGeneration?: string; + /** A verifier encrypted with the target key, never raw key material. */ + targetVerifier?: number[]; + stores: EncryptionMigrationStoreCheckpoint[]; +} + +export class IdbMigrationInProgressError extends Error { + readonly code = 'ENCRYPTION_MIGRATION_IN_PROGRESS' as const; + + constructor(journal: EncryptionMigrationJournal) { + super(`Encryption ${journal.operation} migration is ${journal.phase}`); + this.name = 'IdbMigrationInProgressError'; + } +} + +export class IdbMigrationRecoveryRequiredError extends Error { + readonly code = 'ENCRYPTION_RECOVERY_REQUIRED' as const; + + constructor() { + super('Encryption migration metadata is invalid and requires recovery'); + this.name = 'IdbMigrationRecoveryRequiredError'; + } +} + +/** Raised when a delayed tab tries to overwrite a newer durable journal state. */ +export class IdbMigrationOwnershipError extends Error { + readonly code = 'ENCRYPTION_MIGRATION_OWNERSHIP_LOST' as const; + + constructor() { + super('Encryption migration journal ownership was lost'); + this.name = 'IdbMigrationOwnershipError'; + } +} + +/** Raised when a caller-supplied phase would skip required migration/verification work. */ +export class IdbMigrationInvalidTransitionError extends Error { + readonly code = 'ENCRYPTION_MIGRATION_INVALID_TRANSITION' as const; + + constructor(from: EncryptionMigrationPhase, to: EncryptionMigrationPhase) { + super(`Encryption migration cannot move from ${from} to ${to}`); + this.name = 'IdbMigrationInvalidTransitionError'; + } +} + +// QNBS-v3: enforce transitions against the durable phase, because CAS alone never validated that a phase step was legal and let a caller skip conversion/verification entirely. +const ALLOWED_PHASE_TRANSITIONS: Record< + EncryptionMigrationPhase, + ReadonlySet +> = { + prepared: new Set(['prepared', 'migrating', 'recovery-required']), + migrating: new Set(['migrating', 'verifying', 'recovery-required']), + verifying: new Set(['verifying', 'committing', 'recovery-required']), + committing: new Set([ + 'committing', + 'cleanup', + 'completed', + 'recovery-required', + ]), + cleanup: new Set(['cleanup', 'completed', 'recovery-required']), + completed: new Set(['completed']), + 'recovery-required': new Set(['recovery-required']), +}; + +function assertLegalPhaseTransition( + from: EncryptionMigrationPhase, + to: EncryptionMigrationPhase, +): void { + if (!ALLOWED_PHASE_TRANSITIONS[from].has(to)) { + throw new IdbMigrationInvalidTransitionError(from, to); + } +} + +function isPhase(value: unknown): value is EncryptionMigrationPhase { + return ( + value === 'prepared' || + value === 'migrating' || + value === 'verifying' || + value === 'committing' || + value === 'cleanup' || + value === 'completed' || + value === 'recovery-required' + ); +} + +function isOperation(value: unknown): value is EncryptionMigrationOperation { + return value === 'enable' || value === 'rekey' || value === 'disable'; +} + +function parseJournal(raw: unknown): EncryptionMigrationJournal | null { + if (!raw || typeof raw !== 'object') return null; + const value = raw as Partial; + const revision = value.revision ?? 0; + if ( + value.schemaVersion !== JOURNAL_SCHEMA_VERSION || + typeof value.operationId !== 'string' || + !Number.isSafeInteger(revision) || + revision < 0 || + !isOperation(value.operation) || + !isPhase(value.phase) || + typeof value.startedAt !== 'number' || + typeof value.updatedAt !== 'number' || + !Array.isArray(value.stores) + ) { + return null; + } + if ( + (value.ownerId === undefined) !== (value.ownerLeaseExpiresAt === undefined) || + (value.ownerId !== undefined && + (typeof value.ownerId !== 'string' || + value.ownerId.length === 0 || + typeof value.ownerLeaseExpiresAt !== 'number' || + !Number.isSafeInteger(value.ownerLeaseExpiresAt) || + value.ownerLeaseExpiresAt < 0)) + ) { + return null; + } + if ( + value.stores.some( + (checkpoint) => + !checkpoint || + typeof checkpoint.id !== 'string' || + (checkpoint.cursor !== undefined && typeof checkpoint.cursor !== 'string') || + !Number.isSafeInteger(checkpoint.processed) || + checkpoint.processed < 0 || + !Number.isSafeInteger(checkpoint.verified) || + checkpoint.verified < 0 || + typeof checkpoint.done !== 'boolean', + ) + ) { + return null; + } + if ( + value.targetVerifier !== undefined && + (!Array.isArray(value.targetVerifier) || + !value.targetVerifier.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) + ) { + return null; + } + // QNBS-v3: Pre-CAS journals are safely upgraded in memory and persist a revision on their next write. + return { ...value, revision } as EncryptionMigrationJournal; +} + +function journalIsActive(journal: EncryptionMigrationJournal): boolean { + return journal.phase !== 'completed'; +} + +function withoutOwner( + journal: EncryptionMigrationJournal, +): Omit { + const { ownerId: _ownerId, ownerLeaseExpiresAt: _ownerLeaseExpiresAt, ...withoutLease } = journal; + return withoutLease; +} + +function nextOwnedJournal( + journal: EncryptionMigrationJournal, + ownerId: string | undefined, +): EncryptionMigrationJournal { + const now = Date.now(); + return { + ...withoutOwner(journal), + revision: journal.revision + 1, + updatedAt: now, + ...(ownerId ? { ownerId, ownerLeaseExpiresAt: now + OWNER_LEASE_DURATION_MS } : {}), + }; +} + +class EncryptionMigrationJournalStore extends IdbConnectionManager { + async read(): Promise { + const store = await this.getObjectStore(APP_DATA_STORE, 'readonly'); + return new Promise((resolve, reject) => { + const request = store.get(JOURNAL_RECORD_KEY); + request.onsuccess = () => { + if (request.result === undefined) { + resolve(null); + return; + } + const journal = parseJournal(request.result); + if (!journal) { + reject(new IdbMigrationRecoveryRequiredError()); + return; + } + resolve(journal); + }; + request.onerror = () => reject(request.error); + }); + } + + async begin(journal: EncryptionMigrationJournal): Promise { + const store = await this.getObjectStore(APP_DATA_STORE, 'readwrite'); + const transaction = store.transaction; + return new Promise((resolve, reject) => { + const existingRequest = store.get(JOURNAL_RECORD_KEY); + existingRequest.onerror = () => reject(existingRequest.error); + existingRequest.onsuccess = () => { + if (existingRequest.result !== undefined) { + const existing = parseJournal(existingRequest.result); + if (!existing) { + reject(new IdbMigrationRecoveryRequiredError()); + return; + } + if (journalIsActive(existing)) { + reject(new IdbMigrationInProgressError(existing)); + return; + } + } + const putRequest = store.put(journal, JOURNAL_RECORD_KEY); + putRequest.onerror = () => reject(putRequest.error); + }; + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => + reject(transaction.error ?? new Error('Journal transaction aborted')); + }); + } + + async saveIfCurrent(journal: EncryptionMigrationJournal): Promise { + const store = await this.getObjectStore(APP_DATA_STORE, 'readwrite'); + const transaction = store.transaction; + const next = nextOwnedJournal(journal, journal.ownerId); + return new Promise((resolve, reject) => { + let writeQueued = false; + const existingRequest = store.get(JOURNAL_RECORD_KEY); + existingRequest.onerror = () => reject(existingRequest.error); + existingRequest.onsuccess = () => { + const existing = parseJournal(existingRequest.result); + if ( + !existing || + existing.operationId !== journal.operationId || + existing.revision !== journal.revision || + existing.ownerId !== journal.ownerId || + existing.ownerLeaseExpiresAt !== journal.ownerLeaseExpiresAt + ) { + reject(new IdbMigrationOwnershipError()); + return; + } + try { + assertLegalPhaseTransition(existing.phase, next.phase); + } catch (transitionError) { + reject(transitionError); + return; + } + const putRequest = store.put(next, JOURNAL_RECORD_KEY); + putRequest.onerror = () => reject(putRequest.error); + writeQueued = true; + }; + transaction.oncomplete = () => { + if (writeQueued) resolve(next); + }; + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => + reject(transaction.error ?? new Error('Journal transaction aborted')); + }); + } + + async claimOwnership( + journal: EncryptionMigrationJournal, + ownerId: string, + ): Promise { + const store = await this.getObjectStore(APP_DATA_STORE, 'readwrite'); + const transaction = store.transaction; + const next = nextOwnedJournal(journal, ownerId); + return new Promise((resolve, reject) => { + let writeQueued = false; + const existingRequest = store.get(JOURNAL_RECORD_KEY); + existingRequest.onerror = () => reject(existingRequest.error); + existingRequest.onsuccess = () => { + const existing = parseJournal(existingRequest.result); + if ( + !existing || + existing.operationId !== journal.operationId || + existing.revision !== journal.revision + ) { + reject(new IdbMigrationOwnershipError()); + return; + } + const now = Date.now(); + if ( + existing.ownerId !== undefined && + existing.ownerId !== ownerId && + (existing.ownerLeaseExpiresAt ?? now) > now + ) { + reject(new IdbMigrationOwnershipError()); + return; + } + const putRequest = store.put(next, JOURNAL_RECORD_KEY); + putRequest.onerror = () => reject(putRequest.error); + writeQueued = true; + }; + transaction.oncomplete = () => { + if (writeQueued) resolve(next); + }; + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => + reject(transaction.error ?? new Error('Journal ownership claim aborted')); + }); + } + + async releaseOwnership(journal: EncryptionMigrationJournal): Promise { + if (journal.ownerId === undefined) return journal; + const store = await this.getObjectStore(APP_DATA_STORE, 'readwrite'); + const transaction = store.transaction; + const next = nextOwnedJournal(journal, undefined); + return new Promise((resolve, reject) => { + let writeQueued = false; + const existingRequest = store.get(JOURNAL_RECORD_KEY); + existingRequest.onerror = () => reject(existingRequest.error); + existingRequest.onsuccess = () => { + const existing = parseJournal(existingRequest.result); + if ( + !existing || + existing.operationId !== journal.operationId || + existing.revision !== journal.revision || + existing.ownerId !== journal.ownerId || + existing.ownerLeaseExpiresAt !== journal.ownerLeaseExpiresAt + ) { + reject(new IdbMigrationOwnershipError()); + return; + } + const putRequest = store.put(next, JOURNAL_RECORD_KEY); + putRequest.onerror = () => reject(putRequest.error); + writeQueued = true; + }; + transaction.oncomplete = () => { + if (writeQueued) resolve(next); + }; + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => + reject(transaction.error ?? new Error('Journal ownership release aborted')); + }); + } + + async clearCompleted(): Promise { + const store = await this.getObjectStore(APP_DATA_STORE, 'readwrite'); + const transaction = store.transaction; + return new Promise((resolve, reject) => { + const existingRequest = store.get(JOURNAL_RECORD_KEY); + existingRequest.onerror = () => reject(existingRequest.error); + existingRequest.onsuccess = () => { + if (existingRequest.result === undefined) return; + const existing = parseJournal(existingRequest.result); + if (!existing) { + reject(new IdbMigrationRecoveryRequiredError()); + return; + } + if (journalIsActive(existing)) { + reject(new IdbMigrationInProgressError(existing)); + return; + } + const deleteRequest = store.delete(JOURNAL_RECORD_KEY); + deleteRequest.onerror = () => reject(deleteRequest.error); + }; + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => + reject(transaction.error ?? new Error('Journal transaction aborted')); + }); + } + + resetConnectionsForTest(): void { + this.closeConnections(); + } +} + +const journalStore = new EncryptionMigrationJournalStore(); + +export async function readEncryptionMigrationJournal(): Promise { + return journalStore.read(); +} + +export async function beginEncryptionMigration( + input: Omit< + EncryptionMigrationJournal, + 'schemaVersion' | 'revision' | 'startedAt' | 'updatedAt' | 'ownerId' | 'ownerLeaseExpiresAt' + >, +): Promise { + const now = Date.now(); + const journal: EncryptionMigrationJournal = { + ...input, + schemaVersion: JOURNAL_SCHEMA_VERSION, + revision: 0, + startedAt: now, + updatedAt: now, + }; + await journalStore.begin(journal); + return journal; +} + +export async function updateEncryptionMigrationJournal( + journal: EncryptionMigrationJournal, + changes: Pick, +): Promise { + return journalStore.saveIfCurrent({ ...journal, ...changes }); +} + +/** Claim the single execution lease before an adapter can mutate a protected store. */ +export async function claimEncryptionMigrationOwnership( + journal: EncryptionMigrationJournal, + ownerId: string, +): Promise { + if (!ownerId) throw new IdbMigrationOwnershipError(); + return journalStore.claimOwnership(journal, ownerId); +} + +/** Release a healthy runner after a recoverable failure so an explicit retry can resume immediately. */ +export async function releaseEncryptionMigrationOwnership( + journal: EncryptionMigrationJournal, +): Promise { + return journalStore.releaseOwnership(journal); +} + +export async function completeEncryptionMigration( + journal: EncryptionMigrationJournal, +): Promise { + if (journal.phase !== 'committing' && journal.phase !== 'cleanup') { + throw new IdbMigrationInProgressError(journal); + } + await journalStore.saveIfCurrent({ ...journal, phase: 'completed' }); +} + +export async function clearCompletedEncryptionMigration(): Promise { + await journalStore.clearCompleted(); +} + +/** Reject normal protected access while a cross-store migration is not in a terminal state. */ +export async function assertNoActiveEncryptionMigration(): Promise { + const journal = await journalStore.read(); + if (journal && journalIsActive(journal)) { + throw new IdbMigrationInProgressError(journal); + } +} + +export const __encryptionMigrationJournalRecordKeyForTest = JOURNAL_RECORD_KEY; + +/** Test-only reset so a new fake IndexedDB factory cannot reuse stale singleton connections. */ +export function __resetEncryptionMigrationJournalConnectionsForTest(): void { + journalStore.resetConnectionsForTest(); +} diff --git a/services/storage/idbAssetStore.ts b/services/storage/idbAssetStore.ts index f2ca4e3a..ac6b7024 100644 --- a/services/storage/idbAssetStore.ts +++ b/services/storage/idbAssetStore.ts @@ -1,6 +1,6 @@ /** * IdbAssetStore — Images and Binder binary assets (research PDFs, files). - * ENCRYPTION: plaintext — blob storage; at-rest encryption planned for Phase 2. + * ENCRYPTION: image and binder payloads are encrypted when optional IDB at-rest encryption is unlocked. * QNBS-v3: Extracted from dbService.ts. Redux keeps only asset IDs; blobs stay here. */ @@ -11,6 +11,8 @@ import { getUserFriendlyDbError, retryDb } from './idbCore'; import { IdbSnapshotStore } from './idbSnapshotStore'; import { assertIdbProtectedWriteAllowed, + assertNoActiveEncryptionMigration, + assertSecureStorageReadable, idbEncryptWithKey, idbReadSecure, isEncryptedBlob, @@ -27,6 +29,8 @@ export class IdbAssetStore extends IdbSnapshotStore { // later await could race with Lock Session and silently fall back to plaintext. const writeKey = await resolveProtectedWriteKey(); const payload = writeKey ? await idbEncryptWithKey(writeKey, base64) : base64; + // QNBS-v3: only the migration guard is re-checked here — resolveProtectedWriteKey() already made its own lock check atomically with the key snapshot, so re-running that too would wrongly reject an already-safely-encrypted write if the session locks mid-write. + await assertNoActiveEncryptionMigration(); const store = await this.getObjectStore(IMAGES_STORE, 'readwrite'); return new Promise((resolve, reject) => { const request = store.put(payload, id); @@ -36,8 +40,8 @@ export class IdbAssetStore extends IdbSnapshotStore { } async getImage(id: string): Promise { - // QNBS-v3: A locked session must not be able to read a legacy-plaintext image either. - await assertIdbProtectedWriteAllowed(); + // QNBS-v3: assertSecureStorageReadable also blocks reads during an active journal migration, not just a plain lock — the superset check needed while a journal owns lifecycle state. + await assertSecureStorageReadable(); const store = await this.getObjectStore(IMAGES_STORE, 'readonly'); return new Promise((resolve, reject) => { const request = store.get(id); @@ -95,6 +99,8 @@ export class IdbAssetStore extends IdbSnapshotStore { meta: fullMeta, blob: new Blob([data], { type: meta.mimeType || 'application/octet-stream' }), }; + // QNBS-v3: only the migration guard is re-checked here — resolveProtectedWriteKey() already made its own lock check atomically with the key snapshot, so re-running that too would wrongly reject an already-safely-encrypted write if the session locks mid-write. + await assertNoActiveEncryptionMigration(); const store = await this.getObjectStore(BINDER_ASSETS_STORE, 'readwrite'); return new Promise((resolve, reject) => { const req = store.put(payload, key); @@ -106,8 +112,8 @@ export class IdbAssetStore extends IdbSnapshotStore { async getBinderAsset(projectId: string, assetId: string): Promise { return retryDb(async () => { - // QNBS-v3: A locked session must not be able to read a legacy-plaintext binder asset either. - await assertIdbProtectedWriteAllowed(); + // QNBS-v3: superset of the lock check — also blocks reads during an active journal migration. + await assertSecureStorageReadable(); const key = makeBinderAssetStorageKey(projectId, assetId); const store = await this.getObjectStore(BINDER_ASSETS_STORE, 'readonly'); const raw = await new Promise((resolve, reject) => { @@ -145,9 +151,8 @@ export class IdbAssetStore extends IdbSnapshotStore { async listBinderAssetIds(projectId: string): Promise { return retryDb(async () => { - // QNBS-v3: Binder asset ids are metadata about protected content; a locked session must not - // be able to enumerate them either. - await assertIdbProtectedWriteAllowed(); + // QNBS-v3: Binder asset ids are metadata about protected content — use the superset check so a locked session, or an active journal migration, can't enumerate them either. + await assertSecureStorageReadable(); const prefix = makeBinderAssetIdsPrefix(projectId); const store = await this.getObjectStore(BINDER_ASSETS_STORE, 'readonly'); const ids: string[] = []; @@ -171,7 +176,30 @@ export class IdbAssetStore extends IdbSnapshotStore { } async deleteAllBinderAssetsForProject(projectId: string): Promise { - const ids = await this.listBinderAssetIds(projectId); - await Promise.all(ids.map((id) => this.deleteBinderAsset(projectId, id))); + return retryDb(async () => { + await assertIdbProtectedWriteAllowed(); + const ids = await this.listBinderAssetIds(projectId); + if (ids.length === 0) return; + // QNBS-v3: one transaction for every delete, not one transaction PER asset — a later failure + // aborts the whole batch (IDB rolls back everything already queued in it) instead of + // leaving earlier assets permanently removed while later ones and the project record + // survive. All requests are queued synchronously below the store fetch so IDB never + // auto-commits the transaction mid-batch. + const store = await this.getObjectStore(BINDER_ASSETS_STORE, 'readwrite'); + const transaction = store.transaction; + return new Promise((resolve, reject) => { + let failure: string | undefined; + for (const id of ids) { + const request = store.delete(makeBinderAssetStorageKey(projectId, id)); + request.onerror = () => { + failure = getUserFriendlyDbError(request.error); + transaction.abort(); + }; + } + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(failure ?? getUserFriendlyDbError(transaction.error)); + }); + }); } } diff --git a/services/storage/idbCodexStore.ts b/services/storage/idbCodexStore.ts index c0b90629..859fbc39 100644 --- a/services/storage/idbCodexStore.ts +++ b/services/storage/idbCodexStore.ts @@ -10,6 +10,8 @@ import { compressData, decompressData } from './idbCore'; import { IdbKeyStore } from './idbKeyStore'; import { assertIdbProtectedWriteAllowed, + assertNoActiveEncryptionMigration, + assertSecureStorageReadable, idbEncryptWithKey, idbReadSecure, isEncryptedBlob, @@ -37,6 +39,8 @@ export class IdbCodexStore extends IdbKeyStore { } else { record = processed as object; } + // QNBS-v3: only the migration guard is re-checked here — resolveProtectedWriteKey() already made its own lock check atomically with the key snapshot, so re-running that too would wrongly reject an already-safely-encrypted write if the session locks mid-write. + await assertNoActiveEncryptionMigration(); const store = await this.getObjectStore(CODEX_STORE, 'readwrite'); return new Promise((resolve, reject) => { const request = store.put(record); @@ -46,11 +50,11 @@ export class IdbCodexStore extends IdbKeyStore { } async getStoryCodex(projectId: string): Promise { - await assertIdbProtectedWriteAllowed(); + await assertSecureStorageReadable(); const store = await this.getObjectStore(CODEX_STORE, 'readonly'); return new Promise((resolve, reject) => { const request = store.get(projectId); - request.onsuccess = async () => { + request.onsuccess = () => { const raw = request.result; if (!raw) { resolve(null); @@ -65,7 +69,7 @@ export class IdbCodexStore extends IdbKeyStore { ) { const bytes = new Uint8Array((raw as { encrypted: number[] }).encrypted); if (isEncryptedBlob(bytes)) { - resolve(await idbReadSecure(bytes)); + void idbReadSecure(bytes).then(resolve, reject); return; } } @@ -106,6 +110,8 @@ export class IdbCodexStore extends IdbKeyStore { const encryptedPayload = writeKey ? Array.from(await idbEncryptWithKey(writeKey, { projectId, vectors })) : null; + // QNBS-v3: only the migration guard is re-checked here — the lock check already happened atomically inside resolveProtectedWriteKey(); this function's multiple sequential IDB ops (clear then write) still leave a residual window, but re-running the lock check too would wrongly reject an already-safely-encrypted write if the session locks mid-write. + await assertNoActiveEncryptionMigration(); const store = await this.getObjectStore(RAG_VECTORS_STORE, 'readwrite'); // Clear existing vectors for this project then write the full set const index = store.index('projectId'); @@ -152,11 +158,11 @@ export class IdbCodexStore extends IdbKeyStore { } async getRagVectors(projectId: string): Promise { - await assertIdbProtectedWriteAllowed(); + await assertSecureStorageReadable(); const store = await this.getObjectStore(RAG_VECTORS_STORE, 'readonly'); return new Promise((resolve, reject) => { const req = store.index('projectId').getAll(projectId); - req.onsuccess = async () => { + req.onsuccess = () => { const results = req.result as unknown[]; // QNBS-v3: Check for encrypted blob wrapper (single record with _enc flag) if (results.length === 1) { @@ -164,8 +170,10 @@ export class IdbCodexStore extends IdbKeyStore { if (first?._enc && first.encrypted) { const bytes = new Uint8Array(first.encrypted); if (isEncryptedBlob(bytes)) { - const decrypted = await idbReadSecure<{ vectors: unknown[] }>(bytes); - resolve(decrypted.vectors); + void idbReadSecure<{ vectors: unknown[] }>(bytes).then( + (decrypted) => resolve(decrypted.vectors), + reject, + ); return; } } diff --git a/services/storage/idbCore.ts b/services/storage/idbCore.ts index 91a4efd0..274fd8d2 100644 --- a/services/storage/idbCore.ts +++ b/services/storage/idbCore.ts @@ -96,6 +96,14 @@ export class IdbConnectionManager { protected stateDb: IDBDatabase | null = null; protected dataDb: IDBDatabase | null = null; + protected closeConnections(): void { + // QNBS-v3: Test singletons must release old factories before another fake IndexedDB is installed. + this.stateDb?.close(); + this.dataDb?.close(); + this.stateDb = null; + this.dataDb = null; + } + protected isStateStore(storeName: string): boolean { return storeName === APP_DATA_STORE || storeName === SNAPSHOTS_STORE; } diff --git a/services/storage/idbProjectStore.ts b/services/storage/idbProjectStore.ts index e87d84f5..f51ff03a 100644 --- a/services/storage/idbProjectStore.ts +++ b/services/storage/idbProjectStore.ts @@ -24,6 +24,8 @@ import { IdbAssetStore } from './idbAssetStore'; import { compressData, getUserFriendlyDbError, retryDb } from './idbCore'; import { assertIdbProtectedWriteAllowed, + assertNoActiveEncryptionMigration, + assertSecureStorageReadable, idbEncryptWithKey, idbReadSecure, resolveProtectedWriteKey, @@ -223,6 +225,8 @@ export class IdbProjectStore extends IdbAssetStore { const writeKey = await resolveProtectedWriteKey(); // QNBS-v3: Plaintext is allowed only when encryption was never configured for this library. const payload = writeKey ? await idbEncryptWithKey(writeKey, data) : compressData(data); + // QNBS-v3: only the migration guard is re-checked here — resolveProtectedWriteKey() already made its own lock check atomically with the key snapshot, so re-running that too would wrongly reject an already-safely-encrypted write if the session locks mid-write. + await assertNoActiveEncryptionMigration(); const store = await this.getObjectStore(APP_DATA_STORE, 'readwrite'); return new Promise((resolve, reject) => { const request = store.put(payload, sliceName); @@ -264,6 +268,7 @@ export class IdbProjectStore extends IdbAssetStore { async loadState(): Promise { return retryDb(async () => { + await assertSecureStorageReadable(); const store = await this.getObjectStore(APP_DATA_STORE, 'readonly'); const projectRequest = store.get('project'); const settingsRequest = store.get('settings'); diff --git a/services/storage/idbSnapshotStore.ts b/services/storage/idbSnapshotStore.ts index 46cec74d..ed34722f 100644 --- a/services/storage/idbSnapshotStore.ts +++ b/services/storage/idbSnapshotStore.ts @@ -12,6 +12,8 @@ import { IdbCodexStore } from './idbCodexStore'; import { compressData, getUserFriendlyDbError, retryDb } from './idbCore'; import { assertIdbProtectedWriteAllowed, + assertNoActiveEncryptionMigration, + assertSecureStorageReadable, idbEncryptWithKey, idbReadSecure, resolveProtectedWriteKey, @@ -41,6 +43,8 @@ export class IdbSnapshotStore extends IdbCodexStore { }; return retryDb(async () => { + // QNBS-v3: only the migration guard is re-checked here — resolveProtectedWriteKey() already made its own lock check atomically with the key snapshot, so re-running that too would wrongly reject an already-safely-encrypted write if the session locks mid-write. + await assertNoActiveEncryptionMigration(); const store = await this.getObjectStore(SNAPSHOTS_STORE, 'readwrite'); return new Promise((resolve, reject) => { const request = store.add(snapshotData); @@ -56,9 +60,8 @@ export class IdbSnapshotStore extends IdbCodexStore { async listSnapshots(): Promise { return retryDb(async () => { - // QNBS-v3: Snapshot metadata (name/date/word count) is about protected content — a locked - // session must not be able to enumerate it either, even without the payload. - await assertIdbProtectedWriteAllowed(); + // QNBS-v3: Snapshot metadata (name/date/word count) is about protected content — the superset check blocks a locked session, or an active journal migration, from enumerating it. + await assertSecureStorageReadable(); const store = await this.getObjectStore(SNAPSHOTS_STORE, 'readonly'); // IDBKeyRange: iterate in reverse (newest first) using cursor direction 'prev' const request = store.openCursor(null, 'prev'); @@ -82,18 +85,20 @@ export class IdbSnapshotStore extends IdbCodexStore { async getSnapshotData(id: number): Promise { return retryDb(async () => { - // QNBS-v3: Explicit guard up front (idbReadSecure's legacy-plaintext branch already checks - // this internally) so a missing record can't skip the lock check before decode. - await assertIdbProtectedWriteAllowed(); + // QNBS-v3: superset check — blocks a locked session or an active journal migration before the record lookup can even run. + await assertSecureStorageReadable(); const store = await this.getObjectStore(SNAPSHOTS_STORE, 'readonly'); return new Promise((resolve, reject) => { const request = store.get(id); // QNBS-v3: IDBRequest.onsuccess is not awaited by the browser — an unhandled rejection // here would leave the caller pending instead of surfacing the error. request.onsuccess = () => { - const raw: unknown = request.result?.data; + if (request.result === undefined) { + reject(new Error(`Snapshot ${id} was not found`)); + return; + } // QNBS-v3: Decrypt encrypted snapshot payload; legacy plaintext falls through decompressData. - idbReadSecure(raw).then(resolve).catch(reject); + idbReadSecure(request.result.data).then(resolve).catch(reject); }; request.onerror = () => reject(getUserFriendlyDbError(request.error)); }); diff --git a/services/storage/protectedStoreMigration.ts b/services/storage/protectedStoreMigration.ts new file mode 100644 index 00000000..de32c0da --- /dev/null +++ b/services/storage/protectedStoreMigration.ts @@ -0,0 +1,313 @@ +/** + * Journal-owned execution protocol for protected-store adapters. + * QNBS-v3: Cross-database IndexedDB work is a resumable saga, never a pretend global transaction. + */ + +import { + claimEncryptionMigrationOwnership, + type EncryptionMigrationJournal, + type EncryptionMigrationOperation, + type EncryptionMigrationStoreCheckpoint, + releaseEncryptionMigrationOwnership, + updateEncryptionMigrationJournal, +} from './encryptionMigrationJournal'; +import { assertIdbMigrationTargetKeyMatchesVerifier } from './storageEncryptionService'; + +export interface EncryptionMigrationKeys { + sourceKey?: CryptoKey; + targetKey?: CryptoKey; +} + +export interface ProtectedStoreMigrationBatch { + /** Last logical id durably written by this batch; omit only before any record was committed. */ + cursor?: string; + processed: number; + complete: boolean; +} + +export interface ProtectedStoreAdapterContext extends EncryptionMigrationKeys { + operation: EncryptionMigrationOperation; + cursor?: string; +} + +export interface ProtectedStoreAdapter { + id: string; + /** Required because a crash can occur after a store transaction commits but before its journal checkpoint. */ + replaySafe: true; + /** Converts one bounded, transaction-confirmed batch. */ + migrateNext(context: ProtectedStoreAdapterContext): Promise; + /** Reads every relevant record under the post-migration policy without changing storage. */ + verify(context: Omit): Promise; +} + +export class ProtectedStoreMigrationAdapterError extends Error { + constructor(message: string) { + super(message); + this.name = 'ProtectedStoreMigrationAdapterError'; + } +} + +// QNBS-v3: distinct from a transient/interrupted verify() throw — a shortfall means the adapter re-scanned and found fewer valid records than this saga already migrated, most likely because an ordinary write landed on an already-migrated record using a superseded key after this store's migrating pass finished; nothing in this saga can revisit and reconvert that record, so blindly retrying verify() would fail identically forever. +export class ProtectedStoreVerificationShortfallError extends ProtectedStoreMigrationAdapterError { + constructor(message: string) { + super(message); + this.name = 'ProtectedStoreVerificationShortfallError'; + } +} + +function checkpointFor( + journal: EncryptionMigrationJournal, + adapterId: string, +): EncryptionMigrationStoreCheckpoint { + const checkpoint = journal.stores.find((candidate) => candidate.id === adapterId); + if (!checkpoint) { + throw new ProtectedStoreMigrationAdapterError( + `Migration journal is missing the registered store checkpoint ${adapterId}`, + ); + } + return checkpoint; +} + +function replaceCheckpoint( + journal: EncryptionMigrationJournal, + replacement: EncryptionMigrationStoreCheckpoint, +): EncryptionMigrationStoreCheckpoint[] { + return journal.stores.map((checkpoint) => + checkpoint.id === replacement.id ? replacement : checkpoint, + ); +} + +function nextCheckpoint( + checkpoint: EncryptionMigrationStoreCheckpoint, + batch: ProtectedStoreMigrationBatch, +): EncryptionMigrationStoreCheckpoint { + if (!Number.isSafeInteger(batch.processed) || batch.processed < 0) { + throw new ProtectedStoreMigrationAdapterError( + `Store ${checkpoint.id} returned invalid progress`, + ); + } + if (!batch.complete && batch.processed === 0) { + throw new ProtectedStoreMigrationAdapterError( + `Store ${checkpoint.id} made no progress without completing`, + ); + } + // QNBS-v3: a nonterminal batch that reports progress without a cursor would replay the same records forever, inflating processed without advancing. + if (!batch.complete && batch.cursor === undefined) { + throw new ProtectedStoreMigrationAdapterError( + `Store ${checkpoint.id} reported progress without advancing its cursor`, + ); + } + const cursor = batch.cursor ?? checkpoint.cursor; + return { + ...checkpoint, + ...(cursor !== undefined ? { cursor } : {}), + processed: checkpoint.processed + batch.processed, + done: batch.complete, + }; +} + +function migrationContext( + journal: EncryptionMigrationJournal, + checkpoint: EncryptionMigrationStoreCheckpoint, + keys: EncryptionMigrationKeys, +): ProtectedStoreAdapterContext { + return { + operation: journal.operation, + ...(checkpoint.cursor !== undefined ? { cursor: checkpoint.cursor } : {}), + ...(keys.sourceKey ? { sourceKey: keys.sourceKey } : {}), + ...(keys.targetKey ? { targetKey: keys.targetKey } : {}), + }; +} + +function yieldAfterCheckpoint(): Promise { + // QNBS-v3: WebCrypto/IDB batches must yield so migration progress never monopolizes the renderer event loop. + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +function assertRegisteredAdapters( + journal: EncryptionMigrationJournal, + adapters: readonly ProtectedStoreAdapter[], +): void { + const ids = new Set(adapters.map((adapter) => adapter.id)); + const checkpointIds = new Set(journal.stores.map((checkpoint) => checkpoint.id)); + if (ids.size !== adapters.length) { + throw new ProtectedStoreMigrationAdapterError('Protected-store adapter ids must be unique'); + } + if (checkpointIds.size !== journal.stores.length) { + throw new ProtectedStoreMigrationAdapterError( + 'Migration journal store checkpoint ids must be unique', + ); + } + if (adapters.some((adapter) => adapter.replaySafe !== true)) { + throw new ProtectedStoreMigrationAdapterError( + 'Every protected-store adapter must explicitly guarantee replay-safe batches', + ); + } + for (const checkpoint of journal.stores) { + if (!ids.has(checkpoint.id)) { + throw new ProtectedStoreMigrationAdapterError( + `No registered protected-store adapter exists for ${checkpoint.id}`, + ); + } + } + for (const adapter of adapters) { + if (!checkpointIds.has(adapter.id)) { + throw new ProtectedStoreMigrationAdapterError( + `Registered protected-store adapter ${adapter.id} is missing from the migration journal`, + ); + } + } +} + +function assertRequiredKeys( + operation: EncryptionMigrationOperation, + keys: EncryptionMigrationKeys, +): void { + if (operation === 'enable' && !keys.targetKey) { + throw new ProtectedStoreMigrationAdapterError('Enable migration requires a target key'); + } + if (operation === 'disable' && !keys.sourceKey) { + throw new ProtectedStoreMigrationAdapterError('Disable migration requires a source key'); + } + if (operation === 'rekey' && (!keys.sourceKey || !keys.targetKey)) { + throw new ProtectedStoreMigrationAdapterError( + 'Rekey migration requires source and target keys', + ); + } +} + +async function assertTargetKeyMatchesJournal( + journal: EncryptionMigrationJournal, + keys: EncryptionMigrationKeys, +): Promise { + if (journal.operation === 'disable') return; + if (!keys.targetKey || !journal.targetVerifier || journal.targetVerifier.length === 0) { + throw new ProtectedStoreMigrationAdapterError( + 'Enable and rekey migrations require an authenticated target verifier', + ); + } + try { + await assertIdbMigrationTargetKeyMatchesVerifier(keys.targetKey, journal.targetVerifier); + } catch { + throw new ProtectedStoreMigrationAdapterError( + 'Migration target key does not match the durable target verifier', + ); + } +} + +function createMigrationOwnerId(): string { + if (typeof crypto.randomUUID === 'function') return crypto.randomUUID(); + const bytes = crypto.getRandomValues(new Uint8Array(16)); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +/** + * Convert all registered stores and checkpoint each durable batch. The returned journal is in + * `committing`; only the caller that can atomically change verifier metadata may complete it. + */ +export async function runProtectedStoreMigration( + initialJournal: EncryptionMigrationJournal, + adapters: readonly ProtectedStoreAdapter[], + keys: EncryptionMigrationKeys, +): Promise { + let journal = initialJournal; + if (journal.phase === 'recovery-required') { + throw new ProtectedStoreMigrationAdapterError( + 'Recovery-required journal cannot run until an explicit recovery procedure validates it', + ); + } + assertRegisteredAdapters(journal, adapters); + assertRequiredKeys(journal.operation, keys); + let ownsLease = false; + try { + // QNBS-v3: Claim before the first adapter mutation so two tabs cannot transform the same batch concurrently. + journal = await claimEncryptionMigrationOwnership(journal, createMigrationOwnerId()); + ownsLease = true; + await assertTargetKeyMatchesJournal(journal, keys); + if (journal.phase === 'prepared') { + journal = await updateEncryptionMigrationJournal(journal, { + phase: 'migrating', + stores: journal.stores, + }); + } + if ( + journal.phase !== 'migrating' && + journal.phase !== 'verifying' && + journal.phase !== 'committing' + ) { + throw new ProtectedStoreMigrationAdapterError( + `Cannot execute protected-store migration from ${journal.phase}`, + ); + } + + if (journal.phase === 'migrating') { + for (const adapter of adapters) { + let checkpoint = checkpointFor(journal, adapter.id); + while (!checkpoint.done) { + const batch = await adapter.migrateNext(migrationContext(journal, checkpoint, keys)); + checkpoint = nextCheckpoint(checkpoint, batch); + journal = await updateEncryptionMigrationJournal(journal, { + phase: 'migrating', + stores: replaceCheckpoint(journal, checkpoint), + }); + await yieldAfterCheckpoint(); + } + } + journal = await updateEncryptionMigrationJournal(journal, { + phase: 'verifying', + stores: journal.stores, + }); + } + + if (journal.phase === 'verifying') { + for (const adapter of adapters) { + const checkpoint = checkpointFor(journal, adapter.id); + if (checkpoint.done && checkpoint.verified >= checkpoint.processed) continue; + const verified = await adapter.verify({ + operation: journal.operation, + ...(keys.sourceKey ? { sourceKey: keys.sourceKey } : {}), + ...(keys.targetKey ? { targetKey: keys.targetKey } : {}), + }); + if (!Number.isSafeInteger(verified) || verified < checkpoint.processed) { + throw new ProtectedStoreVerificationShortfallError( + `Store ${adapter.id} verification is incomplete`, + ); + } + journal = await updateEncryptionMigrationJournal(journal, { + phase: 'verifying', + stores: replaceCheckpoint(journal, { ...checkpoint, verified }), + }); + } + journal = await updateEncryptionMigrationJournal(journal, { + phase: 'committing', + stores: journal.stores, + }); + } + + return journal; + } catch (error) { + if ( + ownsLease && + journal.phase === 'verifying' && + error instanceof ProtectedStoreVerificationShortfallError + ) { + // QNBS-v3: retrying verify() alone can never fix a shortfall — mark recovery-required so the stuck state is visible instead of an indefinite, silently-failing retry loop. + try { + journal = await updateEncryptionMigrationJournal(journal, { + phase: 'recovery-required', + stores: journal.stores, + }); + } catch { + // QNBS-v3: best-effort — the original verification error still propagates below either way. + } + } + if (ownsLease && journal.phase !== 'committing') { + try { + await releaseEncryptionMigrationOwnership(journal); + } catch { + // QNBS-v3: A lost lease must never overwrite a newer recovery owner's durable state. + } + } + throw error; + } +} diff --git a/services/storage/secondaryPayloadStoreAdapter.ts b/services/storage/secondaryPayloadStoreAdapter.ts new file mode 100644 index 00000000..34892b37 --- /dev/null +++ b/services/storage/secondaryPayloadStoreAdapter.ts @@ -0,0 +1,397 @@ +/** + * Concrete IndexedDB adapter factory for secondary records with a routing shell and protected payload. + * QNBS-v3: Each batch commits before the journal advances, so a crash can only replay idempotent work. + */ + +import { + type ProtectedStoreAdapter, + type ProtectedStoreAdapterContext, + ProtectedStoreMigrationAdapterError, + type ProtectedStoreMigrationBatch, +} from './protectedStoreMigration'; +import { + isSecureRecordEnvelope, + isSecureRecordEnvelopeCandidate, + prepareSecureRecordPayloadWithKey, + readSecureRecordPayloadWithKey, + type SecureRecordContext, + SecureRecordCorruptError, + type SecureRecordEnvelope, +} from './storageEncryptionService'; + +const DEFAULT_BATCH_SIZE = 25; + +export interface SecondaryPayloadStoreAdapterSpec { + id: string; + databaseName: string; + storeName: string; + /** The IndexedDB key is a stable string so journal cursors remain portable and deterministic. */ + recordId(record: Record): string; + context(recordId: string): SecureRecordContext; + /** Extracts the legacy flat payload or the current stored payload from a structured-clone record. */ + payload(record: Record): unknown; + /** Rejects malformed or semantically mismatched plaintext before it can be transformed. */ + isPayload(value: unknown): value is Payload; + /** Returns the record with only its protected payload changed; routing/index fields must remain intact. */ + withPayload(record: Record, payload: Payload | SecureRecordEnvelope): Record; + batchSize?: number; +} + +interface Batch { + records: Record[]; + complete: boolean; +} + +interface PendingWrite { + recordId: string; + original: Record; + replacement: Record; +} + +export class ProtectedStoreMigrationConflictError extends ProtectedStoreMigrationAdapterError { + constructor(storeId: string, recordId: string) { + super(`Protected-store migration detected a concurrent update in ${storeId}/${recordId}`); + this.name = 'ProtectedStoreMigrationConflictError'; + } +} + +function requireKey(key: CryptoKey | undefined, operation: string, name: string): CryptoKey { + if (!key) { + throw new ProtectedStoreMigrationAdapterError( + `${name} requires a ${operation} key for the protected-store operation`, + ); + } + return key; +} + +function hasMalformedEnvelope(payload: unknown): boolean { + return isSecureRecordEnvelopeCandidate(payload) && !isSecureRecordEnvelope(payload); +} + +async function openExistingDatabase(name: string): Promise { + return new Promise((resolve, reject) => { + let created = false; + let request: IDBOpenDBRequest; + try { + request = indexedDB.open(name); + } catch (error) { + reject(error); + return; + } + request.onupgradeneeded = (event) => { + if (event.oldVersion === 0) { + created = true; + // QNBS-v3: Abort an accidental open of an optional store rather than leaving an empty database behind. + request.transaction?.abort(); + } + }; + request.onsuccess = () => { + const database = request.result; + if (created) { + database.close(); + resolve(null); + return; + } + resolve(database); + }; + request.onerror = () => { + if (created) { + resolve(null); + return; + } + reject(request.error ?? new Error(`Could not open ${name}`)); + }; + request.onblocked = () => reject(new Error(`Opening ${name} is blocked by another client`)); + }); +} + +async function readBatch( + database: IDBDatabase, + storeName: string, + cursor: string | undefined, + batchSize: number, +): Promise> { + return new Promise((resolve, reject) => { + const transaction = database.transaction(storeName, 'readonly'); + const store = transaction.objectStore(storeName); + const range = cursor ? IDBKeyRange.lowerBound(cursor, true) : undefined; + const request = store.openCursor(range); + const records: Record[] = []; + request.onsuccess = () => { + const result = request.result; + if (!result) { + resolve({ records, complete: true }); + return; + } + records.push(result.value as Record); + if (records.length >= batchSize) { + resolve({ records, complete: false }); + return; + } + result.continue(); + }; + request.onerror = () => reject(request.error); + transaction.onabort = () => reject(transaction.error ?? new Error(`${storeName} read aborted`)); + }); +} + +function recordsMatch(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true; + if (left instanceof Uint8Array && right instanceof Uint8Array) { + return left.length === right.length && left.every((byte, index) => byte === right[index]); + } + if (left instanceof Date && right instanceof Date) return left.getTime() === right.getTime(); + if (Array.isArray(left) && Array.isArray(right)) { + return ( + left.length === right.length && + left.every((value, index) => recordsMatch(value, right[index])) + ); + } + if ( + typeof left !== 'object' || + left === null || + typeof right !== 'object' || + right === null || + Object.getPrototypeOf(left) !== Object.prototype || + Object.getPrototypeOf(right) !== Object.prototype + ) { + return false; + } + const leftRecord = left as Record; + const rightRecord = right as Record; + const leftKeys = Object.keys(leftRecord).sort(); + const rightKeys = Object.keys(rightRecord).sort(); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every( + (key, index) => key === rightKeys[index] && recordsMatch(leftRecord[key], rightRecord[key]), + ) + ); +} + +async function writeBatch( + database: IDBDatabase, + storeName: string, + storeId: string, + records: readonly PendingWrite[], +): Promise { + if (records.length === 0) return; + await new Promise((resolve, reject) => { + const transaction = database.transaction(storeName, 'readwrite'); + const store = transaction.objectStore(storeName); + let failure: Error | undefined; + let aborting = false; + const abortForFailure = (error: Error) => { + failure = error; + if (aborting) return; + aborting = true; + try { + transaction.abort(); + } catch { + // QNBS-v3: the request error already aborted the transaction; onabort still reports `failure`. + } + }; + for (const record of records) { + const request = store.get(record.recordId); + request.onsuccess = () => { + if (!recordsMatch(request.result, record.original)) { + abortForFailure(new ProtectedStoreMigrationConflictError(storeId, record.recordId)); + return; + } + const putRequest = store.put(record.replacement); + putRequest.onerror = () => + abortForFailure(putRequest.error ?? new Error(`${storeName} write failed`)); + }; + request.onerror = () => + abortForFailure(request.error ?? new Error(`${storeName} read failed`)); + } + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => + reject(failure ?? transaction.error ?? new Error(`${storeName} write aborted`)); + }); +} + +async function transformForOperation( + spec: SecondaryPayloadStoreAdapterSpec, + record: Record, + context: ProtectedStoreAdapterContext, +): Promise { + const recordId = spec.recordId(record); + const payload = spec.payload(record); + const secureContext = spec.context(recordId); + if (hasMalformedEnvelope(payload)) throw new SecureRecordCorruptError(); + + switch (context.operation) { + case 'enable': { + const targetKey = requireKey(context.targetKey, 'target', spec.id); + if (isSecureRecordEnvelope(payload)) { + const decoded = await readSecureRecordPayloadWithKey( + payload, + secureContext, + targetKey, + ); + if (!spec.isPayload(decoded.value)) throw new SecureRecordCorruptError(); + if (!decoded.needsMigration) return null; + return spec.withPayload( + record, + await prepareSecureRecordPayloadWithKey(decoded.value, secureContext, targetKey), + ); + } + if (!spec.isPayload(payload)) throw new SecureRecordCorruptError(); + return spec.withPayload( + record, + await prepareSecureRecordPayloadWithKey(payload, secureContext, targetKey), + ); + } + case 'rekey': { + const sourceKey = requireKey(context.sourceKey, 'source', spec.id); + const targetKey = requireKey(context.targetKey, 'target', spec.id); + if (!isSecureRecordEnvelope(payload)) { + if (!spec.isPayload(payload)) throw new SecureRecordCorruptError(); + return spec.withPayload( + record, + await prepareSecureRecordPayloadWithKey(payload, secureContext, targetKey), + ); + } + try { + const target = await readSecureRecordPayloadWithKey( + payload, + secureContext, + targetKey, + ); + if (!spec.isPayload(target.value)) throw new SecureRecordCorruptError(); + if (!target.needsMigration) return null; + return spec.withPayload( + record, + await prepareSecureRecordPayloadWithKey(target.value, secureContext, targetKey), + ); + } catch (targetError) { + try { + const source = await readSecureRecordPayloadWithKey( + payload, + secureContext, + sourceKey, + ); + if (!spec.isPayload(source.value)) throw new SecureRecordCorruptError(); + return spec.withPayload( + record, + await prepareSecureRecordPayloadWithKey(source.value, secureContext, targetKey), + ); + } catch { + throw targetError; + } + } + } + case 'disable': { + if (!isSecureRecordEnvelope(payload)) return null; + const sourceKey = requireKey(context.sourceKey, 'source', spec.id); + const decoded = await readSecureRecordPayloadWithKey( + payload, + secureContext, + sourceKey, + ); + if (!spec.isPayload(decoded.value)) throw new SecureRecordCorruptError(); + return spec.withPayload(record, decoded.value); + } + } +} + +async function verifyRecord( + spec: SecondaryPayloadStoreAdapterSpec, + record: Record, + context: Omit, +): Promise { + const payload = spec.payload(record); + if (hasMalformedEnvelope(payload)) throw new SecureRecordCorruptError(); + const secureContext = spec.context(spec.recordId(record)); + if (context.operation === 'disable') { + if (isSecureRecordEnvelope(payload)) { + throw new ProtectedStoreMigrationAdapterError(`${spec.id} retained ciphertext after disable`); + } + if (!spec.isPayload(payload)) throw new SecureRecordCorruptError(); + return; + } + if (!isSecureRecordEnvelope(payload)) { + throw new ProtectedStoreMigrationAdapterError(`${spec.id} retained plaintext after migration`); + } + const key = requireKey(context.targetKey, 'target', spec.id); + const decoded = await readSecureRecordPayloadWithKey(payload, secureContext, key); + if (!spec.isPayload(decoded.value)) throw new SecureRecordCorruptError(); + if (decoded.needsMigration) { + throw new ProtectedStoreMigrationAdapterError(`${spec.id} retained a legacy envelope`); + } +} + +/** Build an idempotent adapter for a store whose encrypted content lives in one nested payload field. */ +export function createSecondaryPayloadStoreAdapter( + spec: SecondaryPayloadStoreAdapterSpec, +): ProtectedStoreAdapter { + const batchSize = spec.batchSize ?? DEFAULT_BATCH_SIZE; + if (!Number.isSafeInteger(batchSize) || batchSize < 1) { + throw new ProtectedStoreMigrationAdapterError(`${spec.id} has an invalid batch size`); + } + + async function withDatabase( + operation: (database: IDBDatabase | null) => Promise, + ): Promise { + const database = await openExistingDatabase(spec.databaseName); + try { + if (database && !database.objectStoreNames.contains(spec.storeName)) + return await operation(null); + return await operation(database); + } finally { + database?.close(); + } + } + + return { + id: spec.id, + // QNBS-v3: Re-keying probes the target key first, so a batch committed before its journal checkpoint can be replayed safely. + replaySafe: true, + async migrateNext(context): Promise { + return withDatabase(async (database) => { + if (!database) return { processed: 0, complete: true }; + const batch = await readBatch(database, spec.storeName, context.cursor, batchSize); + const rewritten: PendingWrite[] = []; + for (const record of batch.records) { + const next = await transformForOperation(spec, record, context); + if (next) { + rewritten.push({ + recordId: spec.recordId(record), + original: record, + replacement: next, + }); + } + } + await writeBatch(database, spec.storeName, spec.id, rewritten); + const last = batch.records.at(-1); + return { + processed: batch.records.length, + complete: batch.complete, + ...(last ? { cursor: spec.recordId(last) } : {}), + }; + }); + }, + async verify(context): Promise { + return withDatabase(async (database) => { + if (!database) return 0; + let cursor: string | undefined; + let verified = 0; + while (true) { + const batch = await readBatch(database, spec.storeName, cursor, batchSize); + for (const record of batch.records) await verifyRecord(spec, record, context); + verified += batch.records.length; + const last = batch.records.at(-1); + if (batch.complete) return verified; + if (!last) { + throw new ProtectedStoreMigrationAdapterError( + `${spec.id} verification made no progress`, + ); + } + cursor = spec.recordId(last); + } + }); + }, + }; +} diff --git a/services/storage/secondaryProtectedStoreAdapters.ts b/services/storage/secondaryProtectedStoreAdapters.ts new file mode 100644 index 00000000..8e885a4c --- /dev/null +++ b/services/storage/secondaryProtectedStoreAdapters.ts @@ -0,0 +1,181 @@ +/** + * Authoritative adapters for secondary stores that have a plaintext routing shell and protected content payload. + * QNBS-v3: Unknown record shapes fail recovery rather than dropping forward-compatible data or preserving secrets. + */ + +import { + type ProtectedStoreAdapter, + ProtectedStoreMigrationAdapterError, +} from './protectedStoreMigration'; +import { + createSecondaryPayloadStoreAdapter, + type SecondaryPayloadStoreAdapterSpec, +} from './secondaryPayloadStoreAdapter'; +import type { SecureRecordEnvelope } from './storageEncryptionService'; + +const SCENE_REVISIONS_DB = 'worldscript-revisions-db'; +const SCENE_REVISIONS_STORE = 'scene-revisions'; +const INFERENCE_CACHE_DB = 'worldscript-inference-cache-db'; +const INFERENCE_CACHE_STORE = 'inference-cache'; + +interface SceneRevisionPayload { + title: string; + content: string; + wordCount: number; + label?: string; + authorName?: string; +} + +interface SceneRevisionRecord extends Record { + id: string; + sectionId: string; + createdAt: number; + schemaVersion?: number; + payload?: unknown; + title?: string; + content?: string; + wordCount?: number; + label?: string; + authorName?: string; +} + +interface CachePayload { + result: string; +} + +interface CacheRecord extends Record { + key: string; + timestamp: number; + payload?: unknown; + result?: string; +} + +function assertExactKeys( + value: Record, + allowed: readonly string[], + store: string, +): void { + if (Object.keys(value).some((key) => !allowed.includes(key))) { + throw new ProtectedStoreMigrationAdapterError( + `${store} contains an unsupported record schema; recovery must preserve it before migration`, + ); + } +} + +// QNBS-v3: IndexedDB allows numeric keys, so an unvalidated routing key would persist a non-string journal cursor that parseJournal() later rejects into recovery-required; fail fast instead. +function assertStringRoutingKey(value: unknown, field: string, store: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new ProtectedStoreMigrationAdapterError( + `${store} record has a non-string ${field}; recovery must reconcile it before migration`, + ); + } + return value; +} + +function isSceneRevisionPayload(value: unknown): value is SceneRevisionPayload { + if (typeof value !== 'object' || value === null) return false; + const payload = value as Partial; + return ( + typeof payload.title === 'string' && + typeof payload.content === 'string' && + typeof payload.wordCount === 'number' && + (payload.label === undefined || typeof payload.label === 'string') && + (payload.authorName === undefined || typeof payload.authorName === 'string') + ); +} + +function isCachePayload(value: unknown): value is CachePayload { + return ( + typeof value === 'object' && + value !== null && + typeof (value as Partial).result === 'string' + ); +} + +function scenePayload(record: SceneRevisionRecord): unknown { + if (record.payload !== undefined) { + assertExactKeys( + record, + ['id', 'sectionId', 'createdAt', 'schemaVersion', 'payload'], + 'scene revisions', + ); + if (record.schemaVersion !== 1) { + throw new ProtectedStoreMigrationAdapterError( + 'Scene revisions have an unsupported schema version', + ); + } + return record.payload; + } + assertExactKeys( + record, + ['id', 'sectionId', 'createdAt', 'title', 'content', 'wordCount', 'label', 'authorName'], + 'scene revisions', + ); + return { + title: record.title, + content: record.content, + wordCount: record.wordCount, + ...(record.label !== undefined ? { label: record.label } : {}), + ...(record.authorName !== undefined ? { authorName: record.authorName } : {}), + }; +} + +function cachePayload(record: CacheRecord): unknown { + if (record.payload !== undefined) { + assertExactKeys(record, ['key', 'timestamp', 'payload'], 'inference cache'); + return record.payload; + } + assertExactKeys(record, ['key', 'timestamp', 'result'], 'inference cache'); + return { result: record.result }; +} + +const sceneRevisionAdapterSpec: SecondaryPayloadStoreAdapterSpec< + SceneRevisionRecord, + SceneRevisionPayload +> = { + id: `${SCENE_REVISIONS_DB}/${SCENE_REVISIONS_STORE}`, + databaseName: SCENE_REVISIONS_DB, + storeName: SCENE_REVISIONS_STORE, + recordId: (record) => assertStringRoutingKey(record.id, 'id', 'scene revisions'), + context: (recordId) => ({ + store: `${SCENE_REVISIONS_DB}/${SCENE_REVISIONS_STORE}`, + recordId, + legacyStores: [SCENE_REVISIONS_STORE], + }), + payload: scenePayload, + isPayload: isSceneRevisionPayload, + withPayload: (record, payload: SceneRevisionPayload | SecureRecordEnvelope) => ({ + id: record.id, + sectionId: record.sectionId, + createdAt: record.createdAt, + schemaVersion: 1, + payload, + }), +}; + +const inferenceCacheAdapterSpec: SecondaryPayloadStoreAdapterSpec = { + id: `${INFERENCE_CACHE_DB}/${INFERENCE_CACHE_STORE}`, + databaseName: INFERENCE_CACHE_DB, + storeName: INFERENCE_CACHE_STORE, + recordId: (record) => assertStringRoutingKey(record.key, 'key', 'inference cache'), + context: (recordId) => ({ + store: `${INFERENCE_CACHE_DB}/${INFERENCE_CACHE_STORE}`, + recordId, + legacyStores: [INFERENCE_CACHE_STORE], + }), + payload: cachePayload, + isPayload: isCachePayload, + withPayload: (record, payload: CachePayload | SecureRecordEnvelope) => ({ + key: record.key, + timestamp: record.timestamp, + payload, + }), +}; + +/** Return a fresh immutable adapter list so migration callers cannot mutate the central registration. */ +export function getRegisteredSecondaryProtectedStoreAdapters(): readonly ProtectedStoreAdapter[] { + return [ + createSecondaryPayloadStoreAdapter(sceneRevisionAdapterSpec), + createSecondaryPayloadStoreAdapter(inferenceCacheAdapterSpec), + ]; +} diff --git a/services/storage/secureRecordCodec.ts b/services/storage/secureRecordCodec.ts new file mode 100644 index 00000000..1d1d4115 --- /dev/null +++ b/services/storage/secureRecordCodec.ts @@ -0,0 +1,109 @@ +/** + * Versioned structured-clone-safe codec for encrypted secondary-store payloads. + * QNBS-v3: JSON.stringify destroys Blob bytes, so protected records use an explicit binary codec. + */ + +const CODEC_VERSION = 1 as const; + +type EncodedNode = + | { k: 'null' } + | { k: 'undef' } + | { k: 'bool'; v: boolean } + | { k: 'num'; v: number } + | { k: 'str'; v: string } + | { k: 'date'; v: string } + | { k: 'arr'; v: EncodedNode[] } + | { k: 'obj'; v: Record } + | { k: 'blob'; mime: string; bytes: number[] } + | { k: 'u8'; v: number[] }; + +function encodeScalarNode(value: unknown): EncodedNode { + if (value === null) return { k: 'null' }; + if (value === undefined) return { k: 'undef' }; + if (typeof value === 'boolean') return { k: 'bool', v: value }; + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new Error('Secure-record codec cannot encode a non-finite number'); + } + return { k: 'num', v: value }; + } + if (typeof value === 'string') return { k: 'str', v: value }; + if (value instanceof Date) { + if (Number.isNaN(value.getTime())) { + throw new Error('Secure-record codec cannot encode an invalid date'); + } + return { k: 'date', v: value.toISOString() }; + } + if (value instanceof Uint8Array) return { k: 'u8', v: Array.from(value) }; + throw new Error(`Secure-record codec cannot encode ${typeof value}`); +} + +async function encodeValueNode(value: unknown): Promise { + if (value instanceof Blob) { + const bytes = new Uint8Array(await value.arrayBuffer()); + return { k: 'blob', mime: value.type || 'application/octet-stream', bytes: Array.from(bytes) }; + } + if (Array.isArray(value)) { + return { k: 'arr', v: await Promise.all(value.map((item) => encodeValueNode(item))) }; + } + if (value instanceof Date || value instanceof Uint8Array) return encodeScalarNode(value); + if (value !== null && typeof value === 'object') { + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new Error('Secure-record codec cannot encode a non-plain structured-clone object'); + } + const encodedEntries = await Promise.all( + Object.entries(value as Record).map( + async ([key, child]) => [key, await encodeValueNode(child)] as const, + ), + ); + return { k: 'obj', v: Object.fromEntries(encodedEntries) }; + } + return encodeScalarNode(value); +} + +function decodeNode(node: EncodedNode): unknown { + switch (node.k) { + case 'null': + return null; + case 'undef': + return undefined; + case 'bool': + case 'num': + case 'str': + return node.v; + case 'date': { + const value = new Date(node.v); + if (Number.isNaN(value.getTime())) + throw new Error('Secure-record codec contains an invalid date'); + return value; + } + case 'u8': + return new Uint8Array(node.v); + case 'arr': + return node.v.map((child) => decodeNode(child)); + case 'obj': + return Object.fromEntries( + Object.entries(node.v).map(([key, child]) => [key, decodeNode(child)]), + ); + case 'blob': + return new Blob([new Uint8Array(node.bytes)], { type: node.mime }); + default: + throw new Error('Unsupported secure-record codec node'); + } +} + +/** Serialize a payload for AES-GCM encryption without losing Blob bytes. */ +export async function encodeSecureRecordValue(value: unknown): Promise { + const root = await encodeValueNode(value); + return new TextEncoder().encode(JSON.stringify({ v: CODEC_VERSION, root })); +} + +/** Deserialize bytes produced by encodeSecureRecordValue with strict format validation. */ +export function decodeSecureRecordValue(bytes: Uint8Array): unknown { + const parsed = JSON.parse(new TextDecoder().decode(bytes)) as { v?: unknown; root?: unknown }; + if (parsed.v !== CODEC_VERSION || !parsed.root || typeof parsed.root !== 'object') { + throw new Error('Unsupported or malformed secure-record codec payload'); + } + return decodeNode(parsed.root as EncodedNode); +} diff --git a/services/storage/storageEncryptionService.ts b/services/storage/storageEncryptionService.ts index 8dcaa861..9d1fc208 100644 --- a/services/storage/storageEncryptionService.ts +++ b/services/storage/storageEncryptionService.ts @@ -11,8 +11,13 @@ * If no sentinel exists the feature flag is silently cleared (App.tsx startup guard). */ +import { assertNoActiveEncryptionMigration } from './encryptionMigrationJournal'; import { decompressData } from './idbCore'; import { getPassphraseSentinel, savePassphraseSentinel } from './idbPassphraseSentinel'; +import { decodeSecureRecordValue, encodeSecureRecordValue } from './secureRecordCodec'; + +// QNBS-v3: re-exported so a write that already captured its key via resolveProtectedWriteKey() can re-check only the migration guard pre-write, without re-running its redundant lock check. +export { assertNoActiveEncryptionMigration } from './encryptionMigrationJournal'; const PBKDF2_ITERATIONS = 600_000; // OWASP 2024 minimum for PBKDF2-HMAC-SHA-256 const IV_BYTE_LENGTH = 12; @@ -29,6 +34,32 @@ export interface EncryptedBlob { bytes: Uint8Array; } +const LEGACY_BOUND_SECURE_RECORD_VERSION = 1 as const; +export const SECURE_RECORD_VERSION = 2 as const; +type SecureRecordVersion = typeof LEGACY_BOUND_SECURE_RECORD_VERSION | typeof SECURE_RECORD_VERSION; + +/** Structured-clone-safe AES-GCM envelope for a secondary-store payload. */ +export interface SecureRecordEnvelope { + version: SecureRecordVersion; + iv: Uint8Array; + ciphertext: Uint8Array; +} + +export interface SecureRecordContext { + /** Stable protected-store namespace, including the database where names can collide. */ + store: string; + /** Immutable logical record identity used as AES-GCM additional authenticated data. */ + recordId: string; + /** Prior documented namespaces accepted only for a verified one-way migration. */ + legacyStores?: readonly string[]; +} + +export interface SecureRecordReadResult { + value: T; + /** True only when an unlocked legacy plaintext or legacy envelope needs a safe rewrite. */ + needsMigration: boolean; +} + // QNBS-v3: Explicit typed errors — callers branch on `.code`, so a locked read/write and an // incomplete migration must never collapse into a generic Error a caller could ignore. /** Raised when configured at-rest encryption has no session key for a protected operation. */ @@ -41,6 +72,23 @@ export class IdbStorageLockedError extends Error { } } +/** Retained for secondary-store callers while sharing the authoritative locked-state policy. */ +export class SecureRecordLockedError extends IdbStorageLockedError { + constructor() { + super(); + this.name = 'SecureRecordLockedError'; + } +} + +export class SecureRecordCorruptError extends Error { + readonly code = 'STORAGE_CORRUPT' as const; + + constructor() { + super('Encrypted storage record is corrupt or uses an unsupported version'); + this.name = 'SecureRecordCorruptError'; + } +} + /** Raised instead of risking an incomplete cross-database disable or passphrase rotation. */ export class IdbEncryptionMigrationRequiredError extends Error { readonly code = 'ENCRYPTION_MIGRATION_REQUIRED' as const; @@ -128,12 +176,51 @@ export class StorageEncryptionService { return JSON.parse(new TextDecoder().decode(plainBuf)) as unknown; } + /** Encrypt already-serialized bytes, optionally binding them to stable record metadata. */ + async encryptBytes( + key: CryptoKey, + plaintext: Uint8Array, + aad?: Uint8Array, + ): Promise { + const iv = crypto.getRandomValues(new Uint8Array(IV_BYTE_LENGTH)); + const cipherBuf = await crypto.subtle.encrypt( + aad ? { name: 'AES-GCM', iv, additionalData: new Uint8Array(aad) } : { name: 'AES-GCM', iv }, + key, + new Uint8Array(plaintext), + ); + const ciphertext = new Uint8Array(cipherBuf); + const bytes = new Uint8Array(SENTINEL.length + IV_BYTE_LENGTH + ciphertext.length); + bytes.set(SENTINEL, 0); + bytes.set(iv, SENTINEL.length); + bytes.set(ciphertext, SENTINEL.length + IV_BYTE_LENGTH); + return { bytes }; + } + + /** Decrypt bytes produced by encryptBytes, including the same AAD when one was supplied. */ + async decryptBytes(key: CryptoKey, blob: EncryptedBlob, aad?: Uint8Array): Promise { + const { bytes } = blob; + if (bytes.length < SENTINEL.length + IV_BYTE_LENGTH + 16) { + throw new Error('Encrypted blob is too short'); + } + for (let index = 0; index < SENTINEL.length; index++) { + if (bytes[index] !== SENTINEL[index]) throw new Error('Encrypted blob sentinel mismatch'); + } + const iv = bytes.slice(SENTINEL.length, SENTINEL.length + IV_BYTE_LENGTH); + const ciphertext = bytes.slice(SENTINEL.length + IV_BYTE_LENGTH); + const plaintext = await crypto.subtle.decrypt( + aad ? { name: 'AES-GCM', iv, additionalData: new Uint8Array(aad) } : { name: 'AES-GCM', iv }, + key, + ciphertext, + ); + return new Uint8Array(plaintext); + } + /** * Re-derive a new key from newPassphrase using the same install salt. * Callers must re-encrypt all IDB data with the returned key. */ async rotateKey(_oldKey: CryptoKey, newPassphrase: string): Promise { - const salt = await getOrCreateSalt(); + const salt = getExistingSalt(); return this.deriveKey(newPassphrase, salt); } } @@ -147,22 +234,19 @@ let _activeKey: CryptoKey | null = null; // unconditionally throw IdbEncryptionMigrationRequiredError today (see below). let _sentinelPresenceCache: boolean | null = null; -async function getOrCreateSalt(): Promise { +function readStoredSalt(): Uint8Array | null { try { const stored = localStorage.getItem(SALT_STORAGE_KEY); - if (stored) { - const arr = Uint8Array.from(atob(stored), (c) => c.charCodeAt(0)); - if (arr.length === SALT_BYTE_LENGTH) return arr; - } + if (!stored) return null; + const salt = Uint8Array.from(atob(stored), (character) => character.charCodeAt(0)); + if (salt.length !== SALT_BYTE_LENGTH) throw new Error('Encryption salt has an invalid length'); + return salt; } catch (error) { - throw new Error('Unable to persist encryption salt', { cause: error }); - } - // QNBS-v3: only first-time setup (no sentinel yet) may create a fresh salt — a missing/invalid - // salt after that point means the original key material is unrecoverable, so fail closed instead - // of silently deriving a different key that can never decrypt the existing sentinel/data. - if (await hasPassphraseSentinel()) { - throw new IdbEncryptionSaltLostError(); + throw new Error('Unable to read encryption salt', { cause: error }); } +} + +function createAndPersistSalt(): Uint8Array { try { const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTE_LENGTH)); const b64 = btoa(String.fromCharCode(...salt)); @@ -174,13 +258,30 @@ async function getOrCreateSalt(): Promise { } } +function getOrCreateSalt(): Uint8Array { + return readStoredSalt() ?? createAndPersistSalt(); +} + +// QNBS-v3: sentinel already exists here, so a missing or corrupted salt means unrecoverable key material — fail closed with a typed error. +function getExistingSalt(): Uint8Array { + let salt: Uint8Array | null; + try { + salt = readStoredSalt(); + } catch { + throw new IdbEncryptionSaltLostError(); + } + if (!salt) throw new IdbEncryptionSaltLostError(); + return salt; +} + /** * Initialise the session encryption key from a passphrase. * Must be called before any idbEncrypt / idbDecrypt calls. */ export async function initIdbEncryption(passphrase: string): Promise { if (!passphrase) throw new Error('Passphrase must not be empty'); - const salt = await getOrCreateSalt(); + await assertNoActiveEncryptionMigration(); + const salt = (await hasPassphraseSentinel()) ? getExistingSalt() : getOrCreateSalt(); _activeKey = await _svc.deriveKey(passphrase, salt); } @@ -209,6 +310,7 @@ export function isIdbEncryptionReady(): boolean { * Call this before opening an IDB write transaction so callers cannot downgrade to plaintext. */ export async function assertIdbProtectedWriteAllowed(): Promise { + await assertNoActiveEncryptionMigration(); if (_activeKey) return; if (await hasPassphraseSentinel()) throw new IdbStorageLockedError(); } @@ -251,6 +353,180 @@ export function isEncryptedBlob(value: unknown): value is Uint8Array { return true; } +/** Bind a secure record to its stable storage namespace and logical identity. */ +export function buildSecureRecordAad(context: SecureRecordContext): Uint8Array { + return new TextEncoder().encode(`${context.store}:${context.recordId}`); +} + +function isSecureRecordCandidate(value: unknown): boolean { + if (typeof value !== 'object' || value === null) return false; + const record = value as Record; + if ('iv' in record || 'ciphertext' in record) return true; + if (!('version' in record)) return false; + return Object.keys(record).every( + (key) => key === 'version' || key === 'iv' || key === 'ciphertext', + ); +} + +/** Detect a complete or truncated secure-record shape so malformed ciphertext is never treated as plaintext. */ +export function isSecureRecordEnvelopeCandidate(value: unknown): boolean { + return isSecureRecordCandidate(value); +} + +/** Strictly validate envelopes so truncated ciphertext cannot be misclassified as legacy plaintext. */ +export function isSecureRecordEnvelope(value: unknown): value is SecureRecordEnvelope { + if (!isSecureRecordCandidate(value)) return false; + const record = value as Record; + return ( + (record['version'] === LEGACY_BOUND_SECURE_RECORD_VERSION || + record['version'] === SECURE_RECORD_VERSION) && + record['iv'] instanceof Uint8Array && + record['iv'].length === IV_BYTE_LENGTH && + record['ciphertext'] instanceof Uint8Array && + record['ciphertext'].length >= 16 + ); +} + +function envelopeToEncryptedBlob(envelope: SecureRecordEnvelope): EncryptedBlob { + const bytes = new Uint8Array(SENTINEL.length + IV_BYTE_LENGTH + envelope.ciphertext.length); + bytes.set(SENTINEL, 0); + bytes.set(envelope.iv, SENTINEL.length); + bytes.set(envelope.ciphertext, SENTINEL.length + IV_BYTE_LENGTH); + return { bytes }; +} + +function encryptedBlobToEnvelope(bytes: Uint8Array): SecureRecordEnvelope { + return { + version: SECURE_RECORD_VERSION, + iv: bytes.slice(SENTINEL.length, SENTINEL.length + IV_BYTE_LENGTH), + ciphertext: bytes.slice(SENTINEL.length + IV_BYTE_LENGTH), + }; +} + +async function decryptSecureRecordValue( + key: CryptoKey, + envelope: SecureRecordEnvelope, + context: SecureRecordContext, +): Promise> { + const blob = envelopeToEncryptedBlob(envelope); + try { + const plaintext = await _svc.decryptBytes(key, blob, buildSecureRecordAad(context)); + return { + value: decodeSecureRecordValue(plaintext) as T, + needsMigration: envelope.version !== SECURE_RECORD_VERSION, + }; + } catch { + if (envelope.version === SECURE_RECORD_VERSION) throw new SecureRecordCorruptError(); + for (const legacyStore of context.legacyStores ?? []) { + try { + const plaintext = await _svc.decryptBytes( + key, + blob, + buildSecureRecordAad({ store: legacyStore, recordId: context.recordId }), + ); + return { value: decodeSecureRecordValue(plaintext) as T, needsMigration: true }; + } catch { + // QNBS-v3: Only an authenticated legacy namespace may fall through to the next documented format. + } + } + // QNBS-v3: AAD-less ciphertext cannot prove its record/store binding, so it is recovery-required rather than rewritten. + throw new SecureRecordCorruptError(); + } +} + +async function encryptSecureRecordValue( + key: CryptoKey, + value: T, + context: SecureRecordContext, +): Promise { + const plaintext = await encodeSecureRecordValue(value); + const blob = await _svc.encryptBytes(key, plaintext, buildSecureRecordAad(context)); + return encryptedBlobToEnvelope(blob.bytes); +} + +/** Protect any secondary-store mutation with the same lock and active-migration policy as primary stores. */ +export async function assertSecureStorageWritableForMutation(): Promise { + await assertIdbProtectedWriteAllowed(); +} + +/** Block protected reads while encryption is locked or a journal owns the storage lifecycle. */ +export async function assertSecureStorageReadable(): Promise { + await assertNoActiveEncryptionMigration(); + const encryptionConfigured = await hasPassphraseSentinel(); + if (encryptionConfigured && !_activeKey) throw new SecureRecordLockedError(); + return encryptionConfigured; +} + +/** Prepare a secondary payload without silently storing protected plaintext while the library is locked. */ +export async function prepareSecureRecordPayload( + value: T, + context: SecureRecordContext, +): Promise { + await assertIdbProtectedWriteAllowed(); + return _activeKey ? encryptSecureRecordValue(_activeKey, value, context) : value; +} + +/** Key-scoped encrypt used by a journal-owned rekey adapter; it never changes the active session key. */ +export async function prepareSecureRecordPayloadWithKey( + value: T, + context: SecureRecordContext, + key: CryptoKey, +): Promise { + return encryptSecureRecordValue(key, value, context); +} + +/** Re-encrypt one fully validated envelope during a journal-owned rekey checkpoint. */ +export async function reEncryptSecureRecordEnvelope( + envelope: SecureRecordEnvelope, + context: SecureRecordContext, + oldKey: CryptoKey, + newKey: CryptoKey, +): Promise { + const decoded = await decryptSecureRecordValue(oldKey, envelope, context); + return encryptSecureRecordValue(newKey, decoded.value, context); +} + +/** + * Decode records collected by one caller immediately after `assertSecureStorageReadable()` returned + * this access value. It is intentionally narrow so batch readers do not re-query lifecycle metadata per row. + */ +export async function readSecureRecordPayloadAfterLifecycleCheck( + stored: unknown, + context: SecureRecordContext, + encryptionConfigured: boolean, +): Promise> { + if (isSecureRecordCandidate(stored)) { + if (!isSecureRecordEnvelope(stored)) throw new SecureRecordCorruptError(); + if (!_activeKey) throw new SecureRecordLockedError(); + return decryptSecureRecordValue(_activeKey, stored, context); + } + if (_activeKey) return { value: stored as T, needsMigration: true }; + if (encryptionConfigured) throw new SecureRecordLockedError(); + return { value: stored as T, needsMigration: false }; +} + +/** Read a secondary payload with locked, malformed-envelope, and legacy states distinguished. */ +export async function readSecureRecordPayload( + stored: unknown, + context: SecureRecordContext, +): Promise> { + const encryptionConfigured = await assertSecureStorageReadable(); + return readSecureRecordPayloadAfterLifecycleCheck(stored, context, encryptionConfigured); +} + +/** Read with an explicitly supplied generation key while a journal owns the migration. */ +export async function readSecureRecordPayloadWithKey( + stored: unknown, + context: SecureRecordContext, + key: CryptoKey, +): Promise> { + if (isSecureRecordCandidate(stored)) { + if (!isSecureRecordEnvelope(stored)) throw new SecureRecordCorruptError(); + return decryptSecureRecordValue(key, stored, context); + } + return { value: stored as T, needsMigration: true }; +} + /** * Unified read helper: decrypts encrypted blobs when the key is available, * decompresses plaintext legacy data, and throws a clear user-facing error @@ -259,14 +535,14 @@ export function isEncryptedBlob(value: unknown): value is Uint8Array { * after encrypted data already exists. */ export async function idbReadSecure(raw: unknown): Promise { + // QNBS-v3: Keep the exported primitive fail-closed too; callers may not bypass lifecycle state by reading raw IDB values. + await assertSecureStorageReadable(); if (isEncryptedBlob(raw)) { if (!isIdbEncryptionReady()) { throw new IdbStorageLockedError(); } return idbDecrypt(raw); } - // QNBS-v3: Legacy plaintext remains readable only after unlock so a locked library cannot expose protected content. - await assertIdbProtectedWriteAllowed(); return decompressData(raw); } @@ -279,7 +555,11 @@ export async function idbReadSecure(raw: unknown): Promise { */ export async function setupIdbEncryption(passphrase: string): Promise { if (!passphrase) throw new Error('Passphrase must not be empty'); - const salt = await getOrCreateSalt(); + await assertNoActiveEncryptionMigration(); + if (await hasPassphraseSentinel()) { + throw new Error('Encryption is already configured; use the resumable passphrase-rotation flow'); + } + const salt = getOrCreateSalt(); const key = await _svc.deriveKey(passphrase, salt); const blob = await _svc.encrypt(key, { v: 1 }); await savePassphraseSentinel(blob.bytes); @@ -297,18 +577,41 @@ export async function setupIdbEncryption(passphrase: string): Promise { */ export async function verifyAndInitIdbEncryption(passphrase: string): Promise { if (!passphrase) throw new Error('Passphrase must not be empty'); + await assertNoActiveEncryptionMigration(); const sentinelBytes = await getPassphraseSentinel(); if (!sentinelBytes) throw new Error('No passphrase sentinel found — encryption was not set up'); // QNBS-v3: sentinelBytes being non-null already proves the sentinel exists — populate the cache // from this read instead of letting the next hasPassphraseSentinel() call repeat the IDB lookup. _sentinelPresenceCache = true; - const salt = await getOrCreateSalt(); + const salt = getExistingSalt(); const key = await _svc.deriveKey(passphrase, salt); // QNBS-v3: decrypt throws on wrong key — AES-GCM auth-tag is the verifier await _svc.decrypt(key, { bytes: sentinelBytes }); _activeKey = key; } +/** Create a non-secret verifier that a future enable/rekey runner must authenticate before mutation. */ +export async function createIdbMigrationTargetVerifier(targetKey: CryptoKey): Promise { + const blob = await _svc.encrypt(targetKey, { v: 1 }); + return Array.from(blob.bytes); +} + +/** Reject a supplied target key unless it decrypts the durable migration verifier exactly. */ +export async function assertIdbMigrationTargetKeyMatchesVerifier( + targetKey: CryptoKey, + targetVerifier: readonly number[], +): Promise { + const verified = await _svc.decrypt(targetKey, { bytes: new Uint8Array(targetVerifier) }); + if ( + typeof verified !== 'object' || + verified === null || + Object.getPrototypeOf(verified) !== Object.prototype || + (verified as { v?: unknown }).v !== 1 + ) { + throw new Error('Migration target verifier is invalid'); + } +} + /** * Returns true if a passphrase sentinel exists in IDB, meaning the user has * previously configured encryption. Used by App.tsx startup guard to auto-heal diff --git a/tests/unit/aiInferenceCacheService.test.ts b/tests/unit/aiInferenceCacheService.test.ts index d44a1fbb..78930ba1 100644 --- a/tests/unit/aiInferenceCacheService.test.ts +++ b/tests/unit/aiInferenceCacheService.test.ts @@ -24,6 +24,25 @@ describe('aiInferenceCacheService — in-memory LRU', () => { expect(result).toBe('world'); }); + it('keeps the in-memory result when non-authoritative durable cache encoding is blocked', async () => { + type CacheInternals = { + dbReady: Promise; + db: IDBDatabase | null; + encodeEntry: (key: string, result: string, timestamp: number) => Promise; + }; + const cache = service.aiInferenceCacheService as unknown as CacheInternals; + await cache.dbReady; + cache.db = {} as IDBDatabase; + vi.spyOn(cache, 'encodeEntry').mockRejectedValueOnce(new Error('storage locked')); + + await expect( + service.aiInferenceCacheService.setCachedInference('hello', 'model-a', 'world'), + ).resolves.toBeUndefined(); + await expect( + service.aiInferenceCacheService.getCachedInference('hello', 'model-a'), + ).resolves.toBe('world'); + }); + it('keys are model-scoped (different model → miss)', async () => { await service.aiInferenceCacheService.setCachedInference('hello', 'model-a', 'world'); const result = await service.aiInferenceCacheService.getCachedInference('hello', 'model-b'); @@ -83,8 +102,9 @@ describe('aiInferenceCacheService — TTL expiry', () => { }); describe('aiInferenceCacheService — IDB unavailable (jsdom)', () => { + // QNBS-v3: tests/setup.ts imports fake-indexeddb/auto globally, so indexedDB IS defined here — + // these exercise the in-memory-only-miss path via an empty durable store, not a true IDB-absent env. it('getCachedInference degrades gracefully when indexedDB is undefined', async () => { - // jsdom does not provide indexedDB by default — service already handles this const result = await service.aiInferenceCacheService.getCachedInference('any', 'model'); expect(result).toBeNull(); // either from in-memory miss or IDB degrade }); @@ -95,3 +115,79 @@ describe('aiInferenceCacheService — IDB unavailable (jsdom)', () => { ).resolves.not.toThrow(); }); }); + +describe('aiInferenceCacheService — protected-storage lifecycle', () => { + afterEach(() => { + vi.doUnmock('../../services/storage/storageEncryptionService'); + }); + + it('returns null instead of rejecting when the protected-storage lifecycle check fails', async () => { + vi.doMock('../../services/storage/storageEncryptionService', () => ({ + assertSecureStorageReadable: vi.fn().mockRejectedValue(new Error('locked')), + assertSecureStorageWritableForMutation: vi.fn().mockResolvedValue(undefined), + prepareSecureRecordPayload: vi.fn(async (value: unknown) => value), + readSecureRecordPayload: vi.fn(), + SecureRecordCorruptError: class extends Error {}, + })); + vi.resetModules(); + const mod = await import('../../services/ai/aiInferenceCacheService'); + + await expect( + mod.aiInferenceCacheService.getCachedInference('hello', 'model-a'), + ).resolves.toBeNull(); + }); + + it('degrades to a miss instead of rejecting when a durable cache row fails to decode', async () => { + // QNBS-v3 regression: the durable-read path used to reject (SecureRecordCorruptError / decode + // failure), which failed an otherwise-successful inference call — the cache is documented as + // non-authoritative and must degrade to a miss instead, matching setCachedInference's own policy. + type CacheInternals = { + inMemory: Map; + decodeEntry: (entry: unknown) => Promise; + }; + await service.aiInferenceCacheService.setCachedInference('hello', 'model-a', 'world'); + const cache = service.aiInferenceCacheService as unknown as CacheInternals; + cache.inMemory.clear(); + vi.spyOn(cache, 'decodeEntry').mockRejectedValueOnce(new Error('corrupt payload')); + + await expect( + service.aiInferenceCacheService.getCachedInference('hello', 'model-a'), + ).resolves.toBeNull(); + }); + + it('opportunistically re-encrypts a legacy plaintext entry after a successful read', async () => { + const persisted: unknown[] = []; + vi.doMock('../../services/storage/storageEncryptionService', () => ({ + assertSecureStorageReadable: vi.fn().mockResolvedValue(true), + assertSecureStorageWritableForMutation: vi.fn().mockResolvedValue(undefined), + prepareSecureRecordPayload: vi.fn(async (value: unknown) => { + persisted.push(value); + return { version: 1, iv: new Uint8Array([1]), ciphertext: new Uint8Array([2]) }; + }), + readSecureRecordPayload: vi.fn().mockResolvedValue({ + value: { result: 'legacy answer' }, + needsMigration: true, + }), + SecureRecordCorruptError: class extends Error {}, + })); + vi.resetModules(); + const mod = await import('../../services/ai/aiInferenceCacheService'); + type CacheInternals = { + dbReady: Promise; + decodeEntry: (entry: { key: string; result: string; timestamp: number }) => Promise; + }; + const cache = mod.aiInferenceCacheService as unknown as CacheInternals; + await cache.dbReady; + + const decoded = await cache.decodeEntry({ + key: 'legacy-key', + result: 'legacy answer', + timestamp: Date.now(), + }); + expect(decoded).toBe('legacy answer'); + + // reencryptLegacyEntry is fire-and-forget from decodeEntry — flush pending microtasks before asserting. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(persisted).toEqual([{ result: 'legacy answer' }]); + }); +}); diff --git a/tests/unit/dbServiceBinder.test.ts b/tests/unit/dbServiceBinder.test.ts index b997510c..81fdd8e6 100644 --- a/tests/unit/dbServiceBinder.test.ts +++ b/tests/unit/dbServiceBinder.test.ts @@ -8,14 +8,17 @@ import type { BinderAssetMeta } from '../../services/storageBackend'; type BinderRecord = { meta: BinderAssetMeta & { byteSize: number }; blob: Blob }; const binderStore = new Map(); -function createBinderFakeStore() { +// QNBS-v3: pending tracks each request's completion promise so the owning transaction mock (below) can fire oncomplete only after every request queued on it has actually settled. +function createBinderFakeStore(pending: Promise[] = []) { return { put: (value: unknown, key: string) => { const req: Record = { onsuccess: null, onerror: null }; - Promise.resolve().then(() => { - binderStore.set(key, value as BinderRecord); - (req['onsuccess'] as (() => void) | null)?.(); - }); + pending.push( + Promise.resolve().then(() => { + binderStore.set(key, value as BinderRecord); + (req['onsuccess'] as (() => void) | null)?.(); + }), + ); return req; }, get: (key: string) => { @@ -25,28 +28,40 @@ function createBinderFakeStore() { result: binderStore.get(key), error: null, }; - Promise.resolve().then(() => { - (req['onsuccess'] as ((e: Event) => void) | null)?.({} as Event); - }); + pending.push( + Promise.resolve().then(() => { + (req['onsuccess'] as ((e: Event) => void) | null)?.({} as Event); + }), + ); return req; }, delete: (key: string) => { const req: Record = { onsuccess: null, onerror: null }; - Promise.resolve().then(() => { - binderStore.delete(key); - (req['onsuccess'] as ((e: Event) => void) | null)?.({} as Event); - }); + pending.push( + Promise.resolve().then(() => { + binderStore.delete(key); + (req['onsuccess'] as ((e: Event) => void) | null)?.({} as Event); + }), + ); return req; }, openCursor: () => { const entries = [...binderStore.entries()]; let index = 0; const req: Record = { onsuccess: null, onerror: null, result: null }; + // QNBS-v3: tracks the whole cursor walk (not just its first step) so the owning transaction's oncomplete waits for every continue()-driven iteration, matching the callers below that always continue to exhaustion. + let resolveCursorDone!: () => void; + pending.push( + new Promise((resolve) => { + resolveCursorDone = resolve; + }), + ); const advance = () => { if (index >= entries.length) { req['result'] = null; (req['onsuccess'] as ((e: Event) => void) | null)?.({} as Event); + resolveCursorDone(); return; } const [key] = entries[index++] as [string, BinderRecord]; @@ -65,10 +80,26 @@ function createBinderFakeStore() { }; } +// QNBS-v3: complete the fake transaction after every queued request (including full cursor walks) settles, mirroring real IDB transaction batching. +function createBinderFakeTransaction() { + const pending: Promise[] = []; + const txn: Record = { + oncomplete: null, + onerror: null, + onabort: null, + error: null, + }; + const store: Record = createBinderFakeStore(pending); + store['transaction'] = txn; + txn['objectStore'] = () => store; + queueMicrotask(() => { + void Promise.all(pending).then(() => (txn['oncomplete'] as (() => void) | null)?.()); + }); + return txn; +} + const fakeDataDb = { - transaction: vi.fn().mockImplementation(() => ({ - objectStore: () => createBinderFakeStore(), - })), + transaction: vi.fn().mockImplementation(() => createBinderFakeTransaction()), }; // stateDb is not used by binder methods — but must be non-null to skip init() diff --git a/tests/unit/ollamaService.test.ts b/tests/unit/ollamaService.test.ts index d147745d..68a03e72 100644 --- a/tests/unit/ollamaService.test.ts +++ b/tests/unit/ollamaService.test.ts @@ -73,12 +73,21 @@ describe('listOllamaModels', () => { // ─── testOllamaConnection ───────────────────────────────────────────────────── describe('testOllamaConnection', () => { - it('returns ok:true on 200', async () => { + it('returns safe endpoint, transport, and model diagnostics on 200', async () => { vi.mocked(fetch).mockResolvedValueOnce( - new Response(JSON.stringify({ models: [] }), { status: 200 }), + new Response(JSON.stringify({ models: [{ name: 'llama3' }, { name: ' mistral ' }] }), { + status: 200, + }), ); const result = await testOllamaConnection(); - expect(result).toEqual({ ok: true }); + expect(result).toEqual({ + ok: true, + localServer: { + normalizedEndpoint: 'http://localhost:11434/api/tags', + transport: 'browser-fetch', + modelNames: ['llama3', 'mistral'], + }, + }); }); it('returns ok:false with error message on HTTP error', async () => { @@ -127,6 +136,31 @@ describe('testOllamaConnection', () => { expect(result.kind).toBe('pluginUnavailable'); expect(result.params).toBeUndefined(); }); + + it('reports invalidResponse instead of a false-positive ok when the body is not valid JSON', async () => { + vi.mocked(fetch).mockResolvedValueOnce(new Response('login', { status: 200 })); + const result = await testOllamaConnection(); + expect(result.ok).toBe(false); + expect(result.kind).toBe('invalidResponse'); + }); + + it('reports invalidResponse instead of a false-positive ok when the body has no models array', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ notModels: [] }), { status: 200 }), + ); + const result = await testOllamaConnection(); + expect(result.ok).toBe(false); + expect(result.kind).toBe('invalidResponse'); + }); + + it('still reports ok:true with an empty model list for a validly-shaped, empty Ollama server', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ models: [] }), { status: 200 }), + ); + const result = await testOllamaConnection(); + expect(result.ok).toBe(true); + expect(result.localServer?.modelNames).toEqual([]); + }); }); // ─── streamOllama ───────────────────────────────────────────────────────────── diff --git a/tests/unit/sceneRevisionService.test.ts b/tests/unit/sceneRevisionService.test.ts index 0535b245..bc192cfa 100644 --- a/tests/unit/sceneRevisionService.test.ts +++ b/tests/unit/sceneRevisionService.test.ts @@ -2,8 +2,7 @@ // QNBS-v3: node environment avoids jsdom's non-configurable indexedDB stub. // Fresh IDBFactory per test ensures complete isolation between tests. import { IDBFactory, IDBKeyRange } from 'fake-indexeddb'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { _resetDbForTest, deleteRevision, @@ -22,6 +21,25 @@ afterEach(() => { _resetDbForTest(); }); +async function insertRawRevision(record: unknown): Promise { + await new Promise((resolve, reject) => { + const request = indexedDB.open('worldscript-revisions-db'); + request.onsuccess = () => { + const database = request.result; + const transaction = database.transaction('scene-revisions', 'readwrite'); + transaction.objectStore('scene-revisions').put(record); + transaction.oncomplete = () => { + database.close(); + resolve(); + }; + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => + reject(transaction.error ?? new Error('Raw revision insert aborted')); + }; + request.onerror = () => reject(request.error); + }); +} + describe('sceneRevisionService', () => { it('saveRevision returns a revision with correct fields', async () => { const revision = await saveRevision('sec1', { title: 'Scene 1', content: 'Hello world' }); @@ -92,6 +110,44 @@ describe('sceneRevisionService', () => { expect(list[0]?.sectionId).toBe('sec1'); }); + it('keeps retention bounded when concurrent saves target the same section', async () => { + await saveRevision('sec1', { title: 'seed', content: 'seed' }); + await Promise.all( + Array.from({ length: 55 }, (_, index) => + saveRevision('sec1', { title: `revision ${index}`, content: `content ${index}` }), + ), + ); + + await expect(listRevisions('sec1')).resolves.toHaveLength(50); + }); + + it('opens the database connection only once for concurrent saves (single-flight)', async () => { + // QNBS-v3: Cover the race that opened multiple databases before the first connection was cached. + const openSpy = vi.spyOn(indexedDB, 'open'); + await Promise.all( + Array.from({ length: 10 }, (_, index) => + saveRevision('sec2', { title: `revision ${index}`, content: `content ${index}` }), + ), + ); + expect(openSpy).toHaveBeenCalledTimes(1); + }); + + it('skips a future stored schema instead of interpreting it as v1, keeping other revisions readable', async () => { + // QNBS-v3: listRevisions skips an unreadable revision (logged) instead of rejecting the whole call. + await saveRevision('sec1', { title: 'known', content: 'known content' }); + await insertRawRevision({ + id: 'future-schema', + sectionId: 'sec1', + createdAt: Date.now(), + schemaVersion: 2, + payload: { title: 'future', content: 'must not decode as v1', wordCount: 6 }, + }); + + const list = await listRevisions('sec1'); + expect(list).toHaveLength(1); + expect(list[0]?.title).toBe('known'); + }); + it('createdAt is a number timestamp', async () => { const rev = await saveRevision('sec1', { title: 'T', content: 'C' }); expect(typeof rev.createdAt).toBe('number'); diff --git a/tests/unit/services/storage/idbAssetStore.test.ts b/tests/unit/services/storage/idbAssetStore.test.ts index c24aa2c5..412fcbb8 100644 --- a/tests/unit/services/storage/idbAssetStore.test.ts +++ b/tests/unit/services/storage/idbAssetStore.test.ts @@ -28,7 +28,8 @@ vi.mock('../../../../services/storage/idbCore', () => ({ vi.mock('../../../../services/storage/storageEncryptionService', () => ({ assertIdbProtectedWriteAllowed: async () => {}, - idbEncrypt: async (data: unknown) => data, + assertNoActiveEncryptionMigration: async () => {}, + assertSecureStorageReadable: async () => false, idbEncryptWithKey: async (_key: unknown, data: unknown) => data, idbReadSecure: async (data: unknown) => data, isIdbEncryptionReady: () => false, diff --git a/tests/unit/services/storage/idbSnapshotStore.test.ts b/tests/unit/services/storage/idbSnapshotStore.test.ts index fef9617f..d815d279 100644 --- a/tests/unit/services/storage/idbSnapshotStore.test.ts +++ b/tests/unit/services/storage/idbSnapshotStore.test.ts @@ -25,8 +25,9 @@ vi.mock('../../../../services/storage/idbCore', () => ({ })); vi.mock('../../../../services/storage/storageEncryptionService', () => ({ - assertIdbProtectedWriteAllowed: async () => {}, - idbEncrypt: async (data: unknown) => data, + assertIdbProtectedWriteAllowed: async () => undefined, + assertNoActiveEncryptionMigration: async () => undefined, + assertSecureStorageReadable: async () => undefined, idbEncryptWithKey: async (_key: unknown, data: unknown) => data, idbDecrypt: async (data: unknown) => data, idbReadSecure: async (data: unknown) => data, @@ -121,4 +122,21 @@ describe('IdbSnapshotStore', () => { expect(result).toEqual([]); }); }); + + describe('getSnapshotData', () => { + it('rejects a missing snapshot instead of fulfilling with undefined project data', async () => { + const mockRequest = { + result: undefined, + error: null, + onsuccess: null as (() => void) | null, + onerror: null as ((err: unknown) => void) | null, + }; + mockStore.get.mockImplementation(() => { + setTimeout(() => mockRequest.onsuccess?.(), 0); + return mockRequest; + }); + + await expect(store.getSnapshotData(404)).rejects.toThrow('Snapshot 404 was not found'); + }); + }); }); diff --git a/tests/unit/settings/AiProviderCard.test.tsx b/tests/unit/settings/AiProviderCard.test.tsx index 51491d1a..0e4d737a 100644 --- a/tests/unit/settings/AiProviderCard.test.tsx +++ b/tests/unit/settings/AiProviderCard.test.tsx @@ -255,6 +255,14 @@ describe('AiProviderCard — ollama provider (#266)', () => { it('desktop: uses the LM Studio preset for the explicit model and connection diagnostics', async () => { setDesktopRuntime(true); + vi.mocked(testAIConnection).mockResolvedValueOnce({ + ok: true, + localServer: { + normalizedEndpoint: 'http://127.0.0.1:1234/v1', + transport: 'tauri-http', + modelNames: ['local-model'], + }, + }); const user = userEvent.setup(); render( { }), ); }); + expect(screen.getByText('http://127.0.0.1:1234/v1')).toBeTruthy(); + expect(screen.getByText('settings.ai.localDiagnostic.tauriHttp')).toBeTruthy(); + expect(screen.getByText('local-model')).toBeTruthy(); + }); + + // QNBS-v3: verify the browser opt-in displays metadata from its separate browser-fetch path. + it('PWA: labels an opted-in Ollama diagnostic with its browser transport', async () => { + setDesktopRuntime(false); + vi.mocked(testAIConnection).mockResolvedValueOnce({ + ok: true, + localServer: { + normalizedEndpoint: 'http://localhost:11434/api/tags', + transport: 'browser-fetch', + modelNames: ['browser-model'], + }, + }); + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole('button', { name: 'settings.ai.testConnection' })); + + await waitFor(() => { + expect(screen.getByText('http://localhost:11434/api/tags')).toBeTruthy(); + }); + expect(screen.getByText('settings.ai.localDiagnostic.browserFetch')).toBeTruthy(); + expect(screen.getByText('browser-model')).toBeTruthy(); + }); + + it('never displays an in-flight diagnostic after the local backend context changes', async () => { + setDesktopRuntime(true); + let resolveTest: ((result: Awaited>) => void) | null = null; + vi.mocked(testAIConnection).mockImplementationOnce( + () => + new Promise>>((resolve) => { + resolveTest = resolve; + }), + ); + const user = userEvent.setup(); + const initialSettings = { + ...ollamaAdvancedAi, + ollamaBaseUrl: 'http://localhost:1234', + localBackendPreset: 'lm_studio' as const, + }; + const { rerender } = render( + , + ); + + await user.click(screen.getByRole('button', { name: 'settings.ai.testConnection' })); + await waitFor(() => expect(resolveTest).not.toBeNull()); + + rerender( + , + ); + + if (!resolveTest) throw new Error('Connection test did not start'); + // QNBS-v3: keep the resolver type explicit because closure assignment narrows it to never after awaits. + const resolve: (result: Awaited>) => void = resolveTest; + resolve({ + ok: true, + localServer: { + normalizedEndpoint: 'http://127.0.0.1:1234/v1', + transport: 'tauri-http', + modelNames: ['stale-model'], + }, + }); + + await waitFor(() => expect(screen.queryByText('stale-model')).toBeNull()); + expect(screen.queryByText('http://127.0.0.1:1234/v1')).toBeNull(); + expect(screen.getByText('settings.ai.providerStatusNotTested')).toBeTruthy(); + }); + + // QNBS-v3: remove completed diagnostics when the active local backend changes. + it('clears a completed local diagnostic when its endpoint context changes', async () => { + setDesktopRuntime(true); + vi.mocked(testAIConnection).mockResolvedValueOnce({ + ok: true, + localServer: { + normalizedEndpoint: 'http://127.0.0.1:1234/v1', + transport: 'tauri-http', + modelNames: ['local-model'], + }, + }); + const user = userEvent.setup(); + const { rerender } = render( + , + ); + + await user.click(screen.getByRole('button', { name: 'settings.ai.testConnection' })); + await waitFor(() => expect(screen.getByText('local-model')).toBeTruthy()); + + rerender( + , + ); + + await waitFor(() => expect(screen.queryByText('local-model')).toBeNull()); + expect(screen.getByText('settings.ai.providerStatusNotTested')).toBeTruthy(); }); it('desktop: scan renders classified status badges and the use-url action patches settings', async () => { @@ -349,9 +487,7 @@ describe('AiProviderCard — ollama provider (#266)', () => { ); await user.click(screen.getByRole('button', { name: 'settings.ai.testConnection' })); await waitFor(() => { - // QNBS-v3: the translated text renders in two places (the status-badge error line and the - // manual "Test connection" result span) — both share the same `testError` state. - expect(screen.getAllByText('settings.ai.testError.httpError').length).toBeGreaterThan(0); + expect(screen.getByText('settings.ai.testError.httpError')).toBeTruthy(); }); expect(screen.queryByText('Ollama HTTP 503')).toBeNull(); }); @@ -373,10 +509,38 @@ describe('AiProviderCard — ollama provider (#266)', () => { ); await user.click(screen.getByRole('button', { name: 'settings.ai.testConnection' })); await waitFor(() => { - expect(screen.getAllByText('settings.ai.testError.unexpected').length).toBeGreaterThan(0); + expect(screen.getByText('settings.ai.testError.unexpected')).toBeTruthy(); }); expect(screen.queryByText(/something internal broke/)).toBeNull(); }); + + it('marks the connection status region aria-busy while a test is in flight', async () => { + // QNBS-v3 regression: the status region announced via aria-live but never set aria-busy, so + // assistive tech had no programmatic signal that a connection test was actively running. + setDesktopRuntime(true); + let resolveTest!: (v: Awaited>) => void; + vi.mocked(testAIConnection).mockReturnValue( + new Promise((resolve) => { + resolveTest = resolve; + }), + ); + render( + , + ); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'settings.ai.testConnection' })); + // QNBS-v3: the Button's own Spinner also uses role="status", so disambiguate by excluding it. + const connectionStatus = () => + screen.getAllByRole('status').find((el) => el.getAttribute('aria-label') !== 'Loading…')!; + await waitFor(() => expect(connectionStatus().getAttribute('aria-busy')).toBe('true')); + + resolveTest({ ok: true }); + await waitFor(() => expect(connectionStatus().getAttribute('aria-busy')).toBe('false')); + }); }); // QNBS-v3 (ADR-0017): opt-in direct browser→Ollama connection — browserOllamaEnabled defaults to diff --git a/tests/unit/storage/encryptionMigrationJournal.test.ts b/tests/unit/storage/encryptionMigrationJournal.test.ts new file mode 100644 index 00000000..898affbd --- /dev/null +++ b/tests/unit/storage/encryptionMigrationJournal.test.ts @@ -0,0 +1,239 @@ +// @vitest-environment node +// QNBS-v3: Real fake IndexedDB verifies journal transactions and competing-owner CAS rejection (same module instance, different ownerId values — not a cross-tab/reloaded-module scenario). +import { IDBFactory } from 'fake-indexeddb'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { APP_DATA_STORE, STATE_DB_NAME } from '../../../services/dbConstants'; +import { + __encryptionMigrationJournalRecordKeyForTest, + __resetEncryptionMigrationJournalConnectionsForTest, + assertNoActiveEncryptionMigration, + beginEncryptionMigration, + claimEncryptionMigrationOwnership, + clearCompletedEncryptionMigration, + completeEncryptionMigration, + IdbMigrationInProgressError, + IdbMigrationOwnershipError, + IdbMigrationRecoveryRequiredError, + readEncryptionMigrationJournal, + releaseEncryptionMigrationOwnership, + updateEncryptionMigrationJournal, +} from '../../../services/storage/encryptionMigrationJournal'; +import { IdbProjectStore } from '../../../services/storage/idbProjectStore'; +import type { Settings } from '../../../types'; + +const migrationInput = (operationId: string) => ({ + operationId, + operation: 'rekey' as const, + phase: 'prepared' as const, + sourceGeneration: 'source-generation', + targetGeneration: 'target-generation', + targetVerifier: [1, 2, 3], + stores: [ + { + id: 'worldscript-state-db/app-data-store', + processed: 0, + verified: 0, + done: false, + }, + ], +}); + +function replaceStoredJournalForTest(value: unknown): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(STATE_DB_NAME); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const database = request.result; + const transaction = database.transaction(APP_DATA_STORE, 'readwrite'); + const putRequest = transaction + .objectStore(APP_DATA_STORE) + .put(value, __encryptionMigrationJournalRecordKeyForTest); + putRequest.onerror = () => reject(putRequest.error); + transaction.oncomplete = () => { + database.close(); + resolve(); + }; + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => + reject(transaction.error ?? new Error('Journal replacement transaction aborted')); + }; + }); +} + +// QNBS-v3: routes through the legal prepared→migrating→verifying→committing chain — saveIfCurrent +// now rejects skipping straight from prepared to committing (see IdbMigrationInvalidTransitionError). +async function markJournalCommitting( + journal: Awaited>, +) { + const migrating = await updateEncryptionMigrationJournal(journal, { + phase: 'migrating', + stores: journal.stores, + }); + const verifying = await updateEncryptionMigrationJournal(migrating, { + phase: 'verifying', + stores: migrating.stores, + }); + return updateEncryptionMigrationJournal(verifying, { + phase: 'committing', + stores: verifying.stores, + }); +} + +beforeEach(() => { + __resetEncryptionMigrationJournalConnectionsForTest(); + globalThis.indexedDB = new IDBFactory(); +}); + +afterEach(() => { + __resetEncryptionMigrationJournalConnectionsForTest(); +}); + +describe('encryption migration journal', () => { + it('durably persists versioned, non-secret migration metadata', async () => { + const created = await beginEncryptionMigration(migrationInput('operation-1')); + const restored = await readEncryptionMigrationJournal(); + + expect(created.schemaVersion).toBe(1); + expect(created.revision).toBe(0); + expect(restored).toMatchObject({ + operationId: 'operation-1', + operation: 'rekey', + phase: 'prepared', + targetVerifier: [1, 2, 3], + }); + expect(restored?.startedAt).toBeTypeOf('number'); + expect(restored?.updatedAt).toBeTypeOf('number'); + }); + + it('rejects a competing migration owner and blocks protected access until terminal completion', async () => { + const created = await beginEncryptionMigration(migrationInput('operation-1')); + + await expect(beginEncryptionMigration(migrationInput('operation-2'))).rejects.toBeInstanceOf( + IdbMigrationInProgressError, + ); + await expect(assertNoActiveEncryptionMigration()).rejects.toBeInstanceOf( + IdbMigrationInProgressError, + ); + await expect(new IdbProjectStore().saveSettings({} as Settings)).rejects.toBeInstanceOf( + IdbMigrationInProgressError, + ); + await expect(new IdbProjectStore().loadState()).rejects.toBeInstanceOf( + IdbMigrationInProgressError, + ); + + await completeEncryptionMigration(await markJournalCommitting(created)); + await expect(assertNoActiveEncryptionMigration()).resolves.toBeUndefined(); + }); + + it('allows a completed journal to be cleared before a later migration begins', async () => { + const first = await beginEncryptionMigration(migrationInput('operation-1')); + await completeEncryptionMigration(await markJournalCommitting(first)); + await clearCompletedEncryptionMigration(); + + await expect(beginEncryptionMigration(migrationInput('operation-2'))).resolves.toMatchObject({ + operationId: 'operation-2', + }); + }); + + it('rejects a delayed owner instead of overwriting a newer checkpoint', async () => { + const created = await beginEncryptionMigration(migrationInput('operation-1')); + const updated = await updateEncryptionMigrationJournal(created, { + phase: 'migrating', + stores: [ + { + ...created.stores[0]!, + cursor: 'project/settings', + processed: 1, + verified: 1, + done: false, + }, + ], + }); + + await expect( + updateEncryptionMigrationJournal(created, { + phase: 'verifying', + stores: created.stores, + }), + ).rejects.toBeInstanceOf(IdbMigrationOwnershipError); + await expect(readEncryptionMigrationJournal()).resolves.toMatchObject({ + operationId: 'operation-1', + phase: 'migrating', + revision: updated.revision, + stores: [{ cursor: 'project/settings', processed: 1, verified: 1, done: false }], + }); + }); + + it('serializes execution owners and permits recovery after a crashed owner lease expires', async () => { + const created = await beginEncryptionMigration(migrationInput('operation-1')); + const firstOwner = await claimEncryptionMigrationOwnership(created, 'owner-a'); + + await expect(claimEncryptionMigrationOwnership(firstOwner, 'owner-b')).rejects.toBeInstanceOf( + IdbMigrationOwnershipError, + ); + + await replaceStoredJournalForTest({ + ...firstOwner, + ownerLeaseExpiresAt: Date.now() - 1, + }); + __resetEncryptionMigrationJournalConnectionsForTest(); + const expiredOwner = await readEncryptionMigrationJournal(); + if (!expiredOwner) throw new Error('Expected an expired migration owner'); + const recoveredOwner = await claimEncryptionMigrationOwnership(expiredOwner, 'owner-b'); + expect(recoveredOwner).toMatchObject({ ownerId: 'owner-b', revision: firstOwner.revision + 1 }); + + const released = await releaseEncryptionMigrationOwnership(recoveredOwner); + expect(released.ownerId).toBeUndefined(); + expect(released.ownerLeaseExpiresAt).toBeUndefined(); + }); + + it('rejects a stale checkpoint from an independently loaded journal module', async () => { + const created = await beginEncryptionMigration(migrationInput('operation-1')); + vi.resetModules(); + const reloaded = await import('../../../services/storage/encryptionMigrationJournal'); + const updated = await reloaded.updateEncryptionMigrationJournal(created, { + phase: 'migrating', + stores: created.stores, + }); + + await expect( + updateEncryptionMigrationJournal(created, { phase: 'verifying', stores: created.stores }), + ).rejects.toMatchObject({ code: 'ENCRYPTION_MIGRATION_OWNERSHIP_LOST' }); + expect(updated.revision).toBe(1); + reloaded.__resetEncryptionMigrationJournalConnectionsForTest(); + }); + + it('never clears an active journal during completed-state cleanup', async () => { + await beginEncryptionMigration(migrationInput('operation-1')); + + await expect(clearCompletedEncryptionMigration()).rejects.toBeInstanceOf( + IdbMigrationInProgressError, + ); + await expect(readEncryptionMigrationJournal()).resolves.toMatchObject({ + operationId: 'operation-1', + phase: 'prepared', + }); + }); + + it('refuses to complete a journal before every store reaches committing', async () => { + const created = await beginEncryptionMigration(migrationInput('operation-1')); + + await expect(completeEncryptionMigration(created)).rejects.toBeInstanceOf( + IdbMigrationInProgressError, + ); + await expect(readEncryptionMigrationJournal()).resolves.toMatchObject({ phase: 'prepared' }); + }); + + it('fails closed into recovery-required when persisted metadata is malformed', async () => { + const created = await beginEncryptionMigration(migrationInput('operation-1')); + await replaceStoredJournalForTest({ ...created, targetVerifier: { malformed: true } }); + __resetEncryptionMigrationJournalConnectionsForTest(); + + await expect(readEncryptionMigrationJournal()).rejects.toBeInstanceOf( + IdbMigrationRecoveryRequiredError, + ); + await expect(assertNoActiveEncryptionMigration()).rejects.toBeInstanceOf( + IdbMigrationRecoveryRequiredError, + ); + }); +}); diff --git a/tests/unit/storage/idbStoreEncryption.test.ts b/tests/unit/storage/idbStoreEncryption.test.ts index 0f369f45..177432e5 100644 --- a/tests/unit/storage/idbStoreEncryption.test.ts +++ b/tests/unit/storage/idbStoreEncryption.test.ts @@ -274,6 +274,31 @@ describe('locked-state guards on destructive and listing operations', () => { await initIdbEncryption('test-pass'); await expect(store.listBinderAssetIds('proj-1')).resolves.toEqual(['asset-1']); }); + + it('deletes every binder asset for a project in a single batched transaction', async () => { + // QNBS-v3 regression: was one transaction PER asset — a later failure could leave earlier assets permanently removed. Now every delete is queued in one transaction (all-or-nothing). + const store = new IdbAssetStore(); + await store.saveBinderAsset('proj-1', 'a1', new ArrayBuffer(1), { + byteSize: 1, + mimeType: 'application/pdf', + originalFileName: 'a.pdf', + }); + await store.saveBinderAsset('proj-1', 'a2', new ArrayBuffer(1), { + byteSize: 1, + mimeType: 'application/pdf', + originalFileName: 'b.pdf', + }); + await store.saveBinderAsset('proj-other', 'a3', new ArrayBuffer(1), { + byteSize: 1, + mimeType: 'application/pdf', + originalFileName: 'c.pdf', + }); + + await store.deleteAllBinderAssetsForProject('proj-1'); + + await expect(store.listBinderAssetIds('proj-1')).resolves.toEqual([]); + await expect(store.listBinderAssetIds('proj-other')).resolves.toEqual(['a3']); + }); }); describe('locked reads reject instead of hanging (IDBRequest.onsuccess propagation)', () => { diff --git a/tests/unit/storage/protectedStoreMigration.test.ts b/tests/unit/storage/protectedStoreMigration.test.ts new file mode 100644 index 00000000..d6b74bd2 --- /dev/null +++ b/tests/unit/storage/protectedStoreMigration.test.ts @@ -0,0 +1,449 @@ +// @vitest-environment node +// QNBS-v3: Failure injection proves a durable checkpoint resumes rather than replaying an ambiguous store. +import { IDBFactory } from 'fake-indexeddb'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { assertIdbMigrationTargetKeyMatchesVerifier } = vi.hoisted(() => ({ + assertIdbMigrationTargetKeyMatchesVerifier: vi.fn(), +})); + +vi.mock('../../../services/storage/storageEncryptionService', () => ({ + assertIdbMigrationTargetKeyMatchesVerifier, +})); + +import { + __resetEncryptionMigrationJournalConnectionsForTest, + beginEncryptionMigration, + readEncryptionMigrationJournal, +} from '../../../services/storage/encryptionMigrationJournal'; +import { + type ProtectedStoreAdapter, + ProtectedStoreMigrationAdapterError, + runProtectedStoreMigration, +} from '../../../services/storage/protectedStoreMigration'; + +beforeEach(() => { + __resetEncryptionMigrationJournalConnectionsForTest(); + globalThis.indexedDB = new IDBFactory(); + assertIdbMigrationTargetKeyMatchesVerifier.mockResolvedValue(undefined); +}); + +afterEach(() => { + __resetEncryptionMigrationJournalConnectionsForTest(); +}); + +const begin = () => + beginEncryptionMigration({ + operationId: 'operation-1', + operation: 'rekey', + phase: 'prepared', + sourceGeneration: 'source', + targetGeneration: 'target', + targetVerifier: [1, 2, 3], + stores: [{ id: 'test-store', processed: 0, verified: 0, done: false }], + }); + +// QNBS-v3: Runner orchestration tests validate key presence; these adapters never invoke WebCrypto. +const migrationKeys = { sourceKey: {} as CryptoKey, targetKey: {} as CryptoKey }; + +describe('runProtectedStoreMigration', () => { + it('checkpoints committed batches and reaches committing only after verification', async () => { + const calls: Array = []; + const adapter: ProtectedStoreAdapter = { + id: 'test-store', + replaySafe: true, + async migrateNext({ cursor }) { + calls.push(cursor); + return cursor === undefined + ? { cursor: 'record-1', processed: 1, complete: false } + : { cursor: 'record-2', processed: 1, complete: true }; + }, + async verify() { + return 2; + }, + }; + + const result = await runProtectedStoreMigration(await begin(), [adapter], migrationKeys); + + expect(calls).toEqual([undefined, 'record-1']); + expect(result.phase).toBe('committing'); + expect(result.stores).toEqual([ + { id: 'test-store', cursor: 'record-2', processed: 2, verified: 2, done: true }, + ]); + await expect(readEncryptionMigrationJournal()).resolves.toMatchObject({ + phase: 'committing', + stores: [{ cursor: 'record-2', processed: 2, verified: 2, done: true }], + }); + }); + + it('resumes from the last durable cursor after an interrupted batch', async () => { + const firstAttempt: ProtectedStoreAdapter = { + id: 'test-store', + replaySafe: true, + async migrateNext() { + return { cursor: 'record-1', processed: 1, complete: false }; + }, + async verify() { + return 1; + }, + }; + const interrupted = await begin(); + await expect( + runProtectedStoreMigration( + interrupted, + [ + { + ...firstAttempt, + async migrateNext(context) { + if (context.cursor === undefined) return firstAttempt.migrateNext(context); + throw new Error('injected interruption'); + }, + }, + ], + migrationKeys, + ), + ).rejects.toThrow('injected interruption'); + + const checkpoint = await readEncryptionMigrationJournal(); + expect(checkpoint).toMatchObject({ + phase: 'migrating', + stores: [{ cursor: 'record-1', processed: 1, done: false }], + }); + + const resumedCalls: Array = []; + const resumed = await runProtectedStoreMigration( + checkpoint!, + [ + { + id: 'test-store', + replaySafe: true, + async migrateNext({ cursor }) { + resumedCalls.push(cursor); + return { cursor: 'record-2', processed: 1, complete: true }; + }, + async verify() { + return 2; + }, + }, + ], + migrationKeys, + ); + + expect(resumedCalls).toEqual(['record-1']); + expect(resumed.phase).toBe('committing'); + expect(resumed.stores[0]).toMatchObject({ cursor: 'record-2', processed: 2, verified: 2 }); + }); + + it('refuses to resume recovery-required metadata through the normal executor', async () => { + const journal = await beginEncryptionMigration({ + operationId: 'recovery-required', + operation: 'rekey', + phase: 'recovery-required', + sourceGeneration: 'source', + targetGeneration: 'target', + targetVerifier: [1, 2, 3], + stores: [{ id: 'test-store', processed: 0, verified: 0, done: false }], + }); + const adapter: ProtectedStoreAdapter = { + id: 'test-store', + replaySafe: true, + async migrateNext() { + throw new Error('must not execute'); + }, + async verify() { + throw new Error('must not execute'); + }, + }; + + await expect( + runProtectedStoreMigration(journal, [adapter], migrationKeys), + ).rejects.toBeInstanceOf(ProtectedStoreMigrationAdapterError); + await expect(readEncryptionMigrationJournal()).resolves.toMatchObject({ + phase: 'recovery-required', + }); + }); + + it('rejects a nonterminal batch that reports progress without advancing its cursor', async () => { + // QNBS-v3: without this guard, an adapter violating the "cursor omitted only before any record + // is committed" contract would retain the stale cursor forever — the runner would call + // migrateNext with the same cursor on every iteration, replaying the same records and + // inflating `processed` without ever terminating. + const adapter: ProtectedStoreAdapter = { + id: 'test-store', + replaySafe: true, + async migrateNext() { + return { processed: 1, complete: false }; + }, + async verify() { + return 0; + }, + }; + + await expect( + runProtectedStoreMigration(await begin(), [adapter], migrationKeys), + ).rejects.toThrow('reported progress without advancing its cursor'); + await expect(readEncryptionMigrationJournal()).resolves.toMatchObject({ + phase: 'migrating', + stores: [{ processed: 0, done: false }], + }); + }); + + it('rejects invalid adapter progress before it can advance the durable checkpoint', async () => { + const adapter: ProtectedStoreAdapter = { + id: 'test-store', + replaySafe: true, + async migrateNext() { + return { cursor: 'record-1', processed: -1, complete: false }; + }, + async verify() { + return 0; + }, + }; + + await expect( + runProtectedStoreMigration(await begin(), [adapter], migrationKeys), + ).rejects.toThrow('returned invalid progress'); + await expect(readEncryptionMigrationJournal()).resolves.toMatchObject({ + phase: 'migrating', + stores: [{ processed: 0, done: false }], + }); + }); + + it('rejects missing operation keys before changing the durable phase', async () => { + const adapter: ProtectedStoreAdapter = { + id: 'test-store', + replaySafe: true, + async migrateNext() { + throw new Error('must not execute'); + }, + async verify() { + throw new Error('must not execute'); + }, + }; + + await expect( + runProtectedStoreMigration(await begin(), [adapter], { sourceKey: migrationKeys.sourceKey }), + ).rejects.toThrow('Rekey migration requires source and target keys'); + await expect(readEncryptionMigrationJournal()).resolves.toMatchObject({ phase: 'prepared' }); + }); + + it('authenticates the target key before the first adapter mutation', async () => { + assertIdbMigrationTargetKeyMatchesVerifier.mockRejectedValueOnce(new Error('wrong key')); + const migrateNext = vi.fn(async () => ({ processed: 0, complete: true })); + const adapter: ProtectedStoreAdapter = { + id: 'test-store', + replaySafe: true, + migrateNext, + async verify() { + return 0; + }, + }; + + await expect( + runProtectedStoreMigration(await begin(), [adapter], migrationKeys), + ).rejects.toThrow('does not match the durable target verifier'); + expect(migrateNext).not.toHaveBeenCalled(); + const journal = await readEncryptionMigrationJournal(); + expect(journal).toMatchObject({ phase: 'prepared' }); + expect(journal?.ownerId).toBeUndefined(); + }); + + it('does not repeat a durably verified store after verification is interrupted', async () => { + const journal = await beginEncryptionMigration({ + operationId: 'verification-resume', + operation: 'rekey', + phase: 'verifying', + sourceGeneration: 'source', + targetGeneration: 'target', + targetVerifier: [1, 2, 3], + stores: [ + { id: 'first-store', processed: 2, verified: 0, done: true }, + { id: 'second-store', processed: 1, verified: 0, done: true }, + ], + }); + const calls: string[] = []; + const first: ProtectedStoreAdapter = { + id: 'first-store', + replaySafe: true, + async migrateNext() { + throw new Error('must not execute'); + }, + async verify() { + calls.push('first'); + return 2; + }, + }; + const secondFailure: ProtectedStoreAdapter = { + id: 'second-store', + replaySafe: true, + async migrateNext() { + throw new Error('must not execute'); + }, + async verify() { + calls.push('second-failure'); + throw new Error('injected verification interruption'); + }, + }; + + await expect( + runProtectedStoreMigration(journal, [first, secondFailure], migrationKeys), + ).rejects.toThrow('injected verification interruption'); + const checkpoint = await readEncryptionMigrationJournal(); + expect(checkpoint).toMatchObject({ + phase: 'verifying', + stores: [ + { id: 'first-store', verified: 2 }, + { id: 'second-store', verified: 0 }, + ], + }); + + const secondResume: ProtectedStoreAdapter = { + ...secondFailure, + async verify() { + calls.push('second-resume'); + return 1; + }, + }; + await expect( + runProtectedStoreMigration(checkpoint!, [first, secondResume], migrationKeys), + ).resolves.toMatchObject({ phase: 'committing' }); + expect(calls).toEqual(['first', 'second-failure', 'second-resume']); + }); + + it('marks recovery-required instead of looping forever when verification finds fewer valid records than were migrated', async () => { + const journal = await beginEncryptionMigration({ + operationId: 'verification-shortfall', + operation: 'rekey', + phase: 'verifying', + sourceGeneration: 'source', + targetGeneration: 'target', + targetVerifier: [1, 2, 3], + stores: [{ id: 'test-store', processed: 5, verified: 0, done: true }], + }); + const adapter: ProtectedStoreAdapter = { + id: 'test-store', + replaySafe: true, + async migrateNext() { + throw new Error('must not execute'); + }, + async verify() { + // QNBS-v3: simulates a stray write landing on an already-migrated record with the superseded key. + return 4; + }, + }; + + await expect(runProtectedStoreMigration(journal, [adapter], migrationKeys)).rejects.toThrow( + 'verification is incomplete', + ); + await expect(readEncryptionMigrationJournal()).resolves.toMatchObject({ + phase: 'recovery-required', + }); + + const parked = await readEncryptionMigrationJournal(); + await expect(runProtectedStoreMigration(parked!, [adapter], migrationKeys)).rejects.toThrow( + 'Recovery-required journal cannot run until an explicit recovery procedure validates it', + ); + }); + + it('rejects a missing registered adapter before a migration can mutate storage', async () => { + const journal = await begin(); + + await expect(runProtectedStoreMigration(journal, [], {})).rejects.toThrow( + 'No registered protected-store adapter exists for test-store', + ); + await expect(readEncryptionMigrationJournal()).resolves.toMatchObject({ + phase: 'prepared', + stores: [{ processed: 0, done: false }], + }); + }); + + it('rejects duplicate adapter identifiers before a migration can mutate storage', async () => { + const adapter: ProtectedStoreAdapter = { + id: 'test-store', + replaySafe: true, + async migrateNext() { + throw new Error('must not execute'); + }, + async verify() { + throw new Error('must not execute'); + }, + }; + + await expect(runProtectedStoreMigration(await begin(), [adapter, adapter], {})).rejects.toThrow( + 'Protected-store adapter ids must be unique', + ); + await expect(readEncryptionMigrationJournal()).resolves.toMatchObject({ + phase: 'prepared', + stores: [{ processed: 0, done: false }], + }); + }); + + it('rejects a registered adapter that has no durable checkpoint before mutation', async () => { + const checkpointAdapter: ProtectedStoreAdapter = { + id: 'test-store', + replaySafe: true, + async migrateNext() { + throw new Error('must not execute'); + }, + async verify() { + return 0; + }, + }; + const migrateNext = vi.fn(async () => ({ processed: 0, complete: true })); + const adapter: ProtectedStoreAdapter = { + id: 'store-added-after-journal-began', + replaySafe: true, + migrateNext, + async verify() { + return 0; + }, + }; + + await expect( + runProtectedStoreMigration(await begin(), [checkpointAdapter, adapter], migrationKeys), + ).rejects.toThrow('is missing from the migration journal'); + expect(migrateNext).not.toHaveBeenCalled(); + await expect(readEncryptionMigrationJournal()).resolves.toMatchObject({ phase: 'prepared' }); + }); + + it('allows only one runner to enter adapters for the same journal revision', async () => { + let releaseBatch: (() => void) | undefined; + let enteredAdapter: (() => void) | undefined; + const entered = new Promise((resolve) => { + enteredAdapter = resolve; + }); + const firstAdapter: ProtectedStoreAdapter = { + id: 'test-store', + replaySafe: true, + async migrateNext() { + enteredAdapter?.(); + return new Promise((resolve) => { + releaseBatch = () => resolve({ processed: 1, complete: true, cursor: 'record-1' }); + }); + }, + async verify() { + return 1; + }, + }; + const secondAdapter: ProtectedStoreAdapter = { + id: 'test-store', + replaySafe: true, + async migrateNext() { + throw new Error('second runner must not enter the adapter'); + }, + async verify() { + return 1; + }, + }; + const journal = await begin(); + const firstRun = runProtectedStoreMigration(journal, [firstAdapter], migrationKeys); + await entered; + + await expect( + runProtectedStoreMigration(journal, [secondAdapter], migrationKeys), + ).rejects.toThrow('ownership was lost'); + if (!releaseBatch) throw new Error('First migration batch did not start'); + releaseBatch(); + await expect(firstRun).resolves.toMatchObject({ phase: 'committing' }); + }); +}); diff --git a/tests/unit/storage/secondaryPayloadStoreAdapter.test.ts b/tests/unit/storage/secondaryPayloadStoreAdapter.test.ts new file mode 100644 index 00000000..06cb45f8 --- /dev/null +++ b/tests/unit/storage/secondaryPayloadStoreAdapter.test.ts @@ -0,0 +1,218 @@ +// @vitest-environment node +// QNBS-v3: Real fake-indexeddb coverage proves each checkpointed adapter conversion preserves recoverable payloads. +import { IDBFactory } from 'fake-indexeddb'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + createSecondaryPayloadStoreAdapter, + ProtectedStoreMigrationConflictError, + type SecondaryPayloadStoreAdapterSpec, +} from '../../../services/storage/secondaryPayloadStoreAdapter'; +import { + isSecureRecordEnvelope, + readSecureRecordPayloadWithKey, + type SecureRecordEnvelope, + StorageEncryptionService, +} from '../../../services/storage/storageEncryptionService'; + +interface DemoPayload { + content: string; +} + +interface DemoRecord extends Record { + id: string; + payload: DemoPayload | SecureRecordEnvelope; +} + +const DB_NAME = 'secondary-adapter-test'; +const STORE_NAME = 'records'; +const STORE_ID = `${DB_NAME}/${STORE_NAME}`; + +const spec: SecondaryPayloadStoreAdapterSpec = { + id: STORE_ID, + databaseName: DB_NAME, + storeName: STORE_NAME, + recordId: (record) => record.id, + context: (recordId) => ({ store: STORE_ID, recordId }), + payload: (record) => record.payload, + isPayload: (value): value is DemoPayload => + typeof value === 'object' && + value !== null && + typeof (value as Partial).content === 'string', + withPayload: (record, payload) => ({ id: record.id, payload }), + batchSize: 1, +}; + +async function createStore(records: readonly DemoRecord[]): Promise { + await new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, 1); + request.onupgradeneeded = () => request.result.createObjectStore(STORE_NAME, { keyPath: 'id' }); + request.onsuccess = () => { + const database = request.result; + const transaction = database.transaction(STORE_NAME, 'readwrite'); + for (const record of records) transaction.objectStore(STORE_NAME).put(record); + transaction.oncomplete = () => { + database.close(); + resolve(); + }; + transaction.onerror = () => reject(transaction.error); + }; + request.onerror = () => reject(request.error); + }); +} + +async function readRecord(id: string): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME); + request.onsuccess = () => { + const database = request.result; + const transaction = database.transaction(STORE_NAME, 'readonly'); + const getRequest = transaction.objectStore(STORE_NAME).get(id); + getRequest.onsuccess = () => { + database.close(); + resolve(getRequest.result as DemoRecord); + }; + getRequest.onerror = () => reject(getRequest.error); + }; + request.onerror = () => reject(request.error); + }); +} + +async function overwriteRecord(record: DemoRecord): Promise { + await new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME); + request.onsuccess = () => { + const database = request.result; + const transaction = database.transaction(STORE_NAME, 'readwrite'); + transaction.objectStore(STORE_NAME).put(record); + transaction.oncomplete = () => { + database.close(); + resolve(); + }; + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => + reject(transaction.error ?? new Error('Concurrent write aborted')); + }; + request.onerror = () => reject(request.error); + }); +} + +async function deriveKey(passphrase: string): Promise { + return new StorageEncryptionService().deriveKey(passphrase, new Uint8Array(32).fill(9)); +} + +beforeEach(() => { + global.indexedDB = new IDBFactory(); +}); + +describe('secondary protected-store payload adapter', () => { + it('converts plaintext through enable, resumable rekey, and verified disable', async () => { + await createStore([ + { id: 'a', payload: { content: 'first protected record' } }, + { id: 'b', payload: { content: 'second protected record' } }, + ]); + const adapter = createSecondaryPayloadStoreAdapter(spec); + expect(adapter.replaySafe).toBe(true); + const sourceKey = await deriveKey('source'); + const targetKey = await deriveKey('target'); + + const firstEnable = await adapter.migrateNext({ operation: 'enable', targetKey: sourceKey }); + expect(firstEnable).toMatchObject({ processed: 1, complete: false, cursor: 'a' }); + const secondEnable = await adapter.migrateNext({ + operation: 'enable', + targetKey: sourceKey, + ...(firstEnable.cursor !== undefined ? { cursor: firstEnable.cursor } : {}), + }); + expect(secondEnable).toMatchObject({ processed: 1, complete: false, cursor: 'b' }); + const finishEnable = await adapter.migrateNext({ + operation: 'enable', + targetKey: sourceKey, + ...(secondEnable.cursor !== undefined ? { cursor: secondEnable.cursor } : {}), + }); + expect(finishEnable).toEqual({ processed: 0, complete: true }); + await expect(adapter.verify({ operation: 'enable', targetKey: sourceKey })).resolves.toBe(2); + + const encrypted = await readRecord('a'); + expect(isSecureRecordEnvelope(encrypted.payload)).toBe(true); + expect(JSON.stringify(encrypted)).not.toContain('first protected record'); + + const rekeyFirst = await adapter.migrateNext({ + operation: 'rekey', + sourceKey, + targetKey, + }); + expect(rekeyFirst.cursor).toBe('a'); + // QNBS-v3: Simulate a crash after the store transaction but before the journal checkpoint persists. + await expect( + adapter.migrateNext({ operation: 'rekey', sourceKey, targetKey }), + ).resolves.toMatchObject({ cursor: 'a', processed: 1, complete: false }); + await adapter.migrateNext({ + operation: 'rekey', + sourceKey, + targetKey, + ...(rekeyFirst.cursor !== undefined ? { cursor: rekeyFirst.cursor } : {}), + }); + await adapter.migrateNext({ + operation: 'rekey', + sourceKey, + targetKey, + cursor: 'b', + }); + await expect(adapter.verify({ operation: 'rekey', sourceKey, targetKey })).resolves.toBe(2); + + const targetEncrypted = await readRecord('a'); + await expect( + readSecureRecordPayloadWithKey( + targetEncrypted.payload, + { store: STORE_ID, recordId: 'a' }, + targetKey, + ), + ).resolves.toMatchObject({ value: { content: 'first protected record' } }); + + const disableFirst = await adapter.migrateNext({ operation: 'disable', sourceKey: targetKey }); + await adapter.migrateNext({ + operation: 'disable', + sourceKey: targetKey, + ...(disableFirst.cursor !== undefined ? { cursor: disableFirst.cursor } : {}), + }); + await adapter.migrateNext({ operation: 'disable', sourceKey: targetKey, cursor: 'b' }); + await expect(adapter.verify({ operation: 'disable', sourceKey: targetKey })).resolves.toBe(2); + expect((await readRecord('a')).payload).toEqual({ content: 'first protected record' }); + }); + + it('aborts a batch instead of overwriting a record changed during asynchronous encryption', async () => { + await createStore([{ id: 'a', payload: { content: 'original protected record' } }]); + const adapter = createSecondaryPayloadStoreAdapter(spec); + const targetKey = await deriveKey('target'); + let releaseEncryption: (() => void) | undefined; + let encryptionStarted: (() => void) | undefined; + const encryptionGate = new Promise((resolve) => { + releaseEncryption = resolve; + }); + const started = new Promise((resolve) => { + encryptionStarted = resolve; + }); + const originalEncrypt = crypto.subtle.encrypt.bind(crypto.subtle); + const encryptSpy = vi.spyOn(crypto.subtle, 'encrypt').mockImplementation(async (...args) => { + encryptionStarted?.(); + await encryptionGate; + return originalEncrypt(...args); + }); + + try { + const migration = adapter.migrateNext({ operation: 'enable', targetKey }); + await started; + await overwriteRecord({ id: 'a', payload: { content: 'newer user write' } }); + if (!releaseEncryption) throw new Error('Encryption did not start'); + releaseEncryption(); + + await expect(migration).rejects.toBeInstanceOf(ProtectedStoreMigrationConflictError); + await expect(readRecord('a')).resolves.toEqual({ + id: 'a', + payload: { content: 'newer user write' }, + }); + } finally { + encryptSpy.mockRestore(); + } + }); +}); diff --git a/tests/unit/storage/secondaryProtectedStoreAdapters.test.ts b/tests/unit/storage/secondaryProtectedStoreAdapters.test.ts new file mode 100644 index 00000000..48f3f734 --- /dev/null +++ b/tests/unit/storage/secondaryProtectedStoreAdapters.test.ts @@ -0,0 +1,298 @@ +// @vitest-environment node +// QNBS-v3: Covers non-string routing-key validation — a numeric id/key must fail fast instead of becoming a journal cursor that parseJournal() later rejects into recovery-required. +import { IDBFactory } from 'fake-indexeddb'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { ProtectedStoreMigrationAdapterError } from '../../../services/storage/protectedStoreMigration'; +import { getRegisteredSecondaryProtectedStoreAdapters } from '../../../services/storage/secondaryProtectedStoreAdapters'; +import { StorageEncryptionService } from '../../../services/storage/storageEncryptionService'; + +const SCENE_REVISIONS_DB = 'worldscript-revisions-db'; +const SCENE_REVISIONS_STORE = 'scene-revisions'; +const INFERENCE_CACHE_DB = 'worldscript-inference-cache-db'; +const INFERENCE_CACHE_STORE = 'inference-cache'; +// QNBS-v3: fixed fixture timestamp instead of Date.now() — these tests never assert on the value, so a literal is simpler and safer than fake timers around real WebCrypto/fake-indexeddb async work. +const FIXED_TIMESTAMP = 1_700_000_000_000; + +function createDatabase(name: string, storeName: string, keyPath: string): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(name, 1); + request.onupgradeneeded = () => request.result.createObjectStore(storeName, { keyPath }); + request.onsuccess = () => { + request.result.close(); + resolve(); + }; + request.onerror = () => reject(request.error); + }); +} + +function putRecord(name: string, storeName: string, record: unknown): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(name); + request.onsuccess = () => { + const database = request.result; + const transaction = database.transaction(storeName, 'readwrite'); + transaction.objectStore(storeName).put(record); + transaction.oncomplete = () => { + database.close(); + resolve(); + }; + transaction.onerror = () => reject(transaction.error); + }; + request.onerror = () => reject(request.error); + }); +} + +function readRecord( + name: string, + storeName: string, + key: string, +): Promise> { + return new Promise((resolve, reject) => { + const request = indexedDB.open(name); + request.onsuccess = () => { + const database = request.result; + const transaction = database.transaction(storeName, 'readonly'); + const getRequest = transaction.objectStore(storeName).get(key); + getRequest.onsuccess = () => { + database.close(); + resolve(getRequest.result as Record); + }; + getRequest.onerror = () => reject(getRequest.error); + }; + request.onerror = () => reject(request.error); + }); +} + +function deriveKey(): Promise { + return new StorageEncryptionService().deriveKey('phase4-target', new Uint8Array(32).fill(7)); +} + +beforeEach(() => { + globalThis.indexedDB = new IDBFactory(); +}); + +describe('secondaryProtectedStoreAdapters — routing-key validation', () => { + it('rejects a scene-revision record with a non-string id instead of persisting a bad cursor', async () => { + await createDatabase(SCENE_REVISIONS_DB, SCENE_REVISIONS_STORE, 'id'); + await putRecord(SCENE_REVISIONS_DB, SCENE_REVISIONS_STORE, { + id: 42, + sectionId: 'section-1', + createdAt: Date.now(), + title: 'Untitled', + content: 'Some content', + wordCount: 2, + }); + const [sceneAdapter] = getRegisteredSecondaryProtectedStoreAdapters(); + + await expect( + sceneAdapter!.migrateNext({ operation: 'enable', targetKey: {} as CryptoKey }), + ).rejects.toMatchObject({ + constructor: ProtectedStoreMigrationAdapterError, + message: expect.stringContaining('non-string id'), + }); + }); + + it('rejects an inference-cache record with a non-string key instead of persisting a bad cursor', async () => { + await createDatabase(INFERENCE_CACHE_DB, INFERENCE_CACHE_STORE, 'key'); + await putRecord(INFERENCE_CACHE_DB, INFERENCE_CACHE_STORE, { + key: 99, + timestamp: Date.now(), + result: 'cached text', + }); + const [, cacheAdapter] = getRegisteredSecondaryProtectedStoreAdapters(); + + await expect( + cacheAdapter!.migrateNext({ operation: 'enable', targetKey: {} as CryptoKey }), + ).rejects.toMatchObject({ + constructor: ProtectedStoreMigrationAdapterError, + message: expect.stringContaining('non-string key'), + }); + }); +}); + +describe('secondaryProtectedStoreAdapters — scene revision payload shapes', () => { + it('migrates a legacy flat-field scene revision record', async () => { + await createDatabase(SCENE_REVISIONS_DB, SCENE_REVISIONS_STORE, 'id'); + await putRecord(SCENE_REVISIONS_DB, SCENE_REVISIONS_STORE, { + id: 'rev-1', + sectionId: 'section-1', + createdAt: FIXED_TIMESTAMP, + title: 'Legacy', + content: 'legacy body', + wordCount: 2, + label: 'Draft', + authorName: 'Alice', + }); + const [sceneAdapter] = getRegisteredSecondaryProtectedStoreAdapters(); + const targetKey = await deriveKey(); + + const result = await sceneAdapter!.migrateNext({ operation: 'enable', targetKey }); + expect(result).toMatchObject({ processed: 1, complete: true }); + + const migrated = await readRecord(SCENE_REVISIONS_DB, SCENE_REVISIONS_STORE, 'rev-1'); + expect(migrated['schemaVersion']).toBe(1); + expect(migrated['payload']).toBeTruthy(); + expect(migrated['title']).toBeUndefined(); + }); + + it('migrates an already-nested schemaVersion 1 scene revision record', async () => { + await createDatabase(SCENE_REVISIONS_DB, SCENE_REVISIONS_STORE, 'id'); + await putRecord(SCENE_REVISIONS_DB, SCENE_REVISIONS_STORE, { + id: 'rev-2', + sectionId: 'section-1', + createdAt: FIXED_TIMESTAMP, + schemaVersion: 1, + payload: { title: 'Nested', content: 'nested body', wordCount: 2 }, + }); + const [sceneAdapter] = getRegisteredSecondaryProtectedStoreAdapters(); + const targetKey = await deriveKey(); + + const result = await sceneAdapter!.migrateNext({ operation: 'enable', targetKey }); + expect(result).toMatchObject({ processed: 1, complete: true }); + + const migrated = await readRecord(SCENE_REVISIONS_DB, SCENE_REVISIONS_STORE, 'rev-2'); + expect(migrated['payload']).not.toEqual({ + title: 'Nested', + content: 'nested body', + wordCount: 2, + }); + }); + + it('rejects a nested-payload scene revision record with an unsupported schema version', async () => { + await createDatabase(SCENE_REVISIONS_DB, SCENE_REVISIONS_STORE, 'id'); + await putRecord(SCENE_REVISIONS_DB, SCENE_REVISIONS_STORE, { + id: 'rev-3', + sectionId: 'section-1', + createdAt: FIXED_TIMESTAMP, + schemaVersion: 2, + payload: { title: 'Future', content: 'future body', wordCount: 2 }, + }); + const [sceneAdapter] = getRegisteredSecondaryProtectedStoreAdapters(); + const targetKey = await deriveKey(); + + await expect( + sceneAdapter!.migrateNext({ operation: 'enable', targetKey }), + ).rejects.toMatchObject({ + constructor: ProtectedStoreMigrationAdapterError, + message: expect.stringContaining('unsupported schema version'), + }); + }); + + it('rejects a legacy-shape scene revision record with an unexpected extra field', async () => { + await createDatabase(SCENE_REVISIONS_DB, SCENE_REVISIONS_STORE, 'id'); + await putRecord(SCENE_REVISIONS_DB, SCENE_REVISIONS_STORE, { + id: 'rev-4', + sectionId: 'section-1', + createdAt: FIXED_TIMESTAMP, + title: 'Bad', + content: 'bad body', + wordCount: 2, + unexpectedField: 'should not be here', + }); + const [sceneAdapter] = getRegisteredSecondaryProtectedStoreAdapters(); + const targetKey = await deriveKey(); + + await expect( + sceneAdapter!.migrateNext({ operation: 'enable', targetKey }), + ).rejects.toMatchObject({ + constructor: ProtectedStoreMigrationAdapterError, + message: expect.stringContaining('unsupported record schema'), + }); + }); + + it('rejects a nested-payload scene revision record with an unexpected extra field', async () => { + await createDatabase(SCENE_REVISIONS_DB, SCENE_REVISIONS_STORE, 'id'); + await putRecord(SCENE_REVISIONS_DB, SCENE_REVISIONS_STORE, { + id: 'rev-5', + sectionId: 'section-1', + createdAt: FIXED_TIMESTAMP, + schemaVersion: 1, + payload: { title: 'Bad nested', content: 'body', wordCount: 2 }, + unexpectedField: 'should not be here', + }); + const [sceneAdapter] = getRegisteredSecondaryProtectedStoreAdapters(); + const targetKey = await deriveKey(); + + await expect( + sceneAdapter!.migrateNext({ operation: 'enable', targetKey }), + ).rejects.toMatchObject({ + constructor: ProtectedStoreMigrationAdapterError, + message: expect.stringContaining('unsupported record schema'), + }); + }); +}); + +describe('secondaryProtectedStoreAdapters — inference cache payload shapes', () => { + it('migrates a legacy flat-field inference cache record', async () => { + await createDatabase(INFERENCE_CACHE_DB, INFERENCE_CACHE_STORE, 'key'); + await putRecord(INFERENCE_CACHE_DB, INFERENCE_CACHE_STORE, { + key: 'cache-1', + timestamp: FIXED_TIMESTAMP, + result: 'legacy cached text', + }); + const [, cacheAdapter] = getRegisteredSecondaryProtectedStoreAdapters(); + const targetKey = await deriveKey(); + + const result = await cacheAdapter!.migrateNext({ operation: 'enable', targetKey }); + expect(result).toMatchObject({ processed: 1, complete: true }); + + const migrated = await readRecord(INFERENCE_CACHE_DB, INFERENCE_CACHE_STORE, 'cache-1'); + expect(migrated['payload']).toBeTruthy(); + expect(migrated['result']).toBeUndefined(); + }); + + it('migrates an already-nested-payload inference cache record', async () => { + await createDatabase(INFERENCE_CACHE_DB, INFERENCE_CACHE_STORE, 'key'); + await putRecord(INFERENCE_CACHE_DB, INFERENCE_CACHE_STORE, { + key: 'cache-2', + timestamp: FIXED_TIMESTAMP, + payload: { result: 'nested cached text' }, + }); + const [, cacheAdapter] = getRegisteredSecondaryProtectedStoreAdapters(); + const targetKey = await deriveKey(); + + const result = await cacheAdapter!.migrateNext({ operation: 'enable', targetKey }); + expect(result).toMatchObject({ processed: 1, complete: true }); + + const migrated = await readRecord(INFERENCE_CACHE_DB, INFERENCE_CACHE_STORE, 'cache-2'); + expect(migrated['payload']).not.toEqual({ result: 'nested cached text' }); + }); + + it('rejects a legacy-shape inference cache record with an unexpected extra field', async () => { + await createDatabase(INFERENCE_CACHE_DB, INFERENCE_CACHE_STORE, 'key'); + await putRecord(INFERENCE_CACHE_DB, INFERENCE_CACHE_STORE, { + key: 'cache-3', + timestamp: FIXED_TIMESTAMP, + result: 'bad', + unexpectedField: 'should not be here', + }); + const [, cacheAdapter] = getRegisteredSecondaryProtectedStoreAdapters(); + const targetKey = await deriveKey(); + + await expect( + cacheAdapter!.migrateNext({ operation: 'enable', targetKey }), + ).rejects.toMatchObject({ + constructor: ProtectedStoreMigrationAdapterError, + message: expect.stringContaining('unsupported record schema'), + }); + }); + + it('rejects a nested-payload inference cache record with an unexpected extra field', async () => { + await createDatabase(INFERENCE_CACHE_DB, INFERENCE_CACHE_STORE, 'key'); + await putRecord(INFERENCE_CACHE_DB, INFERENCE_CACHE_STORE, { + key: 'cache-4', + timestamp: FIXED_TIMESTAMP, + payload: { result: 'bad nested' }, + unexpectedField: 'should not be here', + }); + const [, cacheAdapter] = getRegisteredSecondaryProtectedStoreAdapters(); + const targetKey = await deriveKey(); + + await expect( + cacheAdapter!.migrateNext({ operation: 'enable', targetKey }), + ).rejects.toMatchObject({ + constructor: ProtectedStoreMigrationAdapterError, + message: expect.stringContaining('unsupported record schema'), + }); + }); +}); diff --git a/tests/unit/storage/secureRecordCodec.test.ts b/tests/unit/storage/secureRecordCodec.test.ts new file mode 100644 index 00000000..5e9f6f7a --- /dev/null +++ b/tests/unit/storage/secureRecordCodec.test.ts @@ -0,0 +1,62 @@ +// @vitest-environment node +// QNBS-v3: The codec must preserve structured-clone data rather than silently changing encrypted content. +import { describe, expect, it } from 'vitest'; +import { + decodeSecureRecordValue, + encodeSecureRecordValue, +} from '../../../services/storage/secureRecordCodec'; + +describe('secureRecordCodec', () => { + it('round-trips Blob bytes, Uint8Array, and explicit undefined values', async () => { + const original = { + absent: undefined, + bytes: new Uint8Array([7, 8, 9]), + artifact: new Blob(['screenplay'], { type: 'text/plain' }), + nested: [undefined, { value: undefined }], + }; + + const decoded = decodeSecureRecordValue(await encodeSecureRecordValue(original)) as { + absent: undefined; + bytes: Uint8Array; + artifact: Blob; + nested: [undefined, { value: undefined }]; + }; + + expect('absent' in decoded).toBe(true); + expect(decoded.absent).toBeUndefined(); + expect(decoded.bytes).toEqual(new Uint8Array([7, 8, 9])); + expect(decoded.artifact).toBeInstanceOf(Blob); + await expect(decoded.artifact.text()).resolves.toBe('screenplay'); + expect(decoded.nested[0]).toBeUndefined(); + expect('value' in decoded.nested[1]).toBe(true); + expect(decoded.nested[1].value).toBeUndefined(); + }); + + it.each([Number.NaN, Number.POSITIVE_INFINITY, 1n, () => undefined, Symbol('secret')])( + 'rejects a value the codec cannot represent safely', + async (value) => { + await expect(encodeSecureRecordValue(value)).rejects.toThrow('cannot encode'); + }, + ); + + it.each([ + new Map([['scene', 'opening']]), + new Set(['draft']), + new ArrayBuffer(8), + new Float32Array([1, 2, 3]), + ])('rejects non-plain structured-clone objects instead of flattening them', async (value) => { + await expect(encodeSecureRecordValue({ value })).rejects.toThrow('non-plain structured-clone'); + }); + + it('rejects malformed and unsupported serialized payloads', () => { + expect(() => decodeSecureRecordValue(new TextEncoder().encode('{"v":2,"root":{}}'))).toThrow( + 'Unsupported or malformed', + ); + expect(() => decodeSecureRecordValue(new TextEncoder().encode('{"v":1}'))).toThrow( + 'Unsupported or malformed', + ); + expect(() => decodeSecureRecordValue(new TextEncoder().encode('{"v":1,"root":{}}'))).toThrow( + 'Unsupported secure-record codec node', + ); + }); +}); diff --git a/tests/unit/storage/storageEncryptionService.test.ts b/tests/unit/storage/storageEncryptionService.test.ts index dae9388a..49ba67dc 100644 --- a/tests/unit/storage/storageEncryptionService.test.ts +++ b/tests/unit/storage/storageEncryptionService.test.ts @@ -25,10 +25,18 @@ Object.defineProperty(global, 'localStorage', { value: localStorageMock, writabl // QNBS-v3: provide a working IndexedDB for the sentinel-store tests (node has none by default). globalThis.indexedDB = new IDBFactory(); +import { + beginEncryptionMigration, + completeEncryptionMigration, + IdbMigrationInProgressError, + updateEncryptionMigrationJournal, +} from '../../../services/storage/encryptionMigrationJournal'; import * as sentinelModule from '../../../services/storage/idbPassphraseSentinel'; import { + assertIdbMigrationTargetKeyMatchesVerifier, clearIdbEncryptionKey, clearIdbPassphrase, + createIdbMigrationTargetVerifier, hasPassphraseSentinel, IdbEncryptionMigrationRequiredError, IdbEncryptionSaltLostError, @@ -39,8 +47,14 @@ import { initIdbEncryption, isEncryptedBlob, isIdbEncryptionReady, + isSecureRecordEnvelope, + prepareSecureRecordPayload, + readSecureRecordPayload, resolveProtectedWriteKey, rotateIdbPassphrase, + SECURE_RECORD_VERSION, + SecureRecordCorruptError, + SecureRecordLockedError, StorageEncryptionService, setupIdbEncryption, verifyAndInitIdbEncryption, @@ -153,6 +167,19 @@ describe('StorageEncryptionService.encrypt / decrypt', () => { }); }); +describe('migration target verifier', () => { + it('accepts only the key that created the durable verifier', async () => { + const targetKey = await freshKey('target'); + const otherKey = await freshKey('other'); + const verifier = await createIdbMigrationTargetVerifier(targetKey); + + await expect( + assertIdbMigrationTargetKeyMatchesVerifier(targetKey, verifier), + ).resolves.toBeUndefined(); + await expect(assertIdbMigrationTargetKeyMatchesVerifier(otherKey, verifier)).rejects.toThrow(); + }); +}); + // ── isEncryptedBlob ────────────────────────────────────────────────────────── describe('isEncryptedBlob', () => { @@ -175,6 +202,62 @@ describe('isEncryptedBlob', () => { }); }); +describe('secondary secure-record envelopes', () => { + const context = { store: 'worldscript-revisions-db/scene-revisions', recordId: 'revision-1' }; + + it('binds ciphertext to its record identity with AAD', async () => { + await setupIdbEncryption('secure-record-pass'); + const envelope = await prepareSecureRecordPayload({ content: 'confidential' }, context); + + expect(isSecureRecordEnvelope(envelope)).toBe(true); + expect(envelope).toMatchObject({ version: SECURE_RECORD_VERSION }); + await expect( + readSecureRecordPayload(envelope, { ...context, recordId: 'revision-2' }), + ).rejects.toBeInstanceOf(SecureRecordCorruptError); + await expect(readSecureRecordPayload(envelope, context)).resolves.toMatchObject({ + value: { content: 'confidential' }, + needsMigration: false, + }); + }); + + it('never falls back to plaintext when configured secondary storage is locked', async () => { + await setupIdbEncryption('secure-record-pass'); + clearIdbEncryptionKey(); + + await expect( + prepareSecureRecordPayload({ content: 'changed' }, context), + ).rejects.toBeInstanceOf(IdbStorageLockedError); + await expect(readSecureRecordPayload({ content: 'legacy' }, context)).rejects.toBeInstanceOf( + SecureRecordLockedError, + ); + }); + + it('treats a partial envelope as corruption rather than legacy plaintext', async () => { + await setupIdbEncryption('secure-record-pass'); + + await expect( + readSecureRecordPayload({ version: 1, iv: new Uint8Array(12) }, context), + ).rejects.toBeInstanceOf(SecureRecordCorruptError); + }); + + it('returns unlocked legacy plaintext as migration-required without treating it as final', async () => { + await setupIdbEncryption('secure-record-pass'); + + await expect(readSecureRecordPayload({ content: 'legacy' }, context)).resolves.toEqual({ + value: { content: 'legacy' }, + needsMigration: true, + }); + }); + + it('rejects a ciphertext-only envelope fragment as corruption', async () => { + await setupIdbEncryption('secure-record-pass'); + + await expect( + readSecureRecordPayload({ ciphertext: new Uint8Array(32) }, context), + ).rejects.toBeInstanceOf(SecureRecordCorruptError); + }); +}); + // ── Module singleton functions ─────────────────────────────────────────────── describe('initIdbEncryption / isIdbEncryptionReady / idbEncrypt / idbDecrypt', () => { @@ -284,6 +367,16 @@ describe('setupIdbEncryption', () => { await setupIdbEncryption('secret'); expect(await hasPassphraseSentinel()).toBe(true); }); + + it('refuses to overwrite an existing verifier outside the resumable rotation flow', async () => { + await setupIdbEncryption('first-passphrase'); + + await expect(setupIdbEncryption('second-passphrase')).rejects.toThrow( + 'Encryption is already configured', + ); + clearIdbEncryptionKey(); + await expect(verifyAndInitIdbEncryption('first-passphrase')).resolves.toBeUndefined(); + }); }); describe('verifyAndInitIdbEncryption', () => { @@ -299,6 +392,53 @@ describe('verifyAndInitIdbEncryption', () => { await expect(verifyAndInitIdbEncryption('any')).rejects.toThrow('No passphrase sentinel found'); }); + it('fails closed without replacing missing salt for an existing encrypted library', async () => { + await setupIdbEncryption('correct'); + clearIdbEncryptionKey(); + localStorageMock.removeItem('worldscript-idb-kdf-salt-v1'); + + await expect(verifyAndInitIdbEncryption('correct')).rejects.toThrow( + 'Encryption salt is missing', + ); + expect(localStorageMock.getItem('worldscript-idb-kdf-salt-v1')).toBeNull(); + }); + + it('does not replace the active key while a journal owns the encryption lifecycle', async () => { + await setupIdbEncryption('correct'); + clearIdbEncryptionKey(); + const journal = await beginEncryptionMigration({ + operationId: 'lock-setup-and-unlock', + operation: 'rekey', + phase: 'prepared', + sourceGeneration: 'source', + targetGeneration: 'target', + targetVerifier: [1, 2, 3], + stores: [], + }); + + await expect(verifyAndInitIdbEncryption('correct')).rejects.toBeInstanceOf( + IdbMigrationInProgressError, + ); + await expect(initIdbEncryption('correct')).rejects.toBeInstanceOf(IdbMigrationInProgressError); + await expect(setupIdbEncryption('replacement')).rejects.toBeInstanceOf( + IdbMigrationInProgressError, + ); + // QNBS-v3: route through the legal prepared→migrating→verifying→committing chain so this journal does not leak into later tests. + const migrating = await updateEncryptionMigrationJournal(journal, { + phase: 'migrating', + stores: journal.stores, + }); + const verifying = await updateEncryptionMigrationJournal(migrating, { + phase: 'verifying', + stores: migrating.stores, + }); + const committing = await updateEncryptionMigrationJournal(verifying, { + phase: 'committing', + stores: verifying.stores, + }); + await completeEncryptionMigration(committing); + }); + it('throws on wrong passphrase (AES-GCM auth-tag mismatch)', async () => { await setupIdbEncryption('correct'); clearIdbEncryptionKey();