Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 1 addition & 28 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 9 additions & 14 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -196,15 +195,6 @@ const App: FC<AppProps> = ({ 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);

Expand Down Expand Up @@ -256,6 +246,14 @@ const App: FC<AppProps> = ({ isNewUser }) => {
}
}, [settings.appearancePreset]);

useEffect(() => {
// QNBS-v3: Decorative fixed layers are opt-out so long-form writers can keep a neutral canvas.
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
document.body.classList.toggle(
'writing-surface-plain',
settings.writingSurfaceStyle === 'plain',
);
}, [settings.writingSurfaceStyle]);

useEffect(() => {
document.body.classList.toggle(
'accessibility-high-contrast',
Expand Down Expand Up @@ -809,10 +807,7 @@ const App: FC<AppProps> = ({ isNewUser }) => {
)}
{isIdbUnlockOpen && (
<ErrorBoundary onReset={() => setIdbUnlockOpen(false)}>
<IdbUnlockModal
onUnlocked={() => setIdbUnlockOpen(false)}
onForgotPassphrase={() => void handleForgotPassphrase()}
/>
<IdbUnlockModal onUnlocked={() => setIdbUnlockOpen(false)} />
</ErrorBoundary>
)}
</div>
Expand Down
3 changes: 3 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 10 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<img src="https://img.shields.io/badge/Version-v1.26.0-6366F1" alt="v1.26.0">
<img src="https://img.shields.io/badge/Storage-IndexedDB_v8-F59E0B" alt="IndexedDB v8">
<img src="https://img.shields.io/badge/PWA-v3.0-5BB974?logo=pwa" alt="PWA v3.0">
<img src="https://img.shields.io/badge/i18n-19_locales-2869_keys-0EA5E9" alt="i18n 19 locales — 2869 keys">
<img src="https://img.shields.io/badge/i18n-19_locales-2877_keys-0EA5E9" alt="i18n 19 locales — 2877 keys">
<img src="https://img.shields.io/badge/Tests-6477%2B_%2F_532_files-22C55E" alt="6477+ tests / 532 files">
<img src="https://img.shields.io/codecov/c/github/qnbs/WorldScript-Studio?logo=codecov&label=Coverage" alt="Codecov Coverage">
<img src="https://img.shields.io/badge/License-MIT-22C55E" alt="License MIT">
Expand Down Expand Up @@ -307,20 +307,21 @@ 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

One-click encrypted export of your entire project library from **Settings → Data**:

- 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
Expand Down Expand Up @@ -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**
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions components/settings/GeneralSections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,35 @@ export const AppearanceSection: FC = () => {
</Button>
</div>
</div>
{/* QNBS-v3: Let writers remove decorative layers without changing the selected theme. */}
<div className="pt-2 border-t border-[var(--sc-border-subtle)] space-y-2">
<span className="text-sm font-medium text-[var(--sc-text-secondary)]">
{t('settings.appearance.writingSurface')}
</span>
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
<p className="text-xs text-[var(--sc-text-muted)]">
{t('settings.appearance.writingSurfaceHint')}
</p>
<div className="grid grid-cols-2 gap-3">
<Button
variant={settings.writingSurfaceStyle === 'textured' ? 'primary' : 'secondary'}
aria-pressed={settings.writingSurfaceStyle === 'textured'}
size="sm"
onClick={() => handleSettingChange('writingSurfaceStyle', 'textured')}
className="justify-center"
>
{t('settings.appearance.writingSurfaceTextured')}
</Button>
<Button
variant={settings.writingSurfaceStyle === 'plain' ? 'primary' : 'secondary'}
aria-pressed={settings.writingSurfaceStyle === 'plain'}
size="sm"
onClick={() => handleSettingChange('writingSurfaceStyle', 'plain')}
className="justify-center"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
>
{t('settings.appearance.writingSurfacePlain')}
</Button>
</div>
</div>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</CardContent>
</Card>
<Card>
Expand Down
51 changes: 1 addition & 50 deletions components/settings/IdbUnlockModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { Modal } from '../ui/Modal';

interface Props {
onUnlocked: () => void;
onForgotPassphrase?: () => void;
}

const ATTEMPT_STORAGE_KEY = 'worldscript-idb-unlock-attempts';
Expand Down Expand Up @@ -100,16 +99,13 @@ function lockoutMs(attempts: number): number {
return Math.min(2 ** (attempts - 4), 60) * 1000;
}

export const IdbUnlockModal: FC<Props> = ({ onUnlocked, onForgotPassphrase }) => {
export const IdbUnlockModal: FC<Props> = ({ 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<HTMLInputElement>(null);
const cancelBtnRef = useRef<HTMLButtonElement>(null);

// QNBS-v3: Rate-limiting tick — update remaining lockout time every second
useEffect(() => {
Expand All @@ -127,17 +123,6 @@ export const IdbUnlockModal: FC<Props> = ({ 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;
Expand Down Expand Up @@ -238,40 +223,6 @@ export const IdbUnlockModal: FC<Props> = ({ onUnlocked, onForgotPassphrase }) =>
: t('settings.privacy.encryptionUnlockButton')}
</Button>
</div>

{onForgotPassphrase && (
<div className="border-t border-[var(--sc-border-subtle)] pt-4 space-y-3">
{!showForgotConfirm ? (
<button
type="button"
onClick={() => setShowForgotConfirm(true)}
className="text-sm text-[var(--sc-text-muted)] underline underline-offset-2 hover:text-[var(--sc-text-secondary)] focus-visible:ring-2 focus-visible:ring-[var(--sc-border-focus)] rounded"
>
{t('settings.privacy.encryptionForgotPassphrase')}
</button>
) : (
<div className="space-y-3">
{/* QNBS-v3: role="alert" so screen readers immediately read the warning when this section appears */}
<p
id="idb-forgot-warning"
role="alert"
className="text-sm text-[var(--sc-warning-fg)]"
>
{t('settings.privacy.encryptionForgotPassphraseWarning')}
</p>
<div className="flex gap-2 justify-end">
<Button ref={cancelBtnRef} onClick={() => setShowForgotConfirm(false)}>
{t('common.cancel')}
</Button>
{/* QNBS-v3: aria-describedby links destructive button to the warning text for AT users */}
<Button onClick={onForgotPassphrase} aria-describedby="idb-forgot-warning">
{t('settings.privacy.encryptionDisableConfirm')}
</Button>
</div>
</div>
)}
</div>
)}
</div>
</Modal>
);
Expand Down
Loading
Loading