Fix all critical/high/medium/low audit items

Critical – money integrity:
- server.ts: restrict Socket.IO CORS to NEXT_PUBLIC_SITE_URL (remove origin:"*")
- server.ts: validate userId against DB on join_room before any debit/credit
- server.ts: atomic coin-flip joiner claim via updateMany(where:{status:WAITING,joinerId:null})
- server.ts: await pong game_over payout+room update before emitting result; log errors
- prediction/route.ts: wrap market resolve + all payouts in one Prisma transaction;
  concurrent PATCH returns 409; dust remainder credited to first winner

High – data truth:
- raised/page.tsx, leaderboard/route.ts, cards/route.ts: add status:"succeeded"
  filter to all donation aggregations
- polls/next-president/route.ts: replace full in-memory row scan with DB-side
  groupBy + bounded 14-day window for daily activity chart

Medium – ops:
- faq-board/page.tsx: add admin approve/reject tab (visible to ADMIN role)

Low – UX/consistency:
- faq-board, billboard, spotlight, cards: replace hardcoded "BWT" with creditTicker()
- faq-board: read submitCost/voteCost from API response instead of hardcoded values
- WalletActions.tsx: make refreshBalance stable with useCallback; remove
  eslint-disable exhaustive-deps suppression

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root
2026-05-16 00:56:13 +00:00
parent 391d90a754
commit c6a7b2e08d
11 changed files with 313 additions and 133 deletions

114
server.ts
View File

@@ -14,6 +14,12 @@ 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);
@@ -21,7 +27,7 @@ app.prepare().then(() => {
});
const io = new SocketIOServer(httpServer, {
cors: { origin: "*" },
cors: { origin: allowedOrigin, methods: ["GET", "POST"] },
path: "/api/socket",
});
@@ -30,65 +36,88 @@ app.prepare().then(() => {
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 },
});
if (!userExists) { 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;
}
// Check balance
// 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;
}
// 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" },
});
socket.emit("error", "Insufficient balance");
return;
}
// Debit joiner
// 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
// 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;
}
await prisma.gameRoom.update({
where: { id: roomId },
data: { joinerId: userId, status: "ACTIVE" },
});
socket.join(roomId);
coinFlipNS.to(roomId).emit("game_start", { roomId });
// Resolve
// 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));
await creditForWin(winnerId, payout, "COIN_FLIP");
await prisma.gameRoom.update({
where: { id: roomId },
data: {
status: "RESOLVED",
resultData: { result, winnerId, serverSeed, payout },
},
});
try {
await creditForWin(winnerId, payout, "COIN_FLIP");
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,
@@ -115,6 +144,13 @@ 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; }
const room = await prisma.gameRoom.findUnique({ where: { id: roomId } });
if (!room || !["WAITING", "ACTIVE"].includes(room.status)) {
socket.emit("error", "Room not available");
@@ -127,7 +163,7 @@ app.prepare().then(() => {
if (!pongRooms.has(roomId)) {
pongRooms.set(roomId, {
roomId,
creatorId: userId,
creatorId: room.creatorId, // always from DB, not client
joinerId: null,
wageBLW: room.wageBLW,
ball: { x: 400, y: 200, vx: 3, vy: 2 },
@@ -140,12 +176,14 @@ app.prepare().then(() => {
return;
}
// Joiner
// 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; }
const wallet = await prisma.wallet.findUnique({ where: { userId } });
if (!wallet || wallet.balanceCredits < room.wageBLW) { socket.emit("error", "Insufficient balance"); return; }
if (!wallet || wallet.balanceCredits < room.wageBLW) {
socket.emit("error", "Insufficient balance"); return;
}
try {
await debitForBet(userId, room.wageBLW, "PONG");
@@ -159,7 +197,7 @@ app.prepare().then(() => {
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
// Game loop — 20 ticks/sec.
state.interval = setInterval(() => {
const s = pongRooms.get(roomId);
if (!s) return;
@@ -167,14 +205,10 @@ app.prepare().then(() => {
s.ball.x += s.ball.vx;
s.ball.y += s.ball.vy;
// Wall bounce
if (s.ball.y <= 0 || s.ball.y >= 400) s.ball.vy *= -1;
// Paddle bounce
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);
// Score
if (s.ball.x <= 0) {
s.scores.joiner++;
s.ball = { x: 400, y: 200, vx: 3, vy: 2 };
@@ -186,21 +220,29 @@ app.prepare().then(() => {
pongNS.to(roomId).emit("tick", { ball: s.ball, paddles: s.paddles, scores: s.scores });
// Win condition: first to 5
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));
creditForWin(winnerId, payout, "PONG").then(() => {
prisma.gameRoom.update({
where: { id: roomId },
data: { status: "RESOLVED", resultData: { winnerId, scores: s.scores, payout } },
}).catch(() => {});
}).catch(() => {});
pongNS.to(roomId).emit("game_over", { winnerId, scores: s.scores, payout });
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");
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);
});
@@ -214,7 +256,7 @@ app.prepare().then(() => {
});
});
// Expire abandoned rooms every minute
// Expire abandoned rooms every minute.
setInterval(async () => {
await prisma.gameRoom.updateMany({
where: { status: "WAITING", expiresAt: { lt: new Date() } },