diff --git a/README.md b/README.md index 667d2d3de..40857156d 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ v1.24.3 IndexedDB v8 PWA v3.0 - i18n 19 locales — 2856 keys - 6477 tests / 529 files + i18n 19 locales — 2857 keys + 6477+ tests / 532 files Codecov Coverage License MIT CI Status @@ -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. @@ -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: @@ -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** @@ -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 | @@ -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` | @@ -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 @@ -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. diff --git a/api/_shared/claudeProxyCore.ts b/api/_shared/claudeProxyCore.ts new file mode 100644 index 000000000..b740eec66 --- /dev/null +++ b/api/_shared/claudeProxyCore.ts @@ -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. +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(); + +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; +} + +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): 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 { + 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', + }); + } +} diff --git a/api/claude-proxy.ts b/api/claude-proxy.ts new file mode 100644 index 000000000..56cdcdc65 --- /dev/null +++ b/api/claude-proxy.ts @@ -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 { + return handleClaudeProxyRequest(request); +} diff --git a/components/settings/AiProviderCard.tsx b/components/settings/AiProviderCard.tsx index 83819ad56..8b2aafcd2 100644 --- a/components/settings/AiProviderCard.tsx +++ b/components/settings/AiProviderCard.tsx @@ -11,6 +11,7 @@ import { scanLocalOpenAiCompatibleEndpoints, testAIConnection, } from '../../services/aiProviderService'; +import { isServerlessProxyCapable } from '../../services/deployTarget'; import { storageService } from '../../services/storageService'; import { isTauriRuntime } from '../../services/tauriRuntime'; import type { AdvancedAiSettings, AIProvider, LocalBackendPreset } from '../../types'; @@ -20,6 +21,7 @@ import { Icon } from '../ui/Icon'; import { Input } from '../ui/Input'; import { Select } from '../ui/Select'; import { Spinner } from '../ui/Spinner'; +import { AnthropicProviderFields } from './AnthropicProviderFields'; interface AiProviderCardProps { advancedAi: AdvancedAiSettings; @@ -40,6 +42,9 @@ export const AiProviderCard: FC = ({ // QNBS-v3 (T0): canonical detection (`__TAURI_INTERNALS__`-aware); `__TAURI__` alone read as web // in the real desktop shell, hiding desktop-only provider affordances. const isDesktop = isTauriRuntime(); + // QNBS-v3 (ADR-0016 Track B): web/PWA Claude support depends on api/claude-proxy existing on the + // deployment — Vercel/Cloudflare Pages can host it, GitHub Pages (static-only) never can. + const isAnthropicProxyCapableWeb = !isDesktop && isServerlessProxyCapable(); // QNBS-v3: for Ollama in a browser, the auto-test effect and the manual "Test connection" // button are both disabled (see below) — testStatus can never leave 'idle' here, so the // generic status badge must not render its idle→"Ready" label, which would misleadingly @@ -665,62 +670,19 @@ export const AiProviderCard: FC = ({ )} - {/* QNBS-v3 (ADR-0016 Track A): desktop bypasses Anthropic's CORS restriction via the - native-HTTP path (localServerFetch) — real key input instead of the warning-only - block web/PWA still needs until Track B's proxy ships. */} - {provider === 'anthropic' && isDesktop && ( -
- -
- setAnthropicKey(e.target.value)} - className="flex-1 font-mono text-sm" - /> - -
-

{t('settings.ai.keysEncrypted')}

- - onAnthropicKeyChange(e.target.value)} + className="flex-1 font-mono text-sm" + /> + +
+

{t('settings.ai.keysEncrypted')}

+ {isProxyCapableWeb && ( +

{t('settings.ai.anthropicProxyNote')}

+ )} + +