Files
website/server/src/modules/loader.js
wtclaude cfd1cb3c3c feat(engagement): let a module ship its own templates and rules (Phase 11b)
Phase 11a declared 24 triggers and stopped where the plan said it would. Standing
11b up found that the next sentence — "24 rules, all enabled = 0; bespoke template
bodies" — described work with no mechanism to land in: templateSeeds.js and
coreRules.js are core files with core arrays in them, and there was no
registerTemplates or registerRules anywhere in registries.js.

So a module could say what an event's payload was and could never say what the
mail should read like. That is tolerable for one trigger and not for a catalogue,
and it is decisive once the bodies carry domain prose core must not contain (§5.2).

Adds api.registerEngagementSeeds({ templates, ruleGroups }) — MODULE_API 1.9.0.
The module supplies data; core keeps seedOne's customized skip, its seed_version
comparison and the block registry's validation, which is the whole argument for a
registry over the ctx.query a module already holds: a copy of any of those living
outside engagement/ would drift the first time core improved the original, and the
drift would surface as a mail somebody already received.

The two halves behave differently, deliberately:

  - Templates re-ensure on every boot, so a bumped seedVersion reaches every
    deployment except the ones where an operator edited that row.
  - Rule groups are ONE-SHOT, each under its own settings guard — re-ensuring
    would resurrect a rule an operator deleted and reset one they enabled. This is
    11a's seed-key finding stated as an API rather than as a warning: a rule
    appended to an existing group reaches fresh installs only, and one that must
    reach stamped deployments takes a new group key.

Three prohibitions, each a shipped mistake that would only surface as mail: a
seeded rule is always enabled = 0 (Q3's invariant, ignored rather than refused so
a typo cannot take a module offline at boot); a module may not mark a template
protected; and a rule may only name its own trigger ids and its own or core's
template keys, with template keys namespaced because the key column is UNIQUE.

Runs from modules/lifecycle.js boot() rather than seedDefaults(), and that is
forced rather than chosen: server.js seeds before it requires app.js, and
requiring app.js is what runs the loader — at the moment core seeds, no module has
registered anything. Placed after the installed_modules reconcile (so a disabled
or failed module is skipped) and before the onBoot dispatch (so a module warming a
cache may assume its rules exist).

16 new tests; 1549 core tests green; check:modules clean.

Refs docs#/ENGAGEMENT.md Phase 11b decision 7.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 00:46:15 -05:00

1062 lines
48 KiB
JavaScript

// ── 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).
//
// PR 7 added the client half's server-side end: validating `client.entry` and
// publishing where the chunk lives (`clientChunks()`), so app.js can serve it and
// utils/htmlShell.js can inject its script tag. The loader resolves and validates;
// it does not mount, because the chunk hangs off the ROOT app rather than a tier
// router, and app.js is where core's own static mounts live.
//
// PR 3 added the fragment half of the schema story: this file VALIDATES a
// fragment (statement by statement, at load time, before anything is mounted)
// and publishes it through `fragments()`. Replaying it needs a database, so it
// belongs to modules/schema.js, which utils/db.js calls after core's schema.
//
// PR 5 added the lifecycle hooks a module registers here (`onBoot`/`onShutdown`)
// and the failure STAGE carried beside every reason. Dispatching those hooks and
// reconciling `installed_modules` need a database, so they live in
// modules/lifecycle.js for the same reason schema.js is a separate file: this one
// stays require-able against a dead pool.
const fs = require('fs')
const path = require('path')
const { MODULE_API_VERSION } = require('./version')
const semver = require('./semver')
const registries = require('./registries')
const { splitStatements } = require('../utils/sqlStatements')
const log = require('../utils/logger')('modules')
const REPO_ROOT = path.join(__dirname, '..', '..', '..')
// Resolved absolute, and the `path.resolve` is load-bearing rather than tidy.
// `resolveClient` compares an absolute `path.resolve(dir, entry)` against this
// directory to check containment, so a RELATIVE `MODULES_DIR` — which is what
// anyone following §7.7's smoke recipe from `server/` naturally types — makes
// that comparison fail for every module, with the thoroughly misleading
// "client.entry escapes the module directory". Found the first time the smoke
// was run against a module with a real client half.
const MODULES_DIR = path.resolve(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 are declared by core, at require time, in the router that owns
// the resource (registries.declareSlot). The loader asks the registry which exist
// rather than keeping a list, for the same reason the prefix check probes the
// live tier routers: a second copy of the answer is a copy that drifts.
// id → record. Populated by load(), read by list().
const modules = new Map()
let loaded = false
// Bumped by every state CHANGE. One consumer today: the merged OpenAPI document
// at /api/docs.json, which is built from the fragments of `started` modules and
// so has to be rebuilt when that set moves (§6.1a). A counter rather than an
// event, because the question a cache asks is "is what I have still current",
// and a number answers it without anyone having to remember to subscribe.
let stateVersion = 0
// ── 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')
const activity = require('../model/activity/activity.model')
const users = require('../model/users/users.model')
const teams = require('../model/teams/teamSync.model')
const teamActivity = require('../model/teams/teamActivity.model')
const engagementEmit = require('../utils/engagementEmit')
const inappChannel = require('../engagement/inappChannel')
const { makeLimiter, accountChangeLimiter } = require('../middleware/rateLimit')
/* 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,
// Rate limiting, added in API 1.1.0 as a factory plus one shared limiter.
//
// `rateLimit(options)` is core's `makeLimiter`: a module states its own
// window and cap — it knows what its endpoints cost — and takes the
// plumbing from core, so there is one express-rate-limit in the process,
// one store, and one place a breach is logged.
//
// `accountChangeLimiter` is handed over whole because it is genuinely
// shared policy: core's `/auth/me/account/*` and `/player/appeals` are
// behind the same counter, and a module's account-change route has to land
// in it rather than beside it.
rateLimit: makeLimiter,
accountChangeLimiter,
},
uploads,
posts: {
listAll: posts.listAll,
getById: posts.getById,
linkAnnounceJob: posts.linkAnnounceJob,
markAnnounced: posts.markAnnounced,
},
// The admin audit log — WRITE only (§2.3, added in API 1.1.0). Core's one
// audit trail has to include the admin actions a module performs, or the
// trail has a hole exactly where a module operates the game. A module that
// kept its own log would be a second place to look, which in practice means
// a place nobody looks. `list` stays core's: reading the log is the admin
// panel's job, and it spans every actor.
activity: { log: activity.log },
// Teams (API 1.6.0, TEAMS.md §2.3). Push, to the pull the provider answers.
//
// Both are fire-and-forget by contract. `publish` is an OPTIMISATION — it
// makes a membership change visible at once — and `reconcile` is a REQUEST,
// debounced and never awaited, so a module cannot make its own call site slow
// or turn a background failure into its own error. Correctness comes from the
// reconciler either way; these only decide how soon.
//
// There is deliberately no reader here. A module answers questions about
// Teams; it does not ask them. Every Team table is core-internal (§10.3), and
// a `getTeamRoster` on ctx would be core offering to read back the module's
// own answer — which is the module's data, in the module's own store.
teams: {
publish: (event) => teams.publish(event),
reconcile: (opts) => teams.request(opts),
// §4's activity feed (phase 3). `source` is bound to the CALLING module and
// is never taken from the item — a module writes its own items, under its
// own name, and items name their Team by the module's own `externalId`, so
// there is no id a module could send that reaches another module's Team.
//
// Like `publish` and `reconcile` above, a failure here never reaches the
// module: this is called from inside a game-event handler, and a storage
// problem of core's must not become the module's control flow. A rejected
// write is logged and the promise still resolves.
activity: {
push: (items) => teamActivity.push(id, items).then(
(stored) => { void stored },
(err) => { log.error('ctx.teams.activity.push failed', { module: id, message: err.message }) },
),
},
},
// Engagement (API 1.7.0, ENGAGEMENT.md §5.1). The push half of the trigger
// contract the module registered with `api.registerEventTriggers`.
//
// `id` is bound here and is never taken from the arguments, exactly as
// `teamActivity.push(id, …)` binds its source: a module fires its OWN
// triggers. Without that binding, emit would be a way to fire another
// module's event with a payload of your choosing, and every rule an operator
// wrote against it would fire on that.
//
// Fire-and-forget and returns undefined. `emit()` answers a result its core
// callers want; a module gets nothing back on purpose, because there is
// nothing it could correctly do with a failure from inside a game-event
// handler — and "never throws in production" is only true if there is also
// nothing to await. The dev-time throw is inside `emit`, where the stack
// still points at the module's own call.
events: {
emit: (triggerId, envelope) => {
engagementEmit.emit(id, triggerId, envelope)
},
},
// The in-app sink (§5.1) — a module writing the inbox directly, without a
// rule. Live from Phase 7; it threw until the `user_notifications` table
// behind it existed.
//
// Fire-and-forget and returns undefined, like `events.emit` above and
// `teams.activity.push` before it, and for the same reason: a module calls
// this from inside a game-event handler, and there is nothing it could
// correctly do with a storage failure of core's. The decision the sink makes
// that a module might want to know about — the user has this switched off —
// is deliberately not reported either, because a module that could see it
// would be a module that could enumerate people's preferences one write at a
// time.
//
// `id` is bound here and never taken from the arguments, exactly as `emit`
// and `teams.activity.push` bind theirs.
inbox: {
push: (userId, item) => {
inappChannel.pushDirect(id, userId, item).then(
(result) => { void result },
(err) => { log.error('ctx.inbox.push failed', { module: id, message: err.message }) },
)
},
},
// One function, for one caller: the `admin.users.detail` slot router needs
// the user its prefix names. Narrowed like `ctx.posts` — the users model
// exports creation, role changes and password handling, none of which is a
// module's business.
users: { getById: users.getById },
// Where this deployment is reachable, for a module that has to build an
// absolute link (an announcement carries one into a game window or a chat
// message, where a relative path means nothing). §2.7 forbids a module
// reading `process.env` for core configuration and this is core
// configuration, so core answers it. A getter, not a captured string: the
// value is read per call, so it cannot go stale against the env.
site: { get baseUrl() { return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '') } },
}
// 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)
}
const hook = (name) => (fn) => {
once(name)
if (typeof fn !== 'function') throw new Error(`${name}: expected a function`)
record.hooks[name] = fn
}
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)
}
}
},
// The three de-entanglement registries (§2.4). They live in registries.js
// rather than here because core registers through the same staging area, and
// core has no `api` object.
//
// These STAGE. Nothing a module registers is visible to core until the
// second pass commits it, for the reason the second pass exists at all: a
// module that throws halfway through register(), or fails checkDeclared
// after it, must leave nothing behind. A half-registered stream catalog
// would be worse than a missing one — it would be a subscribable stream
// nothing will ever publish to.
registerExtension: record.staged.registerExtension,
registerNotificationStreams(streams) {
once('registerNotificationStreams')
record.staged.registerNotificationStreams(streams)
},
registerAnnounceLeg: record.staged.registerAnnounceLeg,
// The Team provider (API 1.6.0, TEAMS.md §2.3). Unlike every registration
// above, this one is core CALLING THE MODULE and waiting for an answer — the
// same direction registerAnnounceLeg's dispatch already goes, which is why it
// is modelled on it rather than invented. `once` because a module registering
// twice means two answers to a question that has one.
registerTeamProvider(provider) {
once('registerTeamProvider')
record.staged.registerTeamProvider(provider)
},
// Chat-platform slash commands (API 1.6.0, §7.1), live since phase 7. Like
// registerTeamProvider above, the handler this stages is core CALLING THE
// MODULE and waiting for an answer — but from further away than any other
// member: the caller is a bot in another container, holding a Discord
// interaction open on a deadline. `once` for the same reason the two
// registries above take it — a second call is a module changing its mind
// halfway through register(), not adding to what it already said.
registerSlashCommands(commands) {
once('registerSlashCommands')
record.staged.registerSlashCommands(commands)
},
// The engagement contract (API 1.7.0, ENGAGEMENT.md §4.3 / §5.1a). Both
// STAGE, like the registries above them, and both take `once` for the same
// reason `registerNotificationStreams` does: a batch is a module's complete
// statement about what it declares, and a second call is a module changing
// its mind halfway through register() rather than adding to it.
//
// A trigger id and a stream id share one namespace (§7.2), so a module that
// calls both may legitimately name the same id in each — that is one event
// with a subscription toggle and a payload contract, and it is the case core
// itself exercises on every boot.
registerEventTriggers(triggers) {
once('registerEventTriggers')
record.staged.registerEventTriggers(triggers)
},
registerAudiences(audiences) {
once('registerAudiences')
record.staged.registerAudiences(audiences)
},
// What the module SHIPS behind those two — its message bodies and its
// seeded rules (API 1.9.0, ENGAGEMENT.md Phase 11b decision 7). `once` for
// the same reason again, and here it is load-bearing rather than tidy: a
// rule belongs to exactly one named group, and merging two calls would make
// "which group is this rule in" — the question the one-shot guard answers —
// unanswerable.
//
// Data only. Nothing on the object is a function and nothing on it reaches a
// recipient: seeding writes rows that are `enabled = 0`, and a module still
// cannot send mail (§1.2).
registerEngagementSeeds(seeds) {
once('registerEngagementSeeds')
record.staged.registerEngagementSeeds(seeds)
},
// The two lifecycle hooks (§2.5). Registered here, dispatched from
// lifecycle.js — this file runs with no database and the hooks run with one.
// Both are optional: a module with no warm-up and nothing to close simply
// never calls them.
onBoot: hook('onBoot'),
onShutdown: hook('onShutdown'),
}
}
// ── Validation ─────────────────────────────────────────────────────────────
/**
* Throw with the §4.3 step that failed attached.
*
* `installed_modules.failure_stage` exists so the admin panel can say *where* a
* module broke and not only what the message was, and the model enumerates the
* eight stages (`FAILURE_STAGES`). The steps that share one function — a
* manifest read that also checks `coreApi`, the mounts and the slots — cannot be
* told apart by position in load(), so they carry their own label; everything
* else is inferred from how far load() had got. An untagged error is recorded
* against the step that was running, never guessed at.
*/
function fail(stage, message) {
const err = new Error(message)
err.stage = stage
throw err
}
// 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')
// Split first, for the same reason tablesOf() does: matching the raw file
// reads CREATE TABLE out of comments, and a phantom core table makes a
// module fail with a collision against something that does not exist.
for (const statement of splitStatements(sql)) {
for (const m of statement.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
}
// The only leading verbs a fragment may use — an allowlist, not a DROP denylist.
//
// §2.6 bans `DROP`, but a denylist only ever bans what somebody thought of, and
// core's own schema.sql needs exactly four verbs: CREATE, ALTER, INSERT, UPDATE.
// Anything else in a file that is REPLAYED ON EVERY BOOT is a mistake worth
// failing on — TRUNCATE and DELETE would empty a table every restart, RENAME
// would break on the second one, and GRANT/SET/USE are core's business, not a
// module's. CREATE covers CREATE INDEX as well as CREATE TABLE.
//
// This is a leading-verb check and says so: `ALTER TABLE x DROP COLUMN y` passes
// it. Catching that needs a SQL parser, which is a large dependency to take on
// for a rule whose real job is stopping the obvious foot-gun early.
const ALLOWED_VERBS = new Set(['CREATE', 'ALTER', 'INSERT', 'UPDATE'])
const CREATE_TABLE_ANY = /^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?/i
const CREATE_TABLE_GUARDED = /^CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+/i
/**
* Read and validate a module's schema fragment; return every table it declares.
*
* Validation happens HERE, at load time, and not in modules/schema.js where the
* fragment is replayed, because every rule §2.6 states is knowable by reading
* the file — no database required. Failing at load means a module with a bad
* fragment never mounts at all (§4.4's first column: routes and nav simply
* absent), rather than mounting, 503ing, and leaving whatever its fragment did
* manage to execute behind it.
*
* Throws if the file is unreadable or breaks a rule.
*/
function tablesOf(dir, manifest) {
if (!manifest.schema) return new Set()
const file = path.join(dir, manifest.schema)
const sql = fs.readFileSync(file, 'utf8')
const statements = splitStatements(sql)
for (const statement of statements) {
const verb = (statement.match(/^\w+/) || [''])[0].toUpperCase()
if (!ALLOWED_VERBS.has(verb)) {
throw new Error(`schema fragment statement starts with "${verb}" (allowed: ${[...ALLOWED_VERBS].join(', ')})`)
}
// A bare CREATE TABLE succeeds exactly once and fails every boot after it,
// which presents as a module that worked until the first restart.
if (CREATE_TABLE_ANY.test(statement) && !CREATE_TABLE_GUARDED.test(statement)) {
throw new Error('schema fragment has a CREATE TABLE without IF NOT EXISTS')
}
}
// Scanned over the SPLIT STATEMENTS, never the raw file. `splitStatements`
// strips `--` comments; the file does not, and a fragment that documents
// itself will say "every CREATE TABLE carries IF NOT EXISTS" in its header.
// Read raw, that yields a table called `carries`, and the module is rejected
// for a prefix violation on a table that does not exist — a message with no
// way back to the comment that caused it. module-uo's fragment hit exactly
// this on its first real load.
const tables = new Set()
for (const statement of statements) {
for (const m of statement.matchAll(CREATE_TABLE)) tables.add(m[1].toLowerCase())
}
return tables
}
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),
)
}
// ── The client chunk ───────────────────────────────────────────────────────
// A chunk filename, and the same character set utils/htmlShell.js will accept in
// a script src. Two copies of the rule, deliberately: this one rejects the module
// at load time, that one refuses to write the tag. A validator three files away
// staying strict is not something an HTML attribute should depend on.
const CHUNK_FILE = /^[A-Za-z0-9][A-Za-z0-9._-]*\.js$/
/**
* Resolve and validate `client.entry` — where a module's prebuilt chunk lives on
* disk, and the URL it is served at (§3.1).
*
* The rule that matters most is the last one, and it is the one a reviewer would
* not think to ask for: the static mount is rooted at the DIRECTORY THE ENTRY IS
* IN, not at the module root. One `express.static` over a module root would
* publish its server source, its `module.json` and its schema fragment to the
* internet. So an entry sitting directly in the module root is rejected rather
* than quietly turning the whole module into a public directory.
*
* @returns {{dir: string, url: string, entryUrl: string}|null} null when the
* module ships no client half — a server-only module is perfectly normal.
*/
function resolveClient(dir, id, manifest) {
// Absent `client` is a server-only module. Present but empty is not the same
// thing: it states a client half and delivers none, which would be a module
// whose pages never load and nothing anywhere saying why.
if (manifest.client === undefined) return null
const { entry } = manifest.client
if (typeof entry !== 'string' || !entry.trim()) fail('manifest', 'client.entry must be a path')
const file = path.resolve(dir, entry)
// Containment before anything else: `../../server/src/config` resolves to a
// real, readable directory, and every check below it would pass.
if (file !== dir && !file.startsWith(dir + path.sep)) {
fail('manifest', `client.entry "${entry}" escapes the module directory`)
}
if (!CHUNK_FILE.test(path.basename(file))) {
fail('manifest', `client.entry "${entry}" must name a .js file`)
}
const chunkDir = path.dirname(file)
if (chunkDir === dir) {
fail('manifest', `client.entry "${entry}" must be in a subdirectory — its directory is served`)
}
if (!fs.existsSync(file)) fail('manifest', `client.entry "${entry}" is missing`)
return {
dir: chunkDir,
url: `/modules/${id}`,
entryUrl: `/modules/${id}/${path.basename(file)}`,
}
}
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)) fail('manifest', `unknown key "${key}" in module.json`)
}
if (!ID.test(manifest.id || '')) fail('manifest', `invalid id "${manifest.id}"`)
if (manifest.id !== id) fail('manifest', `id "${manifest.id}" does not match directory "${id}"`)
if (!manifest.version) fail('manifest', 'missing version')
if (!manifest.coreApi) fail('core_api', 'missing coreApi')
if (!semver.satisfies(MODULE_API_VERSION, manifest.coreApi)) {
fail('core_api', `needs core API ${manifest.coreApi}, this core is ${MODULE_API_VERSION}`)
}
for (const [tier, prefixes] of Object.entries(manifest.mounts || {})) {
if (!TIERS.includes(tier)) fail('mounts', `unknown tier "${tier}" in mounts`)
for (const prefix of prefixes) {
if (!PREFIX.test(prefix)) fail('mounts', `bad prefix "${prefix}" in mounts.${tier}`)
if (ownedByCore(tierRouters[tier], prefix)) {
fail('mounts', `prefix ${tier}${prefix} is owned by core`)
}
for (const other of modules.values()) {
if ((other.manifest.mounts?.[tier] || []).includes(prefix)) {
fail('mounts', `prefix ${tier}${prefix} already registered by module "${other.id}"`)
}
}
}
}
for (const slot of manifest.extensions || []) {
if (!registries.hasSlot(slot)) fail('extensions', `unknown extension slot "${slot}"`)
}
if (manifest.client !== undefined) {
if (typeof manifest.client !== 'object' || manifest.client === null || Array.isArray(manifest.client)) {
fail('manifest', 'client must be an object')
}
for (const key of Object.keys(manifest.client)) {
if (key !== 'entry') fail('manifest', `unknown key "client.${key}" in module.json`)
}
}
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.
fail('schema', 'declares schema but no purge')
}
if (manifest.purge && !fs.existsSync(path.join(dir, manifest.purge))) {
fail('schema', `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() },
staged: registries.stage(id),
tables: new Set(),
called: new Set(),
hooks: { onBoot: null, onShutdown: null },
client: null,
ctx: null,
state: 'installed',
stage: null,
reason: null,
}
// How far load() has got, so an untagged throw is recorded against the step
// that was actually running (§4.3's steps 5-7). The steps before it label
// themselves, because readManifest covers four of them in one pass.
let stage = 'manifest'
try {
record.manifest = readManifest(dir, id, tierRouters)
record.client = resolveClient(dir, id, record.manifest)
stage = 'schema'
record.tables = tablesOf(dir, record.manifest)
checkTableNames(id, record.tables)
if (record.manifest.server) {
const entry = path.join(dir, record.manifest.server)
stage = 'require'
// 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`)
stage = 'register'
// Kept on the record, not discarded after register(): §2.5 hands the
// same ctx to onBoot, and building a second one would be a second frozen
// object claiming to be the same handle.
record.ctx = buildCtx(id, dir)
register(record.ctx, 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.stage = err.stage || stage
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`, {
stage: record.stage,
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') continue
try {
// Commit what this module staged. Collisions with core or with an earlier
// module surface here, in scan order, and cost only this module.
registries.apply(record.staged.staged)
} catch (err) {
record.state = 'startup_failed'
record.stage = 'register'
record.reason = err.message
log.error(`module "${record.id}" failed to register — continuing without it`, {
reason: err.message,
})
continue // unmounted, exactly like a validation failure in the first pass
}
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, stateGuard(record), router)
}
}
}
/**
* The dispatch guard, as a middleware over the LIVE record.
*
* A closure over the record rather than over its state: everything mounts once,
* at boot, and the states that matter here are reached afterwards — the schema
* replay fails, `onBoot` throws, an admin disables the module. A guard that read
* the state at mount time would answer for the state a module was in before any
* of that happened.
*
* Used for a module's API routes and, since PR 7, for its client chunk: a module
* answering 503 on its API must not also be handing the browser the script that
* calls it, and one an admin has disabled should be as absent from the page as it
* is from the nav.
*/
function stateGuard(record) {
return (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()
}
}
// ── 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: `ensureSchema()` replays the fragments (PR 3), and
* lifecycle.js runs `onBoot` and reconciles `installed_modules` (whose `disabled`
* rows are what make the guard's 404 leg reachable).
*
* The stage travels with the reason and is cleared by every non-failing move,
* for the same reason the database columns are (§2.4): a running module must
* never be able to show a stale failure.
*
* 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, { stage = null, reason = null } = {}) {
if (!RECORD_STATES.has(state)) throw new Error(`unknown module state "${state}"`)
const record = modules.get(id)
if (!record) return
if (record.state !== state) stateVersion += 1
record.state = state
record.stage = state === 'startup_failed' ? stage : null
record.reason = state === 'startup_failed' ? reason : null
}
// ── Introspection ──────────────────────────────────────────────────────────
function assertLoaded(caller) {
if (!loaded) throw new Error(`modules.${caller}() before modules.load()`)
}
/**
* Has load() run in this process?
*
* The one legitimate reason to ask instead of just calling an accessor: a
* process that never required app.js and so has no module list to be wrong
* about. `npm run seed` (db/seed.js) is exactly that — it calls ensureSchema()
* standalone, and the fragment replay has to be able to tell "this is the seed
* script" from "the server booted and something is mis-ordered", which is the
* distinction §7.6's throw exists to preserve everywhere else.
*/
const isLoaded = () => loaded
/**
* 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,
stage: r.stage,
reason: r.reason,
capabilities: r.manifest.capabilities || [],
}))
}
/**
* The modules that are ready to be booted, with their hook, in scan order.
*
* `registered` only — the state a module holds between a clean load and its
* `onBoot`. One that failed validation or schema replay is not going to run, and
* one already `started` has run. A module with no `onBoot` is still listed: it
* has nothing to warm up, but it still has to reach `started` so the admin panel
* and `installed_modules` agree with the guard about what is serving.
*
* @returns {{id: string, hook: Function|null, ctx: object|null}[]}
*/
function bootable() {
assertLoaded('bootable')
return [...modules.values()]
.filter((r) => r.state === 'registered')
.map((r) => ({ id: r.id, hook: r.hooks.onBoot, ctx: r.ctx }))
}
/**
* The shutdown hooks to run, in REVERSE registration order (§2.5).
*
* `started` only. A module whose `onBoot` threw is mid-way through a warm-up it
* never finished, and calling its `onShutdown` would hand it a half-built world
* to tear down — the one thing worse than not closing cleanly. Reverse order is
* the same reasoning applied between modules rather than within one.
*
* @returns {{id: string, hook: Function}[]}
*/
function shutdownHooks() {
assertLoaded('shutdownHooks')
return [...modules.values()]
.filter((r) => r.state === 'started' && r.hooks.onShutdown)
.map((r) => ({ id: r.id, hook: r.hooks.onShutdown }))
.reverse()
}
/**
* One module's `onShutdown`, for stopping it on its own rather than at exit.
*
* Phase 4 (§2.7.2 decision 3) gave the admin panel's Disable a real meaning.
* Until then, disabling flipped this record's state and the dispatch guard began
* answering 404 — which made the module invisible without making it stop. A
* module's `onBoot` is where it opens its sockets and arms its timers, and none
* of that is reachable through a URL, so an operator disabling a misbehaving
* module got no relief from it at all until the next restart.
*
* `started` only, the same rule shutdownHooks() applies and for the same reason:
* a module whose `onBoot` threw has a half-built world its `onShutdown` was
* never written to tear down. Returns null when there is nothing to run — which
* covers "not started", "no hook", and "no such module", none of which is an
* error the caller can act on differently.
*
* @returns {{id: string, hook: Function}|null}
*/
function stopHook(id) {
assertLoaded('stopHook')
const record = modules.get(id)
if (!record || record.state !== 'started' || !record.hooks.onShutdown) return null
return { id: record.id, hook: record.hooks.onShutdown }
}
/**
* Every schema fragment waiting to be replayed, in scan order.
*
* `registered` only: a module that failed validation must not get its tables
* created (it is not going to run), and one already `started` has had them. The
* absolute path is resolved here rather than handed out as a manifest-relative
* name, so the replay never has to know how a module directory is laid out.
*
* @returns {{id: string, file: string}[]}
*/
function fragments() {
assertLoaded('fragments')
return [...modules.values()]
.filter((r) => r.state === 'registered' && r.manifest.schema)
.map((r) => ({ id: r.id, file: path.join(r.dir, r.manifest.schema) }))
}
/**
* Every module that ships a client chunk, with where to serve it from and the
* guard to serve it behind — in scan order.
*
* Listed regardless of state, because mounting happens once at boot and the
* guard is what answers for the state at request time (the same arrangement the
* API routes have). A module that failed VALIDATION never reaches here at all:
* `record.client` is only resolved once the manifest passed.
*
* `dir` is the directory the entry sits in, never the module root — see
* resolveClient. app.js does the mounting; this file does not know about the
* root app.
*
* @returns {{id: string, dir: string, url: string, entryUrl: string, guard: Function}[]}
*/
function clientChunks() {
assertLoaded('clientChunks')
return [...modules.values()]
.filter((r) => r.client)
.map((r) => ({ id: r.id, ...r.client, guard: stateGuard(r) }))
}
/**
* The script URLs the HTML shell should inject, in scan order.
*
* `started` only, and that is the difference between this and clientChunks():
* the mount is a standing offer answered by a guard, while the tag is a decision
* taken per page render, when the state is already known. A module whose `onBoot`
* failed keeps its URLs and answers 503 on them — loading its client half would
* render its pages against a backend that cannot serve them.
*
* @returns {string[]}
*/
function clientEntryUrls() {
assertLoaded('clientEntryUrls')
return [...modules.values()]
.filter((r) => r.client && r.state === 'started')
.map((r) => r.client.entryUrl)
}
/**
* Every started module's OpenAPI fragment, in scan order.
*
* `started` only, matching clientEntryUrls() rather than clientChunks(): the
* merged document is built when it is asked for, at which point the state is
* known, and documenting a module that is 503ing every one of those paths would
* send a client somewhere it cannot go.
*
* The filename is fixed by §2.8 — `swagger-fragment.json` in the bundle root —
* rather than declared in `module.json`, so a module cannot point core at
* something else. A module that ships none is simply absent: registering routes
* without documenting them is checked in the module's OWN CI (§2.8), where the
* routes are known; core has no way to tell the difference here between a module
* with no routes and one that forgot.
*
* @returns {{id: string, file: string}[]}
*/
function specFragments() {
assertLoaded('specFragments')
return [...modules.values()]
.filter((r) => r.state === 'started')
.map((r) => ({ id: r.id, file: path.join(r.dir, 'swagger-fragment.json') }))
.filter((f) => fs.existsSync(f.file))
}
/**
* How many times a module's state has CHANGED in this process.
*
* A cache key, and nothing more: hold the value you built with, compare, rebuild
* when it differs. It says nothing about which module moved or where to.
*/
const version = () => stateVersion
/** Absolute path of the modules directory. */
const dir = () => MODULES_DIR
module.exports = {
load,
list,
setState,
fragments,
bootable,
shutdownHooks,
stopHook,
clientChunks,
clientEntryUrls,
specFragments,
version,
isLoaded,
dir,
}