Skip to content

Phase 1: production hardening — protect the WCL budget - #5

Merged
alexandermayes merged 5 commits into
mainfrom
harden/phase-1
Jul 22, 2026
Merged

Phase 1: production hardening — protect the WCL budget#5
alexandermayes merged 5 commits into
mainfrom
harden/phase-1

Conversation

@alexandermayes

Copy link
Copy Markdown
Owner

Implements Phase 1 (launch-blocking) of PRODUCTION_HARDENING.md. The API routes were unauthenticated with no rate limiting, no fan-out cap, and no stampede protection — any script or a popular log on a busy raid night could drain the shared daily WCL points budget and take the whole site down. This closes those holes.

All four tasks respect the ground rules: no API response-shape changes (the Discord bot consumes them), graceful degradation without Redis (usingSharedCache === false → everything no-ops to today's behavior), and no new heavy deps beyond the two listed.

Task 1.1 — Per-IP rate limiting (lib/rate-limit.ts)

Sliding-window limiting on the same Upstash Redis the cache uses (env resolution mirrors kv-cache.ts, incl. the KV_REST_API_* names the Marketplace injects). checkRateLimit() returns a 429 (with Retry-After) or null; wired into all five routes after parsing. Tuned per cost in constants.ts (analyze/raid-overview 30/60s, cla 10/60s, report + report-players 60/60s). No-ops without Redis; fails open on any Redis error so it can never take the site down. Hits logged via logEvent. The existing SWR hooks already surface data.error, so the 429 message shows with no frontend change.

Task 1.2 — Input validation + fan-out cap

Shared isValidReportCode/badRequest helpers. On the POST routes: validate reportCode and integer fightId/sourceId (0 stays valid) before building cache keys/querying. On /api/cla: guard that fightIds is a non-empty integer array (a non-array body previously threw → 500), dedupe, and reject > MAX_CLA_FIGHTS (15) so one request can't fan out unbounded.

Task 1.3 — Single-flight lock (cachedApiHandler)

cacheLock/cacheUnlock (SET NX EX 20 / DEL). On a cache miss with Redis, the lock holder computes + caches while everyone else polls the cache (~500ms, up to 15s) and returns it as cache:"wait_hit" — collapsing a 50-people-open-the-same-report stampede into one upstream computation. Waiters that time out compute themselves (never dead-end); lock releases in finally with the EX TTL as a crash backstop.

Task 1.4 — Discord bot hardening

Per-channel (30s) cooldown on passive link replies + only reply to links with a fight; per-user (10s) cooldown on slash commands. Handlers and message.reply wrapped so a rejected promise can't crash the process (an unhandled rejection terminates Node 20). api.ts no longer echoes raw upstream bodies into Discord — throws a typed ApiError surfacing only the API's error field, with a friendly 429 message. Fixed stale activity string → parseforge.gg.

Verification

  • npx tsc --noEmit ✅ · npm run build ✅ · bot tsc build ✅
  • npm run lint holds at the same 16 pre-existing findings — zero new issues in changed files
  • Build/typecheck run without Redis env (rate limiting no-ops, cache falls back to memory)

🤖 Generated with Claude Code

alexandermayes and others added 5 commits July 19, 2026 15:13
Add lib/rate-limit.ts — per-IP sliding-window limiting backed by the same
Upstash Redis the cache uses (env resolution mirrors kv-cache.ts, incl. the
KV_REST_API_* names the Vercel Marketplace injects). No-ops when Redis is
unconfigured (local dev) and fails open on any Redis error, so it can never
take the site down. checkRateLimit() returns a 429 NextResponse (with
Retry-After) or null.

Wired into all five routes after body/param parsing, tuned per cost via
RATE_LIMITS in constants.ts: analyze/raid-overview 30/60s, cla 10/60s (biggest
fan-out), report + report-players 60/60s. Hits logged via logEvent for Vercel
visibility. The existing SWR hooks already surface `data.error`, so the 429
message displays without frontend changes.

Also drops the hardening plan in the repo root for tracking.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ
Add shared isValidReportCode/badRequest helpers to api-utils. On the three POST
routes, validate before building cache keys or querying WCL:
- analyze/raid-overview: reportCode matches the code regex and fightId/sourceId
  are integers (Number.isInteger keeps 0 valid).
- cla: guard that fightIds is a non-empty array of integers (a non-array body
  previously threw on .length → 500), dedupe, and reject > MAX_CLA_FIGHTS (15)
  so one request can't fan out unbounded WCL calls. The handler now queries the
  deduped/capped set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ
Add cacheLock/cacheUnlock (SET NX EX 20 / DEL) to kv-cache, no-op without Redis.
In cachedApiHandler, on a cache miss with Redis configured: the lock holder
computes + caches while everyone else polls the cache (~500ms, up to 15s) and
returns it as cache:"wait_hit". Waiters that time out fall through and compute
themselves so no one dead-ends; the lock releases in finally with the EX TTL as
a crash backstop. Without Redis, behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ
- Passive link replies: per-channel 30s cooldown, and only reply when the link
  has a fight (bare report links no longer trigger the bot). Reply wrapped in
  try/catch so a permissions error logs instead of crashing the process (an
  unhandled rejection terminates Node 20).
- Slash commands: per-user 10s cooldown with an ephemeral throttle notice; the
  whole dispatch is wrapped so a handler rejection can't crash the bot.
- api.ts: stop echoing raw upstream response bodies into Discord — throw a typed
  ApiError(status, cleanMessage) that surfaces only the API's `error` field, and
  add describeApiError() with a friendly 429 message. Both commands use it.
- Fix stale activity string getlootlist.com -> parseforge.gg.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ
@vercel

vercel Bot commented Jul 20, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
parseforge Ready Ready Preview, Comment Jul 20, 2026 2:51am

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