From e79bc2e339af8aa34a42cc615aaf581648c14e33 Mon Sep 17 00:00:00 2001 From: super-jalawii Date: Sat, 27 Jun 2026 11:10:23 -0400 Subject: [PATCH] extract server transform and validation helpers --- server.js | 160 ++++----------------------- server/contentTransforms.d.ts | 3 + server/contentTransforms.js | 27 +++++ server/validation.d.ts | 10 ++ server/validation.js | 55 +++++++++ server/worldTransforms.d.ts | 36 ++++++ server/worldTransforms.js | 71 ++++++++++++ src/server/contentTransforms.test.ts | 21 ++++ src/server/validation.test.ts | 50 +++++++++ src/server/worldTransforms.test.ts | 79 +++++++++++++ 10 files changed, 373 insertions(+), 139 deletions(-) create mode 100644 server/contentTransforms.d.ts create mode 100644 server/contentTransforms.js create mode 100644 server/validation.d.ts create mode 100644 server/validation.js create mode 100644 server/worldTransforms.d.ts create mode 100644 server/worldTransforms.js create mode 100644 src/server/contentTransforms.test.ts create mode 100644 src/server/validation.test.ts create mode 100644 src/server/worldTransforms.test.ts diff --git a/server.js b/server.js index caf42fc..7efea2b 100644 --- a/server.js +++ b/server.js @@ -3,6 +3,23 @@ import { spawn } from "child_process"; import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; +import { + areRowsOnlyFillChar, + normalizeBackgroundTileId, +} from "./server/contentTransforms.js"; +import { + buildWorldChunkFileName, + defaultWorldDirRel, + getWorldStoragePaths as buildWorldStoragePaths, + normalizeWorldBookmark, + normalizeWorldIndexEntry, + normalizeWorldIndexPayload, + sanitizeWorldId, +} from "./server/worldTransforms.js"; +import { + validateCatalogMetaPayload as validateCatalogMetaPayloadShape, + validatePayload as validatePayloadShape, +} from "./server/validation.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -961,27 +978,6 @@ function writeLauncherRequestsPayload(payload) { }); } -function normalizeBackgroundTileId(value, idToSymbol = null) { - const normalizedId = String(value || "").trim(); - if (!normalizedId) { - return ""; - } - if (idToSymbol instanceof Map && idToSymbol.size > 0 && !idToSymbol.has(normalizedId)) { - return ""; - } - return normalizedId; -} - -function areRowsOnlyFillChar(rows, fillChar = ".") { - if (!Array.isArray(rows) || rows.length === 0) { - return true; - } - return rows.every((row) => { - const normalizedRow = String(row || ""); - return normalizedRow.length === 0 || normalizedRow.split("").every((ch) => ch === fillChar); - }); -} - function createDefaultColorCatalogEntries() { return DEFAULT_COLOR_HEXES_ORDERED.map((hex, index) => { const symbol = DEFAULT_COLOR_SYMBOLS_ORDERED[index] || `X${index}`; @@ -1030,70 +1026,14 @@ function readJsonSafe(fullPath, fallback) { } } -function toContentAbs(relPath) { - const normalized = String(relPath || "").replace(/\\/g, "/").replace(/^\/+/, ""); - return path.resolve(contentRoot, normalized); -} - -function sanitizeWorldId(worldId) { - const raw = String(worldId || "").trim(); - if (!raw) { - return "world"; - } - return raw.replace(/[^a-zA-Z0-9_-]/g, "_"); -} - -function defaultWorldDirRel(worldId) { - return `worlds/${sanitizeWorldId(worldId)}`; -} - -function buildWorldChunkFileName(chunkX, chunkY) { - return `${Math.floor(Number(chunkX) || 0)}_${Math.floor(Number(chunkY) || 0)}.json`; -} - function getWorldStoragePaths(worldEntryOrId) { - const worldId = typeof worldEntryOrId === "string" - ? String(worldEntryOrId || "").trim() - : String(worldEntryOrId?.id || "").trim(); - const worldDirRel = typeof worldEntryOrId === "string" - ? defaultWorldDirRel(worldId) - : String(worldEntryOrId?.worldDir || defaultWorldDirRel(worldId)); - const worldDirAbs = toContentAbs(worldDirRel); - const chunksDirRel = `${worldDirRel}/chunks`; - return { - worldId, - worldDirRel, - worldDirAbs, - worldJsonRel: `${worldDirRel}/world.json`, - worldJsonAbs: path.join(worldDirAbs, "world.json"), - bookmarksRel: `${worldDirRel}/bookmarks.json`, - bookmarksAbs: path.join(worldDirAbs, "bookmarks.json"), - chunksDirRel, - chunksDirAbs: path.join(worldDirAbs, "chunks"), - }; -} - -function normalizeWorldIndexEntry(entry) { - const id = sanitizeWorldId(entry?.id || ""); - return { - id, - name: String(entry?.name || id || "World"), - worldDir: String(entry?.worldDir || defaultWorldDirRel(id)), - }; + return buildWorldStoragePaths(contentRoot, worldEntryOrId); } function readWorldIndexPayload() { const fallback = { schemaVersion: 1, worlds: [] }; const payload = readJsonSafe(worldsIndexPath, fallback); - const worlds = Array.isArray(payload?.worlds) - ? payload.worlds - .filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry)) - .map((entry) => normalizeWorldIndexEntry(entry)) - : []; - return { - schemaVersion: typeof payload?.schemaVersion === "number" ? payload.schemaVersion : 1, - worlds, - }; + return normalizeWorldIndexPayload(payload); } function normalizeWorldDefinitionPayload(payload, fallbackId = "") { @@ -1155,16 +1095,6 @@ function readWorldDefinitionPayload(worldId) { ); } -function normalizeWorldBookmark(entry, index = 0) { - const fallbackId = `bookmark_${index + 1}`; - return { - id: String(entry?.id || fallbackId).trim() || fallbackId, - label: String(entry?.label || entry?.id || fallbackId).trim() || fallbackId, - x: Math.floor(Number(entry?.x) || 0), - y: Math.floor(Number(entry?.y) || 0), - }; -} - function readWorldBookmarksPayload(worldId) { const normalizedId = sanitizeWorldId(worldId); const storage = getWorldStoragePaths(normalizedId); @@ -2164,59 +2094,11 @@ function injectNpcNodeDescriptions(payload, meta) { } function validatePayload(payload, type, rootKey) { - if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { - return "Payload must be an object"; - } - if (typeof payload.schemaVersion !== "number") { - return "schemaVersion must be a number"; - } - const allowedTopLevel = new Set(["schemaVersion", rootKey]); - const unknownTopLevel = Object.keys(payload).filter((key) => !allowedTopLevel.has(key)); - if (unknownTopLevel.length > 0) { - return `Unsupported top-level keys for ${type}: ${unknownTopLevel.join(", ")}`; - } - if (!Array.isArray(payload[rootKey])) { - return `Missing array root: ${rootKey}`; - } - - const idKey = REQUIRED_ID_KEY_BY_TYPE[type]; - if (!idKey) { - return null; - } - - const list = payload[rootKey]; - for (let index = 0; index < list.length; index += 1) { - const entry = list[index]; - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { - return `${rootKey}[${index}] must be an object`; - } - const idValue = String(entry[idKey] ?? "").trim(); - if (!idValue) { - return `${rootKey}[${index}] is missing required key: ${idKey}`; - } - } - - return null; + return validatePayloadShape(payload, type, rootKey, REQUIRED_ID_KEY_BY_TYPE); } function validateCatalogMetaPayload(payload) { - if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { - return "Catalog payload must be an object"; - } - if (typeof payload.schemaVersion !== "number") { - return "schemaVersion must be a number"; - } - const allowedTopLevel = new Set(["schemaVersion", ...FROZEN_CATALOG_KEYS]); - const unknownTopLevel = Object.keys(payload).filter((key) => !allowedTopLevel.has(key)); - if (unknownTopLevel.length > 0) { - return `Unsupported catalog keys: ${unknownTopLevel.join(", ")}`; - } - for (const key of FROZEN_CATALOG_KEYS) { - if (!Array.isArray(payload[key])) { - return `${key} must be an array`; - } - } - return null; + return validateCatalogMetaPayloadShape(payload, FROZEN_CATALOG_KEYS); } function writeJsonAtomic(fullPath, data) { diff --git a/server/contentTransforms.d.ts b/server/contentTransforms.d.ts new file mode 100644 index 0000000..e5acf28 --- /dev/null +++ b/server/contentTransforms.d.ts @@ -0,0 +1,3 @@ +export function normalizeBackgroundTileId(value: unknown, idToSymbol?: Map | null): string; +export function areRowsOnlyFillChar(rows: unknown, fillChar?: string): boolean; +export function resolveContentPath(contentRoot: string, relativePath: string): string; diff --git a/server/contentTransforms.js b/server/contentTransforms.js new file mode 100644 index 0000000..9465cb8 --- /dev/null +++ b/server/contentTransforms.js @@ -0,0 +1,27 @@ +import path from "path"; + +export function normalizeBackgroundTileId(value, idToSymbol = null) { + const normalizedId = String(value || "").trim(); + if (!normalizedId) { + return ""; + } + if (idToSymbol instanceof Map && idToSymbol.size > 0 && !idToSymbol.has(normalizedId)) { + return ""; + } + return normalizedId; +} + +export function areRowsOnlyFillChar(rows, fillChar = ".") { + if (!Array.isArray(rows) || rows.length === 0) { + return true; + } + return rows.every((row) => { + const normalizedRow = String(row || ""); + return normalizedRow.length === 0 || normalizedRow.split("").every((ch) => ch === fillChar); + }); +} + +export function resolveContentPath(contentRoot, relativePath) { + const normalized = String(relativePath || "").replace(/\\/g, "/").replace(/^\/+/, ""); + return path.resolve(contentRoot, normalized); +} diff --git a/server/validation.d.ts b/server/validation.d.ts new file mode 100644 index 0000000..eee0347 --- /dev/null +++ b/server/validation.d.ts @@ -0,0 +1,10 @@ +export function validatePayload( + payload: unknown, + type: string, + rootKey: string, + requiredIdKeyByType: Record, +): string | null; +export function validateCatalogMetaPayload( + payload: unknown, + frozenCatalogKeys: string[], +): string | null; diff --git a/server/validation.js b/server/validation.js new file mode 100644 index 0000000..008eae1 --- /dev/null +++ b/server/validation.js @@ -0,0 +1,55 @@ +export function validatePayload(payload, type, rootKey, requiredIdKeyByType) { + if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { + return "Payload must be an object"; + } + if (typeof payload.schemaVersion !== "number") { + return "schemaVersion must be a number"; + } + const allowedTopLevel = new Set(["schemaVersion", rootKey]); + const unknownTopLevel = Object.keys(payload).filter((key) => !allowedTopLevel.has(key)); + if (unknownTopLevel.length > 0) { + return `Unsupported top-level keys for ${type}: ${unknownTopLevel.join(", ")}`; + } + if (!Array.isArray(payload[rootKey])) { + return `Missing array root: ${rootKey}`; + } + + const idKey = requiredIdKeyByType[type]; + if (!idKey) { + return null; + } + + const list = payload[rootKey]; + for (let index = 0; index < list.length; index += 1) { + const entry = list[index]; + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return `${rootKey}[${index}] must be an object`; + } + const idValue = String(entry[idKey] ?? "").trim(); + if (!idValue) { + return `${rootKey}[${index}] is missing required key: ${idKey}`; + } + } + + return null; +} + +export function validateCatalogMetaPayload(payload, frozenCatalogKeys) { + if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { + return "Catalog payload must be an object"; + } + if (typeof payload.schemaVersion !== "number") { + return "schemaVersion must be a number"; + } + const allowedTopLevel = new Set(["schemaVersion", ...frozenCatalogKeys]); + const unknownTopLevel = Object.keys(payload).filter((key) => !allowedTopLevel.has(key)); + if (unknownTopLevel.length > 0) { + return `Unsupported catalog keys: ${unknownTopLevel.join(", ")}`; + } + for (const key of frozenCatalogKeys) { + if (!Array.isArray(payload[key])) { + return `${key} must be an array`; + } + } + return null; +} diff --git a/server/worldTransforms.d.ts b/server/worldTransforms.d.ts new file mode 100644 index 0000000..ef7fbba --- /dev/null +++ b/server/worldTransforms.d.ts @@ -0,0 +1,36 @@ +export function sanitizeWorldId(worldId: unknown): string; +export function defaultWorldDirRel(worldId: unknown): string; +export function buildWorldChunkFileName(chunkX: unknown, chunkY: unknown): string; +export function getWorldStoragePaths( + contentRoot: string, + worldEntryOrId: string | { id?: unknown; worldDir?: unknown }, +): { + worldId: string; + worldDirRel: string; + worldDirAbs: string; + worldJsonRel: string; + worldJsonAbs: string; + bookmarksRel: string; + bookmarksAbs: string; + chunksDirRel: string; + chunksDirAbs: string; +}; +export function normalizeWorldIndexEntry(entry: { id?: unknown; name?: unknown; worldDir?: unknown } | null | undefined): { + id: string; + name: string; + worldDir: string; +}; +export function normalizeWorldIndexPayload(payload: unknown): { + schemaVersion: number; + worlds: Array<{ + id: string; + name: string; + worldDir: string; + }>; +}; +export function normalizeWorldBookmark(entry: { id?: unknown; label?: unknown; x?: unknown; y?: unknown } | null | undefined, index?: number): { + id: string; + label: string; + x: number; + y: number; +}; diff --git a/server/worldTransforms.js b/server/worldTransforms.js new file mode 100644 index 0000000..ebd1af9 --- /dev/null +++ b/server/worldTransforms.js @@ -0,0 +1,71 @@ +import path from "path"; +import { resolveContentPath } from "./contentTransforms.js"; + +export function sanitizeWorldId(worldId) { + const raw = String(worldId || "").trim(); + if (!raw) { + return "world"; + } + return raw.replace(/[^a-zA-Z0-9_-]/g, "_"); +} + +export function defaultWorldDirRel(worldId) { + return `worlds/${sanitizeWorldId(worldId)}`; +} + +export function buildWorldChunkFileName(chunkX, chunkY) { + return `${Math.floor(Number(chunkX) || 0)}_${Math.floor(Number(chunkY) || 0)}.json`; +} + +export function getWorldStoragePaths(contentRoot, worldEntryOrId) { + const worldId = typeof worldEntryOrId === "string" + ? String(worldEntryOrId || "").trim() + : String(worldEntryOrId?.id || "").trim(); + const worldDirRel = typeof worldEntryOrId === "string" + ? defaultWorldDirRel(worldId) + : String(worldEntryOrId?.worldDir || defaultWorldDirRel(worldId)); + const worldDirAbs = resolveContentPath(contentRoot, worldDirRel); + const chunksDirRel = `${worldDirRel}/chunks`; + return { + worldId, + worldDirRel, + worldDirAbs, + worldJsonRel: `${worldDirRel}/world.json`, + worldJsonAbs: path.join(worldDirAbs, "world.json"), + bookmarksRel: `${worldDirRel}/bookmarks.json`, + bookmarksAbs: path.join(worldDirAbs, "bookmarks.json"), + chunksDirRel, + chunksDirAbs: path.join(worldDirAbs, "chunks"), + }; +} + +export function normalizeWorldIndexEntry(entry) { + const id = sanitizeWorldId(entry?.id || ""); + return { + id, + name: String(entry?.name || id || "World"), + worldDir: String(entry?.worldDir || defaultWorldDirRel(id)), + }; +} + +export function normalizeWorldIndexPayload(payload) { + const worlds = Array.isArray(payload?.worlds) + ? payload.worlds + .filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry)) + .map((entry) => normalizeWorldIndexEntry(entry)) + : []; + return { + schemaVersion: typeof payload?.schemaVersion === "number" ? payload.schemaVersion : 1, + worlds, + }; +} + +export function normalizeWorldBookmark(entry, index = 0) { + const fallbackId = `bookmark_${index + 1}`; + return { + id: String(entry?.id || fallbackId).trim() || fallbackId, + label: String(entry?.label || entry?.id || fallbackId).trim() || fallbackId, + x: Math.floor(Number(entry?.x) || 0), + y: Math.floor(Number(entry?.y) || 0), + }; +} diff --git a/src/server/contentTransforms.test.ts b/src/server/contentTransforms.test.ts new file mode 100644 index 0000000..d4062a8 --- /dev/null +++ b/src/server/contentTransforms.test.ts @@ -0,0 +1,21 @@ +import { areRowsOnlyFillChar, normalizeBackgroundTileId, resolveContentPath } from "../../server/contentTransforms.js"; + +describe("server/contentTransforms", () => { + it("normalizes background tile ids and optionally validates them against a known id map", () => { + expect(normalizeBackgroundTileId(" grass ")).toBe("grass"); + expect(normalizeBackgroundTileId("", new Map([["grass", "#"]]))).toBe(""); + expect(normalizeBackgroundTileId("stone", new Map([["grass", "#"]]))).toBe(""); + expect(normalizeBackgroundTileId("grass", new Map([["grass", "#"]]))).toBe("grass"); + }); + + it("detects fill-only row payloads using the provided fill character", () => { + expect(areRowsOnlyFillChar([], ".")).toBe(true); + expect(areRowsOnlyFillChar(["...", ""], ".")).toBe(true); + expect(areRowsOnlyFillChar([" "], " ")).toBe(true); + expect(areRowsOnlyFillChar(["..x"], ".")).toBe(false); + }); + + it("resolves content-relative paths without allowing leading slashes to escape intent", () => { + expect(resolveContentPath("/workspace/content", "/worlds/overworld/world.json")).toBe("/workspace/content/worlds/overworld/world.json"); + }); +}); diff --git a/src/server/validation.test.ts b/src/server/validation.test.ts new file mode 100644 index 0000000..7d3e04d --- /dev/null +++ b/src/server/validation.test.ts @@ -0,0 +1,50 @@ +import { validateCatalogMetaPayload, validatePayload } from "../../server/validation.js"; + +const REQUIRED_ID_KEY_BY_TYPE = { + images: "id", + quests: "questId", +}; + +const FROZEN_CATALOG_KEYS = ["conditions", "itemActions", "systemActions", "effects", "colors"]; + +describe("server/validation", () => { + it("validates standard catalog payload roots and required id keys", () => { + expect(validatePayload({ + schemaVersion: 1, + images: [{ id: "grass" }], + }, "images", "images", REQUIRED_ID_KEY_BY_TYPE)).toBeNull(); + + expect(validatePayload([], "images", "images", REQUIRED_ID_KEY_BY_TYPE)).toBe("Payload must be an object"); + expect(validatePayload({ images: [] }, "images", "images", REQUIRED_ID_KEY_BY_TYPE)).toBe("schemaVersion must be a number"); + expect(validatePayload({ schemaVersion: 1, wrong: [] }, "images", "images", REQUIRED_ID_KEY_BY_TYPE)).toBe("Unsupported top-level keys for images: wrong"); + expect(validatePayload({ schemaVersion: 1, images: [{}] }, "images", "images", REQUIRED_ID_KEY_BY_TYPE)).toBe("images[0] is missing required key: id"); + }); + + it("skips per-entry id checks for types without configured required ids", () => { + expect(validatePayload({ + schemaVersion: 1, + custom: [{ anything: true }], + }, "custom", "custom", REQUIRED_ID_KEY_BY_TYPE)).toBeNull(); + }); + + it("validates frozen catalog payload shape", () => { + expect(validateCatalogMetaPayload({ + schemaVersion: 1, + conditions: [], + itemActions: [], + systemActions: [], + effects: [], + colors: [], + }, FROZEN_CATALOG_KEYS)).toBeNull(); + + expect(validateCatalogMetaPayload({ + schemaVersion: 1, + conditions: [], + itemActions: [], + systemActions: [], + effects: [], + colors: [], + extra: [], + }, FROZEN_CATALOG_KEYS)).toBe("Unsupported catalog keys: extra"); + }); +}); diff --git a/src/server/worldTransforms.test.ts b/src/server/worldTransforms.test.ts new file mode 100644 index 0000000..ba8fafe --- /dev/null +++ b/src/server/worldTransforms.test.ts @@ -0,0 +1,79 @@ +import { + buildWorldChunkFileName, + defaultWorldDirRel, + getWorldStoragePaths, + normalizeWorldBookmark, + normalizeWorldIndexEntry, + normalizeWorldIndexPayload, + sanitizeWorldId, +} from "../../server/worldTransforms.js"; + +describe("server/worldTransforms", () => { + it("sanitizes world ids and builds default world directories", () => { + expect(sanitizeWorldId(" Over world!? ")).toBe("Over_world__"); + expect(sanitizeWorldId("")).toBe("world"); + expect(defaultWorldDirRel("overworld")).toBe("worlds/overworld"); + }); + + it("builds stable chunk file names", () => { + expect(buildWorldChunkFileName(-3, 4)).toBe("-3_4.json"); + expect(buildWorldChunkFileName("2.9", "-1.1")).toBe("2_-2.json"); + }); + + it("resolves world storage paths from either a world id or index entry", () => { + expect(getWorldStoragePaths("/repo/content", "overworld")).toEqual({ + worldId: "overworld", + worldDirRel: "worlds/overworld", + worldDirAbs: "/repo/content/worlds/overworld", + worldJsonRel: "worlds/overworld/world.json", + worldJsonAbs: "/repo/content/worlds/overworld/world.json", + bookmarksRel: "worlds/overworld/bookmarks.json", + bookmarksAbs: "/repo/content/worlds/overworld/bookmarks.json", + chunksDirRel: "worlds/overworld/chunks", + chunksDirAbs: "/repo/content/worlds/overworld/chunks", + }); + + expect(getWorldStoragePaths("/repo/content", { id: "city", worldDir: "custom/worlds/city" })).toEqual( + expect.objectContaining({ + worldId: "city", + worldDirRel: "custom/worlds/city", + chunksDirRel: "custom/worlds/city/chunks", + }), + ); + }); + + it("normalizes world index entries and payloads", () => { + expect(normalizeWorldIndexEntry({ id: "My World!", name: "", worldDir: "" })).toEqual({ + id: "My_World_", + name: "My_World_", + worldDir: "worlds/My_World_", + }); + + expect(normalizeWorldIndexPayload({ + schemaVersion: 3, + worlds: [ + { id: "overworld", name: "Overworld" }, + null, + [], + ], + })).toEqual({ + schemaVersion: 3, + worlds: [ + { + id: "overworld", + name: "Overworld", + worldDir: "worlds/overworld", + }, + ], + }); + }); + + it("normalizes bookmark payload entries", () => { + expect(normalizeWorldBookmark({ id: "", label: "", x: 2.9, y: -3.1 }, 1)).toEqual({ + id: "bookmark_2", + label: "bookmark_2", + x: 2, + y: -4, + }); + }); +});