Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
# Build-time only — env.ts validates presence; no real services are reached.
DATABASE_URL: postgresql://ci:ci@localhost:5432/ci
AUTH_SECRET: ci-only-secret-at-least-32-characters-long
CRON_SECRET: ci-cron-secret
CRON_SECRET: ci-cron-secret-at-least-16-chars
APP_BASE_URL: http://localhost:3000
steps:
- uses: actions/checkout@v4
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Postgres `bracketeer` DB (e.g. a Docker `postgres` container on `localhost:5432`

```bash
# Inline env prefix used throughout (DB + the two required secrets):
ENV='DATABASE_URL=postgresql://postgres:postgres@localhost:5432/bracketeer AUTH_SECRET=dev-only-secret-at-least-32-characters-long CRON_SECRET=dev-cron-secret APP_BASE_URL=http://localhost:3000'
ENV='DATABASE_URL=postgresql://postgres:postgres@localhost:5432/bracketeer AUTH_SECRET=dev-only-secret-at-least-32-characters-long CRON_SECRET=dev-cron-secret-16-chars APP_BASE_URL=http://localhost:3000'

env $ENV npm run dev # dev server (http://localhost:3000)
env $ENV npm run build # production build (compiles/validates every route)
Expand Down
16 changes: 4 additions & 12 deletions app/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextRequest } from "next/server";
import { handlers } from "@/auth";
import { rateLimit } from "@/lib/rate-limit";
import { clientIpFromForwardedFor } from "@/lib/rate-limit-core";
import { apiError } from "@/lib/api";

// Auth.js v5 mounts both verbs from a single handlers object. GET (session/CSRF
Expand All @@ -10,19 +11,10 @@ import { apiError } from "@/lib/api";
// a normal multi-step sign-in.
export const { GET } = handlers;

// Use the RIGHTMOST x-forwarded-for hop — the one the platform edge (Railway)
// appends — not the leftmost, which is client-supplied and trivially spoofed. With
// a single hop the two coincide. Best-effort: a misconfigured proxy weakens this,
// but it's a soft brute-force guard, not an auth control.
function clientIp(req: NextRequest): string {
const xff = req.headers.get("x-forwarded-for");
if (!xff) return "anon";
const hops = xff.split(",").map((h) => h.trim()).filter(Boolean);
return hops.length ? hops[hops.length - 1] : "anon";
}

export async function POST(req: NextRequest): Promise<Response> {
if (!(await rateLimit(`auth-post:${clientIp(req)}`, 20, 60_000)).ok) {
// Rightmost-hop IP keying — see clientIpFromForwardedFor for why leftmost is unsafe.
const ip = clientIpFromForwardedFor(req.headers.get("x-forwarded-for"));
if (!(await rateLimit(`auth-post:${ip}`, 20, 60_000)).ok) {
return apiError("Too many attempts — wait a minute and try again.", 429);
}
return handlers.POST(req);
Expand Down
5 changes: 4 additions & 1 deletion app/api/feedback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { NextRequest } from "next/server";
import { z } from "zod";
import { getSessionUser } from "@/lib/pool/access";
import { rateLimit } from "@/lib/rate-limit";
import { clientIpFromForwardedFor } from "@/lib/rate-limit-core";
import { apiOk, apiError } from "@/lib/api";
import { createFeedback } from "@/lib/feedback/submit";

Expand Down Expand Up @@ -46,7 +47,9 @@ const schema = z.object({
export async function POST(req: NextRequest) {
const user = await getSessionUser();

const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "anon";
// Rightmost XFF hop (edge-appended) — the leftmost is client-spoofable, which
// would let an anonymous submitter mint a fresh rate-limit bucket per request.
const ip = clientIpFromForwardedFor(req.headers.get("x-forwarded-for"));
const key = user ? `feedback:${user.id}` : `feedback:ip:${ip}`;
const rl = await rateLimit(key, 10, 5 * 60_000);
if (!rl.ok) return apiError("rate limited", 429);
Expand Down
6 changes: 6 additions & 0 deletions app/api/giphy/search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { NextRequest } from "next/server";
import { z } from "zod";
import { getSessionUser } from "@/lib/pool/access";
import { rateLimit } from "@/lib/rate-limit";
import { env, giphyEnabled } from "@/lib/env";
import { mapGiphyResults } from "@/lib/chat/giphy";
import { apiOk, apiError } from "@/lib/api";
Expand All @@ -27,6 +28,11 @@ export async function GET(req: NextRequest) {
// Defensive: the UI hides the button when Giphy is disabled.
if (!giphyEnabled) return apiOk({ results: [] });

// Every call spends the shared GIPHY_API_KEY quota, so throttle per user like
// the chat routes (generous for a human paging the picker, a wall for a loop).
const rl = await rateLimit(`giphy:${user.id}`, 30, 60_000);
if (!rl.ok) return apiError("Slow down a moment and try again.", 429);

let parsed: z.infer<typeof querySchema>;
try {
parsed = querySchema.parse({
Expand Down
16 changes: 13 additions & 3 deletions app/challenge/md3/Md3ChallengeForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,28 @@ import type { Md3FixtureVM } from "@/lib/pool/md3-view";
import { Flag } from "@/app/pool/[code]/Flag";
import { WinProbBar } from "@/app/pool/[code]/WinProbBar";
import { LiveBadge } from "@/app/pool/[code]/LiveBadge";
import { DISPLAY_TZ } from "@/lib/tz";

// Group fixtures by kickoff day for light visual chunking.
// Group fixtures by kickoff day for light visual chunking. Pinned to DISPLAY_TZ
// like every other match surface: this is a client component, so an unpinned
// locale format renders in the server's zone (UTC on Railway) then re-renders in
// the viewer's — a hydration mismatch, with late kickoffs grouped under the
// wrong day on first paint.
function dayLabel(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, {
return new Date(iso).toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
timeZone: DISPLAY_TZ,
});
}

function kickoffLabel(iso: string): string {
return new Date(iso).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
return new Date(iso).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
timeZone: DISPLAY_TZ,
});
}

const SCORE_BOX =
Expand Down
17 changes: 15 additions & 2 deletions app/challenge/md3/Md3PickBreakdown.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Md3View, Md3FixtureVM } from "@/lib/pool/md3-view";
import { Flag } from "@/app/pool/[code]/Flag";
import { WinProbBar } from "@/app/pool/[code]/WinProbBar";
import { DISPLAY_TZ } from "@/lib/tz";

// The MD3 analogue of the knockout Profile: a read-only, per-fixture breakdown of
// one entry's predictions vs. the live/final results, with the points each pick
Expand All @@ -9,12 +10,24 @@ import { WinProbBar } from "@/app/pool/[code]/WinProbBar";
// Group-stage accent, matching the ScoreCards / form fixture cards.
const GROUP_ACCENT = "var(--pitch)";

// Pinned to DISPLAY_TZ like every other match surface — this renders on the
// server (UTC on Railway), where an unpinned format shows UTC times and splits
// the late Eastern-evening fixtures across the wrong day headers.
function kickoffLabel(iso: string): string {
return new Date(iso).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
return new Date(iso).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
timeZone: DISPLAY_TZ,
});
}

function dayLabel(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
return new Date(iso).toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
timeZone: DISPLAY_TZ,
});
}

function Stat({ label, value }: { label: string; value: string | number }) {
Expand Down
6 changes: 5 additions & 1 deletion app/pool/[code]/R32Countdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import { LABEL } from "@/lib/ui/labels";
// R32 is under way — and keeps the server component pure (no render-time clock).
export function R32Countdown({ target, label }: { target: string; label: string }) {
const targetMs = new Date(target).getTime();
const [passed, setPassed] = useState(false);
// Seed from the clock, not `false`: once the kickoff is in the past both the
// server render and the client's first paint agree it's gone, instead of
// flashing a stale "kicks off in · · ·" banner that the mount effect removes.
// (Server and client clocks only disagree across the kickoff instant itself.)
const [passed, setPassed] = useState(() => Date.now() >= targetMs);

useEffect(() => {
const tick = () => setPassed(Date.now() >= targetMs);
Expand Down
7 changes: 5 additions & 2 deletions lib/auth/claim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,11 @@ export async function claimEntriesForUser(
// claim path (CSV import + sign-in, the primary way contestants enroll)
// would silently push a FREE pool past its member cap, bypassing the gate
// that joinPool/acceptInvite enforce. At cap, the entry is still bound to
// the account above; the owner upgrades to Premium to seat them. The
// count→create isn't atomic (same documented MVP tradeoff as joinPool).
// the account above; the owner upgrades to Premium to seat them. The same
// per-pool advisory lock joinPool/acceptInvite take serializes this
// count→create against theirs — without it a concurrent join and claim
// both count the pre-insert total and overshoot the cap by one.
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${entry.poolId}))`;
const pool = await tx.pool.findUnique({
where: { id: entry.poolId },
select: { tier: true },
Expand Down
15 changes: 10 additions & 5 deletions lib/challenge/knockout-dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,13 @@ export async function getKnockoutChallengeProfile(entryId: string): Promise<Prof
getChallengeKnockoutProjection(tournamentId),
]);

// Same eligibility filter as the public leaderboard — only complete & valid
// brackets are materialized, so the profile can't leak an incomplete one.
// Board membership is the eligibility source of truth: the leaderboard applies
// the verified-email (anti-Sybil) gate on top of completeness, and a profile —
// like the pick-share denominators — must only ever expose entries the public
// board shows. Filtering here on completeness alone leaked gated brackets.
const boardIds = new Set(board.map((r) => r.entryId));
const eligible = entries.filter((e) => {
if (!boardIds.has(e.id)) return false;
try {
return isKnockoutEntryComplete(pickRowsToSubmission(e.picks).picks);
} catch {
Expand All @@ -172,7 +176,8 @@ export async function getKnockoutChallengeProfile(entryId: string): Promise<Prof
if (!entry) return null;

const row = board.find((r) => r.entryId === entryId);
const projected = row?.projected ?? 0;
if (!row) return null;
const projected = row.projected ?? 0;

const projectionRow = projection.hasData
? projection.rows.find((r) => r.entryId === entryId)
Expand All @@ -188,9 +193,9 @@ export async function getKnockoutChallengeProfile(entryId: string): Promise<Prof
return buildProfile({
entryId: entry.id,
label: entry.label,
total: (row?.total ?? 0) + projected,
total: row.total + projected,
projected: projected > 0 ? projected : undefined,
rank: row?.rank ?? Math.max(1, board.length),
rank: row.rank,
entryCount: board.length,
picks: pickRowsToSubmission(entry.picks).picks,
results: asResults(tournament.officialResults),
Expand Down
5 changes: 4 additions & 1 deletion lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ const schema = z.object({
// links are logged to the server console for local development.
EMAIL_SERVER: z.string().default(""),
EMAIL_FROM: z.string().default("Bracketeer <no-reply@bracketeer.app>"),
CRON_SECRET: z.string().min(1),
// Guards the internet-reachable, result-mutating /api/cron/* routes — a
// trivially-guessable value here would let anyone write scores, so require
// real entropy (prod uses 48 chars; AUTH_SECRET already requires 16+).
CRON_SECRET: z.string().min(16, "CRON_SECRET must be at least 16 characters"),
// Sports data API (optional — manual result entry works without it).
SPORTS_API_KEY: z.string().default(""),
SPORTS_API_BASE: z.string().default("https://v3.football.api-sports.io"),
Expand Down
17 changes: 17 additions & 0 deletions lib/odds/extras.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,13 @@ import {
toGoalscorerProbs,
} from "@/lib/odds/map";
import { loadCodedMatches } from "@/lib/odds/coded";
import { extrasPollDue } from "@/lib/odds/schedule";

export interface OddsExtrasSummary {
// True when the run spent no credits because the last extras poll is recent
// (see extrasPollDue) — the cron's in-memory daily bucket resets on restart,
// so this DB-backed guard is what actually bounds the metered spend.
skipped?: boolean;
totalsFetched: number;
totalsUpdated: number;
spreadsFetched: number;
Expand Down Expand Up @@ -55,6 +60,18 @@ export async function pollOddsExtras(): Promise<OddsExtrasSummary> {
});
if (!tournament) return summary;

// Restart-proof daily throttle: the outright rows carry the last run's
// fetchedAt, so a cron redeploy/crash-loop (which resets the in-memory daily
// bucket) can't re-spend the 4+ metered credits until the interval elapses.
const lastRun = await prisma.teamOutright.findFirst({
where: { tournamentId: tournament.id },
orderBy: { fetchedAt: "desc" },
select: { fetchedAt: true },
});
if (!extrasPollDue(lastRun?.fetchedAt?.getTime() ?? null, Date.now())) {
return { ...summary, skipped: true };
}

// --- Totals: update existing MatchOdds rows only. The h2h poll owns row
// creation; updateMany simply no-ops (0 rows) until that row exists. ---
try {
Expand Down
13 changes: 13 additions & 0 deletions lib/odds/schedule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest";
import {
snapshotDue,
snapshotKickoffRange,
extrasPollDue,
EXTRAS_MIN_INTERVAL_MS,
EARLY_WINDOW_START_MS,
PRE_WINDOW_START_MS,
HALF_WINDOW_START_MS,
Expand Down Expand Up @@ -80,3 +82,14 @@ describe("snapshotKickoffRange", () => {
expect(gt.getTime()).toBeLessThan(lt.getTime());
});
});

describe("extrasPollDue", () => {
it("fires when never fetched and after the interval, not inside it", () => {
const now = Date.parse("2026-07-02T12:00:00Z");
expect(extrasPollDue(null, now)).toBe(true);
// A cron restart minutes after a successful run must NOT re-spend credits.
expect(extrasPollDue(now - 5 * 60_000, now)).toBe(false);
expect(extrasPollDue(now - EXTRAS_MIN_INTERVAL_MS + 1, now)).toBe(false);
expect(extrasPollDue(now - EXTRAS_MIN_INTERVAL_MS, now)).toBe(true);
});
});
14 changes: 14 additions & 0 deletions lib/odds/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,17 @@ export function snapshotKickoffRange(now: number): { gt: Date; lt: Date } {
lt: new Date(now - EARLY_WINDOW_START_MS),
};
}

// Minimum spacing between extras polls (totals/spreads/outrights — 4+ credits per
// run). The cron's own 24 h bucket is in-memory and resets on every restart, and
// the cron service restarts with each deploy — so the DB timestamp of the last
// run, checked through this policy, is the ceiling that actually holds. 20 h (not
// 24) so one poll per calendar day still fires even as the cron's daily bucket
// boundary drifts around restarts.
export const EXTRAS_MIN_INTERVAL_MS = 20 * 60 * 60_000;

// Whether an extras poll may spend credits now, given when the last one ran
// (null = never). Pure for testing; pollOddsExtras supplies the DB timestamp.
export function extrasPollDue(lastFetchedAtMs: number | null, now: number): boolean {
return lastFetchedAtMs == null || now - lastFetchedAtMs >= EXTRAS_MIN_INTERVAL_MS;
}
80 changes: 79 additions & 1 deletion lib/pool/bracket.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { describe, it, expect } from "vitest";
import { resolveBracket, validateKnockoutWinner, buildKnockoutPairMatchNos } from "./bracket";
import {
resolveBracket,
validateKnockoutWinner,
buildKnockoutPairMatchNos,
findKnockoutSeatingConflict,
orientScoresToSlot,
} from "./bracket";
import { resolveR32Slots } from "@/lib/scoring/resolve";
import { GROUPS, R32, R16, QF, SF, BRONZE, FINAL } from "@/lib/scoring/data";
import { emptyPicks, type Results } from "@/lib/scoring/types";
Expand Down Expand Up @@ -151,3 +157,75 @@ describe("buildKnockoutPairMatchNos", () => {
expect([...pairs.values()]).not.toContain(QF[0].id);
});
});

describe("findKnockoutSeatingConflict", () => {
it("is null when every recorded winner sits in its resolved matchup", () => {
const results = chalkStandings();
const bracket = resolveBracket(results);
const knockout = { 73: bracket[73].home!, 74: bracket[74].away! };
expect(findKnockoutSeatingConflict({ ...results, knockout })).toBeNull();
});

it("flags a group-placement edit that unseats a recorded winner", () => {
const results = chalkStandings();
// Find the R32 match hosting some group's 1st-place slot, and record that
// occupant as its winner.
const g = groups[groups.length - 1]; // a group whose 3rd is NOT in thirdAdvance
const match = R32.find(
(m) =>
("group" in m.a && m.a.group === g && m.a.pos === 1) ||
("group" in m.b && m.b.group === g && m.b.pos === 1),
)!;
const winner = results.groupFirst[g];
const knockout = { [match.id]: winner };
expect(findKnockoutSeatingConflict({ ...results, knockout })).toBeNull();
// Re-seat that slot by "correcting" the group's 1st place to a different
// team: the recorded winner is orphaned from its matchup — flagged.
const edited = {
...results,
groupFirst: { ...results.groupFirst, [g]: GROUPS[g][3] },
knockout,
};
const conflict = findKnockoutSeatingConflict(edited);
expect(conflict).toMatch(/not in its resolved matchup/);
});

it("ignores matches whose slots are not resolvable yet", () => {
// A recorded R16 winner with no R32 winners recorded — feeders unresolved, no conflict.
const results = { ...chalkStandings(), knockout: { [R16[0].id]: GROUPS.A[0] } };
expect(findKnockoutSeatingConflict(results)).toBeNull();
});
});

describe("orientScoresToSlot", () => {
const slot = { home: "MEX", away: "USA" };
const scores = { homeScore: 1, awayScore: 2, homePens: 5, awayPens: 4 };

it("passes through when the API home is the bracket home", () => {
expect(orientScoresToSlot(slot, { ...scores, apiHomeCode: "MEX", apiAwayCode: "USA" })).toEqual(
scores,
);
});

it("swaps scores AND pens when the API home is the bracket away", () => {
expect(orientScoresToSlot(slot, { ...scores, apiHomeCode: "USA", apiAwayCode: "MEX" })).toEqual(
{ homeScore: 2, awayScore: 1, homePens: 4, awayPens: 5 },
);
});

it("passes through untouched with no API codes (manual entry) or an unseated slot", () => {
expect(orientScoresToSlot(slot, scores)).toEqual(scores);
expect(orientScoresToSlot(undefined, { ...scores, apiHomeCode: "USA" })).toEqual(scores);
expect(orientScoresToSlot({ home: null, away: null }, { ...scores, apiHomeCode: "USA" })).toEqual(
scores,
);
// API home matches neither seated team — don't guess.
expect(orientScoresToSlot(slot, { ...scores, apiHomeCode: "BRA" })).toEqual(scores);
});

it("normalizes missing fields to null", () => {
expect(orientScoresToSlot(slot, { apiHomeCode: "USA", apiAwayCode: "MEX", homeScore: 3 })).toEqual(
{ homeScore: null, awayScore: 3, homePens: null, awayPens: null },
);
});
});
Loading
Loading