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
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