import type { ServerWebSocket } from "bun"; import { timingSafeEqual } from "node:crypto"; import indexHtml from "./index.html"; import historyHtml from "./history.html"; import adminHtml from "./admin.html"; import broadcastHtml from "./broadcast.html"; import preguntaHtml from "./pregunta.html"; import { clearAllRounds, getRounds, getAllRounds, createPendingQuestion, markQuestionPaid, createPaidQuestion, createPendingCredit, activateCredit, getCreditByOrder, validateCreditToken, getPlayerScores, } from "./db.ts"; import { buildPaymentForm, verifyNotification, decodeParams, isPaymentApproved } from "./redsys.ts"; import { MODELS, LOG_FILE, log, runGame, type GameState, type RoundState, } from "./game.ts"; const VERSION = crypto.randomUUID().slice(0, 8); // ── Game state ────────────────────────────────────────────────────────────── const runsArg = process.argv.find((a) => a.startsWith("runs=")); const runsStr = runsArg ? runsArg.split("=")[1] : "infinite"; const runs = runsStr === "infinite" ? Infinity : parseInt(runsStr || "infinite", 10); if (!process.env.OPENROUTER_API_KEY) { console.error("Error: Set OPENROUTER_API_KEY environment variable"); process.exit(1); } const allRounds = getAllRounds(); const initialScores = Object.fromEntries(MODELS.map((m) => [m.name, 0])); const initialViewerScores = Object.fromEntries(MODELS.map((m) => [m.name, 0])); let initialCompleted: RoundState[] = []; if (allRounds.length > 0) { for (const round of allRounds) { if (round.scoreA !== undefined && round.scoreB !== undefined) { if (round.scoreA > round.scoreB) { initialScores[round.contestants[0].name] = (initialScores[round.contestants[0].name] || 0) + 1; } else if (round.scoreB > round.scoreA) { initialScores[round.contestants[1].name] = (initialScores[round.contestants[1].name] || 0) + 1; } } const vvA = round.viewerVotesA ?? 0; const vvB = round.viewerVotesB ?? 0; if (vvA > vvB) { initialViewerScores[round.contestants[0].name] = (initialViewerScores[round.contestants[0].name] || 0) + 1; } else if (vvB > vvA) { initialViewerScores[round.contestants[1].name] = (initialViewerScores[round.contestants[1].name] || 0) + 1; } } const lastRound = allRounds[allRounds.length - 1]; if (lastRound) { initialCompleted = [lastRound]; } } const gameState: GameState = { completed: initialCompleted, active: null, scores: initialScores, viewerScores: initialViewerScores, done: false, isPaused: false, autoPaused: false, generation: 0, }; // ── Guardrails ────────────────────────────────────────────────────────────── type WsData = { ip: string }; const WINDOW_MS = 60_000; const HISTORY_LIMIT_PER_MIN = parsePositiveInt( process.env.HISTORY_LIMIT_PER_MIN, 120, ); const ADMIN_LIMIT_PER_MIN = parsePositiveInt( process.env.ADMIN_LIMIT_PER_MIN, 10, ); const MAX_WS_GLOBAL = parsePositiveInt(process.env.MAX_WS_GLOBAL, 100_000); const MAX_WS_PER_IP = parsePositiveInt(process.env.MAX_WS_PER_IP, 8); const MAX_WS_NEW_PER_SEC = parsePositiveInt(process.env.MAX_WS_NEW_PER_SEC, 50); let wsNewConnections = 0; let wsNewConnectionsResetAt = Date.now() + 1000; const MAX_HISTORY_PAGE = parsePositiveInt( process.env.MAX_HISTORY_PAGE, 100_000, ); const MAX_HISTORY_LIMIT = parsePositiveInt(process.env.MAX_HISTORY_LIMIT, 50); const HISTORY_CACHE_TTL_MS = parsePositiveInt( process.env.HISTORY_CACHE_TTL_MS, 5_000, ); const MAX_HISTORY_CACHE_KEYS = parsePositiveInt( process.env.MAX_HISTORY_CACHE_KEYS, 500, ); const VIEWER_VOTE_BROADCAST_DEBOUNCE_MS = parsePositiveInt( process.env.VIEWER_VOTE_BROADCAST_DEBOUNCE_MS, 250, ); const AUTOPAUSE_DELAY_MS = parsePositiveInt(process.env.AUTOPAUSE_DELAY_MS, 60_000); const ADMIN_COOKIE = "argumentes_admin"; const CREDIT_TIERS: Record = { dia: { days: 1, amount: 100, label: "1 día" }, semana: { days: 7, amount: 500, label: "1 semana" }, mes: { days: 30, amount: 1500, label: "1 mes" }, }; const ADMIN_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; const requestWindows = new Map(); const wsByIp = new Map(); const historyCache = new Map(); let lastRateWindowSweep = 0; let lastHistoryCacheSweep = 0; function parsePositiveInt(value: string | undefined, fallback: number): number { const parsed = Number.parseInt(value ?? "", 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; } function isPrivateIp(ip: string): boolean { const v4 = ip.startsWith("::ffff:") ? ip.slice(7) : ip; if (v4 === "127.0.0.1" || ip === "::1") return true; if (v4.startsWith("10.")) return true; if (v4.startsWith("192.168.")) return true; // CGNAT range (RFC 6598) — used by Railway's internal proxy if (v4.startsWith("100.")) { const second = parseInt(v4.split(".")[1] ?? "", 10); if (second >= 64 && second <= 127) return true; } if (ip.startsWith("fc") || ip.startsWith("fd")) return true; if (v4.startsWith("172.")) { const second = parseInt(v4.split(".")[1] ?? "", 10); if (second >= 16 && second <= 31) return true; } return false; } function getClientIp(req: Request, server: Bun.Server): string { const socketIp = server.requestIP(req)?.address ?? "unknown"; // Only trust proxy headers when the direct connection comes from // a private IP (i.e. Railway's edge proxy). Direct public connections // cannot spoof their IP this way. if (socketIp !== "unknown" && isPrivateIp(socketIp)) { const xff = req.headers.get("x-forwarded-for"); if (xff) { const rightmost = xff.split(",").at(-1)?.trim(); if (rightmost && !isPrivateIp(rightmost)) { return rightmost.startsWith("::ffff:") ? rightmost.slice(7) : rightmost; } } } return socketIp.startsWith("::ffff:") ? socketIp.slice(7) : socketIp; } function isRateLimited(key: string, limit: number, windowMs: number): boolean { const now = Date.now(); if (now - lastRateWindowSweep >= windowMs) { for (const [bucketKey, timestamps] of requestWindows) { const recent = timestamps.filter( (timestamp) => now - timestamp <= windowMs, ); if (recent.length === 0) { requestWindows.delete(bucketKey); } else { requestWindows.set(bucketKey, recent); } } lastRateWindowSweep = now; } const existing = requestWindows.get(key) ?? []; const recent = existing.filter((timestamp) => now - timestamp <= windowMs); if (recent.length >= limit) { requestWindows.set(key, recent); return true; } recent.push(now); requestWindows.set(key, recent); return false; } function secureCompare(a: string, b: string): boolean { const aBuf = Buffer.from(a); const bBuf = Buffer.from(b); if (aBuf.length !== bBuf.length) return false; return timingSafeEqual(aBuf, bBuf); } function parseCookies(req: Request): Record { const raw = req.headers.get("cookie"); if (!raw) return {}; const cookies: Record = {}; for (const pair of raw.split(";")) { const idx = pair.indexOf("="); if (idx <= 0) continue; const key = pair.slice(0, idx).trim(); const val = pair.slice(idx + 1).trim(); if (!key) continue; try { cookies[key] = decodeURIComponent(val); } catch { cookies[key] = val; } } return cookies; } function buildAdminCookie( passcode: string, isSecure: boolean, maxAgeSeconds = ADMIN_COOKIE_MAX_AGE_SECONDS, ): string { const parts = [ `${ADMIN_COOKIE}=${encodeURIComponent(passcode)}`, "Path=/", "HttpOnly", "SameSite=Strict", `Max-Age=${maxAgeSeconds}`, ]; if (isSecure) { parts.push("Secure"); } return parts.join("; "); } function clearAdminCookie(isSecure: boolean): string { return buildAdminCookie("", isSecure, 0); } function getProvidedAdminSecret(req: Request, url: URL): string { const headerOrQuery = req.headers.get("x-admin-secret") ?? url.searchParams.get("secret"); if (headerOrQuery) return headerOrQuery; const cookies = parseCookies(req); return cookies[ADMIN_COOKIE] ?? ""; } function isAdminAuthorized(req: Request, url: URL): boolean { const expected = process.env.ADMIN_SECRET; if (!expected) return false; const provided = getProvidedAdminSecret(req, url); if (!provided) return false; return secureCompare(provided, expected); } function decrementIpConnection(ip: string) { const current = wsByIp.get(ip) ?? 0; if (current <= 1) { wsByIp.delete(ip); return; } wsByIp.set(ip, current - 1); } function setHistoryCache(key: string, body: string, expiresAt: number) { if (historyCache.size >= MAX_HISTORY_CACHE_KEYS) { const firstKey = historyCache.keys().next().value; if (firstKey) historyCache.delete(firstKey); } historyCache.set(key, { body, expiresAt }); } type ViewerVoteSide = "A" | "B"; function applyViewerVote(voterId: string, side: ViewerVoteSide): boolean { const round = gameState.active; if (!round || round.phase !== "voting") return false; if (!round.viewerVotingEndsAt || Date.now() > round.viewerVotingEndsAt) { return false; } const previousVote = viewerVoters.get(voterId); if (previousVote === side) return false; // Undo previous vote if this viewer switched sides. if (previousVote === "A") { round.viewerVotesA = Math.max(0, (round.viewerVotesA ?? 0) - 1); } else if (previousVote === "B") { round.viewerVotesB = Math.max(0, (round.viewerVotesB ?? 0) - 1); } viewerVoters.set(voterId, side); if (side === "A") { round.viewerVotesA = (round.viewerVotesA ?? 0) + 1; } else { round.viewerVotesB = (round.viewerVotesB ?? 0) + 1; } return true; } // ── Auto-pause ─────────────────────────────────────────────────────────────── let autoPauseTimer: ReturnType | null = null; function scheduleAutoPause() { if (autoPauseTimer) return; autoPauseTimer = setTimeout(() => { autoPauseTimer = null; if (clients.size === 0 && !gameState.isPaused) { gameState.isPaused = true; gameState.autoPaused = true; broadcast(); log("INFO", "server", "Auto-paused game — no viewers"); } }, AUTOPAUSE_DELAY_MS); } function cancelAutoPause() { if (autoPauseTimer) { clearTimeout(autoPauseTimer); autoPauseTimer = null; } } // ── WebSocket clients ─────────────────────────────────────────────────────── const clients = new Set>(); const viewerVoters = new Map(); let viewerVoteBroadcastTimer: ReturnType | null = null; function scheduleViewerVoteBroadcast() { if (viewerVoteBroadcastTimer) return; viewerVoteBroadcastTimer = setTimeout(() => { viewerVoteBroadcastTimer = null; broadcast(); }, VIEWER_VOTE_BROADCAST_DEBOUNCE_MS); } function getClientState() { return { active: gameState.active, lastCompleted: gameState.completed.at(-1) ?? null, scores: gameState.scores, viewerScores: gameState.viewerScores, done: gameState.done, isPaused: gameState.isPaused, autoPaused: gameState.autoPaused, generation: gameState.generation, }; } function broadcast() { const msg = JSON.stringify({ type: "state", data: getClientState(), totalRounds: runs, viewerCount: clients.size, version: VERSION, }); for (const ws of clients) { ws.send(msg); } } let viewerCountTimer: ReturnType | null = null; function broadcastViewerCount() { if (viewerCountTimer) return; viewerCountTimer = setTimeout(() => { viewerCountTimer = null; const msg = JSON.stringify({ type: "viewerCount", viewerCount: clients.size, }); for (const ws of clients) { ws.send(msg); } }, 15_000); } function getAdminSnapshot() { return { isPaused: gameState.isPaused, isRunningRound: Boolean(gameState.active), done: gameState.done, completedInMemory: gameState.completed.length, persistedRounds: getRounds(1, 1).total, viewerCount: clients.size, }; } // ── Server ────────────────────────────────────────────────────────────────── const port = parseInt(process.env.PORT ?? "5109", 10); // 5109 = SLOP const server = Bun.serve({ port, routes: { "/": indexHtml, "/history": historyHtml, "/admin": adminHtml, "/broadcast": broadcastHtml, "/pregunta": preguntaHtml, }, async fetch(req, server) { const url = new URL(req.url); const ip = getClientIp(req, server); if (url.pathname.startsWith("/assets/")) { const path = `./public${url.pathname}`; const file = Bun.file(path); return new Response(file, { headers: { "Cache-Control": "public, max-age=604800, immutable", "X-Content-Type-Options": "nosniff", }, }); } if (url.pathname === "/healthz") { return new Response("ok", { status: 200 }); } if (url.pathname === "/api/vote") { if (req.method !== "POST") { return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST" }, }); } let side: string = ""; try { const body = await req.json(); side = String((body as Record).side ?? ""); } catch { return new Response("Invalid JSON body", { status: 400 }); } if (side !== "A" && side !== "B") { return new Response("Invalid side", { status: 400 }); } const applied = applyViewerVote(ip, side as ViewerVoteSide); if (applied) { scheduleViewerVoteBroadcast(); } return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json", "Cache-Control": "no-store", }, }); } if (url.pathname === "/api/pregunta/iniciar") { if (req.method !== "POST") { return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST" }, }); } const secretKey = process.env.REDSYS_SECRET_KEY; const merchantCode = process.env.REDSYS_MERCHANT_CODE; if (!secretKey || !merchantCode) { return new Response("Pagos no configurados", { status: 503 }); } if (isRateLimited(`pregunta:${ip}`, 5, WINDOW_MS)) { return new Response("Too Many Requests", { status: 429 }); } let text = ""; try { const body = await req.json(); text = String((body as Record).text ?? "").trim(); } catch { return new Response("Invalid JSON body", { status: 400 }); } if (text.length < 10 || text.length > 200) { return new Response("La pregunta debe tener entre 10 y 200 caracteres", { status: 400 }); } const orderId = String(Date.now()).slice(-12); createPendingQuestion(text, orderId); const isTest = process.env.REDSYS_TEST !== "false"; const terminal = process.env.REDSYS_TERMINAL ?? "1"; const baseUrl = process.env.PUBLIC_URL?.replace(/\/$/, "") ?? `${url.protocol}//${url.host}`; const form = buildPaymentForm({ secretKey, merchantCode, terminal, isTest, orderId, amount: 100, urlOk: `${baseUrl}/pregunta?ok=1`, urlKo: `${baseUrl}/pregunta?ko=1`, merchantUrl: `${baseUrl}/api/redsys/notificacion`, productDescription: "Pregunta argument.es", }); log("INFO", "pregunta", "Payment initiated", { orderId, ip }); return new Response(JSON.stringify({ ok: true, ...form }), { status: 200, headers: { "Content-Type": "application/json", "Cache-Control": "no-store", }, }); } if (url.pathname === "/api/redsys/notificacion") { // Always return 200 so Redsys doesn't retry; log errors internally. const secretKey = process.env.REDSYS_SECRET_KEY; if (!secretKey || req.method !== "POST") { return new Response("ok", { status: 200 }); } let merchantParams = ""; let receivedSignature = ""; try { const body = await req.text(); const fd = new URLSearchParams(body); merchantParams = fd.get("Ds_MerchantParameters") ?? ""; receivedSignature = fd.get("Ds_Signature") ?? ""; } catch { return new Response("ok", { status: 200 }); } if (!verifyNotification(secretKey, merchantParams, receivedSignature)) { log("WARN", "redsys", "Invalid notification signature"); return new Response("ok", { status: 200 }); } const decoded = decodeParams(merchantParams); if (isPaymentApproved(decoded)) { const orderId = decoded["Ds_Order"] ?? ""; if (orderId) { // Try question order first, then credit order const markedQuestion = markQuestionPaid(orderId); if (markedQuestion) { log("INFO", "redsys", "Question marked as paid", { orderId }); } else { const credit = getCreditByOrder(orderId); if (credit && credit.status === "pending") { const tierInfo = CREDIT_TIERS[credit.tier]; if (tierInfo) { const expiresAt = Date.now() + tierInfo.days * 24 * 60 * 60 * 1000; const activated = activateCredit(orderId, expiresAt); if (activated) { log("INFO", "redsys", "Credit activated", { orderId, username: activated.username, tier: credit.tier, }); } } } } } } else { log("INFO", "redsys", "Payment not approved", { response: decoded["Ds_Response"], }); } return new Response("ok", { status: 200 }); } if (url.pathname === "/api/credito/iniciar") { if (req.method !== "POST") { return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST" }, }); } const secretKey = process.env.REDSYS_SECRET_KEY; const merchantCode = process.env.REDSYS_MERCHANT_CODE; if (!secretKey || !merchantCode) { return new Response("Pagos no configurados", { status: 503 }); } if (isRateLimited(`credito:${ip}`, 5, WINDOW_MS)) { return new Response("Too Many Requests", { status: 429 }); } let tier = ""; let username = ""; try { const body = await req.json(); tier = String((body as Record).tier ?? "").trim(); username = String((body as Record).username ?? "").trim(); } catch { return new Response("Invalid JSON body", { status: 400 }); } const tierInfo = CREDIT_TIERS[tier]; if (!tierInfo) { return new Response("Tier inválido (dia | semana | mes)", { status: 400 }); } if (!username || username.length < 1 || username.length > 30) { return new Response("El nombre debe tener entre 1 y 30 caracteres", { status: 400 }); } const orderId = String(Date.now()).slice(-12); createPendingCredit(username, orderId, tier); const isTest = process.env.REDSYS_TEST !== "false"; const terminal = process.env.REDSYS_TERMINAL ?? "1"; const baseUrl = process.env.PUBLIC_URL?.replace(/\/$/, "") ?? `${url.protocol}//${url.host}`; const form = buildPaymentForm({ secretKey, merchantCode, terminal, isTest, orderId, amount: tierInfo.amount, urlOk: `${baseUrl}/pregunta?credito_ok=${orderId}`, urlKo: `${baseUrl}/pregunta?ko=1`, merchantUrl: `${baseUrl}/api/redsys/notificacion`, productDescription: `Acceso argument.es — ${tierInfo.label}`, }); log("INFO", "credito", "Credit purchase initiated", { orderId, tier, ip, username: username.slice(0, 10), }); return new Response(JSON.stringify({ ok: true, ...form }), { status: 200, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }, }); } if (url.pathname === "/api/credito/estado") { const orderId = url.searchParams.get("order") ?? ""; if (!orderId) { return new Response("Missing order", { status: 400 }); } const credit = getCreditByOrder(orderId); if (!credit) { return new Response(JSON.stringify({ found: false }), { headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }, }); } return new Response( JSON.stringify({ found: true, status: credit.status, ...(credit.status === "active" ? { token: credit.token, username: credit.username, expiresAt: credit.expiresAt, tier: credit.tier } : {}), }), { headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } }, ); } if (url.pathname === "/api/pregunta/enviar") { if (req.method !== "POST") { return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST" }, }); } if (isRateLimited(`pregunta:${ip}`, 20, WINDOW_MS)) { return new Response("Too Many Requests", { status: 429 }); } let text = ""; let token = ""; try { const body = await req.json(); text = String((body as Record).text ?? "").trim(); token = String((body as Record).token ?? "").trim(); } catch { return new Response("Invalid JSON body", { status: 400 }); } if (!token) { return new Response("Token requerido", { status: 401 }); } const credit = validateCreditToken(token); if (!credit) { return new Response("Crédito no válido o expirado", { status: 401 }); } if (text.length < 10 || text.length > 200) { return new Response("La pregunta debe tener entre 10 y 200 caracteres", { status: 400 }); } createPaidQuestion(text, credit.username); log("INFO", "pregunta", "Question submitted via credit", { username: credit.username, ip }); return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }, }); } if (url.pathname === "/api/jugadores") { return new Response(JSON.stringify(getPlayerScores()), { headers: { "Content-Type": "application/json", "Cache-Control": "public, max-age=30, stale-while-revalidate=60", }, }); } if (url.pathname === "/api/admin/login") { if (req.method !== "POST") { return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST" }, }); } if (isRateLimited(`admin:${ip}`, ADMIN_LIMIT_PER_MIN, WINDOW_MS)) { log("WARN", "http", "Admin login rate limited", { ip }); return new Response("Too Many Requests", { status: 429 }); } const expected = process.env.ADMIN_SECRET; if (!expected) { return new Response("ADMIN_SECRET is not configured", { status: 503 }); } let passcode = ""; try { const body = await req.json(); passcode = String((body as Record).passcode ?? ""); } catch { return new Response("Invalid JSON body", { status: 400 }); } if (!passcode || !secureCompare(passcode, expected)) { return new Response("Invalid passcode", { status: 401 }); } const isSecure = url.protocol === "https:"; return new Response(JSON.stringify({ ok: true, ...getAdminSnapshot() }), { status: 200, headers: { "Content-Type": "application/json", "Set-Cookie": buildAdminCookie(passcode, isSecure), "Cache-Control": "no-store", }, }); } if (url.pathname === "/api/admin/logout") { if (req.method !== "POST") { return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST" }, }); } const isSecure = url.protocol === "https:"; return new Response(null, { status: 204, headers: { "Set-Cookie": clearAdminCookie(isSecure), "Cache-Control": "no-store", }, }); } if (url.pathname === "/api/admin/status") { if (isRateLimited(`admin:${ip}`, ADMIN_LIMIT_PER_MIN, WINDOW_MS)) { return new Response("Too Many Requests", { status: 429 }); } if (!isAdminAuthorized(req, url)) { return new Response("Unauthorized", { status: 401 }); } return new Response(JSON.stringify({ ok: true, ...getAdminSnapshot() }), { status: 200, headers: { "Content-Type": "application/json", "Cache-Control": "no-store", }, }); } if (url.pathname === "/api/admin/export") { if (req.method !== "GET") { return new Response("Method Not Allowed", { status: 405, headers: { Allow: "GET" }, }); } if (isRateLimited(`admin:${ip}`, ADMIN_LIMIT_PER_MIN, WINDOW_MS)) { return new Response("Too Many Requests", { status: 429 }); } if (!isAdminAuthorized(req, url)) { return new Response("Unauthorized", { status: 401 }); } const payload = { exportedAt: new Date().toISOString(), rounds: getAllRounds(), state: gameState, }; return new Response(JSON.stringify(payload, null, 2), { status: 200, headers: { "Content-Type": "application/json", "Cache-Control": "no-store", "Content-Disposition": `attachment; filename="argumentes-export-${Date.now()}.json"`, }, }); } if (url.pathname === "/api/admin/reset") { if (req.method !== "POST") { return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST" }, }); } if (isRateLimited(`admin:${ip}`, ADMIN_LIMIT_PER_MIN, WINDOW_MS)) { return new Response("Too Many Requests", { status: 429 }); } if (!isAdminAuthorized(req, url)) { return new Response("Unauthorized", { status: 401 }); } let confirm = ""; try { const body = await req.json(); confirm = String((body as Record).confirm ?? ""); } catch { return new Response("Invalid JSON body", { status: 400 }); } if (confirm !== "RESET") { return new Response("Confirmation token must be RESET", { status: 400, }); } clearAllRounds(); historyCache.clear(); gameState.completed = []; gameState.active = null; gameState.scores = Object.fromEntries(MODELS.map((m) => [m.name, 0])); gameState.viewerScores = Object.fromEntries(MODELS.map((m) => [m.name, 0])); gameState.done = false; gameState.isPaused = true; gameState.autoPaused = false; gameState.generation += 1; broadcast(); log("WARN", "admin", "Database reset requested", { ip }); return new Response(JSON.stringify({ ok: true, ...getAdminSnapshot() }), { status: 200, headers: { "Content-Type": "application/json", "Cache-Control": "no-store", }, }); } if ( url.pathname === "/api/pause" || url.pathname === "/api/resume" || url.pathname === "/api/admin/pause" || url.pathname === "/api/admin/resume" ) { if (req.method !== "POST") { return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST" }, }); } if (isRateLimited(`admin:${ip}`, ADMIN_LIMIT_PER_MIN, WINDOW_MS)) { return new Response("Too Many Requests", { status: 429 }); } if (!isAdminAuthorized(req, url)) { return new Response("Unauthorized", { status: 401 }); } if (url.pathname.endsWith("/pause")) { gameState.isPaused = true; gameState.autoPaused = false; cancelAutoPause(); } else { gameState.isPaused = false; gameState.autoPaused = false; } broadcast(); const action = url.pathname.endsWith("/pause") ? "Paused" : "Resumed"; if (url.pathname === "/api/pause" || url.pathname === "/api/resume") { return new Response(action, { status: 200 }); } return new Response( JSON.stringify({ ok: true, action, ...getAdminSnapshot() }), { status: 200, headers: { "Content-Type": "application/json", "Cache-Control": "no-store", }, }, ); } if (url.pathname === "/api/history") { if (isRateLimited(`history:${ip}`, HISTORY_LIMIT_PER_MIN, WINDOW_MS)) { log("WARN", "http", "History rate limited", { ip }); return new Response("Too Many Requests", { status: 429 }); } const rawPage = parseInt(url.searchParams.get("page") || "1", 10); const rawLimit = parseInt(url.searchParams.get("limit") || "10", 10); const page = Number.isFinite(rawPage) ? Math.min(Math.max(rawPage, 1), MAX_HISTORY_PAGE) : 1; const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(rawLimit, 1), MAX_HISTORY_LIMIT) : 10; const cacheKey = `${page}:${limit}`; const now = Date.now(); if (now - lastHistoryCacheSweep >= HISTORY_CACHE_TTL_MS) { for (const [key, value] of historyCache) { if (value.expiresAt <= now) historyCache.delete(key); } lastHistoryCacheSweep = now; } const cached = historyCache.get(cacheKey); if (cached && cached.expiresAt > now) { return new Response(cached.body, { headers: { "Content-Type": "application/json", "Cache-Control": "public, max-age=5, stale-while-revalidate=30", "X-Content-Type-Options": "nosniff", }, }); } const body = JSON.stringify(getRounds(page, limit)); setHistoryCache(cacheKey, body, now + HISTORY_CACHE_TTL_MS); return new Response(body, { headers: { "Content-Type": "application/json", "Cache-Control": "public, max-age=5, stale-while-revalidate=30", "X-Content-Type-Options": "nosniff", }, }); } if (url.pathname === "/ws") { if (req.method !== "GET") { return new Response("Method Not Allowed", { status: 405, headers: { Allow: "GET" }, }); } const now = Date.now(); if (now >= wsNewConnectionsResetAt) { wsNewConnections = 0; wsNewConnectionsResetAt = now + 1000; } if (wsNewConnections >= MAX_WS_NEW_PER_SEC) { return new Response("Too Many Requests", { status: 429 }); } if (clients.size >= MAX_WS_GLOBAL) { log("WARN", "ws", "Global WS limit reached, rejecting", { ip, clients: clients.size, limit: MAX_WS_GLOBAL, }); return new Response("Service Unavailable", { status: 503 }); } const existingForIp = wsByIp.get(ip) ?? 0; if (existingForIp >= MAX_WS_PER_IP) { log("WARN", "ws", "Per-IP WS limit reached, rejecting", { ip, existing: existingForIp, limit: MAX_WS_PER_IP, }); return new Response("Too Many Requests", { status: 429 }); } const upgraded = server.upgrade(req, { data: { ip } }); if (!upgraded) { log("WARN", "ws", "WebSocket upgrade failed", { ip }); return new Response("WebSocket upgrade failed", { status: 400 }); } wsNewConnections++; return undefined; } return new Response("Not found", { status: 404 }); }, websocket: { data: {} as WsData, open(ws) { clients.add(ws); const ipCount = (wsByIp.get(ws.data.ip) ?? 0) + 1; wsByIp.set(ws.data.ip, ipCount); log("INFO", "ws", "Client connected", { ip: ws.data.ip, ipConns: ipCount, totalClients: clients.size, uniqueIps: wsByIp.size, }); cancelAutoPause(); if (gameState.autoPaused) { gameState.isPaused = false; gameState.autoPaused = false; log("INFO", "server", "Auto-resumed game — viewer connected"); // Broadcast updated state to all clients (including this new one) broadcast(); } else { // Send current state to the new client only ws.send( JSON.stringify({ type: "state", data: getClientState(), totalRounds: runs, viewerCount: clients.size, version: VERSION, }), ); } // Notify everyone of updated viewer count broadcastViewerCount(); }, message() { // Viewer voting handled via /api/vote endpoint. }, close(ws) { clients.delete(ws); decrementIpConnection(ws.data.ip); log("INFO", "ws", "Client disconnected", { ip: ws.data.ip, totalClients: clients.size, uniqueIps: wsByIp.size, }); if (clients.size === 0 && !gameState.isPaused) { scheduleAutoPause(); } broadcastViewerCount(); }, }, development: process.env.NODE_ENV === "production" ? false : { hmr: true, console: true, }, error(error) { log("ERROR", "server", "Unhandled fetch/websocket error", { message: error.message, stack: error.stack, }); return new Response("Internal Server Error", { status: 500 }); }, }); console.log(`\n🎮 argument.es Web — http://localhost:${server.port}`); console.log(`📡 WebSocket — ws://localhost:${server.port}/ws`); console.log(`🎯 ${runs} rondas con ${MODELS.length} modelos\n`); log("INFO", "server", `Web server started on port ${server.port}`, { runs, models: MODELS.map((m) => m.id), }); // ── Start game ────────────────────────────────────────────────────────────── runGame(runs, gameState, broadcast, () => { viewerVoters.clear(); }).then(() => { console.log(`\n✅ ¡Juego completado! Registro: ${LOG_FILE}`); });