feat(modules): the filesystem module loader (phase 2, PR 2) #129

Merged
whitlocktech merged 1 commits from feat/modules-loader into edge 2026-08-10 21:31:26 +00:00
5 changed files with 1001 additions and 0 deletions

View File

@@ -10,6 +10,7 @@ require('dotenv').config()
const swaggerUi = require('swagger-ui-express')
const apiRouter = require('./router/api.router')
const modules = require('./modules/loader')
const wellKnown = require('./router/wellKnown.controller')
const cspReport = require('./router/cspReport.controller')
const brand = require('./config/brand')
@@ -154,6 +155,28 @@ app.get(
app.post(csp.REPORT_PATH, cspReportLimiter, ...cspReport.parsers, cspReport.receive)
app.use('/api', apiRouter)
// ── Installed modules ─────────────────────────────────────────────────
// Discover, validate and mount whatever is on the modules volume
// (docs/website/MODULE_API.md Part 4). One explicit call, here and nowhere else:
// the loader has no lazy self-scan, so there is exactly one place that decides
// when modules are discovered, and reading the module list before this line is
// an error rather than a silent empty answer (§7.6).
//
// Position is load-bearing, in both directions. It is AFTER `/api` is mounted,
// so every core prefix is already on the tier routers when the collision check
// asks them what core owns — and so first-match-wins means a module physically
// cannot shadow a core route. It is BEFORE the `/api` 404 below, so a module
// route reaches its handler instead of the catch-all.
//
// The three requires resolve from cache to the very routers v1.router.js
// mounted; this is a reference to them, not a second copy.
modules.load({
public: require('./router/v1/public'),
admin: require('./router/v1/admin'),
player: require('./router/v1/player'),
})
app.use('/api', (req, res) => res.status(404).json({ message: 'Not found' }))
// ── /.well-known ──────────────────────────────────────────────────────

View File

@@ -0,0 +1,473 @@
// ── The module loader ──────────────────────────────────────────────────────
//
// Phase 2, PR 2 of docs/website/MODULE_SYSTEM.md §2.7. The normative contract is
// docs/website/MODULE_API.md Part 4; where the two disagree, the contract wins.
//
// The one property this file exists to guarantee, and the reason it looks the
// way it does:
//
// **The filesystem is the mounting source of truth, and mounting is
// SYNCHRONOUS.** `scripts/routeManifest.js:38` and `swagger/swagger.js:29`
// both require app.js with the pool pointed at a dead port. A loader that
// awaited a database row before mounting would make every module route
// invisible to the frozen-URL-surface test (§1.12). So: readdirSync at require
// time, no database, no promises (§4.1).
//
// A module that fails ANYWHERE in this file fails alone. Nothing here may throw
// past its own try/catch — a bad module must cost the site its routes, never its
// boot (§4.4).
//
// What is deliberately NOT here yet, each landing with the PR that first calls
// it (§2.7): schema-fragment replay (PR 3), the three de-entanglement registries
// (PR 4), boot/shutdown hook dispatch and the `installed_modules` reconcile
// (PR 5), GET /api/v1/public/modules and the client chunk's static mount
// (PRs 6-7). Until PR 5 the state a module carries is in memory only.
const fs = require('fs')
const path = require('path')
const { MODULE_API_VERSION } = require('./version')
const semver = require('./semver')
const log = require('../utils/logger')('modules')
const REPO_ROOT = path.join(__dirname, '..', '..', '..')
const MODULES_DIR = process.env.MODULES_DIR || path.join(REPO_ROOT, 'modules')
// One segment, lowercase, no parameters. A module prefix that could contain a
// `/` or a `:` would let a module reach outside the slot it was given.
const ID = /^[a-z][a-z0-9-]{1,31}$/
const PREFIX = /^\/[a-z0-9][a-z0-9-]*$/
const TIERS = ['public', 'admin', 'player']
const MANIFEST_KEYS = new Set([
'id', 'name', 'version', 'coreApi', 'server', 'client',
'schema', 'purge', 'mounts', 'extensions', 'capabilities',
])
// Extension slots core declares (§2.4). Only core may declare one; a module may
// only fill one. Validation rejects a manifest naming a slot that does not
// exist — `registerExtension` itself arrives with PR 4.
const CORE_SLOTS = new Set(['admin.users.detail'])
// id → record. Populated by load(), read by list().
const modules = new Map()
let loaded = false
// ── ctx ────────────────────────────────────────────────────────────────────
// Everything a module may reach in core, and nothing else (§2.3). Required
// lazily inside the factory rather than at file scope: this file is required by
// app.js, and hoisting these to the top would make the DB pool, the settings
// model and the upload directory startup-time dependencies of the loader itself.
function buildCtx(id, moduleRoot) {
/* eslint-disable global-require */
// The shared SERVER dependencies — the exact counterpart of window.__rg's
// react/react-dom/react-router on the client, and load-bearing for the same
// two reasons (§7.2).
//
// 1. A module lives at <repo>/modules/<id>/, OUTSIDE server/, so Node's
// resolver walks up from there and never sees server/node_modules. A
// module that required 'express' itself would fail to load — which is
// exactly how this was discovered.
// 2. Even if it resolved, a second copy of express in the process is a
// second Router prototype and a second set of instanceof checks. One
// express, owned by core, is the same rule as one React.
//
// The consequence for a module author is the same on both sides: declare these
// external, never bundle them, take them from what core hands you.
const express = require('express')
const validator = require('express-validator')
const db = require('../utils/db')
const settings = require('../model/settings/settings.model')
const posts = require('../model/posts/posts.model')
const auth = require('../utils/auth')
const pushDispatch = require('../utils/pushDispatch')
const secretBox = require('../utils/secretBox')
const createLogger = require('../utils/logger')
const { requireAuth, requireRole } = require('../auth/session.middleware')
const siteMode = require('../middleware/siteMode')
const validate = require('../middleware/validate')
const noindex = require('../middleware/noindex')
const uploads = require('../router/v1/admin/imageUpload')
/* eslint-enable global-require */
// Narrowed on purpose (§2.3): utils/auth also re-exports signToken,
// setAuthCookie and the TOTP challenge primitives, and minting a session is
// core's job. A module that needs an identity needs to READ one.
const ctx = {
moduleId: id,
paths: { moduleRoot },
express,
validator,
db: { query: db.query, pool: db.pool },
log: (namespace) => createLogger(namespace ? `${id}:${namespace}` : id),
settings: {
get: settings.get,
set: settings.set,
getInstanceName: settings.getInstanceName,
},
auth: { getUserFromRequest: auth.getUserFromRequest },
push: { publish: pushDispatch.publish },
secretBox: { encrypt: secretBox.encrypt, decrypt: secretBox.decrypt },
middleware: { requireAuth, requireRole, siteMode, validate, noindex },
uploads,
posts: {
listAll: posts.listAll,
getById: posts.getById,
linkAnnounceJob: posts.linkAnnounceJob,
markAnnounced: posts.markAnnounced,
},
}
// A guard against accident, not against a hostile module — the boundary is
// organisational, not a security boundary (MODULE_SYSTEM.md §2.2).
for (const value of Object.values(ctx)) {
if (value && typeof value === 'object') Object.freeze(value)
}
return Object.freeze(ctx)
}
// ── The registration api ───────────────────────────────────────────────────
// Collects what the module registers so validation can compare it against what
// module.json DECLARED. Declaration is the contract; a module that registers a
// prefix it did not declare is rejected, because module.json is what the admin
// panel, the collision check and the reviewer all read.
function buildApi(record) {
const once = (name) => {
if (record.called.has(name)) throw new Error(`${name}() called twice`)
record.called.add(name)
}
// PR 4 brings the three de-entanglement registries and PR 5 the boot hooks.
// They throw rather than no-op: an accepting stub would let a module believe
// it had registered something and fail silently at the far end.
const notYet = (name, pr) => () => {
throw new Error(`${name}: not available until phase 2 PR ${pr}`)
}
return {
registerRoutes(mounts) {
once('registerRoutes')
if (!mounts || typeof mounts !== 'object') throw new Error('registerRoutes: expected an object')
for (const [tier, byPrefix] of Object.entries(mounts)) {
if (!TIERS.includes(tier)) throw new Error(`registerRoutes: unknown tier "${tier}"`)
for (const [prefix, router] of Object.entries(byPrefix)) {
if (!PREFIX.test(prefix)) throw new Error(`registerRoutes: bad prefix "${prefix}"`)
if (typeof router !== 'function') throw new Error(`registerRoutes: ${tier}${prefix} is not a router`)
record.routes[tier].set(prefix, router)
}
}
},
registerExtension: notYet('registerExtension', 4),
registerNotificationStreams: notYet('registerNotificationStreams', 4),
registerAnnounceLeg: notYet('registerAnnounceLeg', 4),
onBoot: notYet('onBoot', 5),
onShutdown: notYet('onShutdown', 5),
}
}
// ── Validation ─────────────────────────────────────────────────────────────
// Table names a module may create despite not carrying its own id as a prefix.
//
// module-uo's twenty-seven tables predate the module system by two years, and
// renaming live tables is a data migration this workstream deliberately does not
// do (MODULE_SYSTEM.md §1.6). Grandfathering them by an explicit, per-module
// allowlist keeps the prefix rule real for every module written after this one —
// the alternative, dropping the rule, would leave the first name collision to be
// discovered by a module silently adopting someone else's table.
const LEGACY_TABLE_PREFIXES = { uo: ['shard_', 'uo_link_'] }
const CREATE_TABLE = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"]?(\w+)[`"]?/gi
/** Table names core's own schema.sql declares — a module may not touch these. */
let coreTables = null
function coreTableNames() {
if (coreTables) return coreTables
coreTables = new Set()
try {
const sql = fs.readFileSync(path.join(__dirname, '..', '..', 'db', 'schema.sql'), 'utf8')
for (const m of sql.matchAll(CREATE_TABLE)) coreTables.add(m[1].toLowerCase())
} catch (err) {
log.warn('could not read core schema for the table-collision check', { message: err.message })
}
return coreTables
}
/** Every table name a schema fragment declares. Throws if the file is unreadable. */
function tablesOf(dir, manifest) {
if (!manifest.schema) return new Set()
const sql = fs.readFileSync(path.join(dir, manifest.schema), 'utf8')
return new Set([...sql.matchAll(CREATE_TABLE)].map((m) => m[1].toLowerCase()))
}
function checkTableNames(id, tables) {
const allowed = LEGACY_TABLE_PREFIXES[id] || []
const core = coreTableNames()
for (const table of tables) {
if (core.has(table)) throw new Error(`schema fragment declares core table "${table}"`)
for (const other of modules.values()) {
if (other.tables.has(table)) {
throw new Error(`schema fragment declares "${table}", already owned by module "${other.id}"`)
}
}
const prefixed = table.startsWith(`${id}_`) || allowed.some((p) => table.startsWith(p))
if (!prefixed) throw new Error(`schema fragment table "${table}" is not prefixed "${id}_"`)
}
}
/**
* Does core already own this prefix in this tier?
*
* Asked of the LIVE tier router rather than a hardcoded list, so the check
* cannot drift the first time core adds a capability router — the spike's
* hardcoded table was already one prefix stale when it was written. Modules are
* loaded after every core mount, so the stack is complete by the time this runs,
* and `layer.match` is express's own matcher rather than a second-guess at its
* regexp grammar.
*
* Root-mounted layers are skipped: `use(noindex, requireAuth)` and the two
* `use('/', singletonRouter)` mounts match every path, and counting them would
* report every prefix as taken.
*/
function ownedByCore(tierRouter, prefix) {
return (tierRouter.stack || []).some(
(layer) => layer.regexp && !layer.regexp.fast_slash && layer.match(prefix),
)
}
function readManifest(dir, id, tierRouters) {
const file = path.join(dir, 'module.json')
const manifest = JSON.parse(fs.readFileSync(file, 'utf8'))
for (const key of Object.keys(manifest)) {
// Rejected, not ignored: a typo'd key must be a loud failure rather than a
// silently inert setting the operator believes they configured.
if (!MANIFEST_KEYS.has(key)) throw new Error(`unknown key "${key}" in module.json`)
}
if (!ID.test(manifest.id || '')) throw new Error(`invalid id "${manifest.id}"`)
if (manifest.id !== id) throw new Error(`id "${manifest.id}" does not match directory "${id}"`)
if (!manifest.version) throw new Error('missing version')
if (!manifest.coreApi) throw new Error('missing coreApi')
if (!semver.satisfies(MODULE_API_VERSION, manifest.coreApi)) {
throw new Error(`needs core API ${manifest.coreApi}, this core is ${MODULE_API_VERSION}`)
}
for (const [tier, prefixes] of Object.entries(manifest.mounts || {})) {
if (!TIERS.includes(tier)) throw new Error(`unknown tier "${tier}" in mounts`)
for (const prefix of prefixes) {
if (!PREFIX.test(prefix)) throw new Error(`bad prefix "${prefix}" in mounts.${tier}`)
if (ownedByCore(tierRouters[tier], prefix)) {
throw new Error(`prefix ${tier}${prefix} is owned by core`)
}
for (const other of modules.values()) {
if ((other.manifest.mounts?.[tier] || []).includes(prefix)) {
throw new Error(`prefix ${tier}${prefix} already registered by module "${other.id}"`)
}
}
}
}
for (const slot of manifest.extensions || []) {
if (!CORE_SLOTS.has(slot)) throw new Error(`unknown extension slot "${slot}"`)
}
if (manifest.schema && !manifest.purge) {
// A module that can create tables and cannot drop them leaves an operator
// with orphaned data and no supported way to remove it.
throw new Error('declares schema but no purge')
}
if (manifest.purge && !fs.existsSync(path.join(dir, manifest.purge))) {
throw new Error(`purge file "${manifest.purge}" is missing`)
}
return manifest
}
// What the module registered must equal what it declared — in both directions.
function checkDeclared(record) {
const declared = record.manifest.mounts || {}
for (const tier of TIERS) {
const want = new Set(declared[tier] || [])
const got = new Set(record.routes[tier].keys())
for (const p of got) if (!want.has(p)) throw new Error(`registered ${tier}${p} without declaring it`)
for (const p of want) if (!got.has(p)) throw new Error(`declared ${tier}${p} but never registered it`)
}
}
// ── Load ───────────────────────────────────────────────────────────────────
/**
* Discover, validate, register and mount every module under MODULES_DIR.
*
* **Called exactly once, explicitly, from app.js**, after the three tier routers
* are required and before the app is exported. There is no lazy self-scan: the
* spike's was lazy and silent, so requiring the loader and reading the module
* list gave an empty array and no error (MODULE_API.md §7.6). Everything that
* reads the module list now throws until this has run.
*
* The ordering is not incidental. Core's mounts must already be on the tier
* routers, because that is what the prefix-collision check is asked about; and
* modules mount after them, so first-match-wins means a module could not shadow
* a core prefix even if the check were bypassed.
*
* Safe to call when the modules directory does not exist — that is the normal
* case for a bare core, and it is the state this PR ships in.
*
* @param {{public: Router, admin: Router, player: Router}} tierRouters
*/
function load(tierRouters) {
if (loaded) return
for (const tier of TIERS) {
if (!tierRouters || typeof tierRouters[tier] !== 'function') {
throw new Error(`modules.load: missing the "${tier}" tier router`)
}
}
loaded = true
let entries = []
try {
entries = fs.readdirSync(MODULES_DIR, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name)
.sort() // alphabetical: there is no dependency resolution, and any other
// order would imply a precedence nothing computes (§4.2)
} catch {
return // no modules directory is the normal case for a bare core
}
for (const id of entries) {
const dir = path.join(MODULES_DIR, id)
if (!fs.existsSync(path.join(dir, 'module.json'))) continue
const record = {
id,
dir,
manifest: null,
routes: { public: new Map(), admin: new Map(), player: new Map() },
tables: new Set(),
called: new Set(),
state: 'installed',
reason: null,
}
try {
record.manifest = readManifest(dir, id, tierRouters)
record.tables = tablesOf(dir, record.manifest)
checkTableNames(id, record.tables)
if (record.manifest.server) {
const entry = path.join(dir, record.manifest.server)
// eslint-disable-next-line global-require, import/no-dynamic-require
const register = require(entry)
if (typeof register !== 'function') throw new Error(`${record.manifest.server} does not export a function`)
register(buildCtx(id, dir), buildApi(record))
checkDeclared(record)
}
record.state = 'registered'
modules.set(id, record)
log.info(`registered module "${id}" v${record.manifest.version}`, {
mounts: record.manifest.mounts,
})
} catch (err) {
// A failure here is BEFORE any route was mounted, so this module's routes
// and nav are simply absent and the site comes up without it (§4.4).
record.state = 'startup_failed'
record.reason = err.message
record.manifest = record.manifest || { id, version: 'unknown' }
modules.set(id, record)
log.error(`module "${id}" failed to load — continuing without it`, { reason: err.message })
}
}
// Mounting is a SECOND pass, after every module has been validated, and not
// because it reads better. `ownedByCore` asks the live tier router what is
// already on it, so mounting inside the loop would make the first module's
// layers indistinguishable from core's — the second module claiming a taken
// prefix would be told it collided with core, naming the wrong culprit, and
// the module-versus-module check below it could never be reached.
for (const record of modules.values()) {
if (record.state === 'registered') mount(record, tierRouters)
}
}
/**
* Mount one module's routers onto the tier routers, behind the dispatch guard.
*
* The guard is the other half of §4.4. A module that fails BEFORE this point has
* no routes at all; one that fails after — schema replay (PR 3), `onBoot`
* (PR 5) — keeps its URLs and answers 503, so `routes.manifest.json` never
* depends on whether a boot hook happened to succeed on the machine that
* generated it. `disabled` is 404 and unreachable until PR 5 wires
* `installed_modules` in; it is written here because the guard is the contract's
* §4.5, not a later addition.
*/
function mount(record, tierRouters) {
for (const tier of TIERS) {
for (const [prefix, router] of record.routes[tier]) {
tierRouters[tier].use(prefix, (req, res, next) => {
if (record.state === 'startup_failed') {
return res.status(503).json({ message: 'Module unavailable' })
}
if (record.state === 'disabled') return res.status(404).json({ message: 'Not found' })
return next()
}, router)
}
}
}
// ── State ──────────────────────────────────────────────────────────────────
// The states a loaded record may hold, deliberately a hardcoded subset rather
// than an import of model/modules/modules.model.js's STATES: that model reaches
// the database, and this file must stay require-able against a dead one.
// `installed` is not here because a record leaves load() resolved either way.
const RECORD_STATES = new Set(['registered', 'started', 'disabled', 'startup_failed'])
/**
* Move a loaded module to a new state — the POST-mount transitions.
*
* Called by whoever ran the step that failed or the step that succeeded, because
* only they can know: PR 3's `ensureSchema()` replays the fragments, PR 5's boot
* dispatch runs `onBoot` and reconciles `installed_modules` (whose `disabled`
* rows are what first make the guard's 404 leg reachable).
*
* Unknown ids are ignored rather than thrown on: a module can be absent from the
* volume and still have a row, and a caller on the boot path must not turn that
* into everyone's failure.
*/
function setState(id, state, reason = null) {
if (!RECORD_STATES.has(state)) throw new Error(`unknown module state "${state}"`)
const record = modules.get(id)
if (!record) return
record.state = state
record.reason = reason
}
// ── Introspection ──────────────────────────────────────────────────────────
function assertLoaded(caller) {
if (!loaded) throw new Error(`modules.${caller}() before modules.load()`)
}
/**
* Every module found on the volume, loaded or failed, in scan order.
*
* Throws rather than returning `[]` when load() has not run — the empty list is
* a real answer for a core with no modules installed, and a caller cannot tell
* the two apart (§7.6).
*/
function list() {
assertLoaded('list')
return [...modules.values()].map((r) => ({
id: r.id,
name: r.manifest.name || r.id,
version: r.manifest.version,
state: r.state,
reason: r.reason,
capabilities: r.manifest.capabilities || [],
}))
}
/** Absolute path of the modules directory. */
const dir = () => MODULES_DIR
module.exports = { load, list, setState, dir }

View File

@@ -0,0 +1,47 @@
// A deliberately tiny semver range check — enough for `coreApi` and no more.
//
// Supports `*`, an exact `x.y.z`, `^x.y.z` and `~x.y.z`. That is the whole
// grammar a module manifest is allowed to use (MODULE_API.md §1.1), so pulling
// in the `semver` package for it would add a dependency to the server for a
// twenty-line job. A range this parser does not understand is REJECTED rather
// than assumed to match — an unparseable range must not silently load a module
// against an API it was never tested on.
const PARTS = /^(\d+)\.(\d+)\.(\d+)$/
function parse(version) {
const m = PARTS.exec(String(version).trim())
if (!m) return null
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) }
}
const gte = (a, b) => {
if (a.major !== b.major) return a.major > b.major
if (a.minor !== b.minor) return a.minor > b.minor
return a.patch >= b.patch
}
/**
* Does `version` satisfy `range`?
* @param {string} version an exact x.y.z
* @param {string} range `*` | `x.y.z` | `^x.y.z` | `~x.y.z`
* @returns {boolean} false for anything unparseable, on either side
*/
function satisfies(version, range) {
const v = parse(version)
if (!v) return false
const raw = String(range).trim()
if (raw === '*') return true
const op = raw[0] === '^' || raw[0] === '~' ? raw[0] : ''
const b = parse(op ? raw.slice(1) : raw)
if (!b) return false
if (op === '') return v.major === b.major && v.minor === b.minor && v.patch === b.patch
if (!gte(v, b)) return false
// ^ allows minor+patch within the same major; ~ allows patch within the same minor.
if (op === '^') return v.major === b.major
return v.major === b.major && v.minor === b.minor
}
module.exports = { satisfies, parse }

View File

@@ -0,0 +1,14 @@
// The module API version — the single number a module's `coreApi` range is
// checked against (docs/website/MODULE_API.md §1.1).
//
// Bump minor when a member is ADDED to ctx or a new register* call appears;
// major when one is removed, its signature changes, or its behaviour changes
// without a signature change. A core-internal refactor behind an unchanged
// member is not a bump.
//
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
// has nothing to say about a website module) and from any module's own version.
const MODULE_API_VERSION = '1.0.0'
module.exports = { MODULE_API_VERSION }

View File

@@ -0,0 +1,444 @@
// ── 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.
// MODULES_DIR is read into a const at require time (it has to be: the scan is
// synchronous and happens during app.js's require), so busting the cache is the
// only honest way to point the loader somewhere else.
//
// Point the pool at a closed port BEFORE requiring anything: buildCtx pulls in
// the models, which build a mariadb pool at require time. No query is ever run.
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 { startApp } = require('./_helper')
after(() => db.close())
let tmpRoot
/** Three empty routers standing in for core's tiers — nothing owned, nothing gated. */
const emptyTiers = () => ({
public: express.Router(),
admin: express.Router(),
player: express.Router(),
})
function freshLoader(dir, tiers = emptyTiers()) {
process.env.MODULES_DIR = dir
delete require.cache[require.resolve('../src/modules/loader')]
// eslint-disable-next-line global-require
const loader = require('../src/modules/loader')
loader.load(tiers)
return 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
}
/** A module that registers one router answering 200 at its prefix root. */
const oneRoute = (prefix, tier = 'public') => ({
manifest: { mounts: { [tier]: [prefix] } },
server: `module.exports = (ctx, api) => {
const r = ctx.express.Router()
r.get('/', (req, res) => res.json({ ok: true }))
api.registerRoutes({ ${tier}: { '${prefix}': r } })
}`,
})
const stateOf = (loader, id) => loader.list().find((m) => m.id === id)
beforeEach(() => {
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-modules-'))
})
// ── Discovery and the explicit trigger ─────────────────────────────────────
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('reading the module list before load() throws instead of answering []', () => {
// §7.6. The spike's scan was lazy and silent, so a caller that required the
// loader and read nothing got an empty list — indistinguishable from a core
// with no modules installed. It cost one confusing failure; it now costs an
// error naming the missing call.
process.env.MODULES_DIR = tmpRoot
delete require.cache[require.resolve('../src/modules/loader')]
// eslint-disable-next-line global-require
const loader = require('../src/modules/loader')
assert.throws(() => loader.list(), /modules\.list\(\) before modules\.load\(\)/)
})
test('load() refuses to run without all three tier routers', () => {
writeModule('aaa', { server: 'module.exports = () => {}' })
process.env.MODULES_DIR = tmpRoot
delete require.cache[require.resolve('../src/modules/loader')]
// eslint-disable-next-line global-require
const loader = require('../src/modules/loader')
// Not a module's failure — a wiring mistake in core, and the one thing in this
// file that is allowed to throw past the caller.
assert.throws(() => loader.load({ public: express.Router() }), /missing the "admin" tier router/)
})
test('load() is once-only, so a second call cannot double-mount', () => {
writeModule('aaa', oneRoute('/thing'))
const tiers = emptyTiers()
const loader = freshLoader(tmpRoot, tiers)
const before = tiers.public.stack.length
loader.load(tiers)
assert.equal(tiers.public.stack.length, before)
assert.equal(loader.list().length, 1)
})
test('modules load in alphabetical order, since nothing computes a precedence', () => {
for (const id of ['ccc', 'aaa', 'bbb']) writeModule(id, { server: 'module.exports = () => {}' })
const loader = freshLoader(tmpRoot)
assert.deepEqual(loader.list().map((m) => m.id), ['aaa', 'bbb', 'ccc'])
})
// ── The failing module fails alone ─────────────────────────────────────────
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('an entry point that exports something other than a function is rejected', () => {
writeModule('notfn', { server: 'module.exports = { register: () => {} }' })
const loader = freshLoader(tmpRoot)
assert.match(stateOf(loader, 'notfn').reason, /does not export a function/)
})
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('an unknown extension slot is rejected; only core may declare a slot', () => {
writeModule('presumptuous', { manifest: { extensions: ['admin.users.detail', 'admin.invented'] } })
const loader = freshLoader(tmpRoot)
assert.match(stateOf(loader, 'presumptuous').reason, /unknown extension slot "admin\.invented"/)
})
// ── Prefix ownership ───────────────────────────────────────────────────────
test('two modules cannot claim the same prefix; the first one wins', () => {
writeModule('aaa', oneRoute('/thing'))
writeModule('bbb', oneRoute('/thing'))
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, asked of the live tier routers', () => {
// Deliberately the REAL routers rather than a hardcoded prefix list. The spike
// hardcoded core's ~24 prefixes and they were already stale; asking express
// itself is what stops the check drifting the next time core adds a capability
// router.
/* eslint-disable global-require */
const real = {
public: require('../src/router/v1/public'),
admin: require('../src/router/v1/admin'),
player: require('../src/router/v1/player'),
}
/* eslint-enable global-require */
writeModule('greedy', { manifest: { mounts: { admin: ['/users'] } } })
writeModule('alsogreedy', { manifest: { mounts: { public: ['/wiki'] } } })
// Free in every tier core actually mounts, so it must be allowed through.
writeModule('polite', oneRoute('/widgets', 'player'))
const loader = freshLoader(tmpRoot, real)
assert.match(stateOf(loader, 'greedy').reason, /owned by core/)
assert.match(stateOf(loader, 'alsogreedy').reason, /owned by core/)
assert.equal(stateOf(loader, 'polite').state, 'registered')
})
test('a root-mounted core layer does not make every prefix look taken', () => {
// public/index.js ends with `use('/', siteRouter)` and admin with the dashboard
// router; both match every path. Counting them would report every prefix as
// owned and no module could ever mount.
const tiers = emptyTiers()
tiers.public.use('/posts', express.Router())
tiers.public.use('/', express.Router())
writeModule('fine', oneRoute('/widgets'))
writeModule('taken', oneRoute('/posts'))
const loader = freshLoader(tmpRoot, tiers)
assert.equal(stateOf(loader, 'fine').state, 'registered')
assert.match(stateOf(loader, 'taken').reason, /owned by core/)
})
test('a prefix with a slash or a parameter in it is rejected', () => {
// A prefix that could contain either would let a module reach outside the slot
// it was given.
writeModule('nested', { manifest: { mounts: { public: ['/a/b'] } } })
writeModule('parameterised', { manifest: { mounts: { public: ['/:id'] } } })
const loader = freshLoader(tmpRoot)
assert.match(stateOf(loader, 'nested').reason, /bad prefix "\/a\/b"/)
assert.match(stateOf(loader, 'parameterised').reason, /bad prefix "\/:id"/)
})
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('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/)
})
// ── Schema fragment validation (the replay itself is PR 3) ─────────────────
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);' })
assert.match(stateOf(freshLoader(tmpRoot), 'mine').reason, /not prefixed "mine_"/)
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-modules-'))
writeModule('mine', { schema: 'CREATE TABLE IF NOT EXISTS mine_widgets (id INT);' })
assert.equal(stateOf(freshLoader(tmpRoot), 'mine').state, 'registered')
})
test('two modules cannot own the same table either', () => {
writeModule('aaa', { schema: 'CREATE TABLE IF NOT EXISTS aaa_shared (id INT);' })
writeModule('bbb', {
manifest: { id: 'bbb' },
schema: 'CREATE TABLE IF NOT EXISTS aaa_shared (id INT);',
})
const loader = freshLoader(tmpRoot)
assert.equal(stateOf(loader, 'aaa').state, 'registered')
assert.match(stateOf(loader, 'bbb').reason, /already owned by module "aaa"/)
})
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)
// A module that can create tables and cannot drop them leaves an operator with
// orphaned data and no supported way to remove it.
assert.match(stateOf(loader, 'noway').reason, /declares schema but no purge/)
})
test('a declared purge file that is not there is rejected', () => {
writeModule('gone', { schema: 'CREATE TABLE IF NOT EXISTS gone_x (id INT);' })
fs.unlinkSync(path.join(tmpRoot, 'gone', 'purge.sql'))
const loader = freshLoader(tmpRoot)
assert.match(stateOf(loader, 'gone').reason, /purge file "purge\.sql" is missing/)
})
// ── Mounting and the dispatch guard ────────────────────────────────────────
test('a registered module answers on its prefix; a failed one is simply absent', async () => {
writeModule('good', oneRoute('/widgets'))
writeModule('bad', { ...oneRoute('/broken'), server: 'throw new Error("boom")' })
const tiers = emptyTiers()
freshLoader(tmpRoot, tiers)
const app = await startApp((a) => a.use('/public', tiers.public))
try {
assert.equal((await fetch(`${app.url}/public/widgets`)).status, 200)
// Failed BEFORE mounting, so its routes are not absent-with-a-503 — they do
// not exist at all (§4.4, left-hand column).
assert.equal((await fetch(`${app.url}/public/broken`)).status, 404)
} finally {
await app.close()
}
})
test('a module that fails AFTER mounting keeps its URLs and answers 503', async () => {
// The right-hand column of §4.4, and the reason routes.manifest.json can be
// generated off a dead database: the URL surface must not depend on whether a
// boot step succeeded on the generating machine. PR 3 (schema replay) and PR 5
// (onBoot) are the two things that will trip this in real life; here the state
// is moved by hand, because the loader is the thing under test.
writeModule('later', oneRoute('/widgets'))
const tiers = emptyTiers()
const loader = freshLoader(tmpRoot, tiers)
const app = await startApp((a) => a.use('/public', tiers.public))
try {
assert.equal((await fetch(`${app.url}/public/widgets`)).status, 200)
assert.equal(stateOf(loader, 'later').state, 'registered')
loader.setState('later', 'startup_failed', 'schema fragment blew up')
assert.equal((await fetch(`${app.url}/public/widgets`)).status, 503)
assert.equal(stateOf(loader, 'later').reason, 'schema fragment blew up')
// The 404 leg becomes reachable for real in PR 5, when the boot reconcile
// reads a `disabled` row out of installed_modules. A disabled module is
// mounted and guarded, never unmounted (§4.5) — same reason as the 503.
loader.setState('later', 'disabled')
assert.equal((await fetch(`${app.url}/public/widgets`)).status, 404)
loader.setState('later', 'started')
assert.equal((await fetch(`${app.url}/public/widgets`)).status, 200)
} finally {
await app.close()
}
})
test('setState refuses a state that is not one, and shrugs at an unknown id', () => {
writeModule('here', oneRoute('/widgets'))
const loader = freshLoader(tmpRoot)
assert.throws(() => loader.setState('here', 'enabled'), /unknown module state "enabled"/)
// An id with no record is not the boot path's problem to escalate.
assert.doesNotThrow(() => loader.setState('never-installed', 'started'))
})
// ── ctx ────────────────────────────────────────────────────────────────────
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(),
moduleId: ctx.moduleId,
mutable,
}))
}`,
})
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.moduleId, 'probe')
assert.equal(probe.mutable, false, 'ctx members must be frozen')
})
test('the register calls PR 4 and PR 5 own throw rather than silently accepting', () => {
// An accepting no-op would let a module believe it had registered a
// notification stream or a boot hook and fail silently at the far end.
for (const [call, pr] of [
['registerExtension', 4],
['registerNotificationStreams', 4],
['registerAnnounceLeg', 4],
['onBoot', 5],
['onShutdown', 5],
]) {
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-modules-'))
writeModule('early', { server: `module.exports = (ctx, api) => api.${call}(() => {})` })
assert.match(
stateOf(freshLoader(tmpRoot), 'early').reason,
new RegExp(`${call}: not available until phase 2 PR ${pr}`),
)
}
})