From 2610b3cc6a05b1c6e1a7c1fd3f9082e6d3242043 Mon Sep 17 00:00:00 2001 From: Alexander Mayes Date: Sun, 19 Jul 2026 15:13:59 -0700 Subject: [PATCH 01/19] Phase 1.1: per-IP rate limiting on all API routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ --- PRODUCTION_HARDENING.md | 207 +++++++++++++++++++++++++ app/api/analyze/route.ts | 4 + app/api/cla/route.ts | 4 + app/api/raid-overview/route.ts | 4 + app/api/report/[code]/players/route.ts | 4 + app/api/report/[code]/route.ts | 6 +- lib/constants.ts | 15 ++ lib/rate-limit.ts | 87 +++++++++++ package-lock.json | 41 +++++ package.json | 2 + 10 files changed, 373 insertions(+), 1 deletion(-) create mode 100644 PRODUCTION_HARDENING.md create mode 100644 lib/rate-limit.ts diff --git a/PRODUCTION_HARDENING.md b/PRODUCTION_HARDENING.md new file mode 100644 index 0000000..efd780c --- /dev/null +++ b/PRODUCTION_HARDENING.md @@ -0,0 +1,207 @@ +# ParseForge — Production Hardening Plan + +> **How to use this file:** Drop it in the repo root and tell Claude Code: +> *"Read PRODUCTION_HARDENING.md and work through the phases in order. Complete one task at a time, run `npm run build` and `npx tsc --noEmit` after each task, and check the box before moving on."* + +--- + +## Context (read this first) + +ParseForge is a **stateless Next.js (App Router) app on Vercel** plus a **Discord bot** in `/bot`. It has: + +- **No database, no auth, no user accounts.** All data comes from the Warcraft Logs (WCL) GraphQL API v2, fetched server-side with OAuth client-credentials from `WCL_CLIENT_ID` / `WCL_CLIENT_SECRET`. +- **A shared Redis cache** (Upstash via Vercel Marketplace) in `lib/kv-cache.ts`, with an in-memory Map fallback. `usingSharedCache` tells you if Redis is configured. +- **Three expensive POST routes** — `/api/analyze`, `/api/cla`, `/api/raid-overview` — each of which fans out into multiple WCL GraphQL calls (`/api/cla` can fire 20+ for a full raid clear). +- **One critical shared resource:** the WCL API has a **daily points budget per client ID**. Every request from every user spends from the same budget. If it's exhausted, the whole site is down for everyone until it resets. + +**The core problem this plan fixes:** the API routes are unauthenticated with **no rate limiting**, no cap on request fan-out, and no protection against many simultaneous requests for the same report (cache stampede). Any script — or just a popular log shared on a busy raid night — can drain the WCL budget and take the site down. + +Key files: + +| File | Role | +|---|---| +| `lib/wcl-client.ts` | WCL GraphQL client: OAuth token cache, retries, timeouts, in-memory query cache | +| `lib/kv-cache.ts` | Shared Redis result cache + in-memory fallback + recent-reports sorted set | +| `lib/api-utils.ts` | `cachedApiHandler` (cache-check → run → cache-set), `errorResponse`, `parseBody` | +| `lib/wcl-queries.ts` | GraphQL query strings, incl. `buildCLABuffUptimeQuery` (string-built query) | +| `app/api/analyze/route.ts` | Player analysis (POST) | +| `app/api/cla/route.ts` | Consumables/buff audit across many fights (POST) — biggest fan-out | +| `app/api/raid-overview/route.ts` | Raid board (POST) | +| `app/og/route.tsx` | OG image; makes server-side fetches to own API with query-param inputs | +| `bot/src/index.ts` | Discord bot; auto-replies to any pasted WCL link, calls the public API | +| `next.config.ts` | Rewrites + redirects; currently **no `headers()` block** | + +**Ground rules for every task:** + +- Don't change response shapes of existing API routes — the Discord bot in `/bot` consumes them. +- Keep everything working **without** Redis configured (local dev). `usingSharedCache === false` must degrade gracefully, exactly like the existing cache code does. +- Match the existing code style: typed errors, heavy explanatory comments on "why", no new heavyweight dependencies unless listed below. +- After each task: `npm run build` and `npx tsc --noEmit` must pass. There are no tests yet (Phase 4 adds them). + +--- + +## Phase 1 — Stop the bleeding (protects the WCL budget) + +### Task 1.1 — IP rate limiting on all API routes 🔴 + +**Goal:** No single IP can hammer the expensive routes. + +- Add dependency: `npm install @upstash/ratelimit @upstash/redis` +- Create `lib/rate-limit.ts`: + - Use `Ratelimit.slidingWindow` backed by Redis (`Redis.fromEnv()` supports both `KV_REST_API_*` and `UPSTASH_REDIS_REST_*` names — verify against how `lib/kv-cache.ts` resolves env vars and keep them consistent; if `Redis.fromEnv()` doesn't pick up the `KV_REST_API_*` names, construct the client manually from the same env resolution used in `kv-cache.ts`). + - **When Redis is not configured (local dev): rate limiting is a no-op that always allows.** Reuse/mirror the `usingSharedCache` pattern. + - Export a helper like `checkRateLimit(request: Request, bucket: string): Promise` that returns a 429 `NextResponse` when limited, or `null` when allowed. Derive the client key from `x-forwarded-for` (first entry) falling back to `"anon"`. + - Suggested limits (make them constants in `lib/constants.ts` so they're easy to tune): + - `/api/analyze`: 30 requests / 60s per IP + - `/api/raid-overview`: 30 / 60s + - `/api/cla`: 10 / 60s (it's the most expensive) + - `/api/report/[code]` and `/api/report/[code]/players`: 60 / 60s + - 429 body: `{ error: "Too many requests — please wait a moment and try again." }` with a `Retry-After` header. +- Wire the helper into **all five** route handlers as the first thing after body/param parsing. +- Log rate-limit hits through the existing `logEvent` in `lib/observability.ts` (e.g. `logEvent("rate_limited", { route })`) so they're visible in Vercel logs. +- Frontend: the SWR hooks in `app/analyze/[reportCode]/hooks/` should surface a friendly message on 429 — check how they currently render `error` and make sure the 429 message reads well (it likely already flows through since routes return `{ error }`). + +**Acceptance:** With Redis configured, a loop of 40 rapid POSTs to `/api/analyze` from one IP gets 429s after ~30. Without Redis env vars, everything behaves exactly as today. + +### Task 1.2 — Cap fan-out on `/api/cla` 🔴 + +**Goal:** One request can't trigger unbounded WCL calls. + +In `app/api/cla/route.ts`, after `parseBody`: + +- Validate `fightIds` is an array of finite integers (currently `fightIds.length` is read without checking it's an array — a non-array body causes an unhandled throw → 500). +- Reject with 400 if `fightIds.length > 15`: `{ error: "Too many fights selected — please select 15 or fewer." }` +- Deduplicate the IDs before building the cache key and querying. + +Also in `app/api/analyze/route.ts` and `app/api/raid-overview/route.ts`: validate that `fightId` / `sourceId` are finite integers (`Number.isInteger`) and that `reportCode` matches `/^[a-zA-Z0-9]{10,20}$/` (the GET routes already validate the code; the POST routes don't). Return 400 on failure. This also prevents weird values from polluting cache keys like `analyze-${reportCode}-${fightId}-${sourceId}`. + +Consider extending `parseBody` in `lib/api-utils.ts` with an optional per-field validator instead of repeating checks — your call, keep it simple. + +**Acceptance:** `fightIds: "abc"`, `fightIds: [1,2,"x"]`, 50 fight IDs, and `reportCode: "../etc"` all return clean 400s. Valid requests unchanged. + +### Task 1.3 — Single-flight lock to kill cache stampedes 🔴 + +**Goal:** When 50 people open the same freshly-shared report at once, only **one** request fans out to WCL; the rest wait for the cache. + +In `lib/api-utils.ts` → `cachedApiHandler`: + +- After a cache miss, attempt to acquire a Redis lock: `SET lock:{cacheKey} 1 NX EX 20` (add a small `cacheLock`/`cacheUnlock` pair to `lib/kv-cache.ts` using the existing `redisCmd` helper — `SET` with `NX` returns `null` result when not acquired). +- **Lock acquired:** run the handler, `setCache`, release the lock (`DEL`), return. +- **Lock not acquired (someone else is computing):** poll `getCached` every ~500ms for up to ~15s. If the cache fills, return it (log as `cache: "wait_hit"` in the existing `logEvent` call so hit-rate metrics stay meaningful). If it times out, fall through and run the handler yourself (never dead-end the user). +- **Without Redis:** skip locking entirely — behave exactly as today. +- Always release the lock in a `finally`, and rely on the `EX 20` TTL as the safety net if the instance dies mid-computation. + +**Acceptance:** Fire 10 concurrent identical `/api/analyze` requests against an uncached report (script it with `Promise.all` of fetches): Vercel logs show ~1 `cache: "miss"` doing real work and ~9 `wait_hit`s. Without Redis, behavior is unchanged. + +### Task 1.4 — Discord bot cooldowns 🔴 + +**Goal:** The bot can't amplify traffic into the API. + +In `bot/src/index.ts`: + +- **Passive link detection (`messageCreate`):** add a per-channel cooldown (in-memory `Map`, e.g. one auto-reply per channel per 30s), and only respond when the link includes fight/source info (`parsed.fightId !== undefined`) — a bare report link pasted in chat shouldn't trigger a reply. Note this path only posts a button (no API call), so the cooldown is about spam, not cost. +- **Slash commands (`/raid`, `/analyze`):** add a per-user cooldown (e.g. one command per user per 10s) with an ephemeral "give it a few seconds" reply when throttled. +- Wrap the `message.reply` and command handlers so a rejected promise (e.g. missing channel permissions) is caught and logged instead of becoming an unhandled rejection — **on Node 20 an unhandled rejection crashes the process**, which currently kills the bot. +- Handle 429 responses from the ParseForge API (Task 1.1) with a friendly message instead of dumping `API 429: {...}` into chat. While in `bot/src/api.ts`, stop echoing raw upstream response bodies into Discord — surface only the `error` field if the body parses as JSON, else a generic message. +- Fix the stale activity string `"getlootlist.com"` → `"parseforge.gg"`. + +**Acceptance:** Bot builds (`cd bot && npm run build`). Pasting a bare report link gets no reply; a link with `#fight=&source=` gets the button, at most once per 30s per channel. A permissions error on reply logs a warning and the bot stays up. + +--- + +## Phase 2 — Hardening (same week) + +### Task 2.1 — Security headers 🟠 + +In `next.config.ts`, add a `headers()` block applying to `/(.*)`: + +- `X-Frame-Options: SAMEORIGIN` +- `X-Content-Type-Options: nosniff` +- `Referrer-Policy: strict-origin-when-cross-origin` +- `Permissions-Policy: camera=(), microphone=(), geolocation=()` +- **Content-Security-Policy in Report-Only mode first** (`Content-Security-Policy-Report-Only`). It must allow: `'self'`; the inline Wowhead config script in `app/layout.tsx` (either add a nonce or, pragmatically, `'unsafe-inline'` for scripts to start); `https://wow.zamimg.com` (Wowhead tooltips.js + images); the PostHog reverse-proxy path `/ingest` is same-origin, but PostHog session replay may need `'unsafe-eval'`/worker allowances — **verify in the browser console before promoting to enforcing mode**, and leave it report-only in this task. + +**Do not** apply frame-blocking headers to `/og` (link unfurlers fetch it; images are fine, but double-check nothing breaks Discord/Twitter unfurls after deploy). + +**Acceptance:** `npm run build` passes; local dev shows the headers on responses; site renders with zero CSP violations in the console on the landing page and an analyze page (report-only mode). + +### Task 2.2 — Tighten `/og` route inputs 🟠 + +In `app/og/route.tsx`: + +- Validate `report` against `/^[a-zA-Z0-9]{10,20}$/`; if invalid, render the generic `ReportCard` (never fetch). +- Only take the analysis path when `fight` and `source` parse via `Number.parseInt` to finite integers ≥ 0. +- Replace the request-derived `origin` with a constant: use `https://parseforge.gg` in production and fall back to the request origin only in development (`process.env.NODE_ENV`). Keep the existing "never fail an unfurl" catch-all. + +**Acceptance:** `/og?report=` returns the branded fallback card without hitting the API; the demo report's OG URL (see `lib/demo-report.ts` comment) still renders the player card. + +### Task 2.3 — Integer-coerce IDs in the built GraphQL query 🟠 + +In `lib/wcl-queries.ts` → `buildCLABuffUptimeQuery`: the query is built by string interpolation of `sourceIds`. They currently come from WCL's own actor list (safe), but this is the only place data could reach a query body as a raw string. Coerce each with `Number(id)` and skip any that fail `Number.isInteger` / are negative. One-line insurance against future refactors. + +### Task 2.4 — PostHog session-replay privacy 🟠 + +In `app/components/PostHogProvider.tsx`: + +- Set `enable_recording_console_log: false` (console capture can hoover up anything logged client-side). +- Set `maskAllInputs: true` and remove the custom `maskInputFn` un-masking (the report-URL input being masked in replays is an acceptable trade for the privacy default). +- Leave a `// TODO` noting that serving EU users with session replay ultimately needs a consent banner — that's a product decision, not part of this task. + +### Task 2.5 — Share the OAuth token across instances 🟠 + +In `lib/wcl-client.ts`, `getAccessToken` caches the WCL token in module scope — per serverless instance. Store it in Redis too: + +- On cache check: module-scope token first (fastest), then Redis (`GET wcl:token` holding `{ token, expiresAt }`), then fetch from WCL and write back to both (`SET ... EX `). +- Reuse `cacheGet`/`cacheSet` from `lib/kv-cache.ts`. Without Redis: exactly today's behavior. +- Keep the existing 401-refresh path working: on 401 it must clear **both** module and Redis copies before refetching. + +**Acceptance:** Type-checks; local dev without Redis still authenticates; the 401 retry path in `wclQuery` still clears state correctly. + +--- + +## Phase 3 — Ops hygiene 🟡 + +### Task 3.1 — CI + +Add `.github/workflows/ci.yml`: on PR and push to main, Node 20, `npm ci`, `npx tsc --noEmit`, `npm run lint`, `npm run build`. Second job for the bot: `cd bot && npm ci && npm run build`. No deploy steps (Vercel handles deploys). + +### Task 3.2 — Bot Dockerfile + +In `bot/Dockerfile`: add `USER node` before `CMD`, and `ENV NODE_ENV=production`. Note in a comment that the runtime should set a restart policy (`restart: unless-stopped` or equivalent) since the bot exits on fatal errors. + +### Task 3.3 — Unify the duplicated URL parser + +`lib/url-parser.ts` and `bot/src/util/parse-url.ts` are two drifting copies of `parseWCLUrl`. The web version is more robust (loose text scan, bounded code length). Port the web version's logic into the bot's copy so behavior matches (they're separate packages, so duplication stays — just make them identical and add a comment in each pointing at the other). + +--- + +## Phase 4 — Tests for the engines 🟡 + +The product **is** the numbers produced by `lib/analysis-engine.ts` (~1,300 lines), `lib/cla-engine.ts` (~600), and `lib/raid-overview-engine.ts` (~400). They're pure functions of their inputs — ideal for unit tests, and currently untested. + +- Add `vitest` as a dev dependency, `"test": "vitest run"` script, and include it in CI. +- Priority order: + 1. `lib/url-parser.ts` — table-driven tests for every documented format (bare code, hash params, query params, `fight=last`, text with embedded URL, junk input). + 2. `lib/async-pool.ts` — order preservation, concurrency cap, empty input. + 3. `lib/api-utils.ts` → `parseBody` — including the "0 is a valid fightId/sourceId" case called out in its comment, plus the new validators from Task 1.2. + 4. `lib/analysis-engine.ts` / `lib/cla-engine.ts` — build small fixture inputs (hand-written, minimal WCL-shaped objects) and assert grades/percentiles/flags. Start with a few high-value cases (empty rankings, healer vs dps path, missing enchant detection) rather than aiming for coverage. +- Rate-limit and single-flight logic (Tasks 1.1/1.3): test the pure parts (key derivation, allow/deny decisions) with a mocked Redis; don't build integration infra. + +--- + +## Explicitly out of scope (don't do these) + +- No auth/login system — the app is intentionally public. +- No database — localStorage history stays as-is. +- No queue/background-job infrastructure. +- No CSP enforcing mode in this pass (report-only first, promote manually after checking real traffic). +- No changes to the analysis math/output values — tests characterize current behavior; they don't "fix" it. + +## Definition of done + +- [ ] All Phase 1 tasks complete — this is the launch-blocking set +- [ ] Phase 2 complete +- [ ] Phase 3 complete +- [ ] Phase 4: parser/pool/parseBody tests exist and run in CI; engine tests started +- [ ] `npm run build`, `npx tsc --noEmit`, `npm run lint`, and `npm test` all pass; bot builds +- [ ] Verified locally **without** Redis env vars that everything still works (rate limiting no-ops, cache falls back to memory) diff --git a/app/api/analyze/route.ts b/app/api/analyze/route.ts index 8521fff..67e19ce 100644 --- a/app/api/analyze/route.ts +++ b/app/api/analyze/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { wclQuery } from "@/lib/wcl-client"; import { cachedApiHandler, parseBody } from "@/lib/api-utils"; +import { checkRateLimit } from "@/lib/rate-limit"; import { PLAYER_FULL_DATA_QUERY, PLAYER_FULL_DATA_QUERY_HEALING, @@ -60,6 +61,9 @@ export async function POST(request: NextRequest) { const body = parsed.body; const { reportCode, fightId, sourceId } = body; + const limited = await checkRateLimit(request, "analyze"); + if (limited) return limited; + return cachedApiHandler(`analyze-${reportCode}-${fightId}-${sourceId}`, async () => { // Step 1: We need player details first to detect role, so fetch with DPS query initially // and re-fetch with healing query if needed diff --git a/app/api/cla/route.ts b/app/api/cla/route.ts index 65c126a..224b941 100644 --- a/app/api/cla/route.ts +++ b/app/api/cla/route.ts @@ -10,6 +10,7 @@ import { getWowheadDomain } from "@/lib/constants"; import { flattenPlayerDetails } from "@/lib/wcl-helpers"; import { mapPool } from "@/lib/async-pool"; import { cachedApiHandler, parseBody } from "@/lib/api-utils"; +import { checkRateLimit } from "@/lib/rate-limit"; import type { CLAFightMeta } from "@/lib/cla-types"; import type { WCLPlayerDetails, @@ -51,6 +52,9 @@ export async function POST(request: NextRequest) { if ("error" in parsed) return parsed.error; const { reportCode, fightIds } = parsed.body; + const limited = await checkRateLimit(request, "cla"); + if (limited) return limited; + if (fightIds.length === 0) { return NextResponse.json({ error: "No fights specified" }, { status: 400 }); } diff --git a/app/api/raid-overview/route.ts b/app/api/raid-overview/route.ts index 7d10266..a1fdbd8 100644 --- a/app/api/raid-overview/route.ts +++ b/app/api/raid-overview/route.ts @@ -8,6 +8,7 @@ import { import { buildRaidOverview } from "@/lib/raid-overview-engine"; import { flattenPlayerDetails } from "@/lib/wcl-helpers"; import { cachedApiHandler, parseBody } from "@/lib/api-utils"; +import { checkRateLimit } from "@/lib/rate-limit"; import type { WCLPlayerDetails, WCLCombatantInfoEvent, @@ -113,6 +114,9 @@ export async function POST(request: NextRequest) { if ("error" in parsed) return parsed.error; const { reportCode, fightId } = parsed.body; + const limited = await checkRateLimit(request, "raid-overview"); + if (limited) return limited; + return cachedApiHandler(`rpb-${reportCode}-${fightId}`, async () => { const [overviewData, combatantData, deathEventsData] = await Promise.all([ wclQuery(RAID_OVERVIEW_QUERY, { diff --git a/app/api/report/[code]/players/route.ts b/app/api/report/[code]/players/route.ts index 7c0b097..b0b73a2 100644 --- a/app/api/report/[code]/players/route.ts +++ b/app/api/report/[code]/players/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { wclQuery } from "@/lib/wcl-client"; import { errorResponse } from "@/lib/api-utils"; +import { checkRateLimit } from "@/lib/rate-limit"; import type { WCLPlayerDetails } from "@/lib/wcl-types"; interface PlayerDetailsResponse { @@ -39,6 +40,9 @@ export async function GET( return NextResponse.json({ error: "Missing fightId" }, { status: 400 }); } + const limited = await checkRateLimit(request, "report-players"); + if (limited) return limited; + try { const data = await wclQuery(QUERY, { code, diff --git a/app/api/report/[code]/route.ts b/app/api/report/[code]/route.ts index 448ae79..05ce95c 100644 --- a/app/api/report/[code]/route.ts +++ b/app/api/report/[code]/route.ts @@ -2,11 +2,12 @@ import { NextRequest, NextResponse } from "next/server"; import { wclQuery, WCLError } from "@/lib/wcl-client"; import { REPORT_META_QUERY } from "@/lib/wcl-queries"; import { errorResponse } from "@/lib/api-utils"; +import { checkRateLimit } from "@/lib/rate-limit"; import { mapReportMeta } from "@/lib/report-meta"; import { WCLReportData } from "@/lib/wcl-types"; export async function GET( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ code: string }> } ) { const { code } = await params; @@ -15,6 +16,9 @@ export async function GET( return NextResponse.json({ error: "Invalid report code" }, { status: 400 }); } + const limited = await checkRateLimit(request, "report"); + if (limited) return limited; + try { const data = await wclQuery<{ reportData: { report: WCLReportData } }>( REPORT_META_QUERY, diff --git a/lib/constants.ts b/lib/constants.ts index 55812e8..6608b23 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -86,6 +86,21 @@ export const TOKEN_EXPIRY_BUFFER = 60; // seconds before expiry to refresh export const TOP_PLAYERS_TO_FETCH = 3; +// ─── Rate limiting (per-IP, sliding window) ────────────────────────── +// Requests allowed per IP per RATE_LIMIT_WINDOW, tuned per route by cost — +// `/api/cla` fans out the most so it's the tightest. Enforced only when Redis +// is configured; a no-op in local dev (see lib/rate-limit.ts). Keys are the +// bucket strings passed to checkRateLimit(); the two report routes get their +// own buckets so a cheap GET never starves the other. +export const RATE_LIMIT_WINDOW = "60 s"; // Upstash duration string +export const RATE_LIMITS: Record = { + analyze: 30, + "raid-overview": 30, + cla: 10, + report: 60, + "report-players": 60, +}; + export const HEALER_SPECS = new Set([ "Restoration", "Holy", diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts new file mode 100644 index 0000000..ea050d0 --- /dev/null +++ b/lib/rate-limit.ts @@ -0,0 +1,87 @@ +import { NextResponse } from "next/server"; +import { Ratelimit } from "@upstash/ratelimit"; +import { Redis } from "@upstash/redis"; +import { RATE_LIMITS, RATE_LIMIT_WINDOW } from "./constants"; +import { logEvent } from "./observability"; + +// Per-IP sliding-window rate limiting for the API routes, backed by the same +// Upstash Redis the result cache uses. +// +// Env resolution mirrors lib/kv-cache.ts EXACTLY — Vercel KV (`KV_REST_API_*`) +// or native Upstash (`UPSTASH_REDIS_REST_*`) — so both features light up from +// the same credentials the Marketplace integration injects. When Redis is not +// configured (local dev, or before provisioning) rate limiting is a no-op that +// always allows, the same graceful-degradation contract as the cache. A Redis +// error at request time also fails OPEN (allow), so a transient Redis blip can +// never take the whole site down. + +const REDIS_URL = + process.env.KV_REST_API_URL ?? process.env.UPSTASH_REDIS_REST_URL ?? ""; +const REDIS_TOKEN = + process.env.KV_REST_API_TOKEN ?? process.env.UPSTASH_REDIS_REST_TOKEN ?? ""; + +/** True when a shared Redis is configured — mirrors kv-cache's `usingSharedCache`. */ +const enabled = Boolean(REDIS_URL && REDIS_TOKEN); + +// Construct the client manually rather than Redis.fromEnv(): fromEnv() only +// reads the UPSTASH_* names and would miss the KV_REST_API_* names the Vercel +// Marketplace integration injects. +const redis = enabled ? new Redis({ url: REDIS_URL, token: REDIS_TOKEN }) : null; + +// One limiter per bucket, created lazily and reused across requests on the same +// instance (each holds its own sliding window). +const limiters = new Map(); + +function limiterFor(bucket: string): Ratelimit { + let limiter = limiters.get(bucket); + if (!limiter) { + const limit = RATE_LIMITS[bucket] ?? 30; // conservative default for unknown buckets + limiter = new Ratelimit({ + redis: redis!, + limiter: Ratelimit.slidingWindow(limit, RATE_LIMIT_WINDOW), + prefix: `rl:${bucket}`, + }); + limiters.set(bucket, limiter); + } + return limiter; +} + +/** Client identity for rate limiting: first x-forwarded-for entry, else "anon". */ +function clientKey(request: Request): string { + const xff = request.headers.get("x-forwarded-for"); + const ip = xff?.split(",")[0]?.trim(); + return ip || "anon"; +} + +/** + * Rate-limit gate for an API route. Returns a 429 `NextResponse` (with a + * `Retry-After` header) when the caller is over the limit for `bucket`, or + * `null` when the request may proceed. Returns `null` (allows) when Redis is + * not configured, and fails open on any Redis error. + * + * Call it as the first thing after body/param parsing in each route. + */ +export async function checkRateLimit( + request: Request, + bucket: string, +): Promise { + if (!enabled || !redis) return null; + + try { + const { success, reset } = await limiterFor(bucket).limit(clientKey(request)); + if (success) return null; + + logEvent("rate_limited", { route: bucket }); + const retryAfter = Math.max(1, Math.ceil((reset - Date.now()) / 1000)); + return NextResponse.json( + { error: "Too many requests — please wait a moment and try again." }, + { status: 429, headers: { "Retry-After": String(retryAfter) } }, + ); + } catch (err) { + // Fail open: a rate-limiter outage must not break the API. + console.error( + `[rate-limit] ${bucket} check failed, allowing request: ${(err as Error).message}`, + ); + return null; + } +} diff --git a/package-lock.json b/package-lock.json index 4f37cac..f663cbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "parseforge", "version": "0.1.0", "dependencies": { + "@upstash/ratelimit": "^2.0.8", + "@upstash/redis": "^1.38.0", "@vercel/analytics": "^2.0.1", "@vercel/speed-insights": "^2.0.0", "class-variance-authority": "^0.7.1", @@ -4811,6 +4813,39 @@ "win32" ] }, + "node_modules/@upstash/core-analytics": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@upstash/core-analytics/-/core-analytics-0.0.10.tgz", + "integrity": "sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ==", + "license": "MIT", + "dependencies": { + "@upstash/redis": "^1.28.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@upstash/ratelimit": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@upstash/ratelimit/-/ratelimit-2.0.8.tgz", + "integrity": "sha512-YSTMBJ1YIxsoPkUMX/P4DDks/xV5YYCswWMamU8ZIfK9ly6ppjRnVOyBhMDXBmzjODm4UQKcxsJPvaeFAijp5w==", + "license": "MIT", + "dependencies": { + "@upstash/core-analytics": "^0.0.10" + }, + "peerDependencies": { + "@upstash/redis": "^1.34.3" + } + }, + "node_modules/@upstash/redis": { + "version": "1.38.0", + "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.38.0.tgz", + "integrity": "sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, "node_modules/@vercel/analytics": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-2.0.1.tgz", @@ -11833,6 +11868,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index 9c7b32f..7fb8fe2 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "lint": "eslint" }, "dependencies": { + "@upstash/ratelimit": "^2.0.8", + "@upstash/redis": "^1.38.0", "@vercel/analytics": "^2.0.1", "@vercel/speed-insights": "^2.0.0", "class-variance-authority": "^0.7.1", From c453db10baa92e5189f67c9992a311eab2a7cfce Mon Sep 17 00:00:00 2001 From: Alexander Mayes Date: Sun, 19 Jul 2026 18:59:56 -0700 Subject: [PATCH 02/19] Phase 1.2: validate POST inputs + cap /api/cla fan-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ --- app/api/analyze/route.ts | 9 ++++++++- app/api/cla/route.ts | 29 ++++++++++++++++++++++------- app/api/raid-overview/route.ts | 7 ++++++- lib/api-utils.ts | 13 +++++++++++++ lib/constants.ts | 5 +++++ 5 files changed, 54 insertions(+), 9 deletions(-) diff --git a/app/api/analyze/route.ts b/app/api/analyze/route.ts index 67e19ce..69e0d71 100644 --- a/app/api/analyze/route.ts +++ b/app/api/analyze/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { wclQuery } from "@/lib/wcl-client"; -import { cachedApiHandler, parseBody } from "@/lib/api-utils"; +import { cachedApiHandler, parseBody, isValidReportCode, badRequest } from "@/lib/api-utils"; import { checkRateLimit } from "@/lib/rate-limit"; import { PLAYER_FULL_DATA_QUERY, @@ -64,6 +64,13 @@ export async function POST(request: NextRequest) { const limited = await checkRateLimit(request, "analyze"); if (limited) return limited; + // Validate before building the cache key / querying WCL. Number.isInteger + // rejects non-numbers too, and 0 stays valid (fight/source slots are 0-indexed). + if (!isValidReportCode(reportCode)) return badRequest("Invalid report code."); + if (!Number.isInteger(fightId) || !Number.isInteger(sourceId)) { + return badRequest("Invalid fight or source id — expected integers."); + } + return cachedApiHandler(`analyze-${reportCode}-${fightId}-${sourceId}`, async () => { // Step 1: We need player details first to detect role, so fetch with DPS query initially // and re-fetch with healing query if needed diff --git a/app/api/cla/route.ts b/app/api/cla/route.ts index 224b941..cf73587 100644 --- a/app/api/cla/route.ts +++ b/app/api/cla/route.ts @@ -6,10 +6,10 @@ import { buildCLABuffUptimeQuery, } from "@/lib/wcl-queries"; import { buildCLAResult, type CLAEngineInput } from "@/lib/cla-engine"; -import { getWowheadDomain } from "@/lib/constants"; +import { getWowheadDomain, MAX_CLA_FIGHTS } from "@/lib/constants"; import { flattenPlayerDetails } from "@/lib/wcl-helpers"; import { mapPool } from "@/lib/async-pool"; -import { cachedApiHandler, parseBody } from "@/lib/api-utils"; +import { cachedApiHandler, parseBody, isValidReportCode, badRequest } from "@/lib/api-utils"; import { checkRateLimit } from "@/lib/rate-limit"; import type { CLAFightMeta } from "@/lib/cla-types"; import type { @@ -55,13 +55,28 @@ export async function POST(request: NextRequest) { const limited = await checkRateLimit(request, "cla"); if (limited) return limited; - if (fightIds.length === 0) { + // Validate BEFORE any use. A non-array `fightIds` (e.g. "abc") would otherwise + // throw on .length/.filter → unhandled 500; non-integer or unbounded lists + // pollute the cache key and can fan out into the shared WCL budget. + if (!isValidReportCode(reportCode)) return badRequest("Invalid report code."); + if (!Array.isArray(fightIds) || fightIds.length === 0) { return NextResponse.json({ error: "No fights specified" }, { status: 400 }); } + if (!fightIds.every((id) => Number.isInteger(id))) { + return badRequest("Invalid fight id — expected integers."); + } + // Dedupe (collapses accidental repeats) then cap: each fight fans out to + // multiple WCL queries, so an unbounded list could drain the daily budget. + const uniqueFightIds = [...new Set(fightIds)]; + if (uniqueFightIds.length > MAX_CLA_FIGHTS) { + return badRequest( + `Too many fights selected — please select ${MAX_CLA_FIGHTS} or fewer.`, + ); + } // Sort a copy numerically — sort() is in-place and lexicographic, which would // mutate the caller's array and order [2,10] as "10,2". - const cacheFightIds = [...fightIds].sort((a, b) => a - b); + const cacheFightIds = [...uniqueFightIds].sort((a, b) => a - b); return cachedApiHandler(`cla-${reportCode}-${cacheFightIds.join(",")}`, async () => { // Step 1: Fetch report metadata (fights + players) const metaData = await wclQuery(REPORT_META_QUERY, { @@ -71,8 +86,8 @@ export async function POST(request: NextRequest) { const zoneName = report.zone?.name; const wowheadDomain = getWowheadDomain(zoneName, report.zone?.expansion?.id); - // Filter to requested fights - const selectedFights = report.fights.filter((f) => fightIds.includes(f.id)); + // Filter to requested fights (deduped + capped set) + const selectedFights = report.fights.filter((f) => uniqueFightIds.includes(f.id)); if (selectedFights.length === 0) { return NextResponse.json({ error: "No matching fights found" }, { status: 404 }); } @@ -120,7 +135,7 @@ export async function POST(request: NextRequest) { // Fetch player details using all fight IDs const playerDetailsPromise = wclQuery( playerDetailsQuery, - { code: reportCode, fightIDs: fightIds } + { code: reportCode, fightIDs: uniqueFightIds } ); // Batch source IDs into groups of BATCH_SIZE (same for every fight) diff --git a/app/api/raid-overview/route.ts b/app/api/raid-overview/route.ts index a1fdbd8..2d43abf 100644 --- a/app/api/raid-overview/route.ts +++ b/app/api/raid-overview/route.ts @@ -7,7 +7,7 @@ import { } from "@/lib/wcl-queries"; import { buildRaidOverview } from "@/lib/raid-overview-engine"; import { flattenPlayerDetails } from "@/lib/wcl-helpers"; -import { cachedApiHandler, parseBody } from "@/lib/api-utils"; +import { cachedApiHandler, parseBody, isValidReportCode, badRequest } from "@/lib/api-utils"; import { checkRateLimit } from "@/lib/rate-limit"; import type { WCLPlayerDetails, @@ -117,6 +117,11 @@ export async function POST(request: NextRequest) { const limited = await checkRateLimit(request, "raid-overview"); if (limited) return limited; + if (!isValidReportCode(reportCode)) return badRequest("Invalid report code."); + if (!Number.isInteger(fightId)) { + return badRequest("Invalid fight id — expected an integer."); + } + return cachedApiHandler(`rpb-${reportCode}-${fightId}`, async () => { const [overviewData, combatantData, deathEventsData] = await Promise.all([ wclQuery(RAID_OVERVIEW_QUERY, { diff --git a/lib/api-utils.ts b/lib/api-utils.ts index 18327a9..caaf241 100644 --- a/lib/api-utils.ts +++ b/lib/api-utils.ts @@ -25,6 +25,19 @@ export function errorResponse(error: unknown, context: string): NextResponse { ); } +/** WCL report codes are 10–20 alphanumeric chars. Shared by every route that + * accepts a code so validation (and cache-key hygiene) stays consistent — bad + * codes must never reach a WCL query or pollute a cache key. */ +export const REPORT_CODE_RE = /^[a-zA-Z0-9]{10,20}$/; +export function isValidReportCode(code: unknown): code is string { + return typeof code === "string" && REPORT_CODE_RE.test(code); +} + +/** 400 with a clean client-facing message. */ +export function badRequest(message: string): NextResponse { + return NextResponse.json({ error: message }, { status: 400 }); +} + /** * Wraps an API route handler with cache check and error handling. * Returns cached result if available, otherwise runs the handler, diff --git a/lib/constants.ts b/lib/constants.ts index 6608b23..fe0b029 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -101,6 +101,11 @@ export const RATE_LIMITS: Record = { "report-players": 60, }; +// Cap on fights per /api/cla request. Each selected fight fans out to multiple +// WCL queries (buff batches + combatant info), so this bounds the worst-case +// WCL spend a single request can trigger. +export const MAX_CLA_FIGHTS = 15; + export const HEALER_SPECS = new Set([ "Restoration", "Holy", From 4349d360a8b5415d214a06785a0c6459ca233a87 Mon Sep 17 00:00:00 2001 From: Alexander Mayes Date: Sun, 19 Jul 2026 19:05:22 -0700 Subject: [PATCH 03/19] Phase 1.3: single-flight lock to kill cache stampedes 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) Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ --- lib/api-utils.ts | 88 +++++++++++++++++++++++++++++++++++++----------- lib/kv-cache.ts | 41 ++++++++++++++++++++++ 2 files changed, 110 insertions(+), 19 deletions(-) diff --git a/lib/api-utils.ts b/lib/api-utils.ts index caaf241..1e5ef8c 100644 --- a/lib/api-utils.ts +++ b/lib/api-utils.ts @@ -1,8 +1,27 @@ import { NextResponse } from "next/server"; import { getCached, setCache, WCLError } from "./wcl-client"; +import { usingSharedCache, cacheLock, cacheUnlock } from "./kv-cache"; import { ANALYSIS_CACHE_TTL } from "./constants"; import { logEvent, routeFromCacheKey } from "./observability"; +// Single-flight wait tuning: a request that lost the lock polls the cache this +// long before giving up and computing itself, checking this often. +const CACHE_WAIT_MS = 15_000; +const CACHE_POLL_MS = 500; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** Poll the shared cache until it fills or we exhaust the wait budget. */ +async function waitForCache(cacheKey: string): Promise { + const deadline = Date.now() + CACHE_WAIT_MS; + while (Date.now() < deadline) { + await sleep(CACHE_POLL_MS); + const cached = await getCached(cacheKey); + if (cached) return cached; + } + return null; +} + /** * Map any caught error to a clean client response. WCLError carries an * actionable user message + correct status; everything else becomes a generic @@ -56,33 +75,64 @@ export async function cachedApiHandler( return NextResponse.json(cached); } - try { - const result = await handler(); + // The original miss path — run the handler, cache the result, return it. + // Extracted so the single-flight branch below can reuse it. + const runAndCache = async (): Promise => { + try { + const result = await handler(); - // If handler returned a NextResponse directly (e.g. 404), pass it through - if (result instanceof NextResponse) { + // If handler returned a NextResponse directly (e.g. 404), pass it through + // (and don't cache it). + if (result instanceof NextResponse) { + logEvent("api_request", { + route, + cache: "miss", + outcome: "early_return", + status: result.status, + ms: Date.now() - start, + }); + return result; + } + + await setCache(cacheKey, result, ANALYSIS_CACHE_TTL); + logEvent("api_request", { route, cache: "miss", outcome: "ok", ms: Date.now() - start }); + return NextResponse.json(result); + } catch (error) { logEvent("api_request", { route, cache: "miss", - outcome: "early_return", - status: result.status, + outcome: "error", + kind: error instanceof WCLError ? error.kind : "unknown", ms: Date.now() - start, }); - return result; + return errorResponse(error, cacheKey); + } + }; + + // Without shared Redis there's nothing to coordinate across instances — run + // directly, exactly as before. + if (!usingSharedCache) return runAndCache(); + + // Single-flight: only the lock holder fans out to WCL for this key. Everyone + // else waits for the cache to fill, collapsing a stampede (50 people opening + // the same freshly-shared report at once) into one upstream computation. + const acquired = await cacheLock(cacheKey); + if (!acquired) { + const waited = await waitForCache(cacheKey); + if (waited) { + logEvent("api_request", { route, cache: "wait_hit", outcome: "ok", ms: Date.now() - start }); + return NextResponse.json(waited); } + // The holder never filled the cache (slow, errored, or returned an early + // 404). Compute ourselves rather than dead-ending the user. + return runAndCache(); + } - await setCache(cacheKey, result, ANALYSIS_CACHE_TTL); - logEvent("api_request", { route, cache: "miss", outcome: "ok", ms: Date.now() - start }); - return NextResponse.json(result); - } catch (error) { - logEvent("api_request", { - route, - cache: "miss", - outcome: "error", - kind: error instanceof WCLError ? error.kind : "unknown", - ms: Date.now() - start, - }); - return errorResponse(error, cacheKey); + try { + return await runAndCache(); + } finally { + // Release promptly so waiters proceed; the lock's EX TTL backstops a crash. + await cacheUnlock(cacheKey); } } diff --git a/lib/kv-cache.ts b/lib/kv-cache.ts index bfcfb50..0b1400b 100644 --- a/lib/kv-cache.ts +++ b/lib/kv-cache.ts @@ -87,6 +87,47 @@ export async function cacheSet(key: string, value: unknown, ttlMs: number): Prom memSet(key, value, ttlMs); } +// ─── Single-flight locks (cache-stampede protection) ──────────────── +// A short-lived Redis lock so that when many requests miss the cache for the +// same key at once, only the lock holder fans out to WCL; the rest wait for the +// cache to fill (see cachedApiHandler). Only meaningful with shared Redis — a +// per-instance lock can't coordinate across serverless instances — so these +// no-op without it. The EX TTL is the safety net: if the holder's instance dies +// mid-computation the lock auto-expires instead of wedging the key forever. +const LOCK_TTL_SEC = 20; + +/** Try to acquire the lock for `key`. Returns true only if we got it. */ +export async function cacheLock(key: string): Promise { + if (!usingSharedCache) return false; + try { + const { result } = await redisCmd([ + "SET", + `lock:${key}`, + "1", + "NX", + "EX", + LOCK_TTL_SEC, + ]); + // SET ... NX returns "OK" when the key was set, null when it already exists. + return result === "OK"; + } catch (err) { + // On error, report "not acquired" so the caller waits/falls through rather + // than wrongly assuming exclusive ownership. + console.error(`[kv-cache] cacheLock ${key} failed: ${(err as Error).message}`); + return false; + } +} + +/** Release a lock acquired via cacheLock. Best-effort; the TTL backstops it. */ +export async function cacheUnlock(key: string): Promise { + if (!usingSharedCache) return; + try { + await redisCmd(["DEL", `lock:${key}`]); + } catch (err) { + console.error(`[kv-cache] cacheUnlock ${key} failed: ${(err as Error).message}`); + } +} + // ─── Recently-indexed reports (feeds the sitemap) ──────────────────── // A sorted set of report codes scored by last-seen timestamp, written each // time a public report is server-rendered. Only active when shared Redis is From 9b484f1c0c5d468358cdb02718d11c9607ec85a3 Mon Sep 17 00:00:00 2001 From: Alexander Mayes Date: Sun, 19 Jul 2026 19:42:02 -0700 Subject: [PATCH 04/19] Phase 1.4: Discord bot cooldowns + crash hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ --- bot/src/api.ts | 37 ++++++++++++++- bot/src/commands/analyze.ts | 5 +-- bot/src/commands/raid.ts | 5 +-- bot/src/index.ts | 89 +++++++++++++++++++++++++++++-------- 4 files changed, 109 insertions(+), 27 deletions(-) diff --git a/bot/src/api.ts b/bot/src/api.ts index 8dd75df..7eea5f0 100644 --- a/bot/src/api.ts +++ b/bot/src/api.ts @@ -2,6 +2,17 @@ import type { ReportMeta, RaidOverviewResult, AnalysisResult } from "./types.js" const API_URL = process.env.PARSEFORGE_API_URL || "https://parseforge.gg"; +/** A failed ParseForge API call, carrying the status and a clean, postable message. */ +export class ApiError extends Error { + constructor( + public status: number, + message: string, + ) { + super(message); + this.name = "ApiError"; + } +} + async function apiFetch(path: string, options?: RequestInit): Promise { const res = await fetch(`${API_URL}${path}`, { ...options, @@ -12,13 +23,35 @@ async function apiFetch(path: string, options?: RequestInit): Promise { }); if (!res.ok) { - const text = await res.text().catch(() => "Unknown error"); - throw new Error(`API ${res.status}: ${text}`); + // Never echo raw upstream bodies into Discord. Surface only the API's + // `error` field when the body is JSON, otherwise a generic message. + let message = "Something went wrong. Please try again."; + const text = await res.text().catch(() => ""); + if (text) { + try { + const parsed = JSON.parse(text) as { error?: unknown }; + if (typeof parsed.error === "string" && parsed.error) message = parsed.error; + } catch { + // Non-JSON body — keep the generic message. + } + } + throw new ApiError(res.status, message); } return res.json() as Promise; } +/** User-facing text for an API failure, always safe to post in a Discord reply. */ +export function describeApiError(err: unknown): string { + if (err instanceof ApiError) { + if (err.status === 429) { + return "ParseForge is handling a lot of requests right now — give it a few seconds and try again."; + } + return err.message; + } + return "Something went wrong. Please try again."; +} + export function fetchReportMeta(reportCode: string): Promise { return apiFetch(`/api/report/${reportCode}`); } diff --git a/bot/src/commands/analyze.ts b/bot/src/commands/analyze.ts index b5e5675..0eacd28 100644 --- a/bot/src/commands/analyze.ts +++ b/bot/src/commands/analyze.ts @@ -1,6 +1,6 @@ import { ChatInputCommandInteraction } from "discord.js"; import { parseWCLUrl } from "../util/parse-url.js"; -import { fetchReportMeta, fetchRaidOverview, fetchAnalysis } from "../api.js"; +import { fetchReportMeta, fetchRaidOverview, fetchAnalysis, describeApiError } from "../api.js"; import { buildAnalyzeEmbed } from "../embeds/analyze-embed.js"; import { buildRaidEmbed } from "../embeds/raid-embed.js"; @@ -57,7 +57,6 @@ export async function handleAnalyze( ...reply, }); } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - await interaction.editReply(`Failed to fetch analysis: ${message}`); + await interaction.editReply(describeApiError(err)); } } diff --git a/bot/src/commands/raid.ts b/bot/src/commands/raid.ts index 52d2d72..a73b3c6 100644 --- a/bot/src/commands/raid.ts +++ b/bot/src/commands/raid.ts @@ -1,6 +1,6 @@ import { ChatInputCommandInteraction } from "discord.js"; import { parseWCLUrl } from "../util/parse-url.js"; -import { fetchReportMeta, fetchRaidOverview } from "../api.js"; +import { fetchReportMeta, fetchRaidOverview, describeApiError } from "../api.js"; import { buildRaidEmbed } from "../embeds/raid-embed.js"; export async function handleRaid( @@ -34,7 +34,6 @@ export async function handleRaid( const reply = buildRaidEmbed(result, parsed.code, fightId); await interaction.editReply(reply); } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - await interaction.editReply(`Failed to fetch raid data: ${message}`); + await interaction.editReply(describeApiError(err)); } } diff --git a/bot/src/index.ts b/bot/src/index.ts index 03b5f8e..f148a27 100644 --- a/bot/src/index.ts +++ b/bot/src/index.ts @@ -76,25 +76,63 @@ const client = new Client({ client.once("ready", (c) => { console.log(`Logged in as ${c.user.tag}`); - c.user.setActivity("getlootlist.com", { type: ActivityType.Playing }); + c.user.setActivity("parseforge.gg", { type: ActivityType.Playing }); }); +// ─── Cooldowns (in-memory, best-effort) ────────────────────────────── +// These blunt spam/amplification, not authoritative rate limits — a bot restart +// resets them, which is fine. Per-channel for passive link replies; per-user for +// slash commands so one user can't hammer the API through /raid or /analyze. +const CHANNEL_COOLDOWN_MS = 30_000; +const USER_COMMAND_COOLDOWN_MS = 10_000; +const channelCooldowns = new Map(); +const userCooldowns = new Map(); + +/** True if `key` is still cooling down; otherwise stamps `now` and returns false. */ +function onCooldown(map: Map, key: string, windowMs: number): boolean { + const now = Date.now(); + const last = map.get(key); + if (last !== undefined && now - last < windowMs) return true; + map.set(key, now); + return false; +} + client.on("interactionCreate", async (interaction: Interaction) => { if (!interaction.isChatInputCommand()) return; - switch (interaction.commandName) { - case "raid": - await handleRaid(interaction); - break; - case "analyze": - await handleAnalyze(interaction); - break; - default: - if (interaction.replied || interaction.deferred) { - await interaction.editReply("Unknown command."); - } else { - await interaction.reply({ content: "Unknown command.", ephemeral: true }); - } + // Per-user cooldown — a single user can't hammer the API through commands. + if (onCooldown(userCooldowns, interaction.user.id, USER_COMMAND_COOLDOWN_MS)) { + await interaction + .reply({ content: "Give it a few seconds between commands and try again.", ephemeral: true }) + .catch(() => {}); + return; + } + + // A handler that rejects (e.g. a Discord permission/API error) would otherwise + // surface as an unhandled rejection and crash the process on Node 20. + try { + switch (interaction.commandName) { + case "raid": + await handleRaid(interaction); + break; + case "analyze": + await handleAnalyze(interaction); + break; + default: + if (interaction.replied || interaction.deferred) { + await interaction.editReply("Unknown command."); + } else { + await interaction.reply({ content: "Unknown command.", ephemeral: true }); + } + } + } catch (err) { + console.error(`Command "${interaction.commandName}" failed:`, err); + const msg = "Something went wrong handling that command."; + if (interaction.replied || interaction.deferred) { + await interaction.editReply(msg).catch(() => {}); + } else { + await interaction.reply({ content: msg, ephemeral: true }).catch(() => {}); + } } }); @@ -112,8 +150,15 @@ client.on("messageCreate", async (message: Message) => { const parsed = parseWCLUrl(match[0]); if (!parsed) return; + // Only auto-reply to links that point at a specific fight — a bare report link + // pasted in chat shouldn't trigger the bot. + if (parsed.fightId === undefined) return; + + // Per-channel cooldown so a flurry of pasted links doesn't spam a channel. + if (onCooldown(channelCooldowns, message.channelId, CHANNEL_COOLDOWN_MS)) return; + const pfUrl = new URL(`https://parseforge.gg/analyze/${parsed.code}`); - if (parsed.fightId !== undefined) pfUrl.searchParams.set("fight", String(parsed.fightId)); + pfUrl.searchParams.set("fight", String(parsed.fightId)); if (parsed.sourceId !== undefined) pfUrl.searchParams.set("source", String(parsed.sourceId)); const row = new ActionRowBuilder().addComponents( @@ -123,10 +168,16 @@ client.on("messageCreate", async (message: Message) => { .setURL(pfUrl.toString()), ); - await message.reply({ - components: [row], - allowedMentions: { repliedUser: false }, - }); + // Catch reply failures (e.g. missing Send Messages permission) so they don't + // bubble up as an unhandled rejection and crash the bot on Node 20. + try { + await message.reply({ + components: [row], + allowedMentions: { repliedUser: false }, + }); + } catch (err) { + console.warn(`Failed to reply in channel ${message.channelId}:`, err); + } }); async function main(): Promise { From 9c1d1d3117828b95d74b3b429c2a5d39b73530bd Mon Sep 17 00:00:00 2001 From: Alexander Mayes Date: Sun, 19 Jul 2026 19:51:17 -0700 Subject: [PATCH 05/19] Phase 1: check off launch-blocking set in plan Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ --- PRODUCTION_HARDENING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PRODUCTION_HARDENING.md b/PRODUCTION_HARDENING.md index efd780c..a7767c3 100644 --- a/PRODUCTION_HARDENING.md +++ b/PRODUCTION_HARDENING.md @@ -199,7 +199,7 @@ The product **is** the numbers produced by `lib/analysis-engine.ts` (~1,300 line ## Definition of done -- [ ] All Phase 1 tasks complete — this is the launch-blocking set +- [x] All Phase 1 tasks complete — this is the launch-blocking set - [ ] Phase 2 complete - [ ] Phase 3 complete - [ ] Phase 4: parser/pool/parseBody tests exist and run in CI; engine tests started From cc6ee44ea355524548a7453a23d1676df6177cb1 Mon Sep 17 00:00:00 2001 From: Alexander Mayes Date: Tue, 21 Jul 2026 11:13:00 -0700 Subject: [PATCH 06/19] Phase 2.1: security headers (CSP report-only first) 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) Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ --- next.config.ts | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/next.config.ts b/next.config.ts index 81dfcbe..405372d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,51 @@ import type { NextConfig } from "next"; +// Content-Security-Policy, shipped in Report-Only mode first: it logs violations +// to the browser console without blocking, so we can confirm the policy is +// complete against real traffic before promoting it to enforcing (a separate, +// manual step). 'unsafe-inline' covers Next's inline bootstrap scripts + the +// Wowhead config script in layout.tsx; 'unsafe-eval'/worker-src cover PostHog +// session replay; wow.zamimg.com serves Wowhead's tooltips.js + icons. PostHog +// ingestion is same-origin via the /ingest rewrite, so it needs no extra host. +const CSP_REPORT_ONLY = [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://wow.zamimg.com", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: https:", + "font-src 'self' data:", + "connect-src 'self' https://us.i.posthog.com https://us-assets.i.posthog.com", + "worker-src 'self' blob:", + "frame-ancestors 'self'", + "base-uri 'self'", + "form-action 'self'", +].join("; "); + const nextConfig: NextConfig = { + async headers() { + return [ + { + // Benign hardening headers — safe on every route, including /og. + source: "/(.*)", + headers: [ + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { + key: "Permissions-Policy", + value: "camera=(), microphone=(), geolocation=()", + }, + ], + }, + { + // Frame-blocking + CSP everywhere EXCEPT /og — link unfurlers fetch the + // OG image and we don't want to risk interfering with that path. + source: "/((?!og).*)", + headers: [ + { key: "X-Frame-Options", value: "SAMEORIGIN" }, + { key: "Content-Security-Policy-Report-Only", value: CSP_REPORT_ONLY }, + ], + }, + ]; + }, async rewrites() { return [ { From 99780c0d755cb3fddc1475c6a7a8fb386df9b980 Mon Sep 17 00:00:00 2001 From: Alexander Mayes Date: Tue, 21 Jul 2026 11:13:00 -0700 Subject: [PATCH 07/19] Phase 2.2: tighten /og route inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ --- app/og/route.tsx | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/app/og/route.tsx b/app/og/route.tsx index 7288f1c..9bec766 100644 --- a/app/og/route.tsx +++ b/app/og/route.tsx @@ -1,6 +1,7 @@ import { ImageResponse } from "next/og"; import type { NextRequest } from "next/server"; import { CLASS_COLORS } from "@/lib/constants"; +import { isValidReportCode } from "@/lib/api-utils"; import type { AnalysisResult, ReportMeta } from "@/lib/wcl-types"; // Dynamic Open Graph image for shared analyze links. Only hit by link unfurlers @@ -184,24 +185,32 @@ export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url); const reportCode = searchParams.get("report"); - const fight = searchParams.get("fight"); - const source = searchParams.get("source"); - const origin = new URL(request.url).origin; + const fightRaw = searchParams.get("fight"); + const sourceRaw = searchParams.get("source"); + // Fetch our own API by an absolute origin. Pin to the canonical host in + // production (an attacker can't steer us via a spoofed Host/origin), and + // only fall back to the request origin in local dev. + const origin = + process.env.NODE_ENV === "production" + ? "https://parseforge.gg" + : new URL(request.url).origin; - if (!reportCode) { + // Invalid/absent code → branded fallback, never fetch. + if (!isValidReportCode(reportCode)) { return new ImageResponse(, { ...size, headers }); } - // Player scorecard when we have a specific fight + player. - if (fight && source) { + // Player scorecard only when fight + source are valid non-negative integers. + const fightId = fightRaw != null ? Number.parseInt(fightRaw, 10) : NaN; + const sourceId = sourceRaw != null ? Number.parseInt(sourceRaw, 10) : NaN; + if ( + Number.isInteger(fightId) && fightId >= 0 && + Number.isInteger(sourceId) && sourceId >= 0 + ) { const data = await fetchJson(`${origin}/api/analyze`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ - reportCode, - fightId: Number(fight), - sourceId: Number(source), - }), + body: JSON.stringify({ reportCode, fightId, sourceId }), }); if (data?.playerName) { return new ImageResponse(, { ...size, headers }); From e32bcf485f317292dd6b24f73e13ea53b69eea92 Mon Sep 17 00:00:00 2001 From: Alexander Mayes Date: Tue, 21 Jul 2026 11:13:00 -0700 Subject: [PATCH 08/19] Phase 2.3: integer-coerce ids in the built CLA query 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) Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ --- lib/wcl-queries.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/wcl-queries.ts b/lib/wcl-queries.ts index df3907f..205381c 100644 --- a/lib/wcl-queries.ts +++ b/lib/wcl-queries.ts @@ -302,6 +302,12 @@ export const REPORT_ACTORS_QUERY = ` */ export function buildCLABuffUptimeQuery(sourceIds: number[]): string { const aliases = sourceIds + // These ids come from WCL's own actor list (safe), but this is the only place + // a value reaches a query body as a raw string. Coerce to a non-negative + // integer and drop anything else — one-line insurance against a future caller + // passing unvalidated data into the interpolated query. + .map((id) => Number(id)) + .filter((id) => Number.isInteger(id) && id >= 0) .map( (id) => `buffs_${id}: table(dataType: Buffs, fightIDs: $fightIDs, sourceID: ${id})` From 47011870d405ebeeaaf040b0c3d3870c553d80b4 Mon Sep 17 00:00:00 2001 From: Alexander Mayes Date: Tue, 21 Jul 2026 11:13:00 -0700 Subject: [PATCH 09/19] Phase 2.4: PostHog session-replay privacy defaults 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) Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ --- app/components/PostHogProvider.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/app/components/PostHogProvider.tsx b/app/components/PostHogProvider.tsx index fdd4115..7003805 100644 --- a/app/components/PostHogProvider.tsx +++ b/app/components/PostHogProvider.tsx @@ -35,16 +35,16 @@ export default function PostHogProvider({ children }: { children: React.ReactNod // Session replay disable_session_recording: false, session_recording: { - maskAllInputs: false, - maskInputFn: (text, element) => { - // Only mask actual sensitive fields, not the report URL input - const el = element as HTMLInputElement | null; - if (el?.type === "password") return "*".repeat(text.length); - return text; - }, + // Mask all inputs by default. This masks the report-URL input too, which + // is an acceptable trade for a privacy-safe default (vs. the previous + // un-masking that could capture whatever a user typed). + maskAllInputs: true, }, - // Console log capture - enable_recording_console_log: true, + // Don't capture console logs into replays — they can hoover up anything + // logged client-side. + enable_recording_console_log: false, + // TODO: serving EU users with session replay ultimately needs a consent + // banner — that's a product decision, out of scope here. // Autocapture clicks, inputs, form submits autocapture: true, loaded: (ph) => { From c18a3d9b7d7ffa16086b2d0bf5422874019e8ac8 Mon Sep 17 00:00:00 2001 From: Alexander Mayes Date: Tue, 21 Jul 2026 11:13:00 -0700 Subject: [PATCH 10/19] Phase 2.5: share the WCL OAuth token across instances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ --- lib/kv-cache.ts | 13 ++++++++++++ lib/wcl-client.ts | 51 ++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/lib/kv-cache.ts b/lib/kv-cache.ts index 0b1400b..b446b26 100644 --- a/lib/kv-cache.ts +++ b/lib/kv-cache.ts @@ -87,6 +87,19 @@ export async function cacheSet(key: string, value: unknown, ttlMs: number): Prom memSet(key, value, ttlMs); } +/** Delete a key from the shared cache (and the memory fallback). Best-effort. */ +export async function cacheDelete(key: string): Promise { + if (usingSharedCache) { + try { + await redisCmd(["DEL", key]); + return; + } catch (err) { + console.error(`[kv-cache] DEL ${key} failed, using memory: ${(err as Error).message}`); + } + } + mem.delete(key); +} + // ─── Single-flight locks (cache-stampede protection) ──────────────── // A short-lived Redis lock so that when many requests miss the cache for the // same key at once, only the lock holder fans out to WCL; the rest wait for the diff --git a/lib/wcl-client.ts b/lib/wcl-client.ts index 22ef7c5..1d27404 100644 --- a/lib/wcl-client.ts +++ b/lib/wcl-client.ts @@ -1,5 +1,6 @@ import { WCL_API_URL, WCL_TOKEN_URL, TOKEN_EXPIRY_BUFFER } from "./constants"; import { logEvent } from "./observability"; +import { cacheGet, cacheSet, cacheDelete } from "./kv-cache"; // ─── Typed errors ──────────────────────────────────────────────────── // wclQuery throws WCLError so routes can map failures to clean, actionable @@ -66,16 +67,44 @@ function classifyGraphQLError(message: string): WCLErrorKind { return "upstream"; } +// The WCL OAuth token is cached at two levels: module scope (fastest, per +// serverless instance) and shared Redis (so a cold instance reuses a token +// another instance already minted instead of spending a fresh token request — +// every mint counts against the same client). Without Redis, only the module +// cache is used, exactly as before. +const TOKEN_CACHE_KEY = "wcl:token"; +interface CachedWclToken { + token: string; + expiresAt: number; // epoch seconds +} + let cachedToken: string | null = null; let tokenExpiresAt = 0; +/** Drop the cached token from BOTH module scope and Redis (used on a 401). */ +async function clearAccessToken(): Promise { + cachedToken = null; + tokenExpiresAt = 0; + await cacheDelete(TOKEN_CACHE_KEY); +} + async function getAccessToken(): Promise { const now = Date.now() / 1000; + // 1. Module scope — fastest, no network. if (cachedToken && tokenExpiresAt > now + TOKEN_EXPIRY_BUFFER) { return cachedToken; } + // 2. Shared Redis — reuse a token another instance already minted if valid. + const shared = await cacheGet(TOKEN_CACHE_KEY); + if (shared && shared.expiresAt > now + TOKEN_EXPIRY_BUFFER) { + cachedToken = shared.token; + tokenExpiresAt = shared.expiresAt; + return shared.token; + } + + // 3. Mint a fresh token from WCL and write it back to both layers. const clientId = process.env.WCL_CLIENT_ID; const clientSecret = process.env.WCL_CLIENT_SECRET; @@ -101,10 +130,22 @@ async function getAccessToken(): Promise { } const data = await res.json(); - cachedToken = data.access_token; + const token: string = data.access_token; + cachedToken = token; tokenExpiresAt = now + data.expires_in; - return cachedToken!; + // Cache in Redis just short of expiry (minus the refresh buffer) so it's never + // served stale. Best-effort — cacheSet never throws. + const ttlMs = Math.max(0, data.expires_in - TOKEN_EXPIRY_BUFFER) * 1000; + if (ttlMs > 0) { + await cacheSet( + TOKEN_CACHE_KEY, + { token, expiresAt: tokenExpiresAt } satisfies CachedWclToken, + ttlMs, + ); + } + + return token; } const MAX_RETRIES = 3; @@ -178,10 +219,10 @@ export async function wclQuery( clearTimeout(timeout); - // 401 = token expired between cache check and use — refresh and retry + // 401 = token expired between cache check and use — clear BOTH the module + // and Redis copies so the next attempt mints a fresh one, then retry. if (res.status === 401) { - cachedToken = null; - tokenExpiresAt = 0; + await clearAccessToken(); lastError = new Error("WCL token expired, retrying"); continue; } From 820eb5978a7966cc5b66456905b185c9bf4a0cf9 Mon Sep 17 00:00:00 2001 From: Alexander Mayes Date: Tue, 21 Jul 2026 11:13:27 -0700 Subject: [PATCH 11/19] Phase 2: check off in plan Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ --- PRODUCTION_HARDENING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PRODUCTION_HARDENING.md b/PRODUCTION_HARDENING.md index a7767c3..8f412fe 100644 --- a/PRODUCTION_HARDENING.md +++ b/PRODUCTION_HARDENING.md @@ -200,7 +200,7 @@ The product **is** the numbers produced by `lib/analysis-engine.ts` (~1,300 line ## Definition of done - [x] All Phase 1 tasks complete — this is the launch-blocking set -- [ ] Phase 2 complete +- [x] Phase 2 complete - [ ] Phase 3 complete - [ ] Phase 4: parser/pool/parseBody tests exist and run in CI; engine tests started - [ ] `npm run build`, `npx tsc --noEmit`, `npm run lint`, and `npm test` all pass; bot builds From d85a0b0e1b15c1f38faea6d234a94e79e7542836 Mon Sep 17 00:00:00 2001 From: Alexander Mayes Date: Tue, 21 Jul 2026 11:44:42 -0700 Subject: [PATCH 12/19] Phase 3.3: unify the duplicated WCL URL parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ --- bot/src/util/parse-url.ts | 81 +++++++++++++++++++++++++++++---------- lib/url-parser.ts | 3 ++ 2 files changed, 64 insertions(+), 20 deletions(-) diff --git a/bot/src/util/parse-url.ts b/bot/src/util/parse-url.ts index 591abe7..0897d08 100644 --- a/bot/src/util/parse-url.ts +++ b/bot/src/util/parse-url.ts @@ -1,43 +1,84 @@ +// Deliberate duplicate of the web app's `lib/url-parser.ts`. The bot is a +// separate package and can't import from the Next app, so the two copies are +// kept byte-for-byte identical in behavior — port any change to both. + export interface ParsedWCLUrl { code: string; fightId?: number; sourceId?: number; } +/** + * Pull fight/source out of a raw fragment/query blob. Handles both the + * hash style (`#fight=5&source=12`) and the query style (`?fight=5&source=12`) + * that Warcraft Logs uses interchangeably. + */ +function extractFightSource(...parts: string[]): { + fightId?: number; + sourceId?: number; +} { + const blob = parts.filter(Boolean).join("&"); + const out: { fightId?: number; sourceId?: number } = {}; + + const fight = blob.match(/(?:^|[#&?])fight=([^&\s]+)/); + if (fight && fight[1] !== "last") { + const n = parseInt(fight[1], 10); + if (!Number.isNaN(n)) out.fightId = n; + } + + const source = blob.match(/(?:^|[#&?])source=(\d+)/); + if (source) { + const n = parseInt(source[1], 10); + if (!Number.isNaN(n)) out.sourceId = n; + } + + return out; +} + +/** + * Parse a Warcraft Logs URL into its components. + * Supports: + * https://classic.warcraftlogs.com/reports/ABC123#fight=5&source=12 + * https://www.warcraftlogs.com/reports/ABC123?fight=5&source=12 + * https://www.warcraftlogs.com/reports/ABC123 + * ABC123 (just the report code) + * Pasted text with a report URL somewhere inside it (e.g. shared from a + * phone or Discord: "check this out https://.../reports/ABC123 gg") + */ export function parseWCLUrl(input: string): ParsedWCLUrl | null { const trimmed = input.trim(); + // Try as a plain report code (alphanumeric, 16 chars typical) if (/^[a-zA-Z0-9]{10,20}$/.test(trimmed)) { return { code: trimmed }; } + // Try as a well-formed URL first — most reliable for fragment/query params. try { const urlStr = trimmed.includes("://") ? trimmed : `https://${trimmed}`; const url = new URL(urlStr); - const pathMatch = url.pathname.match(/\/reports\/([a-zA-Z0-9]+)/); - if (!pathMatch) return null; - - const code = pathMatch[1]; - const result: ParsedWCLUrl = { code }; - - if (url.hash) { - const hashParams = new URLSearchParams(url.hash.slice(1)); - const fight = hashParams.get("fight"); - const source = hashParams.get("source"); - - if (fight && fight !== "last") { - result.fightId = parseInt(fight, 10); - } - if (source) { - result.sourceId = parseInt(source, 10); - } + const pathMatch = url.pathname.match(/\/reports\/([a-zA-Z0-9]{10,20})/); + if (pathMatch) { + return { + code: pathMatch[1], + ...extractFightSource(url.hash, url.search), + }; } - - return result; } catch { - return null; + // Not a parseable URL on its own — fall through to a loose scan. + } + + // Loose fallback: extract a /reports/ from text that may include + // surrounding words (common when sharing from a mobile app or Discord), + // where the input isn't a bare URL and `new URL()` above fails. + const loose = trimmed.match(/\/reports\/([a-zA-Z0-9]{10,20})/); + if (loose) { + const rest = trimmed.slice(trimmed.indexOf(loose[0]) + loose[0].length); + return { code: loose[1], ...extractFightSource(rest) }; } + + return null; } export function buildWCLUrl( diff --git a/lib/url-parser.ts b/lib/url-parser.ts index cb48a71..2b9a3cb 100644 --- a/lib/url-parser.ts +++ b/lib/url-parser.ts @@ -1,3 +1,6 @@ +// Deliberate duplicate: the Discord bot keeps a byte-for-byte-identical copy at +// `bot/src/util/parse-url.ts` (separate package, can't import from here). Port +// any change to both. import { ParsedWCLUrl } from "./wcl-types"; /** From a0c630117c1d6981f8f09b295245270b405b6a9f Mon Sep 17 00:00:00 2001 From: Alexander Mayes Date: Tue, 21 Jul 2026 11:44:43 -0700 Subject: [PATCH 13/19] Phase 3.2: harden the bot Dockerfile 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) Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ --- bot/Dockerfile | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bot/Dockerfile b/bot/Dockerfile index 666ba92..5dc5925 100644 --- a/bot/Dockerfile +++ b/bot/Dockerfile @@ -8,7 +8,15 @@ RUN npm run build FROM node:20-alpine WORKDIR /app +ENV NODE_ENV=production COPY package.json package-lock.json* ./ RUN npm ci --omit=dev COPY --from=builder /app/dist ./dist +# Drop root — run as the image's built-in unprivileged `node` user. `dist` is +# copied root-owned and read-only, which is all the app needs. +USER node +# NOTE: the bot exits on fatal startup errors, so the runtime must restart it — +# set a restart policy (`restart: unless-stopped` in compose, or an equivalent +# Kubernetes restartPolicy). Phase 1.4 hardened the request paths so runtime +# errors no longer crash it; only startup fatals exit, for a clean restart. CMD ["node", "dist/index.js"] From b4a5db2809b2c2eb1a2aa588bc3096e0547e3ed5 Mon Sep 17 00:00:00 2001 From: Alexander Mayes Date: Tue, 21 Jul 2026 11:44:43 -0700 Subject: [PATCH 14/19] Phase 3.1: add GitHub Actions CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ --- .github/workflows/ci.yml | 45 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bb1b15f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + web: + name: Web (typecheck, test, build) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npx tsc --noEmit + - run: npm test + # The build prerenders / and /sitemap.xml, which touch WCL/Redis. Those + # calls degrade gracefully when the env is absent (see FeaturedReports / + # kv-cache), so CI needs no secrets. + - run: npm run build + # Lint is non-blocking for now: the repo has pre-existing lint errors + # (components/ui/meteors.tsx, lib/analysis-engine.ts, …) unrelated to CI. + # Flip continue-on-error off once that debt is cleared. + - name: Lint (non-blocking) + run: npm run lint + continue-on-error: true + + bot: + name: Bot (build) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: bot/package-lock.json + - run: npm ci + working-directory: bot + - run: npm run build + working-directory: bot From e399e9ee909ce806870014e275ff3b72e6b31750 Mon Sep 17 00:00:00 2001 From: Alexander Mayes Date: Tue, 21 Jul 2026 11:44:43 -0700 Subject: [PATCH 15/19] Phase 4: first test suite (vitest) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ --- lib/analysis-engine.test.ts | 36 ++ lib/api-utils.test.ts | 74 +++ lib/async-pool.test.ts | 44 ++ lib/url-parser.test.ts | 65 ++ package-lock.json | 1157 +++++++++++++++++++++++++++++++++-- package.json | 6 +- vitest.config.ts | 16 + 7 files changed, 1347 insertions(+), 51 deletions(-) create mode 100644 lib/analysis-engine.test.ts create mode 100644 lib/api-utils.test.ts create mode 100644 lib/async-pool.test.ts create mode 100644 lib/url-parser.test.ts create mode 100644 vitest.config.ts diff --git a/lib/analysis-engine.test.ts b/lib/analysis-engine.test.ts new file mode 100644 index 0000000..8eefac0 --- /dev/null +++ b/lib/analysis-engine.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { analyzeDps } from "./analysis-engine"; +import type { WCLRanking } from "./wcl-types"; + +// analyzeDps only reads `.amount` off each ranking, so minimal fixtures suffice. +const ranks = (amounts: number[]): WCLRanking[] => + amounts.map((amount) => ({ amount }) as unknown as WCLRanking); + +describe("analyzeDps", () => { + it("defaults to the 50th percentile with no rankings to compare against", () => { + // 1,000,000 damage over 10s = 100,000 DPS. + const r = analyzeDps(1_000_000, 10_000, []); + expect(r.playerDps).toBe(100_000); + expect(r.percentile).toBe(50); + expect(r.medianDps).toBe(100_000); // falls back to the player's own DPS + expect(r.gapToMedian).toBe(0); + expect(r.gapToTop).toBe(0); + }); + + it("places a player who beats part of the field", () => { + // 1,200,000 over 10s = 120,000 DPS, above 2 of 3 ranked parses. + const r = analyzeDps(1_200_000, 10_000, ranks([80_000, 100_000, 130_000]), 3); + expect(r.playerDps).toBe(120_000); + expect(r.medianDps).toBe(100_000); + expect(r.topDps).toBe(130_000); + expect(r.percentile).toBe(67); // round(2/3 * 100) + expect(r.gapToMedian).toBe(-20); // 20% above median + expect(r.gapToTop).toBe(8); // ~7.7% below top + }); + + it("caps the percentile at 99 for a player above the entire field", () => { + // 1,000,000 over 1s = 1,000,000 DPS, well above both ranked parses. + const r = analyzeDps(1_000_000, 1_000, ranks([10_000, 20_000])); + expect(r.percentile).toBe(99); // 100 clamped to 99 + }); +}); diff --git a/lib/api-utils.test.ts b/lib/api-utils.test.ts new file mode 100644 index 0000000..ce59430 --- /dev/null +++ b/lib/api-utils.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from "vitest"; +import { parseBody, isValidReportCode } from "./api-utils"; + +function jsonReq(body: unknown): Request { + return new Request("http://localhost/api", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("isValidReportCode", () => { + it("accepts 10–20 char alphanumeric codes", () => { + expect(isValidReportCode("aBcD1234EfGh5678")).toBe(true); + expect(isValidReportCode("aBcD123456")).toBe(true); // exactly 10 + }); + + it("rejects wrong length, non-alphanumeric, and non-strings", () => { + expect(isValidReportCode("short")).toBe(false); // < 10 + expect(isValidReportCode("a".repeat(21))).toBe(false); // > 20 + expect(isValidReportCode("aB_cD1234EfGh")).toBe(false); // underscore + expect(isValidReportCode("../etc/passwd")).toBe(false); + expect(isValidReportCode(123)).toBe(false); + expect(isValidReportCode(null)).toBe(false); + expect(isValidReportCode(undefined)).toBe(false); + }); +}); + +describe("parseBody", () => { + it("returns the parsed body when required fields are present", async () => { + const result = await parseBody<{ reportCode: string; fightId: number; sourceId: number }>( + jsonReq({ reportCode: "aBcD1234EfGh5678", fightId: 5, sourceId: 12 }), + ["reportCode", "fightId", "sourceId"], + ); + expect("body" in result).toBe(true); + if ("body" in result) { + expect(result.body).toEqual({ reportCode: "aBcD1234EfGh5678", fightId: 5, sourceId: 12 }); + } + }); + + it("treats 0 as present (fight/source slots are 0-indexed)", async () => { + const result = await parseBody<{ reportCode: string; fightId: number; sourceId: number }>( + jsonReq({ reportCode: "aBcD1234EfGh5678", fightId: 0, sourceId: 0 }), + ["reportCode", "fightId", "sourceId"], + ); + expect("body" in result).toBe(true); + }); + + it("400s when a required field is missing, null, or empty string", async () => { + const missing = await parseBody<{ reportCode: string; fightId: number }>( + jsonReq({ fightId: 5 }), + ["reportCode", "fightId"], + ); + expect("error" in missing).toBe(true); + if ("error" in missing) expect(missing.error.status).toBe(400); + + const empty = await parseBody<{ reportCode: string; fightId: number }>( + jsonReq({ reportCode: "", fightId: 5 }), + ["reportCode", "fightId"], + ); + expect("error" in empty).toBe(true); + }); + + it("400s on invalid JSON", async () => { + const badReq = new Request("http://localhost/api", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{not json", + }); + const result = await parseBody(badReq, []); + expect("error" in result).toBe(true); + if ("error" in result) expect(result.error.status).toBe(400); + }); +}); diff --git a/lib/async-pool.test.ts b/lib/async-pool.test.ts new file mode 100644 index 0000000..49ed973 --- /dev/null +++ b/lib/async-pool.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { mapPool } from "./async-pool"; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +describe("mapPool", () => { + it("preserves input order regardless of completion order", async () => { + // Uneven delays so results would arrive out of order without index tracking. + const delays = [30, 5, 20, 1, 15]; + const out = await mapPool(delays, 2, async (ms, i) => { + await sleep(ms); + return i; + }); + expect(out).toEqual([0, 1, 2, 3, 4]); + }); + + it("passes the item and index to the worker", async () => { + const out = await mapPool([10, 20, 30], 3, async (n, i) => n + i); + expect(out).toEqual([10, 21, 32]); + }); + + it("never exceeds the concurrency cap", async () => { + let active = 0; + let peak = 0; + await mapPool( + Array.from({ length: 12 }, (_, i) => i), + 3, + async (n) => { + active++; + peak = Math.max(peak, active); + await sleep(5); + active--; + return n; + }, + ); + expect(peak).toBeLessThanOrEqual(3); + expect(peak).toBeGreaterThan(1); // actually ran concurrently + }); + + it("handles empty input", async () => { + const out = await mapPool([], 4, async (n) => n); + expect(out).toEqual([]); + }); +}); diff --git a/lib/url-parser.test.ts b/lib/url-parser.test.ts new file mode 100644 index 0000000..496149b --- /dev/null +++ b/lib/url-parser.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from "vitest"; +import { parseWCLUrl, buildWCLUrl } from "./url-parser"; + +const CODE = "aBcD1234EfGh5678"; // 16 chars, valid + +describe("parseWCLUrl", () => { + it("parses a bare report code", () => { + expect(parseWCLUrl(CODE)).toEqual({ code: CODE }); + }); + + it("parses hash-style fight/source", () => { + expect( + parseWCLUrl(`https://classic.warcraftlogs.com/reports/${CODE}#fight=5&source=12`), + ).toEqual({ code: CODE, fightId: 5, sourceId: 12 }); + }); + + it("parses query-style fight/source", () => { + expect( + parseWCLUrl(`https://www.warcraftlogs.com/reports/${CODE}?fight=5&source=12`), + ).toEqual({ code: CODE, fightId: 5, sourceId: 12 }); + }); + + it("parses a URL with no params", () => { + expect(parseWCLUrl(`https://www.warcraftlogs.com/reports/${CODE}`)).toEqual({ code: CODE }); + }); + + it("parses a scheme-less URL", () => { + expect(parseWCLUrl(`classic.warcraftlogs.com/reports/${CODE}`)).toEqual({ code: CODE }); + }); + + it("ignores fight=last (no reliable numeric id) but keeps source", () => { + expect( + parseWCLUrl(`https://classic.warcraftlogs.com/reports/${CODE}#fight=last&source=3`), + ).toEqual({ code: CODE, sourceId: 3 }); + }); + + it("extracts a report URL embedded in surrounding text", () => { + expect( + parseWCLUrl(`check this out https://classic.warcraftlogs.com/reports/${CODE}#fight=2&source=7 gg`), + ).toEqual({ code: CODE, fightId: 2, sourceId: 7 }); + }); + + it("returns null for junk / non-report input", () => { + expect(parseWCLUrl("not a url")).toBeNull(); + expect(parseWCLUrl("")).toBeNull(); + expect(parseWCLUrl("short")).toBeNull(); + expect(parseWCLUrl("https://example.com/foo")).toBeNull(); + }); +}); + +describe("buildWCLUrl", () => { + it("builds a bare report URL", () => { + expect(buildWCLUrl(CODE)).toBe(`https://classic.warcraftlogs.com/reports/${CODE}`); + }); + + it("appends fight and source as a hash", () => { + expect(buildWCLUrl(CODE, 5, 12)).toBe( + `https://classic.warcraftlogs.com/reports/${CODE}#fight=5&source=12`, + ); + }); + + it("appends only fight when source is absent", () => { + expect(buildWCLUrl(CODE, 5)).toBe(`https://classic.warcraftlogs.com/reports/${CODE}#fight=5`); + }); +}); diff --git a/package-lock.json b/package-lock.json index f663cbc..597c208 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,7 +34,8 @@ "shadcn": "^3.8.5", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.10" } }, "node_modules/@alloc/quick-lru": { @@ -718,21 +719,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", - "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "license": "MIT", "optional": true, "dependencies": { @@ -740,9 +741,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -2202,6 +2203,16 @@ "node": ">=14" } }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@posthog/core": { "version": "1.23.2", "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.23.2.tgz", @@ -3779,6 +3790,289 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -3806,6 +4100,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -4168,9 +4469,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -4178,6 +4479,24 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -4926,36 +5245,149 @@ } } }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, "license": "MIT", "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, - "engines": { - "node": ">= 0.6" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", "dev": true, "license": "MIT", - "bin": { - "acorn": "bin/acorn" + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", @@ -5260,6 +5692,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.16.1", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", @@ -5534,6 +5976,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -6378,6 +6830,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -6902,6 +7361,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -6972,6 +7441,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -7336,6 +7815,21 @@ "node": ">=14.14" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -9325,9 +9819,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "funding": [ { "type": "github", @@ -9685,6 +10179,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -9959,6 +10467,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -9999,9 +10514,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.21", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.21.tgz", + "integrity": "sha512-v4sDNP3fdNiWMfabO7OwOQdOX8TiQSztKyT1Wj0w+j7LDallJThJRBBBmzVGyYj0crMh7jlV4zepPkiNu9UwDQ==", "dev": true, "funding": [ { @@ -10019,7 +10534,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -10595,6 +11110,40 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -11098,6 +11647,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -11144,6 +11700,13 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -11154,6 +11717,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -11509,6 +12079,13 @@ "dev": true, "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -11520,14 +12097,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -11555,9 +12132,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -11567,6 +12144,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "7.0.24", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.24.tgz", @@ -12078,6 +12665,461 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -12198,6 +13240,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 7fb8fe2..1c966e7 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "test": "vitest run" }, "dependencies": { "@upstash/ratelimit": "^2.0.8", @@ -35,6 +36,7 @@ "shadcn": "^3.8.5", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.10" } } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..e2568e1 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; +import { fileURLToPath } from "node:url"; + +// Resolve the `@/*` path alias (tsconfig paths map `@/*` -> `./*`) so tests can +// import app modules the same way the app does. +const root = fileURLToPath(new URL(".", import.meta.url)); + +export default defineConfig({ + resolve: { + alias: { "@": root.replace(/\/$/, "") }, + }, + test: { + environment: "node", + include: ["lib/**/*.test.ts", "app/**/*.test.ts"], + }, +}); From 8a7e81562398d013a9bd4091737765808d688e8a Mon Sep 17 00:00:00 2001 From: Alexander Mayes Date: Tue, 21 Jul 2026 11:45:12 -0700 Subject: [PATCH 16/19] Phases 3 & 4: check off in plan (all phases complete) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ --- PRODUCTION_HARDENING.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/PRODUCTION_HARDENING.md b/PRODUCTION_HARDENING.md index 8f412fe..b26d456 100644 --- a/PRODUCTION_HARDENING.md +++ b/PRODUCTION_HARDENING.md @@ -201,7 +201,7 @@ The product **is** the numbers produced by `lib/analysis-engine.ts` (~1,300 line - [x] All Phase 1 tasks complete — this is the launch-blocking set - [x] Phase 2 complete -- [ ] Phase 3 complete -- [ ] Phase 4: parser/pool/parseBody tests exist and run in CI; engine tests started -- [ ] `npm run build`, `npx tsc --noEmit`, `npm run lint`, and `npm test` all pass; bot builds -- [ ] Verified locally **without** Redis env vars that everything still works (rate limiting no-ops, cache falls back to memory) +- [x] Phase 3 complete +- [x] Phase 4: parser/pool/parseBody tests exist and run in CI; engine tests started (analyzeDps) +- [x] `npm run build`, `npx tsc --noEmit`, and `npm test` all pass; bot builds. NOTE: `npm run lint` still reports 4 **pre-existing** errors (meteors.tsx, analysis-engine.ts) unrelated to this work — CI runs lint non-blocking until that debt is cleared. +- [x] Verified locally **without** Redis env vars that everything still works (rate limiting no-ops, cache falls back to memory, token/lock paths skip Redis) From df1d4ced1b4ce76bf73f1e5ad3d79bac0bb6d829 Mon Sep 17 00:00:00 2001 From: Alexander Mayes Date: Tue, 21 Jul 2026 13:15:02 -0700 Subject: [PATCH 17/19] Clear pre-existing lint errors + make CI lint blocking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - analysis-engine.ts: missingGems is never reassigned → const (real fix). - meteors.tsx, RecentReports.tsx: the set-state-in-effect is intentional — both compute client-only values after mount (window/Math.random, localStorage) that can't run during SSR. Suppressed with justifications rather than refactoring working components for a compiler heuristic. - AnalyzeClient.tsx: preserve-manual-memoization — the manual useCallback is intentional (a dep is a method off the player hook); suppressed with rationale. - ci.yml: drop continue-on-error on lint now that `npm run lint` exits clean (0 errors; 12 warnings remain, which don't fail eslint). tsc, build, and 24 tests all pass. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ --- .github/workflows/ci.yml | 7 +------ app/analyze/[reportCode]/AnalyzeClient.tsx | 4 ++++ app/components/RecentReports.tsx | 3 +++ components/ui/meteors.tsx | 4 ++++ lib/analysis-engine.ts | 2 +- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb1b15f..40cfd30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,12 +22,7 @@ jobs: # calls degrade gracefully when the env is absent (see FeaturedReports / # kv-cache), so CI needs no secrets. - run: npm run build - # Lint is non-blocking for now: the repo has pre-existing lint errors - # (components/ui/meteors.tsx, lib/analysis-engine.ts, …) unrelated to CI. - # Flip continue-on-error off once that debt is cleared. - - name: Lint (non-blocking) - run: npm run lint - continue-on-error: true + - run: npm run lint bot: name: Bot (build) diff --git a/app/analyze/[reportCode]/AnalyzeClient.tsx b/app/analyze/[reportCode]/AnalyzeClient.tsx index a6cafed..094f05e 100644 --- a/app/analyze/[reportCode]/AnalyzeClient.tsx +++ b/app/analyze/[reportCode]/AnalyzeClient.tsx @@ -82,6 +82,10 @@ export default function AnalyzeClient({ reportCode }: { reportCode: string }) { // Player quick-jump handler: switch to player tab + select player const handlePlayerClick = useCallback( + // The React Compiler can't preserve this manual memoization because a dep is + // a method read off the `player` hook result; the useCallback is intentional + // and behavior-correct. + // eslint-disable-next-line react-hooks/preserve-manual-memoization -- intentional manual memo (sourceId: number) => { player.clear(); setSelectedSource(sourceId); diff --git a/app/components/RecentReports.tsx b/app/components/RecentReports.tsx index b5d83b2..72540ee 100644 --- a/app/components/RecentReports.tsx +++ b/app/components/RecentReports.tsx @@ -19,6 +19,9 @@ export default function RecentReports() { const [reports, setReports] = useState([]); useEffect(() => { + // Client-only: localStorage is unavailable during SSR, so recent reports are + // read after mount (renders null until then; not SSR-critical). + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional client-only read-after-mount setReports(getRecentReports()); }, []); diff --git a/components/ui/meteors.tsx b/components/ui/meteors.tsx index e2aae8e..d5ef843 100644 --- a/components/ui/meteors.tsx +++ b/components/ui/meteors.tsx @@ -37,6 +37,10 @@ export const Meteors = ({ Math.floor(Math.random() * (maxDuration - minDuration) + minDuration) + "s", })) + // Client-only: styles depend on window.innerWidth + Math.random(), so they + // must be computed after mount (not during SSR/render). The one extra render + // is fine for a decorative effect. + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional client-only compute-after-mount setMeteorStyles(styles) }, [number, minDelay, maxDelay, minDuration, maxDuration, angle]) diff --git a/lib/analysis-engine.ts b/lib/analysis-engine.ts index 0c38936..e82e8c0 100644 --- a/lib/analysis-engine.ts +++ b/lib/analysis-engine.ts @@ -165,7 +165,7 @@ export function analyzeGear( const topGearBySlot = normalizeRankingGear(topRankingGear); let missingEnchants = 0; - let missingGems = 0; + const missingGems = 0; const slots: GearSlotComparison[] = []; // Slots where enchants are reliably reported by WCL From 694785cc56f08a49404607f937799c0f8dec75a8 Mon Sep 17 00:00:00 2001 From: Alexander Mayes Date: Tue, 21 Jul 2026 13:15:25 -0700 Subject: [PATCH 18/19] Note lint debt cleared in plan Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ --- PRODUCTION_HARDENING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PRODUCTION_HARDENING.md b/PRODUCTION_HARDENING.md index b26d456..c0cd3e1 100644 --- a/PRODUCTION_HARDENING.md +++ b/PRODUCTION_HARDENING.md @@ -203,5 +203,5 @@ The product **is** the numbers produced by `lib/analysis-engine.ts` (~1,300 line - [x] Phase 2 complete - [x] Phase 3 complete - [x] Phase 4: parser/pool/parseBody tests exist and run in CI; engine tests started (analyzeDps) -- [x] `npm run build`, `npx tsc --noEmit`, and `npm test` all pass; bot builds. NOTE: `npm run lint` still reports 4 **pre-existing** errors (meteors.tsx, analysis-engine.ts) unrelated to this work — CI runs lint non-blocking until that debt is cleared. +- [x] `npm run build`, `npx tsc --noEmit`, `npm run lint`, and `npm test` all pass; bot builds. (The 4 pre-existing lint errors were cleared and CI lint is now blocking; 12 non-failing warnings remain.) - [x] Verified locally **without** Redis env vars that everything still works (rate limiting no-ops, cache falls back to memory, token/lock paths skip Redis) From 8cf0c6d1e72537ef923df93f045ee516dd42451a Mon Sep 17 00:00:00 2001 From: Alexander Mayes Date: Tue, 21 Jul 2026 22:42:46 -0700 Subject: [PATCH 19/19] =?UTF-8?q?Sweep=20remaining=20lint=20warnings=20(12?= =?UTF-8?q?=20=E2=86=92=200)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove 8 unused imports/vars (AnalysisResult, RaidRole, wowheadDomain, useCallback, GEM_NAME_DB/GEM_STAT_DB, PARSEFORGE_GOLD, an unused map index) and 1 stale eslint-disable directive. - The 3 react-hooks/exhaustive-deps warnings are intentional omissions (clear error on tab switch; a !loading start-guard; a stable player.clear) — verified and suppressed with justifications rather than adding deps (which would cause re-render/re-fetch loops). No runtime behavior change. lint is now clean (0 errors, 0 warnings); tsc, 24 tests, and both builds pass. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01AZFK9cogYksgHxYeAF9ReJ --- app/analyze/[reportCode]/AnalyzeClient.tsx | 7 +++---- app/analyze/[reportCode]/hooks/useCLA.ts | 3 +++ app/analyze/[reportCode]/hooks/usePlayerAnalysis.ts | 3 +++ app/analyze/[reportCode]/hooks/useReportMeta.ts | 2 +- app/api/analyze/route.ts | 1 - app/components/CLABuffTable.tsx | 1 - app/components/CLAClassBuffs.tsx | 2 +- app/components/LandingHero.tsx | 2 +- app/opengraph-image.tsx | 1 - bot/src/index.ts | 1 - lib/analysis-engine.ts | 2 +- 11 files changed, 13 insertions(+), 12 deletions(-) diff --git a/app/analyze/[reportCode]/AnalyzeClient.tsx b/app/analyze/[reportCode]/AnalyzeClient.tsx index 094f05e..59621dd 100644 --- a/app/analyze/[reportCode]/AnalyzeClient.tsx +++ b/app/analyze/[reportCode]/AnalyzeClient.tsx @@ -82,16 +82,15 @@ export default function AnalyzeClient({ reportCode }: { reportCode: string }) { // Player quick-jump handler: switch to player tab + select player const handlePlayerClick = useCallback( - // The React Compiler can't preserve this manual memoization because a dep is - // a method read off the `player` hook result; the useCallback is intentional - // and behavior-correct. - // eslint-disable-next-line react-hooks/preserve-manual-memoization -- intentional manual memo (sourceId: number) => { player.clear(); setSelectedSource(sourceId); updateUrlParam("source", String(sourceId)); switchTab("player"); }, + // player.clear is stable; depending on the whole `player` object would + // re-create this callback on every analysis-state change. + // eslint-disable-next-line react-hooks/exhaustive-deps [switchTab, updateUrlParam, player.clear] ); diff --git a/app/analyze/[reportCode]/hooks/useCLA.ts b/app/analyze/[reportCode]/hooks/useCLA.ts index 6b3adbe..05989f3 100644 --- a/app/analyze/[reportCode]/hooks/useCLA.ts +++ b/app/analyze/[reportCode]/hooks/useCLA.ts @@ -27,6 +27,9 @@ export function useCLA( if (activeTab === "cla" && error) { setError(null); } + // Depends only on activeTab by design — re-running when `error` changes + // would clear errors the moment they appear. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeTab]); const run = useCallback(async () => { diff --git a/app/analyze/[reportCode]/hooks/usePlayerAnalysis.ts b/app/analyze/[reportCode]/hooks/usePlayerAnalysis.ts index 8037d1c..5130680 100644 --- a/app/analyze/[reportCode]/hooks/usePlayerAnalysis.ts +++ b/app/analyze/[reportCode]/hooks/usePlayerAnalysis.ts @@ -79,6 +79,9 @@ export function usePlayerAnalysis( if (activeTab === "player" && selectedFight && selectedSource && !loading) { run(); } + // `loading` is a start guard only — depending on it would re-trigger this + // effect when a run finishes. Intentionally omitted. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedSource, selectedFight, activeTab, run]); const clear = useCallback(() => { diff --git a/app/analyze/[reportCode]/hooks/useReportMeta.ts b/app/analyze/[reportCode]/hooks/useReportMeta.ts index 3fb1ec3..a3a2daa 100644 --- a/app/analyze/[reportCode]/hooks/useReportMeta.ts +++ b/app/analyze/[reportCode]/hooks/useReportMeta.ts @@ -1,4 +1,4 @@ -import { useEffect, useCallback } from "react"; +import { useEffect } from "react"; import useSWR from "swr"; import type { ReportMeta } from "@/lib/wcl-types"; import { saveRecentReport } from "@/lib/recent-reports"; diff --git a/app/api/analyze/route.ts b/app/api/analyze/route.ts index 69e0d71..7489cf2 100644 --- a/app/api/analyze/route.ts +++ b/app/api/analyze/route.ts @@ -15,7 +15,6 @@ import { GEM_STAT_DB, GEM_NAME_DB } from "@/lib/cla-constants"; import { flattenPlayerDetails, parsePlayerSpec } from "@/lib/wcl-helpers"; import { AnalyzeRequest, - AnalysisResult, WCLRankingsData, WCLPlayerDetails, WCLDamageEntry, diff --git a/app/components/CLABuffTable.tsx b/app/components/CLABuffTable.tsx index 4f232b7..a18d7c5 100644 --- a/app/components/CLABuffTable.tsx +++ b/app/components/CLABuffTable.tsx @@ -2,7 +2,6 @@ import { useState, useMemo } from "react"; import type { CLAPlayerResult, CLAConsumableRow, CLAConsumableDetail } from "@/lib/cla-types"; -import type { RaidRole } from "@/lib/wcl-types"; import { CLASS_COLORS, ROLE_SORT_ORDER } from "@/lib/constants"; import { AlertTriangle } from "lucide-react"; import SortableTableHead from "./SortableTableHead"; diff --git a/app/components/CLAClassBuffs.tsx b/app/components/CLAClassBuffs.tsx index 5cf3c35..02b9b9f 100644 --- a/app/components/CLAClassBuffs.tsx +++ b/app/components/CLAClassBuffs.tsx @@ -43,7 +43,7 @@ interface Props { wowheadDomain: string; } -export default function CLAClassBuffs({ players, wowheadDomain }: Props) { +export default function CLAClassBuffs({ players }: Props) { const [expandedPlayer, setExpandedPlayer] = useState(null); const playersWithBuffData = useMemo(() => { diff --git a/app/components/LandingHero.tsx b/app/components/LandingHero.tsx index 45603c2..0afead9 100644 --- a/app/components/LandingHero.tsx +++ b/app/components/LandingHero.tsx @@ -99,7 +99,7 @@ export function LandingHero() { {/* Feature cards */}
- {features.map((f, i) => ( + {features.map((f) => ( - {/* eslint-disable-next-line @next/next/no-img-element */}