Skip to content

fix: mid-tournament audit fixes — seating integrity, live-state correctness, abuse/ops hardening - #136

Merged
dommango merged 9 commits into
mainfrom
fix/audit-2026-07-02
Jul 2, 2026
Merged

dommango merged 9 commits into
mainfrom
fix/audit-2026-07-02

Conversation

@dommango

@dommango dommango commented Jul 2, 2026 •

Copy link
Copy Markdown
Owner

Context

Full-codebase audit (2026-07-02, mid-R32), reconciled with main after #124/#125/#130 landed.

Correction from the original description: the audit's headline seating finding was real in the audited checkout, but main's #124 had already root-fixed it via FIFA's Annex C placement table before this PR — my "confirmed in prod" verification ran the audited branch's old resolver against prod data, so it overstated live impact. The thirdAdvance data reorder I applied to prod was a harmless no-op under Annex C (same set → same table row), and the recomputes were idempotent. This PR now keeps only the guards that still add value on top of #124.

Changes (post-reconciliation)

Seating & result integrity

  • setGroupStandings rejects any edit whose re-seated bracket contradicts a recorded knockout winner (findKnockoutSeatingConflict) — Annex C fixes third-place seating, but a group-placement or advancing-set correction after a result is recorded can still orphan it and break team-pair fixture mapping. (The order-preservation helper from the first draft was dropped as redundant under Annex C.)
  • API-final knockout scores/pens are re-oriented to the bracket slot before the Result mirror (orientScoresToSlot) — the live path orients, the FINAL path didn't, so the final scoreline/shootout could flip against winnerCode whenever the provider's home differed from the bracket's home.

Live-state correctness

  • Projected live points skip answer-key-decided matches (no double-pay during the LIVE→FINAL feed lag); a known winner renders FINAL over a Result row stuck at LIVE; the pool live card excludes decided matches. Complements fix: afternoon feedback — stale live scores, chat limit, game nav #126's 4h score-card heuristic — these paths key off the answer key.
  • resolveGamePhase gains COMPLETE (Final kickoff + 6h settle) for all three formats — every phase previously terminated in a forever-"live" state; the hero carousel now empties to the static hero after the tournament. isEarlyBuilderOpen closes at the R32 kickoff.
  • Date spans (md3DateRange/koPickemDateRange) framed in DISPLAY_TZ — UTC framing printed "June 24–28" for the June 24–27 group slate.

Abuse & ops hardening

  • Anonymous feedback rate limit keyed the spoofable leftmost XFF hop → shared rightmost-hop helper (auth route refactored onto it); Giphy proxy rate-limited per user.
  • claimEntriesForUser takes the per-pool advisory lock (closes the FREE-cap count→create race on the primary enrollment path).
  • Odds "extras" poll gains a DB-backed 20h throttle — the cron's in-memory daily bucket resets on every restart/redeploy, re-spending 4+ metered credits (500/mo quota) per restart.
  • cron.mjs fetches time out at 30s (a hung draining web service stalled the sequential job loop ~5 min). CRON_SECRET min length 16 (prod is 48; CI + CLAUDE.md dev values updated — .env.example still says change-me, worth a manual bump).

Challenge & UI

  • Knockout challenge profile requires board membership (verified-email anti-Sybil gate) — previously exposed gated entries' picks with a fabricated rank; pick-share denominators now match the board population.
  • MD3 form/breakdown pinned to DISPLAY_TZ (hydration mismatch / UTC day headers); R32 countdown no longer flashes when past. (Chat scroll: superseded by fix(ui): chat behavior + a11y (PR 5/6 of UI review) #130, took main's.)

Test plan

  • Post-merge with origin/main: tsc --noEmit, eslint, vitest run — 92 files, 713 tests green; next build compiles all routes
  • Intentional test updates: match-center "Result status wins" → known-winner-wins (that priority was the stale-LIVE bug); hero-slides/games tests follow feat: launch the knockout Match Day Pick'em (free, round-weighted ladder) #125's knockout-pick'em timeline + new COMPLETE assertions
  • After deploy: watch poll-odds-extras logs for skipped: true on cron restarts

Deliberately not in this PR (audit findings needing product/schema decisions)

Stripe out-of-order event guard; analytics retention + SQL aggregation; Prisma pool sizing; seed-on-deploy reverting admin edits; MD3 prize eligibility contradiction + goal-delta tiebreak fairness (note: #125 made the pick'em prize-free, which may moot these); push-token ownership check; chat attachment origin allowlist; APNs retry.

dommango-sys added 9 commits June 30, 2026 17:46
…tracking

Adds an admin-only analytics dashboard (/admin/analytics) tracking user
engagement across all pools, plus the event-logging layer that feeds it.

- AnalyticsEvent model + enum (audit-style plain-id FKs, no relations/cascade,
  indexed by day/type/user) — additive and non-breaking, mirroring the
  PrizeAward/Feedback precedent. Migration + regenerated client included.
- logEvent: best-effort write that never throws or breaks the triggering action
  (mirrors notifyPool). Instrumented at sign-in/up, pool create, pool join
  (code + invite, new members only), bracket submit, chat message, reaction.
- Pure, unit-tested rollups (UTC day bucketing, DAU/WAU/MAU, type breakdown,
  top pools, stickiness) split from the DB query layer.
- Dashboard gated by getTournamentAdmin (fail-closed), custom Tailwind bar
  charts (no new deps), linked from the admin page.

MAU/stickiness fetch window covers the full rolling 30d boundary so late-day
actives aren't clipped.
… the bracket slot

The answer key stores third-place advancers as an ordered list whose order
drives R32 seating (resolveR32Slots backtracks in list order). A standings
re-save with the same eight teams in a different order silently re-seated the
live bracket — orphaning recorded winners, breaking pair-map fixture mapping,
and corrupting every seed-derived surface (this happened in prod mid-R32).

- setGroupStandings now preserves the stored order when the submitted set is
  unchanged (mergeThirdAdvance), and rejects any seating that contradicts a
  recorded knockout winner (findKnockoutSeatingConflict).
- setKnockoutResultLocked now orients API-reported scores/pens to the bracket
  slot (orientScoresToSlot) before mirroring, matching the live-row path — the
  FINAL row could previously flip against winnerCode whenever the provider's
  home differed from the bracket's home (exactly the pens ties in play now).
…oards

- liveLeaders now skips matches the answer key already decided, so the
  projected-points layer can't pay a match twice during the whistle-to-feed
  reconcile window (the official total already contains it).
- getLiveMatches filters out answer-key-decided matches, and statusOf treats a
  known winner as FINAL over a Result row stuck at LIVE — a missed poll could
  previously pin a finished match on the live card forever.
- resolveGamePhase gains the COMPLETE transition (last kickoff + 6h settle):
  MD3 was reported LOCKED_LIVE forever, keeping a 'Live now' slide on the home
  carousel five days after the game ended; knockout/full-bracket got the same
  treatment for after the final.
- isEarlyBuilderOpen now closes at the R32 kickoff instead of staying true for
  the rest of the tournament.
- md3DateRange (and gameStateLine's day labels) are framed in DISPLAY_TZ: the
  UTC framing printed 'June 24–28' beside catalog copy saying '(June 24–27)'.
…a, cron

- Feedback keyed its anonymous rate limit on the leftmost X-Forwarded-For hop
  (client-spoofable → unlimited 3MB screenshot submissions). Shared
  clientIpFromForwardedFor now takes the edge-appended rightmost hop; the auth
  route reuses it.
- Giphy proxy gains a per-user rate limit (shared API key quota).
- claimEntriesForUser takes the same per-pool advisory lock as
  joinPool/acceptInvite, closing the count→create race past the FREE member cap
  on the primary enrollment path.
- pollOddsExtras gains a DB-backed 20h throttle (extrasPollDue): the cron's
  in-memory daily bucket resets on every restart/redeploy, re-spending 4+
  metered credits of a 500/mo quota per restart.
- cron.mjs fetches time out at 30s so a draining web service can't stall the
  sequential job loop (and live-score polling) for ~5 minutes.
- CRON_SECRET requires 16+ chars (guards result-mutating routes; prod is 48);
  CI/dev example values updated to match.
- getKnockoutChallengeProfile now requires board membership (which carries the
  verified-email anti-Sybil gate): it previously rendered gated entries' full
  picks with a fabricated rank, and its pick-share denominators now match the
  public board population.
- MD3 form/breakdown pin kickoff day/time labels to DISPLAY_TZ (the form was a
  hydration mismatch, the breakdown rendered UTC times and split late fixtures
  across wrong day headers).
- Chat pins its own scroll box instead of scrollIntoView, which scrolled every
  ancestor and yanked live match pages down to the embedded chat on mount.
- R32Countdown seeds its passed state from the clock so a past kickoff no
  longer flashes a stale countdown banner on first paint.
… knockout pick'em

Main moved substantially since this branch was cut:
- #124 seats R32 third-place slots via FIFA's official Annex C table, fixing
  the seating root cause properly (order-independent for a complete set). The
  branch's mergeThirdAdvance order-preservation is therefore redundant and is
  dropped; findKnockoutSeatingConflict is kept and reframed — it still guards
  the remaining hazard (a standings edit that re-seats a match whose result is
  already recorded), with its test rewritten to a group-placement scenario.
- #125 repurposes MATCH_DAY_3_PICKEM into the knockout-long daily pick'em, so
  the COMPLETE phase now keys off the Final kickoff + settle for all three
  formats; hero-slide and featuredGame expectations follow main's new
  semantics, plus a new post-final empty-carousel test. The Eastern-timezone
  date framing is re-applied to main's spanLabel/koPickemDateRange.
- #130's chat scroll fix supersedes this branch's — took main's Chat.tsx.
- The FINAL score/pens orientation fix, stale-LIVE guards, projection
  double-count fix, rate-limit/claim/odds/cron hardening, and MD3 timezone
  pins all still apply and carry over unchanged.
@dommango
dommango marked this pull request as ready for review July 2, 2026 22:49
@dommango
dommango merged commit 3ee3f70 into main Jul 2, 2026
1 check passed
@dommango
dommango deleted the fix/audit-2026-07-02 branch July 2, 2026 22:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant