diff --git a/.env.example b/.env.example index bd25e7b..1d5fa79 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,8 @@ NEXT_PUBLIC_COMMITTEE_LEGAL_NAME_PLACEHOLDER=Your Committee Legal Name Here COMMITTEE_LEGAL_NAME_PLACEHOLDER=Your Committee Legal Name Here NEXT_PUBLIC_DISCLAIMER_TEXT=Contributions are solicited by an authorized political committee. Federal law requires political committees to report contributor information and to retain records in accordance with FEC rules. This statement is general information only and not legal, FEC, or tax advice; consult qualified counsel for your committee obligations. DISCLAIMER_TEXT=Contributions are solicited by an authorized political committee. Federal law requires political committees to report contributor information and to retain records in accordance with FEC rules. This statement is general information only and not legal, FEC, or tax advice; consult qualified counsel for your committee obligations. +# Privacy / data-deletion requests (shown on /privacy-policy and /datadeletion) +NEXT_PUBLIC_LEGAL_CONTACT_EMAIL=privacy@example.com # --- Stripe (test keys for development; use restricted keys in shared environments) --- STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx @@ -38,3 +40,8 @@ PUBLIC_POLL_VOTE_CREDITS=5 # Optional: Stripe CLI for local webhook forwarding: # stripe listen --forward-to 127.0.0.1:8008/api/webhooks/stripe + +# --- Threads OAuth --- +THREADS_CLIENT_ID=your_threads_client_id +THREADS_CLIENT_SECRET=your_threads_client_secret +THREADS_REDIRECT_URI=https://bwt.democracyrisingbwt.us/threads-callback diff --git a/REDME-FIXSES-TRIAGE.txt b/REDME-FIXSES-TRIAGE.txt new file mode 100644 index 0000000..739fac5 --- /dev/null +++ b/REDME-FIXSES-TRIAGE.txt @@ -0,0 +1,322 @@ +================================================================================ +REDMEFIXSES CONSERVATIVE TRIAGE +================================================================================ +Source reviewed: redmeFIXSES.md +Date: 2026-05-19 +Mode: evaluation only; no application code changed. + +Principle used: + If the site works and a change is not clearly safer, smaller, and worth the + risk, do not do it. Prefer no-op/defer over broad refactors. + +Decision key: + DO = worth doing later as a small, contained change + DEFER = valid concern, but product/legal/infra/risky scope + DO NOT = stale, already fixed, intentional, or too risky for current goal + +================================================================================ +SUMMARY +================================================================================ + +Items evaluated: 35 + +DO: + 1.3 Registration rate limiting + 5.2 Clarify dev vs dev:next because Socket.IO games need server.ts + 5.3 Wire ESLint separately from TypeScript checking + 6.1 Active nav state styling + +DEFER: + 1.4 Password reset flow + 1.5 Admin bootstrap/audit log + 2.4 Ledger FK links to GameSession/GameRoom + 2.5 Pong multi-instance/recovery architecture + 3.4 Homepage meter vs boost meter product wording + 3.6 Static FAQ vs dynamic FAQ source of truth + 4.2 Casino/compliance review + 4.3 Prediction oracle/fairness model + 4.4 Initiative moderation queue + 4.5 Email verification enforcement + 6.2 Skeleton/loading polish + 7.1 Unit test suite + 7.2 Structured logging/APM + 7.3 Webhook alerting + +DO NOT: + 1.1 Socket userId trust issue + 1.2 Socket CORS wildcard + 1.6 Anonymous FAQ pending leak + 2.1 Public stats vs treasury succeeded filter + 2.2 Prediction bet transaction + 2.3 Boost milestone race + 3.1 FAQ API has no UI + 3.2 Billboard API has no UI + 3.3 Spotlight API has no UI + 3.5 Cards API has no UI + 4.1 Committee/legal placeholders + 5.1 NEXT_PUBLIC_SITE_URL missing from .env.example + 5.4 HSTS preload + 5.5 Sitemap incomplete + 6.3 DonationCheckout .env wording + +================================================================================ +ITEM-BY-ITEM DETERMINATION +================================================================================ + +1.1 Socket.IO trusts browser userId +Decision: DO NOT +Current truth: + This appears fixed. server.ts resolves the user from the auth token via + resolveSocketUserId(), and CoinFlipRoom/PongGame emit only roomId on join. +Why: + The old redme claim is stale. Touching working socket auth now risks breaking + coin flip and pong. + +1.2 Socket.IO CORS origin "*" +Decision: DO NOT +Current truth: + server.ts uses allowedOrigin from NEXT_PUBLIC_SITE_URL/AUTH_URL fallback, not + wildcard. +Why: + Already fixed. No change. + +1.3 Registration endpoint unthrottled +Decision: DO +Current truth: + Still valid. /api/register is a plain POST handler. +Worth it: + Yes, but only as a small contained rate limiter. It reduces account spam and + credential stuffing risk without changing normal user flows. +Safe shape: + Add an in-memory IP/email bucket first, or Cloudflare rate rule. Do not add + CAPTCHA/email verification in the same change. + +1.4 No automated password reset +Decision: DEFER +Current truth: + Static forgot-password page only. +Why defer: + A real reset flow touches email delivery, tokens, auth UX, and support + process. It is worth doing eventually, but not a safe quick patch while the + site is working. + +1.5 Admin bootstrap/audit log missing +Decision: DEFER +Current truth: + Admin role exists; audit logging for admin actions is not a complete system. +Why defer: + Cross-cutting ops/security feature. Needs design. Do not bolt it on. + +1.6 Public FAQ API leaks pending submissions +Decision: DO NOT +Current truth: + Anonymous users get pending: []; signed-in users can see pending submissions + for the FAQ board workflow. +Why: + The original public leak claim is stale. Making pending admin-only may break + the current voting/board behavior unless product direction changes. + +2.1 public stats vs treasury status filter mismatch +Decision: DO NOT +Current truth: + Both current stats and treasury queries filter status: \"succeeded\". +Why: + Already fixed. + +2.2 Prediction bets not transactional +Decision: DO NOT +Current truth: + Prediction bet debit, ledger entry, bet create, and market total update are + inside prisma.$transaction(). +Why: + Already fixed. + +2.3 Movement boost milestone race +Decision: DO NOT +Current truth: + Current boost route uses Serializable isolation and handles P2034 conflicts. +Why: + Good enough for current scale. Idempotency rows would add schema complexity. + +2.4 Ledger not linked to GameSession/GameRoom +Decision: DEFER +Current truth: + Valid. Game ledger lines are memo-linked, not FK-linked. +Why defer: + Requires Prisma migration and touching every game settlement path. Good + long-term reconciliation work, but not worth risking a working wallet today. + +2.5 Pong in-memory server state +Decision: DEFER +Current truth: + Valid. Pong state lives in server.ts memory and assumes one Node process. +Why defer: + Redis/recovery would be a significant architecture change. Current service is + single-instance, so do not change now. + +3.1 FAQ API has no UI +Decision: DO NOT +Current truth: + Stale. /faq-board fetches /api/faq. + +3.2 Billboard API has no UI +Decision: DO NOT +Current truth: + Stale. /billboard fetches /api/billboard. + +3.3 Spotlight API has no UI +Decision: DO NOT +Current truth: + Stale. /spotlight fetches /api/spotlight. + +3.4 Movement boost meter vs homepage meter +Decision: DEFER +Current truth: + /boost is wired. Homepage ProgressSection is a USD fundraising meter, not the + BWT boost meter. +Why defer: + Not broken. This is copy/product clarity, not a code defect. + +3.5 Cards API has no UI +Decision: DO NOT +Current truth: + Stale. /cards fetches /api/cards. + +3.6 Static FAQ and dynamic FAQ both exist +Decision: DEFER +Current truth: + Valid. Homepage marketing FAQ and /faq-board dynamic FAQ coexist. +Why defer: + Product/source-of-truth decision. Both can coexist without breaking the site. + +4.1 Committee/legal placeholders +Decision: DO NOT +Current truth: + Valid placeholders remain. +Why: + This is deployment/legal configuration, not code. Do not invent legal copy. + +4.2 Casino / prediction / games compliance +Decision: DEFER +Current truth: + Valid concern. +Why defer: + Legal/jurisdiction scope. No autonomous code change is safe. + +4.3 Prediction creator resolves outcome +Decision: DEFER +Current truth: + Valid. Creator resolves market after close. +Why defer: + Oracle design is product/fairness architecture. Do not change while working. + +4.4 User-generated initiatives moderation +Decision: DEFER +Current truth: + Valid. Initiatives can be submitted without a moderator queue. +Why defer: + New moderation workflow and policy decisions needed. + +4.5 Email verification unused +Decision: DEFER +Current truth: + Valid. emailVerified exists in schema but credentials login does not enforce it. +Why defer: + Enforcing verification changes registration/login behavior. Risky without an + email delivery plan. + +5.1 NEXT_PUBLIC_SITE_URL missing in .env.example +Decision: DO NOT +Current truth: + Fixed. .env.example includes NEXT_PUBLIC_SITE_URL. + +5.2 dev vs dev:next confusion +Decision: DO +Current truth: + Valid. npm run dev uses server.ts with sockets. npm run dev:next runs Next + only and would break coin flip/pong sockets. +Worth it: + Yes. A README/package script description change is low-risk and prevents dev + confusion. + +5.3 lint only runs tsc +Decision: DO +Current truth: + Valid. npm run lint is tsc --noEmit despite ESLint being installed. +Worth it: + Yes, if done as a separate script (for example typecheck + lint:eslint) so the + existing working tsc path is not broken. + +5.4 HSTS preload globally +Decision: DO NOT +Current truth: + Valid, but intentional production security header. +Why: + Removing or env-gating it is unnecessary unless it causes a proven deployment + issue. + +5.5 Sitemap incomplete +Decision: DO NOT +Current truth: + Mostly stale. Sitemap includes many major pages now. Casino auth pages are + intentionally less SEO-oriented. +Why: + Not worth touching right now. + +6.1 Active nav state +Decision: DO +Current truth: + Valid. SiteNav/MobileNav do not appear to show current route state. +Worth it: + Yes, small isolated UX improvement if done carefully. + +6.2 Loading shimmer coverage +Decision: DEFER +Current truth: + Partial. Some loading states exist; skeleton polish is inconsistent. +Why defer: + Cosmetic and broad. Not worth risking layout churn. + +6.3 DonationCheckout .env copy +Decision: DO NOT +Current truth: + Stale/low-value. DonationCheckout is not the active checkout path and copy is + already more production-friendly than the old note suggests. + +7.1 No unit test suite +Decision: DEFER +Current truth: + Valid. +Why defer: + Valuable, but not a quick no-risk change. Add tests around future bug fixes + instead of inventing a full harness now. + +7.2 No structured logging / APM +Decision: DEFER +Current truth: + Valid. +Why defer: + Ops choice. Needs tool/vendor decision. + +7.3 Webhook 500 / alerting +Decision: DEFER +Current truth: + Valid. Stripe retries on 500; no alerting layer found. +Why defer: + Alerting belongs in ops/monitoring setup. Do not fake it in app code. + +================================================================================ +FINAL DETERMINATION +================================================================================ + +Do not perform a broad cleanup/refactor now. The app works and many redme items +are stale. The only changes worth doing next are small and low-risk: + + 1. Add conservative registration rate limiting. + 2. Clarify dev scripts so socket games are not started with dev:next. + 3. Add ESLint as a separate script without replacing the current typecheck. + 4. Add active nav styling. + +Everything else should be deferred or left alone unless it becomes a proven bug, +legal requirement, or planned product change. +================================================================================ diff --git a/SITE-AUDIT.txt b/SITE-AUDIT.txt new file mode 100644 index 0000000..6dcc4c0 --- /dev/null +++ b/SITE-AUDIT.txt @@ -0,0 +1,740 @@ +================================================================================ +DEMOCRACY RISING / FUNDRAISING PLATFORM — DEEP SITE AUDIT +================================================================================ +Generated: 2026-05-19 (UTC) +Scope: /root/fundraising-platform (source, config, Prisma, scripts, runtime) +Machine: Linux (Proxmox LXC), 4 vCPU, ~7.8 GiB RAM, 64 GiB disk (~7% used) +Service: fundraising-platform.service → npm run start → tsx server.ts :8008 +Public: https://bwt.democracyrisingbwt.us (Cloudflare proxy, not direct to origin) +Typecheck: npm run lint (tsc --noEmit) — PASS at audit time +Build: npm run build — PASS (includes /threads-callback, /api/threads-exchange) + +Severity legend: + [CRITICAL] Exploit, money loss, secret exposure, or production outage risk + [HIGH] Broken feature, data corruption risk, or major UX failure + [MEDIUM] Correctness edge case, maintainability, or meaningful perf waste + [LOW] Polish, SEO, docs, minor duplication + +================================================================================ +0. EXECUTIVE SUMMARY +================================================================================ + +This is a Next.js 16 App Router political fundraising app with: + - Stripe donations + webhooks + - Supporter credits (BWT) wallet + ledger + - 10 casino-style games (7 REST solo, 2 Socket.IO PvP, 1 prediction market) + - Community features (billboard, spotlight, cards, FAQ board, boost meter) + +Overall code quality: TypeScript compiles clean; core wallet debits use atomic +updateMany; many game routes use optimistic locking (updateMany + outcome:"active"). +The largest risks are DEPLOYMENT/DNS (Cloudflare serving stale static pages), +SOCKET/PONG in-memory state (single-process assumption), BLACKJACK transaction +gaps, and CLIENT-SIDE performance (always-on canvas animations + polling). + +Approximate codebase: ~15,246 lines in src/**/*.ts(x), 561M node_modules, 1.1G .next + +Top 10 actions (ordered): + 1. Fix Cloudflare routing for bwt.democracyrisingbwt.us → origin :8008 (not static HTML) + 2. Align NEXT_PUBLIC_SITE_URL / AUTH_URL with real public hostname + 3. Rotate any secrets ever committed or logged (.env, old Threads static page) + 4. Wrap blackjack natural/push/resolve in single DB transactions + 5. Add gameSessionId FK on ledger entries for reconciliation + 6. Document/require npm run start (not dev:next) for Socket.IO games + 7. Reduce homepage/global canvas + polling load on this 8GB box + 8. Delete or wire DonationCheckout.tsx (~400 lines dead) + 9. Harden coin-flip/pong payout failure paths (room stuck ACTIVE) + 10. Add rate limits on /api/register and game POST endpoints + +================================================================================ +1. DEPLOYMENT & INFRASTRUCTURE (CRITICAL FOR LIVE SITE) +================================================================================ + +1.1 [CRITICAL] Cloudflare intercepts traffic before Next.js +------------------------------------------------------------------------------ +Symptom verified on live host: + - https://bwt.democracyrisingbwt.us/datadeletion → was 404 until service restart; + may still vary by cache + - https://bwt.democracyrisingbwt.us/threads-callback → serves OLD static HTML + ("Threads Bot Setup", client-side secret exchange) NOT the Next.js route + - curl shows: server: cloudflare, cf-ray present + - DNS: bwt.democracyrisingbwt.us → 104.21.29.229 / 172.67.171.225 (Cloudflare) + - Origin direct :8008 on public IP → connection refused (port not exposed) + +Local origin (correct): + - http://127.0.0.1:8008/threads-callback → Next.js page "Threads callback" + - http://127.0.0.1:8008/datadeletion → legal page + +Old static /threads-callback HTML (served via Cloudflare) contains: + - client_id in page JS + - client_secret in browser fetch to graph.threads.net (SECURITY DISASTER) + - Inline OAuth flow bypassing /api/threads-exchange + +Action: In Cloudflare dashboard, remove Workers/Pages/static asset rules for + /threads-callback, /datadeletion, /api/*; proxy pass-through to origin tunnel + or open 8008 via cloudflared. Purge cache after fix. + +Files (new, correct): src/app/threads-callback/*, src/app/api/threads-exchange/route.ts + +1.2 [HIGH] Environment URL mismatch +------------------------------------------------------------------------------ +.env currently has: + NEXT_PUBLIC_SITE_URL=https://democratic.thetempleofdoom.com +Public users use: + https://bwt.democracyrisingbwt.us + +Impact: + - Canonical URLs, Open Graph, JSON-LD, sitemap base wrong for BWT subdomain + - Socket.IO CORS allowedOrigin may reject browser if users hit bwt.* but env says democratic.* + - Stripe/NextAuth redirect_uri mismatches if callbacks use SITE_URL + +Action: Set NEXT_PUBLIC_SITE_URL, NEXTAUTH_URL, AUTH_URL to the hostname users + actually use (or support multiple origins in server.ts CORS). + +Reference: server.ts lines 18-22 (allowedOrigin), src/lib/public-env.ts + +1.3 [MEDIUM] Single-process architecture +------------------------------------------------------------------------------ +server.ts holds: + - pongRooms Map + setInterval game loops (20 Hz per active room) + - coin-flip resolution inline on join + - No Redis, no horizontal scaling + +If you scale to 2+ Node processes: PvP games break, rooms split-brain. + +Action: Document "single instance only" or migrate room state to Redis. + +1.4 [LOW] systemd unit note +------------------------------------------------------------------------------ +/etc/systemd/system/fundraising-platform.service uses npm run start (good). +Unknown key StartLimitIntervalSec ignored on older systemd — harmless. + +NODE_OPTIONS=--max-old-space-size=8192 on an 7.8GB machine leaves little headroom +for PostgreSQL + OS. Consider 4096 unless you see OOM during build. + +================================================================================ +2. SECURITY +================================================================================ + +2.1 [CRITICAL] Secrets exposure vectors +------------------------------------------------------------------------------ +- Old Cloudflare-hosted threads-callback HTML embeds THREADS client_secret in JS +- .env on server contains live Stripe sk_live_* (never commit; restrict file perms) +- Threads credentials in .env (THREADS_CLIENT_ID, THREADS_CLIENT_SECRET) — OK if + server-only; ensure .gitignore blocks .env (it does) + +Action: Rotate Threads app secret + Stripe keys if static page was ever public. + chmod 600 /root/fundraising-platform/.env + +2.2 [HIGH] No global API rate limiting +------------------------------------------------------------------------------ +Unauthenticated or session-authenticated endpoints can be spammed: + - POST /api/register + - POST /api/games/* (each debits DB) + - POST /api/faq, /api/billboard, etc. + +Action: nginx/cloudflare rate limits, or middleware with IP buckets. + +2.3 [MEDIUM] Auth middleware disabled +------------------------------------------------------------------------------ +src/middleware.ts → matcher: [] — no edge protection. +All auth is per-route auth() calls. Easy to forget on new routes. + +Wallet page protected in page.tsx; most /api/* check session — good pattern but +not enforced centrally. + +2.4 [MEDIUM] FAQ pending submissions +------------------------------------------------------------------------------ +src/app/api/faq/route.ts GET: + - pending[] only returned when signedIn (line 29-44) — IMPROVED vs old audit + - Still: any logged-in user sees ALL pending submissions (moderation leak) + +Action: pending visible to admin role only. + +2.5 [MEDIUM] Socket.IO auth — PARTIALLY FIXED +------------------------------------------------------------------------------ +server.ts resolveSocketUserId() uses getToken from cookie — GOOD. +CoinFlipRoom no longer sends client userId in join_room — GOOD. + +Remaining: + - CORS origin single string; subdomains need array + - No explicit origin check on Engine.IO handshake beyond cors config + +2.6 [LOW] Prediction market trust model +------------------------------------------------------------------------------ +Creator resolves outcome (PATCH). No oracle — fine for demo, risky for prod. + +2.7 [LOW] No email verification enforced +------------------------------------------------------------------------------ +User.emailVerified in schema unused in credentials flow. + +================================================================================ +3. DEAD CODE, DUPLICATION, ORPHANS +================================================================================ + +3.1 [HIGH] Unused component — DonationCheckout.tsx +------------------------------------------------------------------------------ +Path: src/components/DonationCheckout.tsx (~400 lines) +Imports: NONE in app (only EmbeddedDonationCheckout used) + - src/app/donate/page.tsx → EmbeddedDonationCheckout + - src/components/DonateSection.tsx → EmbeddedDonationCheckout + +Action: Delete file or merge unique features into EmbeddedDonationCheckout. + +3.2 [MEDIUM] Unused exports / dead helpers +------------------------------------------------------------------------------ +- src/lib/auth-links.ts: LOGIN_RETURN_WALLET, LOGIN_RETURN_RAISED — no imports +- src/app/api/games/dice/route.ts: imports hashServerSeed, never used +- src/app/api/games/tower/route.ts: SAFE_PER_FLOOR constant was reported unused + (verify before delete) +- src/lib/exchange.ts: @deprecated BLW_* aliases — internal only + +3.3 [MEDIUM] Duplicated game metadata +------------------------------------------------------------------------------ +- src/app/casino/page.tsx — GAMES array (lobby cards) +- src/app/casino/[game]/page.tsx — GAME_META record +- src/components/casino/GameHistory.tsx — GAME_ICONS + +Any new game requires 3 edits. Extract shared catalog module. + +3.4 [MEDIUM] Duplicated settlement logic +------------------------------------------------------------------------------ +mines/route.ts and tower/route.ts — nearly identical cashout/win transactions. +dice, slots, roulette — copy-paste instant-game transaction blocks. +crash/mines/tower use creditWalletCredits + manual ledger; dice uses same; +blackjack uses creditForWin/refundBet from game-ledger — INCONSISTENT. + +Action: Single settleGameWin() / settleGameLoss() in game-ledger.ts. + +3.5 [LOW] redmeFIXSES.md partially stale +------------------------------------------------------------------------------ +Section 3 claims API-only features have no UI — FALSE for: + billboard, spotlight, cards, faq-board, boost (all have pages + fetch) +Section 1.1 socket userId spoof — FIXED in current server.ts +Section 1.2 CORS * — FIXED (restricted origin) +Section 2.1 public stats — BOTH use status:"succeeded" now + +Keep redmeFIXSES.md or merge into this audit after triage. + +================================================================================ +4. GAME-BY-GAME AUDIT (LOGIC, BUGS, EXPLOITS) +================================================================================ + +Shared infrastructure: + - lib/provably-fair.ts — HMAC-SHA256, crash 3% edge, deriveMinePositions + - lib/game-ledger.ts — debitForBet, creditForWin, refundBet + - lib/wallet-safety.ts — atomic debit via updateMany gte check + - prisma GameSession — @@index([userId]), @@index([userId, gameType]) + +------------------------------------------------------------------------------ +4.1 CRASH (REST: POST + PATCH) — src/app/api/games/crash/route.ts +------------------------------------------------------------------------------ +Client: src/components/casino/CrashGame.tsx + +Behavior: + - POST debits wager, stores crashAt in resultData (hidden until loss/cashout) + - PATCH tick: client sends currentMultiplier; server settles loss if >= crashAt + - PATCH cashout: pays floor(wager * cashoutAt) + +Issues: + [MEDIUM] cashOut() sends `cashoutAt: multiplier` (React state) while tick probe + uses multiplierRef.current — possible 1-frame desync under load. + Fix: always send multiplierRef.current on cashout. + + [MEDIUM] Tick interval every 220ms while running = ~4.5 req/s per active player. + Many concurrent crash players = DB load. Consider server-side timer or WS. + + [LOW] Animation runs to 100x client-side even if crashAt is 2x — cosmetic only + until tick returns loss. + + [LOW] No max wager cap server-side beyond integer >= 1. + +Provably fair: serverSeed returned on end — good for verification. + +------------------------------------------------------------------------------ +4.2 DICE (REST: POST instant) — src/app/api/games/dice/route.ts +------------------------------------------------------------------------------ +Client: src/components/casino/DiceGame.tsx + +Issues: + [LOW] Unused import hashServerSeed (no commit hash returned to client) + [LOW] No serverSeedHash at bet time — weaker commit-reveal than mines/crash + +Logic: roll 0-99, threshold 2-98, multiplier = 0.98/winProb — OK. + +Transaction: single prisma.$transaction — GOOD. + +------------------------------------------------------------------------------ +4.3 MINES (REST: POST + PATCH) — src/app/api/games/mines/route.ts +------------------------------------------------------------------------------ +Client: src/components/casino/MinesGame.tsx + +Issues: + [MEDIUM] Loss path uses prisma.gameSession.update (not updateMany) — double-tile + race could overwrite outcome if two PATCHes in flight. + + [MEDIUM] calcMultiplier() uses inverted product formula — verify house edge + matches advertised 1%; not unit tested. + + [LOW] Cashout with 0 reveals refunds full wager (push) — intentional? + + [LOW] minePositions sent to client on loss/cashout — correct for transparency; + during active play positions hidden — GOOD. + +------------------------------------------------------------------------------ +4.4 TOWER (REST: POST + PATCH) — src/app/api/games/tower/route.ts +------------------------------------------------------------------------------ +Client: src/components/casino/TowerGame.tsx + +Same settlement duplication as mines. Pre-generated bomb positions per floor. + +Issues: + [MEDIUM] Same updateMany vs plain update race as mines on some paths + [LOW] FLOOR_MULTIPLIERS hardcoded — not derived from provably-fair module + +------------------------------------------------------------------------------ +4.5 SLOTS / ROULETTE (REST: POST instant) +------------------------------------------------------------------------------ +Files: src/app/api/games/slots/route.ts, roulette/route.ts +Clients: SlotsGame.tsx, RouletteGame.tsx + +Pattern: transaction wraps debit + credit + GameSession — GOOD. + +Issues: + [LOW] No explicit max wager + [LOW] Outcome math not documented in UI paytable linkage to server + +------------------------------------------------------------------------------ +4.6 BLACKJACK (REST: POST + PATCH) — src/app/api/games/blackjack/route.ts +------------------------------------------------------------------------------ +Client: src/components/casino/BlackjackGame.tsx + +Issues: + [HIGH] Natural blackjack / push on POST (lines 91-100): + creditForWin/refundBet + gameSession.update are OUTSIDE initial transaction. + Failure between them → wallet credited but session still "active" or inverse. + + [HIGH] resolveStand() (lines 157-193): + credit/refund then session update — not atomic. + Partial failure → duplicate payout or lost payout. + + [MEDIUM] Card rank parsing: handValue uses c.slice(0,-1) — works for "10♠" + but fragile if format changes. + + [MEDIUM] Double down debits second wager but doesn't link ledger memo to session. + +Action: Wrap each resolution path in prisma.$transaction like dice route. + +------------------------------------------------------------------------------ +4.7 COIN FLIP (REST rooms + Socket.IO) — rooms/route.ts + server.ts +------------------------------------------------------------------------------ +Client: src/components/casino/CoinFlipRoom.tsx + +Flow: + - POST /api/games/rooms creates WAITING room (balance check only, no debit) + - Creator connects socket, waits + - Joiner connects → atomic claim → debit both → instant resolve + +Issues: + [HIGH] Payout failure (server.ts lines 129-132): logs error, still emits + "result" to clients; room may stay ACTIVE — needs reconciliation job. + + [MEDIUM] Creator never debited until joiner arrives — creator can spam WAITING + rooms (DB clutter). Expire job runs every 60s — OK. + + [LOW] Instant resolution — no rematch within room + + [LOW] GameHistory does not show coin flip (no GameSession row) + +Socket auth: server-derived userId — GOOD. + +------------------------------------------------------------------------------ +4.8 PONG (REST rooms + Socket.IO + setInterval) — server.ts +------------------------------------------------------------------------------ +Client: src/components/casino/PongGame.tsx + +Issues: + [HIGH] Game loop setInterval(50ms) per room in Node process — CPU scales with + active pong rooms. On 4-core VPS, cap concurrent rooms. + + [HIGH] pongRooms Map: if process crashes mid-game, debits already taken, + room may stay ACTIVE, interval orphaned until process exit. + + [MEDIUM] paddle_move has no rate limit — flood events + + [MEDIUM] Client/server paddle collision logic duplicated — desync possible + + [LOW] No GameSession history entry + +Payout path (lines 244-255): improved with async IIFE + logging — still emits +game_over even if payout fails. + +------------------------------------------------------------------------------ +4.9 PREDICTION MARKET — src/app/api/games/prediction/route.ts +------------------------------------------------------------------------------ +Client: src/components/casino/PredictionMarket.tsx + +POST bet: NOW uses single $transaction (debit + ledger + bet + market increment) — GOOD. + +PATCH resolve: transaction with resolvedTo null guard — GOOD. + - Pays winners via tx.wallet.upsert — bypasses creditWalletCredits integer guard + if payout is 0 skipped; dust handling to first winner — clever + +Issues: + [MEDIUM] GET markets: no auth required — OK for public markets + [LOW] No GameSession — not in GameHistory + [LOW] Creator can resolve early? — throws MARKET_NOT_ENDED if endsAt > now — GOOD + +------------------------------------------------------------------------------ +4.10 GAME HISTORY — src/app/api/games/history/route.ts + GameHistory.tsx +------------------------------------------------------------------------------ +Issues: + [MEDIUM] Client: fetch without res.ok check — silent failure on 401 + [MEDIUM] Only GameSession rows — PvP and prediction invisible + [LOW] No pagination UI (API may support cursor — verify route) + +================================================================================ +5. API ROUTES — COMPLETE INVENTORY & GAPS +================================================================================ + +37 HTTP route handlers under src/app/api/: + +Auth: /api/auth/[...nextauth] +Register: /api/register +Public: /api/public/stats, /api/leaderboard, /api/exchange/rate +Wallet: /api/wallet, summary, ledger, donations, community +Stripe: create-checkout-session, create-payment-intent, webhooks/stripe +Missions: /api/missions, /api/missions/spend +Initiatives: /api/initiatives, [slug]/pledge +Poll: /api/polls/next-president +Rewards: catalog, redeem, raffle +Community: /api/billboard, /api/spotlight, /api/cards, /api/faq, /api/boost +Games: crash, dice, mines, tower, slots, blackjack, roulette, rooms, + prediction, history +Threads: /api/threads-exchange + +Validation: + [MEDIUM] Game routes use manual req.json() casts — no Zod. Malformed body → 500. + [GOOD] FAQ, boost, cards, many others use Zod schemas. + +Missing patterns: + - No If-None-Match / caching on public stats (force-dynamic everywhere) + - No request ID in logs + - Inconsistent error JSON shape { error } vs { issues } + +------------------------------------------------------------------------------ +5.1 Feature pages vs API (wiring status) +------------------------------------------------------------------------------ +WIRED (page fetches API): + /billboard → /api/billboard + /spotlight → /api/spotlight + /cards → /api/cards + /faq-board → /api/faq + /boost → /api/boost + /casino/* → game APIs + /missions → /api/missions + /initiatives → /api/initiatives + /vote/* → /api/polls/next-president + /wallet → wallet APIs + /donate → Stripe checkout + +HOMEPAGE mismatch: + [MEDIUM] ProgressSection → /api/public/stats (USD Stripe goal meter) + Boost page → /api/boost (BWT community epoch meter) + Marketing copy may conflate two different "meters" — clarify UX. + +================================================================================ +6. DATABASE / PRISMA +================================================================================ + +Schema: prisma/schema.prisma (515 lines, 8 migrations) + +Indexes present: + LedgerEntry userId + GameSession userId, [userId, gameType] + GameRoom status + PollVote, Faq*, MovementBoost, etc. + +Missing indexes (performance at scale): + [MEDIUM] GameSession.createdAt — history orders by time, filter by user + [MEDIUM] GameRoom [status, gameType, expiresAt] composite for lobby listing + [MEDIUM] Donation [status, createdAt] for leaderboard aggregations + [LOW] LedgerEntry [userId, createdAt] for wallet ledger pagination + +Missing FKs: + [MEDIUM] LedgerEntry has no gameSessionId / gameRoomId — hard to audit bets + +Enums: GameType, RoomStatus, LedgerType — comprehensive + +------------------------------------------------------------------------------ +6.1 Data integrity notes +------------------------------------------------------------------------------ + [GOOD] Wallet debit uses conditional updateMany (balance >= amount) + [GOOD] Boost uses Serializable isolation for epoch milestone + [GOOD] public/stats and treasury both filter donation status succeeded + [MEDIUM] Movement boost milestone race under concurrent POSTs (documented in redmeFIXSES) + +================================================================================ +7. FRONTEND / UX / MALFORMED PATTERNS +================================================================================ + +7.1 Global layout performance killers +------------------------------------------------------------------------------ +src/app/layout.tsx loads on EVERY page: + - CursorStreamers — full-screen canvas, mousemove listener, RAF loop + - SiteNav + - framer-motion on Hero and many sections + +src/components/ParticleField.tsx — second full-section canvas on homepage Hero +src/components/SmokeWisps.tsx — additional animation layer + +Impact on 8GB Linux VPS: + - Constant GPU/CPU use even on /datadeletion legal pages + - Battery drain for mobile users + - Competes with Node build (npm run build spikes memory) + +Actions: + - Load CursorStreamers only on homepage or casino + - Respect prefers-reduced-motion (CursorStreamers already skips reduced motion) + - Lazy-load framer-motion below fold + +7.2 Polling / duplicate fetches +------------------------------------------------------------------------------ +Endpoints polled from multiple components without shared SWR/React Query: + + /api/exchange/rate — 9+ separate useEffects across site + /api/wallet — ExchangePanel every 15s on casino pages + /api/public/stats — ProgressSection, HeroLiveStats + +Action: Single client provider for rate + wallet; stale-while-revalidate. + +7.3 Casino page architecture +------------------------------------------------------------------------------ +src/app/casino/[game]/page.tsx: + - Server component fetches wallet once — GOOD + - ExchangePanel re-fetches wallet every 15s — balance can drift from server + - Game components receive static `balance` prop — not updated after wins/losses + unless user refreshes or ExchangePanel poll updates (partial fix) + + [MEDIUM] User sees stale balance in wager inputs after big win until poll. + +7.4 Error handling gaps +------------------------------------------------------------------------------ + - GameHistory: no res.ok check + - Many fetch().then without .catch — silent network errors + - CrashGame tick: empty catch {} swallows errors — intentional comment + +7.5 Accessibility +------------------------------------------------------------------------------ + - Canvas decorations aria-hidden — GOOD + - Some icon-only buttons may lack aria-labels in casino grids + - Focus management on game phase transitions not tested + +7.6 SEO / metadata +------------------------------------------------------------------------------ + [MEDIUM] layout.tsx alternates.canonical hardcoded to "/" — subpages should + override (some do, e.g. privacy-policy) + + [MEDIUM] sitemap.ts missing /casino, /missions, /boost, /billboard, etc. + + [LOW] opengraph-image force-dynamic — regenerates every request + +================================================================================ +8. STRIPE / PAYMENTS / WALLET +================================================================================ + + - EmbeddedDonationCheckout → create-checkout-session (primary path) + - DonationCheckout → create-payment-intent (UNUSED) + - Webhook: src/app/api/webhooks/stripe/route.ts — signature verify — GOOD + +Issues: + [HIGH] Ensure webhook endpoint not cached by Cloudflare (POST body must reach origin) + [MEDIUM] CREDIT_RATIO_CENTS_PER_USD — verify webhook grants match UI promises + [LOW] Guest checkout path — document in FAQ + +================================================================================ +9. AUTH +================================================================================ + + - next-auth v5 beta with Credentials provider + - Email OR username login — GOOD (lib/account-identifiers.ts) + - bcrypt password hashing — GOOD + - JWT session with role — GOOD + - No OAuth providers configured + +Issues: + [MEDIUM] No password reset flow (forgot-password page is static) + [MEDIUM] No brute-force lockout on /api/auth/callback/credentials + [LOW] ADMIN role only via DB seed + +================================================================================ +10. TESTING & OBSERVABILITY +================================================================================ + +Scripts: + scripts/smoke-integration.ts — Prisma/DB + scripts/http-smoke.sh — curl stats + wallet 401 + scripts/full-site-test.ts — extensive HTTP checks (manual, server must run) + scripts/seed-leaderboard.ts + +Gaps: + [HIGH] No automated tests for game math or wallet settlement + [HIGH] No CI pipeline evident + [MEDIUM] console.error only — no structured logs, no correlation IDs + [MEDIUM] No health check endpoint (/api/health) for monitoring + [LOW] eslint installed but npm run lint = tsc only + +================================================================================ +11. LINUX / NODE PERFORMANCE TUNING (THIS MACHINE) +================================================================================ + +Current resources (audit snapshot): + RAM: 7.8 GiB total, ~1.6 GiB used, 6.2 GiB available + CPU: 4 cores + Disk: 64G volume, 7% used + node_modules: 561M + .next: 1.1G + +Recommendations: + +A. Process manager + - Keep single fundraising-platform.service instance + - Add MemoryMax=5G in systemd unit to avoid OOM killing Postgres + - Lower NODE_OPTIONS to --max-old-space-size=4096 for steady-state + +B. PostgreSQL (localhost:5432) + - Ensure shared_buffers ~256MB, effective_cache_size ~2GB on this VM + - Enable log_min_duration_statement = 500 for slow query discovery + - Run EXPLAIN on leaderboard + poll aggregation queries + +C. Next.js production + - Run `npm run build` after every deploy (already required for new routes) + - Consider output: 'standalone' in next.config for smaller runtime footprint + - Disable turbopack for production build if instability observed (using default) + +D. Reverse proxy + - Prefer cloudflared tunnel → 127.0.0.1:8008 over exposing :8008 + - Enable gzip/brotli at edge; Next already compresses + +E. Static assets + - next.config.ts sets immutable cache for /_next/static — GOOD + - Ensure Cloudflare respects cache for hashed assets only + +F. Background jobs + - server.ts setInterval 60s for room expiry — OK + - Add cron for stuck ACTIVE GameRoom reconciliation + +G. Build/deploy workflow + - systemctl restart after git pull + npm run build + - Avoid running build while service uses CPU (schedule maintenance window) + +H. Monitoring + - journalctl -u fundraising-platform -f + - Add node_exporter or simple /api/health returning { ok, db, version } + +================================================================================ +12. LEGAL / COMPLIANCE PAGES (RECENT) +================================================================================ + +Added: + /privacy-policy — src/app/privacy-policy/page.tsx + /datadeletion — src/app/datadeletion/page.tsx + /threads-callback — OAuth UI (blocked on live domain by Cloudflare static file) + +Issues: + [MEDIUM] legal-contact falls back to privacy@example.com if env unset + [LOW] Template disclaimer on pages — counsel review still required + [LOW] threads-callback not in sitemap (robots noindex — OK) + +================================================================================ +13. PACKAGE / DEPENDENCY NOTES +================================================================================ + + next@16.2.6, react@19.2.6, prisma@7.8.0, next-auth@5.0.0-beta.31 + socket.io@4.8.3 — matches client + framer-motion@12 — large bundle; tree-shake or lazy load + zod@4.4.3 — v4 API; ensure team knows breaking changes vs zod 3 + + [LOW] npm audit not run in this audit — run `npm audit` separately + +================================================================================ +14. FILE-BY-FILE HOTSPOT INDEX (QUICK REFERENCE) +================================================================================ + +server.ts Socket.IO, pong loop, coin flip, room expiry +src/middleware.ts matcher [] — auth not global +src/auth.ts Credentials only +src/app/api/games/blackjack/route.ts NON-ATOMIC payouts — FIX FIRST +src/app/api/games/crash/route.ts tick spam, cashout state sync +src/app/api/games/mines/route.ts loss race, ledger bypass +src/app/api/games/prediction/route.ts resolve logic OK; trust model +src/components/DonationCheckout.tsx DELETE (dead) +src/components/CursorStreamers.tsx perf — scope to homepage +src/components/ParticleField.tsx perf — homepage only +src/components/casino/ExchangePanel.tsx 15s polling +src/components/casino/CrashGame.tsx 220ms PATCH polling +src/components/casino/GameHistory.tsx no error handling +src/app/casino/[game]/page.tsx stale balance prop +src/lib/game-ledger.ts canonical money API +src/lib/provably-fair.ts all RNG +prisma/schema.prisma indexes + missing FKs +next.config.ts security headers, HSTS preload +.env / .env.example URL + Threads vars + +================================================================================ +15. SUGGESTED FIX ROADMAP (PHASED) +================================================================================ + +Phase 0 — Today (ops): + [ ] Cloudflare: route all paths to Node origin; delete static threads HTML + [ ] Fix NEXT_PUBLIC_SITE_URL for bwt.democracyrisingbwt.us + [ ] Rotate Threads secret if static page was public + [ ] systemctl restart after build + +Phase 1 — Money safety (1-2 days dev): + [ ] Blackjack transactions + [ ] Coin-flip/pong stuck room reconciler + [ ] LedgerEntry.gameSessionId optional FK + backfill + +Phase 2 — Games UX (2-3 days): + [ ] Unify game catalog + [ ] Fix CrashGame cashout ref + [ ] GameHistory error states + PvP rows (or document omission) + [ ] Balance sync via context after each game + +Phase 3 — Performance (1-2 days): + [ ] Remove/limit CursorStreamers + ParticleField scope + [ ] Shared exchange rate provider + [ ] Add DB indexes listed above + [ ] Lower Node heap to 4GB + +Phase 4 — Hardening (ongoing): + [ ] Rate limits + [ ] Health endpoint + [ ] Integration tests for dice/mines settlement + [ ] Delete DonationCheckout.tsx + +================================================================================ +16. AUDIT METHODOLOGY +================================================================================ + +Performed: + - Full tree listing src/, prisma/, scripts/ + - tsc --noEmit (pass) + - npm run build route list (pass) + - Manual read of all game API routes + server.ts + key components + - Grep for TODO, console.*, fetch patterns, dead imports + - Live HTTP comparison Cloudflare vs 127.0.0.1:8008 + - Subagent explore pass for architecture map + - Cross-check against redmeFIXSES.md (partially outdated) + +Not performed: + - npm audit / dependency CVE scan + - Load testing (k6/ab) on game endpoints + - Stripe live transaction test + - Full line-by-line read of all 15k lines (hot paths covered) + - PostgreSQL EXPLAIN ANALYZE + - Accessibility audit tooling (axe) + +================================================================================ +END OF AUDIT — 16 sections, ~450+ line items reviewed +================================================================================ diff --git a/prisma/migrations/20260520000000_game_ledger_links/migration.sql b/prisma/migrations/20260520000000_game_ledger_links/migration.sql new file mode 100644 index 0000000..a4f218a --- /dev/null +++ b/prisma/migrations/20260520000000_game_ledger_links/migration.sql @@ -0,0 +1,26 @@ +-- Link LedgerEntry rows to their originating GameSession / GameRoom so we can +-- reconcile money flow per round/room. Non-unique because a single session/room +-- produces multiple ledger lines (debit + win/refund). + +ALTER TABLE "LedgerEntry" ADD COLUMN "gameSessionId" TEXT; +ALTER TABLE "LedgerEntry" ADD COLUMN "gameRoomId" TEXT; + +CREATE INDEX "LedgerEntry_gameSessionId_idx" ON "LedgerEntry" ("gameSessionId"); +CREATE INDEX "LedgerEntry_gameRoomId_idx" ON "LedgerEntry" ("gameRoomId"); + +ALTER TABLE "LedgerEntry" + ADD CONSTRAINT "LedgerEntry_gameSessionId_fkey" + FOREIGN KEY ("gameSessionId") REFERENCES "GameSession"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "LedgerEntry" + ADD CONSTRAINT "LedgerEntry_gameRoomId_fkey" + FOREIGN KEY ("gameRoomId") REFERENCES "GameRoom"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + +-- Helper indexes for resume + sweep + admin queries. +CREATE INDEX "GameSession_userId_gameType_outcome_idx" + ON "GameSession" ("userId", "gameType", "outcome"); + +CREATE INDEX "GameRoom_status_expiresAt_idx" ON "GameRoom" ("status", "expiresAt"); +CREATE INDEX "GameRoom_creatorId_status_idx" ON "GameRoom" ("creatorId", "status"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a4a48cf..81d977a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -167,9 +167,18 @@ model LedgerEntry { movementBoostId String? @unique movementBoost MovementBoost? @relation(fields: [movementBoostId], references: [id]) + /// Optional links for casino/PvP reconciliation — not @unique because a single + /// session or room produces multiple ledger lines (debit + payout/refund). + gameSessionId String? + gameSession GameSession? @relation(fields: [gameSessionId], references: [id], onDelete: SetNull) + gameRoomId String? + gameRoom GameRoom? @relation(fields: [gameRoomId], references: [id], onDelete: SetNull) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) + @@index([gameSessionId]) + @@index([gameRoomId]) } model PollVote { @@ -310,10 +319,12 @@ model GameSession { resultData Json? createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + ledgerEntries LedgerEntry[] @@index([userId]) @@index([userId, gameType]) + @@index([userId, gameType, outcome]) } model GameRoom { @@ -327,7 +338,11 @@ model GameRoom { createdAt DateTime @default(now()) expiresAt DateTime + ledgerEntries LedgerEntry[] + @@index([status]) + @@index([status, expiresAt]) + @@index([creatorId, status]) } model PredictionMarket { diff --git a/scripts/full-site-test.ts b/scripts/full-site-test.ts index f4e3a2b..29b4bc0 100644 --- a/scripts/full-site-test.ts +++ b/scripts/full-site-test.ts @@ -104,6 +104,8 @@ async function testPublicRoutes() { { path: "/register", minLen: 500 }, { path: "/login", minLen: 300 }, { path: "/forgot-password", minLen: 200 }, + { path: "/privacy-policy", minLen: 500 }, + { path: "/datadeletion", minLen: 500 }, { path: "/donate", minLen: 500 }, { path: "/donate/thank-you", minLen: 200 }, { path: "/billboard", minLen: 200 }, diff --git a/server.ts b/server.ts index de99ac4..e44f54c 100644 --- a/server.ts +++ b/server.ts @@ -3,9 +3,15 @@ import "dotenv/config"; import { createServer } from "http"; import { parse } from "url"; import next from "next"; +import { getToken } from "next-auth/jwt"; import { Server as SocketIOServer } from "socket.io"; import { prisma } from "./src/lib/prisma"; -import { debitForBet, creditForWin, refundBet } from "./src/lib/game-ledger"; +import { + debitForBet, + creditForWin, + refundBet, + refundRoomPartyIfNotRefunded, +} from "./src/lib/game-ledger"; import { generateServerSeed, deriveInt } from "./src/lib/provably-fair"; const dev = process.env.NODE_ENV !== "production"; @@ -34,14 +40,25 @@ app.prepare().then(() => { // ── COIN FLIP ────────────────────────────────────────────────────────────── const coinFlipNS = io.of("/coin-flip"); - coinFlipNS.on("connection", (socket) => { - socket.on("join_room", async ({ roomId, userId }: { roomId: string; userId: string }) => { - // Reject obviously spoofed ids — user must exist in DB. - const userExists = await prisma.user.findUnique({ - where: { id: userId }, - select: { id: true }, + async function resolveSocketUserId(socket: { request: unknown }): Promise { + try { + const token = await getToken({ + req: socket.request as Parameters[0]["req"], + secret: process.env.AUTH_SECRET, }); - if (!userExists) { socket.emit("error", "Invalid session"); return; } + const uid = typeof token?.id === "string" ? token.id : typeof token?.sub === "string" ? token.sub : null; + if (!uid) return null; + const user = await prisma.user.findUnique({ where: { id: uid }, select: { id: true } }); + return user?.id ?? null; + } catch { + return null; + } + } + + coinFlipNS.on("connection", (socket) => { + socket.on("join_room", async ({ roomId }: { roomId: string }) => { + const userId = await resolveSocketUserId({ request: socket.request }); + if (!userId) { socket.emit("error", "Invalid session"); return; } const room = await prisma.gameRoom.findUnique({ where: { id: roomId } }); if (!room || room.status !== "WAITING") { @@ -67,37 +84,16 @@ app.prepare().then(() => { return; } - // Balance check after claiming to avoid charging then bouncing. - const wallet = await prisma.wallet.findUnique({ where: { userId } }); - if (!wallet || wallet.balanceCredits < room.wageBLW) { - // Revert the slot claim. - await prisma.gameRoom.update({ - where: { id: roomId }, - data: { joinerId: null, status: "WAITING" }, - }); + // Joiner is debited at join time. Creator was already debited when the + // room was created (POST /api/games/rooms) — do NOT debit again here. + try { + await debitForBet(userId, room.wageBLW, "COIN_FLIP", undefined, { gameRoomId: roomId }); + } catch { + await prisma.gameRoom.update({ where: { id: roomId }, data: { joinerId: null, status: "WAITING" } }); socket.emit("error", "Insufficient balance"); return; } - // Debit joiner. - try { - await debitForBet(userId, room.wageBLW, "COIN_FLIP"); - } catch { - await prisma.gameRoom.update({ where: { id: roomId }, data: { joinerId: null, status: "WAITING" } }); - socket.emit("error", "Debit failed"); - return; - } - - // Debit creator. - try { - await debitForBet(room.creatorId, room.wageBLW, "COIN_FLIP"); - } catch { - await refundBet(userId, room.wageBLW, "COIN_FLIP"); - await prisma.gameRoom.update({ where: { id: roomId }, data: { joinerId: null, status: "WAITING" } }); - socket.emit("error", "Creator debit failed"); - return; - } - socket.join(roomId); coinFlipNS.to(roomId).emit("game_start", { roomId }); @@ -109,7 +105,7 @@ app.prepare().then(() => { const payout = Math.floor(pot * (1 - HOUSE_CUT)); try { - await creditForWin(winnerId, payout, "COIN_FLIP"); + await creditForWin(winnerId, payout, "COIN_FLIP", undefined, { gameRoomId: roomId }); await prisma.gameRoom.update({ where: { id: roomId }, data: { status: "RESOLVED", resultData: { result, winnerId, serverSeed, payout } }, @@ -143,13 +139,11 @@ app.prepare().then(() => { }>(); pongNS.on("connection", (socket) => { - socket.on("join_room", async ({ roomId, userId }: { roomId: string; userId: string }) => { - // Reject obviously spoofed ids. - const userExists = await prisma.user.findUnique({ - where: { id: userId }, - select: { id: true }, - }); - if (!userExists) { socket.emit("error", "Invalid session"); return; } + socket.on("join_room", async ({ roomId }: { roomId: string }) => { + const userId = await resolveSocketUserId({ request: socket.request }); + if (!userId) { socket.emit("error", "Invalid session"); return; } + + socket.data.userId = userId; const room = await prisma.gameRoom.findUnique({ where: { id: roomId } }); if (!room || !["WAITING", "ACTIVE"].includes(room.status)) { @@ -180,21 +174,24 @@ app.prepare().then(() => { const state = pongRooms.get(roomId); if (!state || state.joinerId) { socket.emit("error", "Room full"); return; } - const wallet = await prisma.wallet.findUnique({ where: { userId } }); - if (!wallet || wallet.balanceCredits < room.wageBLW) { + // Creator is already debited at room creation. Only debit joiner here. + try { + await debitForBet(userId, room.wageBLW, "PONG", undefined, { gameRoomId: roomId }); + } catch { socket.emit("error", "Insufficient balance"); return; } - try { - await debitForBet(userId, room.wageBLW, "PONG"); - await debitForBet(room.creatorId, room.wageBLW, "PONG"); - } catch { - await refundBet(userId, room.wageBLW, "PONG"); - socket.emit("error", "Debit failed"); return; + // Claim the joiner slot atomically (DB is source of truth). + const claimed = await prisma.gameRoom.updateMany({ + where: { id: roomId, status: "WAITING", joinerId: null }, + data: { joinerId: userId, status: "ACTIVE" }, + }); + if (claimed.count !== 1) { + await refundBet(userId, room.wageBLW, "PONG", undefined, { gameRoomId: roomId }); + socket.emit("error", "Room not available"); return; } state.joinerId = userId; - await prisma.gameRoom.update({ where: { id: roomId }, data: { joinerId: userId, status: "ACTIVE" } }); pongNS.to(roomId).emit("game_start", { roomId, creatorId: room.creatorId, joinerId: userId }); // Game loop — 20 ticks/sec. @@ -233,7 +230,7 @@ app.prepare().then(() => { // not silently swallowed. void (async () => { try { - await creditForWin(winnerId, payout, "PONG"); + await creditForWin(winnerId, payout, "PONG", undefined, { gameRoomId: roomId }); await prisma.gameRoom.update({ where: { id: roomId }, data: { status: "RESOLVED", resultData: { winnerId, scores: finalScores, payout } }, @@ -247,24 +244,78 @@ app.prepare().then(() => { }, 50); }); - socket.on("paddle_move", ({ roomId, userId, y }: { roomId: string; userId: string; y: number }) => { + socket.on("paddle_move", ({ roomId, y }: { roomId: string; y: number }) => { const state = pongRooms.get(roomId); if (!state) return; + const userId = typeof socket.data.userId === "string" ? socket.data.userId : null; + if (!userId) return; const clampedY = Math.max(0, Math.min(360, y)); if (state.creatorId === userId) state.paddles.creator = clampedY; else if (state.joinerId === userId) state.paddles.joiner = clampedY; }); }); - // Expire abandoned rooms every minute. - setInterval(async () => { - await prisma.gameRoom.updateMany({ + // ── Expire abandoned WAITING rooms and refund the creator's locked stake ── + async function sweepExpiredRooms() { + const stale = await prisma.gameRoom.findMany({ where: { status: "WAITING", expiresAt: { lt: new Date() } }, - data: { status: "EXPIRED" }, + select: { id: true, creatorId: true, wageBLW: true, gameType: true }, }); - }, 60_000); + for (const r of stale) { + // Move WAITING → EXPIRED atomically; only one sweep per room wins. + const flipped = await prisma.gameRoom.updateMany({ + where: { id: r.id, status: "WAITING" }, + data: { status: "EXPIRED" }, + }); + if (flipped.count !== 1) continue; + try { + await refundRoomPartyIfNotRefunded(r.id, r.creatorId, r.wageBLW, r.gameType); + } catch (err) { + console.error("[room-expiry] refund failed for", r.id, err); + } + } + } + setInterval(() => { void sweepExpiredRooms(); }, 60_000); - httpServer.listen(8008, "0.0.0.0", () => { - console.log(`> Ready on http://0.0.0.0:8008`); - }); + // ── Boot recovery: any room that was ACTIVE/WAITING when the process died + // has no in-memory state to resume from. Refund all parties and mark EXPIRED + // so players get their BLW back and rooms don't appear as full/playable. + async function recoverOrphanedRoomsOnBoot() { + const orphans = await prisma.gameRoom.findMany({ + where: { + OR: [ + { status: "ACTIVE", gameType: "PONG" }, + // WAITING rooms past expiry caught here too. + { status: "WAITING", expiresAt: { lt: new Date() } }, + ], + }, + select: { id: true, creatorId: true, joinerId: true, wageBLW: true, gameType: true, status: true }, + }); + for (const r of orphans) { + const flipped = await prisma.gameRoom.updateMany({ + where: { id: r.id, status: r.status }, + data: { status: "EXPIRED" }, + }); + if (flipped.count !== 1) continue; + try { + await refundRoomPartyIfNotRefunded(r.id, r.creatorId, r.wageBLW, r.gameType); + if (r.joinerId) { + // Joiner debit also points to gameRoomId — same idempotent guard works. + await refundRoomPartyIfNotRefunded(r.id, r.joinerId, r.wageBLW, r.gameType); + } + console.log(`[boot-recovery] refunded room ${r.id} (${r.gameType})`); + } catch (err) { + console.error("[boot-recovery] refund failed for", r.id, err); + } + } + } + // Run recovery to completion BEFORE we start accepting traffic — otherwise + // new clients could race against rooms we're about to mark EXPIRED. + recoverOrphanedRoomsOnBoot() + .catch((err) => console.error("[boot-recovery] sweep failed:", err)) + .finally(() => { + httpServer.listen(8008, "0.0.0.0", () => { + console.log(`> Ready on http://0.0.0.0:8008`); + }); + }); }); diff --git a/src/app/api/games/active/route.ts b/src/app/api/games/active/route.ts new file mode 100644 index 0000000..d8d019b --- /dev/null +++ b/src/app/api/games/active/route.ts @@ -0,0 +1,115 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/prisma"; +import { hashServerSeed } from "@/lib/provably-fair"; +import type { GameType } from "@prisma/client"; + +export const dynamic = "force-dynamic"; + +/** + * Active-state endpoint used for: + * - Solo game resume (single gameType — returns one session + sanitized state) + * - Wallet/lobby "you have unfinished games" banner (no gameType — bulk) + * + * Never returns serverSeed or hidden board contents (mines/bomb positions) for + * still-active rounds; only a hash and player-visible progress. + */ +export async function GET(req: NextRequest) { + const session = await auth(); + if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const url = new URL(req.url); + const gameType = url.searchParams.get("gameType"); + + if (gameType) { + const gs = await prisma.gameSession.findFirst({ + where: { + userId: session.user.id, + gameType: gameType as GameType, + outcome: "active", + }, + orderBy: { createdAt: "desc" }, + }); + const room = await prisma.gameRoom.findFirst({ + where: { + gameType: gameType as GameType, + OR: [ + { creatorId: session.user.id, status: "WAITING" }, + { creatorId: session.user.id, status: "ACTIVE" }, + { joinerId: session.user.id, status: "ACTIVE" }, + ], + }, + orderBy: { createdAt: "desc" }, + }); + return NextResponse.json({ + session: gs ? formatSession(gs) : null, + room, + }); + } + + // Bulk: every active solo session + every live room for this user. + const [sessions, rooms] = await Promise.all([ + prisma.gameSession.findMany({ + where: { userId: session.user.id, outcome: "active" }, + orderBy: { createdAt: "desc" }, + }), + prisma.gameRoom.findMany({ + where: { + OR: [ + { creatorId: session.user.id, status: { in: ["WAITING", "ACTIVE"] } }, + { joinerId: session.user.id, status: "ACTIVE" }, + ], + }, + orderBy: { createdAt: "desc" }, + }), + ]); + + return NextResponse.json({ + sessions: sessions.map(formatSession), + rooms, + }); +} + +function formatSession(gs: { + id: string; + gameType: GameType; + wageredBLW: number; + serverSeed: string; + clientSeed: string | null; + resultData: unknown; + createdAt: Date; +}) { + return { + id: gs.id, + gameType: gs.gameType, + wageredBLW: gs.wageredBLW, + serverSeedHash: hashServerSeed(gs.serverSeed), + clientSeed: gs.clientSeed, + resultData: sanitizeResultData(gs.gameType, gs.resultData), + createdAt: gs.createdAt, + }; +} + +/** + * Strip server-only info (e.g. mine/bomb positions) from resultData before + * sending back to the client. Players must never see unrevealed positions. + */ +function sanitizeResultData(gameType: GameType, raw: unknown): unknown { + if (!raw || typeof raw !== "object") return null; + const d = raw as Record; + switch (gameType) { + case "MINES": { + return { + revealed: Array.isArray(d.revealed) ? d.revealed : [], + mineCount: typeof d.mineCount === "number" ? d.mineCount : 0, + }; + } + case "TOWER": { + return { + currentFloor: typeof d.currentFloor === "number" ? d.currentFloor : 0, + }; + } + default: + return null; + } +} diff --git a/src/app/api/games/blackjack/route.ts b/src/app/api/games/blackjack/route.ts index 48e2952..4cfee17 100644 --- a/src/app/api/games/blackjack/route.ts +++ b/src/app/api/games/blackjack/route.ts @@ -64,11 +64,12 @@ export async function POST(req: NextRequest) { } const playerVal = handValue(playerHand); + let dealerVal: number | undefined; let outcome = "active"; if (playerVal === 21) { // Natural blackjack — check dealer - const dealerVal = handValue(dealerHand); + dealerVal = handValue(dealerHand); if (dealerVal === 21) { outcome = "push"; } else { @@ -92,12 +93,12 @@ export async function POST(req: NextRequest) { const payout = Math.floor(wageBLW * 2.5); await creditForWin(session.user.id, payout, "BLACKJACK"); await prisma.gameSession.update({ where: { id: gs.id }, data: { outcome: "blackjack", multiplier: 2.5, payoutBLW: payout } }); - return NextResponse.json({ roundId: gs.id, playerHand, dealerVisible: [dealerHand[0]], outcome: "blackjack", payout, playerVal }); + return NextResponse.json({ roundId: gs.id, playerHand, dealerVisible: [dealerHand[0]], dealerHand, outcome: "blackjack", payout, playerVal, dealerVal }); } if (outcome === "push") { await refundBet(session.user.id, wageBLW, "BLACKJACK"); await prisma.gameSession.update({ where: { id: gs.id }, data: { outcome: "push", multiplier: 1, payoutBLW: wageBLW } }); - return NextResponse.json({ roundId: gs.id, playerHand, dealerVisible: [dealerHand[0]], outcome: "push", payout: wageBLW, playerVal }); + return NextResponse.json({ roundId: gs.id, playerHand, dealerVisible: [dealerHand[0]], dealerHand, outcome: "push", payout: wageBLW, playerVal, dealerVal }); } return NextResponse.json({ roundId: gs.id, playerHand, dealerVisible: [dealerHand[0]], outcome: "active", playerVal }); diff --git a/src/app/api/games/crash/route.ts b/src/app/api/games/crash/route.ts index f1b3351..a7d72c2 100644 --- a/src/app/api/games/crash/route.ts +++ b/src/app/api/games/crash/route.ts @@ -7,7 +7,7 @@ import { creditWalletCredits } from "@/lib/wallet-safety"; export const dynamic = "force-dynamic"; -// POST /api/games/crash — start a crash round, returns serverSeedHash + roundId +// POST /api/games/crash — start a round, returns commit hash + round id. export async function POST(req: NextRequest) { const session = await auth(); if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); @@ -42,16 +42,32 @@ export async function POST(req: NextRequest) { }, }); - return NextResponse.json({ roundId: gs.id, serverSeedHash: seedHash, crashAt }); + return NextResponse.json({ roundId: gs.id, serverSeedHash: seedHash }); } -// PATCH /api/games/crash — cash out at current multiplier +async function settleCrashLoss(roundId: string, userId: string, crashAt: number, serverSeed: string) { + const settled = await prisma.gameSession.updateMany({ + where: { id: roundId, userId, outcome: "active" }, + data: { outcome: "loss", multiplier: crashAt, payoutBLW: 0 }, + }); + if (settled.count !== 1) { + return NextResponse.json({ error: "Round not found or already settled" }, { status: 404 }); + } + return NextResponse.json({ outcome: "loss", crashAt, payout: 0, serverSeed }); +} + +// PATCH /api/games/crash — tick status or cash out. export async function PATCH(req: NextRequest) { const session = await auth(); if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const body = await req.json(); - const { roundId, cashoutAt } = body as { roundId: string; cashoutAt: number }; + const { roundId, cashoutAt, action, currentMultiplier } = body as { + roundId: string; + cashoutAt?: number; + action?: "tick" | "cashout"; + currentMultiplier?: number; + }; const gs = await prisma.gameSession.findUnique({ where: { id: roundId } }); if (!gs || gs.userId !== session.user.id || gs.outcome !== "active") { @@ -60,13 +76,25 @@ export async function PATCH(req: NextRequest) { const { crashAt } = gs.resultData as { crashAt: number }; - if (cashoutAt > crashAt) { - // Player cashed out after crash — they lose - await prisma.gameSession.update({ - where: { id: roundId }, - data: { outcome: "loss", multiplier: crashAt, payoutBLW: 0 }, - }); - return NextResponse.json({ outcome: "loss", crashAt, payout: 0 }); + const mode: "tick" | "cashout" = action ?? "cashout"; + + if (mode === "tick") { + if (typeof currentMultiplier !== "number" || !Number.isFinite(currentMultiplier) || currentMultiplier < 1) { + return NextResponse.json({ error: "Invalid multiplier probe" }, { status: 400 }); + } + if (currentMultiplier >= crashAt) { + return settleCrashLoss(roundId, session.user.id, crashAt, gs.serverSeed ?? ""); + } + return NextResponse.json({ outcome: "active", crashed: false }); + } + + if (typeof cashoutAt !== "number" || !Number.isFinite(cashoutAt) || cashoutAt < 1) { + return NextResponse.json({ error: "Invalid cashout value" }, { status: 400 }); + } + + if (cashoutAt >= crashAt) { + // Player cashed out after crash — settle as a loss. + return settleCrashLoss(roundId, session.user.id, crashAt, gs.serverSeed ?? ""); } const multiplier = Math.max(1.0, cashoutAt); diff --git a/src/app/api/games/prediction/route.ts b/src/app/api/games/prediction/route.ts index a2f633e..71f2e81 100644 --- a/src/app/api/games/prediction/route.ts +++ b/src/app/api/games/prediction/route.ts @@ -120,6 +120,10 @@ export async function PATCH(req: NextRequest) { include: { bets: true }, }); + if (market.endsAt > new Date()) { + throw new Error("MARKET_NOT_ENDED"); + } + const totalPot = market.totalYes + market.totalNo; const winnerBets = market.bets.filter((b) => b.side === resolvedTo); const winnerTotal = winnerBets.reduce((s, b) => s + b.blwAmount, 0); @@ -170,6 +174,9 @@ export async function PATCH(req: NextRequest) { return NextResponse.json({ resolved: true, resolvedTo, ...outcome }); } catch (e) { + if (e instanceof Error && e.message === "MARKET_NOT_ENDED") { + return NextResponse.json({ error: "Market cannot be resolved before its close time." }, { status: 400 }); + } if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") { return NextResponse.json({ error: "Market not found, already resolved, or not authorized." }, { status: 409 }); } diff --git a/src/app/api/games/rooms/route.ts b/src/app/api/games/rooms/route.ts index 1bfd0c7..e097af0 100644 --- a/src/app/api/games/rooms/route.ts +++ b/src/app/api/games/rooms/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/auth"; import { prisma } from "@/lib/prisma"; +import { debitWalletCredits, INSUFFICIENT_CREDITS } from "@/lib/wallet-safety"; +import { refundRoomPartyIfNotRefunded } from "@/lib/game-ledger"; export const dynamic = "force-dynamic"; @@ -31,22 +33,64 @@ export async function POST(req: NextRequest) { if (!Number.isInteger(wageBLW) || wageBLW < 1) return NextResponse.json({ error: "Invalid wager" }, { status: 400 }); if (!["COIN_FLIP", "PONG"].includes(gameType)) return NextResponse.json({ error: "Invalid game type" }, { status: 400 }); - // Check balance - const wallet = await prisma.wallet.findUnique({ where: { userId: session.user.id } }); - if (!wallet || wallet.balanceCredits < wageBLW) { - return NextResponse.json({ error: "Insufficient balance" }, { status: 402 }); - } - + const userId = session.user.id; const expiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5 min - const room = await prisma.gameRoom.create({ - data: { - gameType, - creatorId: session.user.id, - wageBLW, - expiresAt, - }, - }); - - return NextResponse.json({ room }); + // Lock the creator's funds atomically with room creation so two browser tabs + // can't double-spend the same balance. The ledger entry references the room + // for later refund / reconciliation. + try { + const room = await prisma.$transaction(async (tx) => { + await debitWalletCredits(tx, userId, wageBLW); + const r = await tx.gameRoom.create({ + data: { gameType, creatorId: userId, wageBLW, expiresAt }, + }); + await tx.ledgerEntry.create({ + data: { + userId, + delta: -wageBLW, + type: "DEBIT_GAME_BET", + memo: `${gameType} room lock`, + gameRoomId: r.id, + }, + }); + return r; + }); + return NextResponse.json({ room }); + } catch (e) { + if (e instanceof Error && e.message === INSUFFICIENT_CREDITS) { + return NextResponse.json({ error: "Insufficient balance" }, { status: 402 }); + } + console.error("[rooms.POST] failed:", e); + return NextResponse.json({ error: "Could not create room" }, { status: 500 }); + } +} + +// Creator-initiated cancel — only works while the room is still WAITING. +// Refunds the locked stake idempotently. +export async function DELETE(req: NextRequest) { + const session = await auth(); + if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const url = new URL(req.url); + const roomId = url.searchParams.get("id"); + if (!roomId) return NextResponse.json({ error: "Missing room id" }, { status: 400 }); + + const room = await prisma.gameRoom.findUnique({ where: { id: roomId } }); + if (!room) return NextResponse.json({ error: "Room not found" }, { status: 404 }); + if (room.creatorId !== session.user.id) { + return NextResponse.json({ error: "Not your room" }, { status: 403 }); + } + + // Atomically move WAITING → EXPIRED; only one cancel wins the race. + const cancelled = await prisma.gameRoom.updateMany({ + where: { id: roomId, status: "WAITING" }, + data: { status: "EXPIRED" }, + }); + if (cancelled.count !== 1) { + return NextResponse.json({ error: "Room not cancellable" }, { status: 409 }); + } + + await refundRoomPartyIfNotRefunded(roomId, room.creatorId, room.wageBLW, room.gameType); + return NextResponse.json({ ok: true }); } diff --git a/src/app/api/threads-exchange/route.ts b/src/app/api/threads-exchange/route.ts new file mode 100644 index 0000000..c83e2f4 --- /dev/null +++ b/src/app/api/threads-exchange/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from "next/server"; + +const THREADS_TOKEN_URL = "https://graph.threads.net/oauth/access_token"; +const THREADS_LONG_LIVED_TOKEN_URL = "https://graph.threads.net/access_token"; +const DEFAULT_REDIRECT_URI = "https://bwt.democracyrisingbwt.us/threads-callback"; + +export async function POST(req: Request) { + const clientId = process.env.THREADS_CLIENT_ID; + const clientSecret = process.env.THREADS_CLIENT_SECRET; + const redirectUri = process.env.THREADS_REDIRECT_URI ?? DEFAULT_REDIRECT_URI; + + if (!clientId || !clientSecret) { + return NextResponse.json({ error: "Threads OAuth is not configured." }, { status: 500 }); + } + + let code: unknown; + try { + const body = await req.json(); + code = body?.code; + } catch { + return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 }); + } + + if (typeof code !== "string" || !code.trim()) { + return NextResponse.json({ error: "Missing authorization code." }, { status: 400 }); + } + + const tokenRes = await fetch(THREADS_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + grant_type: "authorization_code", + redirect_uri: redirectUri, + code, + }), + }); + + const tokenData = await tokenRes.json(); + if (!tokenRes.ok || tokenData.error) { + return NextResponse.json(tokenData, { status: tokenRes.status || 400 }); + } + + const accessToken = tokenData.access_token; + if (typeof accessToken !== "string" || !accessToken) { + return NextResponse.json({ error: "Threads did not return an access token.", tokenData }, { status: 502 }); + } + + const longUrl = new URL(THREADS_LONG_LIVED_TOKEN_URL); + longUrl.searchParams.set("grant_type", "th_exchange_token"); + longUrl.searchParams.set("client_secret", clientSecret); + longUrl.searchParams.set("access_token", accessToken); + + const longRes = await fetch(longUrl); + const longData = await longRes.json(); + if (!longRes.ok || longData.error) { + return NextResponse.json(longData, { status: longRes.status || 400 }); + } + + return NextResponse.json({ success: true, ...longData }); +} diff --git a/src/app/billboard/page.tsx b/src/app/billboard/page.tsx index 11d9324..55c8060 100644 --- a/src/app/billboard/page.tsx +++ b/src/app/billboard/page.tsx @@ -128,7 +128,7 @@ export default function BillboardPage() {

Post your message

{!session ? (

- Sign in to post to the Billboard. + Sign in to post to the Billboard.

) : ( <> diff --git a/src/app/cards/page.tsx b/src/app/cards/page.tsx index 424548d..b970d32 100644 --- a/src/app/cards/page.tsx +++ b/src/app/cards/page.tsx @@ -171,7 +171,7 @@ export default function CardsPage() { ) : (
-

Join Democracy Rising to mint your supporter card.

+

Join Democracy Rising to mint your supporter card.

)} diff --git a/src/app/casino/[game]/page.tsx b/src/app/casino/[game]/page.tsx index 1c73b96..e9ff7e7 100644 --- a/src/app/casino/[game]/page.tsx +++ b/src/app/casino/[game]/page.tsx @@ -63,12 +63,20 @@ export default async function GamePage({ params }: { params: Promise<{ game: str <>
-
- - ← Casino +
+
+ + ← Casino + + / + {meta.name} +
+ + Open wallet → - / - {meta.name}
diff --git a/src/app/casino/page.tsx b/src/app/casino/page.tsx index 6d420e4..eaca6c2 100644 --- a/src/app/casino/page.tsx +++ b/src/app/casino/page.tsx @@ -6,6 +6,7 @@ import { creditDisplayName, creditTicker } from "@/lib/credits-brand"; import { SiteFooter } from "@/components/SiteFooter"; import { ExchangePanel } from "@/components/casino/ExchangePanel"; import { GameHistory } from "@/components/casino/GameHistory"; +import { ActiveGamesBanner } from "@/components/casino/ActiveGamesBanner"; const T = creditTicker(); const CREDIT_LONG = creditDisplayName(); @@ -53,6 +54,8 @@ export default async function CasinoLobby() {

+ +
{/* Exchange Panel + History */}
diff --git a/src/app/datadeletion/page.tsx b/src/app/datadeletion/page.tsx new file mode 100644 index 0000000..302f72e --- /dev/null +++ b/src/app/datadeletion/page.tsx @@ -0,0 +1,124 @@ +import { LegalPageLayout, LegalSection } from "@/components/LegalPageLayout"; +import { legalContactEmail } from "@/lib/legal-contact"; +import { appTitle, siteUrl } from "@/lib/public-env"; +import type { Metadata } from "next"; +import Link from "next/link"; + +const LAST_UPDATED = "May 19, 2026"; + +export const metadata: Metadata = { + title: "Data Deletion", + description: `How to request deletion of personal data from ${appTitle()}.`, + alternates: { canonical: "/datadeletion" }, + robots: { index: true, follow: true }, +}; + +export default function DataDeletionPage() { + const site = appTitle(); + const url = siteUrl(); + const email = legalContactEmail(); + + return ( + + +

+ We respect your right to control your personal information. You may request deletion of account-related data we hold, + subject to exceptions described below. For broader information about how we handle data, see our{" "} + + Privacy Policy + + . +

+
+ + +

Send an email to:

+

+ {email} +

+

Include the following so we can locate your records:

+
    +
  • Subject line: "Data deletion request"
  • +
  • The email address associated with your account (if any)
  • +
  • Your full name as shown on the account
  • +
  • A brief description of what you want deleted (account, profile, activity history, etc.)
  • +
+

+ If you signed in with a third-party provider, mention that provider and the email or identifier linked to your account. +

+
+ + +

When your request is verified and approved, we will delete or anonymize, where feasible:

+
    +
  • Account profile information (name, email, preferences);
  • +
  • Supporter wallet and in-platform activity tied to your user ID;
  • +
  • Game play history and non-financial engagement logs linked to your account;
  • +
  • Other personal data stored in our systems that is not subject to a retention exception.
  • +
+
+ + +

+ Some information may be retained when required or permitted by law, including for fraud prevention, security, + dispute resolution, and compliance with legal or regulatory obligations (for example campaign finance record-keeping + for processed contributions). Retained data is limited to what is necessary and protected appropriately. +

+

+ Payment processors may also retain transaction records under their own policies; contact them directly for + processor-held data where applicable. +

+
+ + +

+ We aim to acknowledge requests within a reasonable period and complete verified deletion within approximately 30 days, + unless a longer period is required by law or the complexity of the request. We will inform you if we need additional + time or information. +

+
+ + +

+ To protect your privacy, we may ask you to verify ownership of the account (for example by replying from the registered + email address or providing information only the account holder would know). We will not fulfill deletion requests that + we cannot reasonably verify. +

+
+ + +

+ If the site offers in-app account closure, you may use that feature for immediate deactivation. You may still email us + to confirm complete deletion of remaining personal data. +

+
+ + +

+ If you connected this service through a third-party login or platform, you may also need to revoke access or delete + data held by that third party through their own settings. +

+
+ + +

+ For privacy questions or to appeal a decision regarding your request, contact{" "} + + {email} + + . +

+

+ {site} ·{" "} + + {url} + +

+
+
+ ); +} diff --git a/src/app/privacy-policy/page.tsx b/src/app/privacy-policy/page.tsx new file mode 100644 index 0000000..38ef581 --- /dev/null +++ b/src/app/privacy-policy/page.tsx @@ -0,0 +1,165 @@ +import { LegalPageLayout, LegalSection } from "@/components/LegalPageLayout"; +import { legalContactEmail } from "@/lib/legal-contact"; +import { appTitle, siteUrl } from "@/lib/public-env"; +import type { Metadata } from "next"; +import Link from "next/link"; + +const LAST_UPDATED = "May 19, 2026"; + +export const metadata: Metadata = { + title: "Privacy Policy", + description: `How ${appTitle()} collects, uses, and protects personal information.`, + alternates: { canonical: "/privacy-policy" }, + robots: { index: true, follow: true }, +}; + +export default function PrivacyPolicyPage() { + const site = appTitle(); + const url = siteUrl(); + const email = legalContactEmail(); + + return ( + + +

+ This Privacy Policy applies to visitors and registered users of our website and related online services. By using the + site, you agree to the practices described here. If you do not agree, please discontinue use. +

+
+ + +

Depending on how you interact with the site, we may collect:

+
    +
  • + Account information — such as name, email address, and credentials you + provide when registering or signing in. +
  • +
  • + Transaction information — such as donation amounts, payment status, and + records required to process contributions through our payment processor. +
  • +
  • + Usage information — such as pages viewed, features used, device type, + browser, IP address, and approximate location derived from IP. +
  • +
  • + Communications — such as messages you send to us for support or account + assistance. +
  • +
+
+ + +

We use collected information to:

+
    +
  • Operate, secure, and improve the website and its features;
  • +
  • Process donations and maintain supporter accounts;
  • +
  • Comply with legal, regulatory, and record-keeping obligations;
  • +
  • Respond to inquiries and provide customer support;
  • +
  • Detect fraud, abuse, and unauthorized access;
  • +
  • Send service-related notices where permitted.
  • +
+
+ + +

+ Where privacy laws require a legal basis, we rely on one or more of: performance of a contract, legitimate interests + (such as security and service improvement), compliance with legal obligations, and consent where required. +

+
+ + +

+ We use trusted third parties to help run the site — for example payment processing, hosting, analytics, and email + delivery. These providers process data only on our instructions and are expected to protect it consistent with this + policy and applicable law. +

+

We do not sell your personal information.

+
+ + +

+ We may use cookies, local storage, and similar technologies for authentication, preferences, security, and basic + analytics. You can control cookies through your browser settings; disabling some cookies may limit site functionality. +

+
+ + +

+ We retain personal information only as long as needed for the purposes described in this policy, including legal, + accounting, and compliance requirements. Retention periods may vary by data type and obligation. +

+

+ To request deletion of personal data associated with your account, see our{" "} + + Data Deletion + {" "} + page. +

+
+ + +

+ Depending on your location, you may have rights to access, correct, delete, restrict, or port your personal information, + and to object to certain processing. You may also withdraw consent where processing is consent-based. +

+

+ To exercise these rights, contact us at{" "} + + {email} + + . We may need to verify your identity before fulfilling a request. +

+
+ + +

+ We implement reasonable administrative, technical, and organizational measures designed to protect personal + information. No method of transmission or storage is completely secure; we cannot guarantee absolute security. +

+
+ + +

+ Our services are not directed to children under 13 (or the minimum age required in your jurisdiction). We do not + knowingly collect personal information from children. If you believe we have done so, contact us and we will take + appropriate steps. +

+
+ + +

+ If you access the site from outside the United States, your information may be processed in the United States or other + locations where our service providers operate. Applicable laws in your region may provide additional rights. +

+
+ + +

+ We may update this Privacy Policy from time to time. The "Last updated" date at the top reflects the latest + revision. Material changes may be communicated through the site or other reasonable means. +

+
+ + +

+ Questions about this policy or our privacy practices:{" "} + + {email} + + . +

+

+ Operator: {site} · Website:{" "} + + {url} + +

+
+
+ ); +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 046976f..6c4f981 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -36,6 +36,18 @@ export default function sitemap(): MetadataRoute.Sitemap { changeFrequency: "yearly", priority: 0.3, }, + { + url: `${base}/privacy-policy`, + lastModified: now, + changeFrequency: "yearly", + priority: 0.4, + }, + { + url: `${base}/datadeletion`, + lastModified: now, + changeFrequency: "yearly", + priority: 0.4, + }, { url: `${base}/donate`, lastModified: now, diff --git a/src/app/spotlight/page.tsx b/src/app/spotlight/page.tsx index c194981..dd40ace 100644 --- a/src/app/spotlight/page.tsx +++ b/src/app/spotlight/page.tsx @@ -144,7 +144,7 @@ export default function SpotlightPage() {

Place your bid

{!session ? ( -

Sign in to bid.

+

Sign in to bid.

) : ( <> {catalog.length === 0 ? ( diff --git a/src/app/threads-callback/ThreadsCallbackClient.tsx b/src/app/threads-callback/ThreadsCallbackClient.tsx new file mode 100644 index 0000000..b9a4079 --- /dev/null +++ b/src/app/threads-callback/ThreadsCallbackClient.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; + +export function ThreadsCallbackClient() { + const searchParams = useSearchParams(); + const [result, setResult] = useState("Processing..."); + + const code = useMemo(() => searchParams.get("code"), [searchParams]); + + useEffect(() => { + if (!code) { + setResult("Waiting for Threads authorization code..."); + return; + } + + const controller = new AbortController(); + + async function exchangeCode() { + try { + setResult("Processing..."); + const response = await fetch("/api/threads-exchange", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code }), + signal: controller.signal, + }); + const data = await response.json(); + setResult(JSON.stringify(data, null, 2)); + } catch (error) { + if (controller.signal.aborted) return; + const message = error instanceof Error ? error.message : "Unknown error"; + setResult(`Error: ${message}`); + } + } + + exchangeCode(); + + return () => controller.abort(); + }, [code]); + + return ( +
+      {result}
+    
+ ); +} diff --git a/src/app/threads-callback/page.tsx b/src/app/threads-callback/page.tsx new file mode 100644 index 0000000..98eb29e --- /dev/null +++ b/src/app/threads-callback/page.tsx @@ -0,0 +1,26 @@ +import { Suspense } from "react"; +import type { Metadata } from "next"; +import { ThreadsCallbackClient } from "./ThreadsCallbackClient"; + +export const metadata: Metadata = { + title: "Threads Callback", + robots: { index: false, follow: false }, +}; + +export default function ThreadsCallbackPage() { + return ( +
+

Threads OAuth

+

Threads callback

+ + Processing... + + } + > + + +
+ ); +} diff --git a/src/app/wallet/WalletActions.tsx b/src/app/wallet/WalletActions.tsx index dc8f200..b01df36 100644 --- a/src/app/wallet/WalletActions.tsx +++ b/src/app/wallet/WalletActions.tsx @@ -74,6 +74,21 @@ export function WalletActions({ setInfiniteCredits(!!data.infiniteCredits); }, []); // setters from useState are stable — no deps needed + // Keep this view in sync with games, donates, and other wallet UIs via the + // shared `wallet:refresh` event bus + tab visibility. + useEffect(() => { + const onCustom = () => void refreshBalance(); + const onVisibility = () => { + if (document.visibilityState === "visible") void refreshBalance(); + }; + window.addEventListener("wallet:refresh", onCustom); + document.addEventListener("visibilitychange", onVisibility); + return () => { + window.removeEventListener("wallet:refresh", onCustom); + document.removeEventListener("visibilitychange", onVisibility); + }; + }, [refreshBalance]); + // Supporter credit spot index (USD per credit unit) useEffect(() => { if (infiniteCredits) return; diff --git a/src/app/wallet/WalletDashboard.tsx b/src/app/wallet/WalletDashboard.tsx index e762dee..07f0338 100644 --- a/src/app/wallet/WalletDashboard.tsx +++ b/src/app/wallet/WalletDashboard.tsx @@ -31,6 +31,14 @@ const SPEND_DESTINATIONS = [ const CHART_COLORS = ["#38bdf8", "#a78bfa", "#34d399", "#f472b6", "#fbbf24", "#fb7185", "#818cf8"]; +function supporterTier(earned: number): { label: string; note: string } { + if (earned >= 10_000) return { label: "Movement Whale", note: "Top-tier capital steering power" }; + if (earned >= 2_500) return { label: "Coalition Captain", note: "High-impact recurring backer" }; + if (earned >= 750) return { label: "Civic Builder", note: "Actively shaping priorities" }; + if (earned >= 100) return { label: "Verified Supporter", note: "Wallet is active and growing" }; + return { label: "Fresh Wallet", note: "First credits are the hardest" }; +} + function ImpactBar({ label, pct, credits, count, color }: { label: string; pct: number; credits: number; count: number; color: string }) { return (
@@ -101,6 +109,19 @@ export function WalletDashboard() { const spendTotal = summary.spent || 1; const initiativePct = summary.spent > 0 ? Math.round((summary.initiativePledges / spendTotal) * 100) : 0; const missionPct = summary.spent > 0 ? Math.round((summary.missionPledges / spendTotal) * 100) : 0; + const tier = supporterTier(summary.earned); + const utilizationPct = summary.earned > 0 ? Math.min(100, Math.round((summary.spent / summary.earned) * 100)) : 0; + + const netFlowBars = useMemo(() => { + const map = new Map(); + for (const item of summary.recentActivity) { + const day = item.at.slice(5, 10); + map.set(day, (map.get(day) ?? 0) + item.delta); + } + const items = [...map.entries()].slice(-10).map(([day, delta]) => ({ day, delta })); + const maxAbs = Math.max(1, ...items.map((i) => Math.abs(i.delta))); + return { items, maxAbs }; + }, [summary.recentActivity]); const sparkPath = historyPoints.length < 2 @@ -122,6 +143,9 @@ export function WalletDashboard() { missionPct={missionPct} sparkPath={sparkPath} maxSpend={maxSpend} + tier={tier} + utilizationPct={utilizationPct} + netFlowBars={netFlowBars} /> ); } @@ -134,8 +158,11 @@ function WalletDashboardView(props: { missionPct: number; sparkPath: string; maxSpend: number; + tier: { label: string; note: string }; + utilizationPct: number; + netFlowBars: { items: { day: string; delta: number }[]; maxAbs: number }; }) { - const { summary, t, indexUsd, initiativePct, missionPct, sparkPath, maxSpend } = props; + const { summary, t, indexUsd, initiativePct, missionPct, sparkPath, maxSpend, tier, utilizationPct, netFlowBars } = props; return (
@@ -192,6 +219,55 @@ function WalletDashboardView(props: {
+
+

Token profile

+

{tier.label}

+

{tier.note}

+
+
+

Total earned

+

+{summary.earned.toLocaleString()} {t}

+
+
+

Utilization

+

{utilizationPct}%

+
+
+
+
+
+

How much of your earned {t} is already deployed into movement actions.

+
+ +
+

Personal net flow (recent)

+ {netFlowBars.items.length === 0 ? ( +

No flow yet — your first transactions will render here.

+ ) : ( +
+ {netFlowBars.items.map((bar) => { + const width = Math.round((Math.abs(bar.delta) / netFlowBars.maxAbs) * 100); + const positive = bar.delta >= 0; + return ( +
+ {bar.day} +
+
+
+ + {positive ? "+" : ""} + {bar.delta.toLocaleString()} + +
+ ); + })} +
+ )} +
+

Where you've spent {t}

{summary.spendBreakdown.length === 0 ? ( diff --git a/src/app/wallet/page.tsx b/src/app/wallet/page.tsx index b9a4f36..7aa70bd 100644 --- a/src/app/wallet/page.tsx +++ b/src/app/wallet/page.tsx @@ -9,6 +9,7 @@ import { Suspense } from "react"; import { WalletActions } from "./WalletActions"; import { WalletDashboard } from "./WalletDashboard"; import { WalletGuestView } from "./WalletGuestView"; +import { ActiveGamesBanner } from "@/components/casino/ActiveGamesBanner"; export const metadata: Metadata = { title: `Supporter wallet — ${appTitle()}`, @@ -44,6 +45,7 @@ export default async function WalletPage() {
+

Perks & ledger

diff --git a/src/auth.ts b/src/auth.ts index 72b77c1..56c5110 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -53,6 +53,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ id: user.id, email: user.email, name: user.name ?? undefined, + username: user.username, role: user.role, }; }, @@ -62,14 +63,18 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ jwt({ token, user }) { if (user) { token.id = user.id; - token.role = (user as { role: string }).role; + if (user.username) token.username = user.username; + const role = (user as { role?: string }).role; + token.role = role === "ADMIN" || role === "USER" ? role : "USER"; } return token; }, session({ session, token }) { if (session.user) { session.user.id = (token.id as string) ?? (token.sub as string); - session.user.role = (token.role as "USER" | "ADMIN") ?? "USER"; + if (typeof token.username === "string") session.user.username = token.username; + const role = token.role; + session.user.role = role === "ADMIN" || role === "USER" ? role : "USER"; } return session; }, diff --git a/src/components/LegalPageLayout.tsx b/src/components/LegalPageLayout.tsx new file mode 100644 index 0000000..a374c26 --- /dev/null +++ b/src/components/LegalPageLayout.tsx @@ -0,0 +1,64 @@ +import Link from "next/link"; +import { SiteFooter } from "@/components/SiteFooter"; +import { appTitle } from "@/lib/public-env"; +import type { ReactNode } from "react"; + +type LegalPageLayoutProps = { + title: string; + description: string; + lastUpdated: string; + children: ReactNode; +}; + +export function LegalPageLayout({ title, description, lastUpdated, children }: LegalPageLayoutProps) { + const site = appTitle(); + + return ( + <> +
+

Legal

+

{title}

+

Last updated: {lastUpdated}

+

{description}

+ +
{children}
+ + + +

+ This document is provided for general informational purposes only and does not constitute legal advice. Replace + placeholders and have qualified counsel review before production use. +

+
+ + + ); +} + +export function LegalSection({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+
{children}
+
+ ); +} diff --git a/src/components/NavWalletBalance.tsx b/src/components/NavWalletBalance.tsx new file mode 100644 index 0000000..36367a9 --- /dev/null +++ b/src/components/NavWalletBalance.tsx @@ -0,0 +1,95 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { creditTicker } from "@/lib/credits-brand"; + +const T = creditTicker(); + +interface Props { + initialBalance: number; + initialInfinite?: boolean; + /** Optional className lets the nav pick tighter spacing on mobile. */ + className?: string; +} + +/** + * Live wallet pill mounted in the top nav. + * + * Refresh strategy: + * - 15 s polling (cheap; same cadence as the per-game `useLiveWalletBalance`) + * - Cross-tab + cross-component instant sync via the `wallet:refresh` custom + * event on `window`. Any client code can call + * `window.dispatchEvent(new Event("wallet:refresh"))` to force a re-fetch + * (the wallet hook does this automatically after every game action). + * - Tab visibility — refetch on tab focus so balance is fresh. + */ +export function NavWalletBalance({ initialBalance, initialInfinite = false, className = "" }: Props) { + const [balance, setBalance] = useState(initialBalance); + const [infinite, setInfinite] = useState(initialInfinite); + const [flash, setFlash] = useState<"up" | "down" | null>(null); + const lastBalanceRef = useRef(initialBalance); + + const fetchBalance = useCallback(async () => { + try { + const res = await fetch("/api/wallet", { cache: "no-store" }); + if (!res.ok) return; + const data = await res.json(); + const next = typeof data.balanceCredits === "number" ? data.balanceCredits : 0; + const isInf = !!data.infiniteCredits; + setInfinite(isInf); + if (!isInf) { + setBalance((prev) => { + if (next !== prev) { + const dir = next > lastBalanceRef.current ? "up" : "down"; + setFlash(dir); + window.setTimeout(() => setFlash(null), 700); + lastBalanceRef.current = next; + } + return next; + }); + } + } catch { + /* ignore — keep last known */ + } + }, []); + + useEffect(() => { + void fetchBalance(); + const id = window.setInterval(() => void fetchBalance(), 15_000); + const onCustom = () => void fetchBalance(); + const onVisibility = () => { if (document.visibilityState === "visible") void fetchBalance(); }; + window.addEventListener("wallet:refresh", onCustom); + document.addEventListener("visibilitychange", onVisibility); + return () => { + window.clearInterval(id); + window.removeEventListener("wallet:refresh", onCustom); + document.removeEventListener("visibilitychange", onVisibility); + }; + }, [fetchBalance]); + + const display = infinite + ? "∞" + : balance.toLocaleString(undefined, { maximumFractionDigits: 0 }); + + const flashClass = + flash === "up" + ? "ring-1 ring-emerald-400/60 bg-emerald-500/15 text-emerald-100" + : flash === "down" + ? "ring-1 ring-rose-400/60 bg-rose-500/15 text-rose-100" + : "border border-white/10 bg-white/5 text-sky-100"; + + return ( + + + ◆ + + {display} + {T} + + ); +} diff --git a/src/components/SiteFooter.tsx b/src/components/SiteFooter.tsx index 79f97fd..85fec8f 100644 --- a/src/components/SiteFooter.tsx +++ b/src/components/SiteFooter.tsx @@ -98,6 +98,16 @@ export function SiteFooter() { FAQ +
  • + + Privacy Policy + +
  • +
  • + + Data Deletion + +
  • diff --git a/src/components/SiteNav.tsx b/src/components/SiteNav.tsx index 321b07c..9f62b0c 100644 --- a/src/components/SiteNav.tsx +++ b/src/components/SiteNav.tsx @@ -1,7 +1,10 @@ import Link from "next/link"; import { auth } from "@/auth"; import { appTitle } from "@/lib/public-env"; +import { prisma } from "@/lib/prisma"; +import { ADMIN_WALLET_DISPLAY, isAdminRole } from "@/lib/admin"; import { MobileNav } from "./MobileNav"; +import { NavWalletBalance } from "./NavWalletBalance"; const primaryLinks = [ { href: "/raised", label: "Raised" }, @@ -15,6 +18,24 @@ export async function SiteNav() { const session = await auth(); const title = appTitle(); + // Fetch username + balance in a single query for signed-in users so the nav + // can render the live wallet pill with a correct server-side initial value. + let username: string | null = null; + let initialBalance = 0; + let initialInfinite = false; + if (session?.user?.id) { + const [dbUser, wallet] = await Promise.all([ + prisma.user.findUnique({ + where: { id: session.user.id }, + select: { username: true, role: true }, + }), + prisma.wallet.findUnique({ where: { userId: session.user.id } }), + ]); + username = session.user.username ?? dbUser?.username ?? null; + initialInfinite = isAdminRole(dbUser?.role ?? session.user.role); + initialBalance = initialInfinite ? ADMIN_WALLET_DISPLAY : wallet?.balanceCredits ?? 0; + } + return (