Files
healer-man/server/database.mjs
T
2026-08-18 16:22:19 -04:00

271 lines
11 KiB
JavaScript

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