import { createHash, randomBytes, scryptSync, timingSafeEqual } from "node:crypto"; import { mkdirSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; import { DatabaseSync } from "node:sqlite"; const SESSION_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000; const MAX_JSON_BYTES = 1024 * 1024; const AUTH_WINDOW_MS = 15 * 60 * 1000; const AUTH_ATTEMPTS_PER_WINDOW = 20; const authAttempts = new Map(); function apiError(message, status = 400) { const error = new Error(message); error.status = status; return error; } function sendJson(response, status, body) { response.statusCode = status; response.setHeader("Content-Type", "application/json; charset=utf-8"); response.setHeader("Cache-Control", "no-store"); response.setHeader("X-Content-Type-Options", "nosniff"); response.end(JSON.stringify(body)); } function configuredCorsOrigins() { return String(process.env.CORS_ORIGINS ?? "") .split(",") .map((origin) => origin.trim()) .filter(Boolean); } function setCorsHeaders(request, response) { const origin = request.headers.origin; if (!origin) return; const configured = configuredCorsOrigins(); if (!configured.includes("*") && !configured.includes(origin)) return; response.setHeader("Access-Control-Allow-Origin", origin); response.setHeader("Access-Control-Allow-Headers", "Authorization,Content-Type"); response.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS"); response.setHeader("Access-Control-Max-Age", "86400"); response.setHeader("Vary", "Origin"); } async function readJson(request) { const chunks = []; let size = 0; for await (const chunk of request) { size += chunk.length; if (size > MAX_JSON_BYTES) throw apiError("Request body is too large.", 413); chunks.push(chunk); } try { return JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { throw apiError("Request body must be valid JSON."); } } function canonicalUsername(value) { return String(value ?? "").trim().toLocaleLowerCase(); } function validateUsername(value) { const username = String(value ?? "").trim(); if (!/^[A-Za-z0-9_]{3,20}$/.test(username)) { throw apiError("Username must be 3–20 letters, numbers, or underscores."); } return username; } function validatePassword(value) { const password = String(value ?? ""); if (password.length < 10 || password.length > 128) { throw apiError("Password must be 10–128 characters."); } return password; } function passwordDigest(password, salt) { return scryptSync(password, salt, 64).toString("hex"); } function verifyPassword(password, account) { const actual = Buffer.from(passwordDigest(password, account.passwordSalt), "hex"); const expected = Buffer.from(account.passwordHash, "hex"); return actual.length === expected.length && timingSafeEqual(actual, expected); } function tokenHash(token) { return createHash("sha256").update(token).digest("hex"); } function bearerToken(request) { const authorization = String(request.headers.authorization ?? ""); return authorization.startsWith("Bearer ") ? authorization.slice(7).trim() : ""; } function createSession(database, accountId) { const token = randomBytes(32).toString("base64url"); const expiresAt = new Date(Date.now() + SESSION_LIFETIME_MS).toISOString(); database.prepare(` INSERT INTO sessions (account_id, token_hash, expires_at) VALUES (?, ?, ?) `).run(accountId, tokenHash(token), expiresAt); return token; } function currentSession(database, request) { const token = bearerToken(request); if (!token) return null; return database.prepare(` SELECT accounts.id AS accountId, accounts.username FROM sessions JOIN accounts ON accounts.id = sessions.account_id WHERE sessions.token_hash = ? AND sessions.expires_at > CURRENT_TIMESTAMP `).get(tokenHash(token)) ?? null; } function requireSession(database, request) { const session = currentSession(database, request); if (!session) throw apiError("Sign in required.", 401); return session; } function clientAddress(request) { return request.socket?.remoteAddress ?? "unknown"; } function enforceAuthRateLimit(request) { const now = Date.now(); const key = clientAddress(request); const existing = authAttempts.get(key); const bucket = existing && now - existing.startedAt < AUTH_WINDOW_MS ? existing : { startedAt: now, count: 0 }; bucket.count += 1; authAttempts.set(key, bucket); if (bucket.count > AUTH_ATTEMPTS_PER_WINDOW) { throw apiError("Too many authentication attempts. Try again later.", 429); } } function register(database, payload) { const username = validateUsername(payload?.username); const password = validatePassword(payload?.password); const canonical = canonicalUsername(username); if (database.prepare("SELECT id FROM accounts WHERE canonical_username = ?").get(canonical)) { throw apiError("Account already exists.", 409); } const salt = randomBytes(16).toString("hex"); const result = database.prepare(` INSERT INTO accounts (username, canonical_username, password_hash, password_salt) VALUES (?, ?, ?, ?) `).run(username, canonical, passwordDigest(password, salt), salt); const accountId = Number(result.lastInsertRowid); return { account: { id: accountId, username }, token: createSession(database, accountId) }; } function login(database, payload) { const canonical = canonicalUsername(payload?.username); const password = String(payload?.password ?? ""); const account = database.prepare(` SELECT id, username, password_hash AS passwordHash, password_salt AS passwordSalt FROM accounts WHERE canonical_username = ? `).get(canonical); if (!account || !verifyPassword(password, account)) { throw apiError("Username or password is incorrect.", 401); } return { account: { id: account.id, username: account.username }, token: createSession(database, account.id), }; } function validateSlotId(value) { const slotId = Number(value); if (!Number.isInteger(slotId) || slotId < 1 || slotId > 3) throw apiError("Invalid save slot."); return slotId; } function validateSave(value, slotId) { if (!value || typeof value !== "object" || Number(value.schemaVersion) !== 5) { throw apiError("Save snapshot is invalid."); } if (Number(value.slotId) !== slotId) throw apiError("Save slot does not match request."); if (typeof value.hunterName !== "string" || !value.hunterName.trim()) { throw apiError("Save snapshot has no hunter name."); } return value; } function normalizeNonNegativeInteger(value) { const number = Math.floor(Number(value)); return Number.isFinite(number) ? Math.max(0, number) : 0; } function syncLeaderboardStats(database, accountId, slotId, save) { database.prepare("DELETE FROM boss_kill_records WHERE account_id = ? AND slot_id = ?").run(accountId, slotId); const insertBoss = database.prepare(` INSERT INTO boss_kill_records (account_id, slot_id, boss_id, kills, updated_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) `); const bossKills = save.stats?.bossKills && typeof save.stats.bossKills === "object" ? save.stats.bossKills : {}; for (const [bossId, rawKills] of Object.entries(bossKills)) { if (!/^[a-z0-9-]{1,64}$/.test(bossId)) continue; const kills = normalizeNonNegativeInteger(rawKills); if (kills > 0) insertBoss.run(accountId, slotId, bossId, kills); } const highestRound = normalizeNonNegativeInteger(save.stats?.highestRoguelikeRound); database.prepare(` INSERT INTO roguelike_records (account_id, slot_id, highest_round, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(account_id, slot_id) DO UPDATE SET highest_round = excluded.highest_round, updated_at = CURRENT_TIMESTAMP `).run(accountId, slotId, highestRound); const highestEndlessKills = normalizeNonNegativeInteger(save.stats?.highestRogueTrialsEndlessKills); database.prepare(` INSERT INTO rogue_trials_endless_records (account_id, slot_id, highest_boss_kills, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(account_id, slot_id) DO UPDATE SET highest_boss_kills = excluded.highest_boss_kills, updated_at = CURRENT_TIMESTAMP `).run(accountId, slotId, highestEndlessKills); } function writeSave(database, accountId, slotId, rawSave) { const save = validateSave(rawSave, slotId); const serialized = JSON.stringify(save); if (Buffer.byteLength(serialized) > MAX_JSON_BYTES) throw apiError("Save snapshot is too large.", 413); database.exec("BEGIN IMMEDIATE"); try { database.prepare(` INSERT INTO hunter_saves (account_id, slot_id, hunter_name, save_json, updated_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(account_id, slot_id) DO UPDATE SET hunter_name = excluded.hunter_name, save_json = excluded.save_json, updated_at = CURRENT_TIMESTAMP `).run(accountId, slotId, save.hunterName.trim().slice(0, 20), serialized); syncLeaderboardStats(database, accountId, slotId, save); database.exec("COMMIT"); } catch (error) { database.exec("ROLLBACK"); throw error; } return save; } function readSave(database, accountId, slotId) { const row = database.prepare(` SELECT save_json AS saveJson FROM hunter_saves WHERE account_id = ? AND slot_id = ? `).get(accountId, slotId); if (!row) return null; try { return JSON.parse(row.saveJson); } catch { return null; } } function listSaves(database, accountId) { return database.prepare(` SELECT slot_id AS slotId, save_json AS saveJson, updated_at AS updatedAt FROM hunter_saves WHERE account_id = ? ORDER BY slot_id `).all(accountId).flatMap((row) => { try { return [{ slotId: row.slotId, save: JSON.parse(row.saveJson), updatedAt: row.updatedAt }]; } catch { return []; } }); } function leaderboardEntry(row, valueKey) { return { rank: row.rank, username: row.username, hunterName: row.hunterName, slotId: row.slotId, value: row[valueKey], }; } function bossLeaderboard(database, accountId, slotId, bossId) { if (!/^[a-z0-9-]{1,64}$/.test(bossId)) throw apiError("Invalid boss."); const rows = database.prepare(` WITH ranked AS ( SELECT RANK() OVER (ORDER BY records.kills DESC) AS rank, records.account_id AS accountId, records.slot_id AS slotId, records.kills, accounts.username, saves.hunter_name AS hunterName, records.updated_at AS updatedAt FROM boss_kill_records records JOIN accounts ON accounts.id = records.account_id JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id WHERE records.boss_id = ? ) SELECT * FROM ranked ORDER BY kills DESC, updatedAt ASC, accountId ASC, slotId ASC `).all(bossId); const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null; return { kind: "boss", bossId, top: rows.slice(0, 5).map((row) => leaderboardEntry(row, "kills")), current: current ? leaderboardEntry(current, "kills") : null, }; } function roguelikeLeaderboard(database, accountId, slotId) { const rows = database.prepare(` WITH ranked AS ( SELECT RANK() OVER (ORDER BY records.highest_round DESC) AS rank, records.account_id AS accountId, records.slot_id AS slotId, records.highest_round AS highestRound, accounts.username, saves.hunter_name AS hunterName, records.updated_at AS updatedAt FROM roguelike_records records JOIN accounts ON accounts.id = records.account_id JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id WHERE records.highest_round > 0 ) SELECT * FROM ranked ORDER BY highestRound DESC, updatedAt ASC, accountId ASC, slotId ASC `).all(); const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null; return { kind: "roguelike", top: rows.slice(0, 5).map((row) => leaderboardEntry(row, "highestRound")), current: current ? leaderboardEntry(current, "highestRound") : null, }; } function rogueTrialsEndlessLeaderboard(database, accountId, slotId) { const rows = database.prepare(` WITH ranked AS ( SELECT RANK() OVER (ORDER BY records.highest_boss_kills DESC) AS rank, records.account_id AS accountId, records.slot_id AS slotId, records.highest_boss_kills AS highestBossKills, accounts.username, saves.hunter_name AS hunterName, records.updated_at AS updatedAt FROM rogue_trials_endless_records records JOIN accounts ON accounts.id = records.account_id JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id WHERE records.highest_boss_kills > 0 ) SELECT * FROM ranked ORDER BY highestBossKills DESC, updatedAt ASC, accountId ASC, slotId ASC `).all(); const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null; return { kind: "rogue-trials-endless", top: rows.slice(0, 5).map((row) => leaderboardEntry(row, "highestBossKills")), current: current ? leaderboardEntry(current, "highestBossKills") : null, }; } export function createGameApiHandler(options = {}) { const dataDirectory = resolve(options.dataDirectory ?? process.env.DATA_DIR ?? "data"); mkdirSync(dataDirectory, { recursive: true }); const database = new DatabaseSync(resolve(dataDirectory, "game.db")); database.exec(readFileSync(new URL("../db/schema.sql", import.meta.url), "utf8")); async function handle(request, response, next) { if (!request.url?.startsWith("/api/")) return next(); setCorsHeaders(request, response); if (request.method === "OPTIONS") { response.statusCode = 204; return response.end(); } try { database.prepare("DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP").run(); const url = new URL(request.url, "http://localhost"); const path = url.pathname; if (path === "/api/health" && request.method === "GET") { return sendJson(response, 200, { ok: true, database: "ready" }); } if (path === "/api/auth/register" && request.method === "POST") { enforceAuthRateLimit(request); return sendJson(response, 201, register(database, await readJson(request))); } if (path === "/api/auth/login" && request.method === "POST") { enforceAuthRateLimit(request); return sendJson(response, 200, login(database, await readJson(request))); } if (path === "/api/auth/session" && request.method === "GET") { const session = currentSession(database, request); return sendJson(response, session ? 200 : 401, session ? { account: { id: session.accountId, username: session.username } } : { error: "Sign in required." }); } if (path === "/api/auth/logout" && request.method === "POST") { const token = bearerToken(request); if (token) database.prepare("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash(token)); return sendJson(response, 200, { ok: true }); } const session = requireSession(database, request); if (path === "/api/saves" && request.method === "GET") { return sendJson(response, 200, { slots: listSaves(database, session.accountId) }); } const saveMatch = path.match(/^\/api\/saves\/([1-3])$/); if (saveMatch && request.method === "GET") { return sendJson(response, 200, { save: readSave(database, session.accountId, validateSlotId(saveMatch[1])) }); } if (saveMatch && request.method === "PUT") { const slotId = validateSlotId(saveMatch[1]); const payload = await readJson(request); return sendJson(response, 200, { save: writeSave(database, session.accountId, slotId, payload?.save) }); } const bossMatch = path.match(/^\/api\/leaderboards\/boss\/([a-z0-9-]+)$/); if (bossMatch && request.method === "GET") { const slotId = validateSlotId(url.searchParams.get("slot")); return sendJson(response, 200, bossLeaderboard(database, session.accountId, slotId, bossMatch[1])); } if (path === "/api/leaderboards/roguelike" && request.method === "GET") { const slotId = validateSlotId(url.searchParams.get("slot")); return sendJson(response, 200, roguelikeLeaderboard(database, session.accountId, slotId)); } if (path === "/api/leaderboards/rogue-trials-endless" && request.method === "GET") { const slotId = validateSlotId(url.searchParams.get("slot")); return sendJson(response, 200, rogueTrialsEndlessLeaderboard(database, session.accountId, slotId)); } return sendJson(response, 404, { error: "API route not found." }); } catch (error) { const status = Number(error?.status) || 500; const message = status >= 500 ? "Server error." : error.message; if (status >= 500) console.error(error); return sendJson(response, status, { error: message }); } } return { handle, close: () => database.close() }; }