Skip to content

feat(ai): Claude serverless proxy for web/PWA (ADR-0016 Track B) - #301

Merged
qnbs merged 12 commits into
mainfrom
feat/claude-track-b-web-proxy
Jul 30, 2026
Merged

feat(ai): Claude serverless proxy for web/PWA (ADR-0016 Track B)#301
qnbs merged 12 commits into
mainfrom
feat/claude-track-b-web-proxy

Conversation

@qnbs

@qnbs qnbs commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Summary

PR 3 of 4 in the stacked sequence (stacked on #300 — Claude Track A). Auto-retargets once #300 merges into #299's branch, then again once #299 merges into main.

Browser tabs have no native-HTTP CORS escape hatch, so Anthropic requests from Vercel/Cloudflare Pages deployments now relay through this app's own stateless serverless proxy — its first backend dependency ever.

  • api/_shared/claudeProxyCore.ts: platform-agnostic relay core. Mandatory abuse controls (the endpoint is public/unauthenticated by construction — CWE-400): Zod schema validation, 256 KiB body-size cap (checked against both the Content-Length header and the actual body), same-origin check, per-client rate limit (20 req/60s), 20s outbound timeout. Never logs the key/prompt/response — asserted directly in tests.
  • api/claude-proxy.ts (Vercel Edge Function) + functions/api/claude-proxy.ts (Cloudflare Pages Function) — thin wrappers around the shared core.
  • services/deployTarget.ts: isServerlessProxyCapable() — reuses import.meta.env.BASE_URL (already this codebase's build-time GitHub-Pages-vs-edge marker) to detect whether the proxy can exist on the current deployment.
  • streamAnthropic() / testAIConnection get a three-way split: desktop (Track A, unchanged) / proxy-capable web (fetches /api/claude-proxy) / GitHub Pages (throws immediately, before checking for a key).
  • AiProviderCard.tsx: extracted the anthropic UI into AnthropicProviderFields.tsx (kept the parent under Biome's cognitive-complexity gate). Desktop and proxy-capable web render the same real key + model UI; only GitHub Pages keeps a warning block, reworded from a generic CORS note to the actual reason.
  • docs/SECURITY-THREAT-MODEL.md: new section — this is the one provider whose BYOK key transits infrastructure WorldScript runs, stated plainly.

Test plan

  • pnpm run lint / pnpm run typecheck — clean
  • pnpm run i18n:check — 2857 keys
  • node scripts/audit-tokens.mjs — 160 ≤ baseline
  • claudeProxyCore.test.ts (14), claudeProxyEntrypoints.test.ts (3), deployTarget.test.ts (3), aiProviderService.test.ts, AiProviderCard.test.tsx — 126 tests green
  • CI — awaiting push

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Claude is now available in supported web deployments through a secure serverless relay.
    • Added clearer Anthropic settings with model selection, API key management, and deployment-specific availability guidance.
    • Desktop installations continue to support direct Claude connections.
  • Bug Fixes

    • Improved Claude response handling and connection testing across desktop, web, and static deployments.
    • Added clearer messaging when Claude is unavailable on static-only hosting.
  • Documentation

    • Updated security, privacy, routing, provider availability, and project metrics documentation.
  • Tests

    • Expanded coverage for proxy security, deployment behavior, connection testing, and localized settings.

qnbs added 6 commits July 30, 2026 21:40
Four findings deferred from PR #297 to keep that PR's merge unblocked,
addressed here first per explicit instruction before starting the
Grok/Claude/Ollama plan execution:

1. GROK-PROVIDER-INTEGRATION-PLAN.md: the addendum was numbered "## 7"
   but its own subsections said "8.1"-"8.4", and a "§§1-7 above"
   self-reference included the addendum itself. Renumbered to 7.1-7.4,
   fixed both self-references, and fixed a second pre-existing
   ambiguous "§5" cross-reference (meant "item 5 within Phase 1's own
   list", not top-level section 5) while in the area.

2. tests/unit/listenerMiddleware.test.ts: added the required QNBS-v3
   comment next to the new featureFlagsActions import.

3. Added the required QNBS-v3 comment before the new "local-first
   shadow sync" describe block.

4. Rewrote the local-first sync test to assert the actual
   selector-to-binding boundary directly instead of inferring success
   from an absence of logger calls in an unmocked-dependency,
   environment-dependent setup. Added real mocks for
   services/localFirst/{projectDoc,docBinding,docPersistence} and
   services/storage/storageEncryptionService so the test can assert
   the exact object selectProjectData(state) returns is what reaches
   ProjectDocBinding's constructor and syncFromProject/verify.

   Hit two real bugs while building this: (a) an arrow-function mock
   implementation can never be used as a constructor -- switched to a
   real class, which Biome's useArrowFunction rule doesn't flag either
   (a function-expression fix would have been immediately reverted by
   the same linter that was satisfied by removing the arrow function
   in the first place); (b) vi.fn().mockImplementation() can't type a
   class constructor against its generic (...args) => any signature --
   mocked the class directly instead of wrapping it in vi.fn().
Re-checked the plan's own findings against current main before starting
implementation (Phase 0's explicit purpose), and found the original
draft's Claude analysis was incomplete in a way that changes the whole
approach:

- AiProviderCard.tsx's primary provider dropdown already has an
  'anthropic' entry with a dedicated warning block -- missed originally
  because the investigation searched for the literal string "claude",
  never "anthropic" (the actual identifier), when checking that file.
  Its existing i18n copy already half-promises desktop support
  ("...or use the Tauri desktop app for Anthropic calls").
- streamAnthropic() throws unconditionally on EVERY platform, including
  desktop -- but CORS is a browser-only restriction. It never even
  checks isTauriRuntime() before throwing. Tauri's native HTTP plugin
  (@tauri-apps/plugin-http, services/localServerHttp.ts) already solves
  exactly this class of problem for Ollama/LM Studio/vLLM (ADR-0012);
  the same escape hatch works for any HTTPS endpoint, including
  api.anthropic.com, since native networking isn't subject to browser
  CORS at all.

Restructured Phase 2 into two independent tracks: Track A (desktop) is
a narrow bug fix reusing ADR-0012's established pattern, no new
infrastructure, ships first. Track B (web/PWA) is the actual new
architecture -- the serverless proxy -- and is now correctly scoped to
only the three web deploy targets (desktop no longer needs it at all).
Updated the Executive Summary, both Definition of Done checklists, and
all internal cross-references accordingly.
Formalizes GROK-PROVIDER-INTEGRATION-PLAN.md's Phase 0 decision record:
Grok is a pure UI-wiring fix (backend already works); Claude splits
into Track A (desktop, native-HTTP via the ADR-0012 pattern, ships
first, no new infrastructure) and Track B (web/PWA, this app's first
serverless backend dependency, ships second). Also documents why the
Ollama-in-PWA follow-up (Issue #266) is a separate decision, not a
Track-B variant -- a hosted proxy can't reach a user's own localhost.
Grok (xAI, Cloud 4) had a real, working backend (aiProviderService.ts's
streamGrok(), key storage, a live /v1/models connection test) but was
never selectable as a primary provider -- reachable only as a hybrid-
fallback-chain option. This closes that UI/wiring gap; no new backend
work needed.

- AiProviderCard.tsx: added Grok to the primary provider dropdown with
  its own API-key input + model selector (grok-3 / grok-3-mini),
  reusing the existing storageService key-storage plumbing and the
  already-working testAIConnection() grok case for Test Connection.
  Also fixed the whole provider array's i18n while touching it --
  gemini/openai/ollama/anthropic labels were hardcoded literals too,
  not just Grok's gap.
- providerFactory.ts: providerToKind() gets a distinct 'grok' kind
  (not folded into 'openaiCompatible') -- Grok needs a fixed baseURL
  (https://api.x.ai/v1) and a real stored key, unlike the Ollama
  default that also maps to 'openaiCompatible'; folding it in would
  have silently sent Grok requests through Ollama's localhost default.
- worldScriptCompletionFetch.ts: resolveModelConfig() branches on the
  new 'grok' kind, bringing Grok into the newer Writer-streaming path
  (useWorldScriptAI), not just legacy thunks.
- i18n: added settings.ai.grokKey + settings.ai.provider.{gemini,
  openai,ollama,anthropic,grok} across all 19 locales -- hand-
  translated for the 5 production locales (de/en/es/fr/it), EN
  fallback seeded for the other 14 pending the established bulk-
  translate workflow. Bundles rebuilt; README's key-count badge/table
  updated 2849 -> 2855.
- Tests: providerFactory (grok maps to 'grok', not 'unsupported'),
  worldScriptCompletionFetch (grok success path + missing-key 401),
  AiProviderCard (renders key input + model selector, saves via
  storageService.saveApiKey('grok', ...)).

No CSP change needed: https://api.x.ai is already in
src-tauri/tauri.conf.json, and the web CSP's https: scheme-source
(ADR-0004) already covers it. No new feature flag -- confirmed via
`pnpm exec tsx scripts/audit-feature-parity.ts` (22 flags, 0 drifts).

The many locales/*/{characters,common,dashboard,desktop,export,
outline,worlds,writer}.json changes in this commit are incidental --
running the i18n tooling's --fix pass re-sorted keys in files it
touched along the way. No content was added, removed, or changed in
any of those files, only key order.
Claude/Anthropic's streamAnthropic() threw unconditionally on every
platform, including desktop -- but CORS is a browser-only restriction.
Tauri's native HTTP plugin (localServerFetch, ADR-0012) already exists
in this codebase to bypass exactly this class of problem for Ollama;
the same escape hatch works for any HTTPS endpoint, since native
networking isn't subject to browser CORS at all.

- aiProviderService.ts: streamAnthropic() branches on isTauriRuntime()
  before throwing -- desktop calls https://api.anthropic.com/v1/messages
  directly via localServerFetch (Messages API format: x-api-key +
  anthropic-version headers). generateTextSingleProvider()'s anthropic
  case now delegates to the fixed streamAnthropic instead of its own
  separate unconditional throw. testAIConnection()'s anthropic case
  gets the same branch, using a minimal (max_tokens: 1) real request as
  the connectivity check since Anthropic has no public /v1/models
  endpoint to probe. Image generation is deliberately left throwing --
  Anthropic's API doesn't offer that endpoint at all, so this isn't a
  CORS bug to fix.
- src-tauri/tauri.conf.json: added https://api.anthropic.com to the
  CSP connect-src (mirrors the existing Grok entry).
- AiProviderCard.tsx: the existing 'anthropic' warning-only block is
  now desktop-conditional -- desktop renders a real API-key input +
  model selector (Opus/Sonnet/Haiku 4.x) exactly like every other
  cloud provider; web/PWA keeps the warning, with copy updated to say
  desktop now genuinely works rather than "might" (Track B's proxy
  note stays "coming soon", since that part isn't built yet).
- i18n: added settings.ai.anthropicKey; revised anthropicCorsNote/
  anthropicHint wording across the 5 production locales to reflect
  desktop actually working now; EN fallback seeded for the other 14.
  2855 -> 2856 keys; README badge/table updated.
- Tests: aiProviderService (testAIConnection + streamText desktop
  success/failure/no-key paths, web-path unchanged), AiProviderCard
  (desktop shows key input, web shows warning, key save wired to
  storageService.saveApiKey('anthropic', ...)).

Track B (the web/PWA serverless proxy) is unaffected by this commit --
still not built, still the only path for browser-tab Claude.
Track A (previous commit) fixed Claude on desktop. This closes the
other half: browser tabs have no native-HTTP escape hatch from CORS,
so Anthropic requests from Vercel/Cloudflare Pages deployments now
relay through this app's own stateless serverless proxy -- its first
backend dependency ever (previously a purely static SPA + a desktop
bundle with no server of its own).

- api/_shared/claudeProxyCore.ts: platform-agnostic relay core (Web
  Request/Response only, no @vercel/node or @cloudflare/workers-types
  dependency). Forwards { apiKey, model, messages, maxTokens? } to
  Anthropic's Messages API and returns the response unmodified. Mandatory
  abuse controls per ADR-0016 (the endpoint is public and unauthenticated
  by construction -- CWE-400 surface): Zod schema validation, a 256 KiB
  body-size cap checked against both the Content-Length header and the
  actual body (defeats a spoofed header), a same-origin check (Origin
  must match the deployment's own host), a best-effort per-client-IP
  rate limit (20 req/60s, in-memory -- explicitly not distributed, see
  code comment), and a 20s outbound timeout to Anthropic. Never logs the
  key, prompt, or response on any path -- asserted directly in tests via
  console spies across every branch.
- api/claude-proxy.ts: Vercel Edge Function entry point (thin wrapper).
- functions/api/claude-proxy.ts: Cloudflare Pages Function equivalent,
  at the matching /api/claude-proxy route (Cloudflare has no automatic
  "/api" prefix the way some platforms do -- the path has to earn it).
- services/deployTarget.ts: isServerlessProxyCapable() -- reuses
  import.meta.env.BASE_URL (already this codebase's build-time
  GitHub-Pages-vs-edge marker, see config/resolveViteBase.ts) to detect
  whether the current web build can host the proxy at all. GitHub Pages
  is static-only and never can.
- services/aiProviderService.ts: streamAnthropic() now has three
  branches -- desktop (Track A, unchanged), proxy-capable web (fetches
  /api/claude-proxy), and GitHub Pages (throws immediately, before ever
  checking for a key, since no key would help). testAIConnection's
  anthropic case gets the same three-way split. Retired the
  'backendProxyRequired' TestConnectionErrorKind (nothing can produce it
  anymore) in favor of 'proxyUnavailableStaticHost'.
- AiProviderCard.tsx: extracted the whole anthropic UI block into
  components/settings/AnthropicProviderFields.tsx -- both to keep
  AiProviderCard under the Biome cognitive-complexity gate (52 > 50
  before the split) and under CLAUDE.md's 700-line file-size target.
  Desktop and proxy-capable web now render the same real key + model
  UI every other cloud provider gets; only GitHub Pages keeps the
  warning block, with copy rewritten from a generic CORS note to the
  actual "no proxy host" reason.
- i18n: settings.ai.anthropicProxyNote (new); anthropicCorsNote/
  anthropicHint/corsRestriction copy rewritten (desktop and Vercel/CF
  no longer say "coming soon" -- they work); testError.
  backendProxyRequired removed, testError.proxyUnavailableStaticHost
  added. Hand-translated for the 5 production locales; EN fallback
  seeded for the other 14. 2856 -> 2857 keys; README badge/table/count
  updated in all 4 places.
- docs/SECURITY-THREAT-MODEL.md: new "Claude serverless proxy
  trust-model change" section, an Information Disclosure row, a Denial
  of Service row (the abuse-control list), a Mitigation Mapping row,
  and a checklist item -- this is the first provider whose BYOK key
  transits infrastructure WorldScript runs, and that's stated plainly
  rather than folded into the general BYOK framing.
- README.md: privacy bullet and the encryption section both carry the
  same caveat; the Cloud 3 provider-stack row lists the actual model
  ids (Opus 4.7/Sonnet 4.6/Haiku 4.5, not the stale "Claude 3.5
  Sonnet") and where Claude does/doesn't work.
- docs/adr/0016: documents why providerFactory.ts's Vercel-AI-SDK layer
  still returns 'unsupported' for anthropic (would need the
  @ai-sdk/anthropic package -- Anthropic's Messages API isn't
  OpenAI-Chat-Completions-shaped like Grok/Ollama are, so that's a new
  runtime dependency out of scope here) and why no dedicated E2E spec
  was added (no other cloud provider -- including Grok -- has one in
  this repo; the RTL/unit level is where this class of Settings-form
  coverage already lives).

Tests: 14 new (claudeProxyCore: schema/size/origin/rate-limit/timeout/
relay-fidelity/no-logging), 3 new (Vercel + Cloudflare entry-point
delegation), 3 new (deployTarget branch coverage), aiProviderService's
Anthropic describe block rewritten for the three-way split,
AiProviderCard's anthropic describe block rewritten with a new
proxy-capable-web case. 126 tests green across the full Track B
surface; lint, typecheck, i18n:check, docs:check, and parity:check
(22 flags, 0 drifts -- no new flag needed) all clean.
@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worldscript-studio Ready Ready Preview Jul 30, 2026 10:38pm

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 25 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 72d2dd0e-d90e-42af-b110-72d08e8d444b

📥 Commits

Reviewing files that changed from the base of the PR and between ab1ddd7 and 4f93f9b.

📒 Files selected for processing (4)
  • api/_shared/claudeProxyCore.ts
  • api/claude-proxy.ts
  • components/settings/AnthropicProviderFields.tsx
  • tests/unit/api/claudeProxyCore.test.ts
📝 Walkthrough

Walkthrough

Claude support now routes desktop requests directly to Anthropic and browser requests through validated serverless proxy endpoints, with static-host detection, capability-aware settings UI, localized availability messaging, security documentation, and expanded unit coverage.

Changes

Claude proxy routing

Layer / File(s) Summary
Validated proxy relay
api/_shared/claudeProxyCore.ts, api/claude-proxy.ts, functions/api/claude-proxy.ts, tests/unit/api/*
Adds shared request validation, origin checks, rate limiting, size limits, timeout handling, Anthropic relay behavior, and Vercel/Cloudflare adapters with tests.
Runtime-aware provider flow
services/aiProviderService.ts, services/deployTarget.ts, tests/unit/aiProviderService.test.ts, tests/unit/deployTarget.test.ts
Routes Tauri calls directly, browser calls through /api/claude-proxy, and static GitHub Pages deployments to a dedicated unavailable result.
Deployment-aware settings UI
components/settings/AiProviderCard.tsx, components/settings/AnthropicProviderFields.tsx, tests/unit/settings/AiProviderCard.test.tsx
Renders Anthropic controls or deployment warnings based on desktop and proxy capability, including proxy notes and model selection.
Localized messaging
locales/*/settings.json, public/locales/*/bundle.json
Adds proxy explanations and replaces the generic backend-proxy error with static-host proxy-unavailability messaging.
Documentation and metrics
README.md, docs/SECURITY-THREAT-MODEL.md, docs/adr/0016-native-grok-and-claude-providers.md
Documents the Claude trust model, abuse controls, resolved ADR decisions, deployment limits, and refreshed project metrics.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant AiProviderService
  participant claudeProxy
  participant Anthropic
  User->>AiProviderService: Request Claude completion
  AiProviderService->>claudeProxy: Send proxy request on supported web deployment
  claudeProxy->>Anthropic: Forward validated Messages API payload
  Anthropic-->>claudeProxy: Return response
  claudeProxy-->>AiProviderService: Return no-store response
  AiProviderService-->>User: Stream Claude text
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 36.36% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding a Claude serverless proxy for web/PWA as ADR-0016 Track B.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/claude-track-b-web-proxy

Comment @coderabbitai help to get the list of available commands.

Add missing single-line QNBS-v3 rationale comments (Grok key state,
key-persistence handler, the openaiCompatible-reuse decision in
worldScriptCompletionFetch.ts, two test fixtures/blocks) and collapse
four multi-line QNBS-v3 comments to the required single line.

Not addressed here (documented, not fixed): the ADR-0016 wording
describing Claude desktop as immediately usable is accurate once the
full stacked sequence merges (Track A is PR #300, already stacked next)
and will read correctly at that point without further edits; the
locale-translation findings for settings.ai.grokKey reflect this repo's
established i18n tiering (5 production locales get hand translations,
14 beta locales intentionally carry an EN fallback per CLAUDE.md's own
i18n section), not a defect; the vi.mock hoisting concern in
listenerMiddleware.test.ts is a false positive verified against the
actual passing suite -- ProjectDocBinding is consumed via a dynamic
await import() inside test bodies, not a static top-level import, so
by the time any mock factory runs the module has already evaluated
top-to-bottom once.
@qnbs

qnbs commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Base automatically changed from feat/claude-track-a-desktop to main July 30, 2026 21:31
@codeant-ai

codeant-ai Bot commented Jul 30, 2026

Copy link
Copy Markdown

🏁 CodeAnt Quality Gate Results

Commit: ab1ddd71
Scan Time: 2026-07-30 22:15:11 UTC

✅ Overall Status: PASSED

Quality Gate Details

Quality Gate Status Details
Secrets ✅ PASSED 0 secrets found
Duplicate Code ✅ PASSED 0.4% duplicated
SAST ✅ PASSED No security issues
Bugs ✅ PASSED Rating S: No bugs
IAC ✅ PASSED Rating S: No issues

View Full Results

…eb-proxy

# Conflicts:
#	README.md
#	components/settings/AiProviderCard.tsx
#	docs/adr/0016-native-grok-and-claude-providers.md
#	locales/ar/settings.json
#	locales/de/settings.json
#	locales/el/settings.json
#	locales/en/settings.json
#	locales/es/settings.json
#	locales/eu/settings.json
#	locales/fa/settings.json
#	locales/fi/settings.json
#	locales/fr/settings.json
#	locales/he/settings.json
#	locales/hu/settings.json
#	locales/is/settings.json
#	locales/it/settings.json
#	locales/ja/settings.json
#	locales/ko/settings.json
#	locales/pt/settings.json
#	locales/ru/settings.json
#	locales/sv/settings.json
#	locales/zh/settings.json
#	public/locales/ar/bundle.json
#	public/locales/de/bundle.json
#	public/locales/el/bundle.json
#	public/locales/en/bundle.json
#	public/locales/es/bundle.json
#	public/locales/eu/bundle.json
#	public/locales/fa/bundle.json
#	public/locales/fi/bundle.json
#	public/locales/fr/bundle.json
#	public/locales/he/bundle.json
#	public/locales/hu/bundle.json
#	public/locales/is/bundle.json
#	public/locales/it/bundle.json
#	public/locales/ja/bundle.json
#	public/locales/ko/bundle.json
#	public/locales/pt/bundle.json
#	public/locales/ru/bundle.json
#	public/locales/sv/bundle.json
#	public/locales/zh/bundle.json
#	services/aiProviderService.ts
#	tests/unit/aiProviderService.test.ts
#	tests/unit/settings/AiProviderCard.test.tsx
@qnbs

qnbs commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@codecov

codecov Bot commented Jul 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.87097% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
services/aiProviderService.ts 78.94% 0 Missing and 4 partials ⚠️
components/settings/AnthropicProviderFields.tsx 88.88% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

…ects

PR #301's Build, E2E Tests, and E2E Deep Coverage jobs all failed at the
same root cause: pnpm run build's predev/prebuild sync step invokes
sync-readme-metrics.mjs, which hard-fails via its own drift guard when a
metric occurrence in README.md doesn't match the real, computed value.

Four README occurrences read "6477 tests / 529 files" (no "+"), while
every regex the script uses -- both its targeted replacements and the
drift guard's generic check -- expects the "6477+ tests" convention the
rest of the file already follows. Because the file count only changed
NOW (529 -> 532, from adding claudeProxyCore.test.ts,
claudeProxyEntrypoints.test.ts, and deployTarget.test.ts across Track B
and the Ollama addendum), the guard fired for the first time -- the "+"-
less phrasing had been silently tolerated as long as the numeric value
happened to still match.

Restored the "+" suffix (this repo's actual, already-established
convention for a fast-changing count, not a new one) rather than adding
a parallel no-"+" regex path to the script -- the script's convention
was correct; my own earlier edit was the drift. Running the script now
auto-updates 529 -> 532 and is idempotent on a second run.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (4)
docs/SECURITY-THREAT-MODEL.md (1)

212-220: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Security Misconfiguration (CWE-346): Origin Validation Error

Reachability: External

Consider qualifying the same-origin control.

The Origin check only constrains browser-driven callers; any non-browser client can set the header freely, so it is not an authentication boundary for scripted abuse (the rate limit and body caps are what bound that case). A one-clause caveat keeps the threat model from being read as stronger than it is.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/SECURITY-THREAT-MODEL.md` around lines 212 - 220, Qualify the
same-origin check in the “Abuse controls” description to state that it
constrains browser-driven callers but is not an authentication boundary for
non-browser clients, which can freely set the Origin header; retain the existing
rate-limit and body-size controls as the protections for scripted abuse.
api/_shared/claudeProxyCore.ts (1)

114-127: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

rawBody.length counts UTF-16 units, not bytes.

The post-read cap therefore admits bodies well over 256 KiB of actual payload for multi-byte content (the Content-Length header path is bytes, the fallback path is not). Using an encoded byte length keeps both checks on the same unit.

♻️ Align the fallback check with byte length
-  if (rawBody.length > MAX_BODY_BYTES) {
+  if (new TextEncoder().encode(rawBody).length > MAX_BODY_BYTES) {
     return jsonResponse(413, { error: 'Request body too large' });
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/_shared/claudeProxyCore.ts` around lines 114 - 127, Update the post-read
size check in the request-body handling flow to measure rawBody’s encoded byte
length rather than UTF-16 string length, keeping it consistent with the
content-length header check and the existing MAX_BODY_BYTES limit.
tests/unit/api/claudeProxyCore.test.ts (1)

40-46: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

% 255 wraps and shares rate-limit buckets once the suite grows.

The limiter map is module-level, so a wrapped IP would inherit a previous test's window. A wider address space (or a per-test unique suffix) removes that future flake.

♻️ Wider unique client id space
 function uniqueIp(): string {
   ipCounter += 1;
-  return `203.0.113.${ipCounter % 255}`;
+  return `198.51.100.${Math.floor(ipCounter / 254) % 254}.${ipCounter % 254}`.replace(
+    /^(\d+\.\d+\.\d+)\.(\d+)\.(\d+)$/,
+    '$1.$3',
+  );
 }

Simpler alternative: append a per-test counter to a synthetic id such as client-${ipCounter}, since the handler only uses the value as a map key.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/api/claudeProxyCore.test.ts` around lines 40 - 46, Update
uniqueIp() so generated client identifiers never repeat as the test suite grows;
remove the `% 255` wrapping and use a wider synthetic value or a monotonically
increasing per-test suffix while preserving its use as the x-forwarded-for
rate-limit key.
services/aiProviderService.ts (1)

306-317: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add a timeout to the Claude proxy request.
fetch('/api/claude-proxy') only forwards opts.signal, so a stalled same-origin call can leave the stream hanging. Merge the caller signal with AbortSignal.timeout(...) here, as the other request paths do.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/aiProviderService.ts` around lines 306 - 317, Update the Claude
proxy request in the fetch call to combine opts.signal with an
AbortSignal.timeout(...) signal, using the same timeout-merging pattern as the
other request paths. Preserve caller cancellation while ensuring stalled
requests are automatically aborted before deliverAnthropicResponse processes the
response.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@api/_shared/claudeProxyCore.ts`:
- Around line 1-5: Update the header cross-reference in
api/_shared/claudeProxyCore.ts lines 1-5 to use functions/api/claude-proxy.ts,
and make the same documentation-only path correction in api/claude-proxy.ts
lines 1-5. No code behavior changes are required.
- Around line 50-61: Update isRateLimited so exceeding
RATE_LIMIT_MAX_TRACKED_CLIENTS evicts only the least-recently-seen client
entries rather than calling rateLimitLog.clear(). Preserve active clients’
timestamp windows while keeping the map bounded, using each client’s most recent
timestamp to identify eviction candidates.

In `@components/settings/AnthropicProviderFields.tsx`:
- Around line 20-26: Add an immediately adjacent single-line `// QNBS-v3: [Grund
/ Impact / Kreativer Mehrwert]` comment above the capability-gated change in
components/settings/AnthropicProviderFields.tsx lines 20-26. Replace the
existing multi-line marker above the new test block in
tests/unit/settings/AiProviderCard.test.tsx lines 387-389 with one single-line
QNBS-v3 comment using the required format.
- Around line 63-89: Localize the Anthropic placeholder and all three model
labels in AnthropicProviderFields using t('key.path') keys. Update the related
AiProviderCard test assertion to validate the localized label/key behavior. Add
the new translation keys to all 19 locale trees and regenerate the runtime
bundles; affected sites are components/settings/AnthropicProviderFields.tsx
lines 63-89 and tests/unit/settings/AiProviderCard.test.tsx lines 427-429.

In `@public/locales/hu/bundle.json`:
- Line 1728: Translate both newly added Hungarian entries in
public/locales/hu/bundle.json: settings.ai.anthropicProxyNote at lines 1728-1728
and settings.ai.testError.proxyUnavailableStaticHost at lines 1872-1872. Add the
corresponding Hungarian source translations and regenerate the bundle,
preserving the proxy/privacy wording and static-host diagnostic details.

In `@public/locales/is/bundle.json`:
- Line 1728: Translate settings.ai.anthropicProxyNote and
settings.ai.testError.proxyUnavailableStaticHost in the source locale files,
then regenerate the bundles. Apply the translations at
public/locales/is/bundle.json lines 1728 and 1872, public/locales/ja/bundle.json
lines 1728 and 1872, and public/locales/ko/bundle.json lines 1728 and 1872;
ensure none of these non-English entries remain in English.

In `@public/locales/pt/bundle.json`:
- Line 1728: Translate the newly added proxy guidance keys in
locales/pt/settings.json into Portuguese, preserving the diagnostic meaning of
the static-host message and the existing message intent. Then regenerate
public/locales/pt/bundle.json using the repository’s standard i18n generation
command so the bundle contains the localized values.

In `@services/deployTarget.ts`:
- Around line 8-19: Replace the BASE_URL comparison in isServerlessProxyCapable
with an explicit build-time capability flag configured only for deployments that
provide api/claude-proxy, including Vercel and Cloudflare builds. Ensure static
Docker/nginx deployments and GitHub Pages custom domains evaluate as
unsupported, while preserving the existing unsupported-deployment fallback
behavior.

---

Nitpick comments:
In `@api/_shared/claudeProxyCore.ts`:
- Around line 114-127: Update the post-read size check in the request-body
handling flow to measure rawBody’s encoded byte length rather than UTF-16 string
length, keeping it consistent with the content-length header check and the
existing MAX_BODY_BYTES limit.

In `@docs/SECURITY-THREAT-MODEL.md`:
- Around line 212-220: Qualify the same-origin check in the “Abuse controls”
description to state that it constrains browser-driven callers but is not an
authentication boundary for non-browser clients, which can freely set the Origin
header; retain the existing rate-limit and body-size controls as the protections
for scripted abuse.

In `@services/aiProviderService.ts`:
- Around line 306-317: Update the Claude proxy request in the fetch call to
combine opts.signal with an AbortSignal.timeout(...) signal, using the same
timeout-merging pattern as the other request paths. Preserve caller cancellation
while ensuring stalled requests are automatically aborted before
deliverAnthropicResponse processes the response.

In `@tests/unit/api/claudeProxyCore.test.ts`:
- Around line 40-46: Update uniqueIp() so generated client identifiers never
repeat as the test suite grows; remove the `% 255` wrapping and use a wider
synthetic value or a monotonically increasing per-test suffix while preserving
its use as the x-forwarded-for rate-limit key.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0077dbe9-88a9-4865-9972-532fec010d0e

📥 Commits

Reviewing files that changed from the base of the PR and between 6cac0df and ab1ddd7.

📒 Files selected for processing (53)
  • README.md
  • api/_shared/claudeProxyCore.ts
  • api/claude-proxy.ts
  • components/settings/AiProviderCard.tsx
  • components/settings/AnthropicProviderFields.tsx
  • docs/SECURITY-THREAT-MODEL.md
  • docs/adr/0016-native-grok-and-claude-providers.md
  • functions/api/claude-proxy.ts
  • locales/ar/settings.json
  • locales/de/settings.json
  • locales/el/settings.json
  • locales/en/settings.json
  • locales/es/settings.json
  • locales/eu/settings.json
  • locales/fa/settings.json
  • locales/fi/settings.json
  • locales/fr/settings.json
  • locales/he/settings.json
  • locales/hu/settings.json
  • locales/is/settings.json
  • locales/it/settings.json
  • locales/ja/settings.json
  • locales/ko/settings.json
  • locales/pt/settings.json
  • locales/ru/settings.json
  • locales/sv/settings.json
  • locales/zh/settings.json
  • public/locales/ar/bundle.json
  • public/locales/de/bundle.json
  • public/locales/el/bundle.json
  • public/locales/en/bundle.json
  • public/locales/es/bundle.json
  • public/locales/eu/bundle.json
  • public/locales/fa/bundle.json
  • public/locales/fi/bundle.json
  • public/locales/fr/bundle.json
  • public/locales/he/bundle.json
  • public/locales/hu/bundle.json
  • public/locales/is/bundle.json
  • public/locales/it/bundle.json
  • public/locales/ja/bundle.json
  • public/locales/ko/bundle.json
  • public/locales/pt/bundle.json
  • public/locales/ru/bundle.json
  • public/locales/sv/bundle.json
  • public/locales/zh/bundle.json
  • services/aiProviderService.ts
  • services/deployTarget.ts
  • tests/unit/aiProviderService.test.ts
  • tests/unit/api/claudeProxyCore.test.ts
  • tests/unit/api/claudeProxyEntrypoints.test.ts
  • tests/unit/deployTarget.test.ts
  • tests/unit/settings/AiProviderCard.test.tsx

Comment thread api/_shared/claudeProxyCore.ts
Comment thread api/_shared/claudeProxyCore.ts
Comment thread components/settings/AnthropicProviderFields.tsx Outdated
Comment thread components/settings/AnthropicProviderFields.tsx
Comment thread public/locales/hu/bundle.json
Comment thread public/locales/is/bundle.json
Comment thread public/locales/pt/bundle.json
Comment thread services/deployTarget.ts
…learing it (CWE-770)

A spoofed x-forwarded-for burst that pushed the tracked-client map over its
5000-entry cap triggered a wholesale rateLimitLog.clear(), resetting every
real client's rate-limit window — a DoS vector against the limiter itself.
Now evicts stale-window entries first, then oldest-by-insertion-order,
never the current caller's own entry. Also fixes two doc-drift nitpicks
CodeRabbit caught on the same PR: a stale functions/claude-proxy.ts path in
two header comments (actual path is functions/api/claude-proxy.ts), and a
multi-line JSDoc block in AnthropicProviderFields.tsx that should have been
the repo's single-line QNBS-v3 comment format.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@qnbs

qnbs commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@qnbs

qnbs commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@qnbs
qnbs merged commit 26925f5 into main Jul 30, 2026
22 checks passed
@qnbs
qnbs deleted the feat/claude-track-b-web-proxy branch July 30, 2026 23:04
qnbs added a commit that referenced this pull request Jul 30, 2026
#266) (#302)

* fix: address deferred CodeRabbit findings from PR #297

Four findings deferred from PR #297 to keep that PR's merge unblocked,
addressed here first per explicit instruction before starting the
Grok/Claude/Ollama plan execution:

1. GROK-PROVIDER-INTEGRATION-PLAN.md: the addendum was numbered "## 7"
   but its own subsections said "8.1"-"8.4", and a "§§1-7 above"
   self-reference included the addendum itself. Renumbered to 7.1-7.4,
   fixed both self-references, and fixed a second pre-existing
   ambiguous "§5" cross-reference (meant "item 5 within Phase 1's own
   list", not top-level section 5) while in the area.

2. tests/unit/listenerMiddleware.test.ts: added the required QNBS-v3
   comment next to the new featureFlagsActions import.

3. Added the required QNBS-v3 comment before the new "local-first
   shadow sync" describe block.

4. Rewrote the local-first sync test to assert the actual
   selector-to-binding boundary directly instead of inferring success
   from an absence of logger calls in an unmocked-dependency,
   environment-dependent setup. Added real mocks for
   services/localFirst/{projectDoc,docBinding,docPersistence} and
   services/storage/storageEncryptionService so the test can assert
   the exact object selectProjectData(state) returns is what reaches
   ProjectDocBinding's constructor and syncFromProject/verify.

   Hit two real bugs while building this: (a) an arrow-function mock
   implementation can never be used as a constructor -- switched to a
   real class, which Biome's useArrowFunction rule doesn't flag either
   (a function-expression fix would have been immediately reverted by
   the same linter that was satisfied by removing the arrow function
   in the first place); (b) vi.fn().mockImplementation() can't type a
   class constructor against its generic (...args) => any signature --
   mocked the class directly instead of wrapping it in vi.fn().

* docs: Phase 0 re-verification finds Claude split -- two tracks, not one

Re-checked the plan's own findings against current main before starting
implementation (Phase 0's explicit purpose), and found the original
draft's Claude analysis was incomplete in a way that changes the whole
approach:

- AiProviderCard.tsx's primary provider dropdown already has an
  'anthropic' entry with a dedicated warning block -- missed originally
  because the investigation searched for the literal string "claude",
  never "anthropic" (the actual identifier), when checking that file.
  Its existing i18n copy already half-promises desktop support
  ("...or use the Tauri desktop app for Anthropic calls").
- streamAnthropic() throws unconditionally on EVERY platform, including
  desktop -- but CORS is a browser-only restriction. It never even
  checks isTauriRuntime() before throwing. Tauri's native HTTP plugin
  (@tauri-apps/plugin-http, services/localServerHttp.ts) already solves
  exactly this class of problem for Ollama/LM Studio/vLLM (ADR-0012);
  the same escape hatch works for any HTTPS endpoint, including
  api.anthropic.com, since native networking isn't subject to browser
  CORS at all.

Restructured Phase 2 into two independent tracks: Track A (desktop) is
a narrow bug fix reusing ADR-0012's established pattern, no new
infrastructure, ships first. Track B (web/PWA) is the actual new
architecture -- the serverless proxy -- and is now correctly scoped to
only the three web deploy targets (desktop no longer needs it at all).
Updated the Executive Summary, both Definition of Done checklists, and
all internal cross-references accordingly.

* docs(adr): add ADR-0016 for native Grok provider + split Claude fix

Formalizes GROK-PROVIDER-INTEGRATION-PLAN.md's Phase 0 decision record:
Grok is a pure UI-wiring fix (backend already works); Claude splits
into Track A (desktop, native-HTTP via the ADR-0012 pattern, ships
first, no new infrastructure) and Track B (web/PWA, this app's first
serverless backend dependency, ships second). Also documents why the
Ollama-in-PWA follow-up (Issue #266) is a separate decision, not a
Track-B variant -- a hosted proxy can't reach a user's own localhost.

* feat(ai): Phase 1 -- native Grok provider wiring

Grok (xAI, Cloud 4) had a real, working backend (aiProviderService.ts's
streamGrok(), key storage, a live /v1/models connection test) but was
never selectable as a primary provider -- reachable only as a hybrid-
fallback-chain option. This closes that UI/wiring gap; no new backend
work needed.

- AiProviderCard.tsx: added Grok to the primary provider dropdown with
  its own API-key input + model selector (grok-3 / grok-3-mini),
  reusing the existing storageService key-storage plumbing and the
  already-working testAIConnection() grok case for Test Connection.
  Also fixed the whole provider array's i18n while touching it --
  gemini/openai/ollama/anthropic labels were hardcoded literals too,
  not just Grok's gap.
- providerFactory.ts: providerToKind() gets a distinct 'grok' kind
  (not folded into 'openaiCompatible') -- Grok needs a fixed baseURL
  (https://api.x.ai/v1) and a real stored key, unlike the Ollama
  default that also maps to 'openaiCompatible'; folding it in would
  have silently sent Grok requests through Ollama's localhost default.
- worldScriptCompletionFetch.ts: resolveModelConfig() branches on the
  new 'grok' kind, bringing Grok into the newer Writer-streaming path
  (useWorldScriptAI), not just legacy thunks.
- i18n: added settings.ai.grokKey + settings.ai.provider.{gemini,
  openai,ollama,anthropic,grok} across all 19 locales -- hand-
  translated for the 5 production locales (de/en/es/fr/it), EN
  fallback seeded for the other 14 pending the established bulk-
  translate workflow. Bundles rebuilt; README's key-count badge/table
  updated 2849 -> 2855.
- Tests: providerFactory (grok maps to 'grok', not 'unsupported'),
  worldScriptCompletionFetch (grok success path + missing-key 401),
  AiProviderCard (renders key input + model selector, saves via
  storageService.saveApiKey('grok', ...)).

No CSP change needed: https://api.x.ai is already in
src-tauri/tauri.conf.json, and the web CSP's https: scheme-source
(ADR-0004) already covers it. No new feature flag -- confirmed via
`pnpm exec tsx scripts/audit-feature-parity.ts` (22 flags, 0 drifts).

The many locales/*/{characters,common,dashboard,desktop,export,
outline,worlds,writer}.json changes in this commit are incidental --
running the i18n tooling's --fix pass re-sorted keys in files it
touched along the way. No content was added, removed, or changed in
any of those files, only key order.

* feat(ai): Phase 2 Track A -- native Claude support on desktop

Claude/Anthropic's streamAnthropic() threw unconditionally on every
platform, including desktop -- but CORS is a browser-only restriction.
Tauri's native HTTP plugin (localServerFetch, ADR-0012) already exists
in this codebase to bypass exactly this class of problem for Ollama;
the same escape hatch works for any HTTPS endpoint, since native
networking isn't subject to browser CORS at all.

- aiProviderService.ts: streamAnthropic() branches on isTauriRuntime()
  before throwing -- desktop calls https://api.anthropic.com/v1/messages
  directly via localServerFetch (Messages API format: x-api-key +
  anthropic-version headers). generateTextSingleProvider()'s anthropic
  case now delegates to the fixed streamAnthropic instead of its own
  separate unconditional throw. testAIConnection()'s anthropic case
  gets the same branch, using a minimal (max_tokens: 1) real request as
  the connectivity check since Anthropic has no public /v1/models
  endpoint to probe. Image generation is deliberately left throwing --
  Anthropic's API doesn't offer that endpoint at all, so this isn't a
  CORS bug to fix.
- src-tauri/tauri.conf.json: added https://api.anthropic.com to the
  CSP connect-src (mirrors the existing Grok entry).
- AiProviderCard.tsx: the existing 'anthropic' warning-only block is
  now desktop-conditional -- desktop renders a real API-key input +
  model selector (Opus/Sonnet/Haiku 4.x) exactly like every other
  cloud provider; web/PWA keeps the warning, with copy updated to say
  desktop now genuinely works rather than "might" (Track B's proxy
  note stays "coming soon", since that part isn't built yet).
- i18n: added settings.ai.anthropicKey; revised anthropicCorsNote/
  anthropicHint wording across the 5 production locales to reflect
  desktop actually working now; EN fallback seeded for the other 14.
  2855 -> 2856 keys; README badge/table updated.
- Tests: aiProviderService (testAIConnection + streamText desktop
  success/failure/no-key paths, web-path unchanged), AiProviderCard
  (desktop shows key input, web shows warning, key save wired to
  storageService.saveApiKey('anthropic', ...)).

Track B (the web/PWA serverless proxy) is unaffected by this commit --
still not built, still the only path for browser-tab Claude.

* feat(ai): Phase 2 Track B -- Claude serverless proxy for web/PWA

Track A (previous commit) fixed Claude on desktop. This closes the
other half: browser tabs have no native-HTTP escape hatch from CORS,
so Anthropic requests from Vercel/Cloudflare Pages deployments now
relay through this app's own stateless serverless proxy -- its first
backend dependency ever (previously a purely static SPA + a desktop
bundle with no server of its own).

- api/_shared/claudeProxyCore.ts: platform-agnostic relay core (Web
  Request/Response only, no @vercel/node or @cloudflare/workers-types
  dependency). Forwards { apiKey, model, messages, maxTokens? } to
  Anthropic's Messages API and returns the response unmodified. Mandatory
  abuse controls per ADR-0016 (the endpoint is public and unauthenticated
  by construction -- CWE-400 surface): Zod schema validation, a 256 KiB
  body-size cap checked against both the Content-Length header and the
  actual body (defeats a spoofed header), a same-origin check (Origin
  must match the deployment's own host), a best-effort per-client-IP
  rate limit (20 req/60s, in-memory -- explicitly not distributed, see
  code comment), and a 20s outbound timeout to Anthropic. Never logs the
  key, prompt, or response on any path -- asserted directly in tests via
  console spies across every branch.
- api/claude-proxy.ts: Vercel Edge Function entry point (thin wrapper).
- functions/api/claude-proxy.ts: Cloudflare Pages Function equivalent,
  at the matching /api/claude-proxy route (Cloudflare has no automatic
  "/api" prefix the way some platforms do -- the path has to earn it).
- services/deployTarget.ts: isServerlessProxyCapable() -- reuses
  import.meta.env.BASE_URL (already this codebase's build-time
  GitHub-Pages-vs-edge marker, see config/resolveViteBase.ts) to detect
  whether the current web build can host the proxy at all. GitHub Pages
  is static-only and never can.
- services/aiProviderService.ts: streamAnthropic() now has three
  branches -- desktop (Track A, unchanged), proxy-capable web (fetches
  /api/claude-proxy), and GitHub Pages (throws immediately, before ever
  checking for a key, since no key would help). testAIConnection's
  anthropic case gets the same three-way split. Retired the
  'backendProxyRequired' TestConnectionErrorKind (nothing can produce it
  anymore) in favor of 'proxyUnavailableStaticHost'.
- AiProviderCard.tsx: extracted the whole anthropic UI block into
  components/settings/AnthropicProviderFields.tsx -- both to keep
  AiProviderCard under the Biome cognitive-complexity gate (52 > 50
  before the split) and under CLAUDE.md's 700-line file-size target.
  Desktop and proxy-capable web now render the same real key + model
  UI every other cloud provider gets; only GitHub Pages keeps the
  warning block, with copy rewritten from a generic CORS note to the
  actual "no proxy host" reason.
- i18n: settings.ai.anthropicProxyNote (new); anthropicCorsNote/
  anthropicHint/corsRestriction copy rewritten (desktop and Vercel/CF
  no longer say "coming soon" -- they work); testError.
  backendProxyRequired removed, testError.proxyUnavailableStaticHost
  added. Hand-translated for the 5 production locales; EN fallback
  seeded for the other 14. 2856 -> 2857 keys; README badge/table/count
  updated in all 4 places.
- docs/SECURITY-THREAT-MODEL.md: new "Claude serverless proxy
  trust-model change" section, an Information Disclosure row, a Denial
  of Service row (the abuse-control list), a Mitigation Mapping row,
  and a checklist item -- this is the first provider whose BYOK key
  transits infrastructure WorldScript runs, and that's stated plainly
  rather than folded into the general BYOK framing.
- README.md: privacy bullet and the encryption section both carry the
  same caveat; the Cloud 3 provider-stack row lists the actual model
  ids (Opus 4.7/Sonnet 4.6/Haiku 4.5, not the stale "Claude 3.5
  Sonnet") and where Claude does/doesn't work.
- docs/adr/0016: documents why providerFactory.ts's Vercel-AI-SDK layer
  still returns 'unsupported' for anthropic (would need the
  @ai-sdk/anthropic package -- Anthropic's Messages API isn't
  OpenAI-Chat-Completions-shaped like Grok/Ollama are, so that's a new
  runtime dependency out of scope here) and why no dedicated E2E spec
  was added (no other cloud provider -- including Grok -- has one in
  this repo; the RTL/unit level is where this class of Settings-form
  coverage already lives).

Tests: 14 new (claudeProxyCore: schema/size/origin/rate-limit/timeout/
relay-fidelity/no-logging), 3 new (Vercel + Cloudflare entry-point
delegation), 3 new (deployTarget branch coverage), aiProviderService's
Anthropic describe block rewritten for the three-way split,
AiProviderCard's anthropic describe block rewritten with a new
proxy-capable-web case. 126 tests green across the full Track B
surface; lint, typecheck, i18n:check, docs:check, and parity:check
(22 flags, 0 drifts -- no new flag needed) all clean.

* feat(ai): Addendum -- opt-in browser-Ollama connection for the PWA (ADR-0017, Issue #266)

Issue #266's own comment thread already worked out the correct answer:
CORS is the server's own configuration, not an immutable browser wall.
If a user starts their own Ollama server with OLLAMA_ORIGINS covering
the PWA's exact origin, a direct browser fetch to localhost succeeds --
real, standards-compliant CORS, the same non-magic model NovelCrafter's
own browser-Ollama support uses. This wires that up as an explicit,
default-off opt-in, without touching the "PWA stays desktop-only by
default" guarantee ADR-0012 established.

- features/featureFlags/featureFlagsSlice.ts: new enableBrowserOllama
  flag, default off (23rd flag, 7th user opt-in).
- features/featureCatalog.ts: catalog entry (tier: ai, risk: medium,
  matches enableVoiceSupport's classification for a similarly-scoped
  opt-in) with real gateLocations.
- hooks/useSettingsView.ts: dispatch case for the new flag (caught by
  scripts/audit-feature-parity.ts's "toggle fires, Redux doesn't
  update" check -- exactly the class of drift that gate exists for).
- services/aiProviderService.ts: testAIConnection's ollama case now
  accepts opts.browserOllamaEnabled instead of hard-requiring
  isTauriRuntime(); a generic 'unreachable' result remaps to a new
  'corsSuspected' kind when running the opt-in browser path -- CORS
  rejection and "server genuinely down" are identical TypeErrors at
  the JS level, so this is an honestly-hedged heuristic, not a
  diagnosis.
- services/localServerHttp.ts: NO functional change -- verified during
  implementation that resolveFetch() already returns globalThis.fetch
  unconditionally on the web; the "PWA never probes localhost" policy
  lived entirely in the UI/testAIConnection call sites, not the
  transport layer. Added a comment explaining why, so a future reader
  doesn't go looking for a change that was never needed here.
- components/settings/AiProviderCard.tsx (+ AiSections.tsx passing the
  flag down): canAttemptOllama = isDesktop || browserOllamaEnabled
  replaces every bare isDesktop check gating the ollama auto-probe
  effect, Load Models, and Test Connection. When on, an info block
  renders the exact `OLLAMA_ORIGINS=<origin> ollama serve` command for
  window.location.origin -- always correct for the current deployment,
  never a guessed/generic value, since WorldScript ships from multiple
  possible origins (unlike NovelCrafter's single fixed SaaS URL).
- docs/adr/0017-pwa-browser-ollama-opt-in.md: new ADR: why this differs
  from the Claude proxy pattern (a hosted function can reach the public
  internet, never a user's own localhost -- hard networking fact, not
  policy), why WorldScript never defaulted this on unlike NovelCrafter,
  explicit non-goals (no LAN-IP, no PNA, not the default). ADR-0012
  cross-referenced both directions.
- docs/FEATURE-PARITY.md, CLAUDE.md (x2), tests/CLAUDE.md, AGENTS.md
  (x2), .github/copilot-instructions.md: flag-count references updated
  22->23 total / 6->7 opt-in-off across every doc that enumerates them.
- i18n: settings.featureFlags.enableBrowserOllama, ai.ollamaBrowserOptInTitle/
  Body, ai.testError.corsSuspected -- hand-translated for the 5
  production locales, EN fallback seeded for the other 14. 2857 -> 2861
  keys; README updated in all 4 places.
- Help articles (help.aiStudio.providers.content, help.faq.api.content,
  5 production locales): fixed pre-existing stale content discovered
  while updating for this work -- wrong model names (Claude 3.5
  Sonnet/Grok-2, both outdated), a genuine CSP-vs-CORS factual error in
  the Ollama description (CORS blocks localhost, not CSP -- the exact
  misconception ADR-0012 already corrected elsewhere), and a missing
  OpenRouter entry (shipped as Cloud 5, never documented in either
  article). Not scope creep: same articles, same edit, left broken
  would misinform users reading about the very feature this PR ships.
- README.md: Ollama provider-stack row now documents both the desktop
  native path and the new opt-in browser path with the ADR-0017 link.

Tests: 5 new (testAIConnection's browserOllamaEnabled on/off,
corsSuspected remapping, non-remap of timeout/success), 5 new
(AiProviderCard's flag-off/on UI states, auto-probe, button enablement,
desktop-unaffected), 1 new (useSettingsView's dispatch case), plus the
parametrized featureFlagsSlice case and updated 21->22 toggle count in
FeatureFlagsSection. 336 tests green across the full affected surface;
lint, typecheck, i18n:check, docs:check, and parity:check (23 flags,
0 drifts) all clean.

* fix(tokens): avoid token-audit false-positive from #266 issue references

audit-tokens.mjs's raw-hex rule (/#[0-9a-fA-F]{3,8}\b/) can't distinguish
a CSS color literal from a GitHub issue reference -- "#266" matches
just as well as "#f0f". The previous commit's two brand-new comments
referencing Issue #266 (an info-block comment in AiProviderCard.tsx, a
roadmapTarget string in featureCatalog.ts) pushed the count to 161
against a baseline of 160. Reworded both to drop the "#" immediately
before the digits rather than raising the baseline -- same rule this
repo already applies to biome-ignore suppressions.

* docs: address CodeRabbit quick-win findings on PR #299

Add missing single-line QNBS-v3 rationale comments (Grok key state,
key-persistence handler, the openaiCompatible-reuse decision in
worldScriptCompletionFetch.ts, two test fixtures/blocks) and collapse
four multi-line QNBS-v3 comments to the required single line.

Not addressed here (documented, not fixed): the ADR-0016 wording
describing Claude desktop as immediately usable is accurate once the
full stacked sequence merges (Track A is PR #300, already stacked next)
and will read correctly at that point without further edits; the
locale-translation findings for settings.ai.grokKey reflect this repo's
established i18n tiering (5 production locales get hand translations,
14 beta locales intentionally carry an EN fallback per CLAUDE.md's own
i18n section), not a defect; the vi.mock hoisting concern in
listenerMiddleware.test.ts is a false positive verified against the
actual passing suite -- ProjectDocBinding is consumed via a dynamic
await import() inside test bodies, not a static top-level import, so
by the time any mock factory runs the module has already evaluated
top-to-bottom once.

* fix(docs): restore the '+' suffix scripts/sync-readme-metrics.mjs expects

PR #301's Build, E2E Tests, and E2E Deep Coverage jobs all failed at the
same root cause: pnpm run build's predev/prebuild sync step invokes
sync-readme-metrics.mjs, which hard-fails via its own drift guard when a
metric occurrence in README.md doesn't match the real, computed value.

Four README occurrences read "6477 tests / 529 files" (no "+"), while
every regex the script uses -- both its targeted replacements and the
drift guard's generic check -- expects the "6477+ tests" convention the
rest of the file already follows. Because the file count only changed
NOW (529 -> 532, from adding claudeProxyCore.test.ts,
claudeProxyEntrypoints.test.ts, and deployTarget.test.ts across Track B
and the Ollama addendum), the guard fired for the first time -- the "+"-
less phrasing had been silently tolerated as long as the numeric value
happened to still match.

Restored the "+" suffix (this repo's actual, already-established
convention for a fast-changing count, not a new one) rather than adding
a parallel no-"+" regex path to the script -- the script's convention
was correct; my own earlier edit was the drift. Running the script now
auto-updates 529 -> 532 and is idempotent on a second run.

* fix(api): bound the Claude proxy rate-limiter's eviction instead of clearing it (CWE-770)

A spoofed x-forwarded-for burst that pushed the tracked-client map over its
5000-entry cap triggered a wholesale rateLimitLog.clear(), resetting every
real client's rate-limit window — a DoS vector against the limiter itself.
Now evicts stale-window entries first, then oldest-by-insertion-order,
never the current caller's own entry. Also fixes two doc-drift nitpicks
CodeRabbit caught on the same PR: a stale functions/claude-proxy.ts path in
two header comments (actual path is functions/api/claude-proxy.ts), and a
multi-line JSDoc block in AnthropicProviderFields.tsx that should have been
the repo's single-line QNBS-v3 comment format.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant