diff --git a/README.md b/README.md
index b71890c0..82fa9c3c 100644
--- a/README.md
+++ b/README.md
@@ -12,8 +12,8 @@
-
-
+
+
@@ -351,7 +351,7 @@ One-click encrypted export of your entire project library from **Settings β Da
### π Full Multi-Language Support
-Shipped UI locales with **2844 i18n keys** across all 19 languages β zero hardcoded user-facing strings:
+Shipped UI locales with **2854 i18n keys** across all 19 languages β zero hardcoded user-facing strings:
- π©πͺ **German** (Deutsch)
- π¬π§ **English**
@@ -460,8 +460,8 @@ 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`) | 2844 keys Γ 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu Beta); EN fallback; `localStorage` persistence |
-| **Testing** | Vitest 4.x (5807+ tests / 525 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
+| **i18n** | Custom React Context (`I18nContext.tsx`) | 2854 keys Γ 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu Beta); EN fallback; `localStorage` persistence |
+| **Testing** | Vitest 4.x (5807+ tests / 527 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 |
| **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` |
@@ -498,7 +498,7 @@ WorldScript-Studio/
β βββ sw.js # PWA Service Worker
β βββ manifest.json # PWA Web App Manifest v3
βββ tests/
-β βββ unit/ # Vitest unit tests (5807+ tests, 525 files)
+β βββ unit/ # Vitest unit tests (5807+ tests, 527 files) β count spans tests/, components/, packages/*/tests/, not just this folder
β β βββ ai/ # aiSmallModules, aiCoreFallbackPaths
β β βββ settings/ # WebLlmPanel, AiSections
β βββ e2e/ # Playwright specs + helpers.ts
@@ -657,9 +657,9 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
| `scorecard` | weekly + `main` push | OpenSSF Scorecard β SARIF uploaded to GitHub Code Scanning |
**Current test metrics (2026-07-28):**
-- **5807+ unit tests** across **525 test files** β all passing
+- **5807+ unit tests** across **527 test files** β all passing
- Coverage thresholds: lines β₯ 74 Β· branches β₯ 60 Β· functions β₯ 67 Β· statements β₯ 72 β enforced in CI (see Codecov badge for live metrics)
-- i18n: **2844 keys Γ 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu Beta)
+- i18n: **2854 keys Γ 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu 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/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md
index 218abbaa..12e403e3 100644
--- a/docs/DEPLOYMENT.md
+++ b/docs/DEPLOYMENT.md
@@ -113,6 +113,21 @@ pnpm run build:edge && pnpm exec vite preview --base /
---
+## Header invariants per host
+
+HTTP response headers are **not** portable across targets β each host has its own config file, and one target can't set headers at all. Voice (`hooks/useMicLevel.ts`, `hooks/useSpeechRecognition.ts`) depends on `Permissions-Policy: microphone=(self)`; an empty `microphone=()` allowlist silently breaks it even for same-origin calls.
+
+| Host | Config file | Permissions-Policy | Content-Security-Policy |
+|------|-------------|---------------------|--------------------------|
+| **GitHub Pages** (canonical upstream) | *(none β platform has no header-injection mechanism)* | β not settable at all (no meta-tag equivalent exists) | β οΈ meta tag in [`index.html`](../index.html) only β this is the **sole** enforcement point on this host |
+| **Vercel** | [`vercel.json`](../vercel.json) `headers[]` | `microphone=(self)` | header set, mirrors the `index.html` meta CSP |
+| **Cloudflare Pages** | [`public/_headers`](../public/_headers) | `microphone=(self)` | header set, mirrors the `index.html` meta CSP |
+| **Docker / nginx** (`.github/workflows/docker.yml` image) | [`nginx.conf`](../nginx.conf) | `microphone=(self)` | header set, mirrors the `index.html` meta CSP |
+
+When both a header CSP and the `index.html` meta CSP are present, the browser enforces **both simultaneously** β a resource load must satisfy every active policy, so if the two diverge on an overlapping directive, the *more restrictive* result applies (not "the header wins and the meta tag is ignored"). The exception is `frame-ancestors` (and `sandbox`/`report-uri`): the CSP spec explicitly disallows these in a ``-delivered policy, so they only take effect via the header β that's why adding the header is a real hardening, not just a duplicate. If the two policies ever diverge on a directive both can express, keep them identical (see [ADR-0004](adr/0004-csp-connect-src-byok-tradeoff.md) and the regression tests in `tests/unit/csp.test.ts` / `tests/unit/deploymentHeaders.test.ts`) so the effective policy stays predictable rather than silently intersecting two different allowlists. **New header-origin rule:** any new external endpoint or directive change must be applied to all three header configs plus both test files, not just `index.html`.
+
+---
+
## Security notes
- No server-side storage of manuscripts or API keys.
diff --git a/docs/SECURITY-THREAT-MODEL.md b/docs/SECURITY-THREAT-MODEL.md
index 6e5c0eec..b56eb5b9 100644
--- a/docs/SECURITY-THREAT-MODEL.md
+++ b/docs/SECURITY-THREAT-MODEL.md
@@ -128,6 +128,7 @@ Goal: Intercept/decrypt collaboration traffic
| `sw.js` | I | Network-only for AI hosts | β
Complete |
| `tauri.conf.json` | I | Strict CSP β explicit `connect-src` allowlist, no `https:` blanket | β
Complete |
| `index.html` (web PWA) | I | CSP `connect-src 'self' https:` β broad HTTPS by design for BYOK; no `http:`/`ws:` wildcards | β οΈ Documented tradeoff ([ADR-0004](adr/0004-csp-connect-src-byok-tradeoff.md)) |
+| `vercel.json` / `public/_headers` / `nginx.conf` | I | `Content-Security-Policy` response header, mirrors the meta CSP (`frame-ancestors 'none'` only takes effect as a header) | β
Complete on Vercel/CF/Docker. **GitHub Pages cannot set response headers at all** β the `index.html` meta CSP is the sole enforcement there. |
### CSP connect-src: web-vs-Tauri asymmetry (ADR-0004)
@@ -138,10 +139,18 @@ CSP. The redundant explicit cloud-provider entries were removed (they changed no
and implied a hardening the policy did not provide). **Residual risk:** a `fetch` driven in the web
PWA (e.g. via AI prompt injection) can reach any HTTPS origin. Mitigations: no secrets in
`connect-src`-reachable globals; keys encrypted at rest and only attached to the user's chosen
-provider request; AI output never `eval`'d; host HTTP-header CSP tightens production further.
-`http:`/`ws:` scheme-wildcards remain disallowed (cleartext exfiltration blocked). The native **Tauri**
-CSP stays strict (no `https:`). Closing this fully = build-time CSP generation (Option C, v2.0).
-Regression test: `tests/unit/csp.test.ts`.
+provider request; AI output never `eval`'d. `http:`/`ws:` scheme-wildcards remain disallowed
+(cleartext exfiltration blocked). The native **Tauri** CSP stays strict (no `https:`). Closing this
+fully = build-time CSP generation (Option C, v2.0). Regression test: `tests/unit/csp.test.ts`.
+
+**Host header CSP (2026-07-28):** `vercel.json`, `public/_headers`, and `nginx.conf` now set a real
+`Content-Security-Policy` response header, identical to the meta CSP above β `connect-src` is
+unchanged (this tradeoff still applies there), but `frame-ancestors 'none'` only takes effect as a
+header, never as a meta tag, so that's a genuine additional control on Vercel/Cloudflare Pages/Docker.
+**GitHub Pages β the canonical upstream mirror β cannot set any HTTP response header**, so the meta
+CSP above remains its *only* enforcement point, and `Permissions-Policy` cannot be set there under any
+circumstance (no meta-tag equivalent exists for it). Regression test:
+`tests/unit/deploymentHeaders.test.ts`.
## Security Checklist
diff --git a/docs/adr/0004-csp-connect-src-byok-tradeoff.md b/docs/adr/0004-csp-connect-src-byok-tradeoff.md
index 8e761ade..d7cca463 100644
--- a/docs/adr/0004-csp-connect-src-byok-tradeoff.md
+++ b/docs/adr/0004-csp-connect-src-byok-tradeoff.md
@@ -2,6 +2,7 @@
- **Status:** Accepted
- **Date:** 2026-06-10
+- **Revised: 2026-07-28** β corrected a false Consequences claim (see below); no change to the Decision.
- **Deciders:** Maintainer + Claude Code
- **Context tags:** security, csp, networking, byok, tauri
@@ -56,13 +57,28 @@ needed there.
successful AI prompt injection into a code path that issues a request) can reach any HTTPS origin.
Mitigations: no secrets are placed in `connect-src`-reachable globals; API keys are encrypted at
rest and only attached to the user-configured provider request; AI output is never `eval`'d
- (`CLAUDE.md` Key Constraints); the host (Vercel/CF) tightens CSP further via HTTP response headers
- in production. Closing this fully requires build-time CSP generation from the provider registry +
- a validated custom-endpoint allowlist (Option C), deferred to v2.0.
+ (`CLAUDE.md` Key Constraints). Closing this fully requires build-time CSP generation from the
+ provider registry + a validated custom-endpoint allowlist (Option C), deferred to v2.0.
+- **Revision note (2026-07-28):** this section previously claimed "the host (Vercel/CF) tightens CSP
+ further via HTTP response headers in production." That was **false** at the time it was written β
+ none of `vercel.json`, `public/_headers`, or `nginx.conf` set a `Content-Security-Policy` header, so
+ the accepted `connect-src` residual risk above had *no* documented compensating control. This has
+ now been fixed: `vercel.json`, `public/_headers`, and `nginx.conf` all set a real `Content-Security-Policy`
+ header, identical to the `index.html` meta CSP (`connect-src` is unchanged β this ADR's tradeoff still
+ applies there β but `frame-ancestors 'none'` is only meaningful as a header, never as a meta tag, so
+ that specific directive is a genuine new hardening on the three hosts that can set it).
+ **GitHub Pages β the canonical upstream mirror β cannot set any HTTP response header at all** (no
+ `_headers`-equivalent, no platform config surface); the `index.html` meta CSP is the *only*
+ enforcement point there, and `Permissions-Policy` has no meta-tag equivalent at all, so it cannot be
+ set on GitHub Pages under any circumstance. Any future claim in this ADR about host-level hardening
+ must be checked against all four surfaces, not assumed.
- **Maintenance rule:** when adding a new **localhost** or **wss** endpoint, update **both**
`index.html` and `src-tauri/tauri.conf.json`, and extend `tests/unit/csp.test.ts`. New **cloud
HTTPS** providers need no `connect-src` change on web (covered by `https:`) but **do** need an
- explicit entry in the strict Tauri `connect-src`.
+ explicit entry in the strict Tauri `connect-src`. **New header-origin or directive change:** update
+ `vercel.json`, `public/_headers`, and `nginx.conf` together, plus `tests/unit/csp.test.ts` and
+ `tests/unit/deploymentHeaders.test.ts` β a divergence between the header CSP and the meta CSP makes
+ the meta tag misleading (see `docs/DEPLOYMENT.md` Β§ Header invariants per host).
## Rejected alternatives
diff --git a/index.css b/index.css
index 9efcc76b..4fab321c 100644
--- a/index.css
+++ b/index.css
@@ -30,12 +30,10 @@
"Noto Sans Arabic", "Noto Sans Hebrew", "Inter", system-ui, -apple-system, sans-serif;
--font-editor-rtl:
"Noto Naskh Arabic", "Noto Sans Hebrew", "Merriweather", Georgia, "Times New Roman", serif;
- /* QNBS-v3: Phase 3 β CJK/Greek font stacks for ja/zh/el Beta languages.
- CJK: Noto Sans JP covers Hiragana, Katakana, and Kanji (loaded via Google Fonts).
- Greek: Noto Sans GR covers monotonic and polytonic Greek (loaded via Google Fonts). */
- --font-ui-cjk: "Noto Sans JP", "Inter", system-ui, -apple-system, sans-serif;
- --font-ui-greek: "Noto Sans GR", "Inter", system-ui, -apple-system, sans-serif;
- /* QNBS-v3: Korean (Hangul) β Noto Sans KR (loaded via Google Fonts CDN); Inter lacks Hangul glyphs. */
+ /* QNBS-v3: self-hosted via @fontsource (index.tsx), replacing a broken Google Fonts request ("Noto Sans GR" doesn't exist) β SC added for zh's Simplified Han forms; Greek uses the base "Noto Sans" family's greek subset. */
+ --font-ui-cjk: "Noto Sans JP", "Noto Sans SC", "Inter", system-ui, -apple-system, sans-serif;
+ --font-ui-greek: "Noto Sans", "Inter", system-ui, -apple-system, sans-serif;
+ /* QNBS-v3: Korean (Hangul) β Noto Sans KR, self-hosted via @fontsource; Inter lacks Hangul glyphs. */
--font-ui-kr: "Noto Sans KR", "Inter", system-ui, -apple-system, sans-serif;
--sc-prose-measure: 65ch;
diff --git a/index.html b/index.html
index 9d28a54b..d7805e26 100644
--- a/index.html
+++ b/index.html
@@ -48,8 +48,9 @@
-
-
-
-
+
WorldScript Studio
diff --git a/index.tsx b/index.tsx
index 1e443585..a4e14015 100644
--- a/index.tsx
+++ b/index.tsx
@@ -32,9 +32,19 @@ import '@fontsource/noto-sans-arabic/700.css';
import '@fontsource/noto-sans-hebrew/400.css';
import '@fontsource/noto-sans-hebrew/500.css';
import '@fontsource/noto-sans-hebrew/700.css';
-/* QNBS-v3: Phase 3 β CJK fonts for ja/zh Beta languages.
- Fonts loaded via Google Fonts CDN in index.html for ja/zh.
- Greek uses system fallback (most systems have Noto Sans Greek pre-installed). */
+/* QNBS-v3: CJK (ja/zh) + Greek (el) self-hosted, replacing a broken Google Fonts CDN request (`Noto+Sans+GR` doesn't exist there, so the combined request 400'd and silently dropped JP/KR too). */
+import '@fontsource/noto-sans-jp/400.css';
+import '@fontsource/noto-sans-jp/500.css';
+import '@fontsource/noto-sans-jp/700.css';
+import '@fontsource/noto-sans-kr/400.css';
+import '@fontsource/noto-sans-kr/500.css';
+import '@fontsource/noto-sans-kr/700.css';
+import '@fontsource/noto-sans-sc/400.css';
+import '@fontsource/noto-sans-sc/500.css';
+import '@fontsource/noto-sans-sc/700.css';
+import '@fontsource/noto-sans/greek-400.css';
+import '@fontsource/noto-sans/greek-500.css';
+import '@fontsource/noto-sans/greek-700.css';
import './index.css';
import './register-sw';
diff --git a/nginx.conf b/nginx.conf
index ead669a7..218659c2 100644
--- a/nginx.conf
+++ b/nginx.conf
@@ -1,3 +1,7 @@
+# QNBS-v3: nginx only inherits a parent level's add_header directives when the current level has
+# NONE of its own β a location block that sets even one add_header (e.g. Cache-Control below) drops
+# every server-level add_header for requests matching it. Both asset-caching location blocks below
+# therefore duplicate the security header lines explicitly rather than relying on inheritance.
server {
listen 80;
server_name _;
@@ -15,19 +19,37 @@ server {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
+ # QNBS-v3: duplicated β see the file-level note above on nginx's add_header inheritance rule.
+ add_header X-Frame-Options "SAMEORIGIN" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+ add_header Permissions-Policy "camera=(), microphone=(self), geolocation=()" always;
+ add_header Content-Security-Policy "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" always;
}
# Service worker must never be cached
location = /sw.js {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate";
+ # QNBS-v3: duplicated β see the file-level note above on nginx's add_header inheritance rule.
+ add_header X-Frame-Options "SAMEORIGIN" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+ add_header Permissions-Policy "camera=(), microphone=(self), geolocation=()" always;
+ add_header Content-Security-Policy "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" always;
}
- # Security headers
+ # Security headers (server level β applies to `location /` and any other block without its own add_header)
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
- add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
+ # QNBS-v3: microphone=(self), NOT (): Voice (hooks/useMicLevel.ts, hooks/useSpeechRecognition.ts)
+ # calls getUserMedia/SpeechRecognition from same-origin. An empty allowlist blocks that same-origin
+ # call too, so it silently kills Whisper STT, push-to-talk, and the mic-level meter on this host.
+ add_header Permissions-Policy "camera=(), microphone=(self), geolocation=()" always;
+ # QNBS-v3: mirrors the index.html meta CSP (ADR-0004) β keep both in sync, see docs/DEPLOYMENT.md
+ # "Header invariants per host" and tests/unit/csp.test.ts.
+ add_header Content-Security-Policy "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" always;
# Gzip compression
gzip on;
diff --git a/package.json b/package.json
index 8ff06f88..84f3f97c 100644
--- a/package.json
+++ b/package.json
@@ -111,8 +111,12 @@
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/merriweather": "^5.2.11",
"@fontsource/noto-naskh-arabic": "^5.2.11",
+ "@fontsource/noto-sans": "^5.3.0",
"@fontsource/noto-sans-arabic": "^5.2.10",
"@fontsource/noto-sans-hebrew": "^5.2.8",
+ "@fontsource/noto-sans-jp": "^5.3.0",
+ "@fontsource/noto-sans-kr": "^5.3.0",
+ "@fontsource/noto-sans-sc": "^5.3.0",
"@google/genai": "^2.8.0",
"@reduxjs/toolkit": "^2.12.0",
"@tanstack/react-virtual": "^3.14.3",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 749aa90d..97741535 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -85,12 +85,24 @@ importers:
'@fontsource/noto-naskh-arabic':
specifier: ^5.2.11
version: 5.2.11
+ '@fontsource/noto-sans':
+ specifier: ^5.3.0
+ version: 5.3.0
'@fontsource/noto-sans-arabic':
specifier: ^5.2.10
version: 5.2.10
'@fontsource/noto-sans-hebrew':
specifier: ^5.2.8
version: 5.2.8
+ '@fontsource/noto-sans-jp':
+ specifier: ^5.3.0
+ version: 5.3.0
+ '@fontsource/noto-sans-kr':
+ specifier: ^5.3.0
+ version: 5.3.0
+ '@fontsource/noto-sans-sc':
+ specifier: ^5.3.0
+ version: 5.3.0
'@google/genai':
specifier: ^2.8.0
version: 2.8.0
@@ -1507,6 +1519,18 @@ packages:
'@fontsource/noto-sans-hebrew@5.2.8':
resolution: {integrity: sha512-FN/GDpE709JQN5f7vmqlbIwz2/vAM6ZnXqRsw47Piq731hvti55V/FLbyU2RWUvlis16ezPf0r+zKTjfdeXF/g==}
+ '@fontsource/noto-sans-jp@5.3.0':
+ resolution: {integrity: sha512-OZbBzZ8LrFRs2RFT2Cc0HUV2C2ZeSfyEXhDJWR0EZuZaraJfRubpvrFmUnCljuBDEkw8Vn6CE/+nf9dj9b+0lg==}
+
+ '@fontsource/noto-sans-kr@5.3.0':
+ resolution: {integrity: sha512-/JnpTjaCOXW7xUoqOyCVYSr05VOkDy5Yla9O+WAEiS7u+yYLsmHoqj6v1W0bf91G8G3XA2+NnRS3CX2Tf/FVZg==}
+
+ '@fontsource/noto-sans-sc@5.3.0':
+ resolution: {integrity: sha512-HeqIlGm0+ohOKxZLuHj1qW6r6avHH0OWdKERAcSDI0RQ+MXrteuLKA+M+5eOA8rYy0MFvOR5AT0fQo2rUkye0Q==}
+
+ '@fontsource/noto-sans@5.3.0':
+ resolution: {integrity: sha512-fBCog2PY7DiVVTEEqtI/Qdinx/knobHYfoGpjFXdfBX5RoJaBp1Prw2G75p0OIsdlMZg3cLo0c+YbIUYOxnQJw==}
+
'@formatjs/ecma402-abstract@2.3.6':
resolution: {integrity: sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==}
@@ -8985,6 +9009,14 @@ snapshots:
'@fontsource/noto-sans-hebrew@5.2.8': {}
+ '@fontsource/noto-sans-jp@5.3.0': {}
+
+ '@fontsource/noto-sans-kr@5.3.0': {}
+
+ '@fontsource/noto-sans-sc@5.3.0': {}
+
+ '@fontsource/noto-sans@5.3.0': {}
+
'@formatjs/ecma402-abstract@2.3.6':
dependencies:
'@formatjs/fast-memoize': 2.2.7
diff --git a/public/_headers b/public/_headers
index c2c27fcc..a1f676a2 100644
--- a/public/_headers
+++ b/public/_headers
@@ -3,7 +3,13 @@
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
- Permissions-Policy: camera=(), microphone=(), geolocation=()
+ # QNBS-v3: microphone=(self), NOT (): Voice (hooks/useMicLevel.ts, hooks/useSpeechRecognition.ts)
+ # calls getUserMedia/SpeechRecognition from same-origin. An empty allowlist blocks that same-origin
+ # call too, so it silently kills Whisper STT, push-to-talk, and the mic-level meter on this host.
+ Permissions-Policy: camera=(), microphone=(self), geolocation=()
+ # QNBS-v3: mirrors the index.html meta CSP (ADR-0004) β keep both in sync, see docs/DEPLOYMENT.md
+ # "Header invariants per host" and tests/unit/csp.test.ts.
+ Content-Security-Policy: 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
/assets/*
Cache-Control: public, max-age=31536000, immutable
diff --git a/tests/unit/csp.test.ts b/tests/unit/csp.test.ts
index 947efe18..b826d0f8 100644
--- a/tests/unit/csp.test.ts
+++ b/tests/unit/csp.test.ts
@@ -2,6 +2,18 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
+import {
+ extractHeadersFileValue,
+ extractNginxHeaderValue,
+ extractVercelHeaderValue,
+ group1,
+} from '../utils/deploymentConfigParsers';
+
+// QNBS-v3: Regression guard for the ADR-0004 revision (2026-07-28) β the ADR previously claimed
+// "the host tightens CSP further via HTTP response headers in production", which was false: none
+// of vercel.json/_headers/nginx.conf set a Content-Security-Policy header. This block asserts the
+// header CSP now actually exists on all three, is identical across them, and is never looser than
+// the meta CSP (a divergence would make the meta tag misleading β see docs/DEPLOYMENT.md).
// QNBS-v3: Regression guard for ADR-0004 (audit finding F-2). The web PWA connect-src keeps a
// `https:` scheme-source ON PURPOSE β it is required by the shipped BYOK `openAiCompatibleBaseUrl`
@@ -15,12 +27,6 @@ const tauriConf = readFileSync(
'utf8',
);
-/** Extract capture group 1 with a narrowing guard (noUncheckedIndexedAccess-safe). */
-function group1(m: RegExpMatchArray | null, msg: string): string {
- if (!m || m[1] === undefined) throw new Error(msg);
- return m[1];
-}
-
/** Pull the `connect-src β¦;` directive value out of a CSP string, normalized to whitespace tokens. */
function connectSrcTokens(csp: string): string[] {
return group1(csp.match(/connect-src([^;]*);/), 'connect-src directive must exist')
@@ -93,3 +99,66 @@ describe('CSP connect-src β ADR-0004 BYOK tradeoff', () => {
});
});
});
+
+describe('CSP response headers β ADR-0004 revision (host header actually exists now)', () => {
+ const vercelJson = readFileSync(
+ fileURLToPath(new URL('../../vercel.json', import.meta.url)),
+ 'utf8',
+ );
+ const headersFile = readFileSync(
+ fileURLToPath(new URL('../../public/_headers', import.meta.url)),
+ 'utf8',
+ );
+ const nginxConf = readFileSync(
+ fileURLToPath(new URL('../../nginx.conf', import.meta.url)),
+ 'utf8',
+ );
+
+ function vercelCsp(): string {
+ return extractVercelHeaderValue(vercelJson, 'Content-Security-Policy');
+ }
+ function headersCsp(): string {
+ return extractHeadersFileValue(headersFile, 'Content-Security-Policy');
+ }
+ function nginxHeaderCsp(): string {
+ return extractNginxHeaderValue(nginxConf, 'Content-Security-Policy');
+ }
+
+ /** Split a CSP string into a directive -> token-set map for per-directive comparison. */
+ function directiveMap(csp: string): Map> {
+ const map = new Map>();
+ for (const part of csp.split(';')) {
+ const tokens = part.trim().split(/\s+/).filter(Boolean);
+ const directive = tokens[0];
+ if (!directive) continue;
+ map.set(directive, new Set(tokens.slice(1)));
+ }
+ return map;
+ }
+
+ it('all three hosts that can set headers set an identical Content-Security-Policy', () => {
+ expect(vercelCsp()).toBe(headersCsp());
+ expect(headersCsp()).toBe(nginxHeaderCsp());
+ });
+
+ it('the header CSP is never looser than the meta CSP for any shared directive', () => {
+ const metaDirectives = directiveMap(webCsp());
+ const headerDirectives = directiveMap(vercelCsp());
+ for (const [directive, headerTokens] of headerDirectives) {
+ const metaTokens = metaDirectives.get(directive);
+ if (!metaTokens) continue; // a header-only directive (e.g. frame-ancestors) adds hardening
+ for (const token of headerTokens) {
+ expect(
+ metaTokens.has(token),
+ `header ${directive} allows "${token}" that the meta CSP does not`,
+ ).toBe(true);
+ }
+ }
+ });
+
+ it('gains frame-ancestors as real enforcement (inert in the meta tag, effective as a header)', () => {
+ const headerDirectives = directiveMap(vercelCsp());
+ expect(headerDirectives.get('frame-ancestors')).toBeDefined();
+ expect(headerDirectives.get('frame-ancestors')?.has("'none'")).toBe(true);
+ });
+});
diff --git a/tests/unit/deploymentHeaders.test.ts b/tests/unit/deploymentHeaders.test.ts
new file mode 100644
index 00000000..19fe2e47
--- /dev/null
+++ b/tests/unit/deploymentHeaders.test.ts
@@ -0,0 +1,60 @@
+// @vitest-environment node
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, it } from 'vitest';
+import {
+ extractHeadersFileValue,
+ extractNginxHeaderValue,
+ extractVercelHeaderValue,
+ parsePermissionsPolicy,
+} from '../utils/deploymentConfigParsers';
+
+// QNBS-v3: Regression guard for the Permissions-Policy microphone block. `microphone=()` is the
+// EMPTY allowlist β it disallows the microphone for every origin, including same-origin. Voice
+// (hooks/useMicLevel.ts, hooks/useSpeechRecognition.ts) calls getUserMedia/SpeechRecognition from
+// same-origin code, so that header silently killed Whisper STT, push-to-talk, and the mic-level
+// meter on Vercel, Cloudflare Pages, and the Docker/nginx image. These assertions lock the fix.
+
+const vercelJson = readFileSync(
+ fileURLToPath(new URL('../../vercel.json', import.meta.url)),
+ 'utf8',
+);
+const headersFile = readFileSync(
+ fileURLToPath(new URL('../../public/_headers', import.meta.url)),
+ 'utf8',
+);
+const nginxConf = readFileSync(fileURLToPath(new URL('../../nginx.conf', import.meta.url)), 'utf8');
+
+function vercelPolicyValue(): string {
+ return extractVercelHeaderValue(vercelJson, 'Permissions-Policy');
+}
+function headersPolicyValue(): string {
+ return extractHeadersFileValue(headersFile, 'Permissions-Policy');
+}
+function nginxPolicyValue(): string {
+ return extractNginxHeaderValue(nginxConf, 'Permissions-Policy');
+}
+
+describe('Permissions-Policy β microphone must stay usable same-origin', () => {
+ it('allows the microphone for self on all three hosts (exact directive value)', () => {
+ // QNBS-v3: assert the exact parsed value, not toContain('microphone=(self)') β that substring
+ // check would also pass for a broader allowlist like `microphone=(self https://evil.example)`
+ // or a duplicated directive where a later, looser value is the one actually in effect.
+ for (const value of [vercelPolicyValue(), headersPolicyValue(), nginxPolicyValue()]) {
+ expect(parsePermissionsPolicy(value).get('microphone')).toBe('(self)');
+ }
+ });
+
+ it('keeps camera and geolocation restrictive with the exact empty-allowlist value', () => {
+ for (const value of [vercelPolicyValue(), headersPolicyValue(), nginxPolicyValue()]) {
+ const directives = parsePermissionsPolicy(value);
+ expect(directives.get('camera')).toBe('()');
+ expect(directives.get('geolocation')).toBe('()');
+ }
+ });
+
+ it('serves the identical Permissions-Policy on Vercel, Cloudflare Pages, and the Docker/nginx image', () => {
+ expect(vercelPolicyValue()).toBe(headersPolicyValue());
+ expect(headersPolicyValue()).toBe(nginxPolicyValue());
+ });
+});
diff --git a/tests/unit/fontPipeline.test.ts b/tests/unit/fontPipeline.test.ts
new file mode 100644
index 00000000..c1eed668
--- /dev/null
+++ b/tests/unit/fontPipeline.test.ts
@@ -0,0 +1,148 @@
+// @vitest-environment node
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, it } from 'vitest';
+
+// QNBS-v3: Regression guard for the Google Fonts pipeline. "Noto Sans GR" does not exist at
+// Google Fonts, so the combined CSS2 request 400'd β silently dropping the JP/KR families
+// requested alongside it. Fixed by self-hosting CJK/Greek via @fontsource (index.tsx), matching
+// the pattern already used for Arabic/Hebrew. These assertions lock the fix in place and prevent
+// a future `--font-ui-*` addition from silently reintroducing an unhosted family.
+
+const indexHtml = readFileSync(fileURLToPath(new URL('../../index.html', import.meta.url)), 'utf8');
+const indexTsx = readFileSync(fileURLToPath(new URL('../../index.tsx', import.meta.url)), 'utf8');
+const indexCss = readFileSync(fileURLToPath(new URL('../../index.css', import.meta.url)), 'utf8');
+
+/**
+ * Repeatedly apply a single-pattern replace until a fixed point, so a marker nested inside
+ * another (e.g. a `/*` inside an HTML comment, exposed once the HTML comment around it is
+ * stripped) can't survive as a residual match β the canonical fix for CodeQL's
+ * js/incomplete-multi-character-sanitization, applied to exactly one pattern per call so its
+ * dataflow analysis can verify each loop independently.
+ */
+function stripToFixedPoint(src: string, pattern: RegExp): string {
+ let result = src;
+ let previous: string;
+ do {
+ previous = result;
+ result = result.replace(pattern, '');
+ } while (result !== previous);
+ return result;
+}
+
+/**
+ * Strip HTML (``) and block (`/* *β/`) comments before a "must not contain" check. The
+ * QNBS-v3 convention requires explaining *why* a value was removed, which means the historical
+ * broken value (e.g. "Noto Sans GR", "fonts.gstatic.com") legitimately appears in prose β this
+ * must not trip a regression test meant to catch an actual re-introduced *live* reference.
+ */
+function stripComments(src: string): string {
+ return stripToFixedPoint(stripToFixedPoint(src, //g), /\/\*[\s\S]*?\*\//g);
+}
+
+/** Extract capture group 1 with a narrowing guard (noUncheckedIndexedAccess-safe). */
+function group1(m: RegExpMatchArray | null, msg: string): string {
+ if (!m || m[1] === undefined) throw new Error(msg);
+ return m[1];
+}
+
+function connectSrcLikeTokens(directive: string, csp: string): string[] {
+ return group1(csp.match(new RegExp(`${directive}([^;]*);`)), `${directive} directive must exist`)
+ .split(/\s+/)
+ .map((t) => t.trim())
+ .filter(Boolean);
+}
+
+function webCsp(): string {
+ return group1(
+ indexHtml.match(/Content-Security-Policy"\s*\n?\s*content="([\s\S]*?)"/),
+ 'web CSP meta must exist in index.html',
+ );
+}
+
+// QNBS-v3: families that intentionally have no @fontsource import β e.g. a documented
+// system-fallback decision. Empty today; keep this as the single place to record an exception
+// rather than silently skipping the loop below.
+const SYSTEM_FALLBACK_ALLOWLIST: readonly string[] = [];
+
+// QNBS-v3: families whose @fontsource package ships multiple subsets (e.g. base "Noto Sans"
+// bundles latin/cyrillic/greek/vietnamese as separate files) where the generic `'${pkg}/` prefix
+// check would pass on ANY subset import, including a Latin-only one that drops the actual script
+// coverage this family exists for. Require the specific subset prefix instead.
+const REQUIRED_IMPORT_PREFIXES: Readonly> = {
+ 'Noto Sans': `'@fontsource/noto-sans/greek-`,
+};
+
+/** Pull every `--font-ui-*: "Family", ...;` declaration's families out of index.css. */
+function fontUiFamilyDeclarations(): { token: string; families: string[] }[] {
+ const declarations: { token: string; families: string[] }[] = [];
+ const re = /--(font-ui[\w-]*):\s*([^;]+);/g;
+ for (const m of indexCss.matchAll(re)) {
+ const token = m[1];
+ const value = m[2];
+ if (!token || !value) continue;
+ const families = [...value.matchAll(/"([^"]+)"/g)]
+ .map((f) => f[1])
+ .filter((f): f is string => !!f);
+ declarations.push({ token, families });
+ }
+ return declarations;
+}
+
+describe('Font pipeline β no external font CDN, self-hosted CJK/Greek', () => {
+ it('index.html has no live reference to fonts.googleapis.com or fonts.gstatic.com', () => {
+ const html = stripComments(indexHtml);
+ expect(html).not.toMatch(/fonts\.googleapis\.com/);
+ expect(html).not.toMatch(/fonts\.gstatic\.com/);
+ });
+
+ it('index.css has no live reference to a Google Fonts host', () => {
+ const css = stripComments(indexCss);
+ expect(css).not.toMatch(/fonts\.googleapis\.com/);
+ expect(css).not.toMatch(/fonts\.gstatic\.com/);
+ });
+
+ it('meta CSP style-src has no foreign font-CDN origin', () => {
+ const tokens = connectSrcLikeTokens('style-src', webCsp());
+ expect(tokens).not.toContain('https://fonts.googleapis.com');
+ });
+
+ it('meta CSP font-src has no foreign font-CDN origin', () => {
+ const tokens = connectSrcLikeTokens('font-src', webCsp());
+ expect(tokens).not.toContain('https://fonts.gstatic.com');
+ });
+
+ it('the nonexistent "Noto Sans GR" family has no live reference in any font source file', () => {
+ for (const file of [indexHtml, indexTsx, indexCss].map(stripComments)) {
+ expect(file).not.toMatch(/Noto\s*\+?\s*Sans\s*\+?\s*GR/);
+ }
+ });
+
+ it('every --font-ui-* family in index.css is self-hosted via @fontsource in index.tsx, or is an explicit system-fallback exception', () => {
+ const declarations = fontUiFamilyDeclarations();
+ expect(declarations.length).toBeGreaterThan(0);
+
+ for (const { families } of declarations) {
+ for (const family of families) {
+ // System-default families never need a font import.
+ if (/^(system-ui|-apple-system|sans-serif|serif|monospace|ui-monospace)$/i.test(family)) {
+ continue;
+ }
+ // A pure-Latin fallback (Inter/Merriweather/JetBrains Mono) is covered by the base
+ // @fontsource imports already asserted elsewhere; only non-Latin families are the point
+ // of this test.
+ if (/^(Inter|Merriweather|JetBrains Mono)$/.test(family)) continue;
+
+ if (SYSTEM_FALLBACK_ALLOWLIST.includes(family)) continue;
+
+ // e.g. "Noto Sans JP" -> @fontsource/noto-sans-jp; "Noto Sans" -> @fontsource/noto-sans
+ const pkg = `@fontsource/${family.toLowerCase().replace(/\s+/g, '-')}`;
+ const requiredPrefix = REQUIRED_IMPORT_PREFIXES[family] ?? `'${pkg}/`;
+ expect(
+ indexTsx.includes(requiredPrefix),
+ `expected an import matching "${requiredPrefix}" for "${family}" in index.tsx, or an entry in SYSTEM_FALLBACK_ALLOWLIST`,
+ ).toBe(true);
+ }
+ }
+ });
+});
diff --git a/tests/utils/deploymentConfigParsers.ts b/tests/utils/deploymentConfigParsers.ts
new file mode 100644
index 00000000..fe1f6568
--- /dev/null
+++ b/tests/utils/deploymentConfigParsers.ts
@@ -0,0 +1,68 @@
+// QNBS-v3: Share one parser contract across the deployment-header regression tests instead of
+// each test file duplicating its own group1/regex-extraction helpers.
+/**
+ * Shared parsing helpers for the deployment-surface regression tests (csp.test.ts,
+ * deploymentHeaders.test.ts). Both suites read the same three host configs β vercel.json,
+ * public/_headers, nginx.conf β and previously duplicated their own group1/regex-extraction
+ * helpers; this is the single source of truth for "pull one header's value out of a host config".
+ */
+
+/** Extract capture group 1 with a narrowing guard (noUncheckedIndexedAccess-safe). */
+export function group1(m: RegExpMatchArray | null, msg: string): string {
+ if (!m || m[1] === undefined) throw new Error(msg);
+ return m[1];
+}
+
+// QNBS-v3: headerName is only ever passed fixed string literals by the test files (never
+// untrusted input), but escaping it before building a RegExp costs nothing and satisfies static
+// analysis that otherwise can't verify that at every call site.
+function escapeRegExp(literal: string): string {
+ return literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+/** Read a `Key: value` header from a Cloudflare-Pages-style `_headers` file (one directive per line). */
+export function extractHeadersFileValue(source: string, headerName: string): string {
+ const safeName = escapeRegExp(headerName);
+ return group1(
+ source.match(new RegExp(`${safeName}:\\s*([^\\n]*)`)),
+ `${headerName} must exist in the _headers file`,
+ ).trim();
+}
+
+/** Read a `add_header Key "value" ...;` directive from an nginx.conf-style file. */
+export function extractNginxHeaderValue(source: string, headerName: string): string {
+ const safeName = escapeRegExp(headerName);
+ return group1(
+ source.match(new RegExp(`add_header ${safeName} "([^"]*)"`)),
+ `${headerName} must exist in nginx.conf`,
+ ).trim();
+}
+
+/** Read a header value from a vercel.json-style `headers[]` array, by key (any source block). */
+export function extractVercelHeaderValue(vercelJsonSource: string, headerKey: string): string {
+ const conf = JSON.parse(vercelJsonSource) as {
+ headers?: { source: string; headers: { key: string; value: string }[] }[];
+ };
+ for (const block of conf.headers ?? []) {
+ const found = block.headers.find((h) => h.key === headerKey);
+ if (found) return found.value;
+ }
+ throw new Error(`${headerKey} must exist in vercel.json's headers[]`);
+}
+
+/**
+ * Parse a Permissions-Policy header value into a directive -> raw-allowlist-string map (last
+ * occurrence wins, matching how a repeated directive would resolve). Lets callers assert the
+ * exact value of a directive instead of `toContain`, which would also pass for a broader or
+ * duplicated allowlist that merely contains the expected substring.
+ */
+export function parsePermissionsPolicy(value: string): Map {
+ const map = new Map();
+ for (const part of value.split(',')) {
+ const trimmed = part.trim();
+ const eq = trimmed.indexOf('=');
+ if (eq === -1) continue;
+ map.set(trimmed.slice(0, eq).trim(), trimmed.slice(eq + 1).trim());
+ }
+ return map;
+}
diff --git a/vercel.json b/vercel.json
index 2243ae81..acc54aa0 100644
--- a/vercel.json
+++ b/vercel.json
@@ -22,7 +22,12 @@
"headers": [
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "X-Frame-Options", "value": "DENY" },
- { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }
+ { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
+ { "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"
+ }
]
}
]