feat(arena): make integrity flags server-authoritative (survive a page refresh) - #128
Merged
Merged
Conversation
…e refresh)
Anti-cheat flags were counted only in client React state and sent once at submit, so
a page refresh reset them to zero — a user could rack up flags, refresh, and submit
clean. Flags are now accumulated on the server as they happen.
- attempts table gains `flags` (total) + `flags_breakdown` (JSON) — migration 0008.
- New POST /api/attempt/flag {slug, event} atomically increments the count on the
user's ranked attempt (a single UPDATE with json_set; it no-ops when there's no
attempt row, so past-problem practice never accrues flags). GET returns the
accumulated count to seed the client on load, so a refresh shows the real total.
- useIntegrityMonitor reports each event to the endpoint and seeds from the server on
mount; /api/submit now scores from attempts.flags, not the client payload.
The client counter stays for instant UI feedback but is no longer trusted for scoring.
This hardens against the casual refresh-to-reset; a determined attacker who disables
the client JS still won't report events — client-side proctoring is inherently a
deterrent, which the arena rules already state.
Verified locally: events accumulate and survive a re-fetch, the endpoint no-ops for
non-attempt slugs, and rejects unauth (401) + invalid events (400). tsc / eslint /
vitest (47) / next build all green; the atomic json_set increment tested on SQLite.
DEPLOY: apply migration 0008 (adds attempts.flags / flags_breakdown) BEFORE deploying
the code — /api/submit and /api/attempt/flag select those columns.
barunaniket
requested review from
hagemaruwu,
shivanshpap and
vaibhavtulsian
as code owners
August 17, 2026 01:02
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Anti-cheat integrity flags (paste / copy / tab-switch / screenshot / …) were counted only in client React state and sent once at submit. A page refresh reset them to zero — so a user could rack up flags, refresh, and submit with
flags: 0. (This is the "why do the flags reset on refresh?" bug.)Fix — accumulate on the server as events happen
attemptsgainsflags(total) +flags_breakdown(JSON per category) — migration 0008.POST /api/attempt/flag {slug, event}atomically increments the count on the user's ranked attempt (oneUPDATE … json_set(…); it no-ops when there's no attempt row, so past-problem practice never accrues flags).GET ?slug=returns the accumulated count to seed the client on load, so a refresh shows the real total instead of 0.useIntegrityMonitornow reports each event to the endpoint and seeds from the server on mount (keeps an optimistic local bump for instant UI, reconciles from the response)./api/submitscores fromattempts.flags, not the client payload — the submit route already read the attempt row for the solve clock, and now reads the flags too. The clientflags/flagsBreakdownpayload is dropped.The client counter stays for instant feedback but is no longer trusted for scoring. This closes the casual refresh-to-reset hole. (A determined attacker who disables the client JS still won't emit events — client-side proctoring is inherently a deterrent, which the arena rules already state.)
Apply migration 0008 (
npx wrangler d1 migrations apply pesuecc-arena --remote) before deploying — the submit + flag routes selectattempts.flags/flags_breakdown.Verified
Local
next dev+ libSQL, end-to-end: 5 events → servertotal:5with the exact per-category breakdown; a fresh re-fetch returns the same total (refresh survival); no-op for non-attempt slugs; 401 unauth; 400 invalid event. The atomicjson_setincrement tested directly on SQLite.tsc/eslint/vitest(47) /next buildall green.