Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
6 changes: 5 additions & 1 deletion components/settings/DataSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
buildSettingsExportEnvelope,
parseSettingsImportEnvelope,
} from '../../services/settingsExchange';
import { normalizePersistedSettings } from '../../services/storage/idbProjectStore';
import { isTauriRuntime, openTauriDataDirectory } from '../../services/tauriRuntime';
import { Button } from '../ui/Button';
import { Card, CardContent, CardHeader } from '../ui/Card';
Expand Down Expand Up @@ -104,7 +105,10 @@ export const DataSection: FC = () => {
const parsed = JSON.parse(String(reader.result ?? ''));
const partial = parseSettingsImportEnvelope(parsed);
if (!partial) return;
dispatch(settingsActions.setSettings({ ...settings, ...partial }));
// QNBS-v3: route through the same sanitizer as IDB rehydration — a raw import spreads arbitrary fields into Redux otherwise, which autosave would then persist unchanged (e.g. a legacy openRouter.apiKey straight into plaintext desktop settings.json).
dispatch(
settingsActions.setSettings(normalizePersistedSettings({ ...settings, ...partial })),
);
} catch {
/* invalid file */
}
Expand Down
4 changes: 2 additions & 2 deletions docs/IDB-ENCRYPTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

## Overview

WorldScript Studio stores project data in IndexedDB. API keys use a separate encrypted-secret mechanism in `dbService.ts`. Optional IDB at-rest encryption uses a passphrase-derived AES-256-GCM key for the primary project, settings, snapshot, image, Codex, RAG, and binder-asset persistence paths. Other persistence surfaces must be inventoried and integrated through the same policy before a blanket “all IndexedDB data” claim is valid.
WorldScript Studio stores project data in IndexedDB. API keys use a separate encrypted-secret mechanism, routed through `storageService` to `services/storage/idbKeyStore.ts` (random, non-extractable AES-GCM key — see the Tauri Desktop Layer section below for the full call path). Optional IDB at-rest encryption uses a passphrase-derived AES-256-GCM key for the primary project, settings, snapshot, image, Codex, RAG, and binder-asset persistence paths. Other persistence surfaces must be inventoried and integrated through the same policy before a blanket “all IndexedDB data” claim is valid.

The encryption service is gated behind `featureFlags.enableIdbAtRestEncryption` (on by default since v1.23 — manage in Settings → Privacy → "Encrypt project data at rest"):
- `IdbUnlockModal` — prompts for passphrase on cold start when flag is on; rate-limiting: 3 failures → 5 s lockout, 6 failures → 30 s lockout
Expand Down 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` discards 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`, surfacing a one-time re-entry notification. `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: `components/ApiKeySection.tsx` and `services/geminiService.ts` now both route through `storageService`, closing the gap tracked in [#358](https://github.com/qnbs/WorldScript-Studio/issues/358) (that issue can be closed as fixed).
**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`.

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

Expand Down
16 changes: 11 additions & 5 deletions docs/cef/TAURI-COUPLING-INVENTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@
# Real @tauri-apps/* imports (static or dynamic)
rg -l "from ['\"]@tauri-apps|import\(['\"]@tauri-apps" -g '*.ts' -g '*.tsx' -g '!*.test.ts' -g '!*.test.tsx' -g '!tests/**' .

# Files that only check the Tauri-presence globals, with no direct API import
rg -l "__TAURI_INTERNALS__|__TAURI__" -g '*.ts' -g '*.tsx' -g '!*.test.ts' -g '!*.test.tsx' -g '!tests/**' .
# ...then diffed against the first list
# Files that only check Tauri presence, with no direct API import — TWO detection methods, both
# required (the first pass only covered the raw-global check and undercounted; corrected 2026-08-18):
rg -l "isTauriRuntime\(\)" -g '*.ts' -g '*.tsx' -g '!*.test.ts' -g '!*.test.tsx' -g '!tests/**' . # the helper function
rg -l "__TAURI_INTERNALS__|__TAURI__|__TAURI_METADATA__" -g '*.ts' -g '*.tsx' -g '!*.test.ts' -g '!*.test.tsx' -g '!tests/**' . # raw globals (e.g. register-sw.ts)
# ...both diffed against the first list; comment-only matches (e.g. a docstring mentioning
# isTauriRuntime()) manually excluded after inspection.
```

Per-file API breakdown was extracted with `rg -oE "@tauri-apps/[a-zA-Z0-9/_-]*"` on each matched file. Full structured result: [`tauri-coupling-inventory.json`](tauri-coupling-inventory.json).
Expand All @@ -25,7 +28,7 @@ Tauri coupling is **real but not centralized**. `services/tauriRuntime.ts` — t
|---|---|---|
| Direct `@tauri-apps/*` API imports | 16 | see table below |
| Build-time externalization only | 1 | `vite.config.ts` |
| Detection-only (`isTauriRuntime()`/`__TAURI__`, no direct API import) | 5 | `components/settings/AiProviderCard.tsx`, `register-sw.ts`, `services/ai/localAiDeviceProfiler.ts`, `services/aiProviderService.ts`, `services/storageService.ts` |
| Detection-only (`isTauriRuntime()`/`__TAURI__`, no direct API import) | 13 | `components/settings/AiProviderCard.tsx`, `components/settings/DataSection.tsx`, `components/settings/DesktopSection.tsx`, `components/settings/FeatureFlagsSection.tsx`, `components/settings/GeneralSections.tsx`, `hooks/useNativeNotifications.ts`, `register-sw.ts`, `services/ai/localAiDeviceProfiler.ts`, `services/aiProviderService.ts`, `services/appBootstrap.ts`, `services/factoryResetService.ts`, `services/ollamaService.ts`, `services/storageService.ts` |
| Ambient type declarations | 1 | `types/tauri-plugins.d.ts` |

## Direct API coupling by category
Expand Down Expand Up @@ -57,6 +60,9 @@ src-tauri/
├── fuzz/Cargo.toml (filename-sanitization fuzz harness)
├── osv-scanner.toml
├── src/
│ ├── commands/
│ │ ├── mod.rs
│ │ └── task_supervisor.rs (defines worldscript_task_supervisor_ping/submit — registered in lib.rs; active native task-dispatch surface used by services/tauriTaskBridge.ts)
│ ├── lib.rs
│ ├── lora.rs
│ ├── main.rs
Expand All @@ -65,7 +71,7 @@ src-tauri/
└── icons/ (11 image assets, no coupling)
```

4 Rust source files. This is the entire native surface being migrated — small relative to the JS/TS coupling above, but it is where `WS-CEF-*` Rust work (§66 workstream catalogue) eventually lands.
6 Rust source files (corrected 2026-08-18 — the `commands/` module was omitted from the first pass). This is the entire native surface being migrated — small relative to the JS/TS coupling above, but it is where `WS-CEF-*` Rust work (§66 workstream catalogue) eventually lands.

## `package.json`

Expand Down
54 changes: 48 additions & 6 deletions docs/cef/tauri-coupling-inventory.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,27 +124,67 @@
"detectionOnly": [
{
"file": "components/settings/AiProviderCard.tsx",
"detects": "__TAURI_INTERNALS__/__TAURI__ or isTauriRuntime()",
"detects": "isTauriRuntime()",
"note": "no direct @tauri-apps import"
},
{
"file": "components/settings/DataSection.tsx",
"detects": "isTauriRuntime()",
"note": "no direct @tauri-apps import; added 2026-08-18 correction"
},
{
"file": "components/settings/DesktopSection.tsx",
"detects": "isTauriRuntime()",
"note": "no direct @tauri-apps import; added 2026-08-18 correction"
},
{
"file": "components/settings/FeatureFlagsSection.tsx",
"detects": "isTauriRuntime()",
"note": "no direct @tauri-apps import; added 2026-08-18 correction"
},
{
"file": "components/settings/GeneralSections.tsx",
"detects": "isTauriRuntime()",
"note": "no direct @tauri-apps import; added 2026-08-18 correction"
},
{
"file": "hooks/useNativeNotifications.ts",
"detects": "isTauriRuntime()",
"note": "no direct @tauri-apps import; added 2026-08-18 correction"
},
{
"file": "register-sw.ts",
"detects": "__TAURI_INTERNALS__/__TAURI__ or isTauriRuntime()",
"detects": "__TAURI_INTERNALS__/__TAURI__/__TAURI_METADATA__ (raw globals, not the isTauriRuntime() helper)",
"note": "no direct @tauri-apps import"
},
{
"file": "services/ai/localAiDeviceProfiler.ts",
"detects": "__TAURI_INTERNALS__/__TAURI__ or isTauriRuntime()",
"detects": "isTauriRuntime()",
"note": "no direct @tauri-apps import"
},
{
"file": "services/aiProviderService.ts",
"detects": "__TAURI_INTERNALS__/__TAURI__ or isTauriRuntime()",
"detects": "isTauriRuntime()",
"note": "no direct @tauri-apps import"
},
{
"file": "services/appBootstrap.ts",
"detects": "isTauriRuntime()",
"note": "no direct @tauri-apps import; added 2026-08-18 correction"
},
{
"file": "services/factoryResetService.ts",
"detects": "isTauriRuntime()",
"note": "no direct @tauri-apps import; added 2026-08-18 correction"
},
{
"file": "services/ollamaService.ts",
"detects": "isTauriRuntime()",
"note": "no direct @tauri-apps import; added 2026-08-18 correction"
},
{
"file": "services/storageService.ts",
"detects": "__TAURI_INTERNALS__/__TAURI__ or isTauriRuntime()",
"detects": "isTauriRuntime()",
"note": "no direct @tauri-apps import"
}
],
Expand All @@ -169,13 +209,15 @@
"Entitlements.plist",
"fuzz/Cargo.toml",
"osv-scanner.toml",
"src/commands/mod.rs",
"src/commands/task_supervisor.rs",
"src/lib.rs",
"src/lora.rs",
"src/main.rs",
"src/pandoc.rs",
"tauri.conf.json"
],
"note": "icons/ (11 image files) omitted from this list — asset-only, no coupling"
"note": "icons/ (11 image files) omitted from this list — asset-only, no coupling. commands/mod.rs and commands/task_supervisor.rs added 2026-08-18 correction (omitted from first pass; task_supervisor.rs defines worldscript_task_supervisor_ping/submit, registered in lib.rs; used by services/tauriTaskBridge.ts)"
},
"packageJsonScripts": {
"existing": ["tauri", "tauri:dev", "tauri:build", "dev:tauri"],
Expand Down
18 changes: 14 additions & 4 deletions features/project/thunks/characterThunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,22 @@ export const uploadCharacterImageThunk = createAsyncThunk(
async ({ characterId, file }: { characterId: string; file: File }) => {
return new Promise<{ characterId: string }>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = async () => {
// QNBS-v3: onload/onerror/onabort (not onloadend) plus Promise.catch(reject) so every terminal FileReader/saveImage outcome settles this Promise instead of leaving it pending.
reader.onload = () => {
const result = reader.result;
if (typeof result !== 'string') {
reject(new Error('FileReader did not produce a string result'));
return;
}
// QNBS-v3: retain the data-URL MIME type so uploaded JPEG/WebP images survive filesystem round-trips.
await storageService.saveImage(characterId, reader.result as string);
resolve({ characterId });
storageService
.saveImage(characterId, result)
.then(() => resolve({ characterId }))
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
.catch(reject);
};
reader.onerror = reject;
reader.onerror = () =>
reject(reader.error ?? new Error('FileReader failed to read the file'));
reader.onabort = () => reject(new Error('FileReader aborted'));
reader.readAsDataURL(file);
});
},
Expand Down
18 changes: 14 additions & 4 deletions features/project/thunks/worldThunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,22 @@ export const uploadWorldImageThunk = createAsyncThunk(
async ({ worldId, file }: { worldId: string; file: File }) => {
return new Promise<{ worldId: string }>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = async () => {
// QNBS-v3: onload/onerror/onabort (not onloadend) plus Promise.catch(reject) so every terminal FileReader/saveImage outcome settles this Promise instead of leaving it pending.
reader.onload = () => {
const result = reader.result;
if (typeof result !== 'string') {
reject(new Error('FileReader did not produce a string result'));
return;
}
// QNBS-v3: retain the data-URL MIME type so uploaded JPEG/WebP images survive filesystem round-trips.
await storageService.saveImage(worldId, reader.result as string);
resolve({ worldId });
storageService
.saveImage(worldId, result)
.then(() => resolve({ worldId }))
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
.catch(reject);
};
reader.onerror = reject;
reader.onerror = () =>
reject(reader.error ?? new Error('FileReader failed to read the file'));
reader.onabort = () => reject(new Error('FileReader aborted'));
reader.readAsDataURL(file);
});
},
Expand Down
2 changes: 1 addition & 1 deletion features/settings/settingsSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ const getSystemThemePreference = (): Theme => {
return 'dark';
};

// QNBS-v3: no apiKey default here — OpenRouterSettings dropped that field (see types.ts), the real key lives only in the dedicated per-provider key store.
export const DEFAULT_OPENROUTER_SETTINGS: OpenRouterSettings = {
enabled: false,
apiKey: '',
Comment thread
qnbs marked this conversation as resolved.
// QNBS-v3: DeepSeek R1 free tier — strong reasoning + no cost, ideal default (zero friction).
preferredModel: 'deepseek/deepseek-r1:free',
};
Expand Down
Loading
Loading