A lease row had no reconcile path at all, and nothing failed to say so.
`cleanup.js` resolves a resource to the action of the step that made it, and for
a lease that action is `core.lease` -- a CORE action, on a path a module cannot
register anything on. So every `override` row came back `unanswered` for the life
of the run, and a lease the shard had quietly dropped (a config lease is
memory-only there, so a restart reverts it by design) stayed in the ledger as
live until teardown went hunting a baseline nobody was holding.
`core.lease` gains a `reconcile()`, and `registerEventLeases` gains an optional
`inForce()`: "does the game side still have any record of this hold?"
Deliberately not `read()` plus a comparison. A value that differs from what the
run applied is DRIFT, which teardown must deliver through `restore()` so the row
lands `drifted` with the current value beside it; a reconcile that inferred
absence from a changed value would orphan the row first and tell the operator the
lease vanished rather than that somebody moved it. Only an explicit
`{ ok: true, held: false }` takes a row out -- a throw, a timeout, an
unrecognised shape and a lease with no `inForce()` all leave the ledger alone.
MODULE_API_VERSION stays 1.10.0, amended in place.
Co-Authored-By: Claude <noreply@anthropic.com>
1920 lines
89 KiB
JavaScript
1920 lines
89 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.
|
|
//
|
|
// Two more arrived with the engagement system (ENGAGEMENT.md Phase 2), from a
|
|
// different workstream but through the same door:
|
|
//
|
|
// 4. `registerEventTriggers(triggers)` — §4.3. The payload CONTRACT behind an
|
|
// event id: what a template may interpolate, and how widely a rule may
|
|
// ever send it (the ceiling, G24).
|
|
// 5. `registerAudiences(audiences)` — §5.1a. Named sets of user ids a
|
|
// module can resolve over its own data, for an operator to point a rule at.
|
|
//
|
|
// And a sixth, in Phase 11b (decision 7):
|
|
//
|
|
// 6. `registerEngagementSeeds({ templates, ruleGroups })` — the message BODIES
|
|
// and the shipped rules behind 4 and 5. A module declaring a trigger could
|
|
// say what its payload was and never say what it should read like, so a
|
|
// module's mail was core's generic body or nothing.
|
|
//
|
|
// **6 stores data and nothing else — no function, no handle.** A template is
|
|
// blocks and a rule is columns, both validated here and both written by core's
|
|
// own seeders (`engagement/moduleSeeds.js`), which is what keeps `seed_version`,
|
|
// `customized` and the block registry in the one file that owns them. It is
|
|
// emphatically not a send path: a module still cannot mail anyone (§1.2).
|
|
//
|
|
// **Triggers and notification streams share ONE id namespace** (the org lead's
|
|
// §7.2 decision). A stream entry is a subscription toggle and a trigger is a
|
|
// payload contract, so they stay two REGISTRATIONS with two shapes — but an id
|
|
// has exactly one owner across both, and `news.post` names one event whichever
|
|
// question is being asked of it. See the cross-facet checks in `apply()`.
|
|
//
|
|
// Nothing here reaches the database or the network. It is a require-time-safe
|
|
// collection of what core and modules have declared, read at request time.
|
|
|
|
const express = require('express')
|
|
|
|
const log = require('../utils/logger')('modules')
|
|
const ceilings = require('./ceilings')
|
|
|
|
// ── State ──────────────────────────────────────────────────────────────────
|
|
|
|
// slot → { router, filledBy }. `router` is created when CORE DECLARES the slot
|
|
// and mounted immediately; registrants `use()` into it later. That indirection is
|
|
// not optional: users.router.js is required while app.js is being built, long
|
|
// before any module has been scanned, so the thing core mounts has to be a stable
|
|
// object that can still be empty.
|
|
const slots = new Map()
|
|
|
|
// Registration order, which is display order in the app's notifications screen.
|
|
const streams = []
|
|
const streamOwners = new Map() // stream id → owner id, for the collision message
|
|
|
|
// leg id → { owner, leg, label, dispatch, classify }
|
|
const legs = new Map()
|
|
|
|
// owner -> { onSaved?, onDeleted? }. Post hooks (§1.8, API 1.1.0). A Map keyed by
|
|
// owner rather than a flat list, so a registrant is a single subscription that
|
|
// can be reported and reasoned about as one thing — and so registering twice is
|
|
// a collision with a name attached rather than a silently doubled side effect.
|
|
const postHooks = new Map()
|
|
|
|
// { owner, getTeams, getTeamMembers, getTeamLeaders } or null — the Team provider
|
|
// (API 1.6.0, TEAMS.md §2.3).
|
|
//
|
|
// A SINGLE value rather than a Map, unlike every registry above it, and that is
|
|
// the contract: one provider per deployment. Teams have one authoritative source
|
|
// by construction — two modules answering "what teams exist" would produce two
|
|
// disjoint sets under one `teams` table with no rule for merging them, so a
|
|
// second registration is a collision rather than an addition.
|
|
let teamProvider = null
|
|
|
|
// command name → { owner, name, description, options, access, handler }. Slash
|
|
// commands a registrant has published for the chat platform (API 1.6.0,
|
|
// TEAMS.md §7.1).
|
|
//
|
|
// The DEFINITION and the HANDLER are registered together and the handler runs
|
|
// HERE, in the website process; the bot pulls the definitions over the internal
|
|
// API and owns every Discord-specific concern. That split is forced — the bot
|
|
// container has no `modules` volume, so a module physically cannot put a handler
|
|
// in it (§0.4) — and it is also the boundary we would pick anyway: a module
|
|
// calling `interaction.deferReply()` would be a module holding a Discord handle.
|
|
const slashCommands = new Map()
|
|
|
|
// trigger id → { owner, id, label, description, kind, subjectKey, audience,
|
|
// ceiling, version, variables } (ENGAGEMENT.md §4.3, API 1.7.0).
|
|
//
|
|
// A Map rather than an array, unlike `streams`: a stream catalog is READ WHOLE
|
|
// (the app renders it in registration order) and a trigger is READ BY ID (the
|
|
// emit path, the rule editor, the template editor), so insertion order is kept
|
|
// for display and the lookup is the primary access.
|
|
const triggers = new Map()
|
|
|
|
// audience id → { owner, id, label, description, params, ceiling, resolve }
|
|
// (§5.1a). Its own id space, not the trigger/stream one: an audience names a set
|
|
// of PEOPLE and a trigger names an EVENT, and `uo.team.members` colliding with a
|
|
// trigger of the same name would be a collision between two unrelated things.
|
|
const audiences = new Map()
|
|
|
|
// action id → { owner, id, label, description, risk, reversible, version,
|
|
// budgetMs, params, cost, perform, revert } (EVENTS.md §F, Phase 1).
|
|
//
|
|
// **Its own id space**, like `audiences` above and for the same kind of reason:
|
|
// an action names a VERB and a trigger names an EVENT, so `uo.champ.start` as
|
|
// the thing a module can be asked to do and `uo.champ.start` as the thing that
|
|
// happened are two unrelated declarations that must not collide with — or
|
|
// silently satisfy — each other. Nothing cross-checks this map against the
|
|
// stream/trigger namespace, and nothing should.
|
|
//
|
|
// A Map, and read by id on the dispatch path exactly as `triggers` is; insertion
|
|
// order is what the admin catalog renders in.
|
|
const eventActions = new Map()
|
|
|
|
// budget id → { owner, id, label, unit, description } (EVENTS.md §F, Phase 7).
|
|
//
|
|
// A dimension of consumption — "creatures spawned", "gate uptime" — declared so
|
|
// that the switchboard's cap editor has a NAME and a UNIT to put beside a number.
|
|
// Phase 6 discovered these by pricing an action's declared `example` values,
|
|
// which was a stand-in that could name a dimension and never label it.
|
|
//
|
|
// **Its own id space**, like `eventActions` above, and §F says why in one line:
|
|
// an action names a VERB and a budget names a RESOURCE. Nothing cross-checks the
|
|
// two maps, and nothing should.
|
|
//
|
|
// Data only. There is no function on a budget and nothing here is ever called —
|
|
// the module says a dimension exists and what to call it, `cost()` says how much
|
|
// of it a step spends, and core owns every piece of arithmetic in between.
|
|
const eventBudgets = new Map()
|
|
|
|
// lease id → { owner, id, label, type, min, max, maxDurationMs, description,
|
|
// read, apply, restore } (§F "Leases: one more declaration", Phase 7).
|
|
//
|
|
// **Core owns the duration and the conflict check; the module owns reading the
|
|
// current value and writing a new one.** Phase 7 registered a lease and nothing
|
|
// acquired one; Phase 8 gave it a verb — `core.lease`, a CORE action, so the
|
|
// bound and the two-events-one-target refusal are enforced in one place rather
|
|
// than re-implemented by every module that ships a lease.
|
|
const eventLeases = new Map()
|
|
|
|
// source id → { owner, id, label, description, resolve } (§F "Param option
|
|
// sources", Phase 7).
|
|
//
|
|
// What turns an authoring field from a text box into a dropdown of real
|
|
// landmarks. Modelled on `audiences` rather than on anything else here, because
|
|
// it is the same shape of thing: an id, a label, and a `resolve()` core calls and
|
|
// waits for. What differs is the meaning of a refusal — an audience that refuses
|
|
// mails nobody, while a source that refuses degrades its field to free text with
|
|
// a warning, because refusing to let an operator type a value they already know
|
|
// is worse than the typo the dropdown existed to prevent.
|
|
const eventOptionSources = new Map()
|
|
|
|
// owner → { templates: [...], ruleGroups: [...] } (ENGAGEMENT.md Phase 11b,
|
|
// decision 7). What a module ships as CONTENT rather than as contract: the
|
|
// bodies its triggers render through, and the rules an operator switches on.
|
|
//
|
|
// Keyed by owner and not by template key, because the seeder runs per module —
|
|
// a module the operator disabled is skipped whole, and a module that failed to
|
|
// load never gets here at all.
|
|
const engagementSeeds = new Map()
|
|
|
|
let coreRegistered = false
|
|
|
|
// Stream ids that predate the module system and may not carry their owner's
|
|
// prefix — the exact counterpart of the loader's LEGACY_TABLE_PREFIXES, for the
|
|
// exact same reason. These seven ids are stored in `notification_subs` rows and
|
|
// are read by a shipped Android client; renaming them in Phase 3 would be a data
|
|
// migration and a client break, so `uo` keeps them and the prefix rule stays real
|
|
// for every module written after it.
|
|
const LEGACY_STREAM_IDS = {
|
|
uo: [
|
|
'server.status', 'idoc.warning', 'champ.start', 'governor.election',
|
|
'vendor.sale', 'house.idoc', 'account.login',
|
|
],
|
|
}
|
|
|
|
// Likewise for announce legs: `towncrier` is a stored value in
|
|
// announce_job_legs.leg and the body of the admin retry endpoint.
|
|
const LEGACY_LEGS = { uo: ['towncrier'] }
|
|
|
|
// ONE grammar for the one namespace streams and triggers share. It relaxes what
|
|
// `STREAM_ID` used to allow by admitting `_` inside a segment, because the
|
|
// trigger ids this contract is written for have them (`uo.house.idoc_warning`,
|
|
// ENGAGEMENT.md §4.3) and two grammars over one namespace would mean an id that
|
|
// is legal as a trigger and illegal as the stream it is the same event as.
|
|
// Relaxation only: every id valid before is valid now, and no stored id changes.
|
|
const EVENT_ID = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/
|
|
const LEG_ID = /^[a-z][a-z0-9.]{1,62}$/
|
|
// Audiences are their own id space (see the `audiences` Map), so they get their
|
|
// own constant even though the grammar is the same one.
|
|
const AUDIENCE_ID = EVENT_ID
|
|
// Likewise event actions (EVENTS.md §F, "Actions and budgets are their own id
|
|
// spaces"). One grammar, three namespaces — the constant is what makes the
|
|
// namespace visible at every use site.
|
|
const ACTION_ID = EVENT_ID
|
|
// And three more id spaces on the same grammar, arriving with the module
|
|
// contract in Phase 7. Three constants rather than three uses of ACTION_ID, for
|
|
// the reason AUDIENCE_ID gets its own: the constant is what makes the namespace
|
|
// visible at the use site, so a future divergence has one place to happen.
|
|
const BUDGET_ID = EVENT_ID
|
|
const LEASE_ID = EVENT_ID
|
|
const OPTION_SOURCE_ID = EVENT_ID
|
|
|
|
// A module's claim must carry its id. Core's ids are its own namespace, and the
|
|
// grandfathered names are the ones that predate all of this.
|
|
function namespaced(owner, name, legacy) {
|
|
return owner === 'core' || name.startsWith(`${owner}.`) || (legacy[owner] || []).includes(name)
|
|
}
|
|
|
|
// ── Extension slots (§1.9) ─────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Core declares an extension slot and gets the router to mount for it.
|
|
*
|
|
* ONLY core may declare a slot; a module may only fill one (MODULE_API.md §2.4).
|
|
* That asymmetry is why this is not on the `api` object handed to a module.
|
|
*
|
|
* `mergeParams` so the slot's router sees the parent's `:id`. Core's own routes
|
|
* on the resource are declared before the slot is mounted, so first-match-wins
|
|
* gives core the path conflict, as the contract requires.
|
|
*
|
|
* @returns {import('express').Router} mount this at the resource, once.
|
|
*/
|
|
function declareSlot(slot) {
|
|
if (slots.has(slot)) throw new Error(`extension slot "${slot}" already declared`)
|
|
const router = express.Router({ mergeParams: true })
|
|
slots.set(slot, { router, filledBy: null })
|
|
return router
|
|
}
|
|
|
|
/** Does this slot exist? The loader asks, to validate `extensions` in a manifest. */
|
|
const hasSlot = (slot) => slots.has(slot)
|
|
|
|
/** Who filled a slot, or null. */
|
|
const slotFilledBy = (slot) => (slots.get(slot) || {}).filledBy || null
|
|
|
|
/**
|
|
* Every FILLED slot, for the OpenAPI build step (swagger/slotSpecs.js).
|
|
*
|
|
* `router` is the slot's own stable router — the object mounted on the resource —
|
|
* so the build can find it in the live express stack and recover the prefix it
|
|
* hangs at without a hardcoded table.
|
|
*/
|
|
const filledSlots = () =>
|
|
[...slots.entries()]
|
|
.filter(([, e]) => e.filledBy)
|
|
.map(([slot, e]) => ({ slot, filledBy: e.filledBy, router: e.router, specFile: e.specFile || null }))
|
|
|
|
/**
|
|
* A DECLARED slot's stable router, filled or not.
|
|
*
|
|
* `filledSlots()` answers what the build needs — a filled slot has a spec file
|
|
* to generate a fragment from. This answers what a test needs: the slot exists
|
|
* from the moment core declares it at require time, and its position in the
|
|
* express stack has to stay findable whether or not a module has filled it.
|
|
* Before Phase 3 the two questions had the same answer, because core filled the
|
|
* only slot itself.
|
|
*/
|
|
const declaredSlotRouter = (slot) => (slots.get(slot) || {}).router || null
|
|
|
|
// ── Notification streams (§1.8) ────────────────────────────────────────────
|
|
|
|
/** The whole catalog, core's entries first, in registration order. */
|
|
const allStreams = () => streams.slice()
|
|
|
|
/** Is this a stream anyone registered? Gates a subscription write. */
|
|
const isValidStream = (id) => streamOwners.has(id)
|
|
|
|
/** Ids of the owner-keyed streams — those needing a linked game account. */
|
|
const personalStreams = () => new Set(streams.filter((s) => s.personal).map((s) => s.id))
|
|
|
|
// ── Post hooks (§1.8) ──────────────────────────────────────────────────────
|
|
|
|
// Core's CMS is the only writer of posts, and a module may need to mirror one
|
|
// somewhere core knows nothing about — module-uo keeps UO's in-game Town Cryer
|
|
// News gump in step with it. Before this existed, core's post controller
|
|
// required `utils/newsGump` directly, which is precisely the coupling the
|
|
// extraction had to remove: core's publish path naming a UO file.
|
|
//
|
|
// It is deliberately NOT folded into `registerAnnounceLeg`, which fires on the
|
|
// same transition. A leg is a one-shot DELIVERY with retry and classification;
|
|
// a post hook maintains idempotent STATE, has to run on delete as well as save,
|
|
// and refreshes silently on an edit. Overloading the leg would have meant a
|
|
// dispatch that must not be retried and a classify that means nothing.
|
|
|
|
/** Every registered hook, in registration order. */
|
|
const postHookEntries = () => [...postHooks.entries()].map(([owner, h]) => ({ owner, ...h }))
|
|
|
|
/**
|
|
* Fire `event` at every registered hook, one at a time, never throwing.
|
|
*
|
|
* Best-effort by contract, and awaited rather than fired-and-forgotten: core's
|
|
* own call site awaited `newsGump.syncPost` before this existed, so a save that
|
|
* returns 200 still means the mirror was attempted. One subscriber's failure
|
|
* must not cost another's, and none of them may cost the save — a sidecar
|
|
* hiccup breaking a post edit would be a worse bug than a stale gump.
|
|
*/
|
|
async function dispatchPostHook(event, payload) {
|
|
for (const { owner, [event]: fn } of postHookEntries()) {
|
|
if (typeof fn !== 'function') continue
|
|
try {
|
|
await fn(payload)
|
|
} catch (err) {
|
|
log.warn('post hook failed', { owner, event, message: err.message })
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Announce legs (§1.8) ───────────────────────────────────────────────────
|
|
|
|
/** Every registered leg, in registration order. */
|
|
const announceLegs = () => [...legs.values()]
|
|
|
|
/** Just the ids — the enqueue order and the retry endpoint's allowlist. */
|
|
const announceLegIds = () => [...legs.keys()]
|
|
|
|
/** One leg, or null. */
|
|
const announceLeg = (leg) => legs.get(leg) || null
|
|
|
|
// ── Team provider (TEAMS.md §2.3) ──────────────────────────────────────────
|
|
|
|
/** The registered provider, or null when no module supplies one. */
|
|
const registeredTeamProvider = () => teamProvider
|
|
|
|
/** Is there a Team provider at all? Read by the reconciler and the read API. */
|
|
const hasTeamProvider = () => teamProvider !== null
|
|
|
|
// ── Slash commands (TEAMS.md §7.1) ─────────────────────────────────────────
|
|
|
|
/**
|
|
* Every registered command WITHOUT its handler — what `/internal/commands`
|
|
* serves to the bot.
|
|
*
|
|
* The handler is stripped rather than merely un-serialisable-and-ignored: this
|
|
* is the object that crosses a process boundary, and the definition half is the
|
|
* whole of what the bot is allowed to know. `owner` rides along so the bot can
|
|
* name the module in a collision warning.
|
|
*/
|
|
const slashCommandDefinitions = () =>
|
|
[...slashCommands.values()].map(({ handler, ...definition }) => definition)
|
|
|
|
/** One command, handler included. The dispatcher's lookup. */
|
|
const slashCommand = (name) => slashCommands.get(name) || null
|
|
|
|
// ── Event triggers (ENGAGEMENT.md §4.3) ────────────────────────────────────
|
|
|
|
/** Every declaration, core's first, in registration order. The admin catalog. */
|
|
const allTriggers = () => [...triggers.values()]
|
|
|
|
/** One declaration, or null. The emit path's lookup and the rule editor's. */
|
|
const eventTrigger = (id) => triggers.get(id) || null
|
|
|
|
/**
|
|
* Who owns this id, across BOTH facets — the one-namespace question.
|
|
*
|
|
* A caller asking "may this module emit this?" wants this rather than
|
|
* `eventTrigger(id).owner`, because an id can be held as a stream by one owner
|
|
* and not yet declared as a trigger by anyone, and that id is still taken.
|
|
*/
|
|
const eventOwner = (id) => triggers.get(id)?.owner || streamOwners.get(id) || null
|
|
|
|
// ── Audiences (§5.1a) ──────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Every declaration WITHOUT its resolver — what the admin surface serves.
|
|
*
|
|
* The resolver is stripped for the same reason a slash command's handler is:
|
|
* this is the object that leaves the process, and `resolve` is a function over a
|
|
* module's own store that no client has any business holding a reference to.
|
|
*/
|
|
const allAudiences = () => [...audiences.values()].map(({ resolve, ...rest }) => rest)
|
|
|
|
/** One declaration, resolver included. The engine's lookup. */
|
|
const audience = (id) => audiences.get(id) || null
|
|
|
|
/**
|
|
* Resolve a declared audience to user ids, never throwing.
|
|
*
|
|
* Three answers, and the middle one is the contract (§5.1a rule 4): a registered
|
|
* audience answers `{ dormant: false, userIds }`; an audience whose module is
|
|
* uninstalled answers `{ dormant: true, userIds: [] }` — the EMPTY set and a
|
|
* flag, never an error and never a fallback to some other set of people; and a
|
|
* resolver that throws or answers a non-array is logged and treated as empty,
|
|
* because a module's storage problem must not become a send to the wrong people.
|
|
*
|
|
* `userIds` is filtered to positive integers here rather than trusted. It is the
|
|
* one value a module hands core that decides who receives mail, and the resolver
|
|
* is module code running over a module's own store.
|
|
*/
|
|
async function resolveAudience(id, params = {}) {
|
|
const entry = audiences.get(id)
|
|
if (!entry) return { dormant: true, userIds: [] }
|
|
try {
|
|
const raw = await entry.resolve(params)
|
|
if (!Array.isArray(raw)) {
|
|
log.warn('audience resolver did not return an array', { audience: id, owner: entry.owner })
|
|
return { dormant: false, userIds: [] }
|
|
}
|
|
const userIds = [...new Set(raw.map(Number).filter((n) => Number.isInteger(n) && n > 0))]
|
|
return { dormant: false, userIds }
|
|
} catch (err) {
|
|
log.error('audience resolver failed', { audience: id, owner: entry.owner, message: err.message })
|
|
return { dormant: false, userIds: [] }
|
|
}
|
|
}
|
|
|
|
// ── Event actions (EVENTS.md §F) ───────────────────────────────────────────
|
|
|
|
/**
|
|
* Every declaration WITHOUT its callables — what the admin catalog serves.
|
|
*
|
|
* `perform`, `revert`, `reconcile` and `cost` are stripped for the same reason `resolve` is
|
|
* stripped from an audience and `handler` from a slash command: this is the
|
|
* object that leaves the process, and the browser's whole relationship with an
|
|
* action is naming one by id. §F's "a module registers actions server-side and
|
|
* adds no routes for them" is only true if the functions never ride out.
|
|
*/
|
|
const allEventActions = () =>
|
|
[...eventActions.values()].map(({ perform, revert, reconcile, cost, ...rest }) => rest)
|
|
|
|
/** One declaration, callables included. The runner's lookup (Phase 2). */
|
|
const eventAction = (id) => eventActions.get(id) || null
|
|
|
|
/**
|
|
* Does anyone register this id right now?
|
|
*
|
|
* The authoring path's question, and it is deliberately not `eventAction(id) !==
|
|
* null` at every call site: a step naming an action whose module is uninstalled
|
|
* is DORMANT, not an error (§F), and the difference between "never existed" and
|
|
* "not installed today" is a distinction only the caller can draw.
|
|
*/
|
|
const isEventAction = (id) => eventActions.has(id)
|
|
|
|
// ── Event budgets, leases and option sources (§F, Phase 7) ─────────────
|
|
|
|
/** Every declared budget dimension, in registration order. */
|
|
const allEventBudgets = () => [...eventBudgets.values()]
|
|
|
|
/** One dimension's declaration, or null. The label-and-unit lookup. */
|
|
const eventBudget = (id) => eventBudgets.get(id) || null
|
|
|
|
/**
|
|
* Does anyone declare this dimension right now?
|
|
*
|
|
* The fail-closed question (org lead, 2026-09-03): a `cost()` naming a dimension
|
|
* nobody registered is REFUSED — at save, at the dry run and at dispatch. §F's
|
|
* *"a module cannot spend a budget it did not declare"* is only true if something
|
|
* asks, and this is what asks.
|
|
*/
|
|
const isEventBudget = (id) => eventBudgets.has(id)
|
|
|
|
/**
|
|
* Every lease declaration WITHOUT its callables — what the catalog serves.
|
|
*
|
|
* Stripped for the reason `perform` is stripped from an action: this object
|
|
* leaves the process, and the browser's whole relationship with a lease is naming
|
|
* one by id.
|
|
*/
|
|
const allEventLeases = () =>
|
|
[...eventLeases.values()].map(({ read, apply: applyValue, restore, inForce, ...rest }) => rest)
|
|
|
|
/** One lease, callables included. `core.lease` and the cleanup sweep read it. */
|
|
const eventLease = (id) => eventLeases.get(id) || null
|
|
|
|
/** Every option source WITHOUT its resolver — the authoring form's list. */
|
|
const allEventOptionSources = () =>
|
|
[...eventOptionSources.values()].map(({ resolve, ...rest }) => rest)
|
|
|
|
/**
|
|
* Resolve one option source, or say why not. Never throws.
|
|
*
|
|
* **A refusal is not an error here, and that is the design.** §F: a source that
|
|
* cannot answer degrades its field to free text with a visible warning rather
|
|
* than blocking the form. So every failure shape — no such source, a throw, a
|
|
* rejected promise, a non-array — comes back as `{ ok: false, reason }` and the
|
|
* caller renders a text box. The alternative is an authoring screen that a
|
|
* module's outage can make unusable, for a field whose value the operator very
|
|
* often already knows.
|
|
*
|
|
* The options are normalised rather than trusted. This array is rendered into a
|
|
* `<select>`, so an entry with no `value` is dropped instead of becoming an
|
|
* option that submits the string "undefined", and `label` falls back to the value
|
|
* rather than to nothing — a dropdown of blank rows is a worse field than the
|
|
* text box it replaced.
|
|
*/
|
|
async function resolveOptionSource(id) {
|
|
const entry = eventOptionSources.get(id)
|
|
if (!entry) return { ok: false, reason: `no module registers the option source "${id}"` }
|
|
let raw
|
|
try {
|
|
raw = await entry.resolve()
|
|
} catch (err) {
|
|
log.error('option source resolver failed', {
|
|
source: id,
|
|
owner: entry.owner,
|
|
message: err.message,
|
|
})
|
|
return { ok: false, reason: `"${entry.label}" could not be read` }
|
|
}
|
|
if (!Array.isArray(raw)) {
|
|
return { ok: false, reason: `"${entry.label}" answered with no option list` }
|
|
}
|
|
const options = []
|
|
for (const o of raw) {
|
|
if (!o || typeof o !== 'object') continue
|
|
if (o.value === undefined || o.value === null || o.value === '') continue
|
|
const option = { value: String(o.value), label: String(o.label ?? o.value) }
|
|
if (o.group) option.group = String(o.group)
|
|
options.push(option)
|
|
}
|
|
return { ok: true, id, label: entry.label, owner: entry.owner, options }
|
|
}
|
|
|
|
// ── 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,
|
|
}
|
|
}
|
|
|
|
// ── Event action shape (EVENTS.md §F) ──────────────────────────────────────
|
|
|
|
// Four values, closed, core-owned (§N6). Deliberately NOT "world-read" and
|
|
// "world-write", which are game words a chess ladder has no use for — and
|
|
// deliberately not extensible by a module, because the class is what core
|
|
// derives a step's `on_failure` from (§L) and a module that could invent
|
|
// `harmless` would be choosing its own retry policy.
|
|
const ACTION_RISKS = ['notify', 'inspect', 'change', 'irreversible']
|
|
|
|
// What core must know in order to clean up after a run (§L). `none` is gone once
|
|
// done; `self` undoes itself; `ledger` needs a `revert` over the rows core
|
|
// recorded; `override` is a lease, whose baseline core restores.
|
|
const ACTION_REVERSIBLE = ['none', 'self', 'ledger', 'override']
|
|
|
|
// The same six types a trigger variable uses. One vocabulary over both, because
|
|
// the authoring form that renders an action param and the template editor that
|
|
// renders a trigger variable are the same widget over the same six types, and a
|
|
// second list is a list that drifts.
|
|
const ACTION_PARAM_TYPES = VARIABLE_TYPES
|
|
|
|
// The param name grammar, shared with trigger variables for the same reason: a
|
|
// param ends up as a key in a JSON object an operator reads.
|
|
const PARAM_NAME = VARIABLE_NAME
|
|
|
|
// The default per-invocation deadline. Ten seconds is §F's own figure and it is
|
|
// the number the sidecar's own request timeout is set near — long enough for a
|
|
// round trip through a module, a sidecar and a game tick, short enough that a
|
|
// wedged action does not hold a step's claim past its lease.
|
|
const DEFAULT_BUDGET_MS = 10_000
|
|
// An hour. Not "unlimited by another name": the bound exists so that a typo in a
|
|
// declaration is a slow action rather than a step that never times out at all,
|
|
// and Phase 2's lease has to be longer than this to mean anything.
|
|
const MAX_BUDGET_MS = 3_600_000
|
|
|
|
function checkActionParam(actionId, entry, seen) {
|
|
const { name, type, required, example, description, source } = entry || {}
|
|
const where = `registerEventActions: ${actionId}`
|
|
if (!PARAM_NAME.test(name || '')) throw new Error(`${where}: bad param name "${name}"`)
|
|
if (seen.has(name)) throw new Error(`${where}: param "${name}" declared twice`)
|
|
seen.add(name)
|
|
if (!ACTION_PARAM_TYPES.includes(type)) {
|
|
throw new Error(`${where}: param "${name}" has unsupported type "${type}"`)
|
|
}
|
|
// REQUIRED, on every param including the optional ones, and it is the same
|
|
// argument `checkTriggerVariable` makes: without it the authoring form has no
|
|
// placeholder and the operator is typing into a blank box, which is exactly
|
|
// how an unattended world write comes to be scheduled with a typo in it. It is
|
|
// one word at declaration time and unreconstructable afterwards.
|
|
if (example === undefined || example === null || example === '') {
|
|
throw new Error(`${where}: param "${name}" needs an example (it is the authoring placeholder)`)
|
|
}
|
|
// A `source` names a module-served option endpoint, so the field is a dropdown
|
|
// of real values rather than a text box (§F "Param option sources"). It is
|
|
// checked as an id here and resolved nowhere yet — the endpoint that answers it
|
|
// is Phase 7's, and a `source` naming nothing degrades the field to free text
|
|
// with a warning rather than blocking the form.
|
|
if (source !== undefined && !ACTION_ID.test(source || '')) {
|
|
throw new Error(`${where}: param "${name}" has a bad option source "${source}"`)
|
|
}
|
|
return {
|
|
name,
|
|
type,
|
|
required: Boolean(required),
|
|
example,
|
|
source: source === undefined ? null : source,
|
|
description: description || '',
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `registerEventActions([{ id, label, risk, reversible, version, budgetMs, cost, params, perform, revert, reconcile }])`.
|
|
*
|
|
* A typed verb core may ask a registrant to carry out. Everything decidable from
|
|
* the argument alone is decided here, at the call; the collision — is this id
|
|
* already someone's action? — waits for `apply()`.
|
|
*
|
|
* The copy at the end is explicit rather than a spread, like every other shape
|
|
* check in this file: this object is served to the admin catalog and is what a
|
|
* step's params are validated against, so anything not named here is not part of
|
|
* the contract and must not ride along.
|
|
*
|
|
* **Nothing here executes and nothing here may touch the database.** Registration
|
|
* runs under `routeManifest.js` and `swagger.js` against a dead pool
|
|
* (MODULE_API.md §2.2), and core's own registration is subject to the same rule
|
|
* as a module's.
|
|
*/
|
|
function checkEventActionShape(entry) {
|
|
const a = entry || {}
|
|
if (!ACTION_ID.test(a.id || '')) {
|
|
throw new Error(`registerEventActions: bad action id "${a.id}"`)
|
|
}
|
|
if (!a.label) throw new Error(`registerEventActions: action "${a.id}" has no label`)
|
|
|
|
// Both required with no default, for the reason a trigger's ceiling is: there
|
|
// is no safe value to guess. Defaulting `risk` to `notify` would give a world
|
|
// write the retry policy of a broadcast, and defaulting `reversible` to `none`
|
|
// would tell the cleanup generator there is nothing to undo.
|
|
if (!ACTION_RISKS.includes(a.risk)) {
|
|
throw new Error(
|
|
`registerEventActions: ${a.id} needs a risk class, one of ${ACTION_RISKS.join(', ')}`,
|
|
)
|
|
}
|
|
if (!ACTION_REVERSIBLE.includes(a.reversible)) {
|
|
throw new Error(
|
|
`registerEventActions: ${a.id} needs a reversible class, one of ${ACTION_REVERSIBLE.join(', ')}`,
|
|
)
|
|
}
|
|
|
|
if (typeof a.perform !== 'function') {
|
|
throw new Error(`registerEventActions: ${a.id} has no perform()`)
|
|
}
|
|
// §F: `revert` is required iff `reversible === 'ledger'`. Checked here rather
|
|
// than discovered at teardown, because the moment it matters is the moment a
|
|
// run has already created something and the answer "there is no undo" is the
|
|
// one answer cleanup cannot act on.
|
|
if (a.reversible === 'ledger' && typeof a.revert !== 'function') {
|
|
throw new Error(`registerEventActions: ${a.id} is reversible: 'ledger' but has no revert()`)
|
|
}
|
|
// The mirror check, and it is not pedantry: a `revert` on a `reversible:
|
|
// 'none'` action is a module author who believes their action can be undone
|
|
// and a cleanup generator that will never call it. Silence there is a promise
|
|
// core does not keep.
|
|
if (a.revert !== undefined && a.reversible !== 'ledger') {
|
|
throw new Error(
|
|
`registerEventActions: ${a.id} declares revert() but is reversible: '${a.reversible}'`,
|
|
)
|
|
}
|
|
// §L's reconnect row, and it is OPTIONAL where `revert` is required (Phase 8).
|
|
// `revert` is how a run gives a resource back; `reconcile` is how a module says
|
|
// which of them the game still has after something outside core restarted. A
|
|
// module that cannot answer that question is not broken -- core simply keeps
|
|
// believing its own ledger, which is the pre-Phase-8 behaviour -- whereas a
|
|
// module that created something and cannot undo it has made a promise core has
|
|
// no way to keep. Only meaningful for an action that ledgers anything.
|
|
if (a.reconcile !== undefined) {
|
|
if (typeof a.reconcile !== 'function') {
|
|
throw new Error(`registerEventActions: ${a.id} reconcile must be a function`)
|
|
}
|
|
if (a.reversible === 'none' || a.reversible === 'self') {
|
|
throw new Error(
|
|
`registerEventActions: ${a.id} declares reconcile() but is reversible: '${a.reversible}' and ledgers nothing`,
|
|
)
|
|
}
|
|
}
|
|
if (a.cost !== undefined && typeof a.cost !== 'function') {
|
|
throw new Error(`registerEventActions: ${a.id} cost must be a function of its params`)
|
|
}
|
|
|
|
const version = a.version === undefined ? 1 : a.version
|
|
if (!Number.isInteger(version) || version < 1) {
|
|
throw new Error(`registerEventActions: ${a.id} has a bad version "${a.version}"`)
|
|
}
|
|
|
|
const budgetMs = a.budgetMs === undefined ? DEFAULT_BUDGET_MS : a.budgetMs
|
|
if (!Number.isInteger(budgetMs) || budgetMs <= 0 || budgetMs > MAX_BUDGET_MS) {
|
|
throw new Error(
|
|
`registerEventActions: ${a.id} budgetMs must be 1..${MAX_BUDGET_MS} ms, got "${a.budgetMs}"`,
|
|
)
|
|
}
|
|
|
|
if (a.params !== undefined && !Array.isArray(a.params)) {
|
|
throw new Error(`registerEventActions: ${a.id} params must be an array`)
|
|
}
|
|
const seen = new Set()
|
|
const params = (a.params || []).map((p) => checkActionParam(a.id, p, seen))
|
|
|
|
return {
|
|
id: a.id,
|
|
label: a.label,
|
|
description: a.description || '',
|
|
risk: a.risk,
|
|
reversible: a.reversible,
|
|
version,
|
|
budgetMs,
|
|
params,
|
|
cost: a.cost || null,
|
|
perform: a.perform,
|
|
revert: a.revert || null,
|
|
reconcile: a.reconcile || null,
|
|
}
|
|
}
|
|
|
|
// A lease's value type. Closed, like `risk` and `reversible`, and for the same
|
|
// reason: core validates an operator's input against it at authoring time, so a
|
|
// type core does not know is a lease core cannot bound.
|
|
const LEASE_TYPES = ['int', 'float', 'bool', 'string']
|
|
|
|
// Thirty days. A lease is a promise the game side keeps WITHOUT being asked again
|
|
// (§F), so its ceiling is the longest outage a restore may have to survive rather
|
|
// than a scheduling convenience. Past that, "temporary" has stopped meaning
|
|
// anything an operator can hold in their head.
|
|
const MAX_LEASE_MS = 30 * 24 * 60 * 60 * 1000
|
|
|
|
/**
|
|
* `registerEventBudgets([{ id, label, unit, description }])`.
|
|
*
|
|
* A dimension of consumption core can bound. Data only — the module says a
|
|
* dimension exists and what to call it, `cost()` says how much of it a step
|
|
* spends, and core owns the arithmetic in between (§F, *"cost is declared by the
|
|
* module and enforced by core"*).
|
|
*
|
|
* **`unit` is required, and its vocabulary is open.** Required because a bare
|
|
* number on a cap box is ambiguous in exactly the case that matters — 30 of
|
|
* what? — and open because core never interprets it. It is a display word beside
|
|
* a number, and closing the set would make "kilometres" a MODULE_API bump for a
|
|
* noun core does not read.
|
|
*/
|
|
function checkEventBudgetShape(entry) {
|
|
const b = entry || {}
|
|
if (!BUDGET_ID.test(b.id || '')) {
|
|
throw new Error(`registerEventBudgets: bad budget id "${b.id}"`)
|
|
}
|
|
if (!b.label) throw new Error(`registerEventBudgets: budget "${b.id}" has no label`)
|
|
if (!b.unit) {
|
|
throw new Error(`registerEventBudgets: budget "${b.id}" has no unit (it is rendered beside the cap)`)
|
|
}
|
|
return { id: b.id, label: b.label, unit: String(b.unit), description: b.description || '' }
|
|
}
|
|
|
|
/**
|
|
* `registerEventLeases([{ id, label, type, min, max, maxDurationMs, read, apply, restore }])`.
|
|
*
|
|
* A value a run may borrow and must give back. Core owns the duration and the
|
|
* conflict check; the module owns reading the current value and writing a new one
|
|
* — the split §F draws, and the reason all three callables are required rather
|
|
* than one of them.
|
|
*
|
|
* **`restore` is required even though `read` could stand in for it.** They answer
|
|
* different questions: `read` is *"what is it now"*, `restore` is *"put this back,
|
|
* and tell me if someone else has moved it"* — the drift check, which is the one
|
|
* thing a module must not be allowed to skip. A lease whose restore writes blindly
|
|
* is a lease that silently reverts an operator's manual fix.
|
|
*
|
|
* **A lease is acquired by `core.lease` and by nothing else** (Phase 8). The step
|
|
* names a lease id, a value and a duration; core reads the baseline, reserves the
|
|
* target in `event_run_resources` — which is where the two-events-one-target
|
|
* refusal comes from — applies the value with the deadline, and restores it at
|
|
* teardown through the same `restore()` the drift check lives in. A module ships
|
|
* the three callables and never has to own any of that.
|
|
*/
|
|
function checkEventLeaseShape(entry) {
|
|
const l = entry || {}
|
|
if (!LEASE_ID.test(l.id || '')) throw new Error(`registerEventLeases: bad lease id "${l.id}"`)
|
|
if (!l.label) throw new Error(`registerEventLeases: lease "${l.id}" has no label`)
|
|
if (!LEASE_TYPES.includes(l.type)) {
|
|
throw new Error(`registerEventLeases: ${l.id} needs a type, one of ${LEASE_TYPES.join(', ')}`)
|
|
}
|
|
|
|
// Only the numeric types carry a range, and for those it is REQUIRED. A lease
|
|
// on a rate multiplier with no bounds is an operator one keystroke away from
|
|
// setting a shard's skill gain to 5000, which is the class of accident the
|
|
// whole cap machinery exists to make impossible — and unlike a cap, a bad lease
|
|
// value is in force the moment it is applied.
|
|
let min = null
|
|
let max = null
|
|
if (l.type === 'int' || l.type === 'float') {
|
|
min = Number(l.min)
|
|
max = Number(l.max)
|
|
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
|
throw new Error(`registerEventLeases: ${l.id} is ${l.type} and needs a numeric min and max`)
|
|
}
|
|
if (l.type === 'int' && (!Number.isInteger(min) || !Number.isInteger(max))) {
|
|
throw new Error(`registerEventLeases: ${l.id} is int and needs whole-number min and max`)
|
|
}
|
|
if (min > max) throw new Error(`registerEventLeases: ${l.id} has min ${min} above max ${max}`)
|
|
}
|
|
|
|
const maxDurationMs = l.maxDurationMs
|
|
if (!Number.isInteger(maxDurationMs) || maxDurationMs <= 0 || maxDurationMs > MAX_LEASE_MS) {
|
|
throw new Error(
|
|
`registerEventLeases: ${l.id} maxDurationMs must be 1..${MAX_LEASE_MS} ms, got "${l.maxDurationMs}"`,
|
|
)
|
|
}
|
|
for (const fn of ['read', 'apply', 'restore']) {
|
|
if (typeof l[fn] !== 'function') throw new Error(`registerEventLeases: ${l.id} has no ${fn}()`)
|
|
}
|
|
|
|
// **`inForce()` is optional, and it is the fourth question a lease can answer**
|
|
// (Phase 11b). `read` is "what is it now", `apply` is "hold it here", `restore`
|
|
// is "put it back and tell me if somebody moved it" — and none of the three
|
|
// answers "does the game side still have any record of this hold?", which is
|
|
// what a reconcile after an outage needs.
|
|
//
|
|
// It is deliberately not `read()` with a comparison. A value that differs from
|
|
// what the event applied is DRIFT, and drift is a verdict teardown has to
|
|
// deliver through `restore` so the resource lands as `drifted`; a reconcile
|
|
// that inferred absence from a changed value would orphan the row first and
|
|
// destroy the one signal an operator needs. The two questions have different
|
|
// answers on purpose.
|
|
//
|
|
// Optional because the fallback is the posture core takes everywhere else: a
|
|
// lease that cannot say leaves its ledger row alone, which is exactly the
|
|
// behaviour before this phase.
|
|
if (l.inForce !== undefined && typeof l.inForce !== 'function') {
|
|
throw new Error(`registerEventLeases: ${l.id} inForce must be a function`)
|
|
}
|
|
|
|
return {
|
|
id: l.id,
|
|
label: l.label,
|
|
description: l.description || '',
|
|
type: l.type,
|
|
min,
|
|
max,
|
|
maxDurationMs,
|
|
read: l.read,
|
|
apply: l.apply,
|
|
restore: l.restore,
|
|
inForce: l.inForce || null,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `registerEventOptionSources([{ id, label, description, resolve }])`.
|
|
*
|
|
* The values behind a param's `source` (§F, *Param option sources*). Its own
|
|
* registration rather than a field on the action that names it, because a catalog
|
|
* has more than one consumer: `uo.options.items` is the allowlist for granting an
|
|
* item and for taking one back, and two actions declaring it separately would be
|
|
* two allowlists that can disagree.
|
|
*
|
|
* `resolve()` answers `[{ value, label, group? }]`. It may be async, it may talk
|
|
* to a sidecar, and it may fail — the failure is handled at the call
|
|
* (`resolveOptionSource`) rather than here, because the answer to a source that
|
|
* cannot answer is a text box, not a broken form.
|
|
*/
|
|
function checkEventOptionSourceShape(entry) {
|
|
const s = entry || {}
|
|
if (!OPTION_SOURCE_ID.test(s.id || '')) {
|
|
throw new Error(`registerEventOptionSources: bad option source id "${s.id}"`)
|
|
}
|
|
if (!s.label) throw new Error(`registerEventOptionSources: source "${s.id}" has no label`)
|
|
if (typeof s.resolve !== 'function') {
|
|
throw new Error(`registerEventOptionSources: ${s.id} has no resolve()`)
|
|
}
|
|
return { id: s.id, label: s.label, description: s.description || '', resolve: s.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,
|
|
// Checked above and carried here: the column is NOT NULL, so a normalizer
|
|
// that validates the ceiling and then drops it fails every insert in the
|
|
// group at boot — loudly, but only on a real database.
|
|
max_sends_per_hour: r.max_sends_per_hour,
|
|
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: [],
|
|
eventActions: [],
|
|
engagementSeeds: [],
|
|
eventBudgets: [],
|
|
eventLeases: [],
|
|
eventOptionSources: [],
|
|
}
|
|
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))
|
|
},
|
|
// EVENTS.md §F, Phase 1. Present on the staging area from this phase and
|
|
// reached ONLY by `registerCore()` below — `loader.js` builds its own `api`
|
|
// facade and has no method that delegates here, so a module cannot call this
|
|
// yet. Phase 7 adds that facade and bumps MODULE_API to 1.10.0; until then
|
|
// the seam is exercised on every boot by core's own three actions and by
|
|
// nothing else, which is the point of registering them through it.
|
|
registerEventActions(entries) {
|
|
if (!Array.isArray(entries)) throw new Error('registerEventActions: expected an array')
|
|
for (const e of entries) staged.eventActions.push(checkEventActionShape(e))
|
|
},
|
|
// The three that arrive WITH the module-facing seam (Phase 7). Unlike
|
|
// `registerEventActions` above, these have never had a core-only period:
|
|
// `loader.js` forwards all four from the boot this lands on, and core
|
|
// registers through them on the same boot, which is the posture
|
|
// `registerCore()` has taken since the module system's Phase 3.
|
|
registerEventBudgets(entries) {
|
|
if (!Array.isArray(entries)) throw new Error('registerEventBudgets: expected an array')
|
|
for (const e of entries) staged.eventBudgets.push(checkEventBudgetShape(e))
|
|
},
|
|
registerEventLeases(entries) {
|
|
if (!Array.isArray(entries)) throw new Error('registerEventLeases: expected an array')
|
|
for (const e of entries) staged.eventLeases.push(checkEventLeaseShape(e))
|
|
},
|
|
registerEventOptionSources(entries) {
|
|
if (!Array.isArray(entries)) throw new Error('registerEventOptionSources: expected an array')
|
|
for (const e of entries) staged.eventOptionSources.push(checkEventOptionSourceShape(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 = [],
|
|
eventActions: newEventActions = [],
|
|
eventBudgets: newEventBudgets = [],
|
|
eventLeases: newEventLeases = [],
|
|
eventOptionSources: newEventOptionSources = [],
|
|
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)
|
|
}
|
|
|
|
// Actions, against their OWN map and nothing else. No cross-facet check with
|
|
// streams or triggers: an action id and a trigger id are different namespaces
|
|
// (§F), so `uo.champ.start` may legitimately be both a verb and an event, and
|
|
// reading a collision there would forbid the most natural pair of names a
|
|
// module will ever want. No legacy allowlist either — nothing predates this,
|
|
// so the prefix rule has no exceptions and should never grow one.
|
|
const seenActions = new Set()
|
|
for (const a of newEventActions) {
|
|
const held = eventActions.get(a.id)
|
|
if (held) throw new Error(`event action "${a.id}" is already registered by "${held.owner}"`)
|
|
if (seenActions.has(a.id)) throw new Error(`event action "${a.id}" registered twice`)
|
|
if (!namespaced(owner, a.id, {})) {
|
|
throw new Error(`event action "${a.id}" is not namespaced "${owner}."`)
|
|
}
|
|
seenActions.add(a.id)
|
|
}
|
|
|
|
// Budgets, leases and option sources: three more id spaces, checked against
|
|
// their own maps and against nothing else, for the reason the actions loop
|
|
// above gives. No legacy allowlist on any of the three — nothing predates them,
|
|
// so the prefix rule has no exceptions and should never grow one.
|
|
//
|
|
// The one cross-facet check that would be wrong here is budget-against-action:
|
|
// §F puts them in separate id spaces deliberately, and `uo.creatures` as a
|
|
// dimension beside `uo.creature.spawn` as a verb is the most natural pair of
|
|
// names a module will ever write.
|
|
const seenBudgets = new Set()
|
|
for (const b of newEventBudgets) {
|
|
const held = eventBudgets.get(b.id)
|
|
if (held) throw new Error(`event budget "${b.id}" is already registered by "${held.owner}"`)
|
|
if (seenBudgets.has(b.id)) throw new Error(`event budget "${b.id}" registered twice`)
|
|
if (!namespaced(owner, b.id, {})) {
|
|
throw new Error(`event budget "${b.id}" is not namespaced "${owner}."`)
|
|
}
|
|
seenBudgets.add(b.id)
|
|
}
|
|
|
|
const seenLeases = new Set()
|
|
for (const l of newEventLeases) {
|
|
const held = eventLeases.get(l.id)
|
|
if (held) throw new Error(`event lease "${l.id}" is already registered by "${held.owner}"`)
|
|
if (seenLeases.has(l.id)) throw new Error(`event lease "${l.id}" registered twice`)
|
|
if (!namespaced(owner, l.id, {})) {
|
|
throw new Error(`event lease "${l.id}" is not namespaced "${owner}."`)
|
|
}
|
|
seenLeases.add(l.id)
|
|
}
|
|
|
|
const seenSources = new Set()
|
|
for (const s of newEventOptionSources) {
|
|
const held = eventOptionSources.get(s.id)
|
|
if (held) throw new Error(`option source "${s.id}" is already registered by "${held.owner}"`)
|
|
if (seenSources.has(s.id)) throw new Error(`option source "${s.id}" registered twice`)
|
|
if (!namespaced(owner, s.id, {})) {
|
|
throw new Error(`option source "${s.id}" is not namespaced "${owner}."`)
|
|
}
|
|
seenSources.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)
|
|
}
|
|
|
|
// 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 a of newEventActions) eventActions.set(a.id, { owner, ...a })
|
|
for (const b of newEventBudgets) eventBudgets.set(b.id, { owner, ...b })
|
|
for (const l of newEventLeases) eventLeases.set(l.id, { owner, ...l })
|
|
for (const s of newEventOptionSources) eventOptionSources.set(s.id, { owner, ...s })
|
|
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 coreEventActions = require('../config/coreEventActions')
|
|
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 event contract (EVENTS.md §F, Phase 1). Core registers `core.announce`,
|
|
// `core.wait` and `core.cue` through the SAME staging area Phase 7 will hand a
|
|
// module, so the registry is exercised on every boot long before a module uses
|
|
// it — the argument registerCore() has made since the module system's Phase 3.
|
|
api.registerEventActions(coreEventActions.ACTIONS)
|
|
// And core's own option source (Phase 7, org lead 2026-09-03). `core.announce`
|
|
// takes a leg id, and until now that was a free-text box whose typo was caught
|
|
// at DISPATCH, mid-run — which is exactly the defect Phase 6's walk hit, an
|
|
// announce leg "site" no module registers. The legs are already in a registry
|
|
// with their labels, so the dropdown costs nothing new, and core registering an
|
|
// option source means the seam's first exercise is not a module's.
|
|
api.registerEventOptionSources(coreEventActions.OPTION_SOURCES)
|
|
|
|
// 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,
|
|
eventActions: eventActions.size,
|
|
eventOptionSources: eventOptionSources.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()
|
|
eventActions.clear()
|
|
eventBudgets.clear()
|
|
eventLeases.clear()
|
|
eventOptionSources.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,
|
|
allEventActions,
|
|
eventAction,
|
|
isEventAction,
|
|
allEventBudgets,
|
|
eventBudget,
|
|
isEventBudget,
|
|
allEventLeases,
|
|
eventLease,
|
|
allEventOptionSources,
|
|
resolveOptionSource,
|
|
allEngagementSeeds,
|
|
engagementSeedsFor,
|
|
SEEDABLE_CHANNELS,
|
|
VARIABLE_TYPES,
|
|
TRIGGER_KINDS,
|
|
ACTION_RISKS,
|
|
ACTION_REVERSIBLE,
|
|
ACTION_PARAM_TYPES,
|
|
LEASE_TYPES,
|
|
MAX_LEASE_MS,
|
|
DEFAULT_BUDGET_MS,
|
|
stage,
|
|
apply,
|
|
registerCore,
|
|
isCoreRegistered,
|
|
_reset,
|
|
}
|