feat(modules): publish the installed-module list at /api/v1/public/modules
All checks were successful
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / server-tests (pull_request) Successful in 1m33s
PR Checks / bot-install (pull_request) Successful in 8m45s

Phase 2, PR 6 of docs/website/MODULE_SYSTEM.md 2.7 — the first module-system
URL a client can see. The SPA and the Android app feature-detect against the
capabilities a module declares; the shape is settled in MODULE_API.md 2.9.

Four decisions, and what is absent from the payload is most of the design:

* started modules only. A module that is disabled or failed to load is
  ABSENT, exactly as 4.4 already leaves its routes and its nav absent, so a
  client renders a site without that capability rather than advertising one
  that 503s.
* no state, failure_stage or failure_reason. Where a module broke belongs to
  the admin Modules screen, and the reason is an exception string from inside
  core — not anonymous-visitor business.
* no client chunk URL. htmlShell injects a script tag per started module
  (3.1.3), so the browser is handed the tag rather than a URL to fetch. This
  endpoint feature-detects; it does not load. MODULE_SYSTEM 2.6 step 4 is
  amended to match (API 6.7).
* no siteMode gate and no database — the same class as /public/status and
  /public/version, so a client can still feature-detect during maintenance.

It is a capability router of its own rather than a fifth singleton in
site.router.js, and that is load-bearing: the loader's prefix-collision probe
reads the live tier stack and skips root-mounted layers, because a use('/', ...)
matches every path. A route inside the root-mounted site router would be
invisible to it — mounting use('/modules', ...) is what makes "no module may
claim /modules" a rule the loader enforces.

910 tests pass (+9, every one on the boundary — what must NOT appear).
routes.manifest.json gains exactly the one route and routes.guards.json records
it with an empty gates list, which is itself the assertion that it is ungated.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-10 22:03:22 -05:00
parent 85f563fc16
commit 291c30f6ff
8 changed files with 483 additions and 0 deletions

View File

@@ -0,0 +1,202 @@
// ── GET /api/v1/public/modules ─────────────────────────────────────────────
//
// Phase 2, PR 6. The endpoint is small; what is worth testing is the boundary it
// draws. `installed_modules` records five states and the loader records a
// failure stage and reason beside them, and exactly none of that may reach an
// anonymous caller — the public answer is "what is serving", and a module that
// is not serving is ABSENT (MODULE_API.md §4.4, MODULE_SYSTEM.md §2.4).
//
// A leak here would not fail anything else: the routes still 503, the nav is
// still absent, the site still boots. It would just quietly publish that a
// module broke and how far it got. So the tests below assert the negative — no
// `state`, no `stage`, no `reason`, no extra key — as well as the positive.
//
// Like the other module tests, each case builds a throwaway modules directory,
// points MODULES_DIR at it and re-requires the loader AND the controller with a
// clean cache: the controller holds the loader in a file-scope const, so a stale
// cache would leave it reading the previous test's module list.
//
// Point the pool at a closed port before requiring anything — buildCtx pulls in
// the models, which build a mariadb pool at require time. That no test here has
// to stub a query is itself the point: this endpoint never touches a database.
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 express = require('express')
const db = require('../src/utils/db')
const registries = require('../src/modules/registries')
const { startApp } = require('./_helper')
after(() => db.close())
let tmpRoot
const emptyTiers = () => ({
public: express.Router(),
admin: express.Router(),
player: express.Router(),
})
/**
* Load a throwaway modules directory and hand back the loader plus a live app
* serving the real router at the real path.
*/
function freshApp(dir) {
process.env.MODULES_DIR = dir
registries._reset()
delete require.cache[require.resolve('../src/modules/loader')]
delete require.cache[require.resolve('../src/router/v1/public/modules.controller')]
delete require.cache[require.resolve('../src/router/v1/public/modules.router')]
/* eslint-disable global-require */
const loader = require('../src/modules/loader')
loader.load(emptyTiers())
const modulesRouter = require('../src/router/v1/public/modules.router')
/* eslint-enable global-require */
return { loader, mount: (app) => app.use('/api/v1/public/modules', modulesRouter) }
}
function writeModule(id, manifest = {}) {
const dir = path.join(tmpRoot, id)
fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify({
id, name: id, version: '1.0.0', coreApi: '^1.0.0', ...manifest,
}))
return dir
}
async function get(mount) {
const app = await startApp(mount)
try {
const res = await fetch(`${app.url}/api/v1/public/modules`)
return { status: res.status, body: await res.json() }
} finally {
await app.close()
}
}
beforeEach(() => {
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-pubmod-'))
})
// ── The answer ─────────────────────────────────────────────────────────────
test('a core with no modules answers an empty list, not a 404', async () => {
// The shipping state of Phase 2: the endpoint exists and is truthful before
// any module does. A client that has to distinguish "no modules" from "old
// backend" would otherwise have to read a status code to find out.
const { mount } = freshApp(path.join(tmpRoot, 'does-not-exist'))
const res = await get(mount)
assert.equal(res.status, 200)
assert.deepEqual(res.body, { modules: [] })
})
test('a started module is published with exactly id, name, version and capabilities', async () => {
writeModule('uo', { name: 'Ultima Online', version: '2.1.0', capabilities: ['shard', 'atlas'] })
const { loader, mount } = freshApp(tmpRoot)
loader.setState('uo', 'started')
const res = await get(mount)
assert.deepEqual(res.body, {
modules: [{ id: 'uo', name: 'Ultima Online', version: '2.1.0', capabilities: ['shard', 'atlas'] }],
})
// Asserted by key set as well as by value: a field added to loader.list()
// later must not reach the public surface just because it was added.
assert.deepEqual(Object.keys(res.body.modules[0]).sort(), ['capabilities', 'id', 'name', 'version'])
})
test('a module that declares no capabilities publishes an empty array, never undefined', async () => {
// `capabilities` is optional in module.json (§2.1). A client iterating the
// array must not have to null-check it.
writeModule('bare')
const { loader, mount } = freshApp(tmpRoot)
loader.setState('bare', 'started')
const res = await get(mount)
assert.deepEqual(res.body.modules[0].capabilities, [])
})
test('modules are published in scan order', async () => {
for (const id of ['zeta', 'alpha', 'mid']) writeModule(id)
const { loader, mount } = freshApp(tmpRoot)
for (const id of ['zeta', 'alpha', 'mid']) loader.setState(id, 'started')
const res = await get(mount)
// Alphabetical, because that is the loader's scan order and there is no
// dependency resolution — any other order would imply a precedence nothing
// computes (§4.2).
assert.deepEqual(res.body.modules.map((m) => m.id), ['alpha', 'mid', 'zeta'])
})
// ── The boundary ───────────────────────────────────────────────────────────
test('a registered-but-not-yet-started module is absent', async () => {
// The state between a clean load and onBoot. It is not serving yet, so it is
// not published — the endpoint answers what IS serving, not what will be.
writeModule('uo')
const { mount } = freshApp(tmpRoot)
const res = await get(mount)
assert.deepEqual(res.body.modules, [])
})
test('a disabled module is absent — the operator switched it off', async () => {
writeModule('uo')
const { loader, mount } = freshApp(tmpRoot)
loader.setState('uo', 'disabled')
const res = await get(mount)
assert.deepEqual(res.body.modules, [])
})
test('a failed module is absent, and its stage and reason never leave the server', async () => {
// The leak this whole file exists to prevent. `startup_failed` carries the
// step that broke and the error message; both belong to the admin Modules
// screen and neither is anonymous-visitor business.
writeModule('uo', { capabilities: ['shard'] })
const { loader, mount } = freshApp(tmpRoot)
loader.setState('uo', 'startup_failed', { stage: 'require', reason: 'Cannot find module ./nope' })
const res = await get(mount)
assert.deepEqual(res.body.modules, [])
const raw = JSON.stringify(res.body)
for (const leak of ['require', 'nope', 'startup_failed', 'stage', 'reason']) {
assert.ok(!raw.includes(leak), `published "${leak}"`)
}
})
test('one failed module does not hide the ones that started', async () => {
writeModule('broken')
writeModule('working', { capabilities: ['shard'] })
const { loader, mount } = freshApp(tmpRoot)
loader.setState('broken', 'startup_failed', { stage: 'schema', reason: 'boom' })
loader.setState('working', 'started')
const res = await get(mount)
assert.deepEqual(res.body.modules.map((m) => m.id), ['working'])
})
// ── Failure ────────────────────────────────────────────────────────────────
test('the module list read before load() is a 500, not a lie', async () => {
// §7.6: `{ modules: [] }` is a true answer for a core with no modules and a
// caller cannot tell it from a mis-ordered boot. So the guard's throw becomes
// a 500 rather than an empty list — the one case where an error is the honest
// answer.
process.env.MODULES_DIR = tmpRoot
registries._reset()
delete require.cache[require.resolve('../src/modules/loader')]
delete require.cache[require.resolve('../src/router/v1/public/modules.controller')]
delete require.cache[require.resolve('../src/router/v1/public/modules.router')]
// eslint-disable-next-line global-require
const modulesRouter = require('../src/router/v1/public/modules.router')
const res = await get((app) => app.use('/api/v1/public/modules', modulesRouter))
assert.equal(res.status, 500)
})