From 76a4773d1f09923f91521199b9a8e08fa206a188 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 08:23:38 +0000 Subject: [PATCH] Security, stability & performance quick wins from full codebase audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security: - Make gold/diamonds/essence/forgeLevel tamper-resistant: server only accepts values that decreased (spending) or forge level that increased, preventing clients from inflating currency - Fix refund gold clawback to use atomic transaction with decrement instead of racy read-then-write pattern - Fix admin gold/essence/diamonds adjustments to use atomic transactions Stability: - Remove competing SIGINT/SIGTERM handlers from prisma.js (index.js handles shutdown in the correct order: Socket.IO → HTTP → Prisma) - Fix trashItem() to actually clear the equipment slot, removing the need for home.js to directly mutate state internals - Add negative amount guard to spendGold() Performance: - Add Cache-Control header to monster templates endpoint (matches equipment and player template endpoints) - Create lightweight LIST_CLAN_INCLUDE for clan list endpoint, avoiding loading full gameState JSON blobs for 25 clans × N members Co-Authored-By: Claude Opus 4.6 (1M context) Claude-Session: https://claude.ai/code/session_01PakRpNc4HcG1sRQwy3h249 --- server/lib/prisma.js | 8 ++--- server/routes/admin.js | 69 +++++++++++++++++++++++++-------------- server/routes/clans.js | 17 +++++++++- server/routes/game.js | 53 ++++++++++++++---------------- server/routes/monsters.js | 1 + server/routes/payment.js | 22 ++++++++----- src/game/state.js | 5 ++- src/screens/home.js | 3 +- 8 files changed, 106 insertions(+), 72 deletions(-) diff --git a/server/lib/prisma.js b/server/lib/prisma.js index 0f00505..5ea0386 100644 --- a/server/lib/prisma.js +++ b/server/lib/prisma.js @@ -4,11 +4,7 @@ const prisma = new PrismaClient({ log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'], }); -function gracefulShutdown(signal) { - console.log(`${signal} received — closing Prisma connection`); - prisma.$disconnect().then(() => process.exit(0)); -} -process.on('SIGINT', gracefulShutdown); -process.on('SIGTERM', gracefulShutdown); +// Shutdown is handled by server/index.js which closes HTTP, Socket.IO, +// then calls prisma.$disconnect() in the correct order. export default prisma; diff --git a/server/routes/admin.js b/server/routes/admin.js index cda645f..87975d3 100644 --- a/server/routes/admin.js +++ b/server/routes/admin.js @@ -331,14 +331,21 @@ router.post('/users/:id/gold', requireRole('admin'), async (req, res) => { } try { - const state = await prisma.gameState.findUnique({ where: { userId } }); - if (!state) return res.status(404).json({ error: 'Game state not found' }); - - const newGold = Math.max(0, state.gold + Math.floor(amount)); - await prisma.gameState.update({ where: { userId }, data: { gold: newGold } }); - await logAudit(req.user.userId, 'add_gold', userId, { amount, newGold }); - - res.json({ gold: newGold }); + const adj = Math.floor(amount); + const result = await prisma.$transaction(async (tx) => { + const state = await tx.gameState.findUnique({ where: { userId }, select: { gold: true } }); + if (!state) return null; + const clampedAdj = adj < 0 ? Math.max(-state.gold, adj) : adj; + const gs = await tx.gameState.update({ + where: { userId }, + data: { gold: { increment: clampedAdj } }, + select: { gold: true }, + }); + return gs; + }); + if (!result) return res.status(404).json({ error: 'Game state not found' }); + await logAudit(req.user.userId, 'add_gold', userId, { amount, newGold: result.gold }); + res.json({ gold: result.gold }); } catch (err) { console.error('Add gold error:', err); res.status(500).json({ error: 'Internal server error' }); @@ -355,14 +362,21 @@ router.post('/users/:id/essence', requireRole('admin'), async (req, res) => { } try { - const state = await prisma.gameState.findUnique({ where: { userId } }); - if (!state) return res.status(404).json({ error: 'Game state not found' }); - - const newEssence = Math.max(0, state.essence + Math.floor(amount)); - await prisma.gameState.update({ where: { userId }, data: { essence: newEssence } }); - await logAudit(req.user.userId, 'add_essence', userId, { amount, newEssence }); - - res.json({ essence: newEssence }); + const adj = Math.floor(amount); + const result = await prisma.$transaction(async (tx) => { + const state = await tx.gameState.findUnique({ where: { userId }, select: { essence: true } }); + if (!state) return null; + const clampedAdj = adj < 0 ? Math.max(-state.essence, adj) : adj; + const gs = await tx.gameState.update({ + where: { userId }, + data: { essence: { increment: clampedAdj } }, + select: { essence: true }, + }); + return gs; + }); + if (!result) return res.status(404).json({ error: 'Game state not found' }); + await logAudit(req.user.userId, 'add_essence', userId, { amount, newEssence: result.essence }); + res.json({ essence: result.essence }); } catch (err) { console.error('Add essence error:', err); res.status(500).json({ error: 'Internal server error' }); @@ -379,14 +393,21 @@ router.post('/users/:id/diamonds', requireRole('admin'), async (req, res) => { } try { - const state = await prisma.gameState.findUnique({ where: { userId } }); - if (!state) return res.status(404).json({ error: 'Game state not found' }); - - const newDiamonds = Math.max(0, state.diamonds + Math.floor(amount)); - await prisma.gameState.update({ where: { userId }, data: { diamonds: newDiamonds } }); - await logAudit(req.user.userId, 'add_diamonds', userId, { amount, newDiamonds }); - - res.json({ diamonds: newDiamonds }); + const adj = Math.floor(amount); + const result = await prisma.$transaction(async (tx) => { + const state = await tx.gameState.findUnique({ where: { userId }, select: { diamonds: true } }); + if (!state) return null; + const clampedAdj = adj < 0 ? Math.max(-state.diamonds, adj) : adj; + const gs = await tx.gameState.update({ + where: { userId }, + data: { diamonds: { increment: clampedAdj } }, + select: { diamonds: true }, + }); + return gs; + }); + if (!result) return res.status(404).json({ error: 'Game state not found' }); + await logAudit(req.user.userId, 'add_diamonds', userId, { amount, newDiamonds: result.diamonds }); + res.json({ diamonds: result.diamonds }); } catch (err) { console.error('Add diamonds error:', err); res.status(500).json({ error: 'Internal server error' }); diff --git a/server/routes/clans.js b/server/routes/clans.js index aebfb2f..5311353 100644 --- a/server/routes/clans.js +++ b/server/routes/clans.js @@ -88,6 +88,21 @@ const MEMBER_INCLUDE = { const FULL_CLAN_INCLUDE = { ...MEMBER_INCLUDE, _count: { select: { members: true } } }; +// Lightweight include for list views — loads only the data needed for power computation +// and member count, skipping full player/gameState JSON blobs. +const LIST_CLAN_INCLUDE = { + members: { + include: { + user: { + select: { + gameState: { select: { equipment: true } }, + }, + }, + }, + }, + _count: { select: { members: true } }, +}; + /** Load the requesting user's membership (or null). */ function getMembership(userId) { return prisma.clanMember.findUnique({ where: { userId } }); @@ -220,7 +235,7 @@ router.get('/', requireAuth, async (req, res) => { where, orderBy: { xp: 'desc' }, take: 25, - include: FULL_CLAN_INCLUDE, + include: LIST_CLAN_INCLUDE, }); res.json(clans.map((c) => serializeClan(c))); } catch (err) { diff --git a/server/routes/game.js b/server/routes/game.js index cc775c7..df8892c 100644 --- a/server/routes/game.js +++ b/server/routes/game.js @@ -3,8 +3,6 @@ import { requireAuth } from '../middleware/auth.js'; import prisma from '../lib/prisma.js'; import { MAX_FORGE_LEVEL } from '../../shared/stats.js'; import { - isFiniteNumber, - isNonNegativeNumber, isValidEquipment, isValidCombat, isValidForgeUpgrade, @@ -57,7 +55,8 @@ router.get('/state', requireAuth, async (req, res) => { // PUT /api/game/state — save player's game state router.put('/state', requireAuth, async (req, res) => { - const { equipment, gold, diamonds, forgeLevel, forgeUpgrade, combat, essence, player, research, forgeHighestLevel, shopState, skills } = req.body; + const { equipment, forgeUpgrade, combat, player, research, forgeHighestLevel, skills } = req.body; + // gold, diamonds, essence, forgeLevel are handled separately below (anti-cheat) try { const data = {}; @@ -67,23 +66,27 @@ router.put('/state', requireAuth, async (req, res) => { } data.equipment = equipment; } - if (gold !== undefined) { - if (!isNonNegativeNumber(gold)) { - return res.status(400).json({ error: 'Gold must be a non-negative number' }); - } - data.gold = Math.floor(gold); - } - if (diamonds !== undefined) { - if (!isNonNegativeNumber(diamonds)) { - return res.status(400).json({ error: 'Diamonds must be a non-negative number' }); - } - data.diamonds = Math.floor(diamonds); - } - if (forgeLevel !== undefined) { - if (!isFiniteNumber(forgeLevel) || forgeLevel < 1 || forgeLevel > MAX_FORGE_LEVEL) { - return res.status(400).json({ error: 'Invalid forge level' }); + // Currency & forge level: accept from client only if it decreased (spending). + // The client can spend gold (forge upgrades, cosmetics) but never inflate it. + // Server-side grants (payments, expeditions, admin) use atomic increments. + if (req.body.gold !== undefined || req.body.diamonds !== undefined || + req.body.essence !== undefined || req.body.forgeLevel !== undefined) { + const current = await prisma.gameState.findUnique({ + where: { userId: req.user.userId }, + select: { gold: true, diamonds: true, essence: true, forgeLevel: true }, + }); + if (current) { + const g = Math.floor(Number(req.body.gold)); + if (Number.isFinite(g) && g >= 0 && g <= current.gold) data.gold = g; + const d = Math.floor(Number(req.body.diamonds)); + if (Number.isFinite(d) && d >= 0 && d <= current.diamonds) data.diamonds = d; + const e = Math.floor(Number(req.body.essence)); + if (Number.isFinite(e) && e >= 0 && e <= current.essence) data.essence = e; + const fl = Math.floor(Number(req.body.forgeLevel)); + if (Number.isFinite(fl) && fl >= 1 && fl >= current.forgeLevel && fl <= MAX_FORGE_LEVEL) { + data.forgeLevel = fl; + } } - data.forgeLevel = Math.floor(forgeLevel); } if (forgeUpgrade !== undefined) { if (!isValidForgeUpgrade(forgeUpgrade)) { @@ -97,12 +100,6 @@ router.put('/state', requireAuth, async (req, res) => { } data.combat = combat; } - if (essence !== undefined) { - if (!isNonNegativeNumber(essence)) { - return res.status(400).json({ error: 'Essence must be a non-negative number' }); - } - data.essence = Math.floor(essence); - } if (player !== undefined) { if (!isValidPlayer(player)) { return res.status(400).json({ error: 'Invalid player structure' }); @@ -134,12 +131,10 @@ router.put('/state', requireAuth, async (req, res) => { create: { userId: req.user.userId, equipment: equipment || {}, - gold: typeof gold === 'number' ? Math.floor(gold) : 100, // STARTING_GOLD - diamonds: typeof diamonds === 'number' ? Math.floor(diamonds) : 100, - forgeLevel: forgeLevel || 1, + gold: 100, // STARTING_GOLD — server-authoritative + forgeLevel: 1, forgeUpgrade: forgeUpgrade || null, combat: combat || { currentWave: 1, currentSubWave: 1, highestWave: 1, highestSubWave: 1 }, - essence: typeof essence === 'number' ? Math.floor(essence) : 0, player: player || { level: 1, xp: 0, profilePicture: 'wizard' }, research: research || { completed: {}, active: null, queue: [] }, forgeHighestLevel: forgeHighestLevel || {}, diff --git a/server/routes/monsters.js b/server/routes/monsters.js index 09474b4..5ff3371 100644 --- a/server/routes/monsters.js +++ b/server/routes/monsters.js @@ -35,6 +35,7 @@ router.get('/templates', async (req, res) => { : null, })); + res.set('Cache-Control', 'public, max-age=3600'); res.json({ spriteSheet, templates }); } catch (err) { console.error('Monster templates error:', err); diff --git a/server/routes/payment.js b/server/routes/payment.js index 6e19c64..cea39d3 100644 --- a/server/routes/payment.js +++ b/server/routes/payment.js @@ -257,15 +257,19 @@ router.post('/webhook', async (req, res) => { }); if (claim.count > 0) { - // Clawback the gold, clamped so the balance can't go negative. - const gs = await prisma.gameState.findUnique({ - where: { userId: purchase.userId }, - select: { gold: true }, - }); - const newGold = Math.max(0, (gs?.gold ?? 0) - purchase.goldGranted); - await prisma.gameState.update({ - where: { userId: purchase.userId }, - data: { gold: newGold }, + // Atomic clawback: decrement gold, floor at 0. + await prisma.$transaction(async (tx) => { + const gs = await tx.gameState.findUnique({ + where: { userId: purchase.userId }, + select: { gold: true }, + }); + const clawback = Math.min(purchase.goldGranted, gs?.gold ?? 0); + if (clawback > 0) { + await tx.gameState.update({ + where: { userId: purchase.userId }, + data: { gold: { decrement: clawback } }, + }); + } }); await logAudit(purchase.userId, 'refund_gold', purchase.userId, { diff --git a/src/game/state.js b/src/game/state.js index 08969e9..cad6275 100644 --- a/src/game/state.js +++ b/src/game/state.js @@ -211,7 +211,7 @@ export function grantGold(base) { } export function spendGold(amount) { - if (state.gold < amount) return false; + if (amount <= 0 || state.gold < amount) return false; state.gold -= amount; save(); gameEvents.emit(EVENTS.STATE_CHANGED); @@ -300,6 +300,9 @@ export function equipItem(item) { /** Discard an item. Gold can't be recovered from gear — gold is deliberately scarce. */ export function trashItem(item) { + if (item?.type && state.equipment[item.type] === item) { + state.equipment[item.type] = null; + } save(); gameEvents.emit(EVENTS.ITEM_TRASHED, { item }); gameEvents.emit(EVENTS.STATE_CHANGED); diff --git a/src/screens/home.js b/src/screens/home.js index 384dfaf..48c50a4 100644 --- a/src/screens/home.js +++ b/src/screens/home.js @@ -7,7 +7,7 @@ import { renderItemCard, renderDeltaBadge, renderItemComparison, powerDelta } fr import { EQUIPMENT_TYPES, MAX_FORGE_LEVEL, TIERS, avatarEmoji, stageInfo, arenaXp, arenaFallbackRank } from '../game/config.js'; import { slotIcon, itemIcon, slotLabel, rarityColor, rarityName, itemName } from '../game/items.js'; import { - getEquipment, getEquippedItem, getForgeLevel, getForgeUpgradeCost, getForgeChances, + getEquippedItem, getForgeLevel, getForgeUpgradeCost, getForgeChances, upgradeForge, equipItem, trashItem, getGold, getArenaRank, setArenaRank, getPowerScore, getAvatar, getCombatStats, grantGold, grantPlayerXp, getForgeLevelProgress, getForgeSpeedPct, getForgeBestOf, @@ -478,7 +478,6 @@ function showSlotDetail(type) { onclick: async () => { const ok = await confirmDialog({ title: 'Trash equipped item?', message: `${itemName(item)} will be removed for good — gear can't be sold for gold.`, confirmText: 'Trash' }); if (ok) { - getEquipment()[type] = null; trashItem(item); closeModal(); toast(`Trashed ${itemName(item)}`, 'info');