// ── 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. // // 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') // ── 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() 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'] } const STREAM_ID = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$/ const LEG_ID = /^[a-z][a-z0-9.]{1,62}$/ // 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 })) // ── 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)) // ── 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 // ── Shape checks, run the moment a registrant calls ──────────────────────── // // Split from the collision checks below on the same line PR 3 drew through // schema-fragment validation: what can be decided from the argument alone is // decided AT THE CALL, so the error carries the registrant's own stack. What // depends on other registrants has to wait for the batch to be complete. function checkStreamShape(entry) { if (!entry || !STREAM_ID.test(entry.id || '')) { throw new Error(`registerNotificationStreams: bad stream id "${entry && entry.id}"`) } if (!entry.label) throw new Error(`registerNotificationStreams: stream "${entry.id}" has no label`) return { id: entry.id, label: entry.label, description: entry.description || '', personal: Boolean(entry.personal), requiresLinkedAccount: Boolean(entry.requiresLinkedAccount), } } function checkLegShape(entry) { const { leg, label, dispatch, classify } = entry || {} if (!LEG_ID.test(leg || '')) throw new Error(`registerAnnounceLeg: bad leg id "${leg}"`) if (typeof dispatch !== 'function') throw new Error(`announce leg "${leg}" has no dispatch()`) if (typeof classify !== 'function') throw new Error(`announce leg "${leg}" has no classify()`) return { leg, label: label || leg, dispatch, classify } } // `specFile` is CORE-ONLY and is not on the module-facing signature. A slot's // router reaches the app through declareSlot(), which no static parse of app.js // can follow, so swagger-autogen would silently drop every route in it — the // spike's exact failure (MODULE_API.md §7.4). Core names the file so // `npm run swagger` can generate a fragment from it and merge it into the // committed spec. A MODULE has no equivalent need: it ships a prebuilt // `swagger-fragment.json` in its bundle (§6.1a), because core never has its // sources to analyse. function checkExtensionShape(slot, router, specFile) { if (!slots.has(slot)) throw new Error(`unknown extension slot "${slot}"`) if (typeof router !== 'function') throw new Error(`registerExtension: ${slot} is not a router`) return { slot, router, specFile: specFile || null } } // ── Staging + commit ─────────────────────────────────────────────────────── /** * A registrant's staging area: shape-checked claims, not yet visible to anyone. * * The loader hands one of these to a module through `api`, and `registerCore()` * builds one for core. Nothing a registrant says is readable through * `allStreams()` / `announceLeg()` / the slot routers until `apply()`. */ function stage(owner) { const staged = { owner, streams: [], legs: [], extensions: [] } return { staged, registerNotificationStreams(entries) { if (!Array.isArray(entries)) throw new Error('registerNotificationStreams: expected an array') for (const e of entries) staged.streams.push(checkStreamShape(e)) }, registerAnnounceLeg(entry) { staged.legs.push(checkLegShape(entry)) }, registerExtension(slot, router, specFile) { staged.extensions.push(checkExtensionShape(slot, router, specFile)) }, } } /** * Validate a staged batch against everything already registered, then commit it. * * Validation is TOTAL before the first write, so this either takes all of a * registrant's claims or none of them. Throws on the first collision, naming who * holds the thing already — which is the message an operator needs and the one * PR 2 learned to protect (mounting inside the scan loop made every collision * look like it was with core). */ function apply({ owner, streams: newStreams, legs: newLegs, extensions: newExtensions }) { // ── validate ── const seenStreams = new Set() for (const s of newStreams) { const held = streamOwners.get(s.id) if (held) throw new Error(`stream "${s.id}" is already registered by "${held}"`) if (seenStreams.has(s.id)) throw new Error(`stream "${s.id}" registered twice`) if (!namespaced(owner, s.id, LEGACY_STREAM_IDS)) { throw new Error(`stream "${s.id}" is not namespaced "${owner}."`) } seenStreams.add(s.id) } const seenLegs = new Set() for (const l of newLegs) { const held = legs.get(l.leg) if (held) throw new Error(`announce leg "${l.leg}" is already registered by "${held.owner}"`) if (seenLegs.has(l.leg)) throw new Error(`announce leg "${l.leg}" registered twice`) if (!namespaced(owner, l.leg, LEGACY_LEGS)) { throw new Error(`announce leg "${l.leg}" is not namespaced "${owner}."`) } seenLegs.add(l.leg) } const seenSlots = new Set() for (const x of newExtensions) { const entry = slots.get(x.slot) if (entry.filledBy) { throw new Error(`extension slot "${x.slot}" is already filled by "${entry.filledBy}"`) } if (seenSlots.has(x.slot)) throw new Error(`extension slot "${x.slot}" filled twice`) seenSlots.add(x.slot) } // ── commit — nothing below can fail ── for (const s of newStreams) { streamOwners.set(s.id, owner) streams.push(s) } for (const l of newLegs) legs.set(l.leg, { owner, ...l }) for (const x of newExtensions) { const entry = slots.get(x.slot) entry.filledBy = owner entry.specFile = x.specFile entry.router.use(x.router) } } // ── Core's own registrations ─────────────────────────────────────────────── /** * Register everything CORE owns, through the same staging area a module uses. * * Called once from app.js, before `modules.load()` — before, because a module's * collision checks are asked against what is already registered, and core's * claims must be the ones already there. * * What is here is what survives Phase 3. Everything after the boundary comment is * shard content and leaves with module-uo, registered rather than hardcoded so * the seam is exercised on every boot long before a module first uses it. */ function registerCore() { if (coreRegistered) return /* eslint-disable global-require */ const coreStreams = require('../config/coreStreams') const discordLeg = require('../utils/discordAnnounce') const shardStreams = require('../config/shardStreams') const townCrierLeg = require('../utils/shardAnnounce') const shardExtension = require('../router/v1/admin/usersShard.router') /* eslint-enable global-require */ const api = stage('core') api.registerNotificationStreams(coreStreams.STREAMS) api.registerAnnounceLeg(discordLeg.leg) // ── Phase 3 boundary ──────────────────────────────────────────────────── // These three lines become module-uo's register() body, with 'core' becoming // 'uo'. Nothing else in core has to change for that to happen — which is the // whole claim PR 4 is making. api.registerNotificationStreams(shardStreams.STREAMS) api.registerAnnounceLeg(townCrierLeg.leg) api.registerExtension('admin.users.detail', shardExtension, require.resolve('../router/v1/admin/usersShard.router')) apply(api.staged) coreRegistered = true log.info('core registrations complete', { streams: streams.length, announceLegs: legs.size, extensions: [...slots.keys()].filter(slotFilledBy), }) } /** Has registerCore() run? Read by tests, and by the loader's ordering assertion. */ const isCoreRegistered = () => coreRegistered // Test-only: hand the process back. Registries are process-global by design // (there is one core), so a test that registers has to be able to undo it. // // Slot DECLARATIONS survive, and only their fills are cleared: a slot is declared // at require time by the router that owns the resource, and that require has // already happened and will not happen again in this process. Clearing the map // would leave a slot that nothing can re-declare. The cost is that a test filling // the same slot twice stacks two routers inside it; no test reads through a slot // router, so that is left rather than papered over with a rebuilt router that // would no longer be the object users.router.js mounted. function _reset() { for (const entry of slots.values()) { entry.filledBy = null entry.specFile = null } streams.length = 0 streamOwners.clear() legs.clear() coreRegistered = false } module.exports = { declareSlot, hasSlot, slotFilledBy, filledSlots, allStreams, isValidStream, personalStreams, announceLegs, announceLegIds, announceLeg, stage, apply, registerCore, isCoreRegistered, _reset, }