import { createHash, randomBytes, randomUUID, scrypt as scryptCallback, timingSafeEqual } from "node:crypto"; import { mkdirSync } from "node:fs"; import path from "node:path"; import { promisify } from "node:util"; import { DatabaseSync } from "node:sqlite"; import { cloneDefaultManastormAdminConfig, normalizeManastormAdminConfig, } from "./manastorm-config.mjs"; const scrypt = promisify(scryptCallback); export class GameDatabaseError extends Error { constructor(message, status = 400, code = "bad_request") { super(message); this.name = "GameDatabaseError"; this.status = status; this.code = code; } } function validateCredentials(username, password) { if (!/^[A-Za-z0-9_]{3,20}$/.test(username)) { throw new GameDatabaseError("Username must be 3-20 letters, numbers, or underscores."); } if (typeof password !== "string" || password.length < 8) { throw new GameDatabaseError("Password must be at least 8 characters."); } if (password.length > 128) throw new GameDatabaseError("Password must be 128 characters or fewer."); } function tokenHash(token) { return createHash("sha256").update(token).digest("hex"); } async function derivePassword(password, salt) { return Buffer.from(await scrypt(password, salt, 64)); } const GM_USERNAME_KEYS = new Set(["phenom"]); function accountRoles(username) { return GM_USERNAME_KEYS.has(String(username ?? "").trim().toLowerCase()) ? ["gm"] : []; } function publicAccount(row) { return { id: row.id, username: row.username, createdAt: Number(row.created_at ?? row.createdAt), roles: accountRoles(row.username), }; } export function openGameDatabase(options = {}) { const dataDir = path.resolve(options.dataDir ?? process.env.DATA_DIR ?? "data/runtime"); const databasePath = path.resolve(options.databasePath ?? path.join(dataDir, "game.db")); const sessionTtlDays = Math.max(1, Number(options.sessionTtlDays ?? process.env.SESSION_TTL_DAYS ?? 30)); mkdirSync(path.dirname(databasePath), { recursive: true }); const database = new DatabaseSync(databasePath); database.exec("PRAGMA journal_mode = WAL"); database.exec("PRAGMA foreign_keys = ON"); database.exec("PRAGMA busy_timeout = 5000"); database.exec(` CREATE TABLE IF NOT EXISTS accounts ( id TEXT PRIMARY KEY, username TEXT NOT NULL, username_key TEXT NOT NULL UNIQUE, password_salt TEXT NOT NULL, password_hash TEXT NOT NULL, created_at INTEGER NOT NULL ) STRICT; CREATE TABLE IF NOT EXISTS sessions ( token_hash TEXT PRIMARY KEY, account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL ) STRICT; CREATE INDEX IF NOT EXISTS sessions_account_id_idx ON sessions(account_id); CREATE INDEX IF NOT EXISTS sessions_expires_at_idx ON sessions(expires_at); CREATE TABLE IF NOT EXISTS cloud_saves ( account_id TEXT PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE, revision INTEGER NOT NULL, data_json TEXT NOT NULL, updated_at INTEGER NOT NULL ) STRICT; CREATE TABLE IF NOT EXISTS manastorm_admin_config ( id INTEGER PRIMARY KEY CHECK (id = 1), schema_version INTEGER NOT NULL, revision INTEGER NOT NULL, config_json TEXT NOT NULL, updated_by_account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL, updated_by_username TEXT, updated_at INTEGER ) STRICT; CREATE TABLE IF NOT EXISTS online_groups ( id TEXT PRIMARY KEY, leader_account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, activity_json TEXT, activity_revision INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ) STRICT; CREATE TABLE IF NOT EXISTS online_group_members ( group_id TEXT NOT NULL REFERENCES online_groups(id) ON DELETE CASCADE, account_id TEXT NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE, character_json TEXT, role TEXT, joined_at INTEGER NOT NULL, PRIMARY KEY (group_id, account_id) ) STRICT; CREATE INDEX IF NOT EXISTS online_group_members_group_idx ON online_group_members(group_id, joined_at); CREATE TABLE IF NOT EXISTS online_group_invites ( id TEXT PRIMARY KEY, group_id TEXT NOT NULL REFERENCES online_groups(id) ON DELETE CASCADE, inviter_account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, invitee_account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, status TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL ) STRICT; CREATE INDEX IF NOT EXISTS online_group_invites_invitee_idx ON online_group_invites(invitee_account_id, status, expires_at); `); database.prepare(` INSERT OR IGNORE INTO manastorm_admin_config ( id, schema_version, revision, config_json, updated_by_account_id, updated_by_username, updated_at ) VALUES (1, 1, 0, ?, NULL, NULL, NULL) `).run(JSON.stringify(cloneDefaultManastormAdminConfig())); const statements = { accountByUsername: database.prepare("SELECT * FROM accounts WHERE username_key = ?"), insertAccount: database.prepare("INSERT INTO accounts (id, username, username_key, password_salt, password_hash, created_at) VALUES (?, ?, ?, ?, ?, ?)"), insertSession: database.prepare("INSERT INTO sessions (token_hash, account_id, created_at, expires_at) VALUES (?, ?, ?, ?)"), sessionAccount: database.prepare(` SELECT accounts.id, accounts.username, accounts.created_at, sessions.expires_at FROM sessions JOIN accounts ON accounts.id = sessions.account_id WHERE sessions.token_hash = ? `), deleteSession: database.prepare("DELETE FROM sessions WHERE token_hash = ?"), deleteExpiredSessions: database.prepare("DELETE FROM sessions WHERE expires_at <= ?"), cloudSave: database.prepare("SELECT revision, data_json, updated_at FROM cloud_saves WHERE account_id = ?"), upsertCloudSave: database.prepare(` INSERT INTO cloud_saves (account_id, revision, data_json, updated_at) VALUES (?, 1, ?, ?) ON CONFLICT(account_id) DO UPDATE SET revision = cloud_saves.revision + 1, data_json = excluded.data_json, updated_at = excluded.updated_at RETURNING revision, updated_at `), manastormConfig: database.prepare("SELECT * FROM manastorm_admin_config WHERE id = 1"), updateManastormConfig: database.prepare(` UPDATE manastorm_admin_config SET schema_version = 1, revision = revision + 1, config_json = ?, updated_by_account_id = ?, updated_by_username = ?, updated_at = ? WHERE id = 1 AND revision = ? RETURNING revision, updated_at `), }; function issueSession(accountId) { const token = randomBytes(32).toString("base64url"); const createdAt = Date.now(); const expiresAt = createdAt + sessionTtlDays * 24 * 60 * 60 * 1000; statements.insertSession.run(tokenHash(token), accountId, createdAt, expiresAt); return { token, expiresAt }; } async function register(usernameInput, password) { const username = typeof usernameInput === "string" ? usernameInput.trim() : ""; validateCredentials(username, password); const usernameKey = username.toLowerCase(); if (statements.accountByUsername.get(usernameKey)) { throw new GameDatabaseError("That account already exists.", 409, "account_exists"); } const salt = randomBytes(16); const digest = await derivePassword(password, salt); const account = { id: `account-${randomUUID()}`, username, createdAt: Date.now() }; try { statements.insertAccount.run(account.id, username, usernameKey, salt.toString("base64"), digest.toString("base64"), account.createdAt); } catch (error) { if (String(error?.message).includes("UNIQUE constraint failed")) { throw new GameDatabaseError("That account already exists.", 409, "account_exists"); } throw error; } return { account: publicAccount(account), ...issueSession(account.id) }; } async function login(usernameInput, password) { const username = typeof usernameInput === "string" ? usernameInput.trim() : ""; if (!username || typeof password !== "string" || !password) { throw new GameDatabaseError("Enter your username and password.", 400, "missing_credentials"); } const row = statements.accountByUsername.get(username.toLowerCase()); if (!row) throw new GameDatabaseError("Incorrect username or password.", 401, "invalid_credentials"); const actual = await derivePassword(password, Buffer.from(row.password_salt, "base64")); const expected = Buffer.from(row.password_hash, "base64"); if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) { throw new GameDatabaseError("Incorrect username or password.", 401, "invalid_credentials"); } return { account: publicAccount(row), ...issueSession(row.id) }; } function authenticate(token) { if (typeof token !== "string" || token.length < 20) { throw new GameDatabaseError("Authentication required.", 401, "authentication_required"); } const hash = tokenHash(token); const row = statements.sessionAccount.get(hash); if (!row || Number(row.expires_at) <= Date.now()) { if (row) statements.deleteSession.run(hash); throw new GameDatabaseError("Your session has expired. Please sign in again.", 401, "session_expired"); } return publicAccount(row); } function getCloudSave(accountId) { const row = statements.cloudSave.get(accountId); if (!row) return { revision: 0, updatedAt: null, data: { version: 1, characters: [] } }; try { return { revision: Number(row.revision), updatedAt: Number(row.updated_at), data: JSON.parse(row.data_json) }; } catch { throw new GameDatabaseError("The cloud save is unreadable.", 500, "cloud_save_corrupt"); } } function putCloudSave(accountId, data) { if (!data || typeof data !== "object" || Array.isArray(data)) { throw new GameDatabaseError("Cloud save data must be an object."); } const now = Date.now(); const row = statements.upsertCloudSave.get(accountId, JSON.stringify(data), now); return { revision: Number(row.revision), updatedAt: Number(row.updated_at), data }; } function isGameMaster(account) { // Recompute authority from the server allowlist for every privileged call. return accountRoles(account?.username).includes("gm"); } function getManastormConfig() { const row = statements.manastormConfig.get(); try { return { revision: Number(row?.revision ?? 0), updatedAt: row?.updated_at == null ? null : Number(row.updated_at), updatedBy: row?.updated_by_username ?? null, config: normalizeManastormAdminConfig(JSON.parse(row?.config_json ?? "null")), }; } catch (error) { console.error("Healer Man Manastorm configuration is corrupt; using safe defaults", error); return { revision: 0, updatedAt: null, updatedBy: null, config: cloneDefaultManastormAdminConfig() }; } } function putManastormConfig(account, expectedRevision, config) { if (!isGameMaster(account)) { throw new GameDatabaseError("Game Master access is required.", 403, "gm_required"); } if (!Number.isInteger(expectedRevision) || expectedRevision < 0) { throw new GameDatabaseError("A valid expected revision is required.", 400, "invalid_revision"); } let normalized; try { normalized = normalizeManastormAdminConfig(config); } catch (error) { throw new GameDatabaseError(error instanceof Error ? error.message : "Invalid Manastorm configuration.", 400, "invalid_manastorm_config"); } const now = Date.now(); const row = statements.updateManastormConfig.get( JSON.stringify(normalized), account.id, account.username, now, expectedRevision, ); if (!row) { throw new GameDatabaseError("The Manastorm configuration changed. Reload and try again.", 409, "revision_conflict"); } return { revision: Number(row.revision), updatedAt: Number(row.updated_at), updatedBy: account.username, config: normalized, }; } function pruneExpiredGroupInvites() { database.prepare(` UPDATE online_group_invites SET status = 'expired' WHERE status = 'pending' AND expires_at <= ? `).run(Date.now()); } function parseStoredJson(value, fallback = null) { if (typeof value !== "string" || !value) return fallback; try { return JSON.parse(value); } catch { return fallback; } } function groupMembership(accountId) { return database.prepare(` SELECT online_groups.* FROM online_group_members JOIN online_groups ON online_groups.id = online_group_members.group_id WHERE online_group_members.account_id = ? `).get(accountId); } function publicGroup(groupId) { const group = database.prepare("SELECT * FROM online_groups WHERE id = ?").get(groupId); if (!group) return null; const members = database.prepare(` SELECT accounts.id AS account_id, accounts.username, online_group_members.character_json, online_group_members.role, online_group_members.joined_at FROM online_group_members JOIN accounts ON accounts.id = online_group_members.account_id WHERE online_group_members.group_id = ? ORDER BY online_group_members.joined_at, accounts.username_key `).all(groupId).map((member) => ({ accountId: member.account_id, username: member.username, character: parseStoredJson(member.character_json), role: ["tank", "healer", "damage"].includes(member.role) ? member.role : null, joinedAt: Number(member.joined_at), })); return { id: group.id, leaderAccountId: group.leader_account_id, members, activity: parseStoredJson(group.activity_json), activityRevision: Number(group.activity_revision), createdAt: Number(group.created_at), updatedAt: Number(group.updated_at), }; } function onlineGroupState(accountId) { pruneExpiredGroupInvites(); const membership = groupMembership(accountId); const invitations = database.prepare(` SELECT online_group_invites.id, online_group_invites.group_id, online_group_invites.created_at, online_group_invites.expires_at, accounts.id AS inviter_account_id, accounts.username AS inviter_username FROM online_group_invites JOIN accounts ON accounts.id = online_group_invites.inviter_account_id WHERE online_group_invites.invitee_account_id = ? AND online_group_invites.status = 'pending' AND online_group_invites.expires_at > ? ORDER BY online_group_invites.created_at DESC `).all(accountId, Date.now()).map((invite) => ({ id: invite.id, groupId: invite.group_id, inviterAccountId: invite.inviter_account_id, inviterUsername: invite.inviter_username, createdAt: Number(invite.created_at), expiresAt: Number(invite.expires_at), })); return { group: membership ? publicGroup(membership.id) : null, invitations }; } function createOnlineGroup(accountId) { const existing = groupMembership(accountId); if (existing) return existing; const id = `group-${randomUUID()}`; const now = Date.now(); database.prepare(` INSERT INTO online_groups (id, leader_account_id, activity_json, activity_revision, created_at, updated_at) VALUES (?, ?, NULL, 0, ?, ?) `).run(id, accountId, now, now); database.prepare(` INSERT INTO online_group_members (group_id, account_id, character_json, role, joined_at) VALUES (?, ?, NULL, NULL, ?) `).run(id, accountId, now); return database.prepare("SELECT * FROM online_groups WHERE id = ?").get(id); } function inviteOnlineGroupMember(account, usernameInput) { pruneExpiredGroupInvites(); const username = typeof usernameInput === "string" ? usernameInput.trim() : ""; if (!username) throw new GameDatabaseError("Enter the player's account name.", 400, "missing_username"); const invitee = statements.accountByUsername.get(username.toLowerCase()); if (!invitee) throw new GameDatabaseError("No online player has that account name.", 404, "player_not_found"); if (invitee.id === account.id) throw new GameDatabaseError("You cannot invite yourself.", 400, "cannot_invite_self"); if (groupMembership(invitee.id)) throw new GameDatabaseError("That player is already in a group.", 409, "player_already_grouped"); const group = createOnlineGroup(account.id); const memberCount = Number(database.prepare("SELECT COUNT(*) AS count FROM online_group_members WHERE group_id = ?").get(group.id).count); if (memberCount >= 5) throw new GameDatabaseError("Your group is already full.", 409, "group_full"); const existing = database.prepare(` SELECT id FROM online_group_invites WHERE group_id = ? AND invitee_account_id = ? AND status = 'pending' AND expires_at > ? `).get(group.id, invitee.id, Date.now()); if (existing) throw new GameDatabaseError("That player already has an invitation from your group.", 409, "invite_exists"); const id = `invite-${randomUUID()}`; const createdAt = Date.now(); const expiresAt = createdAt + 10 * 60 * 1000; database.prepare(` INSERT INTO online_group_invites ( id, group_id, inviter_account_id, invitee_account_id, status, created_at, expires_at ) VALUES (?, ?, ?, ?, 'pending', ?, ?) `).run(id, group.id, account.id, invitee.id, createdAt, expiresAt); return { id, expiresAt, state: onlineGroupState(account.id) }; } function respondToOnlineGroupInvite(accountId, inviteId, accept) { pruneExpiredGroupInvites(); const invite = database.prepare(` SELECT * FROM online_group_invites WHERE id = ? AND invitee_account_id = ? AND status = 'pending' AND expires_at > ? `).get(inviteId, accountId, Date.now()); if (!invite) throw new GameDatabaseError("That group invitation is no longer available.", 404, "invite_not_found"); if (!accept) { database.prepare("UPDATE online_group_invites SET status = 'declined' WHERE id = ?").run(invite.id); return onlineGroupState(accountId); } if (groupMembership(accountId)) throw new GameDatabaseError("Leave your current group before accepting another invitation.", 409, "already_grouped"); const group = database.prepare("SELECT * FROM online_groups WHERE id = ?").get(invite.group_id); if (!group) throw new GameDatabaseError("That group no longer exists.", 404, "group_not_found"); const memberCount = Number(database.prepare("SELECT COUNT(*) AS count FROM online_group_members WHERE group_id = ?").get(group.id).count); if (memberCount >= 5) throw new GameDatabaseError("That group is already full.", 409, "group_full"); const now = Date.now(); database.prepare(` INSERT INTO online_group_members (group_id, account_id, character_json, role, joined_at) VALUES (?, ?, NULL, NULL, ?) `).run(group.id, accountId, now); database.prepare("UPDATE online_group_invites SET status = 'accepted' WHERE id = ?").run(invite.id); database.prepare(` UPDATE online_group_invites SET status = 'expired' WHERE invitee_account_id = ? AND status = 'pending' `).run(accountId); database.prepare(` UPDATE online_groups SET activity_json = NULL, activity_revision = activity_revision + 1, updated_at = ? WHERE id = ? `).run(now, group.id); return onlineGroupState(accountId); } function leaveOnlineGroup(accountId) { const group = groupMembership(accountId); if (!group) return onlineGroupState(accountId); database.prepare("DELETE FROM online_group_members WHERE group_id = ? AND account_id = ?").run(group.id, accountId); const remaining = database.prepare(` SELECT account_id FROM online_group_members WHERE group_id = ? ORDER BY joined_at LIMIT 1 `).get(group.id); if (!remaining) { database.prepare("DELETE FROM online_groups WHERE id = ?").run(group.id); } else { database.prepare(` UPDATE online_groups SET leader_account_id = CASE WHEN leader_account_id = ? THEN ? ELSE leader_account_id END, activity_json = NULL, activity_revision = activity_revision + 1, updated_at = ? WHERE id = ? `).run(accountId, remaining.account_id, Date.now(), group.id); } return onlineGroupState(accountId); } function removeOnlineGroupMember(accountId, targetAccountId) { const group = groupMembership(accountId); if (!group || group.leader_account_id !== accountId) { throw new GameDatabaseError("Only the group leader can remove players.", 403, "leader_required"); } if (targetAccountId === accountId) return leaveOnlineGroup(accountId); const removed = database.prepare(` DELETE FROM online_group_members WHERE group_id = ? AND account_id = ? `).run(group.id, targetAccountId); if (!removed.changes) throw new GameDatabaseError("That player is not in your group.", 404, "member_not_found"); database.prepare(` UPDATE online_groups SET activity_json = NULL, activity_revision = activity_revision + 1, updated_at = ? WHERE id = ? `).run(Date.now(), group.id); return onlineGroupState(accountId); } function normalizeGroupCharacter(character) { if (!character || typeof character !== "object" || Array.isArray(character)) return null; const id = String(character.id ?? "").slice(0, 100); const name = String(character.name ?? "").trim().slice(0, 30); const classId = String(character.classId ?? "").slice(0, 50); if (!id || !name || !classId) throw new GameDatabaseError("Select a valid character for the group.", 400, "invalid_group_character"); let appearance = {}; if (character.appearance && typeof character.appearance === "object" && !Array.isArray(character.appearance)) { try { const serialized = JSON.stringify(character.appearance); if (serialized.length <= 8 * 1024) appearance = JSON.parse(serialized); } catch { appearance = {}; } } return { id, name, classId, categoryId: String(character.categoryId ?? "wow").slice(0, 20), raceId: String(character.raceId ?? "human").slice(0, 50), gender: character.gender === "female" ? "female" : "male", level: Math.max(1, Math.min(1000, Math.trunc(Number(character.level) || 1))), appearance, }; } function updateOnlineGroupMember(accountId, character, role) { const group = groupMembership(accountId); if (!group) throw new GameDatabaseError("Join or create a group first.", 409, "group_required"); if (!["tank", "healer", "damage"].includes(role)) { throw new GameDatabaseError("Choose a valid party role.", 400, "invalid_party_role"); } database.prepare(` UPDATE online_group_members SET character_json = ?, role = ? WHERE group_id = ? AND account_id = ? `).run(JSON.stringify(normalizeGroupCharacter(character)), role, group.id, accountId); database.prepare("UPDATE online_groups SET updated_at = ? WHERE id = ?").run(Date.now(), group.id); return onlineGroupState(accountId); } function startOnlineGroupActivity(accountId, input) { const group = groupMembership(accountId); if (!group || group.leader_account_id !== accountId) { throw new GameDatabaseError("Only the group leader can start an activity.", 403, "leader_required"); } const type = input?.type; if (type !== "dungeon" && type !== "manastorm") { throw new GameDatabaseError("Choose a Dungeon or Manastorm.", 400, "invalid_activity_type"); } const contentId = String(input?.contentId ?? "").trim().slice(0, 120); if (!contentId) throw new GameDatabaseError("Choose content before starting.", 400, "missing_content"); const members = database.prepare(` SELECT account_id, character_json, role FROM online_group_members WHERE group_id = ? ORDER BY joined_at `).all(group.id); if (members.some((member) => !member.character_json || !["tank", "healer", "damage"].includes(member.role))) { throw new GameDatabaseError("Every player must select a character and party role before the group can start.", 409, "group_not_ready"); } const fillWithAi = input?.fillWithAi !== false; const requestedSize = Math.max(1, Math.min(5, Math.trunc(Number(input?.partySize) || 5))); const partySize = fillWithAi ? Math.max(members.length, requestedSize) : members.length; const activity = { id: `activity-${randomUUID()}`, type, contentId, fillWithAi, partySize, startingLevel: type === "manastorm" ? Math.max(1, Math.trunc(Number(input?.startingLevel) || 1)) : null, startedAt: Date.now(), }; database.prepare(` UPDATE online_groups SET activity_json = ?, activity_revision = activity_revision + 1, updated_at = ? WHERE id = ? `).run(JSON.stringify(activity), activity.startedAt, group.id); return onlineGroupState(accountId); } function clearOnlineGroupActivity(accountId) { const group = groupMembership(accountId); if (!group || group.leader_account_id !== accountId) { throw new GameDatabaseError("Only the group leader can reset the activity.", 403, "leader_required"); } database.prepare(` UPDATE online_groups SET activity_json = NULL, activity_revision = activity_revision + 1, updated_at = ? WHERE id = ? `).run(Date.now(), group.id); return onlineGroupState(accountId); } return { databasePath, register, login, authenticate, logout: (token) => { if (typeof token === "string" && token) statements.deleteSession.run(tokenHash(token)); }, getCloudSave, putCloudSave, isGameMaster, getManastormConfig, putManastormConfig, onlineGroupState, inviteOnlineGroupMember, respondToOnlineGroupInvite, leaveOnlineGroup, removeOnlineGroupMember, updateOnlineGroupMember, startOnlineGroupActivity, clearOnlineGroupActivity, pruneExpiredSessions: () => Number(statements.deleteExpiredSessions.run(Date.now()).changes), close: () => database.close(), }; }