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, '\\.')}'`))
+})