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
33 changes: 23 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
<img src="https://img.shields.io/badge/Version-v1.24.3-6366F1" alt="v1.24.3">
<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-2856_keys-0EA5E9" alt="i18n 19 locales — 2856 keys">
<img src="https://img.shields.io/badge/Tests-6477_%2F_529_files-22C55E" alt="6477 tests / 529 files">
<img src="https://img.shields.io/badge/i18n-19_locales-2857_keys-0EA5E9" alt="i18n 19 locales — 2857 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">
<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 @@ -89,7 +89,7 @@ In a world of generic text editors and bloated writing software, WorldScript Stu

- **✍️ From Macro to Micro:** Most tools focus only on writing. We cover the _entire_ creative lifecycle — from high-level plot structure and world-building down to sentence-by-sentence prose refinement.
- **🧠 Intelligent Partnership:** The AI is not a ghostwriter — it's a Socratic partner, a tireless brainstormer, and a creative muse. It's designed to break blocks and expand your own potential, not replace it.
- **🔒 Ultimate Privacy & Ownership:** Your manuscript and project data stay on this device by default (IndexedDB in the browser, or local files in the desktop app). Cloud AI features send only the prompts and context you trigger to the provider you configure. Use local/Ollama/WebLLM mode if you want AI without sending text to any cloud API. There is no WorldScript account — you stay in control of exports and backups.
- **🔒 Ultimate Privacy & Ownership:** Your manuscript and project data stay on this device by default (IndexedDB in the browser, or local files in the desktop app). Cloud AI features send only the prompts and context you trigger to the provider you configure — every provider is called directly from your browser/device, with **one exception**: Claude on the web/PWA (Vercel/Cloudflare Pages) relays through WorldScript's own stateless serverless proxy, since Anthropic blocks direct browser requests (desktop calls Anthropic directly, like every other provider — see [Encryption — which mechanism protects what](#-encryption--which-mechanism-protects-what)). Use local/Ollama/WebLLM mode if you want AI without sending text to any cloud API. There is no WorldScript account — you stay in control of exports and backups.
- **🔬 Built-in Quality Tools:** Go beyond writing with the AI Critic, Plot-Hole Detector, and RAG Consistency Checker — tools that help you catch narrative weaknesses before your readers do.
- **⚡ Browser-Native AI:** A 4-layer local inference stack (WebGPU → ONNX WASM → Transformers.js → heuristics) means local AI works even without Ollama — entirely in-browser, no server, no download manager.

Expand Down Expand Up @@ -337,6 +337,19 @@ different data, with different key material:

See [`docs/SECURITY-THREAT-MODEL.md`](docs/SECURITY-THREAT-MODEL.md) for the full threat-model mapping.

**Claude is the one provider that isn't a direct browser/device→provider call, on the web build
specifically.** Anthropic doesn't send CORS headers permitting direct browser access, so:

- **Desktop (Tauri):** calls `api.anthropic.com` directly via native HTTP — no proxy, same trust
model as every other provider.
- **Web/PWA on Vercel or Cloudflare Pages:** relays through WorldScript's own stateless serverless
proxy (`api/claude-proxy.ts` / `functions/api/claude-proxy.ts`) — your key transits that one
request through infrastructure WorldScript runs, en route to Anthropic. The proxy never logs or
persists the key, prompt, or response (see `docs/SECURITY-THREAT-MODEL.md` § *Claude serverless
proxy trust-model change*).
- **Web/PWA on GitHub Pages:** Claude is unavailable — GitHub Pages is static-only and can't host
the proxy at all. Use the desktop app or a Vercel/Cloudflare Pages deployment instead.

### ⚡ Performance, Analytics & Extensibility

Infrastructure-level features that keep the app fast and extensible as projects grow — all on by default unless noted:
Expand Down Expand Up @@ -383,7 +396,7 @@ Infrastructure-level features that keep the app fast and extensible as projects

### 🌐 Full Multi-Language Support

Shipped UI locales with **2856 i18n keys** across all 19 languages — zero hardcoded user-facing strings:
Shipped UI locales with **2857 i18n keys** across all 19 languages — zero hardcoded user-facing strings:

- 🇩🇪 **German** (Deutsch)
- 🇬🇧 **English**
Expand Down Expand Up @@ -423,7 +436,7 @@ WorldScript Studio supports **9 distinct AI execution paths**, automatically rou
|-------|----------|----------|-------|
| **Cloud 1** | Google Gemini | API key (BYOK) | Primary cloud path; Gemini 2.0 Flash |
| **Cloud 2** | OpenAI | API key (BYOK) | GPT-4o, GPT-4o-mini |
| **Cloud 3** | Anthropic Claude | API key (BYOK) | Claude 3.5 Sonnet |
| **Cloud 3** | Anthropic Claude | API key (BYOK) | Claude Opus 4.7, Sonnet 4.6, Haiku 4.5 — desktop (native) and Vercel/Cloudflare Pages (via serverless proxy); unavailable on GitHub Pages, see [privacy note](#-encryption--which-mechanism-protects-what) |
| **Cloud 4** | Grok (xAI) | API key (BYOK) | grok-3, grok-3-mini |
| **Cloud 5** | **OpenRouter** | Free or paid key | Unified gateway: DeepSeek R1, Llama 3.3 70B, Qwen 2.5 72B + 100s more; `:free` suffix = zero cost |
| **Local 1** | Ollama | Local server | Default model: Qwen3 8B; configurable URL |
Expand Down Expand Up @@ -492,8 +505,8 @@ 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`) | 2856 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu Beta); EN fallback; `localStorage` persistence |
| **Testing** | Vitest 4.x (6477 tests / 529 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **i18n** | Custom React Context (`I18nContext.tsx`) | 2857 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu 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 |
| **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` |
Expand Down Expand Up @@ -531,7 +544,7 @@ WorldScript-Studio/
│ ├── sw.js # PWA Service Worker
│ └── manifest.json # PWA Web App Manifest v3
├── tests/
│ ├── unit/ # Vitest unit tests (6477 tests, 529 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ ├── unit/ # Vitest unit tests (6477+ tests, 532 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 @@ -692,9 +705,9 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
| `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning |

**Current test metrics (2026-07-30, CI-reported):**
- **6477 unit tests** across **529 test files** — all passing
- **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: **2856 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu Beta)
- i18n: **2857 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
182 changes: 182 additions & 0 deletions api/_shared/claudeProxyCore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
// QNBS-v3 (ADR-0016 Track B): platform-agnostic relay core, shared by the Vercel Edge Function
// (api/claude-proxy.ts) and the Cloudflare Pages Function (functions/api/claude-proxy.ts) so the
// abuse-control logic — the actual security-relevant part — is written and tested exactly once.
// Web-standard Request/Response only; no platform-specific types, so it needs neither @vercel/node
// nor @cloudflare/workers-types as a new dependency.
Comment thread
qnbs marked this conversation as resolved.
import { z } from 'zod';

const ANTHROPIC_MESSAGES_URL = 'https://api.anthropic.com/v1/messages';
const ANTHROPIC_VERSION = '2023-06-01';

// QNBS-v3: mirrors the model dropdown in AiProviderCard.tsx (Track A) — keeping the enum in sync
// is a deliberate defense-in-depth constraint, not just laziness: it stops the public endpoint from
// being used to probe/relay requests for arbitrary future Anthropic model ids.
const ALLOWED_MODELS = ['claude-opus-4-7', 'claude-sonnet-4-6', 'claude-haiku-4-5'] as const;

// QNBS-v3: this app only ever sends a single user message (see streamAnthropic in
// aiProviderService.ts) — the small array cap leaves room for a future multi-turn use case without
// letting the body-size/message-count limits do any real abuse-prevention work.
const MAX_MESSAGES = 20;
const MAX_MESSAGE_CHARS = 100_000;
const MAX_BODY_BYTES = 262_144; // 256 KiB
const MAX_TOKENS_CEILING = 8192;
const OUTBOUND_TIMEOUT_MS = 20_000;
const RATE_LIMIT_WINDOW_MS = 60_000;
const RATE_LIMIT_MAX_REQUESTS = 20;
// QNBS-v3: bounds the rate-limit map itself — without this a burst of distinct spoofed
// X-Forwarded-For values would turn the limiter into its own unbounded-memory DoS vector.
const RATE_LIMIT_MAX_TRACKED_CLIENTS = 5000;

const claudeProxyRequestSchema = z.object({
apiKey: z.string().min(20).max(200),
model: z.enum(ALLOWED_MODELS),
maxTokens: z.number().int().positive().max(MAX_TOKENS_CEILING).optional(),
messages: z
.array(
z.object({
role: z.enum(['user', 'assistant']),
content: z.string().min(1).max(MAX_MESSAGE_CHARS),
}),
)
.min(1)
.max(MAX_MESSAGES),
});

// QNBS-v3: module-level so it survives across invocations on a warm edge/worker instance — a
// best-effort, single-instance limiter (explicitly accepted in the plan/ADR: true distributed rate
// limiting needs a platform KV/rate-limit product, which this app does not depend on).
const rateLimitLog = new Map<string, number[]>();

function isRateLimited(clientId: string): boolean {
const now = Date.now();
const recent = (rateLimitLog.get(clientId) ?? []).filter(
(timestamp) => now - timestamp < RATE_LIMIT_WINDOW_MS,
);
recent.push(now);
rateLimitLog.set(clientId, recent);
// QNBS-v3 (CodeRabbit, PR #301): a spoofed x-forwarded-for burst must not be able to reset every
// real client's window via a wholesale .clear() (CWE-770) — evict stale entries first, then oldest
// by insertion order, and never evict the current client.
if (rateLimitLog.size > RATE_LIMIT_MAX_TRACKED_CLIENTS) {
for (const [id, stamps] of rateLimitLog) {
if (id !== clientId && stamps.every((t) => now - t >= RATE_LIMIT_WINDOW_MS)) {
rateLimitLog.delete(id);
}
}
for (const id of rateLimitLog.keys()) {
if (rateLimitLog.size <= RATE_LIMIT_MAX_TRACKED_CLIENTS) break;
if (id !== clientId) rateLimitLog.delete(id);
}
}
return recent.length > RATE_LIMIT_MAX_REQUESTS;
}
Comment thread
qnbs marked this conversation as resolved.

function clientIdFor(request: Request): string {
const forwardedFor = request.headers.get('x-forwarded-for');
const first = forwardedFor?.split(',')[0]?.trim();
return first && first.length > 0 ? first : 'unknown';
}

function isSameOriginRequest(request: Request): boolean {
const origin = request.headers.get('origin');
if (!origin) return false;
try {
return new URL(origin).host === new URL(request.url).host;
} catch {
return false;
}
}

function jsonResponse(status: number, body: Record<string, unknown>): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
'content-type': 'application/json',
// QNBS-v3: this is a stateless per-request relay of user-specific, key-authenticated content
// — it must never be cached by any CDN/browser layer.
'cache-control': 'no-store',
},
});
}

/**
* Relays a validated `{ apiKey, model, messages, maxTokens? }` body to the Anthropic Messages API
* and returns Anthropic's own JSON response (or a proxy-level error) unmodified, so client-side
* parsing is identical whether it called Anthropic directly (Track A, desktop) or via this proxy
* (Track B, web). Never logs the API key, prompt, or response — see ADR-0016 statelessness guarantee.
*/
export async function handleClaudeProxyRequest(
request: Request,
fetchImpl: typeof fetch = fetch,
): Promise<Response> {
if (request.method !== 'POST') {
return jsonResponse(405, { error: 'Method not allowed' });
}

if (!isSameOriginRequest(request)) {
return jsonResponse(403, { error: 'Origin not allowed' });
}

const clientId = clientIdFor(request);
if (isRateLimited(clientId)) {
return jsonResponse(429, { error: 'Rate limit exceeded' });
}

const contentLength = Number(request.headers.get('content-length') ?? '0');
if (contentLength > MAX_BODY_BYTES) {
return jsonResponse(413, { error: 'Request body too large' });
}

let rawBody: string;
try {
rawBody = await request.text();
} catch {
return jsonResponse(400, { error: 'Could not read request body' });
}
if (rawBody.length > MAX_BODY_BYTES) {
return jsonResponse(413, { error: 'Request body too large' });
}

let parsedBody: unknown;
try {
parsedBody = JSON.parse(rawBody);
} catch {
return jsonResponse(400, { error: 'Invalid JSON body' });
}

const validated = claudeProxyRequestSchema.safeParse(parsedBody);
if (!validated.success) {
return jsonResponse(400, { error: 'Request body failed validation' });
}
const { apiKey, model, messages, maxTokens } = validated.data;

try {
const upstreamResponse = await fetchImpl(ANTHROPIC_MESSAGES_URL, {
method: 'POST',
headers: {
'x-api-key': apiKey,
'anthropic-version': ANTHROPIC_VERSION,
'content-type': 'application/json',
},
body: JSON.stringify({
model,
max_tokens: maxTokens ?? 2048,
messages,
}),
signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
});
const upstreamText = await upstreamResponse.text();
return new Response(upstreamText, {
status: upstreamResponse.status,
headers: {
'content-type': upstreamResponse.headers.get('content-type') ?? 'application/json',
'cache-control': 'no-store',
},
});
} catch (err) {
const isTimeout = err instanceof Error && err.name === 'TimeoutError';
return jsonResponse(isTimeout ? 504 : 502, {
error: isTimeout ? 'Upstream request timed out' : 'Upstream request failed',
});
}
}
12 changes: 12 additions & 0 deletions api/claude-proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// QNBS-v3 (ADR-0016 Track B): Vercel Edge Function entry point. All abuse-control and relay logic
// lives in ./_shared/claudeProxyCore.ts (shared with the Cloudflare Pages equivalent in
// functions/api/claude-proxy.ts) — this file only adapts the platform's runtime config + handler shape.
// Edge (not Node) runtime: no new dependency (@vercel/node) needed, since the handler is written
// against the Web-standard Request/Response types already available via the "DOM" lib.
import { handleClaudeProxyRequest } from './_shared/claudeProxyCore';

export const config = { runtime: 'edge' };

export default function handler(request: Request): Promise<Response> {
return handleClaudeProxyRequest(request);
}
Loading
Loading