Release Healer Man 0.1.5
This commit is contained in:
+98
-2
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}));
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user