// ── 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() // 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 // 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: [] } } } // ── 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 || !EVENT_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 } } // ── Event trigger shape (ENGAGEMENT.md §4.3) ─────────────────────────────── // Deliberately small, and closed. A payload variable ends up interpolated into // an email, so the set is "things a template can render and a preview can fake", // not "things JSON can hold". No `object` and no `array`: a template that has to // walk a structure is a template that has outgrown interpolation, and a block // type is the right answer to that (§4.4). const VARIABLE_TYPES = ['string', 'int', 'float', 'boolean', 'datetime', 'url'] // `event` fires from ctx.events.emit; `scheduled` is evaluated periodically and // has no evaluator yet — the org lead's §7.1 Q6 answer is design now, build after // Phase 9. It is declarable from today so `kind` is in the contract, the manifest // and every stored declaration before there are rows to migrate. const TRIGGER_KINDS = ['event', 'scheduled'] const VARIABLE_NAME = /^[a-z][A-Za-z0-9]{0,39}$/ function checkTriggerVariable(triggerId, entry, seen) { const { name, type, required, example, description } = entry || {} const where = `registerEventTriggers: ${triggerId}` if (!VARIABLE_NAME.test(name || '')) throw new Error(`${where}: bad variable name "${name}"`) if (seen.has(name)) throw new Error(`${where}: variable "${name}" declared twice`) seen.add(name) if (!VARIABLE_TYPES.includes(type)) { throw new Error(`${where}: variable "${name}" has unsupported type "${type}"`) } // REQUIRED, and the one field of this shape that looks optional and is not // (§4.3 property 3). Without an example, previewing or test-sending a template // needs a live game event — which is exactly how template systems come to be // shipped untested. It is cheap to write at declaration time and impossible to // reconstruct later. if (example === undefined || example === null || example === '') { throw new Error(`${where}: variable "${name}" needs an example (§4.3 — it is the preview)`) } return { name, type, required: Boolean(required), example, description: description || '', } } /** * `registerEventTriggers([{ id, label, kind, subjectKey, audience, ceiling, version, variables }])`. * * Everything decidable from the argument alone is decided here, at the call, so * the error carries the registrant's own stack. The one-namespace collision — is * this id already someone's stream? — depends on other registrants and waits for * `apply()`, exactly as a stream's own collision does. * * The copy is explicit rather than a spread, like `checkTeamProviderShape`: this * object is served to the admin UI and frozen into a committed manifest, so * anything not named here is not part of the contract and must not ride along. */ function checkTriggerShape(entry) { const t = entry || {} if (!EVENT_ID.test(t.id || '')) { throw new Error(`registerEventTriggers: bad trigger id "${t.id}"`) } if (!t.label) throw new Error(`registerEventTriggers: trigger "${t.id}" has no label`) const kind = t.kind || 'event' if (!TRIGGER_KINDS.includes(kind)) { throw new Error(`registerEventTriggers: ${t.id} has unknown kind "${t.kind}"`) } // G24. Required with no default — a ceiling that could be forgotten is a // ceiling that gets forgotten on the one trigger it mattered for, and there is // no safe value to guess: `owner` would silently break a broadcast and // `authenticated` would silently widen a staff-only event. if (!ceilings.isCeiling(t.ceiling)) { throw new Error( `registerEventTriggers: ${t.id} needs a ceiling, one of ${ceilings.CEILINGS.join(', ')}`, ) } // The DEFAULT a rule is created with; the ceiling is the maximum it may be // raised to. Defaulting it to the ceiling is right — a trigger that declares no // opinion gets the widest it permits, and an operator narrows from there. const audienceDefault = t.audience || t.ceiling if (!ceilings.permits(t.ceiling, audienceDefault)) { throw new Error( `registerEventTriggers: ${t.id} default audience "${audienceDefault}" is not permitted by ceiling "${t.ceiling}"`, ) } const version = t.version === undefined ? 1 : t.version if (!Number.isInteger(version) || version < 1) { throw new Error(`registerEventTriggers: ${t.id} has a bad version "${t.version}"`) } if (t.variables !== undefined && !Array.isArray(t.variables)) { throw new Error(`registerEventTriggers: ${t.id} variables must be an array`) } const seen = new Set() const variables = (t.variables || []).map((v) => checkTriggerVariable(t.id, v, seen)) // A subjectKey naming a variable that does not exist would produce a cooldown // keyed on `undefined` — i.e. one cooldown for every subject at once, which // looks like the feature working until the day two houses share it (§4.1). if (t.subjectKey !== undefined && !seen.has(t.subjectKey)) { throw new Error( `registerEventTriggers: ${t.id} subjectKey "${t.subjectKey}" is not one of its variables`, ) } return { id: t.id, label: t.label, description: t.description || '', kind, subjectKey: t.subjectKey === undefined ? null : t.subjectKey, audience: audienceDefault, ceiling: t.ceiling, version, variables, } } // ── Audience shape (§5.1a) ───────────────────────────────────────────────── // Two types, and no more. A param is something an operator types into a rule // editor to point a declared audience at one row of a module's data ("which // Team?"), so it is an identifier or a word. Anything richer is a query, and a // query surface is the free-form list building Q7 rules out. const AUDIENCE_PARAM_TYPES = ['int', 'string'] function checkAudienceParam(audienceId, entry, seen) { const { id, type, required, label } = entry || {} const where = `registerAudiences: ${audienceId}` if (!VARIABLE_NAME.test(id || '')) throw new Error(`${where}: bad param id "${id}"`) if (seen.has(id)) throw new Error(`${where}: param "${id}" declared twice`) seen.add(id) if (!AUDIENCE_PARAM_TYPES.includes(type)) { throw new Error(`${where}: param "${id}" has unsupported type "${type}"`) } return { id, type, required: Boolean(required), label: label || id } } /** * `registerAudiences([{ id, label, description, params, ceiling, resolve }])`. * * The resolver returns USER IDS and nothing else (§5.1a rule 2). It is not handed * a template, a channel or an address and it cannot enumerate them — a module * still cannot send mail, and this must not become the back door that lets it. * Core maps ids to addresses on its own side, after preferences, suppression and * the verification gate. */ function checkAudienceShape(entry) { const a = entry || {} if (!AUDIENCE_ID.test(a.id || '')) throw new Error(`registerAudiences: bad audience id "${a.id}"`) if (!a.label) throw new Error(`registerAudiences: audience "${a.id}" has no label`) if (!ceilings.isCeiling(a.ceiling)) { throw new Error( `registerAudiences: ${a.id} needs a ceiling, one of ${ceilings.CEILINGS.join(', ')}`, ) } if (typeof a.resolve !== 'function') throw new Error(`registerAudiences: ${a.id} has no resolve()`) if (a.params !== undefined && !Array.isArray(a.params)) { throw new Error(`registerAudiences: ${a.id} params must be an array`) } const seen = new Set() const params = (a.params || []).map((p) => checkAudienceParam(a.id, p, seen)) return { id: a.id, label: a.label, description: a.description || '', params, ceiling: a.ceiling, resolve: a.resolve, } } // ── Engagement seeds (Phase 11b, decision 7) ─────────────────────────────── // // **Two mechanisms, and the asymmetry between them is the whole design.** // // A TEMPLATE is re-ensured on every boot. Its row carries `seed_key`, // `seed_version` and `customized`, so re-ensuring is how a better default // reaches a deployment without stealing an operator's edit (§4.6.1 property 3), // and a template added in a later module version reaches every deployment rather // than only fresh ones. // // A RULE is the opposite. Re-ensuring one would resurrect a rule an operator // deleted and reset one they enabled — so rules arrive in named GROUPS, each // with its own one-shot settings guard. That is 11a's seed-key finding stated as // an API instead of as a warning: appending a rule to an existing group reaches // fresh installs only, and a rule that must reach deployments already stamped // takes a NEW group. The module names its groups, so the module makes that // choice knowingly. // // Everything below is a shape check. Nothing here writes: `engagement/ // moduleSeeds.js` does, through the same `seedOne` and the same block validator // core's own seeds go through. // A module template key must be namespaced to its owner, for the same reason a // trigger id must: `engagement_templates.key` is UNIQUE across the table, so an // unprefixed `notify.event` from a module would collide with core's — and win or // lose depending on boot order, which is the worst of both. const TEMPLATE_KEY = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/ const MAX_TEMPLATE_KEY = 96 const SEED_GROUP_KEY = /^[a-z][a-z0-9]*(?:[-.][a-z0-9]+)*$/ // The channels a seeded template may target. Deliberately a literal rather than // a read of the channel registry: this runs at registration time, which is // before any channel a module might add is registered, and a seed for a channel // nothing delivers is a row an operator can never use. const SEEDABLE_CHANNELS = ['email', 'inapp'] // Core's own seed keys, which a module's rule MAY point at — that is §4.6.1 // property 1 in force, and the nine plain bodies of decision 9 are exactly this. // Required lazily-safe: `templateSeeds` is pure data with no requires of its own. // eslint-disable-next-line global-require const coreTemplateKeys = () => new Set(require('../engagement/templateSeeds').SEEDS.map((s) => s.key)) function checkSeedTemplate(owner, entry) { const t = entry || {} const where = `registerEngagementSeeds: template "${t.key}"` if (!TEMPLATE_KEY.test(t.key || '') || t.key.length > MAX_TEMPLATE_KEY) { throw new Error(`registerEngagementSeeds: bad template key "${t.key}"`) } if (!t.key.startsWith(`${owner}.`)) { throw new Error(`${where} is not namespaced "${owner}."`) } if (!t.name) throw new Error(`${where} has no name`) if (!SEEDABLE_CHANNELS.includes(t.channel)) { throw new Error(`${where} has unknown channel "${t.channel}" (one of ${SEEDABLE_CHANNELS.join(', ')})`) } if (!Array.isArray(t.blocks) || !t.blocks.length) throw new Error(`${where} has no blocks`) if (!Number.isInteger(t.seedVersion) || t.seedVersion < 1) { throw new Error(`${where} needs an integer seedVersion of 1 or more`) } // An email body without a subject is a mail with an empty subject line, which // no operator meant; an in-app body WITH one is a column the inbox does not // read (`inapp.event` leaves it NULL and says why). if (t.channel === 'email' && !t.subject) throw new Error(`${where} is an email body with no subject`) if (t.channel !== 'email' && t.subject) { throw new Error(`${where} is a ${t.channel} body and cannot carry a subject`) } // `protected` is core's alone. It means "the system breaks without this body", // which is true of a password reset and true of nothing a module ships; a // module marking its own template undeletable is a module taking an operator's // delete button away. if (t.protected) throw new Error(`${where} may not be protected — that flag is core's`) return { key: t.key, name: t.name, channel: t.channel, subject: t.subject || null, blocks: t.blocks, seedVersion: t.seedVersion, triggerId: t.triggerId || null, triggerVersion: Number.isInteger(t.triggerVersion) ? t.triggerVersion : null, protected: false, status: 'published', } } function checkSeedRule(owner, entry, ownTemplateKeys, coreKeys) { const r = entry || {} const where = `registerEngagementSeeds: rule for "${r.trigger_id}"` if (!EVENT_ID.test(r.trigger_id || '')) { throw new Error(`registerEngagementSeeds: bad rule trigger_id "${r.trigger_id}"`) } // A module seeds rules for ITS OWN triggers. Shipping one for core's — or for // another module's — would mean uninstalling this module leaves a rule behind // that nobody can explain, and two modules could ship two rules for the same // event with neither aware of the other. if (!namespaced(owner, r.trigger_id, LEGACY_STREAM_IDS)) { throw new Error(`${where} is not namespaced "${owner}."`) } if (!r.name) throw new Error(`${where} has no name`) if (!Array.isArray(r.channels) || !r.channels.length) throw new Error(`${where} has no channels`) if (!r.audience) throw new Error(`${where} has no audience`) if (!Number.isInteger(r.cooldown_seconds) || r.cooldown_seconds < 0) { throw new Error(`${where} needs a cooldown_seconds of 0 or more`) } // Q3's hard ceiling, and the reason a seeded rule cannot omit it: it is what // keeps a misconfiguration from becoming a mail storm, so a module may choose // the number and may not decline to have one. if (!Number.isInteger(r.max_sends_per_hour) || r.max_sends_per_hour < 1) { throw new Error(`${where} needs a max_sends_per_hour of 1 or more`) } const keys = r.template_keys || {} if (!keys || typeof keys !== 'object' || Array.isArray(keys)) { throw new Error(`${where} needs a template_keys object`) } for (const [channel, key] of Object.entries(keys)) { // `digest` is a template slot rather than a channel — the digest worker's // body for a rule whose email channel is set to digest mode — so it is // allowed here and absent from `channels`. if (!ownTemplateKeys.has(key) && !coreKeys.has(key)) { throw new Error( `${where} names template "${key}" for ${channel}, which is neither one of its own seeds nor core's`, ) } } return { trigger_id: r.trigger_id, name: r.name, audience: r.audience, audience_segment_id: null, channels: [...r.channels], template_keys: { ...keys }, conditions: r.conditions === undefined ? null : r.conditions, cooldown_seconds: r.cooldown_seconds, delay_seconds: Number.isInteger(r.delay_seconds) ? r.delay_seconds : 0, cancel_on: Array.isArray(r.cancel_on) ? [...r.cancel_on] : [], // Never negotiable and never a parameter (Q3). A module that could ship an // enabled rule could mail a deployment's whole user table on the strength of // an upgrade nobody read the release note for. enabled: 0, updated_by: null, } } /** * `registerEngagementSeeds({ templates, ruleGroups })`. * * Validated whole, exactly as `apply()` validates: a module that got one of * thirty-two templates wrong ships none of them, and finds out at boot with the * offending key named rather than at send time with a half-seeded table. */ function checkEngagementSeeds(owner, entry) { const e = entry || {} if (e.templates !== undefined && !Array.isArray(e.templates)) { throw new Error('registerEngagementSeeds: templates must be an array') } if (e.ruleGroups !== undefined && !Array.isArray(e.ruleGroups)) { throw new Error('registerEngagementSeeds: ruleGroups must be an array') } const templates = [] const seenKeys = new Set() for (const t of e.templates || []) { const checked = checkSeedTemplate(owner, t) if (seenKeys.has(checked.key)) { throw new Error(`registerEngagementSeeds: template "${checked.key}" declared twice`) } seenKeys.add(checked.key) templates.push(checked) } const coreKeys = coreTemplateKeys() const ruleGroups = [] const seenGroups = new Set() for (const g of e.ruleGroups || []) { const group = g || {} if (!SEED_GROUP_KEY.test(group.key || '')) { throw new Error(`registerEngagementSeeds: bad rule group key "${group.key}"`) } if (seenGroups.has(group.key)) { throw new Error(`registerEngagementSeeds: rule group "${group.key}" declared twice`) } seenGroups.add(group.key) if (!Array.isArray(group.rules) || !group.rules.length) { throw new Error(`registerEngagementSeeds: rule group "${group.key}" has no rules`) } ruleGroups.push({ key: group.key, note: group.note || '', rules: group.rules.map((r) => checkSeedRule(owner, r, seenKeys, coreKeys)), }) } return { templates, ruleGroups } } /** Every registrant's seeds, in registration order. What the seeder walks. */ const allEngagementSeeds = () => [...engagementSeeds.entries()].map(([owner, seeds]) => ({ owner, ...seeds })) /** One registrant's, or null. */ const engagementSeedsFor = (owner) => engagementSeeds.get(owner) || null // `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: [], triggers: [], audiences: [], engagementSeeds: [], } 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)) }, registerEventTriggers(entries) { if (!Array.isArray(entries)) throw new Error('registerEventTriggers: expected an array') for (const e of entries) staged.triggers.push(checkTriggerShape(e)) }, registerAudiences(entries) { if (!Array.isArray(entries)) throw new Error('registerAudiences: expected an array') for (const e of entries) staged.audiences.push(checkAudienceShape(e)) }, registerEngagementSeeds(entry) { staged.engagementSeeds.push(checkEngagementSeeds(owner, entry)) }, } } /** * 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 = [], triggers: newTriggers = [], audiences: newAudiences = [], engagementSeeds: newSeeds = [], }) { // ── 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`) // The cross-facet half of the one-namespace rule (§7.2). A stream may share // its id with a TRIGGER — that is the whole point, `news.post` is one event // with two facets — but only when the same registrant owns both. Someone // else's trigger id is taken. const heldAsTrigger = triggers.get(s.id) if (heldAsTrigger && heldAsTrigger.owner !== owner) { throw new Error(`stream "${s.id}" is already registered as an event trigger by "${heldAsTrigger.owner}"`) } if (!namespaced(owner, s.id, LEGACY_STREAM_IDS)) { throw new Error(`stream "${s.id}" is not namespaced "${owner}."`) } seenStreams.add(s.id) } // Triggers, against the SAME namespace and the SAME legacy allowlist as // streams above. Sharing LEGACY_STREAM_IDS is not laziness: under one // namespace `idoc.warning` is one id, so if `uo` may hold it as a stream // without the prefix it may hold it as a trigger without the prefix, and any // other answer would mean the seven grandfathered ids could never gain a // payload contract. const seenTriggers = new Set() for (const t of newTriggers) { const held = triggers.get(t.id) if (held) throw new Error(`event trigger "${t.id}" is already registered by "${held.owner}"`) if (seenTriggers.has(t.id)) throw new Error(`event trigger "${t.id}" registered twice`) const heldAsStream = streamOwners.get(t.id) if (heldAsStream && heldAsStream !== owner) { throw new Error(`event trigger "${t.id}" is already registered as a notification stream by "${heldAsStream}"`) } if (!namespaced(owner, t.id, LEGACY_STREAM_IDS)) { throw new Error(`event trigger "${t.id}" is not namespaced "${owner}."`) } seenTriggers.add(t.id) } const seenAudiences = new Set() for (const a of newAudiences) { const held = audiences.get(a.id) if (held) throw new Error(`audience "${a.id}" is already registered by "${held.owner}"`) if (seenAudiences.has(a.id)) throw new Error(`audience "${a.id}" registered twice`) // No legacy allowlist — nothing predates audiences, so the prefix rule has no // exceptions and should never grow one. if (!namespaced(owner, a.id, {})) { throw new Error(`audience "${a.id}" is not namespaced "${owner}."`) } seenAudiences.add(a.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) } // One call per registrant, like the post hook and the team provider above it. // A second call is a module that wrote its seeds in two places, and merging // them silently would make "which group is this rule in" unanswerable. if (newSeeds.length > 1) throw new Error(`"${owner}" registered engagement seeds more than once`) if (newSeeds.length && engagementSeeds.has(owner)) { throw new Error(`"${owner}" already registered engagement seeds`) } 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 }) for (const t of newTriggers) triggers.set(t.id, { owner, ...t }) for (const a of newAudiences) audiences.set(a.id, { owner, ...a }) for (const seeds of newSeeds) engagementSeeds.set(owner, seeds) } // ── 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 coreTriggers = require('../config/coreTriggers') const discordLeg = require('../utils/discordAnnounce') /* eslint-enable global-require */ const api = stage('core') api.registerNotificationStreams(coreStreams.STREAMS) api.registerAnnounceLeg(discordLeg.leg) // The engagement contract (ENGAGEMENT.md Phase 2). Core's five trigger ids ARE // its five stream ids — the same-owner upgrade the one-namespace rule above is // written for — so this batch exercises the cross-facet check on every boot. api.registerEventTriggers(coreTriggers.TRIGGERS) // 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, eventTriggers: triggers.size, 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() triggers.clear() audiences.clear() engagementSeeds.clear() coreRegistered = false } module.exports = { declareSlot, hasSlot, slotFilledBy, filledSlots, declaredSlotRouter, allStreams, isValidStream, personalStreams, announceLegs, announceLegIds, announceLeg, postHookEntries, dispatchPostHook, registeredTeamProvider, hasTeamProvider, slashCommandDefinitions, slashCommand, allTriggers, eventTrigger, eventOwner, allAudiences, audience, resolveAudience, allEngagementSeeds, engagementSeedsFor, SEEDABLE_CHANNELS, VARIABLE_TYPES, TRIGGER_KINDS, stage, apply, registerCore, isCoreRegistered, _reset, }