Files
wownerogue/src/index.js
T
jwinterm 895aa2af3d fix: input validation + memory-leak/concurrency hardening
- register_client now validates the client-supplied clientId (type, length,
  charset) before it enters clientSocketMap as both key and value, closing a
  map-poisoning / mapping-impersonation vector.
- mempoolNotified is now a Map with real TTL eviction owned by PaymentHandlers
  (the actual dedup set). The dead duplicate Set in SocketHandlers and its
  random-10%-per-cycle eviction (which could drop live entries) are removed;
  addresses are also evicted promptly when monitoring stops.
- The SessionManager `sessions` cache is evicted on disconnect (removeSocket,
  called after the suspend logic reads it) so it no longer grows unbounded
  with every socket ever seen.
- sessionManager.dispose(), suspendedGameManager.cleanup() and
  paymentHandlers.dispose() are wired into SocketHandlers.shutdown() so their
  timers/maps don't leak on shutdown.
- checkGamesTimeout snapshots activeGames before iterating and awaits each
  game-over sequentially (was mutating the Map during forEach and firing
  async game-overs concurrently, which could skip games / double-process).

Adds inputValidation.test.js. Full suite: 19 suites / 149 tests.
2026-05-31 13:46:53 -04:00

2030 lines
68 KiB
JavaScript

const express = require('express');
const path = require('path');
const crypto = require('crypto');
var app = express();
// 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') {
app.set('trust proxy', true);
}
var http = require('http').Server(app);
var io = require('socket.io')(http);
// Load environment configuration
require('dotenv').config();
// 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 GameModeManager = require('./game/gameModeManager');
const RpcService = require('./rpc/rpcService');
const PaymentConfigManager = require('./config/paymentConfig');
const EnvironmentValidator = require('./config/environmentValidator');
const asyncHandler = require('./middleware/asyncHandler');
const createErrorMiddleware = require('./middleware/errorHandler');
const { AppError, ValidationError, NotFoundError } = require('./utils/errors');
// Import modular components
const BroadcastManager = require('./network/broadcastManager');
const DebugManager = require('./debug/debugManager');
const SocketHandlers = require('./network/socketHandlers');
const { verifyGame, hashSeed } = require('./game/provablyFair');
// Initialize payment configuration
const paymentConfigManager = new PaymentConfigManager({ logger: console });
const environmentValidator = new EnvironmentValidator({ logger: console });
environmentValidator.validate(paymentConfigManager.getConfig());
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 rpcService = new RpcService();
const walletRPCService = new WalletRPCService(debugManager);
const gameModeManager = new GameModeManager(databaseManager, walletRPCService, debugManager, paymentConfigManager);
// Provide io reference so GameModeManager can emit events (e.g., credits_update)
gameModeManager.io = io;
// 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
const socketHandlers = new SocketHandlers(io, activeGames, broadcastManager, debugManager, gameModeManager, walletRPCService);
// 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.
app.use((req, res, next) => {
res.setHeader('Content-Security-Policy', [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' https://cdn.socket.io https://cdn.jsdelivr.net",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self' https://smirk.cash",
"object-src 'none'",
"base-uri 'self'",
"frame-ancestors 'self'",
"form-action 'self'"
].join('; '));
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
res.setHeader('Referrer-Policy', 'no-referrer');
next();
});
// 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 });
});
// 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();
}));
app.get('/api/user/:socketId/credits', asyncHandler(async (req, res) => {
const { socketId } = req.params;
if (!socketId) {
throw new ValidationError('Missing socketId', {
safeMessage: 'socketId parameter is required.'
});
}
try {
const user = await gameModeManager.getOrCreateUser(socketId);
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', asyncHandler(async (req, res) => {
const { socketId } = req.params;
if (!socketId) {
throw new ValidationError('Missing socketId', {
safeMessage: 'socketId parameter is required.'
});
}
try {
const user = await gameModeManager.getOrCreateUser(socketId);
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', 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.'
});
}
// Basic address validation (XMR/WOW addresses)
const ADDRESS_REGEX = /((?:4|8)[1-9A-HJ-NP-Za-km-z]{90,110}|(?:Wo|WO|ww|WW)[0-9A-Za-z]{88,112}|W[0-9A-Za-z]{90,112})/;
if (!ADDRESS_REGEX.test(address.trim())) {
throw new ValidationError('Invalid address format', {
safeMessage: 'The provided address does not appear to be a valid XMR/WOW address.'
});
}
try {
const success = await gameModeManager.setUserPayoutAddress(socketId, address.trim());
if (!success) {
throw new AppError('Failed to save address', {
statusCode: 500,
safeMessage: 'Unable to save payout address.'
});
}
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
});
}
}));
// Health check endpoint - enhanced for monitoring dashboard
app.get('/health', async (req, res) => {
const memUsage = process.memoryUsage();
// Get wallet balance - always attempt
let walletBalance = null;
let lowBalanceWarning = false;
const lowBalanceThreshold = parseInt(process.env.LOW_BALANCE_THRESHOLD) || 100000000000; // 0.1 XMR default
try {
walletBalance = await walletRPCService.getBalance();
if (walletBalance.error) {
console.warn('Wallet balance returned error:', walletBalance.error);
} else {
lowBalanceWarning = walletBalance.unlocked_balance < lowBalanceThreshold;
}
} catch (e) {
console.error('Failed to get wallet balance for health check:', e.message);
walletBalance = { balance: 0, unlocked_balance: 0, error: e.message };
}
const health = {
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
nodeVersion: process.version,
environment: process.env.NODE_ENV || 'development',
// Memory details
memory: {
heapUsed: memUsage.heapUsed,
heapTotal: memUsage.heapTotal,
rss: memUsage.rss,
external: memUsage.external || 0
},
// Game statistics
games: {
active: activeGames.size,
queued: socketHandlers?.queueManager?.getQueueLength?.() || 0,
connected: io.sockets.sockets.size || 0,
mode: gameModeManager.gameMode
},
// Blockchain
blockHeight: debugManager.getCurrentBlockHeight(),
blockSource: process.env.BLOCK_SOURCE || 'daemon',
network: process.env.MONERO_NETWORK || 'mainnet',
// Wallet status
wallet: {
available: gameModeManager.paymentsEnabled,
status: walletRPCService.isHealthy ? 'connected' : 'disconnected',
endpoint: process.env.PRIMARY_WALLET_ENDPOINT ? '...' + process.env.PRIMARY_WALLET_ENDPOINT.slice(-15) : null,
pendingPayouts: 0,
balance: walletBalance,
lowBalanceWarning
},
// Rate limiter stats (if available)
rateLimiter: socketHandlers?.rateLimiter ? {
trackedEntries: socketHandlers.rateLimiter.getTrackedCount?.() || 0,
blockedCount: socketHandlers.rateLimiter.getBlockedCount?.() || 0
} : null,
// Limits
limits: {
maxGamesPerHour: parseInt(process.env.MAX_GAMES_PER_HOUR) || 60,
maxPayoutsPerDay: parseInt(process.env.MAX_PAYOUTS_PER_DAY) || 100
},
// Legacy fields for compatibility
gameMode: gameModeManager.gameMode,
paymentsEnabled: gameModeManager.paymentsEnabled,
directModeEnabled: gameModeManager.directModeEnabled,
creditsModeEnabled: gameModeManager.creditsModeEnabled,
walletHealthy: walletRPCService.isHealthy,
activeGames: activeGames.size,
debugMode: debugManager.getDebugStatus().debugMode
};
res.json(health);
});
// Get payment options for a user (mixed mode support)
app.get('/api/user/:socketId/payment-options', 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', 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 {
const user = await gameModeManager.getOrCreateUser(socketId);
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 = parseInt(countResult.rows[0].total_paid, 10) || 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', 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 {
const user = await gameModeManager.getOrCreateUser(socketId);
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: parseInt(totalResult.rows[0].total_received, 10),
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.
// Disable for Monero stagenet deployments (Smirk doesn't support stagenet).
const smirkEnabled = process.env.SMIRK_ENABLED !== 'false';
if (smirkEnabled) {
/**
* POST /api/auth/smirk/challenge
* Generate a challenge for Smirk wallet signature verification
* Body: { socketId: string }
*/
app.post('/api/auth/smirk/challenge', asyncHandler(async (req, res) => {
const { socketId } = req.body || {};
if (!socketId || typeof socketId !== 'string') {
throw new ValidationError('Missing socketId', {
safeMessage: 'socketId is required to generate a challenge.'
});
}
// Generate a cryptographically secure challenge
const challenge = crypto.randomBytes(32).toString('hex');
// Store challenge in database (expires in 5 minutes)
await db.query(`
INSERT INTO smirk_challenges (challenge, socket_id)
VALUES ($1, $2)
`, [challenge, socketId]);
// Clean up old/expired challenges periodically
await db.query(`
DELETE FROM smirk_challenges
WHERE expires_at < NOW() OR (used = TRUE AND created_at < NOW() - INTERVAL '1 hour')
`);
res.json({
challenge,
expiresIn: 300 // 5 minutes in seconds
});
}));
/**
* POST /api/auth/smirk/verify
* Verify a Smirk wallet signature and link to user session
* Body: { socketId: string, challenge: string, publicKey: string, signature: string }
*/
app.post('/api/auth/smirk/verify', asyncHandler(async (req, res) => {
const { socketId, challenge, publicKey, signature } = req.body || {};
if (!socketId || !challenge || !publicKey || !signature) {
throw new ValidationError('Missing required fields', {
safeMessage: 'socketId, challenge, publicKey, and signature are required.'
});
}
// 1. Verify challenge exists, is not used, and has not expired
const challengeResult = await db.query(`
SELECT * FROM smirk_challenges
WHERE challenge = $1 AND socket_id = $2 AND used = FALSE AND expires_at > NOW()
`, [challenge, socketId]);
if (challengeResult.rows.length === 0) {
throw new ValidationError('Invalid or expired challenge', {
safeMessage: 'The challenge is invalid, expired, or has already been used.'
});
}
// 2. Mark challenge as used immediately (prevent replay attacks)
await db.query(`
UPDATE smirk_challenges SET used = TRUE WHERE id = $1
`, [challengeResult.rows[0].id]);
// 3. Verify signature using tweetnacl
// Accepts both formats during transition:
// - RFC 8032 standard: signature over raw UTF-8 message bytes (new extension)
// - Legacy: signature over SHA256(message) (old extension, remove after 2026-07-01)
let isValidSignature = false;
try {
const nacl = require('tweetnacl');
const signatureBytes = Buffer.from(signature, 'hex');
const publicKeyBytes = Buffer.from(publicKey, 'hex');
// Try RFC 8032 standard first (raw message bytes)
const challengeBytes = Buffer.from(challenge, 'utf8');
isValidSignature = nacl.sign.detached.verify(challengeBytes, signatureBytes, publicKeyBytes);
// Fall back to legacy SHA256 pre-hash (DEPRECATED — remove after 2026-07-01)
if (!isValidSignature) {
const challengeHash = crypto.createHash('sha256').update(challenge).digest();
isValidSignature = nacl.sign.detached.verify(challengeHash, signatureBytes, publicKeyBytes);
}
} catch (verifyError) {
console.error('Signature verification error:', verifyError.message);
throw new ValidationError('Signature verification failed', {
safeMessage: 'Unable to verify the wallet signature.'
});
}
if (!isValidSignature) {
throw new ValidationError('Invalid signature', {
safeMessage: 'The wallet signature is invalid.'
});
}
// 4. Link public key to user session
const userResult = await db.query(`
SELECT id FROM users WHERE socket_id = $1
`, [socketId]);
if (userResult.rows.length === 0) {
throw new NotFoundError('User session not found', {
safeMessage: 'No active session found. Please connect to the game first.'
});
}
const userId = userResult.rows[0].id;
// Check if this public key is already linked to another user
const existingLink = await db.query(`
SELECT id FROM users WHERE smirk_public_key = $1 AND id != $2
`, [publicKey, userId]);
if (existingLink.rows.length > 0) {
throw new ValidationError('Wallet already linked', {
safeMessage: 'This wallet is already linked to another account.'
});
}
// Link the wallet to this user
await db.query(`
UPDATE users SET smirk_public_key = $1 WHERE id = $2
`, [publicKey, userId]);
res.json({
success: true,
linked: true,
message: 'Smirk wallet linked successfully'
});
}));
/**
* GET /api/auth/smirk/status
* Check if a session has a linked Smirk wallet
* Query: socketId=string
*/
app.get('/api/auth/smirk/status', asyncHandler(async (req, res) => {
const { socketId } = req.query;
if (!socketId) {
throw new ValidationError('Missing socketId', {
safeMessage: 'socketId query parameter is required.'
});
}
const result = await db.query(`
SELECT smirk_public_key, payout_address FROM users WHERE socket_id = $1
`, [socketId]);
if (result.rows.length === 0) {
res.json({
linked: false,
hasPayoutAddress: false
});
return;
}
const user = result.rows[0];
res.json({
linked: !!user.smirk_public_key,
hasPayoutAddress: !!user.payout_address
});
}));
} // End of smirkEnabled block
// =============================================================================
// Admin API Endpoints (requires ADMIN_API_KEY)
// =============================================================================
/**
* Admin authentication middleware
* Requires X-Admin-Key header to match ADMIN_API_KEY env variable
*/
const adminAuth = (req, res, next) => {
const adminKey = process.env.ADMIN_API_KEY;
if (!adminKey) {
return res.status(503).json({
error: 'Admin API not configured',
message: 'Set ADMIN_API_KEY environment variable to enable admin endpoints.'
});
}
// Origin check — reject cross-origin requests to admin endpoints
// Same-origin requests (Origin matches Host) are always allowed
const origin = req.headers.origin || req.headers.referer;
if (origin) {
try {
const requestOrigin = new URL(origin).origin;
const host = req.headers.host;
const protocol = req.secure || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http';
const selfOrigin = `${protocol}://${host}`;
// Allow same-origin requests (admin.html served from same server)
if (requestOrigin !== selfOrigin) {
// Check explicit allowlist for cross-origin requests
const allowedOrigins = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim())
: [`http://localhost:${process.env.PORT || 3000}`, `http://127.0.0.1:${process.env.PORT || 3000}`];
if (!allowedOrigins.includes(requestOrigin)) {
return res.status(403).json({ error: 'Forbidden', message: 'Origin not allowed.' });
}
}
} catch (e) {
return res.status(403).json({ error: 'Forbidden', message: 'Invalid origin.' });
}
}
const providedKey = req.headers['x-admin-key'];
if (!providedKey ||
providedKey.length !== adminKey.length ||
!crypto.timingSafeEqual(Buffer.from(providedKey), Buffer.from(adminKey))) {
return res.status(401).json({
error: 'Unauthorized',
message: 'Invalid or missing X-Admin-Key header.'
});
}
next();
};
/**
* POST /api/admin/refund/payment
* Refund a payment - marks it as refunded and optionally sends funds back
* Body: { paymentId: number, reason?: string, sendFunds?: boolean }
*/
app.post('/api/admin/refund/payment', adminAuth, asyncHandler(async (req, res) => {
const { paymentId, reason, sendFunds } = req.body || {};
if (!paymentId || typeof paymentId !== 'number') {
throw new ValidationError('Invalid paymentId', {
safeMessage: 'paymentId (number) is required.'
});
}
// Get payment record
const paymentResult = await db.query(`
SELECT p.*, u.payout_address, u.socket_id
FROM payments p
LEFT JOIN users u ON p.user_id = u.id
WHERE p.id = $1
`, [paymentId]);
if (paymentResult.rows.length === 0) {
throw new NotFoundError('Payment not found', {
safeMessage: `Payment ${paymentId} not found.`
});
}
const payment = paymentResult.rows[0];
if (payment.status === 'refunded') {
return res.json({
success: false,
message: 'Payment already refunded.',
payment: { id: payment.id, status: payment.status }
});
}
// Begin transaction
const client = await db.getClient();
try {
await client.query('BEGIN');
// Mark payment as refunded
await client.query(`
UPDATE payments
SET status = 'refunded',
description = COALESCE(description, '') || ' | Refunded: ' || $2
WHERE id = $1
`, [paymentId, reason || 'Admin refund']);
// If credits were purchased, deduct them
let creditsDeducted = 0;
if (payment.credits_purchased > 0 && payment.user_id) {
// SECURITY: Use FOR UPDATE to lock the row and prevent race conditions
const userResult = await client.query(`
SELECT credits FROM users WHERE id = $1 FOR UPDATE
`, [payment.user_id]);
const currentCredits = userResult.rows[0]?.credits || 0;
creditsDeducted = Math.min(payment.credits_purchased, currentCredits);
if (creditsDeducted > 0) {
// SECURITY: Use atomic decrement with guard to prevent negative balance
const updateResult = await client.query(`
UPDATE users SET credits = credits - $1 WHERE id = $2 AND credits >= $1
RETURNING credits
`, [creditsDeducted, payment.user_id]);
let newBalance = updateResult.rows[0]?.credits;
// If update failed (race condition changed credits), recalculate
if (updateResult.rows.length === 0) {
const recheckResult = await client.query(`SELECT credits FROM users WHERE id = $1 FOR UPDATE`, [payment.user_id]);
const actualCredits = recheckResult.rows[0]?.credits || 0;
creditsDeducted = Math.min(payment.credits_purchased, actualCredits);
if (creditsDeducted > 0) {
const retryResult = await client.query(`
UPDATE users SET credits = credits - $1 WHERE id = $2 AND credits >= $1 RETURNING credits
`, [creditsDeducted, payment.user_id]);
newBalance = retryResult.rows[0]?.credits ?? (actualCredits - creditsDeducted);
} else {
newBalance = actualCredits;
}
}
// Record credit transaction
if (creditsDeducted > 0) {
await client.query(`
INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type)
VALUES ($1, $2, $3, $4, 'refund')
`, [payment.user_id, -creditsDeducted, `Refund for payment ${paymentId}`, newBalance]);
}
}
}
await client.query('COMMIT');
// Optionally send funds back via wallet RPC. Refund what the user ACTUALLY paid
// (received_amount, recorded at confirmation) when available, falling back to the
// expected amount for older records.
let fundsSent = false;
let txHash = null;
const refundAmount = (payment.received_amount != null && Number(payment.received_amount) > 0)
? payment.received_amount
: payment.expected_amount;
if (sendFunds && payment.payout_address && Number(refundAmount) > 0) {
try {
// processPayout validates the address and performs the transfer; returns
// { success, txHash, fee }. (The previous code called a nonexistent sendPayment(),
// so refunds were silently never sent while still reporting success.)
const result = await walletRPCService.processPayout({
address: payment.payout_address,
amount: refundAmount,
userId: payment.user_id,
description: `Refund for payment ${paymentId}`
});
fundsSent = !!(result && result.success);
txHash = (result && result.txHash) || null;
} catch (err) {
console.error('Failed to send refund funds:', err.message);
// Don't fail the request - refund is recorded, funds can be sent manually
}
}
res.json({
success: true,
message: 'Payment refunded successfully.',
refund: {
paymentId,
originalAmount: payment.expected_amount,
originalAmountFormatted: gameModeManager.formatAtomicHuman(payment.expected_amount, 4),
creditsDeducted,
fundsSent,
txHash,
reason: reason || 'Admin refund'
}
});
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}));
/**
* POST /api/admin/credits/adjust
* Adjust a user's credit balance (add or subtract)
* Body: { socketId: string, amount: number, reason?: string }
* amount can be positive (add) or negative (subtract)
*/
app.post('/api/admin/credits/adjust', adminAuth, asyncHandler(async (req, res) => {
const { socketId, amount, reason } = req.body || {};
if (!socketId || typeof socketId !== 'string') {
throw new ValidationError('Invalid socketId', {
safeMessage: 'socketId (string) is required.'
});
}
if (typeof amount !== 'number' || amount === 0) {
throw new ValidationError('Invalid amount', {
safeMessage: 'amount (non-zero number) is required. Positive to add, negative to subtract.'
});
}
// Get user
const user = await gameModeManager.getOrCreateUser(socketId);
if (!user) {
throw new NotFoundError('User not found', {
safeMessage: `User with socket ${socketId} not found.`
});
}
const currentCredits = user.credits || 0;
// SECURITY: Use atomic UPDATE to prevent race conditions
// For negative adjustments, ensure user has enough credits
let updateResult;
let actualAdjustment;
let newBalance;
if (amount < 0) {
// Deduction: atomic check that credits >= amount to deduct
const deductAmount = Math.abs(amount);
updateResult = await db.query(`
UPDATE users
SET credits = credits - $1, updated_at = NOW()
WHERE id = $2 AND credits >= $1
RETURNING credits
`, [deductAmount, user.id]);
if (updateResult.rows.length === 0) {
return res.json({
success: false,
message: `Cannot deduct ${deductAmount} credits - user only has ${currentCredits} credits.`,
user: { credits: currentCredits }
});
}
newBalance = updateResult.rows[0].credits;
actualAdjustment = newBalance - currentCredits;
} else {
// Addition: always succeeds
updateResult = await db.query(`
UPDATE users
SET credits = credits + $1, updated_at = NOW()
WHERE id = $2
RETURNING credits
`, [amount, user.id]);
newBalance = updateResult.rows[0]?.credits ?? (currentCredits + amount);
actualAdjustment = amount;
}
if (actualAdjustment === 0) {
return res.json({
success: false,
message: 'No adjustment made.',
user: { credits: currentCredits }
});
}
// Record transaction
const transactionType = actualAdjustment > 0 ? 'admin_credit' : 'admin_debit';
await db.query(`
INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type)
VALUES ($1, $2, $3, $4, $5)
`, [user.id, actualAdjustment, reason || 'Admin adjustment', newBalance, transactionType]);
// Notify user if connected
try {
io.to(socketId).emit('credits_update', {
balance: newBalance,
creditsPerGame: gameModeManager.creditsPerGameCost
});
} catch (_) {}
res.json({
success: true,
message: `Credits ${actualAdjustment > 0 ? 'added' : 'deducted'} successfully.`,
adjustment: {
userId: user.id,
previousBalance: currentCredits,
adjustment: actualAdjustment,
newBalance,
reason: reason || 'Admin adjustment'
}
});
}));
/**
* POST /api/admin/alerts/test-email
* Send a test email to verify alert service configuration
* Requires: X-Admin-Key header
*/
app.post('/api/admin/alerts/test-email', adminAuth, asyncHandler(async (req, res) => {
if (!alertService) {
return res.status(503).json({
success: false,
message: 'Alert service not initialized. Payment system may not be enabled.'
});
}
const result = await alertService.sendAlert('test_email', {
subject: `🧪 Wownerogue Alert Test - ${process.env.NODE_ENV || 'development'}`,
html: `
<h2>Alert Service Test</h2>
<p>If you received this email, the alert notifications are working correctly!</p>
<hr>
<p><strong>Environment:</strong> ${process.env.NODE_ENV || 'development'}</p>
<p><strong>Time:</strong> ${new Date().toISOString()}</p>
<p><strong>Server:</strong> ${process.env.CRYPTO_TYPE || 'WOW'}</p>
`
});
res.json({
success: result.sent,
message: result.sent ? 'Test email sent successfully!' : (result.reason || 'Failed to send'),
details: result
});
}));
// =============================================================================
// Admin Queue Management
// =============================================================================
/**
* GET /api/admin/queue
* Get current queue details for admin dashboard
* Returns full queue entries (not anonymized)
*/
app.get('/api/admin/queue', adminAuth, asyncHandler(async (req, res) => {
if (!socketHandlers?.queueManager) {
return res.status(503).json({
success: false,
message: 'Queue manager not available'
});
}
const queueDetails = socketHandlers.queueManager.getQueueDetailsForAdmin();
res.json({
success: true,
queue: queueDetails,
count: queueDetails.length
});
}));
/**
* POST /api/admin/queue/remove
* Remove a stuck queue entry by serverId
* Body: { serverId: string, reason?: string }
*/
app.post('/api/admin/queue/remove', adminAuth, asyncHandler(async (req, res) => {
const { serverId, reason } = req.body || {};
if (!serverId || typeof serverId !== 'string') {
throw new ValidationError('Missing serverId', {
safeMessage: 'serverId is required to remove a queue entry.'
});
}
if (!socketHandlers?.queueManager) {
return res.status(503).json({
success: false,
message: 'Queue manager not available'
});
}
// Check if entry exists first
const isQueued = socketHandlers.queueManager.isPlayerQueued(serverId);
if (!isQueued) {
return res.status(404).json({
success: false,
message: `No queue entry found for serverId: ${serverId}`
});
}
// Remove the entry
const removed = socketHandlers.queueManager.removePlayer(serverId);
// Log admin action
console.log(`[Admin] Queue entry removed: serverId=${serverId}, reason="${reason || 'No reason provided'}", by admin at ${new Date().toISOString()}`);
// Optionally notify the user via socket (if still connected)
try {
io.to(serverId).emit('queue_cancelled', {
reason: 'admin_removed',
message: 'Your queue entry was removed by an administrator.'
});
} catch (notifyErr) {
// Ignore errors - user may be disconnected
}
res.json({
success: removed,
message: removed ? 'Queue entry removed successfully' : 'Failed to remove queue entry',
serverId: serverId
});
}));
// =============================================================================
// ADMIN CHAT MODERATION ENDPOINTS
// =============================================================================
/**
* GET /api/admin/chat
* List recent chat messages for moderation
* Query: ?limit=100&includeDeleted=false
*/
app.get('/api/admin/chat', adminAuth, asyncHandler(async (req, res) => {
const limit = Math.min(parseInt(req.query.limit) || 100, 500);
const includeDeleted = req.query.includeDeleted === 'true';
const messages = await socketHandlers.chatHandler.chatHistory.getMessagesForAdmin(limit, includeDeleted);
res.json({
messages,
count: messages.length
});
}));
/**
* DELETE /api/admin/chat/:id
* Soft delete a chat message
* Body: { reason?: string }
*/
app.delete('/api/admin/chat/:id', adminAuth, asyncHandler(async (req, res) => {
const messageId = parseInt(req.params.id);
const { reason } = req.body;
if (!messageId || isNaN(messageId)) {
throw new ValidationError('Invalid message ID');
}
const deleted = await socketHandlers.chatHandler.chatHistory.deleteMessage(
messageId,
'admin',
reason || 'Admin deleted'
);
if (deleted) {
// Broadcast deletion to all connected clients
io.emit('chat_deleted', { messageId });
console.log(`[Admin] Chat message deleted: id=${messageId}, reason="${reason || 'No reason provided'}"`);
}
res.json({
success: deleted,
message: deleted ? 'Message deleted' : 'Message not found or already deleted'
});
}));
/**
* POST /api/admin/users/:id/chat-ban
* Ban or unban a user from chat
* Body: { banned: boolean, reason?: string }
*/
app.post('/api/admin/users/:id/chat-ban', adminAuth, asyncHandler(async (req, res) => {
const userId = parseInt(req.params.id);
const { banned, reason } = req.body;
if (!userId || isNaN(userId)) {
throw new ValidationError('Invalid user ID');
}
if (typeof banned !== 'boolean') {
throw new ValidationError('banned must be a boolean');
}
await db.query(`
UPDATE users
SET chat_banned = $2,
chat_banned_at = CASE WHEN $2 THEN NOW() ELSE NULL END,
chat_banned_reason = $3
WHERE id = $1
`, [userId, banned, reason || null]);
console.log(`[Admin] User ${userId} chat ${banned ? 'banned' : 'unbanned'}: reason="${reason || 'No reason provided'}"`);
res.json({
success: true,
userId,
banned,
reason: reason || null
});
}));
/**
* GET /api/admin/users/search
* Search for users by socket ID prefix or payout address
* Query: ?q=searchterm&limit=20
*/
app.get('/api/admin/users/search', adminAuth, asyncHandler(async (req, res) => {
const { q, limit = 20 } = req.query;
if (!q || q.length < 3) {
throw new ValidationError('Invalid search query', {
safeMessage: 'Search query must be at least 3 characters.'
});
}
const searchLimit = Math.min(parseInt(limit, 10) || 20, 100);
const result = await db.query(`
SELECT id, socket_id, payout_address, credits, total_games_played, total_credits_purchased, created_at
FROM users
WHERE socket_id ILIKE $1 OR payout_address ILIKE $1
ORDER BY created_at DESC
LIMIT $2
`, [`%${q}%`, searchLimit]);
res.json({
users: result.rows.map(u => ({
id: u.id,
socketId: u.socket_id,
payoutAddress: u.payout_address ? `${u.payout_address.substring(0, 10)}...` : null,
credits: u.credits || 0,
gamesPlayed: u.total_games_played || 0,
createdAt: u.created_at
})),
total: result.rows.length
});
}));
// =============================================================================
// Admin Stats Endpoints
// =============================================================================
// GET /api/admin/stats/overview - Server-wide statistics
app.get('/api/admin/stats/overview', adminAuth, asyncHandler(async (req, res) => {
// Get user counts
const userStats = await db.query(`
SELECT
COUNT(*) as total_users,
SUM(total_games_played) as total_games,
SUM(total_games_won) as total_wins,
SUM(total_amount_won) as total_payout_volume,
COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '24 hours') as new_users_24h
FROM users
`);
// Get payout counts
const payoutStats = await db.query(`
SELECT
COUNT(*) FILTER (WHERE status = 'pending') as pending_payouts,
COUNT(*) FILTER (WHERE status = 'failed') as failed_payouts,
COUNT(*) FILTER (WHERE status = 'completed') as completed_payouts,
COUNT(*) FILTER (WHERE status = 'completed' AND processed_at > NOW() - INTERVAL '24 hours') as payouts_24h
FROM payouts
`);
// Get game counts for last 24h
const gameStats = await db.query(`
SELECT
COUNT(*) as games_24h,
COUNT(*) FILTER (WHERE status = 'won') as wins_24h,
COUNT(*) FILTER (WHERE status = 'lost') as losses_24h
FROM games
WHERE created_at > NOW() - INTERVAL '24 hours'
`);
// Get wallet balance - always attempt, even if isHealthy is false
let walletBalance = null;
try {
walletBalance = await walletRPCService.getBalance();
if (walletBalance.error) {
console.warn('Wallet balance returned error:', walletBalance.error);
}
} catch (e) {
console.error('Failed to get wallet balance for admin stats:', e.message);
walletBalance = { balance: 0, unlocked_balance: 0, error: e.message };
}
const user = userStats.rows[0];
const payout = payoutStats.rows[0];
const game = gameStats.rows[0];
const cryptoType = gameModeManager.cryptoType || process.env.CRYPTO_TYPE || 'WOW';
const { inferCurrencyDecimals, getDecimalDivisor } = require('./game/helpers/gameModeUtils');
const atomicDivisor = getDecimalDivisor(inferCurrencyDecimals(cryptoType));
res.json({
totalUsers: parseInt(user.total_users) || 0,
totalGamesPlayed: parseInt(user.total_games) || 0,
totalGamesWon: parseInt(user.total_wins) || 0,
totalPayoutVolume: user.total_payout_volume || '0',
pendingPayouts: parseInt(payout.pending_payouts) || 0,
failedPayouts: parseInt(payout.failed_payouts) || 0,
completedPayouts: parseInt(payout.completed_payouts) || 0,
walletBalance: walletBalance,
cryptoType,
atomicDivisor,
last24h: {
games: parseInt(game.games_24h) || 0,
wins: parseInt(game.wins_24h) || 0,
losses: parseInt(game.losses_24h) || 0,
payouts: parseInt(payout.payouts_24h) || 0,
newUsers: parseInt(user.new_users_24h) || 0
}
});
}));
// GET /api/admin/stats/payouts - Payout details
app.get('/api/admin/stats/payouts', adminAuth, asyncHandler(async (req, res) => {
const { status, limit = 50, offset = 0 } = req.query;
const limitNum = Math.min(parseInt(limit) || 50, 200);
const offsetNum = parseInt(offset) || 0;
let whereClause = '';
const params = [limitNum, offsetNum];
if (status && ['pending', 'failed', 'completed', 'permanently_failed'].includes(status)) {
whereClause = 'WHERE status = $3';
params.push(status);
}
const result = await db.query(`
SELECT p.id, p.game_id, p.user_id, p.amount, p.payout_address, p.status,
p.tx_hash, p.fee, p.retry_count, p.last_error, p.created_at, p.processed_at
FROM payouts p
${whereClause}
ORDER BY p.created_at DESC
LIMIT $1 OFFSET $2
`, params);
// Get totals
const totals = await db.query(`
SELECT
COUNT(*) FILTER (WHERE status = 'pending') as pending_count,
COUNT(*) FILTER (WHERE status = 'failed') as failed_count,
COUNT(*) FILTER (WHERE status = 'completed') as completed_count,
COUNT(*) FILTER (WHERE status = 'permanently_failed') as permanently_failed_count,
COALESCE(SUM(amount) FILTER (WHERE status = 'completed'), 0) as total_volume
FROM payouts
`);
res.json({
payouts: result.rows.map(p => ({
id: p.id,
gameId: p.game_id,
userId: p.user_id,
amount: p.amount,
address: p.payout_address,
status: p.status,
txHash: p.tx_hash,
fee: p.fee,
retryCount: p.retry_count,
lastError: p.last_error,
createdAt: p.created_at,
processedAt: p.processed_at
})),
totals: {
pending: parseInt(totals.rows[0].pending_count) || 0,
failed: parseInt(totals.rows[0].failed_count) || 0,
completed: parseInt(totals.rows[0].completed_count) || 0,
permanentlyFailed: parseInt(totals.rows[0].permanently_failed_count) || 0,
totalVolume: totals.rows[0].total_volume || '0'
}
});
}));
// GET /api/admin/stats/games - Game statistics
app.get('/api/admin/stats/games', adminAuth, asyncHandler(async (req, res) => {
const { period = '24h', limit = 50 } = req.query;
const limitNum = Math.min(parseInt(limit) || 50, 200);
// Parse period
let interval = '24 hours';
if (period === '7d') interval = '7 days';
else if (period === '30d') interval = '30 days';
// Summary stats
const summary = await db.query(`
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE status = 'won') as won,
COUNT(*) FILTER (WHERE status = 'lost') as lost,
COUNT(*) FILTER (WHERE outcome = 'escaped') as escaped,
COUNT(*) FILTER (WHERE outcome = 'caught_by_monster') as caught,
COUNT(*) FILTER (WHERE outcome = 'expired') as expired,
AVG(duration_seconds) as avg_duration
FROM games
WHERE created_at > NOW() - INTERVAL '${interval}'
`);
// By game mode
const byMode = await db.query(`
SELECT game_mode, COUNT(*) as count
FROM games
WHERE created_at > NOW() - INTERVAL '${interval}'
GROUP BY game_mode
`);
// Top winners
const topWinners = await db.query(`
SELECT u.socket_id, u.total_games_won as wins, u.total_amount_won as total_won
FROM users u
WHERE u.total_games_won > 0
ORDER BY u.total_amount_won DESC
LIMIT 10
`);
// Recent games
const recentGames = await db.query(`
SELECT g.id, g.game_mode, g.status, g.outcome, g.duration_seconds, g.created_at,
p.amount as payout_amount
FROM games g
LEFT JOIN payouts p ON p.game_id = g.id AND p.status = 'completed'
ORDER BY g.created_at DESC
LIMIT $1
`, [limitNum]);
const s = summary.rows[0];
const modeMap = {};
byMode.rows.forEach(r => { modeMap[r.game_mode] = parseInt(r.count) || 0; });
res.json({
summary: {
total: parseInt(s.total) || 0,
won: parseInt(s.won) || 0,
lost: parseInt(s.lost) || 0,
escaped: parseInt(s.escaped) || 0,
caught: parseInt(s.caught) || 0,
expired: parseInt(s.expired) || 0
},
avgDuration: parseFloat(s.avg_duration) || 0,
byMode: modeMap,
topWinners: topWinners.rows.map(w => ({
socketId: w.socket_id?.substring(0, 20) + '...',
wins: parseInt(w.wins) || 0,
totalWon: w.total_won || '0'
})),
recentGames: recentGames.rows.map(g => ({
id: g.id,
mode: g.game_mode,
status: g.status,
outcome: g.outcome,
duration: g.duration_seconds,
payoutAmount: g.payout_amount,
createdAt: g.created_at
}))
});
}));
// GET /api/admin/users - Paginated user list
app.get('/api/admin/users', adminAuth, asyncHandler(async (req, res) => {
const { search, limit = 50, offset = 0 } = req.query;
const limitNum = Math.min(parseInt(limit) || 50, 200);
const offsetNum = parseInt(offset) || 0;
let whereClause = '';
const params = [limitNum, offsetNum];
if (search && search.length >= 3) {
whereClause = 'WHERE socket_id ILIKE $3 OR payout_address ILIKE $3';
params.push(`%${search}%`);
}
const result = await db.query(`
SELECT id, socket_id, payout_address, credits, total_games_played, total_games_won,
total_amount_paid, total_amount_won, total_credits_purchased, created_at
FROM users
${whereClause}
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
`, params);
const countResult = await db.query(`
SELECT COUNT(*) as total FROM users ${whereClause}
`, search && search.length >= 3 ? [`%${search}%`] : []);
res.json({
users: result.rows.map(u => ({
id: u.id,
socketId: u.socket_id,
payoutAddress: u.payout_address ? `${u.payout_address.substring(0, 15)}...` : null,
credits: u.credits || 0,
gamesPlayed: parseInt(u.total_games_played) || 0,
gamesWon: parseInt(u.total_games_won) || 0,
totalPaid: u.total_amount_paid || '0',
totalWon: u.total_amount_won || '0',
creditsPurchased: parseInt(u.total_credits_purchased) || 0,
createdAt: u.created_at
})),
total: parseInt(countResult.rows[0]?.total) || 0,
limit: limitNum,
offset: offsetNum
});
}));
// GET /api/admin/users/:id - Detailed user info
app.get('/api/admin/users/:id', adminAuth, asyncHandler(async (req, res) => {
const { id } = req.params;
const userResult = await db.query(`
SELECT * FROM users WHERE id = $1
`, [id]);
if (!userResult.rows.length) {
throw new NotFoundError('User not found');
}
const user = userResult.rows[0];
// Get user's games
const gamesResult = await db.query(`
SELECT id, game_mode, status, outcome, treasure_found, moves_made, duration_seconds, created_at
FROM games
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT 50
`, [id]);
// Get user's payments
const paymentsResult = await db.query(`
SELECT id, amount, status, tx_hash, credits_purchased, created_at
FROM payments
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT 50
`, [id]);
// Get user's payouts
const payoutsResult = await db.query(`
SELECT id, amount, status, tx_hash, fee, payout_address, created_at, processed_at
FROM payouts
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT 50
`, [id]);
res.json({
user: {
id: user.id,
socketId: user.socket_id,
payoutAddress: user.payout_address,
credits: user.credits || 0,
gamesPlayed: parseInt(user.total_games_played) || 0,
gamesWon: parseInt(user.total_games_won) || 0,
totalPaid: user.total_amount_paid || '0',
totalWon: user.total_amount_won || '0',
creditsPurchased: parseInt(user.total_credits_purchased) || 0,
createdAt: user.created_at
},
games: gamesResult.rows,
payments: paymentsResult.rows,
payouts: payoutsResult.rows
});
}));
// POST /api/admin/payouts/:id/retry - Manually retry a failed payout
app.post('/api/admin/payouts/:id/retry', adminAuth, asyncHandler(async (req, res) => {
const { id } = req.params;
const payoutResult = await db.query(`
SELECT * FROM payouts WHERE id = $1
`, [id]);
if (!payoutResult.rows.length) {
throw new NotFoundError('Payout not found');
}
const payout = payoutResult.rows[0];
if (payout.status === 'completed') {
throw new ValidationError('Payout already completed');
}
// Reset status to pending for retry
await db.query(`
UPDATE payouts SET status = 'pending', last_error = 'Manual retry requested' WHERE id = $1
`, [id]);
res.json({
success: true,
message: 'Payout queued for retry',
payoutId: id
});
}));
// =============================================================================
// LEADERBOARD
// =============================================================================
app.get('/api/leaderboard', asyncHandler(async (req, res) => {
const period = req.query.period || 'all';
const limit = Math.min(parseInt(req.query.limit) || 20, 50);
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'";
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}
GROUP BY u.id, u.display_name, u.payout_address
ORDER BY best_score DESC
LIMIT $1
`, [limit]);
res.json({ leaderboard: result.rows, period });
}));
app.get('/api/game-modes', (req, res) => {
const config = paymentConfigManager.getConfig();
const decimals = Number(config.currency?.decimals ?? 12);
const divisor = Number.isFinite(decimals) ? Math.pow(10, decimals) : 1;
const toDisplay = (value) => {
if (value === null || value === undefined) {
return 0;
}
const numeric = typeof value === 'bigint' ? Number(value) : Number(value);
return Number.isFinite(numeric) ? numeric / divisor : 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) {
let hostname = process.env.HOSTED_BY_NAME;
if (!hostname) {
try {
hostname = new URL(process.env.HOSTED_BY).hostname;
} catch (e) {
hostname = process.env.HOSTED_BY; // Use raw value if URL parsing fails
}
}
hostedBy = {
url: process.env.HOSTED_BY,
name: hostname
};
}
res.json({
FREE: {
name: 'Free Play',
cost: 0,
payoutMultiplier: 0,
enabled: !config.paymentsEnabled
},
PAID_SINGLE: {
name: 'Paid Single Game',
cost: toDisplay(directMode.price),
enabled: !!directMode.enabled,
payoutMultiplier: config.payouts.rules.direct.multipliers
},
PAID_CREDITS: {
name: 'Credits Package',
cost: toDisplay(creditsMode.packages?.[0]?.price ?? 0),
credits: creditsMode.packages?.[0]?.credits ?? creditsMode.creditsPerGame,
enabled: !!creditsMode.enabled,
payoutMultiplier: creditsMode.enabled && config.payouts.rules.credits.enabled
? config.payouts.rules.credits.multipliers
: 0
},
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 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 a simple HTML verification page
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Wownerogue - Game Verification</title>
<style>
body { background: #0a0a0a; color: #0f0; font-family: monospace; padding: 20px; max-width: 800px; margin: 0 auto; }
h1 { color: #0ff; }
.box { background: #001a00; border: 1px solid #0f0; padding: 15px; margin: 10px 0; border-radius: 5px; }
input, button { font-family: monospace; padding: 8px; margin: 5px 0; }
input { background: #001a00; color: #0f0; border: 1px solid #0f0; width: 100%; box-sizing: border-box; }
button { background: #0a5c0a; color: #ff0; border: 2px solid #0f0; cursor: pointer; }
button:hover { background: #0f0; color: #000; }
.success { color: #4ade80; }
.error { color: #f00; }
#result { margin-top: 15px; padding: 10px; }
code { background: #002200; padding: 2px 6px; border-radius: 3px; }
.info { color: #888; font-size: 12px; }
</style>
</head>
<body>
<h1>🔐 Wownerogue Provably Fair Verification</h1>
<div class="box">
<h3>How it works:</h3>
<ol>
<li>Before game start, you received a <strong>commitment hash</strong> (SHA-256)</li>
<li>After the game, you received the <strong>seed</strong></li>
<li>This page verifies: <code>SHA256(seed) === commitment</code></li>
<li>The seed deterministically generated your dungeon layout</li>
</ol>
</div>
<div class="box">
<h3>Verify Game</h3>
<label>Game Seed (revealed after game):</label>
<input type="text" id="seed" placeholder="64 character hex string">
<label>Commitment Hash (shown before game):</label>
<input type="text" id="commitment" placeholder="64 character hex string">
<button onclick="verify()">🔍 Verify</button>
<div id="result"></div>
</div>
${gameRecord ? `
<div class="box">
<h3>Game Record</h3>
<p><strong>Game ID:</strong> ${gameId}</p>
<p><strong>Status:</strong> ${gameRecord.status}</p>
<p><strong>Treasure Found:</strong> ${gameRecord.treasure_found ? 'Yes' : 'No'}</p>
<p><strong>Moves:</strong> ${gameRecord.moves_made || 'N/A'}</p>
<p><strong>Duration:</strong> ${gameRecord.duration_seconds ? gameRecord.duration_seconds + 's' : 'N/A'}</p>
<p><strong>Created:</strong> ${gameRecord.created_at}</p>
</div>
` : ''}
<div class="box info">
<p>For technical verification, you can also use the API:</p>
<code>GET /api/verify?seed=YOUR_SEED&commitment=YOUR_COMMITMENT</code>
</div>
<script>
async function verify() {
const seed = document.getElementById('seed').value.trim();
const commitment = document.getElementById('commitment').value.trim();
const result = document.getElementById('result');
if (!seed || !commitment) {
result.innerHTML = '<p class="error">Please enter both seed and commitment.</p>';
return;
}
try {
const response = await fetch('/api/verify?seed=' + encodeURIComponent(seed) + '&commitment=' + encodeURIComponent(commitment));
const data = await response.json();
if (data.valid) {
result.innerHTML = '<p class="success">✅ ' + data.message + '</p>' +
'<p>Computed hash: <code>' + data.computedHash + '</code></p>';
} else {
result.innerHTML = '<p class="error">❌ ' + data.message + '</p>' +
'<p>Expected: <code>' + data.expectedCommitment + '</code></p>' +
'<p>Got: <code>' + data.computedHash + '</code></p>';
}
} catch (err) {
result.innerHTML = '<p class="error">Error: ' + err.message + '</p>';
}
}
</script>
</body>
</html>
`);
}));
// API endpoint for programmatic verification
app.get('/api/verify', (req, res) => {
const { seed, commitment } = req.query;
if (!seed || !commitment) {
return res.status(400).json({
valid: false,
error: 'Missing required parameters: seed and commitment'
});
}
const result = verifyGame(seed, commitment);
// PROVABLY FAIR (Phase 0.2): 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 any pending payouts (safety net for batch timer)
gameModeManager._processPendingPayouts().catch(err => {
console.error('❌ Block-triggered payout processing error:', err.message);
});
});
// Initialize payment system
async function initializePaymentSystem() {
try {
console.log('🔧 Initializing payment system...');
const walletInitialized = await walletRPCService.initialize();
if (!walletInitialized) {
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;
}
// Initialize database connection and run migrations
await databaseManager.initialize();
console.log('✅ Database initialized successfully');
// Test RPC service connectivity
const rpcHealth = await rpcService.healthCheck();
console.log(`✅ RPC Service: ${rpcHealth.healthy ? 'Connected' : 'Warning - using fallback'}`);
console.log('🚀 Payment system ready!');
return true;
} catch (error) {
console.error('❌ Failed to initialize payment system:', error);
console.log('⚠️ Server will continue in FREE mode only');
return 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 paymentSystemReady = await initializePaymentSystem();
// Socket.io connection handler - ONLY after payment system is ready
io.on('connection', function(socket) {
socketHandlers.handleConnection(socket);
});
// Start HTTP server
const PORT = process.env.PORT || 3000;
http.listen(PORT, function() {
console.log(`🚀 Wownerogue server listening on *:${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(', ')}`);
});
// Start batch payout processing if payment system is ready
if (paymentSystemReady) {
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);
}
}, 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;
payoutRetryService = new PayoutRetryService({
db: databaseManager,
walletService: walletRPCService,
debugManager,
maxRetries,
retryIntervalMs,
alertService // Pass alert service for failure notifications
});
payoutRetryService.start();
// Start periodic alert checks (every 5 minutes)
alertService.startPeriodicChecks(300000);
}
// Periodic job: expire old pending payments (every 5 minutes)
paymentExpiryInterval = setInterval(async () => {
try {
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
async function gracefulShutdown(signal) {
console.log(`\n${signal} received. Shutting down gracefully...`);
// Stop accepting new connections
http.close();
// Stop scheduled timers
if (batchPayoutInterval) clearInterval(batchPayoutInterval);
if (paymentExpiryInterval) clearInterval(paymentExpiryInterval);
if (gameModeManager._batchPayoutTimer) clearTimeout(gameModeManager._batchPayoutTimer);
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.');
process.exit(0);
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));