diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 777912a8..d0d9a3ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,5 @@ # ============================================================ -# StoryCraft Studio – CI/CD Pipeline +# WorldScript Studio – CI/CD Pipeline # security → quality (lint, i18n, tsgo, vitest+coverage) → build (+chunk budget, rollup analyze) # ├→ e2e | storybook (parallel) ; lighthouse after build # deploy (main): needs build + e2e → GitHub Pages @@ -130,9 +130,6 @@ jobs: - name: Typecheck (tsgo) run: npx tsgo --project tsconfig.tsgo.json --noEmit --checkers 4 - - name: Build Storybook - run: pnpm run build-storybook - # QNBS-v3: Vitest mit --retry=2 für stabile Runs + NODE_OPTIONS für Node 24 Kompatibilität. # --no-experimental-webstorage deaktiviert native Node 24+ Web Storage API (verhindert localStorage.clear() Fehler). # Best Practice 2026: Retry-Logik fängt transiente Fehler ab, die bei CI-Runner-Überlastung auftreten können. @@ -452,9 +449,19 @@ jobs: run: pnpm exec playwright install --with-deps chromium - name: Serve Storybook + Run test-runner (cloud-optimised) - # QNBS-v3: || true — test failures produce artifacts and annotation but do NOT block the job; - # flaky a11y stories should not cascade into a full pipeline block on every PR. - # Remove || true once baseline is stable and no flaky a11y stories remain. + # QNBS-v3 (F-12, 2026-07-29): the command below was invoking three flags this + # @storybook/test-runner@^0.24.4 CLI does not support (`--max-workers` — wrong case, should + # be `--maxWorkers`; `--retries` and `--screenshot-on-failure` don't exist in this version + # at all). Every prior CI run failed on the FIRST unknown option before any story ever ran, + # printed --help text, and `|| true` silently turned that into a green step — this gate has + # produced zero real signal since it was added. Fixed to only pass supported flags. + # QNBS-v3 (CodeRabbit, 2026-07-29): `|| true` swallowed the exit code so this step showed + # green even when it truly failed — literally how the bug above stayed invisible. Moved the + # non-blocking behavior to step-level `continue-on-error: true`, which still shows the step + # as failed (visible signal for the F-13 "~10 real runs" re-evaluation) without failing the + # job. Re-evaluate after ~10 real (non-help-text) runs on main — if none fail on genuine + # a11y/story assertions, drop `continue-on-error` and make this blocking. + continue-on-error: true env: CI: true DEBUG: pw:browser* @@ -462,10 +469,8 @@ jobs: npx http-server storybook-static -p 6006 -s & npx wait-on http://localhost:6006 --timeout 90000 pnpm exec test-storybook --url http://localhost:6006 \ - --max-workers=2 \ - --retries=3 \ - --junit \ - --screenshot-on-failure || true + --maxWorkers=2 \ + --junit # QNBS-v3: always() — upload even on failure; artifacts are the primary debug tool on low-end # local hardware where storybook cannot run. Use the Storybook Debug workflow for diff --git a/.gitignore b/.gitignore index 3dc7ac2e..c22f837c 100644 --- a/.gitignore +++ b/.gitignore @@ -77,6 +77,11 @@ graphify-out/cost.json public/locales/*/*.json !public/locales/*/bundle.json +# Self-hosted DuckDB-WASM runtime assets (F-09) — ~75 MB, copied from the pinned +# @duckdb/duckdb-wasm npm dependency by scripts/copy-duckdb-assets.mjs (predev/prebuild). +# Never commit: these are build artifacts of an existing dependency, not source. +public/duckdb/ + # Claude Code local config and Lighthouse CI results .claude/ .lighthouseci/ diff --git a/AUDIT.md b/AUDIT.md index bf879eac..166ce2bc 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -1,8 +1,8 @@ # WorldScript Studio — Codebase Audit Report -**Date:** 2026-04-17 (baseline); **follow-up chain:** … → 2026-05-28 (v1.19.0 — Security/Voice/RTL/Logger B-1..B-8) → **2026-05-30 (B-1 passphrase UX + CI unblock)** → **2026-05-31 (i18n audit + settings features + CI stabilization)** → **2026-05-31 (Edge-AI Perfection Cycle — Phases 0-7 complete)** → **2026-06-01 (Post-crash session: CI stabilisation + 14 CodeAnt AI fixes + E2E hardening)** → **2026-06-02 (Perf Phase 2.3 — pipeline-LRU unification + PR #69 CodeAnt fixes)** → **2026-06-03 (WorkerBus v2 Phase 3 — Rust TaskSupervisor + Tauri-build unblock)** → **2026-06-06 (Phase 3 i18n Expansion — ja/zh/pt/el + Intl APIs)** → **2026-06-09 (v1.21 Deep Audit Correction — Whisper WASM download UI + 3 CodeAnt fixes + CloudSync LWW)** → **2026-06-09 (feat/deep-audit-v1.21 — CSP hardening, zh locale ≤5% EN, coverage Batches A/B/C, VoiceActivityCoordinator B-2 bridge)** → **2026-06-11 (Ultimate Copilot v2 Phase 2+3 — markdown, sidebar, Apply-to-chapter, InlineAnnotation, ProForge chip; PR #110+#111)** → **2026-06-11 (v1.22.0 release — OpenRouter Cloud 5 provider, AI Execution Modes hybrid/cloud/local/eco, AiModeIndicator, SW cache-invalidation fix)** → **2026-06-13/14 (v1.23 perfection batch — OpenRouter + AI-Execution-Mode settings sections localized/modernized, i18n single-brace interpolation bug-class fix + `i18nPlaceholders` regression guard, bundle split + budget tightening PR #130)** → **2026-06-16 (v1.23.0 release — rebrand StoryCraft → WorldScript Studio, local-first data foundation ADR-0008, Tauri blank-screen + asset-URL fixes, AI error taxonomy + retry hardening, command-palette & local-AI settings localization, WorldScript W monogram icons)** → **2026-06-16 (post-release documentation perfection pass — corpus sync, metric reconciliation, history archival, dependabot hardening)** → **2026-06-17 (Language expansion — +6 locales fi/sv/hu/is/eu/fa (RTL); PR #174 merged; `LanguageSelector` exonym localization via `portal.language.names.*`; portal chrome 100 % for the 6 new langs; README/AUDIT/CHANGELOG docs sync)** → **2026-07-28 (v1.24.1 — Local-AI reliability fixes #266, Dependabot backlog triage, Issue #60 audit closeout, security/build hardening)** +**Date:** 2026-04-17 (baseline); **follow-up chain:** … → 2026-05-28 (v1.19.0 — Security/Voice/RTL/Logger B-1..B-8) → **2026-05-30 (B-1 passphrase UX + CI unblock)** → **2026-05-31 (i18n audit + settings features + CI stabilization)** → **2026-05-31 (Edge-AI Perfection Cycle — Phases 0-7 complete)** → **2026-06-01 (Post-crash session: CI stabilisation + 14 CodeAnt AI fixes + E2E hardening)** → **2026-06-02 (Perf Phase 2.3 — pipeline-LRU unification + PR #69 CodeAnt fixes)** → **2026-06-03 (WorkerBus v2 Phase 3 — Rust TaskSupervisor + Tauri-build unblock)** → **2026-06-06 (Phase 3 i18n Expansion — ja/zh/pt/el + Intl APIs)** → **2026-06-09 (v1.21 Deep Audit Correction — Whisper WASM download UI + 3 CodeAnt fixes + CloudSync LWW)** → **2026-06-09 (feat/deep-audit-v1.21 — CSP hardening, zh locale ≤5% EN, coverage Batches A/B/C, VoiceActivityCoordinator B-2 bridge)** → **2026-06-11 (Ultimate Copilot v2 Phase 2+3 — markdown, sidebar, Apply-to-chapter, InlineAnnotation, ProForge chip; PR #110+#111)** → **2026-06-11 (v1.22.0 release — OpenRouter Cloud 5 provider, AI Execution Modes hybrid/cloud/local/eco, AiModeIndicator, SW cache-invalidation fix)** → **2026-06-13/14 (v1.23 perfection batch — OpenRouter + AI-Execution-Mode settings sections localized/modernized, i18n single-brace interpolation bug-class fix + `i18nPlaceholders` regression guard, bundle split + budget tightening PR #130)** → **2026-06-16 (v1.23.0 release — rebrand StoryCraft → WorldScript Studio, local-first data foundation ADR-0008, Tauri blank-screen + asset-URL fixes, AI error taxonomy + retry hardening, command-palette & local-AI settings localization, WorldScript W monogram icons)** → **2026-06-16 (post-release documentation perfection pass — corpus sync, metric reconciliation, history archival, dependabot hardening)** → **2026-06-17 (Language expansion — +6 locales fi/sv/hu/is/eu/fa (RTL); PR #174 merged; `LanguageSelector` exonym localization via `portal.language.names.*`; portal chrome 100 % for the 6 new langs; README/AUDIT/CHANGELOG docs sync)** → **2026-07-28 (v1.24.1 — Local-AI reliability fixes #266, Dependabot backlog triage, Issue #60 audit closeout, security/build hardening)** → **2026-07-29 (v1.24.2, PR #284 — CSP functional-truth, desktop crypto, and doc-truth hardening; the local-inference stack had been silently non-functional in production for two months; PR open/CI green, not yet merged)** **Scope:** Full application, repository configuration, CI/CD, documentation, release validation -**Current version:** **v1.24.1** — 2026-07-28 (Local-AI reliability — desktop Ollama/LM Studio/vLLM discovery + browser status-badge fixes; AI heuristic-fallback foundation + Outline/Character/World/Plot-Board generators; real self-hosted LanguageTool integration; 20-PR Dependabot backlog triage; Issue #60 vendor-fork audit closeout; `persist-credentials`/CWE-209 security hardening; **2844 keys × 19 locales**) +**Current version:** **v1.24.2** — 2026-07-29, **PR #284 open, CI green, not yet merged/tagged/released** (tag + GitHub Release publish are maintainer actions per this sprint's own hard rules) — CSP/crypto/doc-truth hardening: `'wasm-unsafe-eval'` restored on all 5 deployment surfaces after a 2-month functional-truth gap; desktop API-key encryption moved from unsalted SHA-256 to PBKDF2 600k + salt; DuckDB-WASM self-hosted; canonical Vercel URL unified; CI Storybook dedup + a genuinely broken test-runner invocation fixed; coverage ratchet raised to CI-measured values; **2849 keys × 19 locales**. Last actually-released version remains **v1.24.1** (2026-07-28) until this PR merges. **Quality gate (2026-06-17 — language expansion +6 locales):** lint ✅ · typecheck ✅ · i18n:check ✅ (**2716 keys × 17 locales** — fi/sv/hu/is/eu + fa RTL) · placeholder guard ✅ (17 bundles) · targeted unit tests ✅ (LanguageSelector 9 · I18nContext 59 · i18nPlaceholders 33). `LanguageSelector` exonym labels localized via `portal.language.names.*` (native endonym stays hardcoded by design). **Bulk translation completed** for all 10 Beta locales (glossary v2.0, ~44 anchor terms/locale; placeholder-masked, checkpointed): post-run coverage fi 91 % · sv 90 % · hu 91 % · is 92 % · eu 92 % · fa 93 % · ja 99 % · zh 100 % · pt 98 % · el 97 % (Beta MT; human native review tracked). Two bulk-script bugs fixed: (1) `glossaryTranslate` partial-match left ~1,300 strings partially English → now exact-match only; (2) `--all` mangled `help.json` rich HTML → `help.json` excluded from `--all` (`ALL_SKIP`) and kept English fallback for the 6 new langs (tag-dense markup isn't MT-safe; human-review task). New `docs/TRANSLATION-GUIDE.md` + `I18N-GLOSSARY.md` v2.0. @@ -12,6 +12,76 @@ **Quality gate (2026-06-21 — feature-flag catalog + grouped Settings + ProForge opt-in):** lint ✅ · typecheck ✅ (tsgo) · i18n:check ✅ (**2793 keys × 17 locales**) · parity:check ✅ (0 drifts) · suppressions ratchet ✅ (52, no new) · targeted unit tests ✅ (127: slice 89, catalog 7, flagDependencies 5, FeatureFlagsSection 15, FeatureFlagsAndOverview 11). `enableProForge` default flipped to opt-in (now **17 on / 6 off**); `featureCatalog.ts` reconciled to all 23 flags with `defaultOn` **derived** from the slice (drift now structurally impossible — guarded by `tests/unit/featureCatalog.test.ts`). Shipped as a standalone PR off `main`. +## Audit 2026-07-29 — CSP / Crypto / Truth (v1.24.2) + +**Quality gate (2026-07-29):** lint ✅ · typecheck ✅ (tsgo) · i18n:check ✅ (**2849 keys × 19 +locales**) · suppressions ratchet ✅ (52, no new) · token-audit ✅ (160, no new) · parity:check ✅ +(0 drifts) · `pnpm run build` + `bundle:budget` ✅ (154 JS chunks ≤ 6200 KB) · +`scripts/smoke-prod-build.mjs` ✅ (0 real CSP violations, 1 documented known-benign exclusion, +`wasm: ok`) · CI `quality` job (Node 22 + 24) ✅ — coverage L80.86/F73.78/B66.74/S79.02, ratchet +raised to L79/F72/B65/S77. Full execution record: +[`docs/audit/WS-RUN-LOG-2026-07-29.md`](docs/audit/WS-RUN-LOG-2026-07-29.md). Branch +`fix/csp-truth-hardening-v1.24.2`, PR #284. + +Source audit (`PROMPT-WSS-v1.24.x`) empirically re-verified against current disk state before any +change landed — all 14 findings CONFIRMED, no baseline drift, base SHA `f5f9c1ba`. + +### Finding table + +| ID | Severity | Finding | Resolution | +|---|---|---|---| +| F-01 | 🔴 P0 | `script-src 'self'` missing `'wasm-unsafe-eval'` on all 5 CSP surfaces — `WebAssembly.instantiate` blocked in every deployed Chromium browser since 2026-05-27 (`faad8f0`); the entire advertised local-inference stack never functioned in production | Fixed on all 5 surfaces; [ADR-0013](docs/adr/0013-csp-wasm-and-blob-frames.md) | +| F-02 | 🔴 P0 | Unhashed inline `` could slip past the + exact detector meant to catch the F-01/F-02 defect class. Fixed in two follow-up commits, both + with new regression tests. + - **Review-loop refinements** (CodeRabbit/CodeAnt, all verified before accepting): the F-10 URL + drift gate had excluded `locales/it/help.json` — the actual file where the real incident + happened — reasoning it was "generated" (it isn't; `public/locales/it/bundle.json` is the + generated one). Now scanned, with the URL regex's scheme made optional (the Italian source + references the bare hostname inside a `` tag, no `https://` prefix). The Storybook + `|| true` fix from WS-8 was itself swallowing the step's real exit code — moved to step-level + `continue-on-error: true`, which still surfaces genuine failure in the Actions UI without + blocking the job (the exact visibility gap that let F-12's flag-name bug hide for so long). + `copy-duckdb-assets.mjs`'s idempotency check gained a SHA-256 content hash alongside the + file-size check (same-size dependency bump could otherwise leave stale content). +- **Unrelated, pre-existing E2E finding (documented, not fixed — correctly out of scope):** + `tests/e2e/a11y.spec.ts` — "writer version control panel has no serious axe violations" fails + deterministically (same 4.47-vs-4.5 `color-contrast` ratio on both `chromium` and `Mobile + Chrome`, both CI attempts) on `--sc-accent` (`#92400e`) over its own `/20`-opacity background + (`#e0cab0`). Confirmed unrelated to this PR: `git diff --name-only main...HEAD` touches no CSS, + Tailwind config, Writer/version-control component, or color token, and `package.json`'s only + diff is the WS-5 `copy-duckdb-assets.mjs` script wiring (no dependency bump). `e2e` is **not** in + the branch's required status checks (`gh api .../branches/main/protection` → + `required_status_checks.contexts` = Security Audit, Build, Quality Gate ×2 only), so this does + not block merge. Left untouched — fixing an unrelated design-token contrast issue is out of scope + for a CSP/crypto/doc-truth security sprint; flagged here for a maintainer to file separately. +- **DoD evidence:** + ```bash + grep -q "Ausstiegskriterium\|exit criterion" docs/CI.md → OK + pnpm exec biome check vitest.config.ts → clean + CI quality job (both Node versions) → pass, with the new thresholds in effect for this exact commit + ``` +- **Deviations (🟡):** thresholds set to +5-per-metric cap rather than the literal "~1pt below + measured" (which would have been +6 on statements) — documented above and in the + `vitest.config.ts` comment itself. + +--- + +### WS-7 — Version bump to 1.24.2 +- **Status:** ✅ done (version-bump commit only, per hard rule — tagging/publishing a release is + a maintainer action, STOP-AND-ASK, not performed) +- **Finding IDs:** none directly — release housekeeping, last in Wave 2 per the execution order +- **Fix:** `package.json` bumped to `1.24.2`; ran `node scripts/sync-tauri-version.mjs` and + `node scripts/sync-sw-version.mjs` rather than hand-editing (per `CLAUDE.md`'s documented + auto-sync convention) — these updated `src-tauri/tauri.conf.json`, `src-tauri/Cargo.toml`, and + `public/sw.js`'s `APP_VERSION` in place. `node scripts/sync-readme-metrics.mjs` reported + "already in sync" (it doesn't track the version badge specifically) — the `README.md` version + badge (`v1.24.1` → `v1.24.2`) updated by hand. New `CHANGELOG.md` `[1.24.2]` entry lists every + finding ID (F-01…F-14) with its resolution, organized by Security/Fixed/Documentation per + Keep-a-Changelog convention, matching this project's existing entry style. +- **Scope note:** `ROADMAP.md`/`AUDIT.md`/`TODO.md`'s remaining `1.24.1` references are WS-11's + job (re-audit close-out), not this workstream — confirmed against the plan's own division of + labor before leaving them untouched here. +- **DoD evidence:** + ```bash + node -p "require('./package.json').version" → 1.24.2 + python3 -c "...tauri.conf.json...['version']" → 1.24.2 + grep -q "v1.24.2" README.md CHANGELOG.md → both found + node scripts/check-doc-metrics.mjs → OK + pnpm exec biome check package.json → clean + pnpm exec vitest run tests/unit/swLocaleStrategy.test.ts → 5/5 passed (only test referencing APP_VERSION conceptually; no hardcoded version-string assertion, confirmed no breakage) + ``` +- **Deviations (🟡):** none. + +--- + +### WS-11 — Re-audit + post-mortem close-out +- **Status:** ✅ done +- **Finding IDs:** all 14 (closing entry) +- **Full re-audit, final state (all commits through the WS-7 version bump):** + ```bash + $ node scripts/check-i18n-keys.mjs && node scripts/build-i18n.mjs && node scripts/content-guard.mjs \ + && node scripts/check-doc-metrics.mjs && node scripts/check-suppressions.mjs && node scripts/audit-tokens.mjs + [i18n] OK — 19 locales match 2849 keys (reference: en). + [build-i18n] Done. 54131 total keys across 19 bundles. (git status confirmed no diff — nothing hand-edited) + content-guard: OK (community templates) + [docs:check] OK — 7 files match actual state (19 locales, 2849 keys, latest v1.24.1) + [suppressions] OK — 52 (baseline 52) + [token-audit] PASS — 160 ≤ baseline 160 + + pnpm run lint → clean (1648 files) + pnpm run typecheck (fresh, *.tsbuildinfo removed first) → clean + pnpm run parity:check → 0 drifts, 22 flags, "Audit passed — all flags consistent" + pnpm run build && pnpm run bundle:budget → 154 JS chunks ≤ 6200 KB + node scripts/smoke-prod-build.mjs → PASS, 0 CSP violations, wasm: ok + ``` + **`latest v1.24.1` above is correct-by-design, not stale** (CodeRabbit flagged this — verified, + not a bug): `check-doc-metrics.mjs#getLatestReleasedVersion()` reads `git tag`, not + `package.json`. `package.json`/`tauri.conf.json` are pre-bumped to `1.24.2` (WS-7) as release + prep, but no `v1.24.2` git tag exists yet — tagging is a maintainer action, deliberately not + performed by this sprint (see WS-7's own entry and every "not yet merged/tagged" note in + `AUDIT.md`/`TODO.md`/`ROADMAP.md`). The gate will correctly report `v1.24.2` once that tag is + created; reporting it now, before the tag exists, would itself be the doc-truth violation this + entire sprint is about. + `pnpm run test:run` (full suite, no `--coverage`) was not re-attempted a third time locally — + two independent attempts earlier in this sprint were both killed with zero output on this + machine's ~3.7 GB RAM. CI's `quality` job's `vitest run --coverage` (a strict superset — a + coverage report cannot generate from a suite with failing tests) already passed on this exact + commit history through the WS-9 push, which is stronger evidence of full-suite health than a + third local attempt would add. +- **Docs:** `AUDIT.md` gained the "Audit 2026-07-29 — CSP / Crypto / Truth" section — full finding + table (all 14 IDs, severity, resolution) plus the post-mortem answering the sprint's central + question directly (would a `faad8f0`-shaped commit fail today? yes, in 3 independent places — + walked through each). The post-mortem also names the two *other* instances of the identical + "gate looks green but checks the wrong property" pattern that surfaced **during this same + sprint's own execution** (Storybook's `|| true`, F-12; the F-10 drift gate's own initial + exclusion of the one file with real incident history) — three occurrences in one sprint is + itself the finding, documented as such rather than treated as three unrelated bugs. + `TODO.md` gained a `v1.24.2` block (code-complete items + the two genuinely-open follow-ups: + worker-generation consolidation, F-14; the pre-existing unrelated a11y color-contrast finding). + `ROADMAP.md` synced with a matching `v1.24.2` section. +- **Doc-truth discipline applied to these edits themselves:** none of the three docs claim this + work is "released" or "merged" — `AUDIT.md`'s version line, `TODO.md`'s status line, and + `ROADMAP.md`'s section header all explicitly state "PR #284 open, CI green, not yet + merged/tagged," matching this sprint's own theme (don't let a doc claim more than what's + actually true). Caught and corrected in my own first draft before committing. +- **DoD evidence:** + ```bash + grep -q "2026-07-29" AUDIT.md → found + node scripts/check-doc-metrics.mjs → OK (re-run after AUDIT/TODO/ROADMAP edits) + git status --short → only AUDIT.md, TODO.md, ROADMAP.md changed + ``` +- **Deviations (🟡):** `pnpm run test:run` full-suite re-run substituted with CI's already-passing + `--coverage` run as equivalent-or-stronger evidence — documented above with the reasoning. diff --git a/index.html b/index.html index d7805e26..e778b547 100644 --- a/index.html +++ b/index.html @@ -47,7 +47,13 @@ - +
diff --git a/index.tsx b/index.tsx index a4e14015..b340b597 100644 --- a/index.tsx +++ b/index.tsx @@ -49,6 +49,14 @@ import '@fontsource/noto-sans/greek-700.css'; import './index.css'; import './register-sw'; +// QNBS-v3: Disable Aurora on low-end hardware (≤4 logical CPUs) to prevent GPU/battery drain; +// prefers-reduced-data handled in CSS. Moved out of an inline bodies in index.html that have no `src` attribute and non-empty + * content. Case-insensitive (`i` flag) — HTML tag names are case-insensitive per spec, and an + * uppercase `` + * or `` — browsers still parse these as a valid `` (CodeQL's own "Bad HTML + * filtering regexp" example), so a strict `<\/script>` literal would let such a script slip past + * this detector undetected, same failure class as the case-sensitivity gap above. `\b` after the + * opening ``. + */ +function inlineScriptContents(html: string): string[] { + const results: string[] = []; + // QNBS-v3 (CodeQL js/bad-tag-filter): tolerate a malformed-but-browser-valid closing tag like `` so it can't evade this detector. + for (const m of html.matchAll(/]*)>([\s\S]*?)<\/script(?:\s[^>]*)?>/gi)) { + const attrs = m[1] ?? ''; + const body = m[2] ?? ''; + if (!/\bsrc\s*=/i.test(attrs) && body.trim().length > 0) { + results.push(body); + } + } + return results; +} + +const namedCsps: readonly [name: string, csp: string][] = [ + ['index.html (meta)', webCsp()], + ['vercel.json (header)', extractVercelHeaderValue(vercelJson, 'Content-Security-Policy')], + ['public/_headers (Cloudflare)', extractHeadersFileValue(headersFile, 'Content-Security-Policy')], + ['src-tauri/tauri.conf.json (Tauri WebView)', tauriCsp()], + ...nginxCsps().map((csp, i) => [`nginx.conf (block ${i + 1})`, csp] as [string, string]), +]; + +describe('CSP correctness — Layer B (functional directives, not cross-surface consistency)', () => { + it('nginx.conf has exactly 3 Content-Security-Policy add_header blocks', () => { + expect(nginxCsps()).toHaveLength(3); + }); + + it('covers all 5 deployment surfaces (4 header/meta files + 3 nginx blocks = 7 CSP strings)', () => { + expect(namedCsps).toHaveLength(7); + }); + + it.each(namedCsps)( + "%s: script-src contains 'wasm-unsafe-eval' (WebLLM/ONNX Runtime Web/Transformers.js/DuckDB-WASM/Whisper-STT/Kokoro-TTS)", + (_name, csp) => { + const tokens = directiveTokens(csp, 'script-src'); + expect(tokens, 'script-src directive must exist').toBeDefined(); + expect(tokens).toContain("'wasm-unsafe-eval'"); + }, + ); + + it.each(namedCsps)("%s: script-src does NOT contain 'unsafe-eval'", (_name, csp) => { + const tokens = directiveTokens(csp, 'script-src') ?? []; + expect(tokens).not.toContain("'unsafe-eval'"); + }); + + it.each(namedCsps)("%s: script-src does NOT contain 'unsafe-inline'", (_name, csp) => { + const tokens = directiveTokens(csp, 'script-src') ?? []; + expect(tokens).not.toContain("'unsafe-inline'"); + }); + + it.each(namedCsps)( + '%s: frame-src (or child-src) allows blob: (Binder/ManuscriptResearchSplit PDF-preview iframes)', + (_name, csp) => { + const tokens = directiveTokens(csp, 'frame-src') ?? directiveTokens(csp, 'child-src'); + expect(tokens, 'frame-src or child-src directive must exist').toBeDefined(); + expect(tokens).toContain('blob:'); + }, + ); + + it.each(namedCsps)( + '%s: worker-src allows blob: (regression guard — DuckDB/inference workers)', + (_name, csp) => { + expect(directiveTokens(csp, 'worker-src')).toContain('blob:'); + }, + ); + + it.each(namedCsps)('%s: img-src allows blob: (regression guard)', (_name, csp) => { + expect(directiveTokens(csp, 'img-src')).toContain('blob:'); + }); + + it.each(namedCsps)("%s: object-src is 'none'", (_name, csp) => { + expect(directiveTokens(csp, 'object-src')).toEqual(["'none'"]); + }); + + it.each(namedCsps)("%s: base-uri is 'self'", (_name, csp) => { + expect(directiveTokens(csp, 'base-uri')).toEqual(["'self'"]); + }); + + it.each(namedCsps)( + "%s: frame-ancestors is 'none' (inert in the meta tag, effective as a header)", + (_name, csp) => { + expect(directiveTokens(csp, 'frame-ancestors')).toEqual(["'none'"]); + }, + ); +}); + +describe('CSP correctness — connect-src completeness (F-08)', () => { + // QNBS-v3: LanguageTool's default self-hosted URL is `http://localhost:8010` — an HTTP origin, + // which the web PWA's `https:` scheme-source does NOT cover either (different scheme). This was + // missing on ALL 5 surfaces, not just Tauri as the source audit assumed (its "the web `https:` + // masks the gap" reasoning only holds for HTTPS-reachable hosts). Fixed everywhere 2026-07-29. + it.each(namedCsps)( + '%s: allows LanguageTool default self-hosted port 8010 (features/settings/settingsSlice.ts:105)', + (_name, csp) => { + const tokens = directiveTokens(csp, 'connect-src') ?? []; + expect(tokens).toContain('http://localhost:8010'); + expect(tokens).toContain('http://127.0.0.1:8010'); + }, + ); + + // QNBS-v3: the web PWA's `connect-src 'self' https: ...` DOES implicitly cover these HTTPS hosts + // (ADR-0004) — only the strict Tauri connect-src (no `https:` blanket) needs them enumerated. + describe('Tauri-only: Hugging Face Hub hosts (no https: blanket to fall back on)', () => { + const tauriConnectSrc = directiveTokens(tauriCsp(), 'connect-src') ?? []; + + it('allows the Hugging Face Hub host that WebLLM/Transformers.js resolve models from (their library defaults)', () => { + expect(tauriConnectSrc).toContain('https://huggingface.co'); + }); + + it('allows the Hugging Face Xet CDN bridge that actual model-weight downloads redirect to (empirically traced 2026-07-29: a resolve/main/*.bin request 302s to us.aws.cdn.hf.co)', () => { + expect(tauriConnectSrc).toContain('https://us.aws.cdn.hf.co'); + }); + }); +}); + +describe('CSP correctness — no ungehashtes (unhashed) inline '; + expect(inlineScriptContents(fakeHtml)).toEqual(['doSomething();']); + }); + + it('does not flag an uppercase '; + expect(inlineScriptContents(fakeHtml)).toEqual([]); + }); + + // QNBS-v3: regression guard for CodeQL's "Bad HTML filtering regexp" (js/bad-tag-filter) — + // a malformed-but-browser-valid closing tag must still be caught, not silently skipped. + it('detects an inline script whose closing tag has trailing whitespace/attributes', () => { + const fakeHtml = ''; + expect(inlineScriptContents(fakeHtml)).toEqual(['doSomething();']); + }); + + it('detects an inline script whose closing tag has only whitespace before >', () => { + const fakeHtml = ''; + expect(inlineScriptContents(fakeHtml)).toEqual(['doSomething();']); + }); + + it('does not mistake an unrelated tag name for a script open tag', () => { + const fakeHtml = 'not a script'; + expect(inlineScriptContents(fakeHtml)).toEqual([]); + }); +}); diff --git a/tests/unit/services/fs/fsCore.test.ts b/tests/unit/services/fs/fsCore.test.ts index bed21c84..284d2194 100644 --- a/tests/unit/services/fs/fsCore.test.ts +++ b/tests/unit/services/fs/fsCore.test.ts @@ -68,6 +68,7 @@ describe('encryptText / decryptText', () => { it('round-trips a value with the same secret', async () => { const payload = await encryptText('top secret manuscript', 'passphrase-123'); expect(payload.iv).toBeTruthy(); + expect(payload.salt).toBeTruthy(); expect(payload.data).toBeTruthy(); await expect(decryptText(payload, 'passphrase-123')).resolves.toBe('top secret manuscript'); }); @@ -76,6 +77,24 @@ describe('encryptText / decryptText', () => { const payload = await encryptText('top secret', 'right-key'); await expect(decryptText(payload, 'wrong-key')).rejects.toBeDefined(); }); + + // QNBS-v3 (F-05/F-06 fix, 2026-07-29): regression guard for the PBKDF2 + random-salt derivation + // replacing the prior unsalted single-SHA-256 scheme. + it('produces a different ciphertext, iv, and salt on every encryption of the same secret+plaintext', async () => { + const a = await encryptText('same plaintext', 'same-secret-material'); + const b = await encryptText('same plaintext', 'same-secret-material'); + expect(a.salt).not.toBe(b.salt); + expect(a.iv).not.toBe(b.iv); + expect(a.data).not.toBe(b.data); + // Both must still independently decrypt correctly with their own salt/iv. + await expect(decryptText(a, 'same-secret-material')).resolves.toBe('same plaintext'); + await expect(decryptText(b, 'same-secret-material')).resolves.toBe('same plaintext'); + }); + + it('rejects a legacy (pre-2026-07-29) payload with no salt field', async () => { + const legacyPayload = { iv: 'AAAAAAAAAAAAAAAA', data: 'AAAAAAAAAAAAAAAA' }; + await expect(decryptText(legacyPayload, 'any-secret')).rejects.toThrow(/legacy/i); + }); }); describe('sanitizePathSegment', () => { diff --git a/tests/unit/services/fs/fsStores.test.ts b/tests/unit/services/fs/fsStores.test.ts index c5325d86..11840ba7 100644 --- a/tests/unit/services/fs/fsStores.test.ts +++ b/tests/unit/services/fs/fsStores.test.ts @@ -37,6 +37,7 @@ vi.mock('@tauri-apps/api/path', () => ({ join: (...parts: string[]) => fsHolder.current.join(...parts), })); +import { appStoreRef } from '../../../../app/storeRef'; import { FsProjectStore } from '../../../../services/fs/projectFsStore'; interface FakeFs { @@ -169,6 +170,48 @@ describe('FsSettingsStore — settings + encrypted API keys', () => { it('returns null when decrypting a missing key', async () => { expect(await store.getApiKey('anthropic')).toBeNull(); }); + + // QNBS-v3 (F-05/F-06 fix, 2026-07-29): a pre-2026-07-29 key file (unsalted single-SHA-256 + // scheme) is discarded, not migrated (locked decision) — this asserts the discard path returns + // null without throwing, removes the stale file, and surfaces a one-time notification rather + // than failing silently. + it('discards a legacy unsalted key file, removes it, and notifies instead of throwing', async () => { + const dispatch = vi.fn(); + appStoreRef.current = { getState: vi.fn(), dispatch } as never; + try { + const legacyFile = '/app/config/legacyprovider_key.enc.json'; + fake.text.set( + legacyFile, + JSON.stringify({ iv: 'AAAAAAAAAAAAAAAA', data: 'AAAAAAAAAAAAAAAA' }), + ); + + const result = await store.getApiKey('legacyprovider'); + + expect(result).toBeNull(); + expect(fake.text.has(legacyFile)).toBe(false); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + type: 'info', + title: expect.stringContaining('API Key Reset'), + }), + }), + ); + } finally { + appStoreRef.current = null; + } + }); + + // QNBS-v3 (Codecov-flagged missing line): the discard path's own cleanup can itself fail (e.g. + // the file is locked or already gone) — asserts that failure is swallowed (logged, not thrown) + // rather than surfacing as an unhandled rejection from getApiKey. + it('swallows a failure to remove the stale legacy key file (cleanup-of-cleanup)', async () => { + const legacyFile = '/app/config/legacyprovider2_key.enc.json'; + fake.text.set(legacyFile, JSON.stringify({ iv: 'AAAAAAAAAAAAAAAA', data: 'AAAAAAAAAAAAAAAA' })); + fake.apis.remove = () => Promise.reject(new Error('EBUSY: file is locked')); + + await expect(store.getApiKey('legacyprovider2')).resolves.toBeNull(); + }); }); describe('FsSnapshotStore — snapshots', () => { diff --git a/vercel.json b/vercel.json index acc54aa0..d0752a89 100644 --- a/vercel.json +++ b/vercel.json @@ -26,7 +26,7 @@ { "key": "Permissions-Policy", "value": "camera=(), microphone=(self), geolocation=()" }, { "key": "Content-Security-Policy", - "value": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob:; connect-src 'self' https: http://localhost:11434 http://127.0.0.1:11434 http://localhost:1234 http://127.0.0.1:1234 http://localhost:8000 http://127.0.0.1:8000 wss://y-webrtc-signaling.fly.dev wss://signaling.yjs.dev; worker-src 'self' blob:; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests" + "value": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob:; connect-src 'self' https: http://localhost:11434 http://127.0.0.1:11434 http://localhost:1234 http://127.0.0.1:1234 http://localhost:8000 http://127.0.0.1:8000 http://localhost:8010 http://127.0.0.1:8010 wss://y-webrtc-signaling.fly.dev wss://signaling.yjs.dev; worker-src 'self' blob:; frame-src 'self' blob:; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests" } ] } diff --git a/vite.config.ts b/vite.config.ts index 1847cafd..5d24ed26 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -69,6 +69,8 @@ export default defineConfig({ '**/vendor-transformers*', '**/vendor-voice-wasm*', '**/*.wasm', + // QNBS-v3 (F-09): self-hosted duckdb-wasm *.worker.js (scripts/copy-duckdb-assets.mjs) matches the **/*.js allowlist above unlike the already-excluded *.wasm — exclude explicitly, same feature-flag-gated reasoning as vendor-duckdb*. + '**/duckdb/**', ], }, // Manifest bereits in public/manifest.json eingebunden diff --git a/vitest.config.ts b/vitest.config.ts index 16910b3e..f849b955 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -72,14 +72,20 @@ export default defineConfig({ // → L74/F66/B60/S72 (2026-06-03: ratchet to ~1pt below CI-measured 75.15/67.84/61.23/73.14 // after Phase 2.3/2.4 tests; margin absorbs Node 22/24 variance). // → L74/F67/B60/S72 (2026-06-06: incremental ratchet to ~1pt below CI-measured values). + // → L79/F72/B65/S77 (2026-07-29, F-13: WS-9 of the CSP/crypto/doc-truth sprint — + // CI-measured L80.86/F73.78/B66.74/S79.02, identical on Node 22 and 24; capped at +5 pts + // per metric per the quarterly cap below rather than the full ~1pt-below-measured jump, + // since the straight ~1pt margin would have exceeded +5 on statements. First ratchet of + // Q3 2026 — the prior entries above are all Q2, so this is not a same-quarter stack-up). // P1 target: L85/B75/F80 — next ratchet after verification. // RATCHET RULE: After 3 consecutive green CI runs on both Node versions, increase each threshold // by 1 point (max 5 points per quarter). Document the new baseline in this comment. + // QNBS-v3 (CodeRabbit): raising these enforces the new, stricter CI floor going forward — any PR that drops coverage below L79/F72/B65/S77 now fails the quality gate instead of silently regressing under the old L74/F67/B60/S72 bar. thresholds: { - lines: 74, - functions: 67, - branches: 60, - statements: 72, + lines: 79, + functions: 72, + branches: 65, + statements: 77, perFile: false, }, }, diff --git a/workers/duckdbWorker.ts b/workers/duckdbWorker.ts index 2b2c4755..30899b36 100644 --- a/workers/duckdbWorker.ts +++ b/workers/duckdbWorker.ts @@ -54,25 +54,23 @@ async function isOPFSSupported(): Promise { } } +// QNBS-v3 (F-09): self-hosted, same-origin DuckDB-WASM assets (scripts/copy-duckdb-assets.mjs) replacing an unversioned CDN that was already dead under worker-src 'self' blob:' CSP; absolute path so resolution is unambiguous from either the main thread or this nested worker context. +const DUCKDB_ASSET_BASE = `${import.meta.env.BASE_URL}duckdb/`; +const SELF_HOSTED_BUNDLES = { + mvp: { + mainModule: `${DUCKDB_ASSET_BASE}duckdb-mvp.wasm`, + mainWorker: `${DUCKDB_ASSET_BASE}duckdb-browser-mvp.worker.js`, + }, + eh: { + mainModule: `${DUCKDB_ASSET_BASE}duckdb-eh.wasm`, + mainWorker: `${DUCKDB_ASSET_BASE}duckdb-browser-eh.worker.js`, + }, +}; + async function initDuckDb(): Promise { const { AsyncDuckDB, selectBundle, ConsoleLogger } = await getDuckDb(); - const JSDELIVR_BUNDLES = { - mvp: { - mainModule: 'https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm/dist/duckdb-mvp.wasm', - mainWorker: - 'https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm/dist/duckdb-browser-mvp.worker.js', - }, - eh: { - mainModule: 'https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm/dist/duckdb-eh.wasm', - mainWorker: - 'https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm/dist/duckdb-browser-eh.worker.js', - }, - }; - - // QNBS-v3: Use self-hosted WASM assets from the Vite build output when available, - // fallback to CDN for dev/test. Vite injectManifest exclusion prevents SW caching. - const bundle = await selectBundle(JSDELIVR_BUNDLES); + const bundle = await selectBundle(SELF_HOSTED_BUNDLES); const logger = new ConsoleLogger(); // QNBS-v3: bundle.mainWorker can be null for non-browser bundles; guard before constructing Worker. if (!bundle.mainWorker) throw new Error('DuckDB bundle has no worker URL'); diff --git a/workers/v2/duckdb.worker.ts b/workers/v2/duckdb.worker.ts index 4b37828c..228e384e 100644 --- a/workers/v2/duckdb.worker.ts +++ b/workers/v2/duckdb.worker.ts @@ -29,23 +29,23 @@ async function isOPFSSupported(): Promise { } } +// QNBS-v3 (F-09): self-hosted, same-origin DuckDB-WASM assets — see workers/duckdbWorker.ts for the full rationale (unpinned CDN URL, supply-chain trust, already CSP-dead code). +const DUCKDB_ASSET_BASE = `${import.meta.env.BASE_URL}duckdb/`; +const SELF_HOSTED_BUNDLES = { + mvp: { + mainModule: `${DUCKDB_ASSET_BASE}duckdb-mvp.wasm`, + mainWorker: `${DUCKDB_ASSET_BASE}duckdb-browser-mvp.worker.js`, + }, + eh: { + mainModule: `${DUCKDB_ASSET_BASE}duckdb-eh.wasm`, + mainWorker: `${DUCKDB_ASSET_BASE}duckdb-browser-eh.worker.js`, + }, +}; + async function initDuckDb(): Promise { const { AsyncDuckDB, selectBundle, ConsoleLogger } = await getDuckDb(); - const JSDELIVR_BUNDLES = { - mvp: { - mainModule: 'https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm/dist/duckdb-mvp.wasm', - mainWorker: - 'https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm/dist/duckdb-browser-mvp.worker.js', - }, - eh: { - mainModule: 'https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm/dist/duckdb-eh.wasm', - mainWorker: - 'https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm/dist/duckdb-browser-eh.worker.js', - }, - }; - - const bundle = await selectBundle(JSDELIVR_BUNDLES); + const bundle = await selectBundle(SELF_HOSTED_BUNDLES); const logger = new ConsoleLogger(); if (!bundle.mainWorker) throw new Error('DuckDB bundle has no worker URL'); const worker = new Worker(bundle.mainWorker);