From bf470c7658541b6a6f3a7b6eb8c69c03fad728da Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 10 Aug 2026 05:29:35 -0500 Subject: [PATCH] spike(modules): carry /public/atlas/* behind the proposed module surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THROWAWAY BRANCH — evidence for the Phase 1 contract, never merged. See modules/uo/SPIKE.md and docs/website/MODULE_API.md Part 7. The six public spawn-atlas routes now live in modules/uo/, reached only through the ctx/register surface, with the client half loading as a prebuilt ESM chunk. All three exit criteria met: • zero internal-file imports from the module into core; the built chunk has zero bare import specifiers and bundles no React • routes.manifest.json AND routes.guards.json are byte-identical • /uo/atlas renders from /modules/uo/entry.js under script-src 'self' with zero CSP violation reports 729 core tests and 81 module tests pass. Verified end to end against the real database: the schema fragment replays after core's, onBoot runs the atlas refresh, and the six API URLs answer unchanged. Two things the spike changed in the contract: • ctx.express / ctx.validator. A module lives outside server/, so Node never reaches server/node_modules and require('express') fails outright — the server-side twin of the one-React rule, which §2.6 had only for the client. • window.__rg.jsxRuntime, so a module can build with the automatic JSX runtime its tooling already assumes rather than being forced to classic. And it confirmed §6.1 empirically: regenerating the OpenAPI spec silently deleted all 361 lines of the atlas paths with "Swagger-autogen: Success", while the route manifest kept all six in the same run. That is exactly the static-analysis-vs-runtime split the fragment merge exists to prevent. Co-Authored-By: Claude --- client/src/App.jsx | 24 +- client/src/api/client.js | 10 + client/src/components/SiteHeader.jsx | 22 +- client/src/main.jsx | 39 +- client/src/modules/registry.js | 100 + client/src/modules/shared.js | 65 + client/src/modules/version.js | 8 + modules/uo/SPIKE.md | 50 + modules/uo/client/dist/entry.js | 409 ++++ modules/uo/client/package-lock.json | 1691 +++++++++++++++++ modules/uo/client/package.json | 13 + modules/uo/client/src/api.js | 47 + modules/uo/client/src/entry.jsx | 53 + .../uo/client/src/pages}/Atlas.jsx | 9 +- .../uo/client/src/pages}/AtlasCreature.jsx | 11 +- .../uo/client/src/shim/react-dom-client.js | 3 + .../uo/client/src/shim/react-jsx-runtime.js | 8 + .../uo/client/src/shim/react-router-dom.js | 10 + modules/uo/client/src/shim/react.js | 19 + modules/uo/client/src/ui.js | 13 + modules/uo/client/vite.config.js | 43 + modules/uo/module.json | 14 + modules/uo/server/core.js | 88 + modules/uo/server/db/purge.sql | 21 + modules/uo/server/db/schema.sql | 166 ++ modules/uo/server/index.js | 59 + .../server}/model/shardAtlas/shardAtlas.db.js | 2 +- .../model/shardAtlas/shardAtlas.model.js | 4 +- .../server/model/shardLinks/shardLinks.db.js | 42 + .../model/shardLinks/shardLinks.model.js | 37 + .../shardVisibility/shardVisibility.db.js | 37 + .../shardVisibility/shardVisibility.model.js | 44 + .../uo/server/router}/atlas.controller.js | 6 +- .../uo/server/router}/atlas.router.js | 16 +- modules/uo/server/test/_ctx.js | 60 + .../uo/server}/test/spawnAtlas.parse.test.js | 2 +- .../uo/server}/test/spawnAtlas.source.test.js | 23 +- .../uo/server}/utils/spawnAtlasParse.js | 0 .../uo/server}/utils/spawnAtlasSource.js | 0 modules/uo/server/utils/visibility.js | 435 +++++ server/db/schema.sql | 150 -- server/src/app.js | 29 + server/src/modules/loader.js | 495 +++++ server/src/modules/semver.js | 47 + server/src/modules/version.js | 14 + server/src/router/v1/admin/index.js | 6 + .../router/v1/admin/shardAtlas.controller.js | 12 +- server/src/router/v1/player/index.js | 5 + server/src/router/v1/public/index.js | 18 +- server/src/server.js | 20 +- server/src/utils/db.js | 65 +- server/src/utils/htmlShell.js | 40 +- server/swagger/swagger-output.json | 361 ---- server/test/atlasController.test.js | 18 +- server/test/moduleLoader.test.js | 256 +++ 55 files changed, 4638 insertions(+), 601 deletions(-) create mode 100644 client/src/modules/registry.js create mode 100644 client/src/modules/shared.js create mode 100644 client/src/modules/version.js create mode 100644 modules/uo/SPIKE.md create mode 100644 modules/uo/client/dist/entry.js create mode 100644 modules/uo/client/package-lock.json create mode 100644 modules/uo/client/package.json create mode 100644 modules/uo/client/src/api.js create mode 100644 modules/uo/client/src/entry.jsx rename {client/src/routes/public => modules/uo/client/src/pages}/Atlas.jsx (97%) rename {client/src/routes/public => modules/uo/client/src/pages}/AtlasCreature.jsx (93%) create mode 100644 modules/uo/client/src/shim/react-dom-client.js create mode 100644 modules/uo/client/src/shim/react-jsx-runtime.js create mode 100644 modules/uo/client/src/shim/react-router-dom.js create mode 100644 modules/uo/client/src/shim/react.js create mode 100644 modules/uo/client/src/ui.js create mode 100644 modules/uo/client/vite.config.js create mode 100644 modules/uo/module.json create mode 100644 modules/uo/server/core.js create mode 100644 modules/uo/server/db/purge.sql create mode 100644 modules/uo/server/db/schema.sql create mode 100644 modules/uo/server/index.js rename {server/src => modules/uo/server}/model/shardAtlas/shardAtlas.db.js (99%) rename {server/src => modules/uo/server}/model/shardAtlas/shardAtlas.model.js (99%) create mode 100644 modules/uo/server/model/shardLinks/shardLinks.db.js create mode 100644 modules/uo/server/model/shardLinks/shardLinks.model.js create mode 100644 modules/uo/server/model/shardVisibility/shardVisibility.db.js create mode 100644 modules/uo/server/model/shardVisibility/shardVisibility.model.js rename {server/src/router/v1/public => modules/uo/server/router}/atlas.controller.js (96%) rename {server/src/router/v1/public => modules/uo/server/router}/atlas.router.js (94%) create mode 100644 modules/uo/server/test/_ctx.js rename {server => modules/uo/server}/test/spawnAtlas.parse.test.js (99%) rename {server => modules/uo/server}/test/spawnAtlas.source.test.js (96%) rename {server/src => modules/uo/server}/utils/spawnAtlasParse.js (100%) rename {server/src => modules/uo/server}/utils/spawnAtlasSource.js (100%) create mode 100644 modules/uo/server/utils/visibility.js create mode 100644 server/src/modules/loader.js create mode 100644 server/src/modules/semver.js create mode 100644 server/src/modules/version.js create mode 100644 server/test/moduleLoader.test.js diff --git a/client/src/App.jsx b/client/src/App.jsx index 3cacb15..85eec43 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -5,6 +5,7 @@ import MaintenanceGate from './components/MaintenanceGate.jsx' import RequireAuth from './components/RequireAuth.jsx' import RequirePlayer from './components/RequirePlayer.jsx' import RoleGate from './components/RoleGate.jsx' +import { routesFor } from './modules/registry.js' // Public import Portal from './routes/public/Portal.jsx' @@ -23,8 +24,6 @@ import Guilds from './routes/public/Guilds.jsx' import Governors from './routes/public/Governors.jsx' import Houses from './routes/public/Houses.jsx' import Rules from './routes/public/Rules.jsx' -import Atlas from './routes/public/Atlas.jsx' -import AtlasCreature from './routes/public/AtlasCreature.jsx' import Leaderboards from './routes/public/Leaderboards.jsx' import Market from './routes/public/Market.jsx' import MarketVendor from './routes/public/MarketVendor.jsx' @@ -108,13 +107,19 @@ export default function App() { } /> } /> } /> - } /> - } /> } /> } /> } /> } /> } /> + {/* Installed modules' public pages, namespaced `//…` (§2.8). + Declared BEFORE the /:slug CMS catch-all: React Router ranks + static segments over dynamic ones so the order is not what saves + us, but keeping them adjacent makes the relationship visible. */} + {routesFor('public').map((r) => ( + + ))} + {/* CMS pages: top-level /:slug, matched only after the named routes above (React Router ranks static routes over this dynamic one). */} } /> @@ -205,6 +210,17 @@ export default function App() { } /> } /> } /> + {/* Installed modules' admin pages, at /admin//…, already inside + RequireAuth + AdminLayout. A module cannot supply its own auth + wrapper — only an optional { roles } that core applies as the + same RoleGate its own routes use (MODULE_API.md §3.3). */} + {routesFor('admin').map((r) => ( + {r.element} : r.element} + /> + ))} } /> diff --git a/client/src/api/client.js b/client/src/api/client.js index 4312872..3ea8d36 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -42,6 +42,16 @@ function safeParse(text) { } } +// The request PRIMITIVE, exported for installed modules (window.__rg.api — see +// docs/website/MODULE_API.md §3.5). A module owns the paths it calls, because it +// owns the routes at the other end; core owns only the fetch semantics — +// same-origin /api/v1, cookies included, JSON in/out, ApiError on non-2xx. +// +// `api` below stays core's own binding surface. Its `atlas` and `shard` +// namespaces are module bindings that only still live here because Phase 3 has +// not moved them yet. +export { req as request } + export const api = { // ----- auth ----- me: () => req('/auth/me'), diff --git a/client/src/components/SiteHeader.jsx b/client/src/components/SiteHeader.jsx index e9e142d..8780bf0 100644 --- a/client/src/components/SiteHeader.jsx +++ b/client/src/components/SiteHeader.jsx @@ -7,6 +7,7 @@ import { useSite } from '../contexts/SiteContext.jsx' import { useShardFeatures, canSee } from '../lib/useShardFeatures.js' import NavDropdown from './NavDropdown.jsx' import { buildPublicNav, pruneNav } from '../lib/navOverrides.js' +import { navFor } from '../modules/registry.js' import { parseJsonSetting } from '../lib/settingsJson.js' // One consistent top nav for the whole public site. Every page gets the same @@ -33,7 +34,6 @@ export const NAV = [ { label: 'Governors', to: '/site/governors', feature: 'governors' }, { label: 'Houses', to: '/site/houses', feature: 'houses' }, { label: 'Rules', to: '/site/rules', feature: 'ruleset' }, - { label: 'Atlas', to: '/site/atlas', feature: 'atlas' }, { label: 'Leaderboards', to: '/site/leaderboards', feature: 'leaderboards' }, { label: 'Market', to: '/site/market', feature: 'market' }, { label: 'About', to: '/site/about' }, @@ -61,10 +61,26 @@ export default function SiteHeader() { // never opens onto nothing; // • with no stored row this is the coded NAV, in code order, so an // untouched instance renders exactly what it renders today. + // Installed modules' entries interleave into this list by `order` BEFORE the + // override merge, so an admin edits one nav rather than "core's, plus whatever + // the module appended" — and a module item is hideable and re-labelable + // exactly like a core one. `order` defaults high, which lands module entries + // where the UO items already sat: after the content links, before About. + const base = useMemo(() => { + const items = navFor('public') + if (items.length === 0) return NAV + const merged = [...NAV] + for (const item of items) { + const at = Number.isFinite(item.order) ? item.order : merged.length + merged.splice(Math.min(at, merged.length), 0, { label: item.label, to: item.to, feature: item.feature }) + } + return merged + }, []) + const nav = useMemo(() => { - const tree = buildPublicNav(NAV, parseJsonSetting(settings.nav_public)) + const tree = buildPublicNav(base, parseJsonSetting(settings.nav_public)) return pruneNav(tree, (item) => !item.feature || canSee(shardFeatures, item.feature)) - }, [settings.nav_public, shardFeatures]) + }, [base, settings.nav_public, shardFeatures]) // Where the auth entry points: staff → admin, player → portal, else sign in. let account diff --git a/client/src/main.jsx b/client/src/main.jsx index dc409af..09bbf34 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -2,12 +2,37 @@ import React from 'react' import { createRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import App from './App.jsx' +import { publishSharedDependencies } from './modules/shared.js' import './styles/theme.css' -createRoot(document.getElementById('root')).render( - - - - - , -) +// Publish window.__rg BEFORE rendering and before any module chunk evaluates. +// Installed modules are ``) +} + /** * Provide the built index.html. Called once at boot by app.js; a separate step * from get() so the file read stays synchronous and startup still fails loudly @@ -170,7 +193,18 @@ async function get() { // mean a failing query per page view. overrides = {} } - const html = render(template, overrides) + // The module list is filesystem-derived and synchronous, so unlike the brand + // read above it cannot fail on a DB fault and needs no fallback. Only STARTED + // modules get a script tag: a module whose onBoot failed answers 503 on its + // API, and loading its client half would render pages against a dead backend. + // eslint-disable-next-line global-require + const modules = require('../modules/loader') + const moduleEntries = modules + .list() + .filter((m) => m.state === 'started' && m.entryUrl) + .map((m) => m.entryUrl) + + const html = render(template, { ...overrides, moduleEntries }) // An invalidation that landed while this read was in flight means the value // we just read may already be stale. Serve it, but do not cache it. if (generation === startedAt) cached = { html, at: Date.now() } diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 60e9a52..80e5613 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -11201,367 +11201,6 @@ ] } }, - "/api/v1/public/atlas/champions": { - "get": { - "tags": [ - "Public · Atlas" - ], - "summary": "Configured champion altars (the roster, not the live board)", - "description": "Where the altars are and what each one summons — \"there is an Unholy Terror altar in Deceit\". `randomType` marks altars whose champion is drawn at activation. Do not conflate this with GET /public/shard/champs, which is the live sidecar-fed board (\"it is on level 3 right now\").", - "parameters": [ - { - "name": "facet", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "Limit to one facet." - } - ], - "responses": { - "200": { - "description": "Altars, by facet then name", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AtlasChampion" - } - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "Not Found" - }, - "500": { - "description": "Internal Server Error" - }, - "503": { - "description": "Service Unavailable" - } - } - } - }, - "/api/v1/public/atlas/creatures": { - "get": { - "tags": [ - "Public · Atlas" - ], - "summary": "Search the bestiary (paginated)", - "description": "Every creature the shard spawns, most numerous first. `total` is how many can be alive at once across all spawners; `points` is how many spawners mention it; `facets` maps facet name to that creature\\'s share on it. Static content parsed from the shard\\'s ServUO tree — unaffected by the shard being offline.", - "parameters": [ - { - "name": "q", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "Substring match on the creature name (max 60 chars)." - }, - { - "name": "facet", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "Limit to creatures spawning on this facet. Facet names come from the shard's own files; an unknown one returns an empty page." - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "integer" - }, - "description": "Page size, 1..100 (default 50)." - }, - { - "name": "offset", - "in": "query", - "required": false, - "schema": { - "type": "integer" - }, - "description": "Rows to skip (default 0)." - } - ], - "responses": { - "200": { - "description": "A page of creatures plus the unpaginated total", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AtlasCreaturePage" - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "403": { - "description": "The atlas feature is gated above this caller", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "404": { - "description": "The atlas feature is disabled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - }, - "503": { - "description": "Service Unavailable" - } - } - } - }, - "/api/v1/public/atlas/creatures/{slug}": { - "get": { - "tags": [ - "Public · Atlas" - ], - "summary": "One creature: where it spawns, and what spawns with it", - "description": "The answer the atlas exists to give. `places` is the aggregate — \"lizardman → Shrines, Isamu-Jima, Yew\" — resolved by point-in-rect against the shard\\'s own region rectangles, falling back to the nearest landmark, else \"Wilderness\". `spawners` lists the individual spawn points (bounded; `spawnersTruncated` says when the list was cut), and `alsoHere` is what shares those spawners.", - "parameters": [ - { - "name": "slug", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Creature slug, e.g. lizardman." - }, - { - "name": "facet", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "Restrict places and spawners to one facet." - }, - { - "name": "points", - "in": "query", - "required": false, - "schema": { - "type": "integer" - }, - "description": "Max spawners to return, 1..1000 (default 200)." - } - ], - "responses": { - "200": { - "description": "The creature", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AtlasCreature" - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "No such creature in this atlas (or the feature is disabled)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - }, - "503": { - "description": "Service Unavailable" - } - } - } - }, - "/api/v1/public/atlas/landmarks": { - "get": { - "tags": [ - "Public · Atlas" - ], - "summary": "Points of interest (dungeon levels, town markers)", - "description": "From the shard\\'s Data/Locations files. `group` is the innermost enclosing parent (\"Covetous\"), which is the label worth showing over the individual marker (\"Level 1\").", - "parameters": [ - { - "name": "facet", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "Limit to one facet." - }, - { - "name": "q", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "Substring match on the landmark name or its group." - } - ], - "responses": { - "200": { - "description": "Landmarks, by facet then group", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AtlasLandmark" - } - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "Not Found" - }, - "500": { - "description": "Internal Server Error" - }, - "503": { - "description": "Service Unavailable" - } - } - } - }, - "/api/v1/public/atlas/meta": { - "get": { - "tags": [ - "Public · Atlas" - ], - "summary": "What atlas is loaded: facets, counts, when it was imported", - "description": "Drives the facet filter and the \"parsed from the shard\\'s own files on \" line. Reports the game world only — the ServUO path, the per-file hashes and any pending refresh are operator detail and live on the admin status route.", - "responses": { - "200": { - "description": "Atlas metadata", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AtlasMeta" - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "Not Found" - }, - "500": { - "description": "Internal Server Error" - }, - "503": { - "description": "Service Unavailable" - } - } - } - }, - "/api/v1/public/atlas/regions": { - "get": { - "tags": [ - "Public · Atlas" - ], - "summary": "Named regions and their rectangles", - "description": "Flattened out of the shard\\'s nested Regions.xml. `priority` and the rectangles are what placed each spawn point, kept so the placement can be re-derived rather than taken on trust.", - "parameters": [ - { - "name": "facet", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "Limit to one facet." - }, - { - "name": "q", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "Substring match on the region name." - } - ], - "responses": { - "200": { - "description": "Regions, by facet then name", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AtlasRegion" - } - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "Not Found" - }, - "500": { - "description": "Internal Server Error" - }, - "503": { - "description": "Service Unavailable" - } - } - } - }, "/api/v1/public/contact": { "post": { "tags": [ diff --git a/server/test/atlasController.test.js b/server/test/atlasController.test.js index 653dfbb..5aea48a 100644 --- a/server/test/atlasController.test.js +++ b/server/test/atlasController.test.js @@ -23,11 +23,21 @@ const assert = require('node:assert/strict') // never blocked by a bad tree), and the admin needs to be told what is wrong // with their path; // • a model failure degrades to a 500 rather than a thrown/uncaught error. -const pub = require('../src/router/v1/public/atlas.controller') +// The module's files read core through their `core` shim, which register() +// normally fills. Nothing registers modules in a unit test, so install the +// module's own fake ctx first — before any of its files are required, since the +// controller resolves its logger at require time. +require('../../modules/uo/server/test/_ctx').installFakeCtx() + +// SPIKE ARTIFACT (see admin/shardAtlas.controller.js): the public atlas +// controller and its model are module-uo's now. Phase 3 moves this test into the +// module alongside them; until the admin half moves too, one test file has to +// see both sides. +const pub = require('../../modules/uo/server/router/atlas.controller') const admin = require('../src/router/v1/admin/shardAtlas.controller') -const atlas = require('../src/model/shardAtlas/shardAtlas.model') +const atlas = require('../../modules/uo/server/model/shardAtlas/shardAtlas.model') const activity = require('../src/model/activity/activity.model') -const visibility = require('../src/utils/shardVisibility') +const visibility = require('../../modules/uo/server/utils/visibility') const db = require('../src/utils/db') after(() => db.close()) @@ -36,7 +46,7 @@ after(() => db.close()) // module-internal getConfig, which an exports-level stub would not intercept — it // would hit the closed DB port and cost a ~10s pool timeout per test before // falling back to these same defaults. -const visibilityModel = require('../src/model/shardVisibility/shardVisibility.model') +const visibilityModel = require('../../modules/uo/server/model/shardVisibility/shardVisibility.model') visibilityModel.listAll = async () => [] // no overrides ⇒ compiled defaults visibility.viewerLevel = async (req) => req?.viewerLevel || 'anonymous' diff --git a/server/test/moduleLoader.test.js b/server/test/moduleLoader.test.js new file mode 100644 index 0000000..ce16707 --- /dev/null +++ b/server/test/moduleLoader.test.js @@ -0,0 +1,256 @@ +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const fs = require('fs') +const os = require('os') +const path = require('path') + +const { test, beforeEach, after } = require('node:test') +const assert = require('node:assert/strict') + +const db = require('../src/utils/db') + +after(() => db.close()) + +// ── The loader's failure guarantees ──────────────────────────────────────── +// +// docs/website/MODULE_API.md §4.4 promises that a module which fails ANYWHERE in +// its lifecycle fails alone: the site comes up, other modules are unaffected, and +// the failure is recorded rather than thrown. That is the property most worth a +// test, because the failure paths are the ones nobody exercises by hand — every +// manual check runs the happy path. +// +// Each test builds a throwaway modules directory, points MODULES_DIR at it and +// re-requires the loader with a clean cache, so the scan is genuinely redone. + +let tmpRoot + +function freshLoader(dir) { + process.env.MODULES_DIR = dir + delete require.cache[require.resolve('../src/modules/loader')] + // eslint-disable-next-line global-require + return require('../src/modules/loader') +} + +function writeModule(id, { manifest = {}, server, schema } = {}) { + const dir = path.join(tmpRoot, id) + fs.mkdirSync(dir, { recursive: true }) + const full = { + id, + name: id, + version: '1.0.0', + coreApi: '^1.0.0', + ...(server === undefined ? {} : { server: 'index.js' }), + ...(schema === undefined ? {} : { schema: 'schema.sql', purge: 'purge.sql' }), + ...manifest, + } + fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify(full)) + if (server !== undefined) fs.writeFileSync(path.join(dir, 'index.js'), server) + if (schema !== undefined) { + fs.writeFileSync(path.join(dir, 'schema.sql'), schema) + fs.writeFileSync(path.join(dir, 'purge.sql'), '') + } + return dir +} + +const stateOf = (loader, id) => loader.list().find((m) => m.id === id) + +beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-modules-')) +}) + +test('a missing modules directory is the normal case, not an error', () => { + const loader = freshLoader(path.join(tmpRoot, 'does-not-exist')) + assert.deepEqual(loader.list(), []) +}) + +test('a module whose entry point throws does not stop the others loading', () => { + writeModule('aaa', { server: 'module.exports = () => {}' }) + writeModule('bbb', { server: 'throw new Error("boom")' }) + writeModule('ccc', { server: 'module.exports = () => {}' }) + const loader = freshLoader(tmpRoot) + + assert.equal(stateOf(loader, 'aaa').state, 'registered') + assert.equal(stateOf(loader, 'ccc').state, 'registered') + + const bad = stateOf(loader, 'bbb') + assert.equal(bad.state, 'startup_failed') + assert.match(bad.reason, /boom/) +}) + +test('a coreApi mismatch is refused before the module is required at all', () => { + // The entry point would throw if it ran; the version gate must run first. + writeModule('old', { + manifest: { coreApi: '^99.0.0' }, + server: 'throw new Error("should never be required")', + }) + const loader = freshLoader(tmpRoot) + const mod = stateOf(loader, 'old') + assert.equal(mod.state, 'startup_failed') + assert.match(mod.reason, /needs core API \^99\.0\.0/) +}) + +test('an unknown manifest key is rejected, not ignored', () => { + // A typo'd key must be loud: an operator who believes they configured + // something and silently did not is worse off than one who sees a failure. + writeModule('typo', { manifest: { mount: { public: ['/x'] } } }) + const loader = freshLoader(tmpRoot) + assert.match(stateOf(loader, 'typo').reason, /unknown key "mount"/) +}) + +test('a module id that does not match its directory is rejected', () => { + writeModule('onedir', { manifest: { id: 'another' } }) + const loader = freshLoader(tmpRoot) + // Recorded under the DIRECTORY name — the id it claimed is exactly what is + // not trusted here. + assert.match(stateOf(loader, 'onedir').reason, /does not match directory/) +}) + +test('two modules cannot claim the same prefix; the first one wins', () => { + writeModule('aaa', { + manifest: { mounts: { public: ['/thing'] } }, + server: "module.exports = (ctx, api) => api.registerRoutes({ public: { '/thing': ctx.express.Router() } })", + }) + writeModule('bbb', { + manifest: { mounts: { public: ['/thing'] } }, + server: "module.exports = (ctx, api) => api.registerRoutes({ public: { '/thing': ctx.express.Router() } })", + }) + const loader = freshLoader(tmpRoot) + + assert.equal(stateOf(loader, 'aaa').state, 'registered') + assert.match(stateOf(loader, 'bbb').reason, /already registered by module "aaa"/) +}) + +test('a module cannot take a prefix core owns', () => { + writeModule('greedy', { manifest: { mounts: { admin: ['/users'] } } }) + const loader = freshLoader(tmpRoot) + assert.match(stateOf(loader, 'greedy').reason, /owned by core/) +}) + +test('registering a prefix that was never declared is rejected', () => { + // module.json is what the admin panel, the collision check and the reviewer + // all read, so it has to be the truth rather than a hint. + writeModule('sneaky', { + manifest: { mounts: { public: ['/declared'] } }, + server: `module.exports = (ctx, api) => api.registerRoutes({ + public: { '/declared': ctx.express.Router(), '/undeclared': ctx.express.Router() }, + })`, + }) + const loader = freshLoader(tmpRoot) + assert.match(stateOf(loader, 'sneaky').reason, /registered public\/undeclared without declaring it/) +}) + +test('declaring a prefix and never registering it is rejected too', () => { + writeModule('forgetful', { + manifest: { mounts: { public: ['/a', '/b'] } }, + server: "module.exports = (ctx, api) => api.registerRoutes({ public: { '/a': ctx.express.Router() } })", + }) + const loader = freshLoader(tmpRoot) + assert.match(stateOf(loader, 'forgetful').reason, /declared public\/b but never registered it/) +}) + +test('a schema fragment declaring a core table is rejected', () => { + writeModule('thief', { schema: 'CREATE TABLE IF NOT EXISTS users (id INT);' }) + const loader = freshLoader(tmpRoot) + assert.match(stateOf(loader, 'thief').reason, /declares core table "users"/) +}) + +test('a schema fragment table must carry the module id as a prefix', () => { + writeModule('mine', { schema: 'CREATE TABLE IF NOT EXISTS widgets (id INT);' }) + const loader = freshLoader(tmpRoot) + assert.match(stateOf(loader, 'mine').reason, /not prefixed "mine_"/) + + const ok = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-modules-')) + tmpRoot = ok + writeModule('mine', { schema: 'CREATE TABLE IF NOT EXISTS mine_widgets (id INT);' }) + assert.equal(stateOf(freshLoader(ok), 'mine').state, 'registered') +}) + +test('declaring a schema without a purge is rejected', () => { + const dir = path.join(tmpRoot, 'noway') + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync( + path.join(dir, 'module.json'), + JSON.stringify({ id: 'noway', name: 'x', version: '1.0.0', coreApi: '^1.0.0', schema: 'schema.sql' }), + ) + const loader = freshLoader(tmpRoot) + assert.match(stateOf(loader, 'noway').reason, /declares schema but no purge/) +}) + +test('an onBoot that throws marks the module failed and never rejects', async () => { + writeModule('boomer', { server: 'module.exports = (ctx, api) => api.onBoot(async () => { throw new Error("late boom") })' }) + writeModule('fine', { server: 'module.exports = (ctx, api) => api.onBoot(async () => {})' }) + const loader = freshLoader(tmpRoot) + + await loader.boot() // must resolve, not reject + + assert.equal(stateOf(loader, 'fine').state, 'started') + const bad = stateOf(loader, 'boomer') + assert.equal(bad.state, 'startup_failed') + assert.match(bad.reason, /onBoot: late boom/) +}) + +test('onShutdown failures and hangs are absorbed', async () => { + writeModule('slow', { + server: 'module.exports = (ctx, api) => { api.onBoot(async () => {}); api.onShutdown(() => new Promise(() => {})) }', + }) + writeModule('angry', { + server: 'module.exports = (ctx, api) => { api.onBoot(async () => {}); api.onShutdown(async () => { throw new Error("nope") }) }', + }) + const loader = freshLoader(tmpRoot) + await loader.boot() + + // `slow` never settles its promise; the loader's own budget has to end it, and + // `angry` throwing must not stop the loop either. Neither may reject. + await loader.shutdown() +}) + +test('registering the same thing twice is an error, not a silent overwrite', () => { + writeModule('twice', { + manifest: { mounts: { public: ['/x'] } }, + server: `module.exports = (ctx, api) => { + api.registerRoutes({ public: { '/x': ctx.express.Router() } }) + api.registerRoutes({ public: { '/x': ctx.express.Router() } }) + }`, + }) + const loader = freshLoader(tmpRoot) + assert.match(stateOf(loader, 'twice').reason, /registerRoutes\(\) called twice/) +}) + +test('ctx exposes exactly the documented surface, and is frozen', () => { + const seen = path.join(tmpRoot, 'probe-out.json') + writeModule('probe', { + server: `const fs = require('fs') + module.exports = (ctx) => { + let mutable = true + try { ctx.db.query = null; mutable = ctx.db.query === null } catch { mutable = false } + fs.writeFileSync(${JSON.stringify(seen)}, JSON.stringify({ + keys: Object.keys(ctx).sort(), + middleware: Object.keys(ctx.middleware).sort(), + mutable, + })) + }`, + }) + // list() is what triggers the lazy scan — requiring the loader alone does not + // run it, deliberately, so app.js controls when modules are discovered. + assert.equal(stateOf(freshLoader(tmpRoot), 'probe').state, 'registered') + + const probe = JSON.parse(fs.readFileSync(seen, 'utf8')) + assert.deepEqual(probe.keys, [ + 'auth', 'db', 'express', 'log', 'middleware', 'moduleId', 'paths', + 'posts', 'push', 'secretBox', 'settings', 'uploads', 'validator', + ]) + assert.deepEqual(probe.middleware, ['noindex', 'requireAuth', 'requireRole', 'siteMode', 'validate']) + assert.equal(probe.mutable, false, 'ctx members must be frozen') +}) + +test('the client and server halves agree on MODULE_API_VERSION', () => { + const { MODULE_API_VERSION } = require('../src/modules/version') + const clientSrc = fs.readFileSync( + path.join(__dirname, '..', '..', 'client', 'src', 'modules', 'version.js'), + 'utf8', + ) + // They version ONE contract; a module checks whichever half it is talking to, + // so a drift between them is a module that passes one gate and fails the other. + assert.match(clientSrc, new RegExp(`'${MODULE_API_VERSION.replace(/\./g, '\\.')}'`)) +})