diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b79019..58a71fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 78e9e29..727c178 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index 285fe74..0a52c1d 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -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 @@ -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 { - 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); diff --git a/app/api/feedback/route.ts b/app/api/feedback/route.ts index e2e72fb..064b931 100644 --- a/app/api/feedback/route.ts +++ b/app/api/feedback/route.ts @@ -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"; @@ -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); diff --git a/app/api/giphy/search/route.ts b/app/api/giphy/search/route.ts index 11acbbf..7781616 100644 --- a/app/api/giphy/search/route.ts +++ b/app/api/giphy/search/route.ts @@ -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"; @@ -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; try { parsed = querySchema.parse({ diff --git a/app/challenge/md3/Md3ChallengeForm.tsx b/app/challenge/md3/Md3ChallengeForm.tsx index 132dec8..a76e958 100644 --- a/app/challenge/md3/Md3ChallengeForm.tsx +++ b/app/challenge/md3/Md3ChallengeForm.tsx @@ -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 = diff --git a/app/challenge/md3/Md3PickBreakdown.tsx b/app/challenge/md3/Md3PickBreakdown.tsx index 27360d1..2acecf4 100644 --- a/app/challenge/md3/Md3PickBreakdown.tsx +++ b/app/challenge/md3/Md3PickBreakdown.tsx @@ -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 @@ -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 }) { diff --git a/app/pool/[code]/R32Countdown.tsx b/app/pool/[code]/R32Countdown.tsx index 8ccdbb2..f80d040 100644 --- a/app/pool/[code]/R32Countdown.tsx +++ b/app/pool/[code]/R32Countdown.tsx @@ -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); diff --git a/lib/auth/claim.ts b/lib/auth/claim.ts index 2670b27..0d36985 100644 --- a/lib/auth/claim.ts +++ b/lib/auth/claim.ts @@ -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 }, diff --git a/lib/challenge/knockout-dashboard.ts b/lib/challenge/knockout-dashboard.ts index 2db9cd2..a70c34d 100644 --- a/lib/challenge/knockout-dashboard.ts +++ b/lib/challenge/knockout-dashboard.ts @@ -159,9 +159,13 @@ export async function getKnockoutChallengeProfile(entryId: string): Promise r.entryId)); const eligible = entries.filter((e) => { + if (!boardIds.has(e.id)) return false; try { return isKnockoutEntryComplete(pickRowsToSubmission(e.picks).picks); } catch { @@ -172,7 +176,8 @@ export async function getKnockoutChallengeProfile(entryId: string): Promise 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) @@ -188,9 +193,9 @@ export async function getKnockoutChallengeProfile(entryId: string): Promise 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), diff --git a/lib/env.ts b/lib/env.ts index 4567ff1..952f1d2 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -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 "), - 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"), diff --git a/lib/odds/extras.ts b/lib/odds/extras.ts index a13216f..40f4857 100644 --- a/lib/odds/extras.ts +++ b/lib/odds/extras.ts @@ -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; @@ -55,6 +60,18 @@ export async function pollOddsExtras(): Promise { }); 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 { diff --git a/lib/odds/schedule.test.ts b/lib/odds/schedule.test.ts index 1365b96..e3967e0 100644 --- a/lib/odds/schedule.test.ts +++ b/lib/odds/schedule.test.ts @@ -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, @@ -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); + }); +}); diff --git a/lib/odds/schedule.ts b/lib/odds/schedule.ts index f2c326a..cd1d6f0 100644 --- a/lib/odds/schedule.ts +++ b/lib/odds/schedule.ts @@ -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; +} diff --git a/lib/pool/bracket.test.ts b/lib/pool/bracket.test.ts index f1ca34f..0d84465 100644 --- a/lib/pool/bracket.test.ts +++ b/lib/pool/bracket.test.ts @@ -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"; @@ -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 }, + ); + }); +}); diff --git a/lib/pool/bracket.ts b/lib/pool/bracket.ts index 46d90f5..c100f36 100644 --- a/lib/pool/bracket.ts +++ b/lib/pool/bracket.ts @@ -77,6 +77,58 @@ export function buildKnockoutPairMatchNos(results: Results): Map const KNOCKOUT_RANGE = (n: number) => n >= 73 && n <= 104; +// Reject an answer key whose resolved seating contradicts a winner it already +// records: if match N's winner is no longer one of match N's two seated teams, +// the seating edit (not the winner) is wrong. A standings correction that +// changes a group's 1st/2nd or the advancing-thirds set after that team's R32 +// match was recorded would re-seat the bracket, orphan the recorded result, and +// break team-pair fixture mapping — surface it before the write instead. (To +// genuinely amend such a result, clear it first via clearKnockoutResult.) +export function findKnockoutSeatingConflict(results: Results): string | null { + const bracket = resolveBracket(results); + for (const [no, winner] of Object.entries(results.knockout || {})) { + if (!winner) continue; + const m = bracket[Number(no)]; + if (m?.home && m?.away && winner !== m.home && winner !== m.away) { + return `Match ${no}'s recorded winner ${winner} is not in its resolved matchup (${m.home} vs ${m.away}) — this standings edit contradicts a recorded result; clear that result first`; + } + } + return null; +} + +// Orient an API-reported scoreline (home/away as the provider frames the fixture) +// to the bracket slot's home/away, so the FINAL Result row reads the same way as +// the live row (which already orients) and never contradicts winnerCode. When the +// API home code matches neither seated team (or no codes are provided) the input +// is returned unchanged — the manual-entry path passes no API codes. +export function orientScoresToSlot( + slot: { home: TeamCode | null; away: TeamCode | null } | undefined, + input: { + homeScore?: number | null; + awayScore?: number | null; + homePens?: number | null; + awayPens?: number | null; + apiHomeCode?: string | null; + apiAwayCode?: string | null; + }, +): { homeScore: number | null; awayScore: number | null; homePens: number | null; awayPens: number | null } { + const asIs = { + homeScore: input.homeScore ?? null, + awayScore: input.awayScore ?? null, + homePens: input.homePens ?? null, + awayPens: input.awayPens ?? null, + }; + if (!input.apiHomeCode || !slot?.home || !slot?.away) return asIs; + if (input.apiHomeCode === slot.home) return asIs; + if (input.apiHomeCode !== slot.away) return asIs; // unrecognized orientation — don't guess + return { + homeScore: asIs.awayScore, + awayScore: asIs.homeScore, + homePens: asIs.awayPens, + awayPens: asIs.homePens, + }; +} + export interface ValidationResult { ok: boolean; reason?: string; diff --git a/lib/pool/games.test.ts b/lib/pool/games.test.ts index eb2a96f..6e3a3b5 100644 --- a/lib/pool/games.test.ts +++ b/lib/pool/games.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect } from "vitest"; -import { GAME_CATALOG, resolveGamePhase, featuredGame, prizeTeaser } from "./games"; +import { + GAME_CATALOG, + resolveGamePhase, + featuredGame, + prizeTeaser, + md3DateRange, + koPickemDateRange, +} from "./games"; import type { PoolFormat } from "@/lib/pool/manage"; // Fixed schedule anchors mirrored from lib/scoring/schedule + lib/pool/knockout. @@ -60,6 +67,15 @@ describe("resolveGamePhase — MD3 (knockout pick'em)", () => { expect(s.joinable).toBe(false); expect(s.creatable).toBe(false); }); + it("is COMPLETE once the Final has settled (never 'live' forever)", () => { + // Final kickoff (19:00 Jul 19) + the 6h settle window → complete from 01:00 Jul 20. + expect(resolveGamePhase("MATCH_DAY_3_PICKEM", at("2026-07-20T00:59:00Z")).phase).toBe( + "LOCKED_LIVE", + ); + expect(resolveGamePhase("MATCH_DAY_3_PICKEM", at("2026-07-20T01:00:00Z")).phase).toBe( + "COMPLETE", + ); + }); }); describe("resolveGamePhase — KNOCKOUT", () => { @@ -74,6 +90,10 @@ describe("resolveGamePhase — KNOCKOUT", () => { it("is LOCKED_LIVE at and after the R32 kickoff", () => { expect(resolveGamePhase("KNOCKOUT", at(KO_LOCK)).phase).toBe("LOCKED_LIVE"); }); + it("stays LOCKED_LIVE through the knockout rounds, COMPLETE after the final settles", () => { + expect(resolveGamePhase("KNOCKOUT", at("2026-07-02T12:00:00Z")).phase).toBe("LOCKED_LIVE"); + expect(resolveGamePhase("KNOCKOUT", at("2026-07-21T00:00:00Z")).phase).toBe("COMPLETE"); + }); }); describe("resolveGamePhase — FULL_BRACKET", () => { @@ -82,6 +102,19 @@ describe("resolveGamePhase — FULL_BRACKET", () => { expect(resolveGamePhase("FULL_BRACKET", at(FULL_START)).phase).toBe("LOCKED_LIVE"); expect(resolveGamePhase("FULL_BRACKET", at("2026-06-22T00:00:00Z")).creatable).toBe(false); }); + it("is COMPLETE after the final settles", () => { + expect(resolveGamePhase("FULL_BRACKET", at("2026-07-21T00:00:00Z")).phase).toBe("COMPLETE"); + }); +}); + +describe("date ranges", () => { + it("frames spans in the display timezone (Eastern), not UTC", () => { + // The last group MD3 kickoff is 02:00Z Jun 28 = 10:00 PM ET Jun 27 — the UTC + // framing this replaced printed "June 24–28" for the June 24–27 slate. + expect(md3DateRange()).toBe("June 24–27"); + // Knockout pick'em span: R32 kickoff → Final kickoff, both mid-day ET. + expect(koPickemDateRange()).toBe("June 28 – July 19"); + }); }); describe("featuredGame across the knockout window", () => { diff --git a/lib/pool/games.ts b/lib/pool/games.ts index 354b813..31d2035 100644 --- a/lib/pool/games.ts +++ b/lib/pool/games.ts @@ -12,6 +12,7 @@ import { KNOCKOUT_PICKS_OPEN_UTC } from "@/lib/pool/knockout"; import { firstMd3Kickoff, lastMd3Kickoff } from "@/lib/pool/match-day-3"; import { firstKnockoutKickoff, lastKnockoutKickoff } from "@/lib/games/daily-pickem/schedule"; import { kickoffFor } from "@/lib/scoring/schedule"; +import { DISPLAY_TZ } from "@/lib/tz"; import { PRIZES, isChallengeFormat } from "@/lib/challenge/prizes-config"; import { formatPrize } from "@/lib/challenge/format-prize"; @@ -99,6 +100,22 @@ const KNOCKOUT_OPEN = new Date(KNOCKOUT_PICKS_OPEN_UTC); const KNOCKOUT_LOCK = kickoffFor(73) ?? new Date(KNOCKOUT_PICKS_OPEN_UTC); const FULL_START = kickoffFor(1) ?? new Date("2026-06-11T19:00:00Z"); +// Every game is COMPLETE a settle-window after the Final's kickoff (match over, +// results propagated) — without this each "live" phase lasted forever, so +// surfaces kept advertising finished games as live. 6h ≈ match length + ET/pens +// + feed lag. (All three formats end with the Final: the pick'em locks at its +// kickoff, the brackets score it.) +const SETTLE_MS = 6 * 3_600_000; +const TOURNAMENT_COMPLETE = new Date(KO_PICKEM_LAST.getTime() + SETTLE_MS); + +const COMPLETE_STATE: GameState = { + phase: "COMPLETE", + label: "Complete", + deadline: null, + creatable: false, + joinable: false, +}; + // Pure, time-derived phase for a game. No DB — every boundary is a fixed instant. export function resolveGamePhase(format: PoolFormat, now: Date = new Date()): GameState { const t = now.getTime(); @@ -124,6 +141,7 @@ export function resolveGamePhase(format: PoolFormat, now: Date = new Date()): Ga joinable: true, }; } + if (t >= TOURNAMENT_COMPLETE.getTime()) return COMPLETE_STATE; return { phase: "LOCKED_LIVE", label: "Locked · live", @@ -154,6 +172,7 @@ export function resolveGamePhase(format: PoolFormat, now: Date = new Date()): Ga joinable: true, }; } + if (t >= TOURNAMENT_COMPLETE.getTime()) return COMPLETE_STATE; return { phase: "LOCKED_LIVE", label: "Locked · live", @@ -175,6 +194,7 @@ export function resolveGamePhase(format: PoolFormat, now: Date = new Date()): Ga joinable: true, }; } + if (t >= TOURNAMENT_COMPLETE.getTime()) return COMPLETE_STATE; return { phase: "LOCKED_LIVE", label: "Locked · live", @@ -214,13 +234,25 @@ export function prizeTeaser(format: PoolFormat): string | null { const MONTH_DAY = new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", - timeZone: "UTC", + timeZone: DISPLAY_TZ, }); function dayLabel(d: Date): string { return MONTH_DAY.format(d); // e.g. "June 24" } +// A kickoff's calendar month/day in the app's display timezone (Eastern). The +// late fixtures kick off past midnight UTC, so a UTC framing put the last MD3 +// group fixture on "June 28" when everyone experiences it as the June 27 night +// game — every other match surface labels dates in DISPLAY_TZ. +function monthDayInDisplayTz(d: Date): { month: string; day: number } { + const parts = MONTH_DAY.formatToParts(d); + return { + month: parts.find((p) => p.type === "month")?.value ?? "", + day: Number(parts.find((p) => p.type === "day")?.value ?? 0), + }; +} + // The calendar span of the Match Day Pickem fixtures (first → last kickoff), for // copy that tells players which games they're predicting and when. UTC-framed to // match the rest of this module's date labels (gameStateLine). Same-month spans @@ -238,16 +270,18 @@ export function koPickemDateRange(): string { return spanLabel(first, last); } -// Collapse a first→last span into a compact label. Same-day → one date; same-month -// → "June 24–27"; cross-month → "July 3 – July 19". +// Collapse a first→last span into a compact label, framed in DISPLAY_TZ. Same-day +// → one date; same-month → "June 24–27"; cross-month → "June 28 – July 19". function spanLabel(first: Date, last: Date): string { - if (first.getUTCMonth() === last.getUTCMonth() && first.getUTCDate() === last.getUTCDate()) { - return dayLabel(first); + const a = monthDayInDisplayTz(first); + const b = monthDayInDisplayTz(last); + if (a.month === b.month && a.day === b.day) { + return `${a.month} ${a.day}`; } - if (first.getUTCMonth() === last.getUTCMonth()) { - return `${dayLabel(first)}–${last.getUTCDate()}`; + if (a.month === b.month) { + return `${a.month} ${a.day}–${b.day}`; } - return `${dayLabel(first)} – ${dayLabel(last)}`; + return `${a.month} ${a.day} – ${b.month} ${b.day}`; } // A short, friendly state line for a game card / badge, derived from the phase. diff --git a/lib/pool/hero-slides.test.ts b/lib/pool/hero-slides.test.ts index 4411c9b..27ecb3e 100644 --- a/lib/pool/hero-slides.test.ts +++ b/lib/pool/hero-slides.test.ts @@ -33,4 +33,13 @@ describe("availableHeroSlides", () => { const ko = slides.find((s) => s.format === "KNOCKOUT")!; expect(ko.stateLine.toLowerCase()).toContain("live"); }); + + it("goes empty once the tournament completes (no game advertised as live forever)", () => { + // Final kickoff + the settle window: every phase is COMPLETE, the carousel + // yields to the static hero instead of a permanent "Live now" slide. + const final = kickoffFor(104); + expect(final).not.toBeNull(); + const now = new Date(final!.getTime() + 7 * 3_600_000); + expect(availableHeroSlides(now)).toEqual([]); + }); }); diff --git a/lib/pool/knockout.test.ts b/lib/pool/knockout.test.ts index 78f1249..6736865 100644 --- a/lib/pool/knockout.test.ts +++ b/lib/pool/knockout.test.ts @@ -3,6 +3,7 @@ import { knockoutR32Seed, isKnockoutFieldSet, isKnockoutLocked, + isEarlyBuilderOpen, knockoutOnlyPicks, hasConcreteR32Slots, knockoutOpenState, @@ -126,3 +127,15 @@ describe("isKnockoutLocked", () => { expect(isKnockoutLocked(null, true)).toBe(true); }); }); + +describe("isEarlyBuilderOpen", () => { + it("opens at the early-build instant and closes at the R32 kickoff", () => { + expect(isEarlyBuilderOpen(new Date("2026-06-17T23:59:59Z"))).toBe(false); + expect(isEarlyBuilderOpen(new Date("2026-06-18T00:00:00Z"))).toBe(true); + expect(isEarlyBuilderOpen(new Date("2026-06-27T12:00:00Z"))).toBe(true); + // At/after the R32 kickoff the bracket is locked — "build early" must never + // be advertised again (it used to stay true for the rest of the tournament). + expect(isEarlyBuilderOpen(new Date("2026-06-28T19:00:00Z"))).toBe(false); + expect(isEarlyBuilderOpen(new Date("2026-07-02T12:00:00Z"))).toBe(false); + }); +}); diff --git a/lib/pool/knockout.ts b/lib/pool/knockout.ts index 203bd04..5411eda 100644 --- a/lib/pool/knockout.ts +++ b/lib/pool/knockout.ts @@ -8,6 +8,7 @@ import { GROUPS, R32 } from "@/lib/scoring/data"; import { resolveR32Slots, type ResolvedR32 } from "@/lib/scoring/resolve"; import { emptyPicks, type Picks, type Results } from "@/lib/scoring/types"; +import { kickoffFor } from "@/lib/scoring/schedule"; const THIRDS_NEEDED = 8; @@ -25,10 +26,15 @@ export const KNOCKOUT_PICKS_OPEN_UTC = "2026-06-28T02:00:00Z"; // (the official open) and from scoring, which still key off the real answer key. export const EARLY_BUILDER_OPEN_UTC = "2026-06-18T00:00:00Z"; -// True once early projected-fill should be available (independent of the official -// open). Time-gated so a pre-tournament visitor doesn't see a bracket of pure TBDs. +// True while early projected-fill should be available (independent of the official +// open). Time-gated on both ends: opens once standings give a meaningful projection +// (not a bracket of pure TBDs), and closes at the R32 kickoff — after the lock the +// builder is read-only, so "early build" must never be advertised again. export function isEarlyBuilderOpen(now: Date = new Date()): boolean { - return now.getTime() >= new Date(EARLY_BUILDER_OPEN_UTC).getTime(); + const t = now.getTime(); + if (t < new Date(EARLY_BUILDER_OPEN_UTC).getTime()) return false; + const lock = kickoffFor(73); + return !lock || t < lock.getTime(); } // The official Round-of-32 matchups (the 32 qualifiers) from the answer key. diff --git a/lib/pool/match-center.test.ts b/lib/pool/match-center.test.ts index 1f574cd..efe38fd 100644 --- a/lib/pool/match-center.test.ts +++ b/lib/pool/match-center.test.ts @@ -41,13 +41,25 @@ describe("buildMatchCenter", () => { expect(g.matches[0].away.code).toBeNull(); }); - it("derives status: Result status wins, else winner => FINAL, else SCHEDULED", () => { + it("derives status: a known winner => FINAL (even over a stale LIVE row), else Result status", () => { const [sec] = buildMatchCenter([ + // A recorded winner with a Result stuck at LIVE (missed poll / answer key + // entered first) is a decided match — it must not render live forever. m({ matchNo: 1, roundCode: "GROUP", resultStatus: "LIVE", winnerCode: "MEX" }), m({ matchNo: 2, roundCode: "GROUP", winnerCode: "CAN" }), m({ matchNo: 3, roundCode: "GROUP" }), + // Genuinely live: LIVE row, no winner anywhere yet. + m({ matchNo: 4, roundCode: "GROUP", resultStatus: "LIVE" }), + // Drawn group match: no winner, but the Result row says FINAL. + m({ matchNo: 5, roundCode: "GROUP", resultStatus: "FINAL" }), + ]); + expect(sec.matches.map((r) => r.status)).toEqual([ + "FINAL", + "FINAL", + "SCHEDULED", + "LIVE", + "FINAL", ]); - expect(sec.matches.map((r) => r.status)).toEqual(["LIVE", "FINAL", "SCHEDULED"]); }); it("serializes scheduledAt to ISO", () => { diff --git a/lib/pool/match-center.ts b/lib/pool/match-center.ts index cfb60f0..13de3b8 100644 --- a/lib/pool/match-center.ts +++ b/lib/pool/match-center.ts @@ -116,11 +116,14 @@ export interface MatchCenterSection { defaultOpen?: boolean; // when collapsible, whether it starts expanded } -// A match is FINAL when the Result says so, or (no Result yet) when the answer -// key already records a winner. LIVE only ever comes from a live Result feed. +// A match is FINAL when a winner is known (Result row or answer key) — a known +// winner overrides a Result stuck at LIVE, so a missed poll can't pin a decided +// match as "live" forever. Otherwise the Result feed's status is authoritative. +// (A drawn group match has no winnerCode but its Result says FINAL — covered by +// the resultStatus fallback.) function statusOf(m: MatchInput): MatchStatus { - if (m.resultStatus) return m.resultStatus; - return m.winnerCode ? "FINAL" : "SCHEDULED"; + if (m.winnerCode) return "FINAL"; + return m.resultStatus ?? "SCHEDULED"; } function buildRow( diff --git a/lib/pool/projected.test.ts b/lib/pool/projected.test.ts index 8b89d3a..b3542a0 100644 --- a/lib/pool/projected.test.ts +++ b/lib/pool/projected.test.ts @@ -66,3 +66,17 @@ describe("projectedLivePoints", () => { expect(out.get("e1")).toBe(10); }); }); + +describe("liveLeaders — decided matches", () => { + it("skips a LIVE row whose match the answer key already decided", () => { + // The scored totals already contain a decided match; projecting it again from + // a Result row the feed hasn't flipped to FINAL would pay it twice. + expect(liveLeaders([row({})], new Set([73]))).toEqual([]); + }); + + it("still projects live matches the answer key has not decided", () => { + expect(liveLeaders([row({})], new Set([74]))).toEqual([ + { matchNo: 73, leadingCode: "BRA" }, + ]); + }); +}); diff --git a/lib/pool/projected.ts b/lib/pool/projected.ts index 454053e..bfd2eff 100644 --- a/lib/pool/projected.ts +++ b/lib/pool/projected.ts @@ -24,10 +24,17 @@ const isKnockout = (n: number) => n >= 73 && n <= 104; // The currently-leading side of each live knockout match. Ties and unknown // teams project nothing — a draw resolves on penalties, which we can't guess. -export function liveLeaders(rows: LiveResultRow[]): LiveLeader[] { +// `decided` is the set of match numbers already recorded in the answer key: a +// decided match is in every entry's OFFICIAL total, so projecting it again from +// a Result row the feed hasn't flipped to FINAL yet would pay the same match +// twice on the live board during the whistle→feed-reconcile window. +export function liveLeaders( + rows: LiveResultRow[], + decided: ReadonlySet = new Set(), +): LiveLeader[] { const out: LiveLeader[] = []; for (const r of rows) { - if (r.status !== "LIVE" || !isKnockout(r.matchNo)) continue; + if (r.status !== "LIVE" || !isKnockout(r.matchNo) || decided.has(r.matchNo)) continue; if (r.homeScore == null || r.awayScore == null || r.homeScore === r.awayScore) continue; const leadingCode = r.homeScore > r.awayScore ? r.homeTeamCode : r.awayTeamCode; if (!leadingCode) continue; diff --git a/lib/pool/queries.ts b/lib/pool/queries.ts index 4e4dc32..d978b96 100644 --- a/lib/pool/queries.ts +++ b/lib/pool/queries.ts @@ -425,7 +425,7 @@ async function withProjectedPoints( format: PoolFormat, rows: LeaderboardRow[], ): Promise { - const [liveRows, groupPoints] = await Promise.all([ + const [liveRows, groupPoints, tournament] = await Promise.all([ prisma.result.findMany({ where: { status: "LIVE", match: { tournamentId } }, select: { @@ -438,10 +438,20 @@ async function withProjectedPoints( }, }), provisionalGroupPoints(poolId, tournamentId, scoringConfig), + prisma.tournament.findUnique({ + where: { id: tournamentId }, + select: { officialResults: true }, + }), ]); + // Matches the answer key already decided are in the official totals; exclude + // them from projection so a Result row lagging at LIVE can't pay them twice. + const decided = new Set( + Object.keys(asResults(tournament?.officialResults).knockout || {}).map(Number), + ); const leaders = liveLeaders( liveRows.map((r) => ({ ...r, matchNo: r.match.matchNo })), + decided, ); if (leaders.length === 0 && groupPoints.size === 0) return rows; @@ -957,10 +967,17 @@ export async function getLiveMatches( }); if (matches.length === 0) return []; - const resolved = resolveBracket(asResults(pool.tournament.officialResults)); + const answerKey = asResults(pool.tournament.officialResults); + // A match whose winner is already in the answer key is over regardless of what + // the feed row says — a poll that never flipped LIVE→FINAL must not pin a + // decided match on the "live now" card forever. + const live = matches.filter((m) => !answerKey.knockout?.[m.matchNo]); + if (live.length === 0) return []; + + const resolved = resolveBracket(answerKey); const yourPicks = await getEntryKnockoutPicks(poolId, userId); - const inputs = matches.map((m) => toMatchInput(m, resolved)); + const inputs = live.map((m) => toMatchInput(m, resolved)); // buildMatchCenter groups + orders by round; flatten back to a single list. return buildMatchCenter(inputs, yourPicks).flatMap((s) => s.matches); diff --git a/lib/pool/results.ts b/lib/pool/results.ts index 1aa4c47..baf5318 100644 --- a/lib/pool/results.ts +++ b/lib/pool/results.ts @@ -10,7 +10,12 @@ import { asResults, recomputePool, recomputeStandalone } from "@/lib/pool/scorin import { notifyPool } from "@/lib/realtime/notify"; import { sendPushToPool } from "@/lib/push/send"; import type { ApnsPayload } from "@/lib/push/apns"; -import { resolveBracket, validateKnockoutWinner } from "@/lib/pool/bracket"; +import { + resolveBracket, + validateKnockoutWinner, + findKnockoutSeatingConflict, + orientScoresToSlot, +} from "@/lib/pool/bracket"; import { findStandingsConflict } from "@/lib/pool/standings"; import { promoteCompletedGroups } from "@/lib/pool/group-promote"; import type { GroupResultRow } from "@/lib/pool/group-table"; @@ -55,6 +60,13 @@ export interface KnockoutInput { awayScore?: number | null; homePens?: number | null; // shootout score when the tie went to penalties awayPens?: number | null; + // How the API frames the fixture's home/away. When set, the scores/pens above + // are in THIS orientation and get re-oriented to the bracket slot before the + // Result mirror (the live display path already orients; without this the FINAL + // row flips whenever the provider's home differs from the bracket's home). + // Manual entry omits these — admin scores are already bracket-oriented. + apiHomeCode?: string | null; + apiAwayCode?: string | null; final?: boolean; // default true; false marks the match LIVE rather than FINAL source?: "API" | "MANUAL"; // default MANUAL } @@ -90,13 +102,14 @@ async function setKnockoutResultLocked( await writeAnswerKey(tournamentId, next, tx); const slot = resolveBracket(next)[matchNo]; + const oriented = orientScoresToSlot(slot, input); await mirrorResultRow(tx, tournamentId, matchNo, { homeTeamCode: slot?.home ?? null, awayTeamCode: slot?.away ?? null, - homeScore: input.homeScore ?? null, - awayScore: input.awayScore ?? null, - homePens: input.homePens ?? null, - awayPens: input.awayPens ?? null, + homeScore: oriented.homeScore, + awayScore: oriented.awayScore, + homePens: oriented.homePens, + awayPens: oriented.awayPens, winnerCode: winner, final: input.final ?? true, source: input.source ?? "MANUAL", @@ -363,6 +376,13 @@ export async function setGroupStandings( const conflict = findStandingsConflict(next); if (conflict) throw new Error(conflict); + // Reject a standings edit whose re-seated bracket would orphan a recorded + // knockout winner — fixture mapping, daily scoring and every seed-derived + // surface read the seating. Clear the affected result first if the standing + // really was wrong (see findKnockoutSeatingConflict). + const seating = findKnockoutSeatingConflict(next); + if (seating) throw new Error(seating); + await writeAnswerKey(tournamentId, next, tx); return next; }); diff --git a/lib/rate-limit-core.ts b/lib/rate-limit-core.ts index bb65a4d..4c05ccf 100644 --- a/lib/rate-limit-core.ts +++ b/lib/rate-limit-core.ts @@ -17,6 +17,18 @@ export interface Decision { next: Bucket; } +// The client IP for rate-limit keying, from an X-Forwarded-For header. Uses the +// RIGHTMOST hop — the one the platform edge (Railway) appends — never the +// leftmost, which is client-supplied and trivially spoofed (a spoofer minting a +// fresh leftmost value per request would get a fresh bucket per request, i.e. no +// limit at all). With a single hop the two coincide. Best-effort: a misconfigured +// proxy weakens this, but these are abuse throttles, not auth controls. +export function clientIpFromForwardedFor(header: string | null): string { + if (!header) return "anon"; + const hops = header.split(",").map((h) => h.trim()).filter(Boolean); + return hops.length ? hops[hops.length - 1] : "anon"; +} + // Given the stored bucket (or null) decide whether this request is allowed and // what the bucket should become. `now` is injected so the rule is deterministic. export function decideRateLimit( diff --git a/lib/rate-limit.test.ts b/lib/rate-limit.test.ts index 59433df..66185b5 100644 --- a/lib/rate-limit.test.ts +++ b/lib/rate-limit.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { decideRateLimit } from "./rate-limit-core"; +import { decideRateLimit, clientIpFromForwardedFor } from "./rate-limit-core"; // The window math is the pure, unit-tested core; the async rateLimit wrapper just // persists `next` and returns `result` under an advisory lock. @@ -38,3 +38,18 @@ describe("decideRateLimit", () => { expect(blocked.next).toEqual(next); // unchanged while blocked }); }); + +describe("clientIpFromForwardedFor", () => { + it("takes the RIGHTMOST hop (edge-appended), so a spoofed leftmost value is ignored", () => { + expect(clientIpFromForwardedFor("6.6.6.6, 203.0.113.9")).toBe("203.0.113.9"); + expect(clientIpFromForwardedFor("a, b, 198.51.100.4")).toBe("198.51.100.4"); + }); + + it("handles single hops, whitespace, and missing headers", () => { + expect(clientIpFromForwardedFor("203.0.113.9")).toBe("203.0.113.9"); + expect(clientIpFromForwardedFor(" 203.0.113.9 , ")).toBe("203.0.113.9"); + expect(clientIpFromForwardedFor("")).toBe("anon"); + expect(clientIpFromForwardedFor(null)).toBe("anon"); + expect(clientIpFromForwardedFor(",,")).toBe("anon"); + }); +}); diff --git a/lib/sports/poll.ts b/lib/sports/poll.ts index f951552..4453d0c 100644 --- a/lib/sports/poll.ts +++ b/lib/sports/poll.ts @@ -292,6 +292,10 @@ export async function pollScores(): Promise { awayScore: f.awayGoals, homePens: f.homePens, awayPens: f.awayPens, + // API orientation of the scores above — the mirror re-orients them to the + // bracket slot so the FINAL row can't flip against the live row. + apiHomeCode: homeCode ?? null, + apiAwayCode: awayCode ?? null, final: true, }); if (didApply) { diff --git a/scripts/cron.mjs b/scripts/cron.mjs index 73e37b4..d299086 100644 --- a/scripts/cron.mjs +++ b/scripts/cron.mjs @@ -18,6 +18,11 @@ if (!base || !secret) { } const TICK_MS = 60_000; // live-score cadence +// Jobs run sequentially, so one hung request (typically the web service draining +// during a redeploy) would otherwise stall every later job — including the +// live-score poll — for undici's ~5 min default. Bound each call instead; the +// next tick retries. +const HIT_TIMEOUT_MS = 30_000; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function hit(path) { @@ -25,6 +30,7 @@ async function hit(path) { const res = await fetch(`${base}${path}`, { method: "POST", headers: { "x-cron-secret": secret }, + signal: AbortSignal.timeout(HIT_TIMEOUT_MS), }); const body = await res.text(); console.log(`${path} ${res.status}: ${body}`);