Self-service account security had three URL surfaces onto one controller. All
three mounted the same `admin/account.controller.js` handlers; each of the three
router files carried a header comment apologising for the arrangement.
`/auth/me/account` was already a strict superset, which settles which to keep:
/admin/account 6 routes noindex, isLoggedIn, staffOnly
/player/account 8 routes noindex, requireAuth
/auth/me/account 10 routes noindex, requireAuth
Neither of the deleted surfaces carried recovery codes, and /admin/account
carried no username or password change at all — so client.js already called
/auth/me/account/recovery-codes/* for two operations on a screen it otherwise
served from /admin/account. The split was leaking before this change.
Gating is equivalent where it overlapped: /player and /auth/me apply identical
`noindex, requireAuth`, and `staffOnly` on /admin/account was strictly narrower
while buying nothing, since every handler is self-scoped to req.user.id. There
is no CSRF layer to differ.
- 14 routes deleted, 0 added, no handler changed.
- account.controller.js moves router/v1/admin/ -> router/v1/auth/, beside the
one router that still reaches it.
- Web client: 14 call sites move onto a root-level api.myAccount /
api.changeUsername / ... group, matching the /auth/me methods already there.
- Android app: no change. MeApi.kt was already 100% /auth/me/account/*.
- Two swagger tags, `Admin · Account` and `Player`, were declared only by the
deleted routes and go with them. The orphaned `AccountStatus` schema goes
too; `PlayerAccount` is re-described as the any-role /auth/me/account shape
(the name is kept so existing $refs resolve).
Breaking to the published OpenAPI surface, accepted deliberately: both consumers
are in this org, and deprecate-then-delete would leave the next phase deciding
whether to add routes to surfaces already marked for removal.
Verification: routes.manifest.json shows exactly 14 deletions and 0 additions.
The OpenAPI spec loses the same 14 paths with zero surviving path definitions
changed; its large textual diff is pure reordering, because removing the
first-mounted router shifts every later path. 1203 server tests, 288 client
tests, 53 bot tests green; check:modules, check:hosts and routes:manifest
--check all pass.
Design of record: docs/website/ENGAGEMENT.md Phase 1a. This lands ahead of
engagement Phase 1b, which adds a self-service email field — written once here
rather than three times.
Co-Authored-By: Claude <noreply@anthropic.com>
760 lines
34 KiB
JavaScript
760 lines
34 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)
|
|
|
|
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 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('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/)
|
|
})
|
|
|
|
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'],
|
|
]) {
|
|
writeModule(id, { schema: sql })
|
|
const reason = stateOf(freshLoader(tmpRoot), 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'))
|
|
writeModule('fine', oneRoute('/ok'))
|
|
|
|
const loader = freshLoader(tmpRoot)
|
|
assert.equal(stateOf(loader, 'missing').state, 'startup_failed')
|
|
assert.equal(stateOf(loader, 'fine').state, 'registered')
|
|
})
|
|
|
|
test('fragments() lists only registered modules that have one', () => {
|
|
writeModule('withdb', { schema: 'CREATE TABLE IF NOT EXISTS withdb_x (id INT);' })
|
|
writeModule('nodb', oneRoute('/plain'))
|
|
writeModule('broken', { schema: 'CREATE TABLE IF NOT EXISTS not_mine (id INT);' })
|
|
|
|
const loader = freshLoader(tmpRoot)
|
|
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'))
|
|
})
|
|
|
|
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 () => {
|
|
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. 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(),
|
|
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.
|
|
assert.deepEqual(probe.keys, [
|
|
'activity', 'auth', 'db', 'express', 'log', 'middleware', 'moduleId', 'paths',
|
|
'posts', 'push', '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',
|
|
])
|
|
assert.equal(probe.moduleId, 'probe')
|
|
assert.equal(probe.mutable, false, 'ctx members must be frozen')
|
|
})
|
|
|
|
// ── 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('shutdown hooks come back in reverse order, and only for started modules', () => {
|
|
const hook = 'module.exports = (ctx, api) => api.onShutdown(async () => {})'
|
|
writeModule('aaa', { server: hook })
|
|
writeModule('bbb', { server: hook })
|
|
writeModule('ccc', { server: hook })
|
|
const loader = freshLoader(tmpRoot)
|
|
|
|
// Nothing has started yet, so there is nothing to tear down.
|
|
assert.deepEqual(loader.shutdownHooks(), [])
|
|
|
|
loader.setState('aaa', 'started')
|
|
loader.setState('bbb', 'startup_failed', { stage: 'boot', reason: 'never warmed up' })
|
|
loader.setState('ccc', 'started')
|
|
|
|
// Reverse registration order (§2.5), and `bbb` is absent: 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.
|
|
assert.deepEqual(loader.shutdownHooks().map((h) => h.id), ['ccc', 'aaa'])
|
|
})
|
|
|
|
// ── 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] of cases) writeModule(id, spec)
|
|
const loader = freshLoader(tmpRoot)
|
|
|
|
for (const [id, , stage] of cases) {
|
|
const state = stateOf(loader, 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 an already-registered name fails alone, unmounted', () => {
|
|
const tiers = emptyTiers()
|
|
writeModule('first', {
|
|
manifest: { mounts: { public: ['/first'] } },
|
|
server: `module.exports = (ctx, api) => {
|
|
api.registerRoutes({ public: { '/first': ctx.express.Router() } })
|
|
api.registerNotificationStreams([{ id: 'first.shared', label: 'Shared' }])
|
|
}`,
|
|
})
|
|
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: 'first.shared', label: 'Mine' }])
|
|
}`,
|
|
})
|
|
const loader = freshLoader(tmpRoot, tiers)
|
|
|
|
assert.equal(stateOf(loader, 'first').state, 'registered')
|
|
assert.match(stateOf(loader, 'second').reason, /already registered by "first"/)
|
|
// 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('/first'), true)
|
|
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' })
|
|
withChunk('bbb', { entry: 'client/dist/entry.js', write: false })
|
|
const loader = freshLoader(tmpRoot)
|
|
assert.match(stateOf(loader, 'aaa').reason, /must name a \.js file/)
|
|
assert.match(stateOf(loader, 'bbb').reason, /is missing/)
|
|
})
|
|
|
|
test('a malformed client key is a loud failure, not an ignored setting', () => {
|
|
writeModule('aaa', { manifest: { client: 'client/dist/entry.js' } })
|
|
writeModule('bbb', { manifest: { client: { entry: 'x/e.js', chunks: ['a.js'] } } })
|
|
writeModule('ccc', { manifest: { client: {} } })
|
|
const loader = freshLoader(tmpRoot)
|
|
assert.match(stateOf(loader, 'aaa').reason, /client must be an object/)
|
|
assert.match(stateOf(loader, 'bbb').reason, /unknown key "client\.chunks"/)
|
|
assert.match(stateOf(loader, 'ccc').reason, /client\.entry must be a path/)
|
|
for (const id of ['aaa', 'bbb', 'ccc']) assert.equal(stateOf(loader, id).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\(\)/)
|
|
})
|