// ── The de-entanglement registries ───────────────────────────────────────── // // Phase 2, PR 4 of docs/website/MODULE_SYSTEM.md §2.7 — the three seams §1.8 and // §1.9 identified, where core code and game-specific content are tangled in one // file and a folder move cannot separate them. The normative contract is // docs/website/MODULE_API.md §2.4. // // The three: // // 1. `registerExtension(slot, router)` — §1.9. Module routes hanging off a // CORE resource (`/admin/users/:id`), so all six shard sub-paths keep their // URLs while core never learns what "shard" means. // 2. `registerNotificationStreams(streams)` — §1.8. The push-stream catalog: // push INFRASTRUCTURE is core, this CATALOG is content. // 3. `registerAnnounceLeg({ leg, label, dispatch, classify })` — §1.8. The news // dispatcher's delivery legs; Discord is core, town crier is content. // // **Core registers through these functions too, and is the only registrant until // Phase 3.** `registerCore()` below is called explicitly from app.js before // `modules.load()` — explicit, never lazy, the same decision the loader's trigger // took (MODULE_API.md §7.6). Core going through the same door is the point: a // registry only core's hardcoded base bypasses is a registry whose first real // exercise is a module, which is the drift this PR exists to prevent. // // **Registering is validate-then-commit, per registrant.** `apply()` checks every // claim in a batch before it writes any of them, so a module that registers two // streams and then throws — or fails a later validation step in the loader — has // left nothing behind. That is the registry-side twin of the loader's second-pass // mount rule: nothing a module claims takes effect until the module as a whole is // known good. // // Two more arrived with the engagement system (ENGAGEMENT.md Phase 2), from a // different workstream but through the same door: // // 4. `registerEventTriggers(triggers)` — §4.3. The payload CONTRACT behind an // event id: what a template may interpolate, and how widely a rule may // ever send it (the ceiling, G24). // 5. `registerAudiences(audiences)` — §5.1a. Named sets of user ids a // module can resolve over its own data, for an operator to point a rule at. // // And a sixth, in Phase 11b (decision 7): // // 6. `registerEngagementSeeds({ templates, ruleGroups })` — the message BODIES // and the shipped rules behind 4 and 5. A module declaring a trigger could // say what its payload was and never say what it should read like, so a // module's mail was core's generic body or nothing. // // **6 stores data and nothing else — no function, no handle.** A template is // blocks and a rule is columns, both validated here and both written by core's // own seeders (`engagement/moduleSeeds.js`), which is what keeps `seed_version`, // `customized` and the block registry in the one file that owns them. It is // emphatically not a send path: a module still cannot mail anyone (§1.2). // // **Triggers and notification streams share ONE id namespace** (the org lead's // §7.2 decision). A stream entry is a subscription toggle and a trigger is a // payload contract, so they stay two REGISTRATIONS with two shapes — but an id // has exactly one owner across both, and `news.post` names one event whichever // question is being asked of it. See the cross-facet checks in `apply()`. // // Nothing here reaches the database or the network. It is a require-time-safe // collection of what core and modules have declared, read at request time. const express = require('express') const log = require('../utils/logger')('modules') const ceilings = require('./ceilings') // ── State ────────────────────────────────────────────────────────────────── // slot → { router, filledBy }. `router` is created when CORE DECLARES the slot // and mounted immediately; registrants `use()` into it later. That indirection is // not optional: users.router.js is required while app.js is being built, long // before any module has been scanned, so the thing core mounts has to be a stable // object that can still be empty. const slots = new Map() // Registration order, which is display order in the app's notifications screen. const streams = [] const streamOwners = new Map() // stream id → owner id, for the collision message // leg id → { owner, leg, label, dispatch, classify } const legs = new Map() // owner -> { onSaved?, onDeleted? }. Post hooks (§1.8, API 1.1.0). A Map keyed by // owner rather than a flat list, so a registrant is a single subscription that // can be reported and reasoned about as one thing — and so registering twice is // a collision with a name attached rather than a silently doubled side effect. const postHooks = new Map() // { owner, getTeams, getTeamMembers, getTeamLeaders } or null — the Team provider // (API 1.6.0, TEAMS.md §2.3). // // A SINGLE value rather than a Map, unlike every registry above it, and that is // the contract: one provider per deployment. Teams have one authoritative source // by construction — two modules answering "what teams exist" would produce two // disjoint sets under one `teams` table with no rule for merging them, so a // second registration is a collision rather than an addition. let teamProvider = null // command name → { owner, name, description, options, access, handler }. Slash // commands a registrant has published for the chat platform (API 1.6.0, // TEAMS.md §7.1). // // The DEFINITION and the HANDLER are registered together and the handler runs // HERE, in the website process; the bot pulls the definitions over the internal // API and owns every Discord-specific concern. That split is forced — the bot // container has no `modules` volume, so a module physically cannot put a handler // in it (§0.4) — and it is also the boundary we would pick anyway: a module // calling `interaction.deferReply()` would be a module holding a Discord handle. const slashCommands = new Map() // trigger id → { owner, id, label, description, kind, subjectKey, audience, // ceiling, version, variables } (ENGAGEMENT.md §4.3, API 1.7.0). // // A Map rather than an array, unlike `streams`: a stream catalog is READ WHOLE // (the app renders it in registration order) and a trigger is READ BY ID (the // emit path, the rule editor, the template editor), so insertion order is kept // for display and the lookup is the primary access. const triggers = new Map() // audience id → { owner, id, label, description, params, ceiling, resolve } // (§5.1a). Its own id space, not the trigger/stream one: an audience names a set // of PEOPLE and a trigger names an EVENT, and `uo.team.members` colliding with a // trigger of the same name would be a collision between two unrelated things. const audiences = new Map() // action id → { owner, id, label, description, risk, reversible, version, // budgetMs, params, cost, perform, revert } (EVENTS.md §F, Phase 1). // // **Its own id space**, like `audiences` above and for the same kind of reason: // an action names a VERB and a trigger names an EVENT, so `uo.champ.start` as // the thing a module can be asked to do and `uo.champ.start` as the thing that // happened are two unrelated declarations that must not collide with — or // silently satisfy — each other. Nothing cross-checks this map against the // stream/trigger namespace, and nothing should. // // A Map, and read by id on the dispatch path exactly as `triggers` is; insertion // order is what the admin catalog renders in. const eventActions = new Map() // budget id → { owner, id, label, unit, description } (EVENTS.md §F, Phase 7). // // A dimension of consumption — "creatures spawned", "gate uptime" — declared so // that the switchboard's cap editor has a NAME and a UNIT to put beside a number. // Phase 6 discovered these by pricing an action's declared `example` values, // which was a stand-in that could name a dimension and never label it. // // **Its own id space**, like `eventActions` above, and §F says why in one line: // an action names a VERB and a budget names a RESOURCE. Nothing cross-checks the // two maps, and nothing should. // // Data only. There is no function on a budget and nothing here is ever called — // the module says a dimension exists and what to call it, `cost()` says how much // of it a step spends, and core owns every piece of arithmetic in between. const eventBudgets = new Map() // lease id → { owner, id, label, type, min, max, maxDurationMs, description, // read, apply, restore } (§F "Leases: one more declaration", Phase 7). // // **Core owns the duration and the conflict check; the module owns reading the // current value and writing a new one.** Phase 7 registered a lease and nothing // acquired one; Phase 8 gave it a verb — `core.lease`, a CORE action, so the // bound and the two-events-one-target refusal are enforced in one place rather // than re-implemented by every module that ships a lease. const eventLeases = new Map() // source id → { owner, id, label, description, resolve } (§F "Param option // sources", Phase 7). // // What turns an authoring field from a text box into a dropdown of real // landmarks. Modelled on `audiences` rather than on anything else here, because // it is the same shape of thing: an id, a label, and a `resolve()` core calls and // waits for. What differs is the meaning of a refusal — an audience that refuses // mails nobody, while a source that refuses degrades its field to free text with // a warning, because refusing to let an operator type a value they already know // is worse than the typo the dropdown existed to prevent. const eventOptionSources = new Map() // owner → { templates: [...], ruleGroups: [...] } (ENGAGEMENT.md Phase 11b, // decision 7). What a module ships as CONTENT rather than as contract: the // bodies its triggers render through, and the rules an operator switches on. // // Keyed by owner and not by template key, because the seeder runs per module — // a module the operator disabled is skipped whole, and a module that failed to // load never gets here at all. const engagementSeeds = new Map() let coreRegistered = false // Stream ids that predate the module system and may not carry their owner's // prefix — the exact counterpart of the loader's LEGACY_TABLE_PREFIXES, for the // exact same reason. These seven ids are stored in `notification_subs` rows and // are read by a shipped Android client; renaming them in Phase 3 would be a data // migration and a client break, so `uo` keeps them and the prefix rule stays real // for every module written after it. const LEGACY_STREAM_IDS = { uo: [ 'server.status', 'idoc.warning', 'champ.start', 'governor.election', 'vendor.sale', 'house.idoc', 'account.login', ], } // Likewise for announce legs: `towncrier` is a stored value in // announce_job_legs.leg and the body of the admin retry endpoint. const LEGACY_LEGS = { uo: ['towncrier'] } // ONE grammar for the one namespace streams and triggers share. It relaxes what // `STREAM_ID` used to allow by admitting `_` inside a segment, because the // trigger ids this contract is written for have them (`uo.house.idoc_warning`, // ENGAGEMENT.md §4.3) and two grammars over one namespace would mean an id that // is legal as a trigger and illegal as the stream it is the same event as. // Relaxation only: every id valid before is valid now, and no stored id changes. const EVENT_ID = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/ const LEG_ID = /^[a-z][a-z0-9.]{1,62}$/ // Audiences are their own id space (see the `audiences` Map), so they get their // own constant even though the grammar is the same one. const AUDIENCE_ID = EVENT_ID // Likewise event actions (EVENTS.md §F, "Actions and budgets are their own id // spaces"). One grammar, three namespaces — the constant is what makes the // namespace visible at every use site. const ACTION_ID = EVENT_ID // And three more id spaces on the same grammar, arriving with the module // contract in Phase 7. Three constants rather than three uses of ACTION_ID, for // the reason AUDIENCE_ID gets its own: the constant is what makes the namespace // visible at the use site, so a future divergence has one place to happen. const BUDGET_ID = EVENT_ID const LEASE_ID = EVENT_ID const OPTION_SOURCE_ID = EVENT_ID // A module's claim must carry its id. Core's ids are its own namespace, and the // grandfathered names are the ones that predate all of this. function namespaced(owner, name, legacy) { return owner === 'core' || name.startsWith(`${owner}.`) || (legacy[owner] || []).includes(name) } // ── Extension slots (§1.9) ───────────────────────────────────────────────── /** * Core declares an extension slot and gets the router to mount for it. * * ONLY core may declare a slot; a module may only fill one (MODULE_API.md §2.4). * That asymmetry is why this is not on the `api` object handed to a module. * * `mergeParams` so the slot's router sees the parent's `:id`. Core's own routes * on the resource are declared before the slot is mounted, so first-match-wins * gives core the path conflict, as the contract requires. * * @returns {import('express').Router} mount this at the resource, once. */ function declareSlot(slot) { if (slots.has(slot)) throw new Error(`extension slot "${slot}" already declared`) const router = express.Router({ mergeParams: true }) slots.set(slot, { router, filledBy: null }) return router } /** Does this slot exist? The loader asks, to validate `extensions` in a manifest. */ const hasSlot = (slot) => slots.has(slot) /** Who filled a slot, or null. */ const slotFilledBy = (slot) => (slots.get(slot) || {}).filledBy || null /** * Every FILLED slot, for the OpenAPI build step (swagger/slotSpecs.js). * * `router` is the slot's own stable router — the object mounted on the resource — * so the build can find it in the live express stack and recover the prefix it * hangs at without a hardcoded table. */ const filledSlots = () => [...slots.entries()] .filter(([, e]) => e.filledBy) .map(([slot, e]) => ({ slot, filledBy: e.filledBy, router: e.router, specFile: e.specFile || null })) /** * A DECLARED slot's stable router, filled or not. * * `filledSlots()` answers what the build needs — a filled slot has a spec file * to generate a fragment from. This answers what a test needs: the slot exists * from the moment core declares it at require time, and its position in the * express stack has to stay findable whether or not a module has filled it. * Before Phase 3 the two questions had the same answer, because core filled the * only slot itself. */ const declaredSlotRouter = (slot) => (slots.get(slot) || {}).router || null // ── Notification streams (§1.8) ──────────────────────────────────────────── /** The whole catalog, core's entries first, in registration order. */ const allStreams = () => streams.slice() /** Is this a stream anyone registered? Gates a subscription write. */ const isValidStream = (id) => streamOwners.has(id) /** Ids of the owner-keyed streams — those needing a linked game account. */ const personalStreams = () => new Set(streams.filter((s) => s.personal).map((s) => s.id)) // ── Post hooks (§1.8) ────────────────────────────────────────────────────── // Core's CMS is the only writer of posts, and a module may need to mirror one // somewhere core knows nothing about — module-uo keeps UO's in-game Town Cryer // News gump in step with it. Before this existed, core's post controller // required `utils/newsGump` directly, which is precisely the coupling the // extraction had to remove: core's publish path naming a UO file. // // It is deliberately NOT folded into `registerAnnounceLeg`, which fires on the // same transition. A leg is a one-shot DELIVERY with retry and classification; // a post hook maintains idempotent STATE, has to run on delete as well as save, // and refreshes silently on an edit. Overloading the leg would have meant a // dispatch that must not be retried and a classify that means nothing. /** Every registered hook, in registration order. */ const postHookEntries = () => [...postHooks.entries()].map(([owner, h]) => ({ owner, ...h })) /** * Fire `event` at every registered hook, one at a time, never throwing. * * Best-effort by contract, and awaited rather than fired-and-forgotten: core's * own call site awaited `newsGump.syncPost` before this existed, so a save that * returns 200 still means the mirror was attempted. One subscriber's failure * must not cost another's, and none of them may cost the save — a sidecar * hiccup breaking a post edit would be a worse bug than a stale gump. */ async function dispatchPostHook(event, payload) { for (const { owner, [event]: fn } of postHookEntries()) { if (typeof fn !== 'function') continue try { await fn(payload) } catch (err) { log.warn('post hook failed', { owner, event, message: err.message }) } } } // ── Announce legs (§1.8) ─────────────────────────────────────────────────── /** Every registered leg, in registration order. */ const announceLegs = () => [...legs.values()] /** Just the ids — the enqueue order and the retry endpoint's allowlist. */ const announceLegIds = () => [...legs.keys()] /** One leg, or null. */ const announceLeg = (leg) => legs.get(leg) || null // ── Team provider (TEAMS.md §2.3) ────────────────────────────────────────── /** The registered provider, or null when no module supplies one. */ const registeredTeamProvider = () => teamProvider /** Is there a Team provider at all? Read by the reconciler and the read API. */ const hasTeamProvider = () => teamProvider !== null // ── Slash commands (TEAMS.md §7.1) ───────────────────────────────────────── /** * Every registered command WITHOUT its handler — what `/internal/commands` * serves to the bot. * * The handler is stripped rather than merely un-serialisable-and-ignored: this * is the object that crosses a process boundary, and the definition half is the * whole of what the bot is allowed to know. `owner` rides along so the bot can * name the module in a collision warning. */ const slashCommandDefinitions = () => [...slashCommands.values()].map(({ handler, ...definition }) => definition) /** One command, handler included. The dispatcher's lookup. */ const slashCommand = (name) => slashCommands.get(name) || null // ── Event triggers (ENGAGEMENT.md §4.3) ──────────────────────────────────── /** Every declaration, core's first, in registration order. The admin catalog. */ const allTriggers = () => [...triggers.values()] /** One declaration, or null. The emit path's lookup and the rule editor's. */ const eventTrigger = (id) => triggers.get(id) || null /** * Who owns this id, across BOTH facets — the one-namespace question. * * A caller asking "may this module emit this?" wants this rather than * `eventTrigger(id).owner`, because an id can be held as a stream by one owner * and not yet declared as a trigger by anyone, and that id is still taken. */ const eventOwner = (id) => triggers.get(id)?.owner || streamOwners.get(id) || null // ── Audiences (§5.1a) ────────────────────────────────────────────────────── /** * Every declaration WITHOUT its resolver — what the admin surface serves. * * The resolver is stripped for the same reason a slash command's handler is: * this is the object that leaves the process, and `resolve` is a function over a * module's own store that no client has any business holding a reference to. */ const allAudiences = () => [...audiences.values()].map(({ resolve, ...rest }) => rest) /** One declaration, resolver included. The engine's lookup. */ const audience = (id) => audiences.get(id) || null /** * Resolve a declared audience to user ids, never throwing. * * Three answers, and the middle one is the contract (§5.1a rule 4): a registered * audience answers `{ dormant: false, userIds }`; an audience whose module is * uninstalled answers `{ dormant: true, userIds: [] }` — the EMPTY set and a * flag, never an error and never a fallback to some other set of people; and a * resolver that throws or answers a non-array is logged and treated as empty, * because a module's storage problem must not become a send to the wrong people. * * `userIds` is filtered to positive integers here rather than trusted. It is the * one value a module hands core that decides who receives mail, and the resolver * is module code running over a module's own store. */ async function resolveAudience(id, params = {}) { const entry = audiences.get(id) if (!entry) return { dormant: true, userIds: [] } try { const raw = await entry.resolve(params) if (!Array.isArray(raw)) { log.warn('audience resolver did not return an array', { audience: id, owner: entry.owner }) return { dormant: false, userIds: [] } } const userIds = [...new Set(raw.map(Number).filter((n) => Number.isInteger(n) && n > 0))] return { dormant: false, userIds } } catch (err) { log.error('audience resolver failed', { audience: id, owner: entry.owner, message: err.message }) return { dormant: false, userIds: [] } } } // ── Event actions (EVENTS.md §F) ─────────────────────────────────────────── /** * Every declaration WITHOUT its callables — what the admin catalog serves. * * `perform`, `revert`, `reconcile` and `cost` are stripped for the same reason `resolve` is * stripped from an audience and `handler` from a slash command: this is the * object that leaves the process, and the browser's whole relationship with an * action is naming one by id. §F's "a module registers actions server-side and * adds no routes for them" is only true if the functions never ride out. */ const allEventActions = () => [...eventActions.values()].map(({ perform, revert, reconcile, cost, ...rest }) => rest) /** One declaration, callables included. The runner's lookup (Phase 2). */ const eventAction = (id) => eventActions.get(id) || null /** * Does anyone register this id right now? * * The authoring path's question, and it is deliberately not `eventAction(id) !== * null` at every call site: a step naming an action whose module is uninstalled * is DORMANT, not an error (§F), and the difference between "never existed" and * "not installed today" is a distinction only the caller can draw. */ const isEventAction = (id) => eventActions.has(id) // ── Event budgets, leases and option sources (§F, Phase 7) ───────────── /** Every declared budget dimension, in registration order. */ const allEventBudgets = () => [...eventBudgets.values()] /** One dimension's declaration, or null. The label-and-unit lookup. */ const eventBudget = (id) => eventBudgets.get(id) || null /** * Does anyone declare this dimension right now? * * The fail-closed question (org lead, 2026-09-03): a `cost()` naming a dimension * nobody registered is REFUSED — at save, at the dry run and at dispatch. §F's * *"a module cannot spend a budget it did not declare"* is only true if something * asks, and this is what asks. */ const isEventBudget = (id) => eventBudgets.has(id) /** * Every lease declaration WITHOUT its callables — what the catalog serves. * * Stripped for the reason `perform` is stripped from an action: this object * leaves the process, and the browser's whole relationship with a lease is naming * one by id. */ const allEventLeases = () => [...eventLeases.values()].map(({ read, apply: applyValue, restore, inForce, ...rest }) => rest) /** One lease, callables included. `core.lease` and the cleanup sweep read it. */ const eventLease = (id) => eventLeases.get(id) || null /** Every option source WITHOUT its resolver — the authoring form's list. */ const allEventOptionSources = () => [...eventOptionSources.values()].map(({ resolve, ...rest }) => rest) /** * Resolve one option source, or say why not. Never throws. * * **A refusal is not an error here, and that is the design.** §F: a source that * cannot answer degrades its field to free text with a visible warning rather * than blocking the form. So every failure shape — no such source, a throw, a * rejected promise, a non-array — comes back as `{ ok: false, reason }` and the * caller renders a text box. The alternative is an authoring screen that a * module's outage can make unusable, for a field whose value the operator very * often already knows. * * The options are normalised rather than trusted. This array is rendered into a * `