mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 22:31:26 +02:00
1415 lines
59 KiB
JavaScript
1415 lines
59 KiB
JavaScript
// Load .env before reading any process setting (notably TRUST_PROXY). systemd's
|
||
// EnvironmentFile masked this ordering bug in production, while `npm run dev` did not.
|
||
require('dotenv').config();
|
||
|
||
const express = require('express');
|
||
const path = require('path');
|
||
const crypto = require('crypto');
|
||
const { createSocketOriginAllowRequest } = require('./network/socketOriginPolicy');
|
||
var app = express();
|
||
app.disable('x-powered-by');
|
||
// Trust the reverse proxy (for correct req.ip / X-Forwarded-For handling) only when
|
||
// explicitly enabled, so client IPs can't be spoofed in a direct-exposure deployment.
|
||
if (process.env.TRUST_PROXY === 'true') {
|
||
// Trust only the configured number of closest proxy hops. `true` trusts every value in
|
||
// X-Forwarded-For, allowing a client-supplied leftmost address to spoof req.ip. NPM is one
|
||
// hop in the production topology; multi-proxy deployments must opt in explicitly.
|
||
const trustedProxyHops = Math.max(1, Math.min(8, parseInt(process.env.TRUST_PROXY_HOPS, 10) || 1));
|
||
app.set('trust proxy', trustedProxyHops);
|
||
}
|
||
var http = require('http').Server(app);
|
||
var io = require('socket.io')(http, {
|
||
allowRequest: createSocketOriginAllowRequest(process.env)
|
||
});
|
||
|
||
// Import payment system components
|
||
const DatabaseManager = require('./db/databaseManager');
|
||
const WalletRPCService = require('./payments/walletRPCService');
|
||
const PayoutRetryService = require('./payments/payoutRetryService');
|
||
const AlertService = require('./services/alertService');
|
||
const HealthService = require('./services/healthService');
|
||
const LatePaymentReconciler = require('./services/latePaymentReconciler');
|
||
const FinancialEventExporter = require('./services/financialEventExporter');
|
||
const GameModeManager = require('./game/gameModeManager');
|
||
const PaymentConfigManager = require('./config/paymentConfig');
|
||
const EnvironmentValidator = require('./config/environmentValidator');
|
||
const RuntimePolicy = require('./config/runtimePolicy');
|
||
const asyncHandler = require('./middleware/asyncHandler');
|
||
const createErrorMiddleware = require('./middleware/errorHandler');
|
||
const noStore = require('./middleware/noStore');
|
||
const { createCanaryDatabaseIdentityHandler } = require('./services/canaryDatabaseIdentity');
|
||
const { AppError, ValidationError, NotFoundError } = require('./utils/errors');
|
||
const money = require('./money/atomic');
|
||
|
||
// Import modular components
|
||
const BroadcastManager = require('./network/broadcastManager');
|
||
const DebugManager = require('./debug/debugManager');
|
||
const SocketHandlers = require('./network/socketHandlers');
|
||
const { verifyGame, verifyGameProof } = require('./game/provablyFair');
|
||
const { attachDungeonVerification } = require('./game/fairnessVerifier');
|
||
const { renderVerifyPage } = require('./views/verifyPage');
|
||
const { renderPrivacy, renderResponsiblePlay, renderTerms } = require('./views/legalPages');
|
||
const { buildCommerceDisclosure } = require('./config/commerceDisclosurePolicy');
|
||
const createAdminRoutes = require('./routes/admin');
|
||
const createAuthRoutes = require('./routes/auth');
|
||
const { isSmirkEnabled } = require('./auth/smirkPolicy');
|
||
const {
|
||
normalizeLeaderboardPeriod,
|
||
queryPrestigeLeaderboard
|
||
} = require('./network/prestigeLeaderboard');
|
||
|
||
// Initialize payment configuration
|
||
const paymentConfigManager = new PaymentConfigManager({ logger: console });
|
||
const environmentValidator = new EnvironmentValidator({ logger: console });
|
||
try {
|
||
environmentValidator.assertValid(paymentConfigManager.getConfig());
|
||
} catch (error) {
|
||
console.error(`❌ Startup aborted: ${error.message}`);
|
||
process.exit(1);
|
||
}
|
||
process.env.GAME_MODE = paymentConfigManager.getLegacyGameMode();
|
||
|
||
// Initialize modular components first
|
||
const broadcastManager = new BroadcastManager(io);
|
||
const debugManager = new DebugManager(broadcastManager);
|
||
broadcastManager.setDebugManager(debugManager);
|
||
|
||
// Initialize payment system components (debugManager is now available)
|
||
const databaseManager = new DatabaseManager();
|
||
const db = databaseManager; // Alias for convenience in API endpoints
|
||
const financialEventExporter = new FinancialEventExporter({ db: databaseManager });
|
||
const walletRPCService = new WalletRPCService(debugManager, {
|
||
minConfirmations: paymentConfigManager.getConfig().payouts.processing.confirmations
|
||
});
|
||
const gameModeManager = new GameModeManager(databaseManager, walletRPCService, debugManager, paymentConfigManager);
|
||
// Provide io reference so GameModeManager can emit events (e.g., credits_update)
|
||
gameModeManager.io = io;
|
||
|
||
// One effective payout gate used by every dispatcher. PAYOUTS_ENABLED is the master switch;
|
||
// per-mode flags cannot turn dispatch back on underneath it.
|
||
function isPayoutProcessingEnabled() {
|
||
return RuntimePolicy.isPayoutProcessingEnabled(
|
||
paymentConfigManager.getConfig(),
|
||
gameModeManager,
|
||
{ settleAcceptedLiabilities: true }
|
||
);
|
||
}
|
||
|
||
// Set only after startup has authenticated and probed the wallet. Every dispatch path also
|
||
// rechecks live health so a later outage cannot keep sending from a stale startup result.
|
||
let payoutDispatchReady = false;
|
||
// Readiness, paid admission, and every payout dispatcher stay closed until all durable startup
|
||
// recovery passes have completed without an unresolved financial row.
|
||
let financialRecoveryReady = false;
|
||
function isFinancialRecoveryReady() {
|
||
return financialRecoveryReady
|
||
&& socketHandlers?.isFinancialRecoveryReady?.() === true;
|
||
}
|
||
function canDispatchPayouts() {
|
||
return isFinancialRecoveryReady()
|
||
&& payoutDispatchReady
|
||
&& walletRPCService.getHealthStatus().healthy === true
|
||
&& isPayoutProcessingEnabled();
|
||
}
|
||
// Match/solo completion can request the manager's debounced dispatcher directly. Give that
|
||
// internal path the same startup, liveness, and operator-policy predicate as timers/retries.
|
||
gameModeManager.payoutDispatchAllowed = canDispatchPayouts;
|
||
// Invoice creation and paid solo entry consume value too; close both whenever startup or
|
||
// periodic liability reconciliation is incomplete. Explicit free play remains available.
|
||
gameModeManager.financialAdmissionAllowed = isFinancialRecoveryReady;
|
||
// Last-line guard shared by every wallet caller, including manual refunds and provider adapters.
|
||
// Dispatchers still check earlier so they do not claim durable rows while the gate is closed.
|
||
walletRPCService.transferAllowed = canDispatchPayouts;
|
||
|
||
// Initialize remaining components
|
||
const activeGames = new Map(); // Maps socketId to Game objects
|
||
let alertService = null; // Initialized later when payment system starts
|
||
let payoutRetryService = null; // Initialized later when payment system starts
|
||
let batchPayoutInterval = null; // Initialized later when payment system starts
|
||
let paymentExpiryInterval = null; // Initialized later when payment system starts
|
||
let latePaymentReconciler = null;
|
||
let walletHealthInterval = null; // Keeps readiness tied to a live wallet, not startup state
|
||
let databaseHealthInterval = null; // Detects a database outage after startup
|
||
const socketHandlers = new SocketHandlers(io, activeGames, broadcastManager, debugManager, gameModeManager, walletRPCService);
|
||
// Third-party renderer code executes with the same privileges as the game/payment UI. Keep CDN
|
||
// execution off by default; production operators must make the supply-chain tradeoff explicit.
|
||
const rendererCdnEnabled = /^true$/i.test(String(process.env.RENDERER_CDN_ENABLED || 'false'));
|
||
if (rendererCdnEnabled && process.env.NODE_ENV === 'production') {
|
||
console.warn('⚠️ RENDERER_CDN_ENABLED=true: third-party jsDelivr code is trusted by the production CSP');
|
||
}
|
||
// Security headers (defense in depth alongside output escaping).
|
||
// CSP locks down where scripts/styles/connections may come from. Inline scripts/styles
|
||
// are still permitted ('unsafe-inline') because the current pages rely on them heavily;
|
||
// removing that is tracked as Phase 4.4 (nonce-based CSP). connect-src 'self' covers
|
||
// same-origin Socket.IO websockets under CSP Level 3. blob: is limited to render-kit
|
||
// GLB texture decoding; GLTFLoader turns embedded textures into blob URLs.
|
||
app.use((req, res, next) => {
|
||
const scriptSources = ["'self'", "'unsafe-inline'"];
|
||
const connectSources = ["'self'", 'blob:', 'https://smirk.cash'];
|
||
if (rendererCdnEnabled) {
|
||
scriptSources.push('https://cdn.jsdelivr.net');
|
||
connectSources.push('https://cdn.jsdelivr.net');
|
||
}
|
||
const csp = [
|
||
"default-src 'self'",
|
||
`script-src ${scriptSources.join(' ')}`,
|
||
"style-src 'self' 'unsafe-inline'",
|
||
"img-src 'self' data: blob:",
|
||
"font-src 'self'",
|
||
`connect-src ${connectSources.join(' ')}`,
|
||
"object-src 'none'",
|
||
"base-uri 'self'",
|
||
"frame-ancestors 'self'",
|
||
"form-action 'self'"
|
||
];
|
||
if (process.env.NODE_ENV === 'production') csp.push('upgrade-insecure-requests');
|
||
res.setHeader('Content-Security-Policy', csp.join('; '));
|
||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
||
res.setHeader('Referrer-Policy', 'no-referrer');
|
||
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()');
|
||
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin-allow-popups');
|
||
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
|
||
res.setHeader('Origin-Agent-Cluster', '?1');
|
||
if (process.env.NODE_ENV === 'production') {
|
||
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||
}
|
||
next();
|
||
});
|
||
|
||
// These surfaces return bearer-authenticated account, payment, or operator data. Tell both
|
||
// browsers and reverse proxies never to retain a response, including errors.
|
||
app.use(['/api/user', '/api/auth', '/api/admin', '/api/payment'], noStore);
|
||
|
||
// Same-origin, non-secret browser policy. Loading this before renderModes.js prevents an optional
|
||
// renderer selection from even attempting a third-party fetch when CDN execution is disabled.
|
||
app.get('/runtime-config.js', (req, res) => {
|
||
res.setHeader('Cache-Control', 'no-store, max-age=0');
|
||
res.type('application/javascript').send(
|
||
`window.WOWNGEON_RUNTIME=Object.freeze({rendererCdnEnabled:${rendererCdnEnabled}});`
|
||
);
|
||
});
|
||
|
||
// Configure static file serving
|
||
const htmlPath = path.join(__dirname, '../html');
|
||
app.use(express.static(htmlPath, {
|
||
etag: true,
|
||
lastModified: true,
|
||
setHeaders: (res, filePath) => {
|
||
if (filePath.endsWith('.html') || filePath.endsWith('.js')) {
|
||
res.setHeader('Cache-Control', 'no-cache');
|
||
}
|
||
}
|
||
}));
|
||
app.use(express.json()); // Parse JSON for API endpoints
|
||
|
||
// Serve main page
|
||
app.get('/', function(req, res) {
|
||
res.sendFile('index.html', { root: htmlPath });
|
||
});
|
||
|
||
function currentCommerceDisclosure() {
|
||
return buildCommerceDisclosure(gameModeManager, process.env);
|
||
}
|
||
|
||
// These pages intentionally describe the active server instead of claiming every deployment has
|
||
// the same money rules. They are product disclosures, not a substitute for operator legal review.
|
||
app.get('/terms', function(req, res) {
|
||
res.setHeader('Cache-Control', 'no-cache');
|
||
res.type('html').send(renderTerms(currentCommerceDisclosure()));
|
||
});
|
||
app.get('/privacy', function(req, res) {
|
||
res.setHeader('Cache-Control', 'no-cache');
|
||
res.type('html').send(renderPrivacy(currentCommerceDisclosure()));
|
||
});
|
||
app.get('/responsible-play', function(req, res) {
|
||
res.setHeader('Cache-Control', 'no-cache');
|
||
res.type('html').send(renderResponsiblePlay(currentCommerceDisclosure()));
|
||
});
|
||
|
||
app.get('/api/disclosures', noStore, function(req, res) {
|
||
res.json(currentCommerceDisclosure());
|
||
});
|
||
|
||
// This route is absent unless an isolated XMR stagenet canary opts in with a protected one-run
|
||
// nonce. It lets the financial harness bind the application process to the exact PostgreSQL
|
||
// database it independently audited before any value-bearing action.
|
||
const canaryDatabaseIdentityHandler = createCanaryDatabaseIdentityHandler({
|
||
db: databaseManager,
|
||
env: process.env
|
||
});
|
||
if (canaryDatabaseIdentityHandler) {
|
||
app.get('/api/canary/database-identity', noStore,
|
||
asyncHandler(canaryDatabaseIdentityHandler));
|
||
}
|
||
|
||
// Human-friendly tavern route. The static file remains /tavern.html for direct asset serving.
|
||
app.get(['/tavern', '/tavern/'], function(req, res) {
|
||
res.sendFile('tavern.html', { root: htmlPath });
|
||
});
|
||
|
||
// Serve admin panel
|
||
app.get('/admin', function(req, res) {
|
||
res.sendFile('admin.html', { root: htmlPath });
|
||
});
|
||
|
||
// Debug endpoint to receive client-side debug info
|
||
app.post('/debug', (req, res) => {
|
||
if (debugManager.CONSOLE_LOGGING) {
|
||
console.log('📝 CLIENT DEBUG INFO:', req.body);
|
||
}
|
||
res.json({ received: true, timestamp: Date.now() });
|
||
});
|
||
|
||
// Payment system API endpoints
|
||
app.post('/api/payment/create', asyncHandler(async (req, res) => {
|
||
const { userId, gameMode } = req.body || {};
|
||
if (!userId || !gameMode) {
|
||
throw new ValidationError('Missing userId or gameMode', {
|
||
safeMessage: 'userId and gameMode are required to create a payment.'
|
||
});
|
||
}
|
||
|
||
throw new AppError('REST payment creation endpoint is not implemented for unified payments', {
|
||
statusCode: 501,
|
||
code: 'NOT_IMPLEMENTED',
|
||
safeMessage: 'Payment creation via REST API is not available. Please use the in-game flow.'
|
||
});
|
||
}));
|
||
|
||
const restNotImplemented = () => new AppError('Endpoint not available in unified payment system', {
|
||
statusCode: 501,
|
||
code: 'NOT_IMPLEMENTED',
|
||
safeMessage: 'This API endpoint is not available. Please use the supported in-game flow.'
|
||
});
|
||
|
||
app.post('/api/payment/callback', asyncHandler(async (req, res) => {
|
||
throw restNotImplemented();
|
||
}));
|
||
|
||
app.get('/api/payment/status/:paymentId', asyncHandler(async (req, res) => {
|
||
throw restNotImplemented();
|
||
}));
|
||
|
||
// =============================================================================
|
||
// /api/user/* protection (S1 / S2 / S3, contract C2)
|
||
// =============================================================================
|
||
|
||
// S3: simple in-memory IP rate limiter for the /api/user/* REST surface. Each of these
|
||
// endpoints hits the DB, so cap requests per IP over a short window. Best-effort only (the
|
||
// socket layer has its own limiter and nginx can add another); the map is pruned lazily so
|
||
// it can't grow unbounded.
|
||
const _userApiHits = new Map(); // ip -> { count, windowStart }
|
||
const USER_API_WINDOW_MS = 60 * 1000;
|
||
const USER_API_MAX = Number(process.env.USER_API_RATE_MAX) || 120;
|
||
function userApiRateLimit(req, res, next) {
|
||
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
||
const now = Date.now();
|
||
let rec = _userApiHits.get(ip);
|
||
if (!rec || (now - rec.windowStart) > USER_API_WINDOW_MS) {
|
||
rec = { count: 0, windowStart: now };
|
||
_userApiHits.set(ip, rec);
|
||
}
|
||
rec.count += 1;
|
||
if (_userApiHits.size > 5000) {
|
||
for (const [k, v] of _userApiHits) {
|
||
if ((now - v.windowStart) > USER_API_WINDOW_MS) _userApiHits.delete(k);
|
||
}
|
||
}
|
||
if (rec.count > USER_API_MAX) {
|
||
return res.status(429).json({ error: 'Too many requests' });
|
||
}
|
||
next();
|
||
}
|
||
app.use('/api/user', userApiRateLimit);
|
||
|
||
// C2: session-ownership guard for /api/user/:socketId/*. The caller must present the session
|
||
// token (users.anon_token — emitted to the client as the 'session_token'/'session_resumed'
|
||
// event and stored as localStorage['wownerogue_token']) via the 'X-Session-Token' header.
|
||
// Tokens are never accepted in URLs because reverse proxies and browser history log them.
|
||
// We only proceed for the row whose socket_id AND anon_token both match, so
|
||
// one client can't read or mutate another player's payments/payouts/address. failStatus is
|
||
// 403 for mutations, 401 for reads.
|
||
function requireSessionOwnership(failStatus) {
|
||
return async (req, res, next) => {
|
||
try {
|
||
const { socketId } = req.params;
|
||
const token = req.get('X-Session-Token');
|
||
if (!socketId || !token) {
|
||
return res.status(failStatus).json({ error: 'Session token required' });
|
||
}
|
||
const result = await databaseManager.query(
|
||
'SELECT id FROM users WHERE socket_id = $1 AND anon_token = $2',
|
||
[socketId, token]
|
||
);
|
||
if (result.rows.length === 0) {
|
||
return res.status(failStatus).json({ error: 'Session ownership verification failed' });
|
||
}
|
||
req.sessionUserId = result.rows[0].id;
|
||
next();
|
||
} catch (err) {
|
||
console.error('[requireSessionOwnership] verification error:', err.message);
|
||
return res.status(failStatus).json({ error: 'Session verification failed' });
|
||
}
|
||
};
|
||
}
|
||
|
||
app.get('/api/user/:socketId/credits', requireSessionOwnership(401), asyncHandler(async (req, res) => {
|
||
const { socketId } = req.params;
|
||
if (!socketId) {
|
||
throw new ValidationError('Missing socketId', {
|
||
safeMessage: 'socketId parameter is required.'
|
||
});
|
||
}
|
||
|
||
try {
|
||
// C1: read-only route — never mint a user row for a stranger's socketId.
|
||
const user = await gameModeManager.getOrCreateUser(socketId, { create: false });
|
||
if (!user) {
|
||
return res.json({});
|
||
}
|
||
res.json({
|
||
socketId,
|
||
credits: user.credits || 0,
|
||
totalCreditsPurchased: user.total_credits_purchased || 0
|
||
});
|
||
} catch (error) {
|
||
throw new AppError('Failed to retrieve credits', {
|
||
statusCode: 500,
|
||
safeMessage: 'Unable to retrieve credit balance.',
|
||
cause: error
|
||
});
|
||
}
|
||
}));
|
||
|
||
app.get('/api/user/:socketId/mode', requireSessionOwnership(401), asyncHandler(async (req, res) => {
|
||
const { socketId } = req.params;
|
||
if (!socketId) {
|
||
throw new ValidationError('Missing socketId', {
|
||
safeMessage: 'socketId parameter is required.'
|
||
});
|
||
}
|
||
|
||
try {
|
||
// C1: read-only route — do not create a user row here.
|
||
const user = await gameModeManager.getOrCreateUser(socketId, { create: false });
|
||
if (!user) {
|
||
return res.json({});
|
||
}
|
||
res.json({
|
||
socketId,
|
||
preferredPaymentMode: user.preferred_payment_mode || 'direct',
|
||
hasPayoutAddress: !!user.payout_address,
|
||
paymentsEnabled: gameModeManager.paymentsEnabled,
|
||
directModeEnabled: gameModeManager.directModeEnabled,
|
||
creditsModeEnabled: gameModeManager.creditsModeEnabled
|
||
});
|
||
} catch (error) {
|
||
throw new AppError('Failed to retrieve user mode', {
|
||
statusCode: 500,
|
||
safeMessage: 'Unable to retrieve user mode.',
|
||
cause: error
|
||
});
|
||
}
|
||
}));
|
||
|
||
app.post('/api/user/:socketId/address', requireSessionOwnership(403), asyncHandler(async (req, res) => {
|
||
const { socketId } = req.params;
|
||
const { address } = req.body || {};
|
||
|
||
if (!socketId) {
|
||
throw new ValidationError('Missing socketId', {
|
||
safeMessage: 'socketId parameter is required.'
|
||
});
|
||
}
|
||
|
||
if (!address || typeof address !== 'string') {
|
||
throw new ValidationError('Missing or invalid address', {
|
||
safeMessage: 'A valid payout address is required.'
|
||
});
|
||
}
|
||
|
||
try {
|
||
// Keep REST and Socket.IO on the same network-aware validation path. AddressManager checks
|
||
// XMR prefixes for mainnet/stagenet/testnet, then asks wallet-rpc to verify checksum/nettype
|
||
// before persistence.
|
||
const success = await socketHandlers.addressManager.saveAddress(socketId, address, { autoConfirm: true });
|
||
if (!success) {
|
||
throw new AppError('Failed to save address', {
|
||
statusCode: 400,
|
||
safeMessage: 'That address is not valid for this server network.'
|
||
});
|
||
}
|
||
res.json({
|
||
success: true,
|
||
message: 'Payout address saved successfully.'
|
||
});
|
||
} catch (error) {
|
||
if (error instanceof AppError || error instanceof ValidationError) {
|
||
throw error;
|
||
}
|
||
throw new AppError('Failed to save address', {
|
||
statusCode: 500,
|
||
safeMessage: 'Unable to save payout address.',
|
||
cause: error
|
||
});
|
||
}
|
||
}));
|
||
|
||
function publicHealthSnapshot() {
|
||
const walletHealth = walletRPCService.getHealthStatus();
|
||
const health = HealthService.buildPublicHealth({
|
||
databaseReady: databaseManager.isHealthy(),
|
||
blockHeight: debugManager.getCurrentBlockHeight(),
|
||
simulatedBlocks: debugManager.SIMULATED_BLOCKS,
|
||
chainHealthy: debugManager.isChainHealthy?.(),
|
||
blockSource: process.env.BLOCK_SOURCE || (debugManager.SIMULATED_BLOCKS ? 'simulated' : 'daemon'),
|
||
network: process.env.MONERO_NETWORK || 'mainnet',
|
||
paymentsEnabled: gameModeManager.paymentsEnabled,
|
||
payoutsEnabled: isPayoutProcessingEnabled(),
|
||
walletHealthy: walletHealth.healthy,
|
||
identityRequired: process.env.NODE_ENV === 'production',
|
||
daemonIdentity: debugManager.getChainIdentity(),
|
||
walletIdentity: walletHealth.identity,
|
||
financialRecoveryReady: isFinancialRecoveryReady(),
|
||
activeGames: activeGames.size,
|
||
queuedGames: socketHandlers?.queueManager?.getQueueLength?.() || 0,
|
||
connectedPlayers: io.sockets.sockets.size || 0,
|
||
gameMode: gameModeManager.gameMode,
|
||
uptime: process.uptime()
|
||
});
|
||
// Informational only: an optional accounting sink must not make gameplay readiness fail.
|
||
// The exporter exposes a deliberately sanitized view with no endpoint/token/error/event data.
|
||
health.financialEvents = financialEventExporter.getPublicHealth();
|
||
return health;
|
||
}
|
||
|
||
// Public probes intentionally omit balances, RPC addresses, secrets, memory details, and abuse
|
||
// thresholds. Those remain available through the authenticated admin stats endpoints.
|
||
app.get('/health', (req, res) => {
|
||
res.setHeader('Cache-Control', 'no-store');
|
||
res.json(publicHealthSnapshot());
|
||
});
|
||
|
||
app.get('/health/live', (req, res) => {
|
||
res.setHeader('Cache-Control', 'no-store');
|
||
res.json({ status: 'ok', timestamp: new Date().toISOString(), uptime: process.uptime() });
|
||
});
|
||
|
||
app.get('/health/ready', (req, res) => {
|
||
res.setHeader('Cache-Control', 'no-store');
|
||
const health = publicHealthSnapshot();
|
||
res.status(health.ready ? 200 : 503).json(health);
|
||
});
|
||
|
||
// Get payment options for a user (mixed mode support)
|
||
app.get('/api/user/:socketId/payment-options', requireSessionOwnership(401), asyncHandler(async (req, res) => {
|
||
const { socketId } = req.params;
|
||
if (!socketId) {
|
||
throw new ValidationError('Missing socketId', {
|
||
safeMessage: 'socketId parameter is required.'
|
||
});
|
||
}
|
||
|
||
try {
|
||
const options = await gameModeManager.getPaymentOptionsForUser(socketId);
|
||
res.json(options);
|
||
} catch (error) {
|
||
throw new AppError('Failed to retrieve payment options', {
|
||
statusCode: 500,
|
||
safeMessage: 'Unable to retrieve payment options.',
|
||
cause: error
|
||
});
|
||
}
|
||
}));
|
||
|
||
// Get payment history for a user
|
||
app.get('/api/user/:socketId/payments', requireSessionOwnership(401), asyncHandler(async (req, res) => {
|
||
const { socketId } = req.params;
|
||
const limit = Math.min(parseInt(req.query.limit, 10) || 20, 100);
|
||
const offset = parseInt(req.query.offset, 10) || 0;
|
||
|
||
if (!socketId) {
|
||
throw new ValidationError('Missing socketId', {
|
||
safeMessage: 'socketId parameter is required.'
|
||
});
|
||
}
|
||
|
||
try {
|
||
// C1: read-only route — do not create a user row here.
|
||
const user = await gameModeManager.getOrCreateUser(socketId, { create: false });
|
||
if (!user) {
|
||
return res.json({ payments: [], total: 0, totalPaid: 0, limit, offset });
|
||
}
|
||
const result = await db.query(`
|
||
SELECT
|
||
id,
|
||
payment_type,
|
||
expected_amount,
|
||
status,
|
||
credits_purchased,
|
||
created_at,
|
||
confirmed_at,
|
||
description
|
||
FROM payments
|
||
WHERE user_id = $1
|
||
ORDER BY created_at DESC
|
||
LIMIT $2 OFFSET $3
|
||
`, [user.id, limit, offset]);
|
||
|
||
const countResult = await db.query(
|
||
`SELECT COUNT(*) as total,
|
||
COALESCE(SUM(CASE WHEN status = 'confirmed' THEN expected_amount ELSE 0 END), 0) as total_paid
|
||
FROM payments WHERE user_id = $1`,
|
||
[user.id]
|
||
);
|
||
|
||
const totalPaid = money.toSafe(money.toBig(countResult.rows[0].total_paid || 0));
|
||
|
||
res.json({
|
||
payments: result.rows.map(row => ({
|
||
id: row.id,
|
||
type: row.payment_type,
|
||
amount: row.expected_amount,
|
||
amountFormatted: gameModeManager.formatAtomicHuman(row.expected_amount, 4),
|
||
status: row.status,
|
||
creditsReceived: row.credits_purchased || 0,
|
||
createdAt: row.created_at,
|
||
confirmedAt: row.confirmed_at,
|
||
description: row.description
|
||
})),
|
||
total: parseInt(countResult.rows[0].total, 10),
|
||
totalPaid: totalPaid,
|
||
totalPaidFormatted: gameModeManager.formatAtomicHuman(totalPaid, 4),
|
||
limit,
|
||
offset,
|
||
currency: gameModeManager.cryptoType
|
||
});
|
||
} catch (error) {
|
||
throw new AppError('Failed to retrieve payment history', {
|
||
statusCode: 500,
|
||
safeMessage: 'Unable to retrieve payment history.',
|
||
cause: error
|
||
});
|
||
}
|
||
}));
|
||
|
||
// Get payout history for a user
|
||
app.get('/api/user/:socketId/payouts', requireSessionOwnership(401), asyncHandler(async (req, res) => {
|
||
const { socketId } = req.params;
|
||
const limit = Math.min(parseInt(req.query.limit, 10) || 20, 100);
|
||
const offset = parseInt(req.query.offset, 10) || 0;
|
||
|
||
if (!socketId) {
|
||
throw new ValidationError('Missing socketId', {
|
||
safeMessage: 'socketId parameter is required.'
|
||
});
|
||
}
|
||
|
||
try {
|
||
// C1: read-only route — do not create a user row here.
|
||
const user = await gameModeManager.getOrCreateUser(socketId, { create: false });
|
||
if (!user) {
|
||
return res.json({ payouts: [], total: 0, totalReceived: 0, limit, offset });
|
||
}
|
||
const result = await db.query(`
|
||
SELECT
|
||
p.id,
|
||
p.amount,
|
||
p.multiplier,
|
||
p.reason,
|
||
p.status,
|
||
p.tx_hash,
|
||
p.created_at,
|
||
p.processed_at,
|
||
g.outcome as game_outcome
|
||
FROM payouts p
|
||
LEFT JOIN games g ON p.game_id = g.id
|
||
WHERE p.user_id = $1
|
||
ORDER BY p.created_at DESC
|
||
LIMIT $2 OFFSET $3
|
||
`, [user.id, limit, offset]);
|
||
|
||
const countResult = await db.query(
|
||
`SELECT COUNT(*) as total FROM payouts WHERE user_id = $1`,
|
||
[user.id]
|
||
);
|
||
|
||
// Calculate total received (confirmed payouts)
|
||
const totalResult = await db.query(
|
||
`SELECT COALESCE(SUM(amount), 0) as total_received FROM payouts WHERE user_id = $1 AND status = 'completed'`,
|
||
[user.id]
|
||
);
|
||
|
||
res.json({
|
||
payouts: result.rows.map(row => ({
|
||
id: row.id,
|
||
amount: row.amount,
|
||
amountFormatted: gameModeManager.formatAtomicHuman(row.amount, 4),
|
||
multiplier: parseFloat(row.multiplier) || 0,
|
||
reason: row.reason,
|
||
status: row.status,
|
||
txHash: row.tx_hash,
|
||
createdAt: row.created_at,
|
||
processedAt: row.processed_at,
|
||
gameOutcome: row.game_outcome
|
||
})),
|
||
total: parseInt(countResult.rows[0].total, 10),
|
||
totalReceived: money.toSafe(money.toBig(totalResult.rows[0].total_received || 0)),
|
||
totalReceivedFormatted: gameModeManager.formatAtomicHuman(totalResult.rows[0].total_received, 4),
|
||
limit,
|
||
offset,
|
||
currency: gameModeManager.cryptoType
|
||
});
|
||
} catch (error) {
|
||
throw new AppError('Failed to retrieve payout history', {
|
||
statusCode: 500,
|
||
safeMessage: 'Unable to retrieve payout history.',
|
||
cause: error
|
||
});
|
||
}
|
||
}));
|
||
|
||
// =============================================================================
|
||
// Smirk Wallet Authentication Endpoints
|
||
// =============================================================================
|
||
// Smirk is a browser extension wallet for Wownero/Monero (mainnet only).
|
||
// Forced off on any test network (stagenet/testnet), regardless of SMIRK_ENABLED.
|
||
const smirkEnabled = isSmirkEnabled(process.env);
|
||
if (smirkEnabled) {
|
||
app.use(createAuthRoutes({
|
||
db: databaseManager,
|
||
sessionManager: socketHandlers.sessionManager
|
||
}));
|
||
}
|
||
|
||
|
||
// =============================================================================
|
||
// Admin API Endpoints (see src/routes/admin.js) — DI ctx; alertService is set later
|
||
// =============================================================================
|
||
const adminRouteCtx = {
|
||
db,
|
||
gameModeManager,
|
||
walletRPCService,
|
||
socketHandlers,
|
||
io,
|
||
alertService: null,
|
||
canDispatchPayouts
|
||
};
|
||
app.use(createAdminRoutes(adminRouteCtx));
|
||
|
||
// =============================================================================
|
||
// LEADERBOARD
|
||
// =============================================================================
|
||
app.get('/api/leaderboard', asyncHandler(async (req, res) => {
|
||
const period = normalizeLeaderboardPeriod(req.query.period);
|
||
const requestedLimit = Number.parseInt(req.query.limit, 10);
|
||
const limit = Number.isFinite(requestedLimit)
|
||
? Math.max(1, Math.min(requestedLimit, 50))
|
||
: 20;
|
||
|
||
let timeFilter = '';
|
||
if (period === 'week') timeFilter = "AND g.completed_at > NOW() - INTERVAL '7 days'";
|
||
else if (period === 'month') timeFilter = "AND g.completed_at > NOW() - INTERVAL '30 days'";
|
||
|
||
// Per-game leaderboard split (whitelisted -> safe to interpolate):
|
||
// champions = games played with credits/entry-fee (Hall of Champions)
|
||
// pleb = free games (Pleb board)
|
||
// prestige = credit-prestige match races
|
||
// all = everyone (default, backward compatible)
|
||
const board = req.query.board || 'all';
|
||
let boardFilter = '';
|
||
if (board === 'champions') boardFilter = "AND g.game_mode IN ('PAID_SINGLE','PAID_CREDITS')";
|
||
else if (board === 'pleb') boardFilter = "AND g.game_mode = 'FREE'";
|
||
|
||
if (board === 'prestige') {
|
||
const result = await queryPrestigeLeaderboard(db, { period, limit });
|
||
return res.json({ leaderboard: result.rows, period, board });
|
||
}
|
||
|
||
const result = await db.query(`
|
||
SELECT
|
||
u.id,
|
||
COALESCE(u.display_name,
|
||
CASE WHEN u.payout_address IS NOT NULL
|
||
THEN LEFT(u.payout_address, 4) || '...' || RIGHT(u.payout_address, 4)
|
||
ELSE 'Anon#' || u.id
|
||
END
|
||
) as name,
|
||
MAX(g.score) as best_score,
|
||
COUNT(*) FILTER (WHERE g.status = 'won') as wins,
|
||
COUNT(*) as games_played
|
||
FROM games g
|
||
JOIN users u ON g.user_id = u.id
|
||
WHERE g.status IN ('won', 'lost') AND g.score > 0 ${timeFilter} ${boardFilter}
|
||
GROUP BY u.id, u.display_name, u.payout_address
|
||
ORDER BY best_score DESC
|
||
LIMIT $1
|
||
`, [limit]);
|
||
|
||
res.json({ leaderboard: result.rows, period, board });
|
||
}));
|
||
|
||
// Public "social proof" stats strip: players online, games + escapes in the last 24h, and
|
||
// total paid out. Cached in-memory for 10s so a public, unauthenticated endpoint can't hammer
|
||
// the DB (one query set per 10s regardless of traffic).
|
||
let _statsCache = { at: 0, data: null };
|
||
app.get('/api/stats', asyncHandler(async (req, res) => {
|
||
const now = Date.now();
|
||
const online = io.sockets.sockets.size || io.engine.clientsCount || 0;
|
||
|
||
if (_statsCache.data && (now - _statsCache.at) < 10000) {
|
||
return res.json({ ..._statsCache.data, online });
|
||
}
|
||
|
||
const config = paymentConfigManager.getConfig();
|
||
const decimals = Number(config.currency?.decimals ?? 12);
|
||
const currencyLabel = gameModeManager.currencyLabel || config.currency?.symbol || 'WOW';
|
||
|
||
let gamesToday = 0, escapesToday = 0, totalPaidOut = 0;
|
||
try {
|
||
const [games, payouts] = await Promise.all([
|
||
db.query(`SELECT COUNT(*) AS games,
|
||
COUNT(*) FILTER (WHERE status = 'won') AS escapes
|
||
FROM games WHERE created_at > NOW() - INTERVAL '24 hours'`),
|
||
// Only count payouts that actually left the wallet.
|
||
db.query(`SELECT COALESCE(SUM(amount), 0) AS total FROM payouts WHERE status = 'completed'`)
|
||
]);
|
||
gamesToday = parseInt(games.rows[0]?.games || 0, 10);
|
||
escapesToday = parseInt(games.rows[0]?.escapes || 0, 10);
|
||
// Keep the aggregate exact across PostgreSQL BIGINT -> JSON. It is a decimal string in
|
||
// whole currency units; browsers can display/coerce it without losing server-side units.
|
||
totalPaidOut = money.format(payouts.rows[0]?.total || 0, decimals);
|
||
} catch (e) {
|
||
// Best-effort; serve whatever we have rather than 500 on a public widget.
|
||
}
|
||
|
||
const data = {
|
||
gamesToday,
|
||
escapesToday,
|
||
totalPaidOut,
|
||
currencyLabel,
|
||
// Paid out is only meaningful where payouts are enabled (stagenet / payout instances).
|
||
payoutsEnabled: isPayoutProcessingEnabled()
|
||
};
|
||
_statsCache = { at: now, data };
|
||
res.json({ ...data, online });
|
||
}));
|
||
|
||
app.get('/api/game-modes', (req, res) => {
|
||
const config = paymentConfigManager.getConfig();
|
||
const decimals = Number(config.currency?.decimals ?? 12);
|
||
const toDisplay = (value) => {
|
||
if (value === null || value === undefined) {
|
||
return 0;
|
||
}
|
||
try {
|
||
return money.format(value, decimals);
|
||
} catch (_) {
|
||
return 0;
|
||
}
|
||
};
|
||
|
||
const directMode = config.modes.direct;
|
||
const creditsMode = config.modes.credits;
|
||
|
||
// Include hosted by info if configured
|
||
let hostedBy = null;
|
||
if (process.env.HOSTED_BY) {
|
||
try {
|
||
const hostedUrl = new URL(process.env.HOSTED_BY);
|
||
if (hostedUrl.protocol === 'https:' || hostedUrl.protocol === 'http:') {
|
||
hostedBy = {
|
||
url: hostedUrl.href,
|
||
name: process.env.HOSTED_BY_NAME || hostedUrl.hostname
|
||
};
|
||
}
|
||
} catch (_) {
|
||
// Environment validation reports malformed production values. Never reflect an unsafe URL.
|
||
}
|
||
}
|
||
|
||
res.json({
|
||
// Free play is available when the instance is free-only OR when free play is offered
|
||
// as a choice alongside paid options (FREE_PLAY_ENABLED).
|
||
freePlayEnabled: !!gameModeManager.freePlayEnabled,
|
||
FREE: {
|
||
name: 'Free Play',
|
||
cost: 0,
|
||
payoutMultiplier: 0,
|
||
enabled: !!gameModeManager.freePlayEnabled
|
||
},
|
||
PAID_SINGLE: {
|
||
name: 'Paid Single Game',
|
||
cost: toDisplay(directMode.price),
|
||
enabled: !!directMode.enabled,
|
||
payoutMultiplier: (isPayoutProcessingEnabled() && directMode.enabled && config.payouts.rules.direct.enabled)
|
||
? gameModeManager.getImplementedPayoutMultipliersForMode('PAID_SINGLE')
|
||
: 0
|
||
},
|
||
PAID_CREDITS: {
|
||
name: 'Credits Package',
|
||
cost: toDisplay(creditsMode.packages?.[0]?.price ?? 0),
|
||
credits: creditsMode.packages?.[0]?.credits ?? creditsMode.creditsPerGame,
|
||
enabled: !!creditsMode.enabled,
|
||
payoutMultiplier: isPayoutProcessingEnabled() && creditsMode.enabled && config.payouts.rules.credits.enabled
|
||
? gameModeManager.getImplementedPayoutMultipliersForMode('PAID_CREDITS')
|
||
: 0
|
||
},
|
||
rendererCdnEnabled,
|
||
hostedBy
|
||
});
|
||
});
|
||
|
||
// ============================================================================
|
||
// Provably Fair Verification Endpoint
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Verify a game's fairness by checking if the seed matches the commitment hash
|
||
*
|
||
* GET /verify/:gameId - Returns HTML verification page
|
||
* GET /api/verify - Verify seed and commitment (query params: seed, commitment)
|
||
*/
|
||
app.get('/verify/:gameId', asyncHandler(async (req, res) => {
|
||
const { gameId } = req.params;
|
||
|
||
// Try to look up the game in the database
|
||
let gameRecord = null;
|
||
try {
|
||
const result = await databaseManager.query(
|
||
`SELECT dungeon_seed, status, treasure_found, moves_made, duration_seconds, created_at,
|
||
completed_at, proof_version, fairness_offer_id, fairness_offer_issued_at,
|
||
proof_commitment, client_seed, effective_seed, layout_fingerprint,
|
||
layout_fingerprints, generator_version, proof_context,
|
||
CASE WHEN completed_at IS NOT NULL THEN server_seed ELSE NULL END AS server_seed
|
||
FROM games WHERE dungeon_seed = $1`,
|
||
[gameId]
|
||
);
|
||
if (result.rows.length > 0) {
|
||
gameRecord = result.rows[0];
|
||
}
|
||
} catch (err) {
|
||
// Database lookup failed, continue without game record
|
||
}
|
||
|
||
// Return the server-rendered HTML verification page (see src/views/verifyPage.js).
|
||
// Pass an absolute base URL (honours the trusted reverse proxy) + a brand-specific social
|
||
// card image so shared /verify links unfurl with a preview on Twitter/Discord/etc.
|
||
const baseUrl = `${req.protocol}://${req.get('host')}`;
|
||
const ogImage = (process.env.CRYPTO_TYPE === 'XMR') ? 'og-card-xmr.png' : 'og-card-wow.png';
|
||
res.send(renderVerifyPage(gameId, gameRecord, {
|
||
gameName: gameModeManager.gameName,
|
||
baseUrl,
|
||
ogImage
|
||
}));
|
||
}));
|
||
|
||
// Authoritative one-click verification by game UUID. Active games never reveal serverSeed.
|
||
app.get('/api/verify/:gameId', asyncHandler(async (req, res) => {
|
||
const proofResult = await databaseManager.query(
|
||
`SELECT dungeon_seed, status, completed_at, proof_version, fairness_offer_id,
|
||
fairness_offer_issued_at, proof_commitment, server_seed, client_seed,
|
||
effective_seed, layout_fingerprint, layout_fingerprints,
|
||
generator_version, proof_context
|
||
FROM games WHERE dungeon_seed = $1 LIMIT 1`,
|
||
[req.params.gameId]
|
||
);
|
||
const record = proofResult.rows[0];
|
||
if (!record) throw new NotFoundError('Game proof not found', { safeMessage: 'Game proof not found.' });
|
||
if (!record.completed_at) {
|
||
return res.status(409).json({ valid: false, error: 'Proof is revealed only after the game ends.' });
|
||
}
|
||
if (!record.server_seed || !record.proof_commitment || !record.effective_seed) {
|
||
return res.status(422).json({ valid: false, error: 'This legacy game does not have a complete v2 proof.' });
|
||
}
|
||
|
||
const result = verifyGameProof({
|
||
serverSeed: record.server_seed.trim(),
|
||
clientSeed: record.client_seed || '',
|
||
effectiveSeed: record.effective_seed.trim(),
|
||
commitment: record.proof_commitment.trim()
|
||
});
|
||
attachDungeonVerification(result, {
|
||
effectiveSeed: record.effective_seed.trim(),
|
||
proofContext: record.proof_context,
|
||
expectedFingerprint: record.layout_fingerprint?.trim(),
|
||
expectedFingerprints: record.layout_fingerprints,
|
||
generatorVersion: record.generator_version
|
||
});
|
||
result.gameId = record.dungeon_seed;
|
||
result.proofVersion = record.proof_version;
|
||
result.offerId = record.fairness_offer_id;
|
||
result.offerIssuedAt = record.fairness_offer_issued_at;
|
||
res.json(result);
|
||
}));
|
||
|
||
// API endpoint for programmatic verification
|
||
app.get('/api/verify', (req, res) => {
|
||
const { seed, serverSeed, clientSeed, effectiveSeed, commitment } = req.query;
|
||
|
||
// V2: verify the server's precommit AND the client-derived effective dungeon seed.
|
||
if (serverSeed || effectiveSeed || clientSeed !== undefined) {
|
||
if (!serverSeed || !effectiveSeed || !commitment) {
|
||
return res.status(400).json({
|
||
valid: false,
|
||
error: 'Missing required parameters: serverSeed, effectiveSeed, and commitment'
|
||
});
|
||
}
|
||
return res.json(verifyGameProof({ serverSeed, clientSeed: clientSeed || '', effectiveSeed, commitment }));
|
||
}
|
||
|
||
if (!seed || !commitment) {
|
||
return res.status(400).json({
|
||
valid: false,
|
||
error: 'Missing required parameters: seed and commitment'
|
||
});
|
||
}
|
||
|
||
const result = verifyGame(seed, commitment);
|
||
|
||
// Legacy v1 path: when the seed matches the commitment, regenerate the
|
||
// dungeon from the seed and return a layout fingerprint so the player can confirm
|
||
// the layout is a deterministic function of the committed seed (not crafted after payment).
|
||
if (result.valid) {
|
||
try {
|
||
const DungeonGenerator = require('./game/dungeon');
|
||
const dungeon = DungeonGenerator.regenerateFromSeed(seed, process.env.CRYPTO_TYPE || 'WOW');
|
||
result.layoutFingerprint = DungeonGenerator.layoutFingerprint(dungeon);
|
||
result.dungeonSize = { width: dungeon.map[0].length, height: dungeon.map.length };
|
||
result.entrance = dungeon.entrance;
|
||
result.exit = dungeon.exit;
|
||
result.treasure = dungeon.treasure;
|
||
} catch (e) {
|
||
// Regeneration is best-effort; the hash check above is the primary verification.
|
||
}
|
||
}
|
||
|
||
res.json(result);
|
||
});
|
||
|
||
// Initialize debug manager with new block callbacks
|
||
debugManager.onNewBlockCallback((blockHeight) => {
|
||
// Start games for waiting players when new block detected
|
||
socketHandlers.startGamesForWaiting(blockHeight);
|
||
// Check active games for timeout
|
||
Promise.resolve(socketHandlers.checkGamesTimeout(blockHeight)).catch(err => {
|
||
console.error('❌ checkGamesTimeout error:', err.message);
|
||
});
|
||
// Flush pending payouts only when the master switch and at least one per-mode policy are on.
|
||
if (canDispatchPayouts()) {
|
||
gameModeManager._processPendingPayouts().catch(err => {
|
||
console.error('❌ Block-triggered payout processing error:', err.message);
|
||
});
|
||
}
|
||
});
|
||
|
||
// Initialize payment system
|
||
async function initializePaymentSystem() {
|
||
try {
|
||
const production = process.env.NODE_ENV === 'production';
|
||
let paymentsRequested = Boolean(paymentConfigManager.getConfig().paymentsEnabled);
|
||
const walletRequired = RuntimePolicy.isWalletRequired(
|
||
paymentConfigManager.getConfig(),
|
||
gameModeManager,
|
||
{ settleAcceptedLiabilities: true }
|
||
);
|
||
let walletReady = false;
|
||
|
||
if (walletRequired) {
|
||
console.log('🔧 Initializing authenticated wallet boundary...');
|
||
walletReady = Boolean(await walletRPCService.initialize());
|
||
if (!walletReady) {
|
||
if (production) {
|
||
throw new Error('Wallet RPC is unavailable while payment intake or payout settlement is enabled.');
|
||
}
|
||
if (paymentsRequested) {
|
||
console.log('⚠️ Wallet RPC not available - falling back to FREE mode');
|
||
process.env.GAME_MODE = 'FREE';
|
||
paymentConfigManager.refresh();
|
||
gameModeManager.setLegacyGameMode('FREE');
|
||
gameModeManager.paymentsEnabled = false;
|
||
gameModeManager.directModeEnabled = false;
|
||
gameModeManager.creditsModeEnabled = false;
|
||
paymentsRequested = false;
|
||
} else {
|
||
console.log('⚠️ Wallet RPC not available - payout-only settlement remains stopped');
|
||
}
|
||
}
|
||
// A successful startup probe is not permanent health. Refresh the wallet liveness
|
||
// bit in the background so /health/ready turns degraded after an RPC outage.
|
||
let walletProbeRunning = false;
|
||
const requestedWalletProbeMs = Number(process.env.WALLET_HEALTH_INTERVAL_MS);
|
||
const walletProbeMs = Math.max(1000, Math.min(
|
||
Number.isFinite(requestedWalletProbeMs) && requestedWalletProbeMs > 0
|
||
? requestedWalletProbeMs
|
||
: 10000,
|
||
Math.max(1000, Math.floor(walletRPCService.identityMaxAgeMs / 2))
|
||
));
|
||
walletHealthInterval = setInterval(async () => {
|
||
if (walletProbeRunning) return;
|
||
walletProbeRunning = true;
|
||
try {
|
||
// A transport-only get_version response must never revive readiness. Recheck
|
||
// the wallet's validated chain/nettype identity on every liveness probe.
|
||
await walletRPCService.ensureNetworkIdentity({ force: true });
|
||
} catch (_) {
|
||
// ensureNetworkIdentity records isHealthy=false; readiness is fail-closed.
|
||
} finally {
|
||
walletProbeRunning = false;
|
||
}
|
||
}, walletProbeMs);
|
||
walletHealthInterval.unref?.();
|
||
} else {
|
||
console.log('ℹ️ Payments disabled; wallet RPC initialization skipped.');
|
||
}
|
||
|
||
// Initialize database connection and run migrations
|
||
await databaseManager.initialize();
|
||
if (!databaseManager.isConnected()) {
|
||
throw new Error('PostgreSQL is unavailable.');
|
||
}
|
||
console.log('✅ Database initialized successfully');
|
||
databaseHealthInterval = setInterval(() => {
|
||
databaseManager.healthCheck().catch?.(() => {});
|
||
}, Number(process.env.DB_HEALTH_INTERVAL_MS) || 15000);
|
||
databaseHealthInterval.unref?.();
|
||
|
||
// Probe the exact RpcService instance that drives block callbacks. A second independent
|
||
// client could pass startup while the polling instance points at the wrong chain.
|
||
const rpcHealth = debugManager.SIMULATED_BLOCKS
|
||
? { healthy: true, identity: debugManager.getChainIdentity() }
|
||
: await debugManager.rpcService.healthCheck();
|
||
if (production && (!rpcHealth.healthy || rpcHealth.identity?.verified !== true)) {
|
||
throw new Error('No configured blockchain daemon RPC has the expected chain/network identity.');
|
||
}
|
||
console.log(`${rpcHealth.healthy ? '✅' : '⚠️'} RPC Service: ${rpcHealth.healthy ? 'Connected' : 'Unavailable'}`);
|
||
|
||
const paymentSystemReady = paymentsRequested && walletReady;
|
||
console.log(paymentSystemReady ? '🚀 Payment system ready!' : '🚀 Free-play services ready!');
|
||
return { paymentSystemReady, paymentsRequested, walletRequired, walletReady };
|
||
} catch (error) {
|
||
console.error('❌ Failed to initialize payment system:', error?.message || String(error));
|
||
if (process.env.NODE_ENV === 'production') {
|
||
throw error;
|
||
}
|
||
console.log('⚠️ Server will continue in FREE mode only');
|
||
return {
|
||
paymentSystemReady: false,
|
||
paymentsRequested: false,
|
||
walletRequired: false,
|
||
walletReady: false
|
||
};
|
||
}
|
||
}
|
||
|
||
// Start the debug/production system
|
||
debugManager.initialize();
|
||
|
||
// Debug function for registered users
|
||
function debugRegisteredUsers() {
|
||
if (debugManager.CONSOLE_LOGGING) {
|
||
console.log("📊 REGISTERED USERS DEBUG:");
|
||
const allUsers = require('./db/user').getAllUsers();
|
||
console.log(`Total registered users: ${allUsers.length}`);
|
||
allUsers.forEach(u => {
|
||
// u.id is the socket/server id; there is no separate socketId property on User
|
||
console.log(` - User ID: ${u.id}, Socket: ${u.id}, Client: ${u.clientId}`);
|
||
});
|
||
console.log(`Active games: ${activeGames.size}`);
|
||
}
|
||
}
|
||
|
||
// Regular debug logging
|
||
setInterval(debugRegisteredUsers, 10000);
|
||
|
||
// Start server
|
||
async function startServer() {
|
||
try {
|
||
// Initialize payment system first
|
||
const financialRuntime = await initializePaymentSystem();
|
||
const { paymentSystemReady } = financialRuntime;
|
||
|
||
// No listener or paid dispatcher is enabled until every due financial row has been
|
||
// reconciled. Recovery is idempotent and throws FINANCIAL_RECOVERY_INCOMPLETE with a
|
||
// sanitized unresolved-row summary on either a scan or per-row failure.
|
||
const sm = socketHandlers.sessionManager;
|
||
if (!sm || typeof sm.recoverOrphanedGames !== 'function') {
|
||
throw new Error('Orphaned solo-game financial recovery is unavailable.');
|
||
}
|
||
// A graceful predecessor may have frozen exact nonterminal solo runtime state. Rebuild
|
||
// those games into the reconnect cache before orphan recovery scans active rows; invalid
|
||
// or drifted snapshots abort startup before a listener or financial dispatcher exists.
|
||
const restartRestore = await socketHandlers.rehydrateSoloRestartSnapshots();
|
||
if (restartRestore.restored > 0) {
|
||
console.log(`🔄 Rehydrated ${restartRestore.restored} durable solo restart snapshot(s)`);
|
||
}
|
||
await sm.recoverOrphanedGames();
|
||
|
||
// Match queue recovery and payout-liability reconciliation require the migrations that
|
||
// initializePaymentSystem() just applied. Await them before accepting any sockets.
|
||
await socketHandlers.initializeMatchMode();
|
||
financialRecoveryReady = true;
|
||
payoutDispatchReady = financialRuntime.walletReady && isPayoutProcessingEnabled();
|
||
financialEventExporter.start();
|
||
|
||
// Socket.io connection handler - ONLY after payment system is ready
|
||
io.on('connection', function(socket) {
|
||
socketHandlers.handleConnection(socket);
|
||
});
|
||
|
||
// Start HTTP server.
|
||
// Bind all interfaces by default: the reverse proxy (Nginx Proxy Manager) is off-host /
|
||
// containerized and reaches this app over a non-loopback address, so a loopback-only bind
|
||
// makes the public site 502. Operators whose proxy runs on THIS host can set HOST=127.0.0.1.
|
||
// The real "don't expose :3000" hardening is a firewall rule limiting the port to the
|
||
// proxy's source, not the bind address; and X-Forwarded-For is already validated (rightmost
|
||
// hop) so a direct hit can't spoof the client IP.
|
||
const PORT = process.env.PORT || 3000;
|
||
const HOST = process.env.HOST || '0.0.0.0';
|
||
http.listen(PORT, HOST, function() {
|
||
console.log(`🚀 Wownerogue server listening on ${HOST}:${PORT}`);
|
||
console.log(`🐛 Debug mode: ${debugManager.getDebugStatus().debugMode ? 'ENABLED' : 'DISABLED'}`);
|
||
console.log(`💰 Payment system: ${paymentSystemReady ? 'ENABLED' : 'FREE MODE ONLY'}`);
|
||
const summary = paymentConfigManager.summarize();
|
||
const enabledModes = [];
|
||
if (!summary.paymentsEnabled || !paymentSystemReady) {
|
||
enabledModes.push('FREE');
|
||
}
|
||
if (summary.directEnabled && paymentSystemReady) {
|
||
enabledModes.push('PAID_SINGLE');
|
||
}
|
||
if (summary.creditsEnabled && paymentSystemReady) {
|
||
enabledModes.push('PAID_CREDITS');
|
||
}
|
||
if (enabledModes.length === 0) {
|
||
enabledModes.push(summary.legacyMode || 'FREE');
|
||
}
|
||
console.log(`🎮 Available game modes: ${enabledModes.join(', ')}`);
|
||
});
|
||
|
||
// Payout dispatch/retry is intentionally independent from payment intake. A prestige
|
||
// instance may accept paid credits while PAYOUTS_ENABLED=false and must never start a
|
||
// worker capable of sending old queued rows.
|
||
if (canDispatchPayouts()) {
|
||
const payoutConfig = paymentConfigManager.getConfig().payouts?.processing || {};
|
||
const payoutIntervalSeconds = Math.max(1, Number(payoutConfig.batchInterval || 300));
|
||
const payoutIntervalMs = payoutIntervalSeconds * 1000;
|
||
batchPayoutInterval = setInterval(async () => {
|
||
try {
|
||
await gameModeManager._processPendingPayouts();
|
||
} catch (error) {
|
||
console.error('Error in batch payout processing:', error?.message || String(error));
|
||
}
|
||
}, payoutIntervalMs);
|
||
|
||
// Start payout retry service for failed/stuck payouts
|
||
const maxRetries = Number(process.env.PAYOUT_MAX_RETRIES) || 3;
|
||
const retryIntervalMs = Number(process.env.PAYOUT_RETRY_INTERVAL_MS) || 300000; // 5 minutes
|
||
// Initialize alert service for email notifications
|
||
alertService = new AlertService({
|
||
walletService: walletRPCService,
|
||
db: databaseManager,
|
||
debugManager
|
||
});
|
||
|
||
// Make alertService available to gameModeManager/paymentHandlers for balance checks
|
||
gameModeManager.alertService = alertService;
|
||
// And to the admin routes (late-bound via the shared ctx object)
|
||
adminRouteCtx.alertService = alertService;
|
||
|
||
payoutRetryService = new PayoutRetryService({
|
||
db: databaseManager,
|
||
walletService: walletRPCService,
|
||
debugManager,
|
||
maxRetries,
|
||
retryIntervalMs,
|
||
alertService, // Pass alert service for failure notifications
|
||
isEnabled: canDispatchPayouts
|
||
});
|
||
payoutRetryService.start();
|
||
|
||
// Start periodic alert checks (every 5 minutes)
|
||
alertService.startPeriodicChecks(300000);
|
||
}
|
||
|
||
// Reconcile transfers that mined just after invoice expiry into durable manual review.
|
||
// This worker never confirms an invoice or grants an entitlement.
|
||
if (financialRuntime.walletReady) {
|
||
latePaymentReconciler = new LatePaymentReconciler({
|
||
db: databaseManager,
|
||
gameModeManager,
|
||
walletService: walletRPCService,
|
||
lookbackHours: process.env.LATE_PAYMENT_RECONCILE_HOURS,
|
||
batchSize: process.env.LATE_PAYMENT_RECONCILE_BATCH
|
||
});
|
||
latePaymentReconciler.runOnce().catch(error => {
|
||
console.error('[LatePayment] Startup reconciliation error:', error.message);
|
||
});
|
||
}
|
||
|
||
// Periodic job: reconcile, then expire old pending payments (every 5 minutes).
|
||
paymentExpiryInterval = setInterval(async () => {
|
||
try {
|
||
if (latePaymentReconciler) await latePaymentReconciler.runOnce();
|
||
const result = await databaseManager.query(`
|
||
UPDATE payments SET status = 'expired'
|
||
WHERE status = 'pending' AND expires_at < NOW()
|
||
RETURNING id, subaddress
|
||
`);
|
||
if (result.rows.length > 0) {
|
||
for (const row of result.rows) {
|
||
walletRPCService.stopPaymentMonitoring(row.subaddress);
|
||
walletRPCService.addressToUser.delete(row.subaddress);
|
||
walletRPCService.addressToSocket.delete(row.subaddress);
|
||
}
|
||
console.log(`[PaymentExpiry] Expired ${result.rows.length} stale payment(s)`);
|
||
}
|
||
} catch (e) {
|
||
console.error('[PaymentExpiry] Error:', e.message);
|
||
}
|
||
}, 5 * 60 * 1000);
|
||
|
||
} catch (error) {
|
||
console.error('❌ Failed to start server:', error);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
// Attach 404 and error handlers last
|
||
app.use((req, res, next) => {
|
||
next(new NotFoundError(`Route not found: ${req.method} ${req.originalUrl}`, {
|
||
safeMessage: 'The requested resource was not found.'
|
||
}));
|
||
});
|
||
|
||
app.use(createErrorMiddleware({ logger: console }));
|
||
|
||
startServer();
|
||
|
||
// Graceful shutdown — clean up timers, wait for in-flight payouts, close DB pool
|
||
let shutdownStarted = false;
|
||
async function gracefulShutdown(signal, exitCode = 0) {
|
||
if (shutdownStarted) return;
|
||
shutdownStarted = true;
|
||
payoutDispatchReady = false;
|
||
// Freeze solo input first. Any game-over callback already executing has synchronously
|
||
// installed its settlement intent; no new terminal result can appear after the drain sees an
|
||
// empty map.
|
||
socketHandlers?.beginShutdown?.();
|
||
console.log(`\n${signal} received. Shutting down gracefully...`);
|
||
|
||
// A broken dependency must not leave a corrupt process hanging forever. systemd will
|
||
// restart the service after this bounded drain window.
|
||
const forcedExit = setTimeout(() => {
|
||
console.error('Graceful shutdown timed out; forcing process exit.');
|
||
process.exit(exitCode || 1);
|
||
}, 15000);
|
||
forcedExit.unref?.();
|
||
|
||
// Stop accepting new connections
|
||
http.close();
|
||
|
||
// Stop scheduled timers
|
||
if (batchPayoutInterval) clearInterval(batchPayoutInterval);
|
||
if (paymentExpiryInterval) clearInterval(paymentExpiryInterval);
|
||
if (walletHealthInterval) clearInterval(walletHealthInterval);
|
||
if (databaseHealthInterval) clearInterval(databaseHealthInterval);
|
||
financialEventExporter.stop();
|
||
|
||
// Work that crossed the synchronous shutdown gate may still be completing a game start or a
|
||
// sequential block-timeout pass. Drain every such producer before GameManager snapshots its
|
||
// terminal settlement set; otherwise a paused timeout pass could add a later result behind it.
|
||
if (socketHandlers?.drainShutdownProducers) {
|
||
await socketHandlers.drainShutdownProducers();
|
||
}
|
||
|
||
// Terminal solo games are held in a serialized settlement-pending state when their atomic
|
||
// completion transaction fails. Drain those retries before disabling GameModeManager or
|
||
// closing PostgreSQL so a normal deploy/restart cannot turn a known result into an orphan.
|
||
if (socketHandlers?.gameManager?.shutdown) {
|
||
const soloDrain = await socketHandlers.gameManager.shutdown({ timeoutMs: 4000 });
|
||
if (soloDrain.pending > 0) {
|
||
console.error(` ${soloDrain.pending} solo settlement(s) remain unresolved after shutdown drain`);
|
||
const error = new Error('Refusing graceful restart with unresolved solo settlements');
|
||
error.code = 'SOLO_SETTLEMENT_DRAIN_INCOMPLETE';
|
||
throw error;
|
||
} else if (soloDrain.initial > 0) {
|
||
console.log(` Settled ${soloDrain.settled} terminal solo game(s) during shutdown drain`);
|
||
}
|
||
}
|
||
|
||
// Only controlled termination signals may serialize live runtime objects. After an uncaught
|
||
// exception/rejection process state is not trustworthy; startup's orphan recovery remains the
|
||
// explicit economic fallback for those crash paths.
|
||
if (signal === 'SIGTERM' || signal === 'SIGINT') {
|
||
const restartSnapshots = await socketHandlers.persistSoloRestartSnapshots();
|
||
if (restartSnapshots.captured > 0) {
|
||
console.log(` Persisted ${restartSnapshots.captured} active solo game(s) for restart`);
|
||
}
|
||
}
|
||
// Snapshot ownership is now durable. Close Socket.IO transports so no existing connection
|
||
// survives into DB teardown; disconnect cleanup may adjust in-memory maps but cannot erase the
|
||
// PostgreSQL restart snapshots.
|
||
await new Promise((resolve) => io.close(() => resolve()));
|
||
gameModeManager.shutdown();
|
||
if (payoutRetryService) payoutRetryService.stop();
|
||
if (alertService) alertService.stopPeriodicChecks?.();
|
||
|
||
// Wait for in-flight batch processing to finish (up to 10 seconds)
|
||
let waitMs = 0;
|
||
while (gameModeManager._isBatchProcessing && waitMs < 10000) {
|
||
await new Promise(r => setTimeout(r, 500));
|
||
waitMs += 500;
|
||
}
|
||
if (waitMs > 0) {
|
||
console.log(` Waited ${waitMs}ms for in-flight payout processing`);
|
||
}
|
||
|
||
// Close database pool
|
||
try {
|
||
await databaseManager.close();
|
||
console.log(' Database pool closed');
|
||
} catch (err) {
|
||
console.error(' Error closing database pool:', err.message);
|
||
}
|
||
|
||
console.log('Shutdown complete.');
|
||
clearTimeout(forcedExit);
|
||
process.exit(exitCode);
|
||
}
|
||
|
||
// Last-resort process guards. After an uncaught exception, Node process state is not trustworthy.
|
||
// Payout claims are durable/ambiguous-safe, so drain briefly and let the service manager restart.
|
||
process.on('uncaughtException', (err) => {
|
||
console.error('❌ uncaughtException (fatal):', err && err.stack ? err.stack : err);
|
||
try {
|
||
alertService?.sendAlert?.('process_uncaught_exception', {
|
||
subject: 'uncaughtException (server restarting)',
|
||
body: String(err && err.stack ? err.stack : err),
|
||
level: 'error'
|
||
})?.catch?.(() => {});
|
||
} catch (_) {}
|
||
gracefulShutdown('uncaughtException', 1).catch(() => process.exit(1));
|
||
});
|
||
process.on('unhandledRejection', (reason) => {
|
||
console.error('❌ unhandledRejection (fatal):', reason && reason.stack ? reason.stack : reason);
|
||
try {
|
||
alertService?.sendAlert?.('process_unhandled_rejection', {
|
||
subject: 'unhandledRejection (server restarting)',
|
||
body: String(reason && reason.stack ? reason.stack : reason),
|
||
level: 'error'
|
||
})?.catch?.(() => {});
|
||
} catch (_) {}
|
||
gracefulShutdown('unhandledRejection', 1).catch(() => process.exit(1));
|
||
});
|
||
|
||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM', 0).catch((error) => {
|
||
console.error('Graceful SIGTERM failed:', error?.message || error);
|
||
process.exit(1);
|
||
}));
|
||
process.on('SIGINT', () => gracefulShutdown('SIGINT', 0).catch((error) => {
|
||
console.error('Graceful SIGINT failed:', error?.message || error);
|
||
process.exit(1);
|
||
}));
|