The consumer half of a release module-uo's CI has been publishing since
phase 3 closed. Before this, core had the installed_modules provenance
columns and no code that could ever fill them: nothing fetched, verified,
unpacked, removed or purged anything, and there was no admin route at all.
Adds modules/archive.js, modules/install.js, schema.runPurge(),
lifecycle.stop(), loader.stopHook(), and /api/v1/admin/modules with eight
routes. 797 server tests (+76), manifest 158 -> 166 + 2 internal, OpenAPI
gains 8 operations and loses nothing.
Reject, never sanitise
----------------------
The download is the easy part: an https-only allowlist re-checked on every
redirect hop, a declared sha256 compared against the bytes that arrived, and
a byte cap. Unpacking is where the archive chooses the filenames, and core
writes into a directory bind-mounted from the host, so an escape is not
confined to the container.
archive.js inspects the whole archive before a byte is unpacked and refuses
absolute and drive-absolute paths, `..` segments, NUL bytes, backslashes,
anything that is not a regular file or a directory, more than one top-level
entry, and anything over the entry or byte caps. Refusing symlinks and
hardlinks outright is what keeps this off the majority of node-tar's
published advisories rather than depending on the library to contain them.
That two-pass shape is load-bearing, and it was measured rather than assumed:
extracting an archive whose fourth member escapes upward throws under
node-tar 7.5.22 -- and leaves the first three members on disk. The loader
scans that directory at require time on the next boot, so a half-unpacked
module is a module. Everything therefore happens in a scratch directory that
is removed on any failure, and the move into place is the last step.
`tar` is pinned to ^7.5.22 rather than the ^6 that installs by default: 6.x
is flagged critical, and reading the advisory list is what the file's header
now says out loud -- almost all of it is hardlink or symlink traversal and
PAX header interpretation differentials, which is exactly this feature's
threat model.
Two things the plan had wrong
-----------------------------
The bundle's top-level directory is `module-uo-<version>`, not the module id
-- so "the top-level name must equal the id" was checked against nothing real.
The extractor strips that level instead, because its name belongs to whoever
published the bundle and the directory it lands in is core's. What is checked
instead is the unpacked module.json: a manifest promising `uo` and delivering
something else is refused rather than installed under the name it promised.
And purge cannot be a follow-up action (decision 5): purge.sql lives inside
the directory uninstall deletes. It is offered in the uninstall flow and as a
standalone action on a still-installed module, and the standalone one refuses
unless the module is already disabled -- dropping tables under something that
is still serving leaves it answering out of a world that no longer exists.
Disable now means stopped
-------------------------
lifecycle.stop() dispatches that one module's onShutdown before flipping the
guard, so a module an operator switches off actually releases its sockets and
closes its streams instead of merely becoming unreachable. The hook runs
first and the state moves after it, because while onShutdown runs the module
is still `started` and that is the only state in which its routes and the
world it is tearing down agree. A hook that throws does not stop the disable
-- the opposite of the boot path's rule, and deliberately.
Enable is not its mirror and there is no start(id) beside it. There is no
onBoot re-dispatch and the hooks were never promised re-entrant, so enable
moves the row and the restart route starts it. A test pins that enable does
not touch the loader, because "fixing" it is a one-line change that would put
a module with closed sockets back on the nav.
Restart raises SIGTERM against its own process rather than calling the
shutdown path directly, so server.js's handler stays the one graceful-shutdown
path and this route cannot drift from it.
The allowlist bootstraps from MODULE_SOURCE_HOSTS into a settings row and is
admin-managed after that (decision 6); seedDefault is INSERT IGNORE, so
changing the variable on an existing deployment is a no-op by design. An empty
list forbids every install rather than allowing every host -- the safe
direction for a value someone might blank by accident.
Verified against the real v0.3.0 release
----------------------------------------
Not a fixture: fetched the published install manifest over the real Gitea
host and its redirect chain, verified the sha256, inspected and unpacked the
252,517-byte artifact to 82 files, and then booted core against the result --
the module registered its five mounts, seven streams and eight capabilities
and resolved its client chunk, with no scratch directory left behind.
Two defects this slice's own tooling caught, both of which had already been
written down as classes:
- the controller destructured runPurge at require time, capturing the
function rather than the module, which made the one dependency whose
ORDER matters the one that could not be substituted;
- two swagger annotations carried an apostrophe inside a quoted string,
dropped silently by swagger-autogen before slice 5 taught it to fail loudly.
Co-Authored-By: Claude <noreply@anthropic.com>
932 lines
41 KiB
JavaScript
932 lines
41 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 { 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,
|
|
}
|