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"; 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)); } function publicAccount(row) { return { id: row.id, username: row.username, createdAt: Number(row.created_at) }; } 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; `); 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 `), }; 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, ...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 }; } return { databasePath, register, login, authenticate, logout: (token) => { if (typeof token === "string" && token) statements.deleteSession.run(tokenHash(token)); }, getCloudSave, putCloudSave, pruneExpiredSessions: () => Number(statements.deleteExpiredSessions.run(Date.now()).changes), close: () => database.close(), }; }