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>
1062 lines
48 KiB
JavaScript
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,
|
|
}
|