Files
2026-08-14 15:56:39 -04:00

175 lines
7.8 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";
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(),
};
}