Files
website/server/test/moduleLoader.test.js
wtclaude 41a1e84b51
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 36s
PR Checks / server-tests (pull_request) Successful in 6m11s
PR Checks / client-build (pull_request) Successful in 49s
feat(safety): report, block and hide, MODULE_API 1.21.0
USER_SAFETY.md step 2 (core), with S11-S14 decided while building it.

- Reports: POST /player/reports takes any target type, core's forum
  types or a module's registered ones. One resolver
  (model/safety/targets.js) answers "what is this item, for this
  reader" and 404s an item the reader cannot see. Every report stores
  content_reports.snapshot; the queue shows a module item from it.
- Hide for everyone: POST /admin/moderation/reports/:id/hide hides the
  item through its own moderation (forum hide as staff, or the module's
  hide) and actions the report with actioned_with = 'hide'.
- Blocks (user_blocks) name an item, never a person, and land on the
  author's account first (S12); an unlinked game identity is blocked as
  itself and becomes an account block once linked (S14). Staff cannot
  be blocked; a block on someone later promoted stops applying (S8).
- Hide for me (user_hidden_items), /auth/me/blocks and /auth/me/hidden.
- Forum reads filter blocked authors and hidden items on the server and
  carry canBlock per thread and post.
- MODULE_API 1.21.0: api.registerReportTargets,
  api.registerGameIdentities, ctx.safety.forViewer and
  ctx.safety.staffAmong, so a module filters its own per-viewer feeds on
  the server (S11) and marks lines canBlock (S13).
- Web: Hide for me / Block author in the forum menus, a Blocked section
  on both account pages, Hide for everyone in the Reports queue.
- Tests, OpenAPI, route/guard/engagement manifests regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-30 05:41:00 -05:00

833 lines
38 KiB
JavaScript

// ── 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 registries = require('../src/modules/registries')
const { startApp } = require('./_helper')
// Requiring the real admin router declares the `admin.users.detail` extension
// slot exactly the way production does (users.router.js, at require time). Doing
// it here rather than calling declareSlot by hand matters: one test below builds
// the real tier routers, and a hand-declared slot would collide with that
// require's own declaration.
require('../src/router/v1/admin')
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
// The registries are process-global (there is one core), so hand the process
// back between tests. Without this a module's staged registrations from a
// previous test would still be committed, and every collision assertion below
// would be asserting against the wrong history.
registries._reset()
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)
/**
* One module in a modules directory of its own, loaded. A site runs one module
* (W19), so a test that checks several cases loads each case by itself rather
* than side by side, where every one after the first would be refused.
*/
function alone(id, spec, tiers) {
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-modules-'))
writeModule(id, spec)
return freshLoader(tmpRoot, tiers)
}
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)
})
// ── One module per site (W19) ──────────────────────────────────────────────
test('the first module by name holds the site; every other is refused unrequired', () => {
// Install refuses a second module, so this is the hand-copied case: Module-uo
// and Module-Rust both fill `admin.users.detail`, and the second used to fail
// with a slot collision that named neither the rule nor the fix.
writeModule('ccc', { server: 'throw new Error("ccc should never be required")' })
writeModule('aaa', { server: 'module.exports = () => {}' })
writeModule('bbb', { manifest: { version: '2.1.0' }, server: 'throw new Error("bbb should never be required")' })
const loader = freshLoader(tmpRoot)
assert.deepEqual(loader.list().map((m) => m.id), ['aaa', 'bbb', 'ccc'])
assert.equal(stateOf(loader, 'aaa').state, 'registered')
for (const id of ['bbb', 'ccc']) {
const refused = stateOf(loader, id)
assert.equal(refused.state, 'startup_failed')
assert.equal(refused.stage, 'manifest')
assert.match(refused.reason, /already runs the module "aaa", and a site runs one module/)
assert.match(refused.reason, new RegExp(`Remove "${id}" or "aaa"`))
}
// The version still reaches the admin panel, read from module.json alone.
assert.equal(stateOf(loader, 'bbb').version, '2.1.0')
})
test('the first module holds the site even when it fails to load', () => {
// Which module a site runs is decided by the directory, not by which one
// happened to start: a broken first module does not hand the site to the
// second, where an operator would be debugging a module they did not mean to run.
writeModule('aaa', { server: 'throw new Error("boom")' })
writeModule('bbb', { server: 'module.exports = () => {}' })
const loader = freshLoader(tmpRoot)
const bad = stateOf(loader, 'aaa')
assert.equal(bad.state, 'startup_failed')
assert.match(bad.reason, /boom/)
assert.match(stateOf(loader, 'bbb').reason, /already runs the module "aaa"/)
})
test('a second module never mounts, registers or claims a table', () => {
const tiers = emptyTiers()
writeModule('aaa', oneRoute('/thing'))
writeModule('bbb', {
...oneRoute('/other'),
schema: 'CREATE TABLE IF NOT EXISTS bbb_x (id INT);',
})
const loader = freshLoader(tmpRoot, tiers)
const claims = (prefix) =>
tiers.public.stack.some((l) => l.regexp && !l.regexp.fast_slash && l.match(prefix))
assert.equal(claims('/thing'), true)
assert.equal(claims('/other'), false)
assert.deepEqual(loader.fragments().map((f) => f.id), [])
assert.deepEqual(loader.bootable().map((b) => b.id), ['aaa'])
})
// ── The failing module fails alone ─────────────────────────────────────────
test('a module whose entry point throws is recorded, and the site still comes up', () => {
const loader = alone('bbb', { server: 'throw new Error("boom")' })
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('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 */
assert.match(stateOf(alone('greedy', { manifest: { mounts: { admin: ['/users'] } } }, real), 'greedy').reason, /owned by core/)
assert.match(
stateOf(alone('alsogreedy', { manifest: { mounts: { public: ['/wiki'] } } }, real), 'alsogreedy').reason,
/owned by core/,
)
// Free in every tier core actually mounts, so it must be allowed through.
assert.equal(stateOf(alone('polite', oneRoute('/widgets', 'player'), real), '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 coreTiers = () => {
const tiers = emptyTiers()
tiers.public.use('/posts', express.Router())
tiers.public.use('/', express.Router())
return tiers
}
assert.equal(stateOf(alone('fine', oneRoute('/widgets'), coreTiers()), 'fine').state, 'registered')
assert.match(stateOf(alone('taken', oneRoute('/posts'), coreTiers()), '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.
assert.match(
stateOf(alone('nested', { manifest: { mounts: { public: ['/a/b'] } } }), 'nested').reason,
/bad prefix "\/a\/b"/,
)
assert.match(
stateOf(alone('parameterised', { manifest: { mounts: { public: ['/:id'] } } }), '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 moduleSchema.test.js) ──
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('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/)
})
test('a fragment may only use the four verbs core\'s own schema.sql uses', () => {
// An allowlist rather than a DROP denylist. §2.6 bans DROP, but this file is
// REPLAYED ON EVERY BOOT, so TRUNCATE and DELETE would empty a table at every
// restart and RENAME would fail at the second one — a denylist only ever bans
// what somebody thought of.
for (const [id, sql, verb] of [
['dropper', 'DROP TABLE dropper_x;', 'DROP'],
['nuker', 'TRUNCATE TABLE nuker_x;', 'TRUNCATE'],
['wiper', 'DELETE FROM wiper_x;', 'DELETE'],
['granter', 'GRANT ALL ON *.* TO app;', 'GRANT'],
]) {
const reason = stateOf(alone(id, { schema: sql }), id).reason
assert.match(reason, new RegExp(`starts with "${verb}"`))
}
})
test('a fragment may INSERT and UPDATE its own seed data', () => {
// Core's schema.sql does both (INSERT IGNORE INTO settings, one UPDATE), so a
// module that seeds a lookup table the same way must not be rejected.
writeModule('seeder', {
schema: [
'CREATE TABLE IF NOT EXISTS seeder_kinds (id INT PRIMARY KEY, label VARCHAR(32));',
"INSERT IGNORE INTO seeder_kinds (id, label) VALUES (1, 'first');",
"UPDATE seeder_kinds SET label = 'first' WHERE id = 1;",
].join('\n'),
})
assert.equal(stateOf(freshLoader(tmpRoot), 'seeder').state, 'registered')
})
test('a CREATE TABLE without IF NOT EXISTS is rejected', () => {
// It succeeds exactly once and fails every boot after it, which presents as a
// module that worked until the first restart — the worst kind of bug to ship
// to an operator, and free to catch by reading the file.
writeModule('once', { schema: 'CREATE TABLE once_x (id INT);' })
assert.match(stateOf(freshLoader(tmpRoot), 'once').reason, /CREATE TABLE without IF NOT EXISTS/)
})
test('a fragment carrying an unreadable file fails the module, not the boot', () => {
writeModule('missing', { schema: 'CREATE TABLE IF NOT EXISTS missing_x (id INT);' })
fs.unlinkSync(path.join(tmpRoot, 'missing', 'schema.sql'))
const loader = freshLoader(tmpRoot)
assert.equal(stateOf(loader, 'missing').state, 'startup_failed')
})
test('fragments() lists only a registered module that has one', () => {
const loader = alone('withdb', { schema: 'CREATE TABLE IF NOT EXISTS withdb_x (id INT);' })
const frags = loader.fragments()
assert.deepEqual(frags.map((f) => f.id), ['withdb'])
// An absolute path, so the replay never has to know how a module dir is laid out.
assert.equal(frags[0].file, path.join(tmpRoot, 'withdb', 'schema.sql'))
assert.deepEqual(alone('nodb', oneRoute('/plain')).fragments(), [])
assert.deepEqual(alone('broken', { schema: 'CREATE TABLE IF NOT EXISTS not_mine (id INT);' }).fragments(), [])
})
test('fragments() before load() throws, like every other accessor', () => {
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.fragments(), /modules\.fragments\(\) before modules\.load\(\)/)
})
// ── Mounting and the dispatch guard ────────────────────────────────────────
test('a registered module answers on its prefix; a failed one is simply absent', async () => {
for (const [id, spec, url, status] of [
['good', oneRoute('/widgets'), '/public/widgets', 200],
// Failed BEFORE mounting, so its routes are not absent-with-a-503 — they do
// not exist at all (§4.4, left-hand column).
['bad', { ...oneRoute('/broken'), server: 'throw new Error("boom")' }, '/public/broken', 404],
]) {
const tiers = emptyTiers()
alone(id, spec, tiers)
const app = await startApp((a) => a.use('/public', tiers.public))
try {
assert.equal((await fetch(`${app.url}${url}`)).status, status, id)
} 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. The schema replay and onBoot
// are the two things that trip this in real life (moduleSchema.test.js and
// moduleLifecycle.test.js cover both); 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', { stage: 'schema', reason: 'schema fragment blew up' })
assert.equal((await fetch(`${app.url}/public/widgets`)).status, 503)
assert.equal(stateOf(loader, 'later').reason, 'schema fragment blew up')
assert.equal(stateOf(loader, 'later').stage, 'schema')
// The 404 leg is reached for real by the boot reconcile, when it finds a
// `disabled` row in 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)
// Every non-failing move clears the failure, so a running module can never
// show the reason it failed two boots ago (§2.4).
assert.equal(stateOf(loader, 'later').reason, null)
assert.equal(stateOf(loader, 'later').stage, null)
} 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(),
events: Object.keys(ctx.events).sort(),
moduleId: ctx.moduleId,
mutable,
}))
}`,
})
assert.equal(stateOf(freshLoader(tmpRoot), 'probe').state, 'registered')
const probe = JSON.parse(fs.readFileSync(seen, 'utf8'))
// API 1.1.0 added activity, users and site — each because module-uo's
// extraction needed it and none could be vendored: an admin action a module
// performs belongs in core's one audit log, the extension slot needs the user
// its prefix names, and §2.7 forbids a module reading core's APP_BASE_URL.
// API 1.6.0 added `teams` — push, to the pull the team provider answers
// (TEAMS.md §2.3). Read-only by omission: a module answers questions about
// Teams and never asks them, so there is no getter here to add later by
// accident.
// API 1.7.0 added `events` and `inbox` (ENGAGEMENT.md Phase 2), and they are
// two members rather than one on purpose: `events.emit` fires a DECLARED event
// for the engine to decide the consequence of, and `inbox.push` writes a
// user's in-app inbox with no rule in between. `inbox.push` is present and
// throws until Phase 7 builds the channel — which is why it has to be in this
// list now: a member of a declared version that were absent would make
// MODULE_API_VERSION a lie, and this test is what says so.
// API 1.17.0 added `identities` (STEAM_SIGNIN.md §4): READ only, one provider
// at a time. Linking stays core's, and a module takes part through the two
// hooks on `api`, never by writing an identity itself.
// API 1.18.0 added `demoMode` (DEMO_MODE.md §3.4): a boolean, for the locks
// the module enforces in its own handlers.
// API 1.21.0 added `safety` (USER_SAFETY.md §4.1, S11): READ only, one
// reader's blocks and hides, so a module filters its own feeds on the server.
assert.deepEqual(probe.keys, [
'activity', 'auth', 'db', 'demoMode', 'events', 'express', 'identities', 'inbox', 'log', 'middleware', 'moduleId', 'paths',
'posts', 'push', 'safety', 'secretBox', 'settings', 'site', 'teams', 'uploads', 'users', 'validator',
])
// is core's limiter FACTORY, not a limiter: a module states its own
// window and cap and takes the plumbing, so there is one express-rate-limit in
// the process and one place a breach is logged. is
// handed over whole because it is shared policy — core's /auth/me/account/*
// and /player/appeals sit behind the same counter.
assert.deepEqual(probe.middleware, [
'accountChangeLimiter', 'noindex', 'rateLimit', 'requireAuth', 'requireRole', 'siteMode', 'validate',
])
// API 1.10.0 gave `events` its `reconcile`, and 1.11.0 its `expired` (Rust
// PLAN_FIXES D183): the game ended a ledgered resource at its own deadline.
assert.deepEqual(probe.events, ['emit', 'expired', 'reconcile'])
assert.equal(probe.moduleId, 'probe')
assert.equal(probe.mutable, false, 'ctx members must be frozen')
})
test("ctx.events.expired closes the calling module's rows, whatever it passes", async () => {
// The owner is bound by the loader, never read from the arguments — the same
// rule `reconcile` keeps. Without it a module could mark another module's
// resources expired and take them off the teardown list.
const cleanup = require('../src/events/cleanup')
const original = cleanup.expireResource
const calls = []
cleanup.expireResource = async (owner, resource) => {
calls.push({ owner, resource })
return { expired: 1 }
}
try {
writeModule('zoner', {
server: `module.exports = (ctx) => {
const result = ctx.events.expired({ kind: 'zone', ref: '3:rg-1', owner: 'someone-else', owner_module: 'x' })
if (result !== undefined) throw new Error('expired must return undefined')
}`,
})
assert.equal(stateOf(freshLoader(tmpRoot), 'zoner').state, 'registered')
await new Promise((resolve) => setImmediate(resolve))
assert.equal(calls.length, 1)
assert.equal(calls[0].owner, 'zoner')
assert.equal(calls[0].resource.kind, 'zone')
assert.equal(calls[0].resource.ref, '3:rg-1')
} finally {
cleanup.expireResource = original
}
})
// ── Lifecycle hooks ────────────────────────────────────────────────────────
test('a lifecycle hook must be a function, and may be registered once', () => {
// Both are register-time failures, so they cost the module its mount entirely
// rather than surfacing at boot — the far end of a hook that was never really
// registered is a module that silently never warms up.
for (const [body, expected] of [
['api.onBoot("later")', /onBoot: expected a function/],
['api.onShutdown("later")', /onShutdown: expected a function/],
['api.onBoot(() => {}); api.onBoot(() => {})', /onBoot\(\) called twice/],
]) {
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-modules-'))
writeModule('hooked', { server: `module.exports = (ctx, api) => { ${body} }` })
const state = stateOf(freshLoader(tmpRoot), 'hooked')
assert.match(state.reason, expected)
assert.equal(state.stage, 'register')
}
})
test('a module with no hooks is bootable, and offers nothing to shut down', () => {
writeModule('quiet', oneRoute('/widgets'))
const loader = freshLoader(tmpRoot)
// Listed with a null hook rather than filtered out: it still has to reach
// `started`, or the admin panel and the dispatch guard would disagree about
// whether it is serving.
assert.deepEqual(loader.bootable().map((b) => b.id), ['quiet'])
assert.equal(loader.bootable()[0].hook, null)
assert.deepEqual(loader.shutdownHooks(), [])
})
test('a shutdown hook comes back only for a started module', () => {
const loader = alone('aaa', { server: 'module.exports = (ctx, api) => api.onShutdown(async () => {})' })
// Nothing has started yet, so there is nothing to tear down.
assert.deepEqual(loader.shutdownHooks(), [])
loader.setState('aaa', 'started')
assert.deepEqual(loader.shutdownHooks().map((h) => h.id), ['aaa'])
// A module whose onBoot threw is mid-way through a warm-up it never finished,
// and handing it a half-built world to tear down is worse than not closing
// cleanly.
loader.setState('aaa', 'startup_failed', { stage: 'boot', reason: 'never warmed up' })
assert.deepEqual(loader.shutdownHooks(), [])
})
// ── Failure stages ─────────────────────────────────────────────────────────
test('a failure is recorded against the §4.3 step that produced it', () => {
// installed_modules.failure_stage exists so the admin panel can say WHERE a
// module broke. The four steps readManifest covers in one pass have to label
// themselves; the rest are inferred from how far load() had got.
const cases = [
['a-manifest', { manifest: { nonsense: true } }, 'manifest'],
['b-coreapi', { manifest: { coreApi: '^99.0.0' } }, 'core_api'],
['c-mounts', { manifest: { mounts: { public: ['/bad prefix'] } } }, 'mounts'],
['d-slots', { manifest: { extensions: ['no.such.slot'] } }, 'extensions'],
['e-schema', { schema: 'DELETE FROM x;' }, 'schema'],
['f-require', { server: 'throw new Error("boom")' }, 'require'],
['g-register', { server: 'module.exports = (ctx, api) => { throw new Error("nope") }' }, 'register'],
]
for (const [id, spec, stage] of cases) {
const state = stateOf(alone(id, spec), id)
assert.equal(state.state, 'startup_failed', `${id} should have failed`)
assert.equal(state.stage, stage, `${id} should have failed at "${stage}"`)
}
})
// ── Staged registrations are committed only for a module that survives ─────
test('a module that fails AFTER registering leaves nothing in the registries', () => {
// The registry-side twin of the second-pass mount rule. register() runs before
// checkDeclared, so a module can stage a stream catalog and then be rejected —
// and a half-registered catalog is worse than a missing one, because it is a
// subscribable stream nothing will ever publish to.
writeModule('halfway', {
manifest: { mounts: { public: ['/declared'] } },
server: `module.exports = (ctx, api) => {
api.registerNotificationStreams([{ id: 'halfway.thing', label: 'Thing' }])
api.registerAnnounceLeg({ leg: 'halfway.leg', label: 'L', dispatch: async () => ({}), classify: () => ({}) })
// declared /declared and never registered it → rejected by checkDeclared
}`,
})
const loader = freshLoader(tmpRoot)
assert.match(stateOf(loader, 'halfway').reason, /declared public\/declared but never registered it/)
assert.equal(registries.isValidStream('halfway.thing'), false)
assert.equal(registries.announceLeg('halfway.leg'), null)
})
test('a module colliding with a name core registered fails alone, unmounted', () => {
// Core registers through the same staging area a module does, and may name
// anything; with one module per site, core is the registrant a module can
// collide with. Staged by hand between the reset and the load, which is the
// order app.js runs registerCore() and modules.load() in.
const tiers = emptyTiers()
writeModule('second', {
manifest: { mounts: { public: ['/second'] } },
server: `module.exports = (ctx, api) => {
api.registerRoutes({ public: { '/second': ctx.express.Router() } })
api.registerNotificationStreams([{ id: 'second.ok', label: 'Ok' }, { id: 'second.shared', label: 'Mine' }])
}`,
})
process.env.MODULES_DIR = tmpRoot
registries._reset()
const core = registries.stage('core')
core.registerNotificationStreams([{ id: 'second.shared', label: 'Shared' }])
registries.apply(core.staged)
delete require.cache[require.resolve('../src/modules/loader')]
// eslint-disable-next-line global-require
const loader = require('../src/modules/loader')
loader.load(tiers)
assert.match(stateOf(loader, 'second').reason, /already registered by "core"/)
// Not even the claim that did not collide.
assert.equal(registries.isValidStream('second.ok'), false)
// And the loser is not mounted at all. Asked of the live router the way the
// prefix-ownership check asks it, rather than by counting layers — one mount
// produces two (the dispatch guard, then the module's router).
const claims = (prefix) =>
tiers.public.stack.some((l) => l.regexp && !l.regexp.fast_slash && l.match(prefix))
assert.equal(claims('/second'), false)
})
// ── The client chunk (MODULE_API.md §3.1) ──────────────────────────────────
/** A module shipping a prebuilt chunk at the conventional client/dist/entry.js. */
function withChunk(id, { entry = 'client/dist/entry.js', write = true, body = 'export default 1' } = {}) {
const dir = writeModule(id, { manifest: { client: { entry } } })
if (write) {
const file = path.join(dir, entry)
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, body)
}
return dir
}
test('a module with a chunk publishes where to serve it from and its URL', () => {
const dir = withChunk('uo')
const loader = freshLoader(tmpRoot)
const [chunk] = loader.clientChunks()
assert.equal(chunk.id, 'uo')
assert.equal(chunk.url, '/modules/uo')
assert.equal(chunk.entryUrl, '/modules/uo/entry.js')
// The DIRECTORY THE ENTRY IS IN, never the module root: one express.static over
// a module root would publish its server source, its module.json and its schema
// fragment.
assert.equal(chunk.dir, path.join(dir, 'client', 'dist'))
assert.equal(typeof chunk.guard, 'function')
})
test('a server-only module contributes no chunk', () => {
writeModule('plain', { server: 'module.exports = () => {}' })
const loader = freshLoader(tmpRoot)
assert.deepEqual(loader.clientChunks(), [])
assert.deepEqual(loader.clientEntryUrls(), [])
})
test('an entry directly in the module root is refused — its directory is served', () => {
// The rule with the largest blast radius in this file. Accepting it would root
// the static mount at the module root and publish everything in it.
const dir = writeModule('uo', { manifest: { client: { entry: 'entry.js' } } })
fs.writeFileSync(path.join(dir, 'entry.js'), 'export default 1')
const loader = freshLoader(tmpRoot)
assert.equal(stateOf(loader, 'uo').state, 'startup_failed')
assert.match(stateOf(loader, 'uo').reason, /must be in a subdirectory/)
assert.deepEqual(loader.clientChunks(), [])
})
test('an entry that escapes the module directory is refused before anything else', () => {
// `../../server/src/config/csp.js` is a real, readable file, and every check
// after containment would have passed.
withChunk('uo', { entry: '../../server/src/config/csp.js', write: false })
const loader = freshLoader(tmpRoot)
assert.equal(stateOf(loader, 'uo').state, 'startup_failed')
assert.match(stateOf(loader, 'uo').reason, /escapes the module directory/)
})
test('an entry that is not a .js file, or is missing, is refused', () => {
withChunk('aaa', { entry: 'client/dist/entry.mjs' })
assert.match(stateOf(freshLoader(tmpRoot), 'aaa').reason, /must name a \.js file/)
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-modules-'))
withChunk('bbb', { entry: 'client/dist/entry.js', write: false })
assert.match(stateOf(freshLoader(tmpRoot), 'bbb').reason, /is missing/)
})
test('a malformed client key is a loud failure, not an ignored setting', () => {
for (const [id, client, reason] of [
['aaa', 'client/dist/entry.js', /client must be an object/],
['bbb', { entry: 'x/e.js', chunks: ['a.js'] }, /unknown key "client\.chunks"/],
['ccc', {}, /client\.entry must be a path/],
]) {
const state = stateOf(alone(id, { manifest: { client } }), id)
assert.match(state.reason, reason)
assert.equal(state.stage, 'manifest')
}
})
test('only a STARTED module gets a script tag, though every one keeps its mount', () => {
// The mount is a standing offer answered by a guard; the tag is a decision
// taken per render, when the state is known. A module answering 503 on its API
// must not also be handing the browser the script that calls it.
withChunk('uo')
const loader = freshLoader(tmpRoot)
assert.deepEqual(loader.clientEntryUrls(), [], 'registered is not yet serving')
loader.setState('uo', 'started')
assert.deepEqual(loader.clientEntryUrls(), ['/modules/uo/entry.js'])
loader.setState('uo', 'startup_failed', { stage: 'boot', reason: 'nope' })
assert.deepEqual(loader.clientEntryUrls(), [])
assert.equal(loader.clientChunks().length, 1, 'the mount stays; the guard answers for it')
loader.setState('uo', 'disabled')
assert.deepEqual(loader.clientEntryUrls(), [])
})
test('the chunk accessors throw before load(), like every other one', () => {
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.clientChunks(), /modules\.clientChunks\(\) before modules\.load\(\)/)
assert.throws(() => loader.clientEntryUrls(), /modules\.clientEntryUrls\(\) before modules\.load\(\)/)
})