Phase 1: production hardening — protect the WCL budget - #5
Merged
Conversation
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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This was referenced Jul 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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. theKV_REST_API_*names the Marketplace injects).checkRateLimit()returns a 429 (withRetry-After) or null; wired into all five routes after parsing. Tuned per cost inconstants.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 vialogEvent. The existing SWR hooks already surfacedata.error, so the 429 message shows with no frontend change.Task 1.2 — Input validation + fan-out cap
Shared
isValidReportCode/badRequesthelpers. On the POST routes: validatereportCodeand integerfightId/sourceId(0 stays valid) before building cache keys/querying. On/api/cla: guard thatfightIdsis 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 ascache:"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 infinallywith 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.replywrapped so a rejected promise can't crash the process (an unhandled rejection terminates Node 20).api.tsno longer echoes raw upstream bodies into Discord — throws a typedApiErrorsurfacing only the API'serrorfield, with a friendly 429 message. Fixed stale activity string →parseforge.gg.Verification
npx tsc --noEmit✅ ·npm run build✅ · bottscbuild ✅npm run lintholds at the same 16 pre-existing findings — zero new issues in changed files🤖 Generated with Claude Code