Skip to content

Phases 3 & 4: ops hygiene + first test suite - #7

Merged
alexandermayes merged 16 commits into
mainfrom
harden/phase-3-4
Jul 22, 2026
Merged

Phases 3 & 4: ops hygiene + first test suite#7
alexandermayes merged 16 commits into
mainfrom
harden/phase-3-4

Conversation

@alexandermayes

Copy link
Copy Markdown
Owner

Implements Phase 3 (ops hygiene) and Phase 4 (tests) of PRODUCTION_HARDENING.md. Done together because CI (3.1) runs the test script that Phase 4 adds.

⚠️ Stacked on #6#5. Base is harden/phase-2. Merge order: #5#6 → this. I can retarget to main once the earlier two land.

Phase 3 — Ops hygiene

  • 3.1 CI (.github/workflows/ci.yml): web job (npm ci, tsc --noEmit, npm test, build; lint non-blocking because of the repo's pre-existing lint errors) + bot job (npm ci, build). No deploy steps. Build needs no secrets — prerender degrades gracefully without WCL/Redis env.
  • 3.2 Bot Dockerfile: NODE_ENV=production, drop to the unprivileged node user, comment on the required restart policy.
  • 3.3 Unify URL parser: ported the web app's more-robust parseWCLUrl (bounded code length, query-param + embedded-text handling) into the bot's drifting copy so behavior matches; cross-reference comments in both.

Phase 4 — First test suite (vitest) — 24 tests, all green

  • url-parser: every documented format (bare code, hash/query params, fight=last, embedded-in-text, scheme-less, junk) + buildWCLUrl.
  • async-pool: order preservation, index passing, concurrency cap, empty input.
  • api-utils: isValidReportCode + parseBody incl. the "0 is a valid fight/source id" case, missing-field, and invalid-JSON 400s.
  • analysis-engine (started): analyzeDps — empty rankings, mid-field placement, 99-percentile cap.

Verification

  • npm test → 24 passed ✅ · npx tsc --noEmit ✅ · npm run build ✅ · bot build ✅
  • npm run lint unchanged at 16 pre-existing findings (0 new)
  • All run without Redis env

Follow-ups (noted, not in scope)

  • Clear the 4 pre-existing lint errors, then flip CI lint to blocking.
  • Broaden engine test coverage (cla-engine, gear/enchant detection, healer path).

🤖 Generated with Claude Code

alexandermayes and others added 16 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
Add a headers() block: X-Content-Type-Options, Referrer-Policy, and
Permissions-Policy on every route; X-Frame-Options: SAMEORIGIN and a
Content-Security-Policy-Report-Only on everything EXCEPT /og (link unfurlers
fetch the OG image). CSP ships report-only so violations log to the console
without blocking, to be promoted to enforcing manually after checking real
traffic. Verified: / carries all headers incl. CSP; /og carries only the benign
ones (frame-blocking correctly excluded via negative-lookahead source).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ
Validate report against the code regex (invalid/absent → branded fallback,
never fetch); only take the player-scorecard path when fight+source parse to
non-negative integers; pin the self-fetch origin to https://parseforge.gg in
production (request origin only in dev) so a spoofed Host can't steer it. Keeps
the never-fail-an-unfurl catch-all.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ
buildCLABuffUptimeQuery interpolates sourceIds into the query string. They come
from WCL's own actor list (safe today), but coerce each via Number() and drop
non-integers/negatives as insurance against a future caller passing unvalidated
data into the only string-built query.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ
maskAllInputs: true (drops the custom un-masking of the report-URL input) and
enable_recording_console_log: false so replays can't hoover up client-side
console output or typed input. TODO left re: EU consent banner (product call).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ
getAccessToken now checks module scope, then Redis (wcl:token), then mints from
WCL and writes back to both — so a cold serverless instance reuses a token
another instance already minted instead of spending a fresh token request
(every mint counts against the same client). TTL is set just short of expiry.
On a 401, clearAccessToken() drops both the module and Redis copies before
retrying. Adds cacheDelete() to kv-cache. No Redis → module-only, as before.

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 bot's parse-url.ts was a thinner, drifting copy — unbounded code length and
no query-param / embedded-text handling. Port the web app's more robust logic
into it verbatim so behavior matches, and add cross-reference comments in both
files noting the intentional duplication (separate packages).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ
Set NODE_ENV=production and drop to the image's unprivileged `node` user before
CMD. Comment notes the runtime must set a restart policy since the bot exits on
fatal startup errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ
Two jobs on push-to-main and PR: web (npm ci, tsc --noEmit, test, build; lint
non-blocking given pre-existing lint debt) and bot (npm ci + build). No deploy
steps — Vercel handles deploys. Build needs no secrets (prerender degrades
gracefully without WCL/Redis env).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ
Add vitest + a `test` script + config that resolves the @/* alias. 24 tests:
- url-parser: every documented format (bare code, hash/query params, fight=last,
  embedded-in-text, scheme-less, junk) + buildWCLUrl.
- async-pool: order preservation, index passing, concurrency cap, empty input.
- api-utils: isValidReportCode, and parseBody incl. the "0 is a valid
  fight/source id" case and invalid-JSON/missing-field 400s.
- analysis-engine (started): analyzeDps — empty rankings, mid-field placement,
  and the 99 percentile cap.

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 21, 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 21, 2026 6:45pm

@alexandermayes
alexandermayes changed the base branch from harden/phase-2 to main July 22, 2026 05:45
@alexandermayes
alexandermayes merged commit 8a7e815 into main Jul 22, 2026
5 checks passed
@alexandermayes
alexandermayes deleted the harden/phase-3-4 branch July 22, 2026 05:45
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