diff --git a/.npmrc b/.npmrc index af699df4f..b7fe3ebea 100644 --- a/.npmrc +++ b/.npmrc @@ -1,28 +1 @@ -# Peer dependencies -# strict mode: install fails on unmet or conflicting peers (instead of just warning) -# QNBS-v3: TS7 migration - disabled strict-peer-dependencies for tsgo preview compatibility -strict-peer-dependencies=false -# autoInstallPeers stays true to match lockfile settings.autoInstallPeers: true; -# flipping it requires a full lockfile regen + React-19 peer-dep audit (deferred to pnpm v11 migration) -auto-install-peers=true - -# Build scripts -# allowBuilds in pnpm-workspace.yaml is the v10 guard — only core-js, esbuild, protobufjs -# may run lifecycle scripts; ignore-scripts stays false so that list is enforced, not bypassed -ignore-scripts=false - -# Runtime safety -engine-strict=true # fail install if Node version doesn't satisfy engines.node (>=22) -prefer-frozen-lockfile=true # pnpm install refuses to mutate the lockfile (CI-safe) -verify-store-integrity=true # checksum-verify packages from the content-addressable store - -# Performance / correctness (unchanged) -side-effects-cache=true - -# Supply-chain hardening (QNBS-v3: P1) -# strict-dep-builds: verify that all built dependencies match lockfile checksums -strict-dep-builds=true -# block-exotic-subdeps: reject packages with exotic subdependency patterns (security risk) -block-exotic-subdeps=true -# minimum-release-age: quarantine packages newer than 7 days (10080 minutes) -minimum-release-age=10080 +# QNBS-v3: The pnpm v11 workspace policy is defined exclusively in pnpm-workspace.yaml. diff --git a/App.tsx b/App.tsx index c29236617..25e889ada 100644 --- a/App.tsx +++ b/App.tsx @@ -67,7 +67,6 @@ import { pluginRegistry } from './services/pluginRegistry'; import { repairProjectI18nFields } from './services/projectI18nRepair'; import { hasCompletedSpotlightTour, startSpotlightTour } from './services/spotlightTour'; import { - clearIdbPassphrase, hasPassphraseSentinel, isIdbEncryptionReady, } from './services/storage/storageEncryptionService'; @@ -196,15 +195,6 @@ const App: FC = ({ isNewUser }) => { const isIdbUnlockOpen = useTransientUiStore((s) => s.isIdbUnlockOpen); const setIdbUnlockOpen = useTransientUiStore((s) => s.setIdbUnlockOpen); - // QNBS-v3: escape hatch — clears sentinel + disables flag so the app is accessible again - const handleForgotPassphrase = useCallback(async () => { - await clearIdbPassphrase(); - dispatch(featureFlagsActions.setEnableIdbAtRestEncryption(false)); - setIdbUnlockOpen(false); - // QNBS-v3: WCAG 4.1.3 — assertive announcement so screen reader users know the security state changed - announce(t('settings.privacy.encryptionDisabledStatus'), 'assertive'); - }, [dispatch, setIdbUnlockOpen, announce, t]); - // Collaboration Panel State const [isCollabPanelOpen, setIsCollabPanelOpen] = useState(false); @@ -256,6 +246,14 @@ const App: FC = ({ isNewUser }) => { } }, [settings.appearancePreset]); + useEffect(() => { + // QNBS-v3: Decorative fixed layers are opt-out so long-form writers can keep a neutral canvas. + document.body.classList.toggle( + 'writing-surface-plain', + settings.writingSurfaceStyle === 'plain', + ); + }, [settings.writingSurfaceStyle]); + useEffect(() => { document.body.classList.toggle( 'accessibility-high-contrast', @@ -809,10 +807,7 @@ const App: FC = ({ isNewUser }) => { )} {isIdbUnlockOpen && ( setIdbUnlockOpen(false)}> - setIdbUnlockOpen(false)} - onForgotPassphrase={() => void handleForgotPassphrase()} - /> + setIdbUnlockOpen(false)} /> )} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2f585e46f..0046be278 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -84,8 +84,11 @@ pnpm run graphs:update # update both Graphify + CodeGraph git clone https://github.com/qnbs/WorldScript-Studio.git cd WorldScript-Studio pnpm install +pnpm run hooks:install # configures the pre-commit lint-staged hook ``` +`hooks:install` is a separate, explicit step rather than an automatic `prepare` script: pnpm v11's `allowBuilds` policy denies `simple-git-hooks`' own install-time script by default (supply-chain hardening — see `pnpm-workspace.yaml`), so a `prepare` script that shelled out to it would silently do nothing anyway. Skipping this step means commits bypass the `lint-staged` pre-commit check. + ### Environment The app uses **no build-time secrets**. The Gemini API key is entered via the Settings UI and stored encrypted in IndexedDB. diff --git a/README.md b/README.md index 0b73422bc..1108caf59 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ v1.26.0 IndexedDB v8 PWA v3.0 - i18n 19 locales — 2869 keys + i18n 19 locales — 2877 keys 6477+ tests / 532 files Codecov Coverage License MIT @@ -307,12 +307,13 @@ Real-time P2P co-editing via **Yjs + collab-transport** (vendor fork of y-webrtc ### 🔒 IDB At-Rest Encryption _(B-1, v1.19.0)_ -All project data, snapshots, and settings stored in IndexedDB can be encrypted at rest via `services/storage/storageEncryptionService.ts`: +The current primary project, settings, snapshot, image, Codex, RAG, and binder-asset IndexedDB paths can be encrypted at rest via `services/storage/storageEncryptionService.ts`. This is not yet a claim that every IndexedDB surface is covered: - **AES-256-GCM** with a PBKDF2-derived key (600 000 iterations, SHA-256, 32-byte random salt). -- Gated behind `featureFlags.enableIdbAtRestEncryption` (on by default since v1.23; the passphrase unlock UX is complete — Settings → Privacy). -- Same passphrase-entry unlock screen (`IdbUnlockModal`) on cold start, session-scoped in-memory key, on **both** the web build and the Tauri desktop build — Tauri's WebView uses the same IndexedDB-backed storage path, not an OS keychain. (No `tauri-plugin-stronghold` or equivalent OS-keychain integration ships today — see the API-key encryption note below for the desktop-specific mechanism that does exist.) -- GDPR-compliant: encrypted blobs are unreadable without the passphrase, even from the browser profile directory. +- Gated behind `featureFlags.enableIdbAtRestEncryption`. When a library is configured but locked, protected reads and writes fail closed rather than falling back to plaintext. +- Disable and passphrase rotation are temporarily unavailable until a journaled, cross-store migration protocol can prove recovery after interruption. +- **Web/PWA build only.** The unlock screen (`IdbUnlockModal`) and session-scoped in-memory key protect the IndexedDB-backed storage path used by the browser/PWA build. On the **Tauri desktop build**, primary project, settings, snapshot, image, Codex, RAG, and binder-asset data are written by the filesystem-backed store (`services/fs/*`), which is plaintext (LZ-string compressed, not encrypted) regardless of this setting — enabling it on desktop still shows the same unlock screen (the passphrase sentinel lives in the WebView's IndexedDB) but does not encrypt the actual manuscript files on disk. No `tauri-plugin-stronghold` or equivalent OS-keychain integration ships today — see the API-key encryption note below for the desktop-specific mechanism that does exist. +- At-rest protection reduces disclosure from an extracted browser profile while the library is locked; it does not protect an unlocked renderer, a compromised device, or every persistence surface. ### 🔐 Encrypted Library Backup @@ -320,7 +321,7 @@ One-click encrypted export of your entire project library from **Settings → Da - Archives all projects as a **ZIP** containing `META.json` + `vault.bin`. - `vault.bin` is encrypted with **AES-256-GCM** — the decryption key is derived from your chosen passphrase using PBKDF2. -- No plaintext project data ever leaves your device unencrypted. +- The encrypted vault holds its project payload in `vault.bin`; users must still protect the downloaded archive and should not confuse it with ordinary plaintext JSON export. - Import on any device using the same passphrase to restore your full library. ### 🔑 Encryption — which mechanism protects what @@ -396,7 +397,7 @@ Infrastructure-level features that keep the app fast and extensible as projects ### 🌐 Full Multi-Language Support -Shipped UI locales with **2869 i18n keys** across all 19 languages — zero hardcoded user-facing strings: +Shipped UI locales with **2877 i18n keys** across all 19 languages — zero hardcoded user-facing strings: - 🇩🇪 **German** (Deutsch) - 🇬🇧 **English** @@ -505,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`) | 2869 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`) | 2877 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 | @@ -707,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: **2869 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu Beta) +- i18n: **2877 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/TODO.md b/TODO.md index a31ac6dbd..c6eb6fda9 100644 --- a/TODO.md +++ b/TODO.md @@ -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 UX (2026-06-02 reconciliation) — `IdbUnlockModal` (startup unlock + 2-step forgot-passphrase escape hatch, `App.tsx:182-188,638-643`), `PassphraseModal` (set/change/disable), real read/write gating `idbProjectStore.ts:209-265`, session lock + key rotation (Phase 1). `enableIdbAtRestEncryption` flag in Settings › Privacy with ⚠ warning +- 🟡 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. - ✅ **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/GeneralSections.tsx b/components/settings/GeneralSections.tsx index d011520a6..accc08c88 100644 --- a/components/settings/GeneralSections.tsx +++ b/components/settings/GeneralSections.tsx @@ -206,6 +206,35 @@ export const AppearanceSection: FC = () => { + {/* QNBS-v3: Let writers remove decorative layers without changing the selected theme. */} +
+ + {t('settings.appearance.writingSurface')} + +

+ {t('settings.appearance.writingSurfaceHint')} +

+
+ + +
+
diff --git a/components/settings/IdbUnlockModal.tsx b/components/settings/IdbUnlockModal.tsx index 3ad9b0ee2..1397fce62 100644 --- a/components/settings/IdbUnlockModal.tsx +++ b/components/settings/IdbUnlockModal.tsx @@ -7,7 +7,6 @@ import { Modal } from '../ui/Modal'; interface Props { onUnlocked: () => void; - onForgotPassphrase?: () => void; } const ATTEMPT_STORAGE_KEY = 'worldscript-idb-unlock-attempts'; @@ -100,16 +99,13 @@ function lockoutMs(attempts: number): number { return Math.min(2 ** (attempts - 4), 60) * 1000; } -export const IdbUnlockModal: FC = ({ onUnlocked, onForgotPassphrase }) => { +export const IdbUnlockModal: FC = ({ onUnlocked }) => { const { t } = useTranslation(); const [passphrase, setPassphrase] = useState(''); const [error, setError] = useState(''); const [busy, setBusy] = useState(false); - // QNBS-v3: two-step confirm for forgot-passphrase to prevent accidental clicks - const [showForgotConfirm, setShowForgotConfirm] = useState(false); const [lockoutRemaining, setLockoutRemaining] = useState(0); const inputRef = useRef(null); - const cancelBtnRef = useRef(null); // QNBS-v3: Rate-limiting tick — update remaining lockout time every second useEffect(() => { @@ -127,17 +123,6 @@ export const IdbUnlockModal: FC = ({ onUnlocked, onForgotPassphrase }) => inputRef.current?.focus(); }, []); - // QNBS-v3: WCAG 2.4.3 focus management — when confirmation panel opens, move focus to - // the Cancel button so keyboard users don't lose their position in the document. - useEffect(() => { - if (showForgotConfirm) { - cancelBtnRef.current?.focus(); - } else { - // Restore focus to the passphrase input when the panel is dismissed. - inputRef.current?.focus(); - } - }, [showForgotConfirm]); - const handleUnlock = useCallback(async () => { if (!passphrase) return; if (lockoutRemaining > 0) return; @@ -238,40 +223,6 @@ export const IdbUnlockModal: FC = ({ onUnlocked, onForgotPassphrase }) => : t('settings.privacy.encryptionUnlockButton')} - - {onForgotPassphrase && ( -
- {!showForgotConfirm ? ( - - ) : ( -
- {/* QNBS-v3: role="alert" so screen readers immediately read the warning when this section appears */} - -
- - {/* QNBS-v3: aria-describedby links destructive button to the warning text for AT users */} - -
-
- )} -
- )} ); diff --git a/components/settings/PassphraseModal.tsx b/components/settings/PassphraseModal.tsx index 9cd3877e9..cbbead206 100644 --- a/components/settings/PassphraseModal.tsx +++ b/components/settings/PassphraseModal.tsx @@ -5,12 +5,12 @@ import { Button } from '../ui/Button'; import { Modal } from '../ui/Modal'; import { Spinner } from '../ui/Spinner'; -export type PassphraseModalMode = 'set' | 'change' | 'disable' | 'unlock'; +export type PassphraseModalMode = 'set' | 'unlock'; interface Props { mode: PassphraseModalMode; onClose: () => void; - /** Called with (current, next) — for 'set': ('' , passphrase); for 'unlock'/'disable': (passphrase, ''); for 'change': (old, new). */ + /** Called with (current, next) — for 'set': ('', passphrase); for 'unlock': (passphrase, ''). */ onConfirm: (current: string, next: string) => Promise; } @@ -35,14 +35,10 @@ export const PassphraseModal: FC = ({ mode, onClose, onConfirm }) => { const title = mode === 'set' ? t('settings.privacy.encryptionModalSetTitle') - : mode === 'change' - ? t('settings.privacy.encryptionModalChangeTitle') - : mode === 'unlock' - ? t('settings.privacy.encryptionModalUnlockTitle') - : t('settings.privacy.encryptionModalDisableTitle'); + : t('settings.privacy.encryptionModalUnlockTitle'); const validate = useCallback((): string => { - if (mode === 'set' || mode === 'change') { + if (mode === 'set') { if (next.length < MIN_LEN) return t('settings.privacy.encryptionTooShort'); if (next !== confirm) return t('settings.privacy.encryptionMismatch'); } @@ -62,31 +58,28 @@ export const PassphraseModal: FC = ({ mode, onClose, onConfirm }) => { await onConfirm(current, next); onClose(); } catch { - setError(t('settings.privacy.encryptionWrongPassphrase')); + // QNBS-v3: 'set' (first-time setup) has no prior passphrase to be "wrong" — a thrown error + // there is always a storage/salt failure, not an auth-tag mismatch. Only 'unlock' verifies + // an existing passphrase, so only it can genuinely fail because the passphrase is wrong. + setError( + mode === 'set' + ? t('settings.privacy.encryptionSetupFailed') + : t('settings.privacy.encryptionWrongPassphrase'), + ); } finally { setBusy(false); } - }, [validate, onConfirm, onClose, current, next, t]); + }, [validate, onConfirm, onClose, current, next, mode, t]); const confirmButtonLabel = mode === 'set' ? t('settings.privacy.encryptionSetButton') - : mode === 'change' - ? t('settings.privacy.encryptionChangeButton') - : mode === 'unlock' - ? t('settings.privacy.encryptionUnlockButton') - : t('settings.privacy.encryptionDisableButton'); + : t('settings.privacy.encryptionUnlockButton'); const hasError = error.length > 0; - // QNBS-v3: 'disable' mode is a destructive confirmation — alertdialog announces immediately via AT return ( - +
{/* 'unlock' mode: single current-passphrase field to re-derive the in-memory key */} {mode === 'unlock' && ( @@ -114,33 +107,7 @@ export const PassphraseModal: FC = ({ mode, onClose, onConfirm }) => {
)} - {(mode === 'change' || mode === 'disable') && ( -
- - { - setCurrent(e.target.value); - setError(''); - }} - // QNBS-v3: aria-describedby + aria-invalid wire the error to the field for screen readers - aria-describedby={hasError ? ERROR_ID : undefined} - aria-invalid={hasError} - className="w-full px-3 py-2 rounded-lg border border-[var(--sc-border-subtle)] bg-[var(--sc-surface-base)] text-[var(--sc-text-primary)] focus-visible:ring-2 focus-visible:ring-[var(--sc-border-focus)] outline-none" - /> -
- )} - - {(mode === 'set' || mode === 'change') && ( + {mode === 'set' && ( <>
= ({ mode, onClose, onConfirm }) => {

)} - {/* forgot passphrase hint — only relevant when entering an existing passphrase */} - {(mode === 'unlock' || mode === 'disable') && ( -

- {t('settings.privacy.encryptionForgotPassphrase')}{' '} - - {t('settings.privacy.encryptionForgotPassphraseWarning')} - -

- )} - {/* QNBS-v3: pre-rendered with minHeight so the DOM node exists before text is injected — required by NVDA/JAWS for role="alert" to fire the live-region announcement */}

= ({ mode, onClose, onConfirm }) => { {t('common.cancel')} - {/* QNBS-v3: Lock Session clears the in-memory key without disabling encryption — user must re-enter passphrase on next access. */}