Release Healer Man 0.1.5

This commit is contained in:
phenom
2026-08-18 16:22:19 -04:00
parent f4c9cf356c
commit 55cfd43d66
62 changed files with 3030 additions and 153 deletions
+98 -2
View File
@@ -3,6 +3,10 @@ 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);
@@ -33,8 +37,19 @@ 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) };
return {
id: row.id,
username: row.username,
createdAt: Number(row.created_at ?? row.createdAt),
roles: accountRoles(row.username),
};
}
export function openGameDatabase(options = {}) {
@@ -62,7 +77,21 @@ export function openGameDatabase(options = {}) {
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 = ?"),
@@ -82,6 +111,18 @@ export function openGameDatabase(options = {}) {
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) {
@@ -110,7 +151,7 @@ export function openGameDatabase(options = {}) {
}
throw error;
}
return { account, ...issueSession(account.id) };
return { account: publicAccount(account), ...issueSession(account.id) };
}
async function login(usernameInput, password) {
@@ -160,6 +201,58 @@ export function openGameDatabase(options = {}) {
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,
@@ -168,6 +261,9 @@ export function openGameDatabase(options = {}) {
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(),
};
+70
View File
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { DatabaseSync } from "node:sqlite";
import { GameDatabaseError, openGameDatabase } from "./database.mjs";
function withDatabase(run) {
@@ -52,3 +53,72 @@ test("persists opaque cloud roster data with increasing revisions", () => withDa
assert.equal(second.revision, 2);
assert.equal(database.getCloudSave(registered.account.id).data.characters[0].name, "Mendbetter");
}));
test("derives GM authority from the hardcoded Phenom account", () => withDatabase(async (database) => {
const gm = await database.register("Phenom", "storm-admin");
const player = await database.register("Ordinary", "storm-player");
assert.deepEqual(gm.account.roles, ["gm"]);
assert.deepEqual((await database.login("pHeNoM", "storm-admin")).account.roles, ["gm"]);
assert.equal(database.isGameMaster(database.authenticate(gm.token)), true);
assert.equal(database.isGameMaster(database.authenticate(player.token)), false);
assert.equal(database.isGameMaster({ ...player.account, roles: ["gm"] }), false);
}));
test("recovers Manastorm configuration after restart and falls back safely from corrupt data", async () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-db-restart-test-"));
const databasePath = path.join(directory, "game.db");
try {
const first = openGameDatabase({ databasePath });
const gm = await first.register("Phenom", "storm-admin");
const current = first.getManastormConfig();
first.putManastormConfig(first.authenticate(gm.token), 0, {
...current.config,
global: { ...current.config.global, enabled: false },
});
first.close();
const restarted = openGameDatabase({ databasePath });
assert.equal(restarted.getManastormConfig().revision, 1);
assert.equal(restarted.getManastormConfig().config.global.enabled, false);
restarted.close();
const raw = new DatabaseSync(databasePath);
raw.prepare("UPDATE manastorm_admin_config SET config_json = ? WHERE id = 1").run("{corrupt");
raw.close();
const recovered = openGameDatabase({ databasePath });
assert.equal(recovered.getManastormConfig().revision, 0);
assert.deepEqual(recovered.getManastormConfig().config.bosses, {});
recovered.close();
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test("persists revisioned Manastorm GM configuration and rejects stale or unauthorized writes", () => withDatabase(async (database) => {
const gm = await database.register("Phenom", "storm-admin");
const player = await database.register("Ordinary", "storm-player");
const gmAccount = database.authenticate(gm.token);
const playerAccount = database.authenticate(player.token);
const initial = database.getManastormConfig();
assert.equal(initial.revision, 0);
assert.deepEqual(initial.config.bosses, {});
const configured = {
...initial.config,
global: { ...initial.config.global, affixesEnabled: false },
};
const saved = database.putManastormConfig(gmAccount, 0, configured);
assert.equal(saved.revision, 1);
assert.equal(saved.config.global.affixesEnabled, false);
assert.equal(database.getManastormConfig().updatedBy, "Phenom");
assert.throws(() => database.putManastormConfig(gmAccount, 0, configured), (error) => (
error instanceof GameDatabaseError && error.status === 409
));
assert.throws(() => database.putManastormConfig(playerAccount, 1, configured), (error) => (
error instanceof GameDatabaseError && error.status === 403
));
assert.throws(() => database.putManastormConfig(gmAccount, 1, {
...configured,
bosses: { "boss:incomplete": { status: "ready" } },
}), (error) => error instanceof GameDatabaseError && error.status === 400);
}));
+161
View File
@@ -0,0 +1,161 @@
export const MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION = 1;
export const DEFAULT_MANASTORM_ADMIN_CONFIG = Object.freeze({
schemaVersion: MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION,
global: Object.freeze({
enabled: true,
levelingEnabled: true,
endgameEnabled: true,
affixesEnabled: true,
chaoticLinkEnabled: true,
milestoneEncountersEnabled: true,
}),
dungeonSpawns: Object.freeze({}),
bosses: Object.freeze({}),
});
const BOSS_STATUSES = new Set(["ready", "needs-tested", "not-ready"]);
const MAX_RECORDS = 10_000;
const MAX_ID_LENGTH = 256;
function invalid(message) {
const error = new Error(message);
error.code = "invalid_manastorm_config";
return error;
}
function record(value, label) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw invalid(`${label} must be an object.`);
}
return value;
}
function identifier(value, label) {
if (typeof value !== "string" || !value.trim() || value.length > MAX_ID_LENGTH) {
throw invalid(`${label} must be a non-empty string no longer than ${MAX_ID_LENGTH} characters.`);
}
return value.trim();
}
function optionalIdentifier(value, label) {
return value === undefined || value === null ? undefined : identifier(value, label);
}
function finiteNumber(value, label) {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw invalid(`${label} must be a finite number.`);
}
return value;
}
function vector(value, label) {
if (!Array.isArray(value) || value.length !== 3) {
throw invalid(`${label} must contain exactly three coordinates.`);
}
return value.map((coordinate, index) => finiteNumber(coordinate, `${label}[${index}]`));
}
function worldSpawn(value, label) {
const input = record(value, label);
return {
footPosition: vector(input.footPosition, `${label}.footPosition`),
yaw: finiteNumber(input.yaw, `${label}.yaw`),
};
}
function boolean(value, label) {
if (typeof value !== "boolean") throw invalid(`${label} must be a boolean.`);
return value;
}
function normalizeGlobal(value) {
const input = record(value, "config.global");
return {
enabled: boolean(input.enabled, "config.global.enabled"),
levelingEnabled: boolean(input.levelingEnabled, "config.global.levelingEnabled"),
endgameEnabled: boolean(input.endgameEnabled, "config.global.endgameEnabled"),
affixesEnabled: boolean(input.affixesEnabled, "config.global.affixesEnabled"),
chaoticLinkEnabled: boolean(input.chaoticLinkEnabled, "config.global.chaoticLinkEnabled"),
milestoneEncountersEnabled: boolean(
input.milestoneEncountersEnabled,
"config.global.milestoneEncountersEnabled",
),
};
}
function normalizeStage(value, label) {
const input = record(value, label);
const linkedMobRuntimeIds = Array.isArray(input.linkedMobRuntimeIds)
? input.linkedMobRuntimeIds.map((id, index) => identifier(id, `${label}.linkedMobRuntimeIds[${index}]`))
: (() => { throw invalid(`${label}.linkedMobRuntimeIds must be an array.`); })();
if (new Set(linkedMobRuntimeIds).size !== linkedMobRuntimeIds.length) {
throw invalid(`${label}.linkedMobRuntimeIds must not contain duplicates.`);
}
const requiredKillCount = finiteNumber(input.requiredKillCount, `${label}.requiredKillCount`);
if (!Number.isInteger(requiredKillCount) || requiredKillCount < 0 || requiredKillCount > linkedMobRuntimeIds.length) {
throw invalid(`${label}.requiredKillCount must be an integer between zero and the linked mob count.`);
}
const mapId = finiteNumber(input.mapId, `${label}.mapId`);
if (!Number.isInteger(mapId) || mapId <= 0) throw invalid(`${label}.mapId must be a positive integer.`);
return {
dungeonId: identifier(input.dungeonId, `${label}.dungeonId`),
mapId,
bossRuntimeId: identifier(input.bossRuntimeId, `${label}.bossRuntimeId`),
bossEntityId: identifier(input.bossEntityId, `${label}.bossEntityId`),
bossName: identifier(input.bossName, `${label}.bossName`),
...(optionalIdentifier(input.baseStageId, `${label}.baseStageId`)
? { baseStageId: optionalIdentifier(input.baseStageId, `${label}.baseStageId`) }
: {}),
...(optionalIdentifier(input.assetPackageId, `${label}.assetPackageId`)
? { assetPackageId: optionalIdentifier(input.assetPackageId, `${label}.assetPackageId`) }
: {}),
bossPosition: vector(input.bossPosition, `${label}.bossPosition`),
bossYaw: finiteNumber(input.bossYaw, `${label}.bossYaw`),
groupSpawn: worldSpawn(input.groupSpawn, `${label}.groupSpawn`),
portalPosition: vector(input.portalPosition, `${label}.portalPosition`),
linkedMobRuntimeIds,
requiredKillCount,
};
}
function normalizeDictionary(value, label, normalizeValue) {
const input = record(value, label);
const entries = Object.entries(input);
if (entries.length > MAX_RECORDS) throw invalid(`${label} contains too many records.`);
return Object.fromEntries(entries.map(([key, entry]) => [
identifier(key, `${label} key`),
normalizeValue(entry, `${label}.${key}`),
]));
}
export function normalizeManastormAdminConfig(value) {
const input = record(value, "config");
if (input.schemaVersion !== MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION) {
throw invalid(`Unsupported Manastorm admin config schema ${String(input.schemaVersion)}.`);
}
const dungeonSpawns = normalizeDictionary(input.dungeonSpawns, "config.dungeonSpawns", worldSpawn);
const bosses = normalizeDictionary(input.bosses, "config.bosses", (rawBoss, label) => {
const boss = record(rawBoss, label);
if (!BOSS_STATUSES.has(boss.status)) throw invalid(`${label}.status is invalid.`);
if (boss.status === "ready" && (boss.stage === undefined || boss.stage === null)) {
throw invalid(`${label} cannot be Ready without a complete stage.`);
}
return {
status: boss.status,
...(boss.stage === undefined || boss.stage === null
? {}
: { stage: normalizeStage(boss.stage, `${label}.stage`) }),
};
});
return {
schemaVersion: MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION,
global: normalizeGlobal(input.global),
dungeonSpawns,
bosses,
};
}
export function cloneDefaultManastormAdminConfig() {
return JSON.parse(JSON.stringify(DEFAULT_MANASTORM_ADMIN_CONFIG));
}
+21
View File
@@ -209,6 +209,27 @@ export function createGameServer(options = {}) {
json(response, 200, gameDatabase.putCloudSave(account.id, body.data));
return;
}
if (url.pathname === "/api/manastorm-config" && request.method === "GET") {
const current = gameDatabase.getManastormConfig();
json(response, 200, {
revision: current.revision,
updatedAt: current.updatedAt,
config: current.config,
});
return;
}
if (url.pathname === "/api/gm/manastorm-config" && request.method === "PUT") {
const account = gameDatabase.authenticate(bearerToken(request));
if (!gameDatabase.isGameMaster(account)) {
console.warn(`Denied Manastorm GM write for ${account.username} (${account.id})`);
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
}
const body = await readJson(request, bodyLimit);
const result = gameDatabase.putManastormConfig(account, body.expectedRevision, body.config);
console.info(`Manastorm configuration revision ${result.revision} published by ${account.username}`);
json(response, 200, result);
return;
}
if (url.pathname.startsWith("/api/")) {
json(response, 404, { error: "API route not found.", code: "not_found" });
return;
+31
View File
@@ -53,6 +53,37 @@ test("serves the game and authenticated cloud-save API", async () => {
});
assert.equal(saved.status, 200);
assert.equal((await saved.json()).revision, 1);
const publicConfig = await fetch(`${origin}/api/manastorm-config`);
assert.equal(publicConfig.status, 200);
assert.equal((await publicConfig.json()).revision, 0);
const denied = await fetch(`${origin}/api/gm/manastorm-config`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` },
body: JSON.stringify({ expectedRevision: 0, config: { schemaVersion: 1 } }),
});
assert.equal(denied.status, 403);
const gmRegistration = await fetch(`${origin}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "Phenom", password: "storm-admin" }),
});
assert.equal(gmRegistration.status, 201);
const gmAuth = await gmRegistration.json();
assert.deepEqual(gmAuth.account.roles, ["gm"]);
const current = await (await fetch(`${origin}/api/manastorm-config`)).json();
const published = await fetch(`${origin}/api/gm/manastorm-config`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${gmAuth.token}` },
body: JSON.stringify({
expectedRevision: current.revision,
config: { ...current.config, global: { ...current.config.global, chaoticLinkEnabled: false } },
}),
});
assert.equal(published.status, 200);
assert.equal((await published.json()).config.global.chaoticLinkEnabled, false);
} finally {
await new Promise((resolve) => runtime.server.close(resolve));
rmSync(directory, { recursive: true, force: true });