Phase 7 of TEAMS.md. `api.registerSlashCommands` stops throwing: a module registers a command's DEFINITION and its HANDLER together, the bot pulls the definitions over the internal listener and runs none of our code, and the handler executes here — forced by the bot container having no `modules` volume, and the right boundary anyway. Registration validates what Discord would reject as a batch (names, description lengths, the four option types, required-before-optional), because the bot registers the whole set in one PUT and a single bad entry costs every command including the bot's own. Commands are not namespaced under their owner — there is no dot in Discord's name grammar — so collisions are first-come with the holder named. The dispatcher is the access boundary: `linked` has no Discord equivalent, so the platform-side permission default can only ever be advertising. It resolves the actor by `auth_providers.kind` rather than the id slug, treats a banned account as unlinked, bounds a handler under the bot's own timeout, and keeps `ok` outside the envelope so a handler cannot forge it. Liveness is asked at both the pull and the dispatch. The registries have no removal path, so a module an operator disables at runtime would otherwise keep a live handler behind a command Discord still advertises. Co-Authored-By: Claude <noreply@anthropic.com>
681 lines
31 KiB
JavaScript
681 lines
31 KiB
JavaScript
// ── 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()
|
|
|
|
// 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()
|
|
|
|
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 }))
|
|
|
|
/**
|
|
* 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
|
|
|
|
// ── 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 }
|
|
}
|
|
|
|
// Three methods are REQUIRED, with no optional half. A provider that could list
|
|
// Teams but not their members would leave core holding Teams it can never
|
|
// populate, and the reconciler has no sensible behaviour for that — it is not the
|
|
// same as a call that fails, which is staleness and already handled (§2.4). A
|
|
// module unable to answer one of the three answers `{ ok: false }` at call time.
|
|
//
|
|
// `projectRoster` is the fourth and is OPTIONAL (TEAMS.md §3.3): it expresses an
|
|
// audience model, and a module with no rung system of its own has no opinion to
|
|
// express. Omitting it means core serves rosters at its own public shape;
|
|
// implementing it means core fails CLOSED when the call cannot be made, so this
|
|
// is a member to add deliberately rather than by habit.
|
|
//
|
|
// `pageUrlTemplate` is the fifth, also OPTIONAL, and is data rather than a method
|
|
// — see its own comment below. A module that omits it costs its deployment
|
|
// clickable links in Team notification email and nothing else.
|
|
//
|
|
// The copy is explicit rather than a spread: this object is what core calls, so
|
|
// anything not named here is not part of the contract and must not survive
|
|
// registration. A method that silently rode along would look implemented from the
|
|
// module's side and be invisible from core's.
|
|
function checkTeamProviderShape(entry) {
|
|
const provider = entry || {}
|
|
const out = {}
|
|
for (const name of ['getTeams', 'getTeamMembers', 'getTeamLeaders']) {
|
|
if (typeof provider[name] !== 'function') {
|
|
throw new Error(`registerTeamProvider: ${name}() is missing or not a function`)
|
|
}
|
|
out[name] = provider[name]
|
|
}
|
|
if (provider.projectRoster !== undefined) {
|
|
if (typeof provider.projectRoster !== 'function') {
|
|
throw new Error('registerTeamProvider: projectRoster must be a function if present')
|
|
}
|
|
out.projectRoster = provider.projectRoster
|
|
}
|
|
if (provider.pageUrlTemplate !== undefined) {
|
|
out.pageUrlTemplate = checkPageUrlTemplate(provider.pageUrlTemplate)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// `pageUrlTemplate` is the fifth member and OPTIONAL (TEAMS.md §6.4, phase 6).
|
|
//
|
|
// **Why a module has to supply this at all.** Teams are a contract primitive with
|
|
// no core surface: core owns the tables and the access rules, and the MODULE owns
|
|
// the page, because core does not own the word for a Team. That is settled and
|
|
// right — but it leaves core unable to write a link to one, and a notification
|
|
// email that cannot link to the thread it is about is most of the way to useless.
|
|
// So the module that owns the page says where it is.
|
|
//
|
|
// **A template, not a callback.** Core substitutes `{externalId}` and `{slug}`
|
|
// into a relative path and does nothing else with it. A function would be a
|
|
// module hook on the mail path — one more thing that can hang or throw between a
|
|
// forum reply and the mail about it — to produce a string that never varies.
|
|
//
|
|
// Validated hard, because the output goes into an email as a link. Relative only:
|
|
// a template naming its own host would let a module redirect the site's outbound
|
|
// mail somewhere else, and there is no reason for one to.
|
|
// One leading slash, and the second character may not be another. `//evil.test/x`
|
|
// passes an "is it rooted" check and is a PROTOCOL-RELATIVE url — core prefixing
|
|
// its own base makes it harmless today, but a template is a string that ends up
|
|
// in an href sooner or later, and this is a character class rather than a
|
|
// judgement call about who concatenates it.
|
|
const PAGE_URL_TEMPLATE = /^\/(?!\/)[A-Za-z0-9\-._~/{}]*$/
|
|
|
|
function checkPageUrlTemplate(value) {
|
|
if (typeof value !== 'string' || !PAGE_URL_TEMPLATE.test(value)) {
|
|
throw new Error(`registerTeamProvider: pageUrlTemplate must be a relative path, got "${value}"`)
|
|
}
|
|
return value
|
|
}
|
|
|
|
// A slash command's name and description are validated HERE and not only at the
|
|
// bot, for a reason worth stating: the bot registers the whole set in a single
|
|
// `REST.put(applicationGuildCommands)`, so ONE malformed definition is rejected
|
|
// by Discord as a batch and takes every other command down with it — including
|
|
// the bot's own. A definition that cannot be registered must therefore fail at
|
|
// `register()`, where it belongs to a module that can be named and marked
|
|
// failed, rather than at the next `ready` where it looks like the bot is broken.
|
|
//
|
|
// **Commands are NOT namespaced under their owner, unlike every other id in this
|
|
// file.** Discord's name grammar has no `.` in it, so `uo.guild` is unregistrable
|
|
// and the prefix rule cannot be expressed. Collisions are caught by first-come
|
|
// instead, with the holder named — and the bot resolves the one collision core
|
|
// cannot see (a pulled name against its own built-ins) in the module's disfavour.
|
|
const SLASH_NAME = /^[a-z0-9_-]{1,32}$/
|
|
const SLASH_ACCESS = ['everyone', 'linked', 'staff']
|
|
|
|
// §7.1.1: `string | integer | boolean | user`, and deliberately nothing else. No
|
|
// subcommand groups, autocomplete, attachments, modals or component
|
|
// interactions. Those are exactly the features whose semantics do not survive a
|
|
// second platform, and admitting one here is how Discord specifics leak into a
|
|
// platform-agnostic registration API by accident.
|
|
const SLASH_OPTION_TYPES = ['string', 'integer', 'boolean', 'user']
|
|
|
|
function checkSlashOption(command, option) {
|
|
const { name, type, description, required, choices } = option || {}
|
|
const where = `registerSlashCommands: ${command}`
|
|
if (!SLASH_NAME.test(name || '')) throw new Error(`${where}: bad option name "${name}"`)
|
|
if (!SLASH_OPTION_TYPES.includes(type)) {
|
|
throw new Error(`${where}: option "${name}" has unsupported type "${type}" (§7.1.1)`)
|
|
}
|
|
if (!description || description.length > 100) {
|
|
throw new Error(`${where}: option "${name}" needs a description of 1-100 characters`)
|
|
}
|
|
const out = { name, type, description, required: Boolean(required) }
|
|
if (choices !== undefined) {
|
|
if (!Array.isArray(choices) || !choices.length) {
|
|
throw new Error(`${where}: option "${name}" has an empty choices list`)
|
|
}
|
|
// Only the two option types Discord itself allows choices on. `boolean` is
|
|
// already a two-value choice and `user` is a picker; a choices list on
|
|
// either is a misunderstanding worth failing rather than dropping.
|
|
if (type !== 'string' && type !== 'integer') {
|
|
throw new Error(`${where}: option "${name}" is ${type}; choices need string or integer`)
|
|
}
|
|
out.choices = choices.map((c) => {
|
|
if (!c || !c.name || c.value === undefined) {
|
|
throw new Error(`${where}: option "${name}" has a choice with no name/value`)
|
|
}
|
|
return { name: String(c.name), value: c.value }
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* `registerSlashCommands([{ name, description, options, access, handler }])`.
|
|
*
|
|
* `access` is enforced TWICE and this copy is not the gate: the bot sets
|
|
* Discord-side default member permissions from it where it can, and the
|
|
* dispatcher re-checks it on every call. Client-side is about not advertising a
|
|
* dead end; the server is the boundary — the same principle the nav follows.
|
|
*/
|
|
function checkSlashCommandShape(entry) {
|
|
const { name, description, options, access, handler } = entry || {}
|
|
if (!SLASH_NAME.test(name || '')) {
|
|
throw new Error(`registerSlashCommands: bad command name "${name}" (lowercase, 1-32, no dots)`)
|
|
}
|
|
if (!description || description.length > 100) {
|
|
throw new Error(`registerSlashCommands: ${name} needs a description of 1-100 characters`)
|
|
}
|
|
if (typeof handler !== 'function') throw new Error(`registerSlashCommands: ${name} has no handler()`)
|
|
if (access !== undefined && !SLASH_ACCESS.includes(access)) {
|
|
throw new Error(`registerSlashCommands: ${name} has unknown access "${access}"`)
|
|
}
|
|
if (options !== undefined && !Array.isArray(options)) {
|
|
throw new Error(`registerSlashCommands: ${name} options must be an array`)
|
|
}
|
|
const checked = (options || []).map((o) => checkSlashOption(name, o))
|
|
// Discord rejects a definition that puts an optional option before a required
|
|
// one, and does it for the whole batch. Sorting silently would change what the
|
|
// module wrote; this is the module's own ordering bug and it gets its name.
|
|
const firstOptional = checked.findIndex((o) => !o.required)
|
|
if (firstOptional !== -1 && checked.slice(firstOptional).some((o) => o.required)) {
|
|
throw new Error(`registerSlashCommands: ${name} lists a required option after an optional one`)
|
|
}
|
|
return { name, description, options: checked, access: access || 'everyone', handler }
|
|
}
|
|
|
|
/**
|
|
* `registerPostHook({ onSaved, onDeleted })` — both optional, at least one
|
|
* required. A registration with neither is a subscription that can never fire,
|
|
* which is a typo rather than an intention.
|
|
*/
|
|
function checkPostHookShape(entry) {
|
|
const { onSaved, onDeleted } = entry || {}
|
|
for (const [name, fn] of [['onSaved', onSaved], ['onDeleted', onDeleted]]) {
|
|
if (fn !== undefined && typeof fn !== 'function') {
|
|
throw new Error(`registerPostHook: ${name} must be a function`)
|
|
}
|
|
}
|
|
if (!onSaved && !onDeleted) throw new Error('registerPostHook: needs onSaved or onDeleted')
|
|
return { onSaved, onDeleted }
|
|
}
|
|
|
|
// `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: [], postHooks: [], teamProviders: [], slashCommands: [],
|
|
}
|
|
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))
|
|
},
|
|
registerPostHook(entry) {
|
|
staged.postHooks.push(checkPostHookShape(entry))
|
|
},
|
|
registerTeamProvider(entry) {
|
|
staged.teamProviders.push(checkTeamProviderShape(entry))
|
|
},
|
|
registerSlashCommands(entries) {
|
|
if (!Array.isArray(entries)) throw new Error('registerSlashCommands: expected an array')
|
|
for (const e of entries) staged.slashCommands.push(checkSlashCommandShape(e))
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
postHooks: newPostHooks = [],
|
|
teamProviders: newTeamProviders = [],
|
|
slashCommands: newSlashCommands = [],
|
|
}) {
|
|
// ── 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)
|
|
}
|
|
|
|
if (newPostHooks.length > 1) throw new Error(`"${owner}" registered more than one post hook`)
|
|
if (newPostHooks.length && postHooks.has(owner)) {
|
|
throw new Error(`"${owner}" already registered a post hook`)
|
|
}
|
|
|
|
if (newTeamProviders.length > 1) throw new Error(`"${owner}" registered more than one team provider`)
|
|
if (newTeamProviders.length && teamProvider) {
|
|
throw new Error(`a team provider is already registered by "${teamProvider.owner}"`)
|
|
}
|
|
|
|
const seenCommands = new Set()
|
|
for (const c of newSlashCommands) {
|
|
const held = slashCommands.get(c.name)
|
|
if (held) throw new Error(`slash command "/${c.name}" is already registered by "${held.owner}"`)
|
|
if (seenCommands.has(c.name)) throw new Error(`slash command "/${c.name}" registered twice`)
|
|
seenCommands.add(c.name)
|
|
}
|
|
|
|
// ── 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)
|
|
}
|
|
for (const h of newPostHooks) postHooks.set(owner, h)
|
|
for (const p of newTeamProviders) teamProvider = { owner, ...p }
|
|
for (const c of newSlashCommands) slashCommands.set(c.name, { owner, ...c })
|
|
}
|
|
|
|
// ── 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')
|
|
/* eslint-enable global-require */
|
|
|
|
const api = stage('core')
|
|
api.registerNotificationStreams(coreStreams.STREAMS)
|
|
api.registerAnnounceLeg(discordLeg.leg)
|
|
|
|
// The three lines that used to follow — the shard stream catalog, the town
|
|
// crier leg and the `admin.users.detail` filling — were shard CONTENT held
|
|
// here so the seam would be exercised on every boot before a module first used
|
|
// it. Phase 3 moved them into module-uo's `register()` verbatim, with 'core'
|
|
// becoming 'uo', and nothing else in core changed. That was the claim PR 4
|
|
// made, and this deletion is it being collected.
|
|
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()
|
|
postHooks.clear()
|
|
teamProvider = null
|
|
slashCommands.clear()
|
|
coreRegistered = false
|
|
}
|
|
|
|
module.exports = {
|
|
declareSlot,
|
|
hasSlot,
|
|
slotFilledBy,
|
|
filledSlots,
|
|
declaredSlotRouter,
|
|
allStreams,
|
|
isValidStream,
|
|
personalStreams,
|
|
announceLegs,
|
|
announceLegIds,
|
|
announceLeg,
|
|
postHookEntries,
|
|
dispatchPostHook,
|
|
registeredTeamProvider,
|
|
hasTeamProvider,
|
|
slashCommandDefinitions,
|
|
slashCommand,
|
|
stage,
|
|
apply,
|
|
registerCore,
|
|
isCoreRegistered,
|
|
_reset,
|
|
}
|