Files
democratic-money/server.ts
root 7eac42e820 Legal pages, Threads OAuth, live wallet pill, PvP money flow + resume
- Legal: /privacy-policy and /datadeletion (shared LegalPageLayout, footer
  links, sitemap + integration tests, NEXT_PUBLIC_LEGAL_CONTACT_EMAIL).
- Threads OAuth: server-side /api/threads-exchange and /threads-callback
  page (Suspense + client component, noindex, no leaked secrets).
- Username + live wallet pill in top nav. New NavWalletBalance component
  polls /api/wallet, refreshes on tab focus, and listens to the
  `wallet:refresh` event bus so cash-outs and refunds update the nav in
  real time. Flash animation on balance changes.
- useLiveWalletBalance hook now broadcasts `wallet:refresh` after every
  fetch so games, exchange panel, wallet actions, and nav all stay in
  sync without extra polling.
- PvP fund-locking (`POST /api/games/rooms`): creator funds debited
  atomically with room creation; ledger entry tagged with `gameRoomId`.
  Joiner debit happens at join. Old double-debit of the creator is gone.
- DELETE /api/games/rooms?id=... lets a creator cancel a WAITING room
  and get an idempotent refund. Coin Flip + Pong waiting screens show a
  Cancel & Refund button.
- Pong/Coin Flip recovery: expiry sweep + boot-time `recoverOrphaned
  RoomsOnBoot()` (runs before listen()) refund both parties for any
  ACTIVE/expired rooms so a server restart never strands locked credits.
- Schema migration `20260520000000_game_ledger_links` adds optional
  `gameSessionId` + `gameRoomId` FKs to LedgerEntry (with indexes) and
  extra indexes on GameSession/GameRoom for resume + sweep queries.
- GET /api/games/active returns a user's active solo session + open
  rooms (sanitized — no mine/bomb positions). Mines and Tower clients
  rehydrate on mount so a refresh mid-round resumes instead of dropping.
- ActiveGamesBanner surfaces unfinished rounds on /wallet and /casino
  with Resume / Rejoin / Cancel & refund actions.
- ExchangePanel unified with useLiveWalletBalance; per-game header gets
  an "Open wallet →" chip; Dice clears stale result on roll; Blackjack
  reveals full dealer hand on natural blackjack/push; Mines refund
  label fixed; Tower final multiplier fixed; GameHistory error path;
  Prediction "Resolved" tab.
- Site audit + redmeFIXES triage notes (REDME-FIXSES-TRIAGE.txt,
  SITE-AUDIT.txt).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 06:43:26 +00:00

322 lines
12 KiB
TypeScript

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,
refundRoomPartyIfNotRefunded,
} from "./src/lib/game-ledger";
import { generateServerSeed, deriveInt } from "./src/lib/provably-fair";
const dev = process.env.NODE_ENV !== "production";
const app = next({ dev, hostname: "0.0.0.0", port: 8008 });
const handle = app.getRequestHandler();
const HOUSE_CUT = 0.05; // 5% on PvP
// CORS: restrict to configured site URL only — never wildcard in production.
const allowedOrigin =
process.env.NEXT_PUBLIC_SITE_URL ??
process.env.AUTH_URL ??
"http://127.0.0.1:8008";
app.prepare().then(() => {
const httpServer = createServer((req, res) => {
const parsedUrl = parse(req.url ?? "/", true);
handle(req, res, parsedUrl);
});
const io = new SocketIOServer(httpServer, {
cors: { origin: allowedOrigin, methods: ["GET", "POST"] },
path: "/api/socket",
});
// ── COIN FLIP ──────────────────────────────────────────────────────────────
const coinFlipNS = io.of("/coin-flip");
async function resolveSocketUserId(socket: { request: unknown }): Promise<string | null> {
try {
const token = await getToken({
req: socket.request as Parameters<typeof getToken>[0]["req"],
secret: process.env.AUTH_SECRET,
});
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") {
socket.emit("error", "Room not available");
return;
}
// Creator just reconnects/waits — nothing to debit.
if (room.creatorId === userId) {
socket.join(roomId);
socket.emit("waiting", { roomId });
return;
}
// Atomically claim the joiner slot — prevents concurrent joiners racing
// on the same WAITING room.
const claimed = await prisma.gameRoom.updateMany({
where: { id: roomId, status: "WAITING", joinerId: null },
data: { joinerId: userId, status: "ACTIVE" },
});
if (claimed.count !== 1) {
socket.emit("error", "Room not available");
return;
}
// 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;
}
socket.join(roomId);
coinFlipNS.to(roomId).emit("game_start", { roomId });
// Resolve — provably fair flip.
const serverSeed = generateServerSeed();
const result = deriveInt(serverSeed, roomId, 0, 0, 1); // 0 = heads, 1 = tails
const winnerId = result === 0 ? room.creatorId : userId;
const pot = room.wageBLW * 2;
const payout = Math.floor(pot * (1 - HOUSE_CUT));
try {
await creditForWin(winnerId, payout, "COIN_FLIP", undefined, { gameRoomId: roomId });
await prisma.gameRoom.update({
where: { id: roomId },
data: { status: "RESOLVED", resultData: { result, winnerId, serverSeed, payout } },
});
} catch (err) {
console.error("[coin-flip] payout/resolve failed for room", roomId, err);
// Room stuck as ACTIVE — needs manual reconciliation; we still emit result.
}
coinFlipNS.to(roomId).emit("result", {
result,
resultLabel: result === 0 ? "heads" : "tails",
winnerId,
payout,
serverSeed,
});
});
});
// ── PONG ───────────────────────────────────────────────────────────────────
const pongNS = io.of("/pong");
const pongRooms = new Map<string, {
roomId: string;
creatorId: string;
joinerId: string | null;
wageBLW: number;
ball: { x: number; y: number; vx: number; vy: number };
paddles: { creator: number; joiner: number };
scores: { creator: number; joiner: number };
interval: ReturnType<typeof setInterval> | null;
}>();
pongNS.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; }
socket.data.userId = userId;
const room = await prisma.gameRoom.findUnique({ where: { id: roomId } });
if (!room || !["WAITING", "ACTIVE"].includes(room.status)) {
socket.emit("error", "Room not available");
return;
}
socket.join(roomId);
if (room.creatorId === userId) {
if (!pongRooms.has(roomId)) {
pongRooms.set(roomId, {
roomId,
creatorId: room.creatorId, // always from DB, not client
joinerId: null,
wageBLW: room.wageBLW,
ball: { x: 400, y: 200, vx: 3, vy: 2 },
paddles: { creator: 180, joiner: 180 },
scores: { creator: 0, joiner: 0 },
interval: null,
});
}
socket.emit("waiting", { roomId });
return;
}
// Joiner — in-memory race guard (single-process; DB is source of truth).
const state = pongRooms.get(roomId);
if (!state || state.joinerId) { socket.emit("error", "Room full"); return; }
// 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;
}
// 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;
pongNS.to(roomId).emit("game_start", { roomId, creatorId: room.creatorId, joinerId: userId });
// Game loop — 20 ticks/sec.
state.interval = setInterval(() => {
const s = pongRooms.get(roomId);
if (!s) return;
s.ball.x += s.ball.vx;
s.ball.y += s.ball.vy;
if (s.ball.y <= 0 || s.ball.y >= 400) s.ball.vy *= -1;
if (s.ball.x <= 20 && Math.abs(s.ball.y - s.paddles.creator) < 50) s.ball.vx = Math.abs(s.ball.vx);
if (s.ball.x >= 780 && Math.abs(s.ball.y - s.paddles.joiner) < 50) s.ball.vx = -Math.abs(s.ball.vx);
if (s.ball.x <= 0) {
s.scores.joiner++;
s.ball = { x: 400, y: 200, vx: 3, vy: 2 };
}
if (s.ball.x >= 800) {
s.scores.creator++;
s.ball = { x: 400, y: 200, vx: -3, vy: 2 };
}
pongNS.to(roomId).emit("tick", { ball: s.ball, paddles: s.paddles, scores: s.scores });
if (s.scores.creator >= 5 || s.scores.joiner >= 5) {
clearInterval(s.interval!);
s.interval = null;
const winnerId = s.scores.creator >= 5 ? s.creatorId : s.joinerId!;
const pot = s.wageBLW * 2;
const payout = Math.floor(pot * (1 - HOUSE_CUT));
const finalScores = { ...s.scores };
pongRooms.delete(roomId);
// Await payout + DB update before broadcasting — errors are logged,
// not silently swallowed.
void (async () => {
try {
await creditForWin(winnerId, payout, "PONG", undefined, { gameRoomId: roomId });
await prisma.gameRoom.update({
where: { id: roomId },
data: { status: "RESOLVED", resultData: { winnerId, scores: finalScores, payout } },
});
} catch (err) {
console.error("[pong] payout/resolve failed for room", roomId, err);
}
pongNS.to(roomId).emit("game_over", { winnerId, scores: finalScores, payout });
})();
}
}, 50);
});
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 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() } },
select: { id: true, creatorId: true, wageBLW: true, gameType: true },
});
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);
// ── 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`);
});
});
});