diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..40cfd30 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +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 + - run: npm run lint + + 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 diff --git a/PRODUCTION_HARDENING.md b/PRODUCTION_HARDENING.md new file mode 100644 index 0000000..c0cd3e1 --- /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 + +- [x] All Phase 1 tasks complete — this is the launch-blocking set +- [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`, `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) diff --git a/app/analyze/[reportCode]/AnalyzeClient.tsx b/app/analyze/[reportCode]/AnalyzeClient.tsx index a6cafed..59621dd 100644 --- a/app/analyze/[reportCode]/AnalyzeClient.tsx +++ b/app/analyze/[reportCode]/AnalyzeClient.tsx @@ -88,6 +88,9 @@ export default function AnalyzeClient({ reportCode }: { reportCode: string }) { 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 8521fff..7489cf2 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 { cachedApiHandler, parseBody, isValidReportCode, badRequest } from "@/lib/api-utils"; +import { checkRateLimit } from "@/lib/rate-limit"; import { PLAYER_FULL_DATA_QUERY, PLAYER_FULL_DATA_QUERY_HEALING, @@ -14,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, @@ -60,6 +60,16 @@ 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; + + // 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 65c126a..cf73587 100644 --- a/app/api/cla/route.ts +++ b/app/api/cla/route.ts @@ -6,10 +6,11 @@ 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 { WCLPlayerDetails, @@ -51,13 +52,31 @@ export async function POST(request: NextRequest) { if ("error" in parsed) return parsed.error; const { reportCode, fightIds } = parsed.body; - if (fightIds.length === 0) { + const limited = await checkRateLimit(request, "cla"); + if (limited) return limited; + + // 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, { @@ -67,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 }); } @@ -116,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 7d10266..2d43abf 100644 --- a/app/api/raid-overview/route.ts +++ b/app/api/raid-overview/route.ts @@ -7,7 +7,8 @@ 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, WCLCombatantInfoEvent, @@ -113,6 +114,14 @@ 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; + + 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/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/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) => ( { - // 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) => { 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/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 }); diff --git a/app/opengraph-image.tsx b/app/opengraph-image.tsx index 35813b8..ed69f63 100644 --- a/app/opengraph-image.tsx +++ b/app/opengraph-image.tsx @@ -49,7 +49,6 @@ export default function OGImage() { gap: "20px", }} > - {/* eslint-disable-next-line @next/next/no-img-element */} (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..515b149 100644 --- a/bot/src/index.ts +++ b/bot/src/index.ts @@ -15,7 +15,6 @@ import { import { handleRaid } from "./commands/raid.js"; import { handleAnalyze } from "./commands/analyze.js"; import { parseWCLUrl } from "./util/parse-url.js"; -import { PARSEFORGE_GOLD } from "./util/constants.js"; const token = process.env.DISCORD_TOKEN; const clientId = process.env.DISCORD_CLIENT_ID; @@ -76,25 +75,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 +149,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 +167,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 { 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/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.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/analysis-engine.ts b/lib/analysis-engine.ts index 0c38936..65443a0 100644 --- a/lib/analysis-engine.ts +++ b/lib/analysis-engine.ts @@ -8,7 +8,7 @@ import { CLASS_TALENT_TREES, getPerformanceGrade, } from "./constants"; -import { ENCHANT_NAME_DB, GEM_NAME_DB, GEM_STAT_DB } from "./cla-constants"; +import { ENCHANT_NAME_DB } from "./cla-constants"; import { AnalysisResult, ConsumableStatus, @@ -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 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/api-utils.ts b/lib/api-utils.ts index 18327a9..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 @@ -25,6 +44,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, @@ -43,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/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/constants.ts b/lib/constants.ts index 55812e8..fe0b029 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -86,6 +86,26 @@ 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, +}; + +// 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", diff --git a/lib/kv-cache.ts b/lib/kv-cache.ts index bfcfb50..b446b26 100644 --- a/lib/kv-cache.ts +++ b/lib/kv-cache.ts @@ -87,6 +87,60 @@ 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 +// 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 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/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/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"; /** 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; } 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})` 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 [ { diff --git a/package-lock.json b/package-lock.json index 4f37cac..597c208 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", @@ -32,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": { @@ -716,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": { @@ -738,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, @@ -2200,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", @@ -3777,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", @@ -3804,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", @@ -4166,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, @@ -4176,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", @@ -4811,6 +5132,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", @@ -4891,14 +5245,127 @@ } } }, - "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", + "@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" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "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": { @@ -5225,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", @@ -5499,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", @@ -6343,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", @@ -6867,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", @@ -6937,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", @@ -7301,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", @@ -9290,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", @@ -9650,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", @@ -9924,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", @@ -9964,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": [ { @@ -9984,7 +10534,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -10560,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", @@ -11063,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", @@ -11109,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", @@ -11119,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", @@ -11474,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", @@ -11485,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" @@ -11520,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": { @@ -11532,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", @@ -11833,6 +12455,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", @@ -12037,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", @@ -12157,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 9c7b32f..1c966e7 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,12 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "test": "vitest run" }, "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", @@ -33,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"], + }, +});