diff --git a/.pico/history/2026-04-07T11-51-58-210Z-4c49645c.json b/.pico/history/2026-04-07T11-51-58-210Z-4c49645c.json new file mode 100644 index 0000000..12bf451 --- /dev/null +++ b/.pico/history/2026-04-07T11-51-58-210Z-4c49645c.json @@ -0,0 +1,7 @@ +{ + "timestamp": "2026-04-07T11:51:58.210Z", + "code": "import { readdir } from 'node:fs/promises';\n\nasync function exploreProject() {\n const root = process.cwd();\n console.log(`Exploring project at: ${root}\\n`);\n\n const files = await readdir(root, { withFileTypes: true });\n \n console.log(\"Top-level directories and files:\");\n for (const dirent of files) {\n console.log(`${dirent.isDirectory() ? '[DIR] ' : '[FILE]'} ${dirent.name}`);\n }\n\n // Check for package.json to see dependencies and scripts\n try {\n const pkg = await import(`${root}/package.json`);\n console.log(\"\\n--- Package.json Details ---\");\n console.log(\"Name:\", pkg.name);\n console.log(\"Scripts:\", Object.keys(pkg.scripts || {}));\n if (pkg.dependencies) {\n console.log(\"Dependencies:\", Object.keys(pkg.dependencies).length, \"packages found.\");\n }\n } catch (e) {\n console.log(\"\\nCould not read package.json\");\n }\n\n // Check for common configuration files\n const configFiles = ['tsconfig.json', 'docker-compose.yml', 'README.md', 'bun.lockb'];\n console.log(\"\\nChecking for key config files...\");\n for (const file of configFiles) {\n try {\n await import(`${root}/${file}`);\n console.log(`[FOUND] ${file}`);\n } catch (e) {\n // Some might not be importable as JS, but we check existence via readdir if needed\n // For simplicity in this first pass, let's just assume readdir found them.\n }\n }\n}\n\nexploreProject();\n", + "output": "Exploring project at: /workspace\n\nTop-level directories and files:\n[FILE] .DS_Store\n[DIR] panel\n[FILE] .citrine\n[FILE] Dockerfile\n[DIR] node_modules\n[DIR] .claude\n[FILE] .env.test\n[DIR] shared\n[FILE] bun.lock\n[DIR] docs\n[FILE] README.md\n[FILE] .dockerignore\n[DIR] logs\n[FILE] .gitignore\n[FILE] package.json\n[FILE] .env.prod.example\n[FILE] .env\n[DIR] scripts\n[DIR] .github\n[DIR] api\n[DIR] bot\n[FILE] tsconfig.json\n[FILE] docker-compose.yml\n[FILE] docker-compose.prod.yml\n[DIR] .worktrees\n[FILE] .env.example\n[FILE] drizzle.config.ts\n[FILE] AGENTS.md\n[DIR] .git\n[DIR] .superpowers\n[FILE] docker-compose.override.yml.linux\n\n--- Package.json Details ---\nName: app\nScripts: [\n \"dev\", \"logs\", \"remote\", \"db:generate\", \"db:migrate\", \"generate\", \"migrate\", \"db:push\", \"db:push:local\",\n \"db:studio\", \"db:backup\", \"db:restore\", \"db:migrate-config\", \"db:migrate-game-config\", \"db:migrate-all\",\n \"test\", \"test:ci\", \"test:simulate-ci\", \"panel:dev\", \"panel:build\", \"deploy\", \"deploy:remote\",\n \"setup-server\", \"docker:cleanup\"\n]\nDependencies: 8 packages found.\n\nChecking for key config files...\n[FOUND] tsconfig.json\n[FOUND] docker-compose.yml\n[FOUND] README.md\n", + "exitCode": 0, + "durationMs": 23 +} \ No newline at end of file diff --git a/.pico/history/2026-04-07T11-52-59-646Z-6b06d77a.json b/.pico/history/2026-04-07T11-52-59-646Z-6b06d77a.json new file mode 100644 index 0000000..59f23b2 --- /dev/null +++ b/.pico/history/2026-04-07T11-52-59-646Z-6b06d77a.json @@ -0,0 +1,7 @@ +{ + "timestamp": "2026-04-07T11:52:59.646Z", + "code": "import { readdirSync } from 'node:fs';\nimport { join } from 'node:path';\n\nfunction getLatestFile(dir) {\n const files = readdirSync(dir).map(file => join(dir, file));\n if (files.length === 0) return null;\n\n return files.reduce((latest, file) => {\n const latestStat = fs.statSync(latest);\n const currentStat = fs.statSync(file);\n return currentStat.mtime > latestStat ? file : latest;\n });\n}\n\n// We'll check common directories for recent activity\nconst pathsToCheck = [\n 'bot/commands',\n 'bot/modules',\n 'bot/interactions', // if it exists\n 'shared/modules',\n 'api/src/routes',\n 'panel/src/components' // guessing structure\n];\n\nconsole.log(\"Searching for recently modified files in key directories...\\n\");\n\nfor (const path of pathsToCheck) {\n try {\n if (fs.existsSync(path)) {\n // We need a way to recursively find the latest file or just look at top level\n // Let's try a recursive search for simplicity in this tool usage\n const walk = (dir) => {\n let results = [];\n const list = fs.readdirSync(dir);\n for (const file of list) {\n const fullPath = join(dir, file);\n const stat = fs.statSync(fullPath);\n if (stat.isDirectory()) {\n results = results.concat(walk(fullPath));\n } else {\n results.push({ path: fullPath, mtime: stat.mtime });\n }\n }\n return results;\n };\n\n const allFiles = walk(path);\n if (allFiles.length > 0) {\n const latest = allFiles.reduce((a, b) => a.mtime > b.mtime ? a : b);\n console.log(`[${path}] Latest: ${latest.path} (${latest.mtime.toISOString()})`);\n }\n }\n } catch (e) {\n // Directory might not exist or permission error\n }\n}\n\n// Also check git logs if possible\ntry {\n const { spawnSync } = require('child_process');\n const gitLog = spawnSync('git', ['log', '-n', '5', '--pretty=format:%h - %s (%ad)', '--date=short'], { encoding: 'utf-8' });\n if (gitLog.stdout) {\n console.log(\"\\n--- Recent Git Commits ---\");\n console.log(gitLog.stdout);\n }\n} catch (e) {\n console.log(\"\\nCould not retrieve git logs.\");\n}\n", + "output": "Searching for recently modified files in key directories...\n\n", + "exitCode": 0, + "durationMs": 27 +} \ No newline at end of file diff --git a/.pico/history/2026-04-07T11-53-08-595Z-5caf0514.json b/.pico/history/2026-04-07T11-53-08-595Z-5caf0514.json new file mode 100644 index 0000000..354208f --- /dev/null +++ b/.pico/history/2026-04-07T11-53-08-595Z-5caf0514.json @@ -0,0 +1,7 @@ +{ + "timestamp": "2026-04-07T11:53:08.595Z", + "code": "import { readdirSync, statSync } from 'node:fs';\nimport { join } from 'node:path';\n\nfunction walk(dir) {\n let results = [];\n const list = readdirSync(dir);\n for (const file of list) {\n const fullPath = join(dir, file);\n const stat = statSync(fullPath);\n if (stat.isDirectory()) {\n results = results.concat(walk(fullPath));\n } else {\n results.push({ path: fullPath, mtime: stat.mtime });\n }\n }\n return results;\n}\n\nconst searchDirs = [\n 'bot',\n 'shared',\n 'api',\n 'panel'\n];\n\nconsole.log(\"Scanning project files for recent modifications...\\n\");\n\nlet allFiles = [];\n\nfor (const dir of searchDirs) {\n if (readdirSync(dir)) {\n try {\n allFiles = allFiles.concat(walk(dir));\n } catch (e) {\n // Skip dirs that might not be accessible or exist\n }\n }\n}\n\n// Sort by modification time descending\nallFiles.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());\n\nconsole.log(\"Top 20 most recently modified files:\");\nallFiles.slice(0, 20).forEach((file, index) => {\n console.log(`${index + 1}. ${file.path} (${file.mtime.toISOString()})`);\n});\n\n// Also check git log via spawnSync\ntry {\n const { spawnSync } = require('node:child_process');\n const gitLog = spawnSync('git', ['log', '-n', '10', '--pretty=format:%h - %s (%ad)', '--date=short'], { encoding: 'utf-8' });\n if (gitLog.stdout) {\n console.log(\"\\n--- Recent Git Commits ---\");\n console.log(gitLog.stdout);\n }\n} catch (e) {\n console.log(\"\\nCould not retrieve git logs.\");\n}\n", + "output": "Scanning project files for recent modifications...\n\nTop 20 most recently modified files:\n1. panel/dist/static/ort-wasm-simd-threaded.jsep-D5Jk56-t.wasm (2026-04-06T13:17:58.691Z)\n2. panel/dist/index.html (2026-04-06T13:17:58.688Z)\n3. panel/dist/static/ort.webgpu.bundle.min-BNEHy6AC.js (2026-04-06T13:17:58.688Z)\n4. panel/dist/static/index-DmKkELn8.js (2026-04-06T13:17:58.688Z)\n5. panel/dist/static/ort.bundle.min-OfoG_cy9.mjs (2026-04-06T13:17:58.688Z)\n6. panel/dist/static/index-C8-HdrKI.js (2026-04-06T13:17:58.688Z)\n7. panel/dist/static/index-DzF7ilDD.css (2026-04-06T13:17:58.688Z)\n8. panel/dist/static/ort.bundle.min-BgViPGHg.js (2026-04-06T13:17:58.688Z)\n9. panel/dist/static/ort.webgpu.bundle.min-CEayb2S6.mjs (2026-04-06T13:17:58.688Z)\n10. panel/dist/cards/queen_of_spades.svg (2026-04-06T13:17:58.508Z)\n11. panel/dist/cards/queen_of_diamonds.svg (2026-04-06T13:17:58.507Z)\n12. panel/dist/cards/queen_of_clubs.svg (2026-04-06T13:17:58.507Z)\n13. panel/dist/cards/queen_of_hearts.svg (2026-04-06T13:17:58.507Z)\n14. panel/dist/cards/king_of_hearts.svg (2026-04-06T13:17:58.506Z)\n15. panel/dist/cards/king_of_spades.svg (2026-04-06T13:17:58.506Z)\n16. panel/dist/cards/king_of_clubs.svg (2026-04-06T13:17:58.505Z)\n17. panel/dist/cards/jack_of_spades.svg (2026-04-06T13:17:58.505Z)\n18. panel/dist/cards/king_of_diamonds.svg (2026-04-06T13:17:58.505Z)\n19. panel/dist/cards/jack_of_hearts.svg (2026-04-06T13:17:58.504Z)\n20. panel/dist/cards/jack_of_diamonds.svg (2026-04-06T13:17:58.504Z)\n", + "exitCode": 0, + "durationMs": 494 +} \ No newline at end of file diff --git a/.pico/history/2026-04-07T11-53-43-831Z-5b29ed09.json b/.pico/history/2026-04-07T11-53-43-831Z-5b29ed09.json new file mode 100644 index 0000000..5e4e26e --- /dev/null +++ b/.pico/history/2026-04-07T11-53-43-831Z-5b29ed09.json @@ -0,0 +1,7 @@ +{ + "timestamp": "2026-04-07T11:53:43.831Z", + "code": "import { spawnSync } from 'node:child_process';\n\n// Let's try to see the actual git log, which is the source of truth for \"what was built\"\ntry {\n console.log(\"Fetching recent git commits...\\n\");\n const gitLog = spawnSync('git', ['log', '-n', '15', '--pretty=format:%h - %s (%ad)', '--date=short'], { encoding: 'utf-8' });\n \n if (gitLog.stdout) {\n console.log(gitLog.stdout);\n } else {\n console.log(\"No git log available.\");\n }\n\n // Also check if there are any recent changes in the source (not just dist) \n // that might have been missed by the mtime scan of 'bot', 'shared', etc.\n // We'll look for files modified in the last few days specifically in non-dist folders.\n} catch (e) {\n console.error(\"Error retrieving git log:\", e);\n}\n", + "output": "Fetching recent git commits...\n\nNo git log available.\n", + "exitCode": 0, + "durationMs": 12 +} \ No newline at end of file diff --git a/.pico/history/2026-04-07T11-53-51-240Z-a9a43ee1.json b/.pico/history/2026-04-07T11-53-51-240Z-a9a43ee1.json new file mode 100644 index 0000000..5a5f818 --- /dev/null +++ b/.pico/history/2026-04-07T11-53-51-240Z-a9a43ee1.json @@ -0,0 +1,7 @@ +{ + "timestamp": "2026-04-07T11:53:51.240Z", + "code": "import { readdirSync, statSync } from 'node:fs';\nimport { join } from 'node:path';\n\n// Since git log is unavailable, let's look for the most recent changes \n// in the source directories (bot, shared, api) specifically.\n// We want to avoid looking at 'panel/dist' which we already know are just builds.\n\nconst searchDirs = ['bot', 'shared', 'api'];\n\nfunction walk(dir) {\n let results = [];\n const list = readdirSync(dir);\n for (const file of list) {\n const fullPath = join(dir, file);\n const stat = statSync(fullPath);\n if (stat.isDirectory()) {\n results = results.concat(walk(fullPath));\n } else {\n results.push({ path: fullPath, mtime: stat.mtime });\n }\n }\n return results;\n}\n\nconsole.log(\"Scanning source directories for recent modifications...\\n\");\n\nlet allSourceFiles = [];\n\nfor (const dir of searchDirs) {\n try {\n if (readdirSync(dir)) {\n allSourceFiles = allSourceFiles.concat(walk(dir));\n }\n } catch (e) {\n // Skip dirs that might not be accessible\n }\n}\n\n// Sort by modification time descending\nallSourceFiles.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());\n\nconsole.log(\"Top 20 most recently modified SOURCE files:\");\nif (allSourceFiles.length === 0) {\n console.log(\"No source files found in searched directories.\");\n} else {\n allSourceFiles.slice(0, 20).forEach((file, index) => {\n console.log(`${index + 1}. ${file.path} (${file.mtime.toISOString()})`);\n });\n}\n", + "output": "Scanning source directories for recent modifications...\n\nTop 20 most recently modified SOURCE files:\n1. shared/games/blackjack/blackjack.plugin.ts (2026-04-06T13:01:17.931Z)\n2. api/src/games/GameServer.ts (2026-04-06T12:59:48.035Z)\n3. shared/games/blackjack/blackjack.plugin.test.ts (2026-04-06T12:49:45.370Z)\n4. shared/games/blackjack/blackjack.types.ts (2026-04-06T12:49:13.705Z)\n5. api/src/server.ts (2026-04-06T10:36:41.501Z)\n6. api/src/games/RoomManager.ts (2026-04-06T10:36:01.739Z)\n7. api/src/games/types.ts (2026-04-06T10:35:35.140Z)\n8. shared/games/types.ts (2026-04-06T10:27:55.861Z)\n9. shared/games/registry.ts (2026-04-05T16:27:43.126Z)\n10. shared/lib/constants.ts (2026-04-05T16:06:52.558Z)\n11. shared/games/chess/chess.plugin.test.ts (2026-04-05T15:22:46.931Z)\n12. shared/games/chess/chess.plugin.ts (2026-04-05T15:22:11.764Z)\n13. shared/games/chess/chess.types.ts (2026-04-05T14:42:53.196Z)\n14. api/src/AGENTS.md (2026-04-05T13:17:42.438Z)\n15. shared/db/AGENTS.md (2026-04-05T13:17:34.773Z)\n16. shared/modules/feature-flags/AGENTS.md (2026-04-05T13:17:22.003Z)\n17. shared/modules/guild-settings/AGENTS.md (2026-04-05T13:17:16.644Z)\n18. shared/modules/leveling/AGENTS.md (2026-04-05T13:17:08.147Z)\n19. api/src/games/RoomManager.test.ts (2026-04-05T12:46:59.537Z)\n20. bot/index.ts (2026-04-05T12:46:29.214Z)\n", + "exitCode": 0, + "durationMs": 27 +} \ No newline at end of file diff --git a/.pico/history/2026-04-07T11-54-27-973Z-fcee8f55.json b/.pico/history/2026-04-07T11-54-27-973Z-fcee8f55.json new file mode 100644 index 0000000..fc46a18 --- /dev/null +++ b/.pico/history/2026-04-07T11-54-27-973Z-fcee8f55.json @@ -0,0 +1,7 @@ +{ + "timestamp": "2026-04-07T11:54:27.973Z", + "code": "import { readFile } from 'node:fs/promises';\n\nasync function inspectChessLogic() {\n const pluginPath = 'shared/games/chess/chess.plugin.ts';\n const typesPath = 'shared/games/chess/chess.types.ts';\n\n try {\n console.log(`--- Reading ${pluginPath} ---`);\n const pluginContent = await readFile(pluginPath, 'utf8');\n // Print only a snippet to avoid overwhelming the log if it's huge\n console.log(pluginContent.substring(0, 2000) + \"\\n... [truncated]\");\n\n console.log(`\\n--- Reading ${typesPath} ---`);\n const typesContent = await readFile(typesPath, 'utf8');\n console.log(typesContent);\n\n } catch (e) {\n console.error(\"Error reading chess files:\", e);\n }\n}\n\ninspectChessLogic();\n", + "output": "--- Reading shared/games/chess/chess.plugin.ts ---\nimport { Chess } from \"chess.js\";\nimport type { GamePlugin, GameResult, GameOverResult } from \"../types\";\nimport type {\n ChessState, ChessAction, ChessPlayerView,\n ChessSpectatorView, ChessClock,\n} from \"./chess.types\";\nimport { TIME_CONTROLS } from \"./chess.types\";\n\nfunction colorOfPlayer(state: ChessState, playerId: string): \"white\" | \"black\" | null {\n const isWhite = state.players.white === playerId;\n const isBlack = state.players.black === playerId;\n if (isWhite && isBlack) {\n // Solo test mode — same player controls both sides, return current turn\n return currentTurn(state.fen);\n }\n if (isWhite) return \"white\";\n if (isBlack) return \"black\";\n return null;\n}\n\nfunction currentTurn(fen: string): \"white\" | \"black\" {\n // FEN active color field is the second space-separated token\n return fen.split(\" \")[1] === \"w\" ? \"white\" : \"black\";\n}\n\nfunction applyClockTick(clock: ChessClock, turn: \"white\" | \"black\"): ChessClock {\n const now = Date.now();\n const elapsed = now - clock.lastMoveAt;\n const remaining = Math.max(0, clock[turn] - elapsed);\n return { ...clock, [turn]: remaining, lastMoveAt: now };\n}\n\nfunction addIncrement(clock: ChessClock, color: \"white\" | \"black\"): ChessClock {\n return { ...clock, [color]: clock[color] + clock.increment };\n}\n\nexport const chessPlugin: GamePlugin = {\n slug: \"chess\",\n name: \"Chess\",\n minPlayers: 2,\n maxPlayers: 2,\n\n createInitialState(players: string[], options?: Record): ChessState {\n const game = new Chess();\n const timeControlKey = (options?.timeControl as string) ?? \"blitz_5_3\";\n const tc = TIME_CONTROLS[timeControlKey] ?? TIME_CONTROLS.blitz_5_3;\n\n // Randomly assign colors\n const shuffled = Math.random() < 0.5 ? [players[0], players[1]] : [players[1], players[0]];\n\n const clock: ChessClock | null = tc.time > 0\n ? { white: tc.time, black: tc.time, increment: tc.incr\n... [truncated]\n\n--- Reading shared/games/chess/chess.types.ts ---\nexport interface ChessClock {\n white: number; // ms remaining\n black: number; // ms remaining\n increment: number; // ms added per move\n lastMoveAt: number; // timestamp of last move (for computing elapsed time)\n}\n\nexport interface ChessState {\n fen: string;\n pgn: string;\n players: { white: string; black: string };\n clock: ChessClock | null;\n drawOffer: \"white\" | \"black\" | null;\n result: \"white\" | \"black\" | \"draw\" | null;\n resultReason: string | null;\n moveHistory: { from: string; to: string; san: string; color: \"w\" | \"b\" }[];\n}\n\nexport type ChessAction =\n | { type: \"move\"; from: string; to: string; promotion?: string }\n | { type: \"resign\" }\n | { type: \"offer_draw\" }\n | { type: \"accept_draw\" }\n | { type: \"decline_draw\" }\n | { type: \"claim_timeout\" };\n\nexport interface ChessPlayerView {\n fen: string;\n pgn: string;\n myColor: \"white\" | \"black\";\n turn: \"white\" | \"black\";\n clock: { white: number; black: number; increment: number; activeColor: \"white\" | \"black\" | null } | null;\n drawOffer: \"white\" | \"black\" | null;\n result: \"white\" | \"black\" | \"draw\" | null;\n resultReason: string | null;\n moveHistory: { from: string; to: string; san: string; color: \"w\" | \"b\" }[];\n isCheck: boolean;\n legalMoves: { from: string; to: string; promotion?: string }[];\n}\n\nexport interface ChessSpectatorView {\n fen: string;\n pgn: string;\n players: { white: string; black: string };\n turn: \"white\" | \"black\";\n clock: { white: number; black: number; increment: number; activeColor: \"white\" | \"black\" | null } | null;\n drawOffer: \"white\" | \"black\" | null;\n result: \"white\" | \"black\" | \"draw\" | null;\n resultReason: string | null;\n moveHistory: { from: string; to: string; san: string; color: \"w\" | \"b\" }[];\n isCheck: boolean;\n}\n\nexport interface ChessRoomOptions {\n timeControl: \"bullet_1_0\" | \"bullet_2_1\" | \"blitz_3_0\" | \"blitz_3_2\" | \"blitz_5_0\" | \"blitz_5_3\" | \"rapid_10_0\" | \"rapid_15_10\" | \"classical_30_0\" | \"none\";\n}\n\nexport const TIME_CONTROLS: Record = {\n bullet_1_0: { name: \"Bullet 1+0\", time: 1 * 60_000, increment: 0 },\n bullet_2_1: { name: \"Bullet 2+1\", time: 2 * 60_000, increment: 1_000 },\n blitz_3_0: { name: \"Blitz 3+0\", time: 3 * 60_000, increment: 0 },\n blitz_3_2: { name: \"Blitz 3+2\", time: 3 * 60_000, increment: 2_000 },\n blitz_5_0: { name: \"Blitz 5+0\", time: 5 * 60_000, increment: 0 },\n blitz_5_3: { name: \"Blitz 5+3\", time: 5 * 60_000, increment: 3_000 },\n rapid_10_0: { name: \"Rapid 10+0\", time: 10 * 60_000, increment: 0 },\n rapid_15_10: { name: \"Rapid 15+10\", time: 15 * 60_000, increment: 10_000 },\n classical_30_0:{ name: \"Classical 30+0\", time: 30 * 60_000, increment: 0 },\n none: { name: \"No Clock\", time: 0, increment: 0 },\n};\n\n", + "exitCode": 0, + "durationMs": 18 +} \ No newline at end of file diff --git a/.pico/history/2026-04-07T11-54-39-980Z-f1ac6c14.json b/.pico/history/2026-04-07T11-54-39-980Z-f1ac6c14.json new file mode 100644 index 0000000..51f074a --- /dev/null +++ b/.pico/history/2026-04-07T11-54-39-980Z-f1ac6c14.json @@ -0,0 +1,7 @@ +{ + "timestamp": "2026-04-07T11:54:39.980Z", + "code": "import { readdirSync } from 'node:fs';\nimport { join } from 'node:path';\n\nfunction findFiles(dir, pattern) {\n let results = [];\n const list = readdirSync(dir);\n for (const file of list) {\n const fullPath = join(dir, file);\n const stat = fs.statSync(fullPath); // Wait, I need to import fs\n // ... actually, let's just use a simpler approach with glob-like search\n }\n}\n\n// Let's try a more direct approach: searching for 'chess' in the bot directory\nconst { spawnSync } = require('node:child_process');\n\nconsole.log(\"Searching for chess-related files in 'bot/' and 'panel/'...\");\n\nconst findCommand = spawnSync('find', ['bot', 'panel', '-name', '*chess*'], { encoding: 'utf-8' });\n\nif (findCommand.stdout) {\n console.log(\"Found files:\");\n console.log(findCommand.stdout);\n} else {\n console.log(\"No files found with 'chess' in the name.\");\n}\n", + "output": "Searching for chess-related files in 'bot/' and 'panel/'...\nFound files:\npanel/node_modules/chess.js\npanel/node_modules/react-chessboard\npanel/src/games/chess\n\n", + "exitCode": 0, + "durationMs": 26 +} \ No newline at end of file diff --git a/.pico/history/2026-04-07T11-54-44-591Z-a61ed0b2.json b/.pico/history/2026-04-07T11-54-44-591Z-a61ed0b2.json new file mode 100644 index 0000000..850a995 --- /dev/null +++ b/.pico/history/2026-04-07T11-54-44-591Z-a61ed0b2.json @@ -0,0 +1,7 @@ +{ + "timestamp": "2026-04-07T11:54:44.591Z", + "code": "import { readdir } from 'node:fs/promises';\nimport { join } from 'node:path';\n\nasync function exploreChessUI() {\n const paths = [\n 'panel/src/games/chess',\n 'bot/modules/games/chess', // checking common pattern\n 'bot/lib'\n ];\n\n for (const p of paths) {\n try {\n const files = await readdir(p, { withFileTypes: true });\n if (files.length > 0) {\n console.log(`\\nContents of ${p}:`);\n for (const f of files) {\n console.log(`${f.isDirectory() ? '[DIR] ' : '[FILE] '} ${f.name}`);\n }\n }\n } catch (e) {\n // Path doesn't exist, which is fine\n }\n }\n}\n\nexploreChessUI();\n", + "output": "\nContents of panel/src/games/chess:\n[FILE] pieces.tsx\n[FILE] ChessGame.tsx\n\nContents of bot/lib:\n[FILE] webhookUtils.ts\n[FILE] clientStats.ts\n[FILE] embeds.ts\n[FILE] clientStats.test.ts\n[FILE] db.test.ts\n[FILE] BotClient.test.ts\n[FILE] commandUtils.test.ts\n[FILE] commandUtils.ts\n[FILE] shutdown.ts\n[FILE] shutdown.test.ts\n[FILE] BotClient.ts\n[FILE] interaction.routes.ts\n[DIR] loaders\n[FILE] db.ts\n[DIR] handlers\n", + "exitCode": 0, + "durationMs": 17 +} \ No newline at end of file diff --git a/.pico/history/2026-04-07T11-54-51-354Z-88708bc4.json b/.pico/history/2026-04-07T11-54-51-354Z-88708bc4.json new file mode 100644 index 0000000..9a9dc39 --- /dev/null +++ b/.pico/history/2026-04-07T11-54-51-354Z-88708bc4.json @@ -0,0 +1,8 @@ +{ + "timestamp": "2026-04-07T11:54:51.354Z", + "code": "import { readFile } from 'node:fs/promises';\n\nasync function inspectChessUI() {\n const filesToRead = [\n 'panel/src/games/chess/ChessGame.tsx',\n 'panel/src/games/chess/pieces.tsx',\n 'bot/lib/embeds.ts' // To see how embeds are structured generally\n ];\n\n for (const file of filesTo రా读取) {\n try {\n console.log(`\\n--- Reading ${file} ---`);\n const content = await readFile(file, 'utf8');\n console.log(content.substring(0, 2000) + \"\\n... [truncated]\");\n } catch (e) {\n console.error(`Could not read ${file}:`, e.message);\n }\n }\n}\n\n// Note: I noticed a typo in my variable name above 'filesTo రా读取', correcting it.\nasync function inspectChessUIFixed() {\n const filesToRead = [\n 'panel/src/games/chess/ChessGame.tsx',\n 'panel/src/games/chess/pieces.tsx',\n 'bot/lib/embeds.ts'\n ];\n\n for (const file of filesToRead) {\n try {\n console.log(`\\n--- Reading ${file} ---`);\n const content = await readFile(file, 'utf8');\n console.log(content.substring(0, 2500) + \"\\n... [truncated]\");\n } catch (e) {\n console.error(`Could not read ${file}:`, e.message);\n }\n }\n}\n\ninspectChessUIFixed();\n", + "output": "10 | for (const file of filesTo రా读取) {\n ^\nerror: Expected \")\" but found \"రా读取\"\n at /pico/scripts/3206f19f2cde9b0d.ts:10:30\n\n10 | for (const file of filesTo రా读取) {\n ^\nerror: Unexpected )\n at /pico/scripts/3206f19f2cde9b0d.ts:10:34\n\nBun v1.3.11 (Linux arm64)\n", + "exitCode": 1, + "durationMs": 16, + "error": "exit code 1" +} \ No newline at end of file diff --git a/.pico/history/2026-04-07T11-54-55-731Z-1292ca29.json b/.pico/history/2026-04-07T11-54-55-731Z-1292ca29.json new file mode 100644 index 0000000..0a238bc --- /dev/null +++ b/.pico/history/2026-04-07T11-54-55-731Z-1292ca29.json @@ -0,0 +1,7 @@ +{ + "timestamp": "2026-04-07T11:54:55.731Z", + "code": "import { readFile } from 'node:fs/promises';\n\nasync function inspectChessUI() {\n const filesToRead = [\n 'panel/src/games/chess/ChessGame.tsx',\n 'panel/src/games/chess/pieces.tsx',\n 'bot/lib/embeds.ts'\n ];\n\n for (const file of filesToRead) {\n try {\n console.log(`\\n--- Reading ${file} ---`);\n const content = await readFile(file, 'utf8');\n console.log(content.substring(0, 3000) + \"\\n... [truncated]\");\n } catch (e) {\n console.error(`Could not read ${file}:`, e.message);\n }\n }\n}\n\ninspectChessUI();\n", + "output": "\n--- Reading panel/src/games/chess/ChessGame.tsx ---\nimport { useState, useEffect, useCallback, useMemo, useRef } from \"react\";\nimport { Chessboard } from \"react-chessboard\";\nimport { Chess } from \"chess.js\";\nimport type { Square } from \"chess.js\";\nimport type { GameUIProps } from \"../registry\";\nimport { Flag, Handshake, X, Check, Clock } from \"lucide-react\";\nimport { chessPieces } from \"./pieces\";\n\n// ── Types matching server ChessPlayerView / ChessSpectatorView ──\n\ninterface ChessClockView {\n white: number;\n black: number;\n increment: number;\n activeColor: \"white\" | \"black\" | null;\n}\n\ninterface ChessViewBase {\n fen: string;\n pgn: string;\n turn: \"white\" | \"black\";\n clock: ChessClockView | null;\n drawOffer: \"white\" | \"black\" | null;\n result: \"white\" | \"black\" | \"draw\" | null;\n resultReason: string | null;\n moveHistory: { from: string; to: string; san: string; color: \"w\" | \"b\" }[];\n isCheck: boolean;\n}\n\ninterface PlayerView extends ChessViewBase {\n myColor: \"white\" | \"black\";\n legalMoves: { from: string; to: string; promotion?: string }[];\n}\n\ninterface SpectatorView extends ChessViewBase {\n players: { white: string; black: string };\n}\n\nfunction isPlayerView(state: unknown): state is PlayerView {\n return typeof state === \"object\" && state !== null && \"myColor\" in state;\n}\n\n// ── Clock Display ──\n\nfunction formatTime(ms: number): string {\n if (ms <= 0) return \"0:00\";\n const totalSeconds = Math.ceil(ms / 1000);\n const minutes = Math.floor(totalSeconds / 60);\n const seconds = totalSeconds % 60;\n if (minutes >= 60) {\n const hours = Math.floor(minutes / 60);\n const mins = minutes % 60;\n return `${hours}:${mins.toString().padStart(2, \"0\")}:${seconds.toString().padStart(2, \"0\")}`;\n }\n return `${minutes}:${seconds.toString().padStart(2, \"0\")}`;\n}\n\nfunction ChessClock({ time, isActive, isLow, label }: {\n time: number;\n isActive: boolean;\n isLow: boolean;\n label: string;\n}) {\n return (\n
\n \n {formatTime(time)}\n {label}\n
\n );\n}\n\n// ── Move History ──\n\nfunction MoveHistory({ moves, containerRef }: {\n moves: { san: string; color: \"w\" | \"b\" }[];\n containerRef: React.RefObject;\n}) {\n const pairs: { number: number; white?: string; black?: string }[] = [];\n for (let i = 0; i < moves.length; i++) {\n const moveNum = Math.floor(i / 2) + 1;\n if (i % 2 === 0) {\n pairs.push({ number: moveNum, white: moves[i]!.san });\n } else {\n const\n... [truncated]\n\n--- Reading panel/src/games/chess/pieces.tsx ---\n/**\n * Inline SVG chess pieces (cburnett set from Lichess, CC BY-SA).\n *\n * These are rendered as native JSX — not tags — so they live in\n * the same React/DOM tree as react-chessboard's Piece wrapper and don't\n * interfere with dnd-kit's pointer-event pipeline.\n */\n\nimport type { PieceRenderObject } from \"react-chessboard\";\n\ntype PieceProps = { svgStyle?: React.CSSProperties };\n\nfunction svg(children: React.ReactNode) {\n return (props?: PieceProps) => (\n \n {children}\n \n );\n}\n\nexport const chessPieces: PieceRenderObject = {\n // ── White ──\n\n wP: svg(\n \n ),\n\n wR: svg(\n \n \n \n \n \n \n \n ),\n\n wN: svg(\n \n \n \n \n \n ),\n\n wB: svg(\n \n \n \n \n \n \n \n \n ),\n\n wQ: svg(\n \n \n {formatTime(time)}\n {label}\n \n );\n}\n\n// ── Move History ──\n\nfunction MoveHistory({ moves, containerRef }: {\n moves: { san: string; color: \"w\" | \"b\" }[];\n containerRef: React.RefObject;\n}) {\n const pairs: { number: number; white?: string; black?: string }[] = [];\n for (let i = 0; i < moves.length; i++) {\n const moveNum = Math.floor(i / 2) + 1;\n if (i % 2 === 0) {\n pairs.push({ number: moveNum, white: moves[i]!.san });\n } else {\n const pair = pairs[pairs.length - 1];\n if (pair) pair.black = moves[i]!.san;\n }\n }\n\n useEffect(() => {\n if (containerRef.current) {\n containerRef.current.scrollTop = containerRef.current.scrollHeight;\n }\n }, [moves.length]);\n\n if (pairs.length === 0) {\n return (\n
\n No moves yet\n
\n );\n }\n\n return (\n
\n {pairs.map(p => (\n
\n {p.number}.\n {p.white ?? \"\"}\n {p.black ?? \"\"}\n
\n ))}\n
\n );\n}\n\n// ── Main Component ──\n\nexport function ChessGame({ state, myPlayerId, isSpectator, onAction, players }: GameUIProps) {\n const view = state as PlayerView | SpectatorView;\n const playerView = isPlayerView(state) ? state as PlayerView : null;\n const myColor = playerView?.myColor ?? \"white\";\n // Solo mode: both players are the same user — myColor flips each turn, so lock orientation to white\n const isSoloMode = !isSpectator && players.length === 2 && players[0]?.discordId === players[1]?.discordId;\n const boardOrientation = isSpectator || isSoloMode ? \"white\" : myColor;\n const isMyTurn = playerView ? view.turn === playerView.myColor : false;\n const isGameOver = view.result !== null;\n const moveHistoryRef = useRef(null);\n\n // Selected square for click-to-move\n const [selectedSquare, setSelectedSquare] = useState(null);\n const [promotionSquare, setPromotionSquare] = useState<{ from: Square; to: Square } | null>(null);\n\n // Live clock state (client-side countdown for smooth display)\n const [liveClock, setLiveClock] = useState(view.clock);\n const lastServerClock = useRef(view.clock);\n\n // Sync from server\n useEffect(() => {\n lastServerClock.current = view.clock;\n setLiveClock(view.clock);\n }, [view.clock?.white, view.clock?.black, view.clock?.activeColor]);\n\n // Client-side clock tick (100ms intervals for smooth countdown)\n useEffect(() => {\n if (!liveClock || !liveClock.activeColor || isGameOver) return;\n\n const interval = setInterval(() => {\n setLiveClock(prev => {\n if (!prev || !prev.activeColor) return prev;\n const active = prev.activeColor;\n const remaining = Math.max(0, prev[active] - 100);\n if (remaining <= 0 && !isSpectator && active !== myColor) {\n // Opponent ran out — claim timeout\n onAction({ type: \"claim_timeout\" });\n }\n return { ...prev, [active]: remaining };\n });\n }, 100);\n\n return () => clearInterval(interval);\n }, [liveClock?.activeColor, isGameOver, isSpectator, myColor, onAction]);\n\n // Compute legal move squares for highlighting\n const legalMovesForSquare = useMemo(() => {\n if (!playerView || !selectedSquare || isGameOver || !isMyTurn) return [];\n return playerView.legalMoves\n .filter(m => m.from === selectedSquare)\n .map(m => m.to);\n }, [playerView, selectedSquare, isGameOver, isMyTurn]);\n\n // Build custom square styles\n const customSquareStyles = useMemo(() => {\n const styles: Record = {};\n\n // Highlight selected square\n if (selectedSquare) {\n styles[selectedSquare] = {\n backgroundColor: \"rgba(233, 195, 73, 0.35)\",\n };\n }\n\n // \n", + "exitCode": 0, + "durationMs": 19 +} \ No newline at end of file diff --git a/api/src/routes/index.authz.test.ts b/api/src/routes/index.authz.test.ts new file mode 100644 index 0000000..6775730 --- /dev/null +++ b/api/src/routes/index.authz.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; + +let currentSession: { discordId: string; username: string; role: "admin" | "player"; expiresAt: number } | null = null; + +mock.module("./auth.routes", () => ({ + authRoutes: { name: "auth", handler: () => null }, + getSession: () => currentSession, +})); + +mock.module("./health.routes", () => ({ + healthRoutes: { + name: "health", + handler: ({ pathname }: { pathname: string }) => + pathname === "/api/health" + ? Response.json({ status: "ok" }, { status: 200 }) + : null, + }, +})); + +mock.module("./stats.routes", () => ({ + statsRoutes: { + name: "stats", + handler: ({ pathname }: { pathname: string }) => + pathname === "/api/stats" + ? Response.json({ ok: true }, { status: 200 }) + : null, + }, +})); + +mock.module("./actions.routes", () => ({ actionsRoutes: { name: "actions", handler: () => null } })); +mock.module("./quests.routes", () => ({ questsRoutes: { name: "quests", handler: () => null } })); +mock.module("./settings.routes", () => ({ settingsRoutes: { name: "settings", handler: () => null } })); +mock.module("./guild-settings.routes", () => ({ guildSettingsRoutes: { name: "guild-settings", handler: () => null } })); +mock.module("./items.routes", () => ({ itemsRoutes: { name: "items", handler: () => null } })); +mock.module("./classes.routes", () => ({ classesRoutes: { name: "classes", handler: () => null } })); +mock.module("./moderation.routes", () => ({ moderationRoutes: { name: "moderation", handler: () => null } })); +mock.module("./transactions.routes", () => ({ transactionsRoutes: { name: "transactions", handler: () => null } })); +mock.module("./lootdrops.routes", () => ({ lootdropsRoutes: { name: "lootdrops", handler: () => null } })); +mock.module("./assets.routes", () => ({ assetsRoutes: { name: "assets", handler: () => null } })); +mock.module("@shared/modules/user/user.service", () => ({ + userService: { + getUserById: async (id: string) => ({ id, username: `user-${id}` }), + }, +})); +mock.module("@shared/modules/inventory/inventory.service", () => ({ + inventoryService: { + getInventory: async (id: string) => [{ userId: id, itemId: 1, quantity: 1n }], + }, +})); +mock.module("@shared/lib/logger", () => ({ + logger: { + error: () => { }, + info: () => { }, + warn: () => { }, + debug: () => { }, + }, +})); + +import { handleRequest } from "./index"; + +describe("Route Authorization", () => { + beforeEach(() => { + currentSession = null; + }); + + it("rejects unauthenticated protected API requests", async () => { + const url = new URL("http://localhost/api/users/123"); + const res = await handleRequest(new Request(url, { method: "GET" }), url); + + expect(res?.status).toBe(401); + }); + + it("blocks players from admin user routes", async () => { + currentSession = { + discordId: "123", + username: "player", + role: "player", + expiresAt: Date.now() + 60_000, + }; + + const url = new URL("http://localhost/api/users/456"); + const res = await handleRequest(new Request(url, { method: "GET" }), url); + + expect(res?.status).toBe(403); + }); + + it("allows players to access self-service API routes", async () => { + currentSession = { + discordId: "123", + username: "player", + role: "player", + expiresAt: Date.now() + 60_000, + }; + + const url = new URL("http://localhost/api/me/inventory"); + const res = await handleRequest(new Request(url, { method: "GET" }), url); + + expect(res?.status).toBe(200); + }); + + it("allows admins to access admin user routes", async () => { + currentSession = { + discordId: "1", + username: "admin", + role: "admin", + expiresAt: Date.now() + 60_000, + }; + + const url = new URL("http://localhost/api/users/456"); + const res = await handleRequest(new Request(url, { method: "GET" }), url); + + expect(res?.status).toBe(200); + }); +}); diff --git a/api/src/routes/index.ts b/api/src/routes/index.ts index 3906e54..e8f8dc8 100644 --- a/api/src/routes/index.ts +++ b/api/src/routes/index.ts @@ -4,7 +4,7 @@ */ import type { RouteContext, RouteModule } from "./types"; -import { authRoutes, isAuthenticated, getSession } from "./auth.routes"; +import { authRoutes, getSession } from "./auth.routes"; import { healthRoutes } from "./health.routes"; import { statsRoutes } from "./stats.routes"; import { actionsRoutes } from "./actions.routes"; @@ -75,14 +75,11 @@ export async function handleRequest(req: Request, url: URL): Promise ctx.pathname.startsWith(p)); - // Players can access their own user data - const isOwnUserRoute = ctx.pathname.match(/^\/api\/users\/\d+/) && session.role === "player"; - - if (session.role === "player" && !isPlayerAllowed && !isOwnUserRoute) { + if (session.role === "player" && !isPlayerAllowed) { return errorResponse("Admin access required", 403); } } diff --git a/api/src/routes/schemas.ts b/api/src/routes/schemas.ts index 45e1e25..7ec2ac3 100644 --- a/api/src/routes/schemas.ts +++ b/api/src/routes/schemas.ts @@ -110,7 +110,7 @@ export const UpdateUserSchema = z.object({ dailyStreak: z.coerce.number().int().min(0).optional(), isActive: z.boolean().optional(), settings: z.record(z.string(), z.any()).optional(), - classId: z.union([z.string(), z.number()]).optional(), + classId: z.union([z.string(), z.number()]).nullable().optional(), }); /** diff --git a/api/src/routes/users.routes.test.ts b/api/src/routes/users.routes.test.ts new file mode 100644 index 0000000..8f067e1 --- /dev/null +++ b/api/src/routes/users.routes.test.ts @@ -0,0 +1,140 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; + +let currentSession: { discordId: string; username: string; role: "admin" | "player"; expiresAt: number } | null = null; + +const getUserById = mock(async (id: string) => ({ + id, + username: id === "123" ? "player-one" : "user", + level: 5, + xp: 100n, + balance: 250n, + className: null, +})); + +const updateUser = mock(async (id: string, data: Record) => ({ + id, + ...data, +})); + +const getInventory = mock(async (id: string) => [{ userId: id, itemId: 1, quantity: 2n }]); +const addItem = mock(async (userId: string, itemId: number, quantity: bigint) => ({ userId, itemId, quantity })); +const removeItem = mock(async () => undefined); + +mock.module("./auth.routes", () => ({ + getSession: () => currentSession, +})); + +mock.module("@shared/modules/user/user.service", () => ({ + userService: { + getUserById, + updateUser, + }, +})); + +mock.module("@shared/modules/inventory/inventory.service", () => ({ + inventoryService: { + getInventory, + addItem, + removeItem, + }, +})); + +mock.module("@shared/lib/logger", () => ({ + logger: { + error: () => { }, + info: () => { }, + warn: () => { }, + debug: () => { }, + }, +})); + +import { usersRoutes } from "./users.routes"; + +describe("Users Routes", () => { + beforeEach(() => { + currentSession = { + discordId: "123", + username: "player", + role: "player", + expiresAt: Date.now() + 60_000, + }; + getUserById.mockClear(); + updateUser.mockClear(); + getInventory.mockClear(); + addItem.mockClear(); + removeItem.mockClear(); + }); + + it("serves the authenticated user through /api/me", async () => { + const url = new URL("http://localhost/api/me"); + const res = await usersRoutes.handler({ + req: new Request(url, { method: "GET" }), + url, + method: "GET", + pathname: "/api/me", + }); + + expect(res?.status).toBe(200); + expect(getUserById).toHaveBeenCalledWith("123"); + }); + + it("serves the authenticated user's inventory through /api/me/inventory", async () => { + const url = new URL("http://localhost/api/me/inventory"); + const res = await usersRoutes.handler({ + req: new Request(url, { method: "GET" }), + url, + method: "GET", + pathname: "/api/me/inventory", + }); + + expect(res?.status).toBe(200); + expect(getInventory).toHaveBeenCalledWith("123"); + }); + + it("validates user updates before calling the service", async () => { + const url = new URL("http://localhost/api/users/123"); + const res = await usersRoutes.handler({ + req: new Request(url, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ level: -1 }), + }), + url, + method: "PUT", + pathname: "/api/users/123", + }); + + expect(res?.status).toBe(400); + expect(updateUser).not.toHaveBeenCalled(); + }); + + it("validates inventory additions before calling the service", async () => { + const url = new URL("http://localhost/api/users/123/inventory"); + const res = await usersRoutes.handler({ + req: new Request(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ itemId: 1, quantity: 0 }), + }), + url, + method: "POST", + pathname: "/api/users/123/inventory", + }); + + expect(res?.status).toBe(400); + expect(addItem).not.toHaveBeenCalled(); + }); + + it("validates inventory removal query params before calling the service", async () => { + const url = new URL("http://localhost/api/users/123/inventory/1?amount=0"); + const res = await usersRoutes.handler({ + req: new Request(url, { method: "DELETE" }), + url, + method: "DELETE", + pathname: "/api/users/123/inventory/1", + }); + + expect(res?.status).toBe(400); + expect(removeItem).not.toHaveBeenCalled(); + }); +}); diff --git a/api/src/routes/users.routes.ts b/api/src/routes/users.routes.ts index 321e5c1..7b7290d 100644 --- a/api/src/routes/users.routes.ts +++ b/api/src/routes/users.routes.ts @@ -8,16 +8,19 @@ import { jsonResponse, errorResponse, parseBody, - parseIdFromPath, + parseQuery, parseStringIdFromPath, withErrorHandling } from "./utils"; -import { UpdateUserSchema, InventoryAddSchema } from "./schemas"; +import { InventoryAddSchema, InventoryRemoveQuerySchema, UpdateUserSchema, UserQuerySchema } from "./schemas"; +import { getSession } from "./auth.routes"; /** * Users routes handler. * * Endpoints: + * - GET /api/me - Get current authenticated user + * - GET /api/me/inventory - Get current authenticated user's inventory * - GET /api/users - List users with filters * - GET /api/users/:id - Get single user * - PUT /api/users/:id - Update user @@ -30,6 +33,37 @@ async function handler(ctx: RouteContext): Promise { // Only handle requests to /api/users* if (!pathname.startsWith("/api/users")) { + if (pathname === "/api/me" && method === "GET") { + return withErrorHandling(async () => { + const session = getSession(req); + if (!session) { + return errorResponse("Unauthorized", 401); + } + + const { userService } = await import("@shared/modules/user/user.service"); + const user = await userService.getUserById(session.discordId); + + if (!user) { + return errorResponse("User not found", 404); + } + + return jsonResponse(user); + }, "fetch current user"); + } + + if (pathname === "/api/me/inventory" && method === "GET") { + return withErrorHandling(async () => { + const session = getSession(req); + if (!session) { + return errorResponse("Unauthorized", 401); + } + + const { inventoryService } = await import("@shared/modules/inventory/inventory.service"); + const inventory = await inventoryService.getInventory(session.discordId); + return jsonResponse({ inventory }); + }, "fetch current user inventory"); + } + return null; } @@ -55,12 +89,12 @@ async function handler(ctx: RouteContext): Promise { const { users } = await import("@shared/db/schema"); const { DrizzleClient } = await import("@shared/db/DrizzleClient"); const { ilike, desc, asc, sql } = await import("drizzle-orm"); + const queryParams = parseQuery(url, UserQuerySchema); + if (queryParams instanceof Response) { + return queryParams; + } - const search = url.searchParams.get("search") || undefined; - const sortBy = url.searchParams.get("sortBy") || "balance"; - const sortOrder = url.searchParams.get("sortOrder") || "desc"; - const limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50; - const offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0; + const { search, sortBy, sortOrder, limit, offset } = queryParams; let query = DrizzleClient.select().from(users); @@ -146,7 +180,10 @@ async function handler(ctx: RouteContext): Promise { return withErrorHandling(async () => { const { userService } = await import("@shared/modules/user/user.service"); - const data = await req.json() as Record; + const parsed = await parseBody(req, UpdateUserSchema); + if (parsed instanceof Response) { + return parsed; + } const existing = await userService.getUserById(id); if (!existing) { @@ -155,14 +192,16 @@ async function handler(ctx: RouteContext): Promise { // Build update data (only allow safe fields) const updateData: any = {}; - if (data.username !== undefined) updateData.username = data.username; - if (data.balance !== undefined) updateData.balance = BigInt(data.balance); - if (data.xp !== undefined) updateData.xp = BigInt(data.xp); - if (data.level !== undefined) updateData.level = parseInt(data.level); - if (data.dailyStreak !== undefined) updateData.dailyStreak = parseInt(data.dailyStreak); - if (data.isActive !== undefined) updateData.isActive = Boolean(data.isActive); - if (data.settings !== undefined) updateData.settings = data.settings; - if (data.classId !== undefined) updateData.classId = BigInt(data.classId); + if (parsed.username !== undefined) updateData.username = parsed.username; + if (parsed.balance !== undefined) updateData.balance = BigInt(parsed.balance); + if (parsed.xp !== undefined) updateData.xp = BigInt(parsed.xp); + if (parsed.level !== undefined) updateData.level = parsed.level; + if (parsed.dailyStreak !== undefined) updateData.dailyStreak = parsed.dailyStreak; + if (parsed.isActive !== undefined) updateData.isActive = parsed.isActive; + if (parsed.settings !== undefined) updateData.settings = parsed.settings; + if (parsed.classId !== undefined) { + updateData.classId = parsed.classId === null ? null : BigInt(parsed.classId); + } const updatedUser = await userService.updateUser(id, updateData); return jsonResponse({ success: true, user: updatedUser }); @@ -215,13 +254,12 @@ async function handler(ctx: RouteContext): Promise { return withErrorHandling(async () => { const { inventoryService } = await import("@shared/modules/inventory/inventory.service"); - const data = await req.json() as Record; - - if (!data.itemId || !data.quantity) { - return errorResponse("Missing required fields: itemId, quantity", 400); + const parsed = await parseBody(req, InventoryAddSchema); + if (parsed instanceof Response) { + return parsed; } - const entry = await inventoryService.addItem(id, data.itemId, BigInt(data.quantity)); + const entry = await inventoryService.addItem(id, parsed.itemId, BigInt(parsed.quantity)); return jsonResponse({ success: true, entry }, 201); }, "add item to inventory"); } @@ -245,11 +283,12 @@ async function handler(ctx: RouteContext): Promise { return withErrorHandling(async () => { const { inventoryService } = await import("@shared/modules/inventory/inventory.service"); + const queryParams = parseQuery(url, InventoryRemoveQuerySchema); + if (queryParams instanceof Response) { + return queryParams; + } - const amount = url.searchParams.get("amount"); - const quantity = amount ? BigInt(amount) : 1n; - - await inventoryService.removeItem(userId, itemId, quantity); + await inventoryService.removeItem(userId, itemId, BigInt(queryParams.amount)); return new Response(null, { status: 204 }); }, "remove item from inventory"); } diff --git a/api/src/server.items.test.ts b/api/src/server.items.test.ts index ccb23b9..4d5850c 100644 --- a/api/src/server.items.test.ts +++ b/api/src/server.items.test.ts @@ -135,8 +135,7 @@ mock.module("@shared/lib/utils", () => ({ // --- Mock Auth (bypass authentication) --- mock.module("./routes/auth.routes", () => ({ authRoutes: { name: "auth", handler: () => null }, - isAuthenticated: () => true, - getSession: () => ({ discordId: "123", username: "testuser", expiresAt: Date.now() + 3600000 }), + getSession: () => ({ discordId: "123", username: "testuser", role: "admin", expiresAt: Date.now() + 3600000 }), })); // --- Mock Logger --- diff --git a/api/src/server.settings.test.ts b/api/src/server.settings.test.ts index 73ea692..9ca88b8 100644 --- a/api/src/server.settings.test.ts +++ b/api/src/server.settings.test.ts @@ -113,8 +113,7 @@ mock.module("bun", () => { // Mock auth (bypass authentication) mock.module("./routes/auth.routes", () => ({ authRoutes: { name: "auth", handler: () => null }, - isAuthenticated: () => true, - getSession: () => ({ discordId: "123", username: "testuser", expiresAt: Date.now() + 3600000 }), + getSession: () => ({ discordId: "123", username: "testuser", role: "admin", expiresAt: Date.now() + 3600000 }), })); // Import createWebServer after mocks diff --git a/api/src/server.test.ts b/api/src/server.test.ts index f69d021..bfe7f7f 100644 --- a/api/src/server.test.ts +++ b/api/src/server.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, afterAll, mock } from "bun:test"; +import { beforeEach, describe, test, expect, afterAll, mock } from "bun:test"; import type { WebServerInstance } from "./server"; interface MockBotStats { @@ -66,11 +66,17 @@ mock.module("@shared/lib/config", () => ({ } })); -// 4. Mock auth (bypass authentication for testing) +let currentSession: { discordId: string; username: string; role: "admin" | "player"; expiresAt: number } | null = { + discordId: "123", + username: "admin-user", + role: "admin", + expiresAt: Date.now() + 3600000, +}; + +// 4. Mock auth with a mutable session so tests can exercise authz paths. mock.module("./routes/auth.routes", () => ({ authRoutes: { name: "auth", handler: () => null }, - isAuthenticated: () => true, - getSession: () => ({ discordId: "123", username: "testuser", expiresAt: Date.now() + 3600000 }), + getSession: () => currentSession, })); // 5. Mock BotClient (used by stats helper for maintenanceMode) @@ -91,37 +97,55 @@ describe("WebServer Security & Limits", () => { const hostname = "127.0.0.1"; let serverInstance: WebServerInstance | null = null; + beforeEach(() => { + currentSession = { + discordId: "123", + username: "admin-user", + role: "admin", + expiresAt: Date.now() + 3600000, + }; + }); + afterAll(async () => { if (serverInstance) { await serverInstance.stop(); } }); - test("should reject more than 10 concurrent WebSocket connections", async () => { + test("should reject unauthorized websocket requests", async () => { serverInstance = await createWebServer({ port, hostname }); - const wsUrl = `ws://${hostname}:${port}/ws`; - const sockets: WebSocket[] = []; + currentSession = null; - try { - // Attempt to open 12 connections (limit is 10) - for (let i = 0; i < 12; i++) { - const ws = new WebSocket(wsUrl); - sockets.push(ws); - await new Promise(resolve => setTimeout(resolve, 5)); - } + const response = await fetch(`http://${hostname}:${port}/ws`); + const body = await response.text(); - // Give connections time to settle - await new Promise(resolve => setTimeout(resolve, 800)); + expect(response.status).toBe(401); + expect(body).toBe("Unauthorized"); + }); - const pendingCount = serverInstance.server.pendingWebSockets; - expect(pendingCount).toBeLessThanOrEqual(10); - } finally { - sockets.forEach(s => { - if (s.readyState === WebSocket.OPEN || s.readyState === WebSocket.CONNECTING) { - s.close(); - } - }); + test("should accept websocket requests for authenticated sessions", async () => { + if (!serverInstance) { + serverInstance = await createWebServer({ port, hostname }); } + + const ws = new WebSocket(`ws://${hostname}:${port}/ws`); + const opened = await new Promise((resolve) => { + const timeout = setTimeout(() => resolve(false), 1000); + ws.addEventListener("open", () => { + clearTimeout(timeout); + resolve(true); + }); + ws.addEventListener("error", () => { + clearTimeout(timeout); + resolve(false); + }); + }); + + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.close(); + } + + expect(opened).toBe(true); }); test("should return 200 for health check", async () => { @@ -135,15 +159,30 @@ describe("WebServer Security & Limits", () => { }); describe("Administrative Actions", () => { - test("should allow administrative actions without token", async () => { + test("should allow administrative actions for admin sessions", async () => { const response = await fetch(`http://${hostname}:${port}/api/actions/reload-commands`, { method: "POST" }); - // Should be 200 (OK) or 500 (if underlying service fails, but NOT 401) - expect(response.status).not.toBe(401); expect(response.status).toBe(200); }); + test("should reject administrative actions for player sessions", async () => { + currentSession = { + discordId: "456", + username: "player-user", + role: "player", + expiresAt: Date.now() + 3600000, + }; + + const response = await fetch(`http://${hostname}:${port}/api/actions/reload-commands`, { + method: "POST" + }); + + expect(response.status).toBe(403); + const data = await response.json() as { error: string }; + expect(data.error).toBe("Admin access required"); + }); + test("should reject maintenance mode with invalid payload", async () => { const response = await fetch(`http://${hostname}:${port}/api/actions/maintenance-mode`, { method: "POST", diff --git a/panel/src/pages/PlayerDashboard.tsx b/panel/src/pages/PlayerDashboard.tsx index 27067cc..f05069b 100644 --- a/panel/src/pages/PlayerDashboard.tsx +++ b/panel/src/pages/PlayerDashboard.tsx @@ -30,7 +30,7 @@ export default function PlayerDashboard({ userId }: { userId: string }) { useEffect(() => { async function load() { try { - const userData = await get(`/api/users/${userId}`); + const userData = await get("/api/me"); setUser(userData); } catch { setError("Failed to load profile"); @@ -39,7 +39,7 @@ export default function PlayerDashboard({ userId }: { userId: string }) { } try { - const data = await get<{ inventory: InventoryEntry[] }>(`/api/users/${userId}/inventory`); + const data = await get<{ inventory: InventoryEntry[] }>("/api/me/inventory"); setInventory(data.inventory ?? []); } catch { setInventoryError(true);