Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2610b3c
Phase 1.1: per-IP rate limiting on all API routes
alexandermayes Jul 19, 2026
c453db1
Phase 1.2: validate POST inputs + cap /api/cla fan-out
alexandermayes Jul 20, 2026
4349d36
Phase 1.3: single-flight lock to kill cache stampedes
alexandermayes Jul 20, 2026
9b484f1
Phase 1.4: Discord bot cooldowns + crash hardening
alexandermayes Jul 20, 2026
9c1d1d3
Phase 1: check off launch-blocking set in plan
alexandermayes Jul 20, 2026
cc6ee44
Phase 2.1: security headers (CSP report-only first)
alexandermayes Jul 21, 2026
99780c0
Phase 2.2: tighten /og route inputs
alexandermayes Jul 21, 2026
e32bcf4
Phase 2.3: integer-coerce ids in the built CLA query
alexandermayes Jul 21, 2026
4701187
Phase 2.4: PostHog session-replay privacy defaults
alexandermayes Jul 21, 2026
c18a3d9
Phase 2.5: share the WCL OAuth token across instances
alexandermayes Jul 21, 2026
820eb59
Phase 2: check off in plan
alexandermayes Jul 21, 2026
d85a0b0
Phase 3.3: unify the duplicated WCL URL parser
alexandermayes Jul 21, 2026
a0c6301
Phase 3.2: harden the bot Dockerfile
alexandermayes Jul 21, 2026
b4a5db2
Phase 3.1: add GitHub Actions CI
alexandermayes Jul 21, 2026
e399e9e
Phase 4: first test suite (vitest)
alexandermayes Jul 21, 2026
8a7e815
Phases 3 & 4: check off in plan (all phases complete)
alexandermayes Jul 21, 2026
df1d4ce
Clear pre-existing lint errors + make CI lint blocking
alexandermayes Jul 21, 2026
694785c
Note lint debt cleared in plan
alexandermayes Jul 21, 2026
8cf0c6d
Sweep remaining lint warnings (12 → 0)
alexandermayes Jul 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
207 changes: 207 additions & 0 deletions PRODUCTION_HARDENING.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions app/analyze/[reportCode]/AnalyzeClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]
);

Expand Down
3 changes: 3 additions & 0 deletions app/analyze/[reportCode]/hooks/useCLA.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
3 changes: 3 additions & 0 deletions app/analyze/[reportCode]/hooks/usePlayerAnalysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
2 changes: 1 addition & 1 deletion app/analyze/[reportCode]/hooks/useReportMeta.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
14 changes: 12 additions & 2 deletions app/api/analyze/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
33 changes: 26 additions & 7 deletions app/api/cla/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<ReportMetaResponse>(REPORT_META_QUERY, {
Expand All @@ -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 });
}
Expand Down Expand Up @@ -116,7 +135,7 @@ export async function POST(request: NextRequest) {
// Fetch player details using all fight IDs
const playerDetailsPromise = wclQuery<PlayerDetailsResponse>(
playerDetailsQuery,
{ code: reportCode, fightIDs: fightIds }
{ code: reportCode, fightIDs: uniqueFightIds }
);

// Batch source IDs into groups of BATCH_SIZE (same for every fight)
Expand Down
11 changes: 10 additions & 1 deletion app/api/raid-overview/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<RaidOverviewResponse>(RAID_OVERVIEW_QUERY, {
Expand Down
4 changes: 4 additions & 0 deletions app/api/report/[code]/players/route.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<PlayerDetailsResponse>(QUERY, {
code,
Expand Down
6 changes: 5 additions & 1 deletion app/api/report/[code]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
1 change: 0 additions & 1 deletion app/components/CLABuffTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion app/components/CLAClassBuffs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<number | null>(null);

const playersWithBuffData = useMemo(() => {
Expand Down
2 changes: 1 addition & 1 deletion app/components/LandingHero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export function LandingHero() {
{/* Feature cards */}
<BlurFade delay={0.3} inView>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-left">
{features.map((f, i) => (
{features.map((f) => (
<MagicCard
key={f.title}
className="rounded-xl p-4 cursor-default"
Expand Down
18 changes: 9 additions & 9 deletions app/components/PostHogProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,16 @@ export default function PostHogProvider({ children }: { children: React.ReactNod
// Session replay
disable_session_recording: false,
session_recording: {
maskAllInputs: false,
maskInputFn: (text, element) => {
// Only mask actual sensitive fields, not the report URL input
const el = element as HTMLInputElement | null;
if (el?.type === "password") return "*".repeat(text.length);
return text;
},
// Mask all inputs by default. This masks the report-URL input too, which
// is an acceptable trade for a privacy-safe default (vs. the previous
// un-masking that could capture whatever a user typed).
maskAllInputs: true,
},
// Console log capture
enable_recording_console_log: true,
// Don't capture console logs into replays — they can hoover up anything
// logged client-side.
enable_recording_console_log: false,
// TODO: serving EU users with session replay ultimately needs a consent
// banner — that's a product decision, out of scope here.
// Autocapture clicks, inputs, form submits
autocapture: true,
loaded: (ph) => {
Expand Down
3 changes: 3 additions & 0 deletions app/components/RecentReports.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export default function RecentReports() {
const [reports, setReports] = useState<RecentReport[]>([]);

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());
}, []);

Expand Down
31 changes: 20 additions & 11 deletions app/og/route.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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(<ReportCard meta={null} reportCode="" />, { ...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<AnalysisResult>(`${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(<PlayerCard data={data} />, { ...size, headers });
Expand Down
1 change: 0 additions & 1 deletion app/opengraph-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ export default function OGImage() {
gap: "20px",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="https://parseforge.gg/logo.png"
width={64}
Expand Down
8 changes: 8 additions & 0 deletions bot/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,15 @@ RUN npm run build

FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
# Drop root — run as the image's built-in unprivileged `node` user. `dist` is
# copied root-owned and read-only, which is all the app needs.
USER node
# NOTE: the bot exits on fatal startup errors, so the runtime must restart it —
# set a restart policy (`restart: unless-stopped` in compose, or an equivalent
# Kubernetes restartPolicy). Phase 1.4 hardened the request paths so runtime
# errors no longer crash it; only startup fatals exit, for a clean restart.
CMD ["node", "dist/index.js"]
Loading
Loading