DEMO_MODE.md §3.4 refuses a denied console command when the event is saved and when it runs. Saving is core's route, and core never asked a module about a step's params, so the save-time half had no seam (DM18). An event action may now declare demoRefuses(params), returning a sentence or null. With DEMO_MODE on, create, save and publish ask it for every step; the first sentence refuses the write 403 DEMO_LOCKED with that sentence as the message (and in errors). A hook that throws refuses too. Off the demo it is validated and never called, and the catalog strips it with the other callables. The events controller passes code and message through on create, update and publish, so the client's one DEMO_LOCKED toast fires. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2240 lines
105 KiB
JavaScript
2240 lines
105 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()
|
|
|
|
// template id → { owner, id, label, description, inputs, build } (MODULE_API
|
|
// 1.13.0; Rust PLAN_REDESIGNS §2.5, D192).
|
|
//
|
|
// **An authoring shortcut and nothing else.** A template turns a handful of
|
|
// inputs into an ordinary draft — phases, steps, params — and the draft is the
|
|
// only thing that survives: no column records which template made it, and the
|
|
// runner never sees one. That is what lets a template change, or its module go,
|
|
// without touching a single definition it was ever used for.
|
|
const eventTemplates = 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`, `cost`, `progress` and `demoRefuses` 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, progress, demoRefuses, ...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
|
|
|
|
// How a targeted lease's reservation ref is written, and the one place that
|
|
// knows it. `#` is safe as the separator because `EVENT_ID` admits only
|
|
// `[a-z0-9_.]`, so no lease id can contain one and the split is unambiguous
|
|
// however odd a module's target string is.
|
|
const LEASE_TARGET_SEP = '#'
|
|
|
|
/** Compose the ref an `override` row is reserved under. */
|
|
const leaseRef = (id, target) =>
|
|
target === null || target === undefined || target === '' ? id : `${id}${LEASE_TARGET_SEP}${target}`
|
|
|
|
/**
|
|
* Split an `override` row's ref back into the lease and the target it names.
|
|
*
|
|
* **Every reader of a ledgered lease needs this, not just the one that wrote
|
|
* it.** `cleanup.restoreLease` and `ledger.normalise` both look a lease up by
|
|
* `row.ref`, and both were correct for exactly as long as a ref was a bare lease
|
|
* id. A targeted row would have missed in both — cleanup reporting a lease no
|
|
* module registers and refusing to restore a world that really was changed,
|
|
* which is the worst failure this table has.
|
|
*/
|
|
function parseLeaseRef(ref) {
|
|
const text = String(ref === undefined || ref === null ? '' : ref)
|
|
const at = text.indexOf(LEASE_TARGET_SEP)
|
|
if (at < 0) return { id: text, target: null }
|
|
return { id: text.slice(0, at), target: text.slice(at + 1) }
|
|
}
|
|
|
|
/** The lease an `override` row names, target included. Null when nothing registers it. */
|
|
function eventLeaseForRef(ref) {
|
|
const { id, target } = parseLeaseRef(ref)
|
|
const lease = eventLeases.get(id) || null
|
|
return lease ? { lease, target } : null
|
|
}
|
|
|
|
/** Every option source WITHOUT its resolver — the authoring form's list. */
|
|
const allEventOptionSources = () =>
|
|
[...eventOptionSources.values()].map(({ resolve, ...rest }) => rest)
|
|
|
|
// The longest search term a source is asked to honour. Bounded rather than
|
|
// refused: a term this long is a paste rather than a search, and truncating it
|
|
// still answers something, where refusing would blank a form mid-keystroke.
|
|
const MAX_OPTION_QUERY = 120
|
|
|
|
// The longest note an option may carry, in characters.
|
|
const MAX_OPTION_NOTE = 300
|
|
|
|
/**
|
|
* 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, { q = '' } = {}) {
|
|
const entry = eventOptionSources.get(id)
|
|
if (!entry) return { ok: false, reason: `no module registers the option source "${id}"` }
|
|
// **The search term is passed to every source and required of none** (Phase
|
|
// 12b). A source answers a list; one whose catalog is larger than a dropdown
|
|
// can hold answers a list NARROWED BY a term, and the two are the same
|
|
// callable because the difference is the source's business rather than the
|
|
// form's. A resolver that ignores the argument behaves exactly as it did
|
|
// before this existed, which is what made this additive.
|
|
//
|
|
// It exists because the first source with more entries than `MAX_OPTIONS` is
|
|
// Phase 12b's spawner target — 6,707 spawn points against a 2,000 bound. Every
|
|
// source before it fitted, so the bound had only ever truncated in theory; a
|
|
// dropdown quietly missing two thirds of the world is the same failure 12a
|
|
// named for decoration, and truncation cannot be the answer to it.
|
|
const term = String(q || '').trim().slice(0, MAX_OPTION_QUERY)
|
|
let raw
|
|
try {
|
|
raw = await entry.resolve({ q: term })
|
|
} 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)
|
|
// A sentence the editor shows under the field while this option is the
|
|
// chosen one (MODULE_API 1.13.0; Rust PLAN_REDESIGNS §2.6) — advice about
|
|
// the value, like "9 of 41 linked players have used this kit up". Bounded,
|
|
// because it is rendered as-is and one option's essay is a form nobody
|
|
// can read.
|
|
if (o.note) option.note = String(o.note).slice(0, MAX_OPTION_NOTE)
|
|
options.push(option)
|
|
}
|
|
// `searchable` is reported so the authoring form knows which field is a
|
|
// typeahead and which is a plain select. Without it P13 would have to infer it
|
|
// from whether a truncated list came back, which is a guess that reads
|
|
// correctly right up until a small shard's spawner list fits.
|
|
return { ok: true, id, label: entry.label, owner: entry.owner, searchable: entry.searchable, options, q: term }
|
|
}
|
|
|
|
// ── 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
|
|
|
|
// A param that is a length of time says which unit it is in (MODULE_API 1.13.0;
|
|
// Rust PLAN_REDESIGNS §2.2). One unit, minutes, because every timed param
|
|
// registered so far is one — a lease's `minutes`, a zone's `minutes` — and a
|
|
// vocabulary of one is a vocabulary that can grow without a version.
|
|
const PARAM_DURATIONS = ['minutes']
|
|
|
|
// A param that holds several values (MODULE_API 1.14.0; Rust PLAN_REDESIGNS
|
|
// §12.3, D344). Two types, because the one step that needed it picks strings
|
|
// from a source and an int list is the same check one type over; a list of
|
|
// booleans, dates or links is a shape nothing has asked for, and every control
|
|
// the editor draws for a list is a picker or a tag box.
|
|
const MULTIPLE_PARAM_TYPES = ['string', 'int']
|
|
// `max` when the declaration leaves it out, and the most it may say. The ceiling
|
|
// is a bound on one step's params, not a number anyone should reach: a list of a
|
|
// hundred is a step nobody can read back.
|
|
const DEFAULT_MULTIPLE_MAX = 20
|
|
const MAX_MULTIPLE_MAX = 100
|
|
|
|
// `call` names the registration in the error, because a template's inputs are
|
|
// checked by this same function (they ARE params: one control each, with a
|
|
// name, a type, an example and maybe a source).
|
|
function checkActionParam(actionId, entry, seen, call = 'registerEventActions') {
|
|
const { name, type, required, example, description, source, duration, multiple, max } = entry || {}
|
|
const where = `${call}: ${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}"`)
|
|
}
|
|
// **A duration is what lets the editor see a timed step** (§2.2). A phase that
|
|
// ends before its longest timed step gives the thing back early — a lease held
|
|
// "for 10 minutes" in a phase that ends at once lasted one second — and the
|
|
// editor can only say so about a param that says it is a length of time.
|
|
// Numeric only: a duration typed as a string would be a number the editor
|
|
// has to parse with rules nobody declared.
|
|
if (duration !== undefined && duration !== null) {
|
|
if (!PARAM_DURATIONS.includes(duration)) {
|
|
throw new Error(`${where}: param "${name}" has duration "${duration}"; the one unit is ${PARAM_DURATIONS.join(', ')}`)
|
|
}
|
|
if (type !== 'int' && type !== 'float') {
|
|
throw new Error(`${where}: param "${name}" is a duration and must be int or float, not ${type}`)
|
|
}
|
|
}
|
|
// **`multiple` makes the value a list** (1.14.0, §12.3). The example stays ONE
|
|
// value — it is the placeholder of the box a value is typed into — and the
|
|
// value a step stores is a JSON array of them (`events/spec.js`).
|
|
//
|
|
// Refused on a duration, because a list of lengths of time is not a hold the
|
|
// editor can keep a phase open for, and on a template's inputs, because
|
|
// `build()` is a module's own code reading `inputs.x` as one value and this
|
|
// version does not change what it is handed.
|
|
if (multiple !== undefined && multiple !== null && multiple !== false) {
|
|
if (multiple !== true) throw new Error(`${where}: param "${name}" multiple must be true or left out`)
|
|
if (call === 'registerEventTemplates') {
|
|
throw new Error(`${where}: input "${name}" cannot be multiple; a template's inputs are single values`)
|
|
}
|
|
if (!MULTIPLE_PARAM_TYPES.includes(type)) {
|
|
throw new Error(`${where}: param "${name}" is multiple and must be ${MULTIPLE_PARAM_TYPES.join(' or ')}, not ${type}`)
|
|
}
|
|
if (duration !== undefined && duration !== null) {
|
|
throw new Error(`${where}: param "${name}" cannot be both multiple and a duration`)
|
|
}
|
|
if (Array.isArray(example)) {
|
|
throw new Error(`${where}: param "${name}" needs ONE example, not a list (it is the placeholder of one value)`)
|
|
}
|
|
}
|
|
if (max !== undefined && max !== null) {
|
|
if (multiple !== true) throw new Error(`${where}: param "${name}" has a max but is not multiple`)
|
|
if (!Number.isInteger(max) || max < 1 || max > MAX_MULTIPLE_MAX) {
|
|
throw new Error(`${where}: param "${name}" max must be 1..${MAX_MULTIPLE_MAX}, got "${max}"`)
|
|
}
|
|
}
|
|
return {
|
|
name,
|
|
type,
|
|
required: Boolean(required),
|
|
example,
|
|
source: source === undefined ? null : source,
|
|
description: description || '',
|
|
...(duration ? { duration } : {}),
|
|
// Absent rather than `false` on every other param, as `duration` is, so a
|
|
// module that never says it reads back exactly the declaration it wrote.
|
|
...(multiple === true ? { multiple: true, max: max ?? DEFAULT_MULTIPLE_MAX } : {}),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `registerEventActions([{ id, label, risk, reversible, version, budgetMs, cost, params, perform, revert, reconcile, progress, demoRefuses }])`.
|
|
*
|
|
* 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`)
|
|
}
|
|
// 1.12.0, optional: how a live run's step stands, for its public page
|
|
// (`events/progress.js`). Any action may declare it; an action that ledgers
|
|
// nothing can still say something about the world it changed.
|
|
if (a.progress !== undefined && typeof a.progress !== 'function') {
|
|
throw new Error(`registerEventActions: ${a.id} progress must be a function`)
|
|
}
|
|
// 1.19.0, optional: the public demo's save-time refusal (DEMO_MODE.md §3.4,
|
|
// DM18). Asked only with DEMO_MODE on, by the definition save path; a
|
|
// sentence back refuses the save `403 DEMO_LOCKED`. The module still checks
|
|
// at run, because a definition can predate the list it is checked against.
|
|
if (a.demoRefuses !== undefined && typeof a.demoRefuses !== 'function') {
|
|
throw new Error(`registerEventActions: ${a.id} demoRefuses 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,
|
|
progress: a.progress || null,
|
|
demoRefuses: a.demoRefuses || 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`)
|
|
}
|
|
|
|
// **A TARGETED lease is a family of values rather than one** (Phase 12b).
|
|
//
|
|
// Every lease before this one named a single value — a config key, a rate —
|
|
// and the lease id WAS the target, which is why the four callables took none
|
|
// and why the reservation ref was the id alone. An object property is not that
|
|
// shape: `Spawner.MaxCount` is one lease over thousands of spawners, and two
|
|
// runs turning up two DIFFERENT spawners must both be allowed while two runs
|
|
// turning up the same one must not.
|
|
//
|
|
// So a lease may declare a target, and when it does core adds one to the
|
|
// reservation ref (`<id>#<target>`) and hands it to every callable. The
|
|
// two-events-one-target refusal then comes from the same unique index it
|
|
// always did, at the granularity the world actually has. **Core still owns the
|
|
// duration bound and the conflict check** — which is the whole of why §F put
|
|
// the verb in core, and the reason this is an extension of `core.lease` rather
|
|
// than a lease verb of the module's own.
|
|
let target = null
|
|
if (l.target !== undefined && l.target !== null) {
|
|
const t = l.target
|
|
if (typeof t !== 'object' || Array.isArray(t)) {
|
|
throw new Error(`registerEventLeases: ${l.id} target must be an object`)
|
|
}
|
|
if (!t.label) throw new Error(`registerEventLeases: ${l.id} target has no label`)
|
|
// The source is checked for shape only, never for existence, on
|
|
// `resolveOptionSource`'s own argument: a source that cannot answer degrades
|
|
// its field to free text rather than blocking the form, and a source
|
|
// registered by a module that boots later must not make this one throw.
|
|
if (t.source !== undefined && !OPTION_SOURCE_ID.test(t.source || '')) {
|
|
throw new Error(`registerEventLeases: ${l.id} target names a bad option source "${t.source}"`)
|
|
}
|
|
target = {
|
|
label: String(t.label),
|
|
source: t.source || null,
|
|
example: t.example === undefined ? null : String(t.example),
|
|
description: t.description || '',
|
|
}
|
|
}
|
|
|
|
// **A closed set of values, for the type that has no range** (Phase 12b).
|
|
// `min`/`max` bound the numeric types and nothing bounded `string`, so a string
|
|
// lease was free text validated only by the game side — which means an invalid
|
|
// value is a refusal at DISPATCH, unattended, halfway through a run, instead of
|
|
// a refusal on the form. Optional, because a genuinely free-text lease still
|
|
// exists; enforced by `coerceLeaseValue` when present.
|
|
let values = null
|
|
if (l.values !== undefined && l.values !== null) {
|
|
if (!Array.isArray(l.values) || !l.values.length) {
|
|
throw new Error(`registerEventLeases: ${l.id} values must be a non-empty array`)
|
|
}
|
|
if (l.type !== 'string') {
|
|
throw new Error(`registerEventLeases: ${l.id} is ${l.type}; values is for string leases`)
|
|
}
|
|
values = l.values.map((v) => String(v))
|
|
}
|
|
|
|
// **The lease's own example for its value** (MODULE_API 1.13.0; Rust
|
|
// PLAN_REDESIGNS §2.1, U-3). `core.lease`'s `value` param has one example for
|
|
// every lease there is, and a Rust admin borrowing a group permission was shown
|
|
// a skill-gain rate. Optional; a scalar, because it is a placeholder in one
|
|
// field. Not range-checked: it is shown, never sent.
|
|
let example = null
|
|
if (l.example !== undefined && l.example !== null && l.example !== '') {
|
|
if (!['string', 'number', 'boolean'].includes(typeof l.example)) {
|
|
throw new Error(`registerEventLeases: ${l.id} example must be a single value`)
|
|
}
|
|
example = l.example
|
|
}
|
|
|
|
return {
|
|
id: l.id,
|
|
label: l.label,
|
|
description: l.description || '',
|
|
type: l.type,
|
|
min,
|
|
max,
|
|
values,
|
|
example,
|
|
target,
|
|
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()`)
|
|
}
|
|
// **A declaration, not an inference** (Phase 12b). `resolve({ q })` is passed a
|
|
// term whether or not the source reads it, so nothing here can tell from the
|
|
// callable alone whether a term would narrow the answer. The module says, and
|
|
// the authoring form renders a typeahead rather than a select for the ones
|
|
// that do.
|
|
return {
|
|
id: s.id,
|
|
label: s.label,
|
|
description: s.description || '',
|
|
searchable: s.searchable === true,
|
|
resolve: s.resolve,
|
|
}
|
|
}
|
|
|
|
// The most inputs a template may ask for. A template is a shortcut past the
|
|
// editor, and one that asks for a dozen things is the editor with fewer labels.
|
|
const MAX_TEMPLATE_INPUTS = 8
|
|
|
|
/**
|
|
* `registerEventTemplates([{ id, label, description, inputs, build }])`
|
|
* (MODULE_API 1.13.0; Rust PLAN_REDESIGNS §2.5, D192).
|
|
*
|
|
* A named starting point for a new event: the author fills in `inputs`, core
|
|
* calls `build({ inputs })`, and what comes back is a DRAFT the editor opens like
|
|
* any other — `{ title, summary?, spec: { phases } }`. Nothing about the template
|
|
* survives into the definition.
|
|
*
|
|
* **`inputs` are params, checked by the same function**, because they are drawn
|
|
* by the same widget: a name, a type, an example, maybe a source. That gives an
|
|
* input the dropdown, the search box and the duration unit for free, and gives
|
|
* the author one form language rather than two.
|
|
*
|
|
* `build` may be async and may read the module's own data (the kit weekend needs
|
|
* the kit's permission). It is never trusted: core checks the inputs before the
|
|
* call and validates the spec after it, and a throw is the author's error
|
|
* message rather than a 500 (`buildEventTemplate` in eventDefinitions.model).
|
|
*/
|
|
function checkEventTemplateShape(entry) {
|
|
const t = entry || {}
|
|
if (!EVENT_ID.test(t.id || '')) throw new Error(`registerEventTemplates: bad template id "${t.id}"`)
|
|
if (!t.label) throw new Error(`registerEventTemplates: template "${t.id}" has no label`)
|
|
if (typeof t.build !== 'function') throw new Error(`registerEventTemplates: ${t.id} has no build()`)
|
|
const rawInputs = t.inputs === undefined ? [] : t.inputs
|
|
if (!Array.isArray(rawInputs)) throw new Error(`registerEventTemplates: ${t.id} inputs must be an array`)
|
|
if (rawInputs.length > MAX_TEMPLATE_INPUTS) {
|
|
throw new Error(`registerEventTemplates: ${t.id} asks for ${rawInputs.length} inputs; the most is ${MAX_TEMPLATE_INPUTS}`)
|
|
}
|
|
const seen = new Set()
|
|
return {
|
|
id: t.id,
|
|
label: t.label,
|
|
description: t.description || '',
|
|
inputs: rawInputs.map((p) => checkActionParam(t.id, p, seen, 'registerEventTemplates')),
|
|
build: t.build,
|
|
}
|
|
}
|
|
|
|
/** Every template WITHOUT its `build` — what the catalog serves. */
|
|
const allEventTemplates = () =>
|
|
[...eventTemplates.values()].map(({ build, ...rest }) => rest)
|
|
|
|
/** One template, `build` included. */
|
|
const eventTemplate = (id) => eventTemplates.get(id) || null
|
|
|
|
|
|
// ── 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: [],
|
|
eventTemplates: [],
|
|
}
|
|
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))
|
|
},
|
|
// MODULE_API 1.13.0. Core registers none; the first is Module-Rust's kit
|
|
// weekend (Rust PLAN_REDESIGNS §2.5).
|
|
registerEventTemplates(entries) {
|
|
if (!Array.isArray(entries)) throw new Error('registerEventTemplates: expected an array')
|
|
for (const e of entries) staged.eventTemplates.push(checkEventTemplateShape(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 = [],
|
|
eventTemplates: newEventTemplates = [],
|
|
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 seenTemplates = new Set()
|
|
for (const t of newEventTemplates) {
|
|
const held = eventTemplates.get(t.id)
|
|
if (held) throw new Error(`event template "${t.id}" is already registered by "${held.owner}"`)
|
|
if (seenTemplates.has(t.id)) throw new Error(`event template "${t.id}" registered twice`)
|
|
if (!namespaced(owner, t.id, {})) {
|
|
throw new Error(`event template "${t.id}" is not namespaced "${owner}."`)
|
|
}
|
|
seenTemplates.add(t.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 t of newEventTemplates) eventTemplates.set(t.id, { owner, ...t })
|
|
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()
|
|
eventTemplates.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,
|
|
eventLeaseForRef,
|
|
parseLeaseRef,
|
|
leaseRef,
|
|
allEventOptionSources,
|
|
allEventTemplates,
|
|
eventTemplate,
|
|
PARAM_DURATIONS,
|
|
MULTIPLE_PARAM_TYPES,
|
|
DEFAULT_MULTIPLE_MAX,
|
|
MAX_MULTIPLE_MAX,
|
|
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,
|
|
}
|