// ── 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 /modules//, 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 { 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`, `/player/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 }, // 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 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, }