Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
94 commits
Select commit Hold shift + click to select a range
7057cf8
fix: fail closed encrypted storage lifecycle
qnbs Aug 11, 2026
3f70e0e
fix: harden desktop AI integrations
qnbs Aug 11, 2026
814ff04
feat: add durable encryption migration journal
qnbs Aug 11, 2026
349b962
docs: clarify encryption journal rollout
qnbs Aug 11, 2026
79b482c
fix: expose local provider diagnostics
qnbs Aug 11, 2026
8145eec
fix: resolve audited dependency vulnerabilities
qnbs Aug 11, 2026
366fcfe
fix: resolve audited dependency vulnerabilities
qnbs Aug 11, 2026
f978606
Merge branch 'fix/encryption-lifecycle-safety' into fix/desktop-relia…
qnbs Aug 11, 2026
c0707ed
Merge branch 'fix/desktop-reliability-hardening' into feat/encryption…
qnbs Aug 11, 2026
a130b00
fix: make encryption journal updates atomic
qnbs Aug 11, 2026
82e2e8d
fix: enforce pnpm v11 build-script policy
qnbs Aug 11, 2026
05d590c
docs: reconcile PR 310 migration work
qnbs Aug 11, 2026
0ebbca2
feat(storage): add secure record envelope primitives
qnbs Aug 11, 2026
9afbdec
feat(storage): checkpoint resumable migration batches
qnbs Aug 11, 2026
7498444
chore: harden dependency execution policy
qnbs Aug 11, 2026
58a3a82
feat(storage): add resumable secondary store adapters
qnbs Aug 11, 2026
cb87963
fix(storage): fail closed during migration reads
qnbs Aug 11, 2026
2017957
fix(storage): harden migration recovery protocol
qnbs Aug 11, 2026
c4b64f8
fix(deps): reconcile release-age lockfile
qnbs Aug 11, 2026
997b2f6
fix(storage): harden migration recovery protocol
qnbs Aug 11, 2026
88016dd
fix(tauri): bound Python probes and LoRA process lifecycle
qnbs Aug 11, 2026
fa3cd98
chore(deps): align pnpm v11 security policy
qnbs Aug 11, 2026
fd7ed7c
chore: merge encryption lifecycle foundation
qnbs Aug 11, 2026
dda48b3
chore: merge desktop reliability foundation
qnbs Aug 11, 2026
fefd9ef
docs: add desktop performance evidence ledger
qnbs Aug 11, 2026
8b0afe5
docs: capture current agent handoff state
qnbs Aug 11, 2026
a623213
docs: correct README i18n key count (2869 → 2876)
qnbs Aug 11, 2026
264fb7d
fix(storage): close TOCTOU/fail-closed gaps in the IDB encryption guard
qnbs Aug 11, 2026
d318abc
fix(storage,i18n): correct encryption-scope claims and salt-loss fail…
qnbs Aug 11, 2026
ebdcac1
fix: backfill writingSurfaceStyle in all Settings producers; correct …
qnbs Aug 11, 2026
d0ca4e6
fix(storage): correct transaction-ordering regression and cross-tab s…
qnbs Aug 11, 2026
759a9ec
fix(i18n): synchronize stale feature-flag counts and add missing Basq…
qnbs Aug 11, 2026
5e80aaa
fix(settings): distinguish setup-failed from wrong-passphrase errors;…
qnbs Aug 11, 2026
09d8c59
Merge remote-tracking branch 'origin/fix/encryption-lifecycle-safety'…
qnbs Aug 11, 2026
1a081cf
Merge branch 'fix/desktop-reliability-hardening' into feat/encryption…
qnbs Aug 11, 2026
e42fd3c
fix(desktop): fix LoRA env-report deserialization, panic risk, Python…
qnbs Aug 11, 2026
da3b4d7
Merge remote-tracking branch 'origin/fix/desktop-reliability-hardenin…
qnbs Aug 11, 2026
501a403
fix: correct Tauri arg casing and surface native diagnostics in LoRA …
qnbs Aug 11, 2026
e204463
fix: correct AiCreativity casing typo blocking typecheck
qnbs Aug 11, 2026
fd38847
fix: classify a killed training process as aborted, not failed
qnbs Aug 11, 2026
8a6f895
fix: sync Local AI panel when a preload finishes outside handleDownload
qnbs Aug 11, 2026
a01e298
fix: scope the global download modal to explicit preloads only
qnbs Aug 11, 2026
20c9954
fix: guard connection-context races and the WebGPU spinner in AiProvi…
qnbs Aug 11, 2026
756e3c0
fix: correct LM Studio/vLLM/custom local-backend streaming and routing
qnbs Aug 11, 2026
2438f99
test: cover vLLM preset and invalid OpenAI-compatible models responses
qnbs Aug 11, 2026
7cdd128
Merge branch 'fix/desktop-reliability-hardening' into feat/encryption…
qnbs Aug 11, 2026
3253c41
fix: resolve tsgo narrowing issue in AiProviderCard's stale-context test
qnbs Aug 11, 2026
56e2509
fix: enforce legal phase transitions and close the checkpoint cursor-…
qnbs Aug 11, 2026
a8dd917
fix: gate protected-store writes against an active migration
qnbs Aug 11, 2026
46198b2
fix(storage): skip corrupt scene revisions instead of hiding history;…
qnbs Aug 11, 2026
411943a
fix(storage): fail migration to recovery-required on a verification s…
qnbs Aug 11, 2026
beadfa2
fix(ai): degrade cache reads to a miss on lock/migration, re-encrypt …
qnbs Aug 11, 2026
3f31c1e
fix(ollama): report invalidResponse instead of a false-positive conne…
qnbs Aug 11, 2026
68050d8
docs(test): clarify encryptionMigrationJournal.test.ts's ownership-CA…
qnbs Aug 11, 2026
dc0b526
fix(settings): stop rendering the connection test result twice in AiP…
qnbs Aug 11, 2026
ed46bef
docs: refresh stack SHAs in the performance ledger; reconcile PR310-R…
qnbs Aug 11, 2026
667f6f3
fix(storage): route locked encrypted startup and Lock Session to the …
qnbs Aug 11, 2026
99c2839
fix(storage): route locked encrypted startup and Lock Session to the …
qnbs Aug 11, 2026
0353364
fix(settings): restore sepia as the first-run appearance default
qnbs Aug 11, 2026
ad4364a
Merge branch 'fix/encryption-lifecycle-safety' into fix/desktop-relia…
qnbs Aug 11, 2026
dd92628
Merge branch 'fix/desktop-reliability-hardening' into feat/encryption…
qnbs Aug 11, 2026
74ce8fd
docs: refresh stack SHAs after the appearancePreset/unlock-routing fi…
qnbs Aug 11, 2026
58d95d1
docs: refresh session handoff, archive the prior capture
qnbs Aug 11, 2026
be11482
fix(lora,settings): stale-run training race, stuck onboarding on canc…
qnbs Aug 11, 2026
d536649
fix(settings,ai): decouple URL edits from protocol preset; guard prel…
qnbs Aug 11, 2026
ebafce7
fix(desktop): revalidate the cached Python interpreter before trustin…
qnbs Aug 11, 2026
c3f00cf
fix(settings): auto-validate a newly saved Gemini key instead of defe…
qnbs Aug 11, 2026
0b1cc2e
Merge branch 'fix/desktop-reliability-hardening' into feat/encryption…
qnbs Aug 11, 2026
bc078b8
docs: refresh session handoff after the #336 fresh-review-wave fix ca…
qnbs Aug 11, 2026
edc3ef1
fix(deps): exclude the two unpatched uuid releases from the override …
qnbs Aug 11, 2026
e99f354
Merge branch 'fix/encryption-lifecycle-safety' into fix/desktop-relia…
qnbs Aug 11, 2026
5bd4c77
Merge branch 'fix/desktop-reliability-hardening' into feat/encryption…
qnbs Aug 11, 2026
451f681
fix(lora): clear cancellationRequested on a failed native abort; coll…
qnbs Aug 11, 2026
b01564e
fix(i18n): translate LoRA onboarding strings and fix broken shell com…
qnbs Aug 11, 2026
1096861
Merge branch 'fix/desktop-reliability-hardening' into feat/encryption…
qnbs Aug 12, 2026
6dc90eb
docs: refresh performance ledger SHAs after #336 layering-mistake cor…
qnbs Aug 12, 2026
3a3ed23
docs: capture session handoff after the second layering-mistake corre…
qnbs Aug 12, 2026
652fa72
docs(pr310): mark review-thread reconciliation complete (0/317 unreso…
qnbs Aug 12, 2026
a24a987
docs: update handoff with Tauri build success and #310 reconciliation…
qnbs Aug 12, 2026
369d064
fix(lora): only classify training as aborted on a confirmed native ca…
qnbs Aug 12, 2026
09eae35
Merge branch 'fix/desktop-reliability-hardening' into feat/encryption…
qnbs Aug 12, 2026
afbcdb8
Merge remote-tracking branch 'origin/main' into fix/desktop-reliabili…
qnbs Aug 12, 2026
56389e6
fix(lora,ai): distinguish pending-start cancellation from a true abor…
qnbs Aug 12, 2026
5568d91
Merge branch 'fix/desktop-reliability-hardening' into feat/encryption…
qnbs Aug 12, 2026
43e4afc
fix(settings): restore the WebGPU auto-probe lost when the ollama aut…
qnbs Aug 12, 2026
55bc5ea
Merge branch 'fix/desktop-reliability-hardening' into feat/encryption…
qnbs Aug 12, 2026
1f1ec61
Merge remote-tracking branch 'origin/main' into feat/encryption-recov…
qnbs Aug 12, 2026
e708940
fix(a11y): set aria-busy on the provider connection status region whi…
qnbs Aug 12, 2026
1335e81
fix(test): give the binder-asset transaction mock a real .transaction…
qnbs Aug 12, 2026
5fed880
fix(storage,ai,a11y): fail-soft cache reads, single-flight IDB open, …
qnbs Aug 12, 2026
0ab3341
docs: capture handoff after merging #335/#336 into main and #337's fu…
qnbs Aug 12, 2026
92dd2b5
fix(test,docs): track cursor completion in binder mock; fix doc drift…
qnbs Aug 12, 2026
ed22774
test(storage): cover scene/cache payload-shape branches in the second…
qnbs Aug 12, 2026
8e1e31d
test(storage): fix test-fixture Date.now() nondeterminism; docs: reco…
qnbs Aug 12, 2026
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
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.
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
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
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).
- 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.
- 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.
- 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
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, fail-closed protected writes, and a versioned single-owner migration journal are implemented. Disable, forgot-passphrase deletion, and passphrase rotation are intentionally blocked until per-store conversion, verification, cross-tab recovery, and recovery UX 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
20 changes: 16 additions & 4 deletions components/ApiKeySection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ import { Button } from './ui/Button';
import { Input } from './ui/Input';
import { Spinner } from './ui/Spinner';

// QNBS-v3: Reject non-printing control input without binding Gemini acceptance to a historical key prefix.
const containsAsciiControlCharacter = (value: string): boolean =>
Array.from(value).some((character) => {
const code = character.charCodeAt(0);
return code < 0x20 || code === 0x7f;
});

export const isSyntacticallySafeGeminiApiKey = (value: string): boolean =>
value.length <= 4096 && !containsAsciiControlCharacter(value);

export const ApiKeySection: FC = () => {
const { t } = useTranslation();
const [apiKey, setApiKey] = useState('');
Expand Down Expand Up @@ -45,18 +55,20 @@ export const ApiKeySection: FC = () => {
}, [checkKeyStatus]);

const handleSaveKey = async () => {
if (!apiKey.trim()) {
const normalizedKey = apiKey.trim();
if (!normalizedKey) {
setMessage({ type: 'error', text: t('settings.apiKey.errorEmpty') });
return;
}
if (!apiKey.trim().startsWith('AIza')) {
if (!isSyntacticallySafeGeminiApiKey(normalizedKey)) {
setMessage({ type: 'error', text: t('settings.apiKey.errorInvalid') });
return;
}
setIsSaving(true);
setMessage(null);
try {
await dbService.saveGeminiApiKey(apiKey.trim());
// QNBS-v3: Provider validation, not an historical prefix, determines credential validity.
await dbService.saveGeminiApiKey(normalizedKey);
invalidateAiClientCache();
setApiKey('');
setHasKey(true);
Expand Down Expand Up @@ -296,7 +308,7 @@ export const ApiKeySection: FC = () => {
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSaveKey()}
placeholder="AIza..."
placeholder={t<string>('settings.apiKey.inputLabel')}
autoComplete="off"
className="pr-10"
/>
Expand Down
41 changes: 39 additions & 2 deletions components/lora/LoraOnboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,24 @@ interface EnvStatus {
unslothAvailable: boolean;
cudaAvailable: boolean;
vramGb: number;
pythonVersion: string;
pythonPath?: string;
lastError?: string;
message?: string;
}

// QNBS-v3: Dynamic import to enable code-splitting — loraTrainingService is also dynamically imported in thunks.
const useLoraTrainingService = () => {
const [api, setApi] = useState<{
checkTrainingEnvironment: () => Promise<Omit<EnvStatus, 'loaded'>>;
selectPythonExecutable: () => Promise<Omit<EnvStatus, 'loaded'> | null>;
} | null>(null);
useEffect(() => {
void import('../../services/lora/loraTrainingService').then((m) => {
setApi({ checkTrainingEnvironment: m.checkTrainingEnvironment });
setApi({
checkTrainingEnvironment: m.checkTrainingEnvironment,
selectPythonExecutable: m.selectPythonExecutable,
});
});
}, []);
return api;
Expand Down Expand Up @@ -52,8 +59,10 @@ export default React.memo(function LoraOnboarding({ onDismiss }: { onDismiss: ()
unslothAvailable: false,
cudaAvailable: false,
vramGb: 0,
pythonVersion: '',
});
const api = useLoraTrainingService();
const [isSelectingPython, setIsSelectingPython] = useState(false);

useEffect(() => {
if (!api) return;
Expand All @@ -62,6 +71,24 @@ export default React.memo(function LoraOnboarding({ onDismiss }: { onDismiss: ()
});
}, [api]);

const handleSelectPython = async () => {
if (!api) return;
setIsSelectingPython(true);
try {
const result = await api.selectPythonExecutable();
if (result) setEnv({ loaded: true, ...result });
} catch (error) {
// QNBS-v3: Keep the failed manual selection visible instead of falsely reporting Python as absent.
setEnv((current) => ({
...current,
loaded: true,
lastError: error instanceof Error ? error.message : 'python_selection_failed',
}));
} finally {
setIsSelectingPython(false);
}
};

return (
<div className="flex flex-col gap-6 rounded-sc-xl border border-[var(--sc-border-default)] bg-[var(--sc-surface-base)] p-6 shadow-sm">
<div className="space-y-2">
Expand Down Expand Up @@ -96,7 +123,9 @@ export default React.memo(function LoraOnboarding({ onDismiss }: { onDismiss: ()
label="Python 3.10+"
ok={env.pythonAvailable}
detail={
env.pythonAvailable ? t('lora.onboarding.installed') : t('lora.onboarding.notFound')
env.pythonAvailable
? [env.pythonVersion, env.pythonPath].filter(Boolean).join(' · ')
: (env.lastError ?? t('lora.onboarding.notFound'))
}
/>
<StatusRow
Expand All @@ -118,6 +147,14 @@ export default React.memo(function LoraOnboarding({ onDismiss }: { onDismiss: ()
{env.message && (
<p className="text-xs text-[var(--sc-danger-fg)]">{t('lora.onboarding.envError')}</p>
)}
<button
type="button"
onClick={() => void handleSelectPython()}
disabled={!api || isSelectingPython}
className="text-sm text-[var(--sc-interactive-primary)] underline underline-offset-2 disabled:cursor-not-allowed disabled:opacity-60 focus-visible:ring-2 focus-visible:ring-[var(--sc-border-focus)]"
>
{isSelectingPython ? t('lora.onboarding.checking') : t('lora.onboarding.selectPython')}
</button>
</div>

<button
Expand Down
Loading