Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<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-2942_keys-0EA5E9" alt="i18n 19 locales — 2942 keys">
<img src="https://img.shields.io/badge/Tests-7567%2B_%2F_603_files-22C55E" alt="7567+ tests / 603 files">
<img src="https://img.shields.io/badge/Tests-7575%2B_%2F_603_files-22C55E" alt="7575+ tests / 603 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">
<img src="https://img.shields.io/github/actions/workflow/status/qnbs/WorldScript-Studio/.github/workflows/ci.yml?branch=main&logo=github" alt="CI Status">
Expand Down Expand Up @@ -511,7 +511,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and
| **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`) | 2942 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 (7567+ tests / 603 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Testing** | Vitest 4.x (7575+ tests / 603 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` |
Expand Down Expand Up @@ -549,7 +549,7 @@ WorldScript-Studio/
│ ├── sw.js # PWA Service Worker
│ └── manifest.json # PWA Web App Manifest v3
├── tests/
│ ├── unit/ # Vitest unit tests (7567+ tests, 603 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ ├── unit/ # Vitest unit tests (7575+ tests, 603 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths
│ │ └── settings/ # WebLlmPanel, AiSections
│ └── e2e/ # Playwright specs + helpers.ts
Expand Down Expand Up @@ -714,7 +714,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
Raw bundle-budget ceilings (KB per uncompressed asset): entry **2500 KB**, vendor **6200 KB**, other JavaScript **2500 KB**, and WASM **30000 KB**.

**Current test metrics (2026-09-07, source-synchronized; CI remains authoritative for pass/fail):**
- **7567+ unit tests** across **603 test files** — CI is authoritative for pass/fail
- **7575+ unit tests** across **603 test files** — CI is authoritative for pass/fail
- Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics)
- i18n: **2942 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta)

Expand Down
2 changes: 1 addition & 1 deletion docs/IDB-ENCRYPTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ Every protected store writer runs inside `withProtectedWriteAdmission()` (shared

On the Tauri desktop build, primary project, settings, snapshot, image, Codex, RAG, and binder-asset data is persisted by the filesystem-backed store (`services/fs/*Store.ts`), not IndexedDB. That store writes plaintext (LZ-string compressed only, no encryption) regardless of `enableIdbAtRestEncryption`. Enabling the setting on desktop still shows `IdbUnlockModal`/`PassphraseModal` (the passphrase sentinel lives in the WebView's own IndexedDB, which persists on desktop too), but that unlock flow gates nothing on the filesystem side today — only the UI, not the actual manuscript files under `$APPDATA`, is shared with the web build. Character and world image reads use `storageService`, so they now follow the same selected backend as image uploads; this removes the prior desktop filesystem/IndexedDB split-persistence availability bug. See `README.md`'s "Encryption — which mechanism protects what" table for the authoritative per-mechanism breakdown. Extending real at-rest protection to the desktop filesystem store is a tracked, open gap — not yet implemented.

**API keys (resolved 2026-08-14):** all provider API keys, including Gemini, now route through `storageService` directly to the IndexedDB key store (`services/storage/idbKeyStore.ts`, random non-extractable AES-GCM key) on every platform, desktop included. The Tauri filesystem adapter's `saveApiKey`/`getApiKey` (`services/fs/settingsFsStore.ts`) is now a defense-in-depth backstop rather than the active path: `saveApiKey` throws if ever called, and `getApiKey` silently removes any pre-existing legacy key file — whether from the pre-2026-07-29 unsalted-SHA-256 scheme or the since-hardened but still filesystem-reconstructible PBKDF2 scheme — and returns `null`. A user-facing "API Key Reset Required" notification fires only if that removal itself fails (a permissions/IO error surfacing through the catch path); the common case (file found and removed cleanly) is silent, since a re-prompt for a never-populated key is indistinguishable from normal first-use. `encryptText`/`decryptText` in `fsCore.ts` are no longer called anywhere in the codebase for API keys and remain only as shared crypto plumbing pending [PR #356](https://github.com/qnbs/WorldScript-Studio/pull/356)'s project-data encryption work. This also resolves the earlier Gemini split-persistence bug tracked in [#358](https://github.com/qnbs/WorldScript-Studio/issues/358): `components/ApiKeySection.tsx` and `services/geminiService.ts` now both route through `storageService`.
**API keys (resolved 2026-08-14):** all provider API keys, including Gemini, now route through `storageService` directly to the IndexedDB key store (`services/storage/idbKeyStore.ts`, random non-extractable AES-GCM key) on every platform, desktop included. The Tauri filesystem adapter's `saveApiKey`/`getApiKey` (`services/fs/settingsFsStore.ts`) is now a defense-in-depth backstop rather than the active path: `saveApiKey` throws if ever called, and `getApiKey` silently removes any pre-existing legacy key file — whether from the pre-2026-07-29 unsalted-SHA-256 scheme or the since-hardened but still filesystem-reconstructible PBKDF2 scheme — and returns `null`. A user-facing "API Key Reset Required" notification fires only when the initial removal attempt throws (a permissions/IO error) **and** the catch block's own follow-up removal then succeeds; if that follow-up removal also fails, the notification is suppressed and only a warning is logged. The common case — no legacy file exists, or the initial removal succeeds outright — returns silently, since a re-prompt for a never-populated key is indistinguishable from normal first-use. `encryptText`/`decryptText` in `fsCore.ts` are no longer called anywhere in the codebase for API keys and remain only as shared crypto plumbing pending R-15's project-data encryption work (`docs/native/CORE-MIGRATION-LEDGER.md` row 10, which itself records `S5_IMPLEMENTATION_READY=NO` and is additionally gated behind row 9, the project state-shape compatibility adapter, converging first). This also resolves the earlier Gemini split-persistence bug tracked in [#358](https://github.com/qnbs/WorldScript-Studio/issues/358): `components/ApiKeySection.tsx` and `services/geminiService.ts` now both route through `storageService`.
Comment thread
qnbs marked this conversation as resolved.
Outdated
Comment thread
qnbs marked this conversation as resolved.
Outdated

The repository does **not** currently use `tauri-plugin-stronghold`, an OS keychain, or a transparent desktop-only passphrase store.

Expand Down
4 changes: 2 additions & 2 deletions docs/SECURITY-THREAT-MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ This document provides a formal STRIDE threat analysis for WorldScript Studio, m
| Threat | Mitigation | Code Location |
|--------|------------|-------------|
| API key leakage via logs | StructuredLogger sanitization; never log keys | `services/logger.ts:sanitizeLogContext()` |
| Desktop API key exposure via local filesystem read | **Resolved 2026-08-14 (was "not resolved" through 2026-08-13 — see history).** Filesystem API-key persistence is disabled: `storageService`'s key methods route directly to the IndexedDB key store (random non-extractable AES-GCM key) on every platform, desktop included — the filesystem adapter's own `saveApiKey` is a defense-in-depth backstop that throws if ever called directly. The prior PBKDF2-from-reconstructible-material derivation (`deriveFileSystemCryptoKey()`) is no longer used for API keys; it remains in `fsCore.ts` as shared crypto plumbing for other filesystem-encrypted data (see [PR #356](https://github.com/qnbs/WorldScript-Studio/pull/356)). Legacy filesystem key files are removed on a best-effort basis (each failure is logged, not retried indefinitely) and re-entry is required if cleanup or decryption fails. **Gemini split-persistence bug also resolved:** `components/ApiKeySection.tsx` and `services/geminiService.ts` now both route through `storageService`, closing [#358](https://github.com/qnbs/WorldScript-Studio/issues/358) (previously ApiKeySection read/wrote the Gemini key via `dbService` directly while geminiService read it via `storageService`, so a key saved on desktop was invisible to the code that used it). | `services/storage/idbKeyStore.ts`, `services/storageService.ts`, `services/fs/settingsFsStore.ts` |
| Desktop project/settings/snapshot/Codex/RAG/image/binder-asset data disclosure via local file-read access | **Not resolved on current `main`.** Tauri filesystem records, including binder `.bin` payloads, are plaintext (some text records are compressed only), so an attacker who can read the app-data directory can disclose them. The separate tampering threat requires local file-write access and is modeled under Tampering above. Enabling the current browser/IndexedDB setting does not protect these files. [PR #356](https://github.com/qnbs/WorldScript-Studio/pull/356) is the active remediation; do not broaden this claim until its combined-state runtime proof completes. | `services/fs/*Store.ts` |
| Desktop API key exposure via local filesystem read | **Resolved 2026-08-14 (was "not resolved" through 2026-08-13 — see history).** Filesystem API-key persistence is disabled: `storageService`'s key methods route directly to the IndexedDB key store (random non-extractable AES-GCM key) on every platform, desktop included — the filesystem adapter's own `saveApiKey` is a defense-in-depth backstop that throws if ever called directly. The prior PBKDF2-from-reconstructible-material derivation (`deriveFileSystemCryptoKey()`) is no longer used for API keys; it remains in `fsCore.ts` as shared crypto plumbing for other filesystem-encrypted data (see [PR #356](https://github.com/qnbs/WorldScript-Studio/pull/356), closed 2026-08-18 as superseded). Legacy filesystem key files are removed on a best-effort basis (each failure is logged, not retried indefinitely) and re-entry is required if cleanup or decryption fails. **Gemini split-persistence bug also resolved:** `components/ApiKeySection.tsx` and `services/geminiService.ts` now both route through `storageService`, closing [#358](https://github.com/qnbs/WorldScript-Studio/issues/358) (previously ApiKeySection read/wrote the Gemini key via `dbService` directly while geminiService read it via `storageService`, so a key saved on desktop was invisible to the code that used it). | `services/storage/idbKeyStore.ts`, `services/storageService.ts`, `services/fs/settingsFsStore.ts` |
| Desktop project/settings/snapshot/Codex/RAG/image/binder-asset data disclosure via local file-read access | **Not resolved on current `main`.** Tauri filesystem records, including binder `.bin` payloads, are plaintext (some text records are compressed only), so an attacker who can read the app-data directory can disclose them. The separate tampering threat requires local file-write access and is modeled under Tampering above. Enabling the current browser/IndexedDB setting does not protect these files. R-15 (`docs/native/CORE-MIGRATION-LEDGER.md` row 10) is the design-complete remediation — row 10 itself records `S5_IMPLEMENTATION_READY=NO`, and implementation additionally stays gated behind row 9 (the project state-shape compatibility adapter) converging first; do not broaden this claim until implementation actually starts. | `services/fs/*Store.ts` |
Comment thread
qnbs marked this conversation as resolved.
Outdated
| Manuscript data in IndexedDB | AES-256-GCM at-rest encryption | `services/storage/storageEncryptionService.ts` |
| Voice audio to cloud | Web Speech API consent gate | `components/voice/VoicePrivacyConsentModal.tsx` |
| DuckDB analytics unencrypted (SEC-6) | **Bounded by design, with one prose column now encrypted:** most persisted fields are local metadata only (titles, loglines, character names, word counts, embeddings) and **nothing leaves the device**. The one column that genuinely holds literal manuscript prose, `codex_mentions.excerpt`, is now cell-level encrypted (AES-256-GCM via `services/duckdb/duckdbEncryption.ts`, reusing the IDB at-rest encryption key) whenever `enableIdbAtRestEncryption` is active: `duckdbCodexWrite()` writes ciphertext into `excerpt_enc BLOB` and nulls the plaintext `excerpt` column; `services/duckdb/codexExcerptEncryptionMigration.ts` backfills any pre-existing plaintext rows once encryption is unlocked. Gated by `enableDuckDbAnalytics` **and** the Settings → Privacy "Analytics" opt-out (`isAnalyticsPersistenceAllowed` in `app/listenerMiddleware.ts`); turning the toggle off stops all DuckDB writes + inference telemetry. Full OPFS file-level encryption remains **infeasible** — DuckDB-WASM owns the OPFS file handle directly, so there is no app-level interception point; the other metadata columns stay intentionally plaintext (bounded-exposure design). | `app/listenerMiddleware.ts:isAnalyticsPersistenceAllowed`, `services/duckdb/duckdbAnalytics.ts:duckdbCodexWrite()`, `services/duckdb/duckdbEncryption.ts`, `services/duckdb/codexExcerptEncryptionMigration.ts` |
Expand Down
1 change: 1 addition & 0 deletions scripts/check-doc-metrics.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,4 @@ export function scanLocalizedBundleBudgetTruth(
export function getCanonicalProductionUrl(): string;
export function scanForUrlDrift(content: string, filePath: string, canonicalUrl: string): string[];
export const VERCEL_URL_PATTERN: RegExp;
export function scanSecurityDocPrStatus(content: string, filePath: string): string[];
91 changes: 91 additions & 0 deletions scripts/check-doc-metrics.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,86 @@ export function scanForDrift(content, filePath, { localeCount, keyCount, latestV
return findings;
}

// QNBS-v3: (audit F-1) reject a live/pending-remediation claim tied to a bare PR number in the two
// security-status docs unless that same PR number also carries its actual closed/merged state
// nearby — a stale "PR #356 is the active remediation" survived weeks after #356 closed because
// nothing checked it. Deliberately scoped to these two files, not repo-wide: a blanket rule would
// also reject the legitimate historical CHANGELOG entry, ADR narrative, and already-qualified
// ROADMAP citations of the same PR elsewhere in the repo.
const SECURITY_STATUS_DOCS = ['docs/SECURITY-THREAT-MODEL.md', 'docs/IDB-ENCRYPTION.md'];
// QNBS-v3 (CodeAnt): the PR number is captured INSIDE each alternative so the qualifier check
// below can require proximity to that specific PR, not "any qualifier anywhere in the text" —
// otherwise a closed/merged mention of a DIFFERENT PR would wrongly suppress this one's finding.
const LIVE_STATUS_CLAIM_WITH_PR = new RegExp(
[
// QNBS-v3: the gap allows any character (a markdown link's URL contains periods, e.g.
// "github.com") — matching already runs per-sentence, so a real sentence boundary was
// already cut before this regex ever sees the text.
String.raw`\[?PR\s*#(?<prA>\d+)\]?[\s\S]{0,120}?\bis the active remediation`,
String.raw`\bpending\s+\[?PR\s*#(?<prB>\d+)\]?`,
String.raw`\bin progress on\s+\[?PR\s*#(?<prC>\d+)\]?`,
Comment thread
qnbs marked this conversation as resolved.
Outdated
].join('|'),
'gi',
);
const STATUS_QUALIFIER_WORD = 'closed|merged|superseded';

// QNBS-v3 (CodeAnt): group physical lines into Markdown paragraphs (blank-line-delimited) before
// matching — a naive per-line split let a status claim split across a soft-wrapped line evade
// detection entirely, since neither half alone matched the full pattern.
function splitIntoParagraphs(content) {
const paragraphs = [];
let buffer = [];
let startLine = 0;
const flush = () => {
if (buffer.length > 0) {
paragraphs.push({ text: buffer.join(' '), startLine: startLine + 1 });
Comment thread
qnbs marked this conversation as resolved.
buffer = [];
}
};
content.split('\n').forEach((line, i) => {
if (line.trim() === '') {
flush();
} else {
if (buffer.length === 0) startLine = i;
Comment thread
qnbs marked this conversation as resolved.
buffer.push(line.trim());
}
});
flush();
return paragraphs;
}

// QNBS-v3: crude but sufficient sentence split for this narrow, two-file gate — doesn't need to
// handle abbreviations/decimals correctly, only to stop a qualifier for one PR bleeding across an
// unrelated sentence into another PR's claim.
function splitIntoSentences(paragraph) {
return paragraph.split(/(?<=[.;])\s+/);
Comment thread
qnbs marked this conversation as resolved.
Outdated
}

export function scanSecurityDocPrStatus(content, filePath) {
const findings = [];
for (const { text: paragraph, startLine } of splitIntoParagraphs(content)) {
Comment thread
qnbs marked this conversation as resolved.
Outdated
for (const sentence of splitIntoSentences(paragraph)) {
for (const match of sentence.matchAll(LIVE_STATUS_CLAIM_WITH_PR)) {
const prNumber = match.groups?.prA ?? match.groups?.prB ?? match.groups?.prC;
// QNBS-v3 (CodeAnt): the qualifier must sit near THIS PR's own number in the same
// sentence, not merely appear somewhere in it — otherwise a different, already-qualified
// PR mentioned nearby would incorrectly suppress this one's live-status finding.
const qualifiedForThisPr = new RegExp(
`#${prNumber}\\b[\\s\\S]{0,60}\\b(?:${STATUS_QUALIFIER_WORD})\\b|` +
`\\b(?:${STATUS_QUALIFIER_WORD})\\b[\\s\\S]{0,60}#${prNumber}\\b`,
Comment thread
qnbs marked this conversation as resolved.
Outdated
'i',
).test(sentence);
if (!qualifiedForThisPr) {
findings.push(
`${filePath}:${startLine} — asserts a live/pending remediation status tied to PR #${prNumber} without stating that PR's actual closed/merged state: "${sentence.trim()}"`,
);
}
}
}
}
return findings;
}
Comment thread
qnbs marked this conversation as resolved.

// QNBS-v3: exits 1 on any finding — unlike check-coverage-ratchet.mjs this gate is blocking, since a doc claiming a wrong locale/key/release count is actively misleading, not just an opportunity.
// QNBS-v3 (F-10, CodeRabbit follow-up): locales/it/help.json IS included — it's exactly where the F-10 stale-URL drift happened; the in-app link reads the constant directly so it can't drift and isn't listed here.
const URL_CHECK_FILES = ['README.md', 'CLAUDE.md', 'locales/it/help.json'];
Expand Down Expand Up @@ -555,6 +635,17 @@ function main() {
allFindings.push(...scanForUrlDrift(content, relPath, canonicalUrl));
}

for (const relPath of SECURITY_STATUS_DOCS) {
const abs = join(root, relPath);
let content;
try {
content = readFileSync(abs, 'utf8');
} catch {
continue;
Comment thread
qnbs marked this conversation as resolved.
Comment thread
qnbs marked this conversation as resolved.
}
allFindings.push(...scanSecurityDocPrStatus(content, relPath));
}

for (const relPath of BUNDLE_BUDGET_DOCS) {
const abs = join(root, relPath);
let content;
Expand Down
Loading
Loading