Skip to content

feat(app): cap live analyses per client, and say what the cap is worth - #9

Merged
wonkwonlee merged 1 commit into
mainfrom
claude/live-analyze-rate-limit
Jul 27, 2026
Merged

feat(app): cap live analyses per client, and say what the cap is worth#9
wonkwonlee merged 1 commit into
mainfrom
claude/live-analyze-rate-limit

Conversation

@wonkwonlee

Copy link
Copy Markdown
Owner

What and why

Item #10 from the security review.

POST /api/analyze is unauthenticated by design — the demo promises no signup — so on a deployment with a provider key configured, live mode spent the owner's credit for whoever asked, at whatever rate they asked. Live calls now count against a per-client window; over it, the route answers 429 with retry-after before reaching any provider, and points at replay, which runs the identical pipeline for free.

CHANGESAFE_LIVE_RATE_LIMIT=10            # per client per window; 0 disables
CHANGESAFE_LIVE_RATE_WINDOW_SECONDS=3600

Replay stays uncapped. It costs nothing, and "anyone can drive the whole gate with no key and no account" is the demo — capping it to protect a budget it never touches would be the wrong trade.

The cap is documented as a speed bump, not a defense. It counts in one process's memory, so a serverless deployment holds a counter per instance, and it identifies callers by a forwarded header that only a trusted proxy makes trustworthy. README and .env.example both say so, and say to put authentication or a proxy in front of a public live deployment. Overstating it would be worse than not shipping it, because someone would rely on it.

Two smaller decisions worth flagging for review:

  • An unreadable CHANGESAFE_LIVE_RATE_LIMIT falls back to the default, not to unlimited — a typo in a deployment variable must not quietly remove the cap. 0 still disables it deliberately.
  • A refused call does not extend the window. A client that keeps knocking gets in when the window turns over rather than being pushed back for as long as it retries.

RATE_LIMITED is added to ApiErrorCodeSchema; the client already renders error.message and honors replayAvailable, so no UI change was needed.

Safety review

  • Invariants preserved: nothing in the gate, policies, or receipts is touched — this only decides whether a model call is attempted. The refusal message names no credential and no provider detail (asserted). Replay's keyless path is explicitly tested as unaffected. Secrets stay server-side: the client bundle check still finds no provider endpoints.
  • Tests that prove it: tests/unit/rate-limit.test.ts (limit, per-client isolation, window rollover, no push-back on retries, 0 disables, env parsing including the typo fallback, caller identification) and three cases in tests/integration/analyze-api.test.ts — a 429 that never reaches a provider, replay staying open past the live limit, and one client's spending not refusing another's first call.
  • Does this change any policy's verdict for existing scenarios? No.

Checklist

  • npm run lint && npm run typecheck && npm test && npm run build pass
  • npm run test:e2e passes (3/3, run against the preinstalled Chromium)
  • No execution path to real infrastructure added
  • No secrets, real data, or third-party branding added
  • Tests added or updated for the behavior change

Tests are 423 passing (+14). The rate-limit tests consume the window directly rather than by issuing live calls, so the suite still needs no network and no credential. The one failure in my container is the pre-existing root-only case in verification-bundle.test.ts:924#7 fixes it.


Generated by Claude Code

`POST /api/analyze` is unauthenticated by design — the demo promises no
signup — so a deployment with a provider key configured was spending its
owner's credit for whoever asked, at whatever rate they asked. Live calls
now count against a per-client window (CHANGESAFE_LIVE_RATE_LIMIT, default
10 per hour, 0 to disable), and a refusal answers 429 with retry-after and
points at replay, which runs the identical pipeline for free.

Replay stays uncapped. It costs nothing, and the promise that anyone can
drive the whole gate without an account is the demo.

The cap is documented as what it is: a speed bump. It counts in one
process's memory, so serverless holds a counter per instance, and it
identifies callers by a forwarded header that only a trusted proxy makes
trustworthy. README and .env.example say so, and say to put authentication
or a proxy in front of a public live deployment. Overstating it would be
worse than not having it — someone would rely on it.

A bad value for the limit falls back to the default rather than to
unlimited: a typo in a deployment variable must not quietly remove the cap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Npn2z4Uami1SpJyaCRsy6N
@vercel

vercel Bot commented Jul 26, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
change-safe Ready Ready Preview, Comment Jul 26, 2026 11:47pm

@wonkwonlee
wonkwonlee merged commit e7aa17b into main Jul 27, 2026
8 checks passed
@wonkwonlee wonkwonlee mentioned this pull request Jul 27, 2026
5 tasks
@wonkwonlee
wonkwonlee deleted the claude/live-analyze-rate-limit branch July 27, 2026 08:37
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.

2 participants