feat(modules): event triggers, audiences and the ceiling lattice (engagement Phase 2)
The contract half of the engagement system: a module (and core) can DECLARE an
event with a payload contract and fire it. Nothing delivers yet — `emit`
validates, logs and stops, and Phase 4 replaces that log line with the engine.
`api.registerEventTriggers` and `api.registerAudiences` ride the existing
stage()/apply() validate-then-commit discipline, so a registrant that throws
halfway leaves nothing behind. `ctx.events.emit` is fire-and-forget and binds
the owner from the calling module — a module fires its own triggers and no one
else's. `ctx.inbox.push` is present and throws until Phase 7, the shape 1.6.0
settled on for a member that arrives a phase late.
MODULE_API_VERSION 1.7.0 on both halves. Additions only; module-uo's
`coreApi: "^1.3.0"` still resolves.
Three design decisions, approved by the org lead before any code:
ONE NAMESPACE for trigger ids and notification-stream ids (ENGAGEMENT.md §7.2,
against the recommendation in the text). A trigger is a payload contract
attached to an id that may also carry a subscription toggle, so an id has
exactly one owner across both facets, checked in both directions. Core's five
trigger ids ARE its five stream ids, so the same-owner upgrade case is
exercised on every boot rather than only by a module. It keeps
notification_channel_prefs single-keyed in Phase 3, where two namespaces would
have forced a `kind` discriminator into its primary key.
Two knock-on effects appeared only once it was implemented. The id grammar had
to be RELAXED to admit `_` inside a segment — §4.3's own worked example is
`uo.house.idoc_warning`, and two grammars over one namespace would mean an id
legal as a trigger and illegal as the stream it is the same event as. And the
seven grandfathered `uo.*` ids had to share their legacy allowlist with
triggers, because under one namespace `idoc.warning` is a single id. The push
catalog is untouched either way: allStreams() still serves the stream facet
only, so the shipped Android client sees exactly what it saw before.
THE CEILING LATTICE (G24), which the plan named everywhere and defined nowhere.
It is containment, not size: everyone ⊃ authenticated ⊃ {subscribers, members,
staff, owner}, with the four leaves mutually incomparable. The flat total order
the plan's wording invites would let a `staff`-ceilinged trigger be given an
`owner` audience — a rule that mails cheat detection to the player it detected.
Fewer people is not less exposure. Two incomparable ceilings have no meet at
all, so a composition is refused rather than guessed; union-widens is the
intuitive implementation and it is the wrong one.
`kind: 'event' | 'scheduled'` is declarable now and no evaluator exists (§7.1
Q6). Registration accepts `scheduled` and emit refuses to fire one, so `kind`
means something from the moment it can be written rather than from the moment
it is honoured.
Also: `GET /admin/engagement/{triggers,audiences}`, served from the registries
rather than a table so an uninstalled module simply stops appearing;
`npm run engagement:manifest` plus its CI `--check`, the twin of the route
manifest, because renaming a variable breaks stored templates silently, at send
time, in mail someone already received.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -29,12 +29,28 @@
|
||||
// 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.
|
||||
//
|
||||
// **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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -80,6 +96,21 @@ let teamProvider = null
|
||||
// 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()
|
||||
|
||||
let coreRegistered = false
|
||||
|
||||
// Stream ids that predate the module system and may not carry their owner's
|
||||
@@ -99,8 +130,17 @@ const LEGACY_STREAM_IDS = {
|
||||
// announce_job_legs.leg and the body of the admin retry endpoint.
|
||||
const LEGACY_LEGS = { uo: ['towncrier'] }
|
||||
|
||||
const STREAM_ID = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$/
|
||||
// ONE grammar for the one namespace streams and triggers share. It relaxes what
|
||||
// `STREAM_ID` used to allow by admitting `_` inside a segment, because the
|
||||
// trigger ids this contract is written for have them (`uo.house.idoc_warning`,
|
||||
// ENGAGEMENT.md §4.3) and two grammars over one namespace would mean an id that
|
||||
// is legal as a trigger and illegal as the stream it is the same event as.
|
||||
// Relaxation only: every id valid before is valid now, and no stored id changes.
|
||||
const EVENT_ID = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/
|
||||
const LEG_ID = /^[a-z][a-z0-9.]{1,62}$/
|
||||
// Audiences are their own id space (see the `audiences` Map), so they get their
|
||||
// own constant even though the grammar is the same one.
|
||||
const AUDIENCE_ID = EVENT_ID
|
||||
|
||||
// A module's claim must carry its id. Core's ids are its own namespace, and the
|
||||
// grandfathered names are the ones that predate all of this.
|
||||
@@ -243,6 +283,68 @@ const slashCommandDefinitions = () =>
|
||||
/** One command, handler included. The dispatcher's lookup. */
|
||||
const slashCommand = (name) => slashCommands.get(name) || null
|
||||
|
||||
// ── Event triggers (ENGAGEMENT.md §4.3) ────────────────────────────────────
|
||||
|
||||
/** Every declaration, core's first, in registration order. The admin catalog. */
|
||||
const allTriggers = () => [...triggers.values()]
|
||||
|
||||
/** One declaration, or null. The emit path's lookup and the rule editor's. */
|
||||
const eventTrigger = (id) => triggers.get(id) || null
|
||||
|
||||
/**
|
||||
* Who owns this id, across BOTH facets — the one-namespace question.
|
||||
*
|
||||
* A caller asking "may this module emit this?" wants this rather than
|
||||
* `eventTrigger(id).owner`, because an id can be held as a stream by one owner
|
||||
* and not yet declared as a trigger by anyone, and that id is still taken.
|
||||
*/
|
||||
const eventOwner = (id) => triggers.get(id)?.owner || streamOwners.get(id) || null
|
||||
|
||||
// ── Audiences (§5.1a) ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every declaration WITHOUT its resolver — what the admin surface serves.
|
||||
*
|
||||
* The resolver is stripped for the same reason a slash command's handler is:
|
||||
* this is the object that leaves the process, and `resolve` is a function over a
|
||||
* module's own store that no client has any business holding a reference to.
|
||||
*/
|
||||
const allAudiences = () => [...audiences.values()].map(({ resolve, ...rest }) => rest)
|
||||
|
||||
/** One declaration, resolver included. The engine's lookup. */
|
||||
const audience = (id) => audiences.get(id) || null
|
||||
|
||||
/**
|
||||
* Resolve a declared audience to user ids, never throwing.
|
||||
*
|
||||
* Three answers, and the middle one is the contract (§5.1a rule 4): a registered
|
||||
* audience answers `{ dormant: false, userIds }`; an audience whose module is
|
||||
* uninstalled answers `{ dormant: true, userIds: [] }` — the EMPTY set and a
|
||||
* flag, never an error and never a fallback to some other set of people; and a
|
||||
* resolver that throws or answers a non-array is logged and treated as empty,
|
||||
* because a module's storage problem must not become a send to the wrong people.
|
||||
*
|
||||
* `userIds` is filtered to positive integers here rather than trusted. It is the
|
||||
* one value a module hands core that decides who receives mail, and the resolver
|
||||
* is module code running over a module's own store.
|
||||
*/
|
||||
async function resolveAudience(id, params = {}) {
|
||||
const entry = audiences.get(id)
|
||||
if (!entry) return { dormant: true, userIds: [] }
|
||||
try {
|
||||
const raw = await entry.resolve(params)
|
||||
if (!Array.isArray(raw)) {
|
||||
log.warn('audience resolver did not return an array', { audience: id, owner: entry.owner })
|
||||
return { dormant: false, userIds: [] }
|
||||
}
|
||||
const userIds = [...new Set(raw.map(Number).filter((n) => Number.isInteger(n) && n > 0))]
|
||||
return { dormant: false, userIds }
|
||||
} catch (err) {
|
||||
log.error('audience resolver failed', { audience: id, owner: entry.owner, message: err.message })
|
||||
return { dormant: false, userIds: [] }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shape checks, run the moment a registrant calls ────────────────────────
|
||||
//
|
||||
// Split from the collision checks below on the same line PR 3 drew through
|
||||
@@ -251,7 +353,7 @@ const slashCommand = (name) => slashCommands.get(name) || null
|
||||
// depends on other registrants has to wait for the batch to be complete.
|
||||
|
||||
function checkStreamShape(entry) {
|
||||
if (!entry || !STREAM_ID.test(entry.id || '')) {
|
||||
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`)
|
||||
@@ -448,6 +550,179 @@ function checkPostHookShape(entry) {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// `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
|
||||
@@ -473,7 +748,15 @@ function checkExtensionShape(slot, router, specFile) {
|
||||
*/
|
||||
function stage(owner) {
|
||||
const staged = {
|
||||
owner, streams: [], legs: [], extensions: [], postHooks: [], teamProviders: [], slashCommands: [],
|
||||
owner,
|
||||
streams: [],
|
||||
legs: [],
|
||||
extensions: [],
|
||||
postHooks: [],
|
||||
teamProviders: [],
|
||||
slashCommands: [],
|
||||
triggers: [],
|
||||
audiences: [],
|
||||
}
|
||||
return {
|
||||
staged,
|
||||
@@ -497,6 +780,14 @@ function stage(owner) {
|
||||
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))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,6 +808,8 @@ function apply({
|
||||
postHooks: newPostHooks = [],
|
||||
teamProviders: newTeamProviders = [],
|
||||
slashCommands: newSlashCommands = [],
|
||||
triggers: newTriggers = [],
|
||||
audiences: newAudiences = [],
|
||||
}) {
|
||||
// ── validate ──
|
||||
const seenStreams = new Set()
|
||||
@@ -524,12 +817,54 @@ function apply({
|
||||
const held = streamOwners.get(s.id)
|
||||
if (held) throw new Error(`stream "${s.id}" is already registered by "${held}"`)
|
||||
if (seenStreams.has(s.id)) throw new Error(`stream "${s.id}" registered twice`)
|
||||
// The cross-facet half of the one-namespace rule (§7.2). A stream may share
|
||||
// its id with a TRIGGER — that is the whole point, `news.post` is one event
|
||||
// with two facets — but only when the same registrant owns both. Someone
|
||||
// else's trigger id is taken.
|
||||
const heldAsTrigger = triggers.get(s.id)
|
||||
if (heldAsTrigger && heldAsTrigger.owner !== owner) {
|
||||
throw new Error(`stream "${s.id}" is already registered as an event trigger by "${heldAsTrigger.owner}"`)
|
||||
}
|
||||
if (!namespaced(owner, s.id, LEGACY_STREAM_IDS)) {
|
||||
throw new Error(`stream "${s.id}" is not namespaced "${owner}."`)
|
||||
}
|
||||
seenStreams.add(s.id)
|
||||
}
|
||||
|
||||
// Triggers, against the SAME namespace and the SAME legacy allowlist as
|
||||
// streams above. Sharing LEGACY_STREAM_IDS is not laziness: under one
|
||||
// namespace `idoc.warning` is one id, so if `uo` may hold it as a stream
|
||||
// without the prefix it may hold it as a trigger without the prefix, and any
|
||||
// other answer would mean the seven grandfathered ids could never gain a
|
||||
// payload contract.
|
||||
const seenTriggers = new Set()
|
||||
for (const t of newTriggers) {
|
||||
const held = triggers.get(t.id)
|
||||
if (held) throw new Error(`event trigger "${t.id}" is already registered by "${held.owner}"`)
|
||||
if (seenTriggers.has(t.id)) throw new Error(`event trigger "${t.id}" registered twice`)
|
||||
const heldAsStream = streamOwners.get(t.id)
|
||||
if (heldAsStream && heldAsStream !== owner) {
|
||||
throw new Error(`event trigger "${t.id}" is already registered as a notification stream by "${heldAsStream}"`)
|
||||
}
|
||||
if (!namespaced(owner, t.id, LEGACY_STREAM_IDS)) {
|
||||
throw new Error(`event trigger "${t.id}" is not namespaced "${owner}."`)
|
||||
}
|
||||
seenTriggers.add(t.id)
|
||||
}
|
||||
|
||||
const seenAudiences = new Set()
|
||||
for (const a of newAudiences) {
|
||||
const held = audiences.get(a.id)
|
||||
if (held) throw new Error(`audience "${a.id}" is already registered by "${held.owner}"`)
|
||||
if (seenAudiences.has(a.id)) throw new Error(`audience "${a.id}" registered twice`)
|
||||
// No legacy allowlist — nothing predates audiences, so the prefix rule has no
|
||||
// exceptions and should never grow one.
|
||||
if (!namespaced(owner, a.id, {})) {
|
||||
throw new Error(`audience "${a.id}" is not namespaced "${owner}."`)
|
||||
}
|
||||
seenAudiences.add(a.id)
|
||||
}
|
||||
|
||||
const seenLegs = new Set()
|
||||
for (const l of newLegs) {
|
||||
const held = legs.get(l.leg)
|
||||
@@ -584,6 +919,8 @@ function apply({
|
||||
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 })
|
||||
}
|
||||
|
||||
// ── Core's own registrations ───────────────────────────────────────────────
|
||||
@@ -604,12 +941,17 @@ function registerCore() {
|
||||
|
||||
/* eslint-disable global-require */
|
||||
const coreStreams = require('../config/coreStreams')
|
||||
const coreTriggers = require('../config/coreTriggers')
|
||||
const discordLeg = require('../utils/discordAnnounce')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
const api = stage('core')
|
||||
api.registerNotificationStreams(coreStreams.STREAMS)
|
||||
api.registerAnnounceLeg(discordLeg.leg)
|
||||
// The engagement contract (ENGAGEMENT.md Phase 2). Core's five trigger ids ARE
|
||||
// its five stream ids — the same-owner upgrade the one-namespace rule above is
|
||||
// written for — so this batch exercises the cross-facet check on every boot.
|
||||
api.registerEventTriggers(coreTriggers.TRIGGERS)
|
||||
|
||||
// The three lines that used to follow — the shard stream catalog, the town
|
||||
// crier leg and the `admin.users.detail` filling — were shard CONTENT held
|
||||
@@ -622,6 +964,7 @@ function registerCore() {
|
||||
|
||||
log.info('core registrations complete', {
|
||||
streams: streams.length,
|
||||
eventTriggers: triggers.size,
|
||||
announceLegs: legs.size,
|
||||
extensions: [...slots.keys()].filter(slotFilledBy),
|
||||
})
|
||||
@@ -651,6 +994,8 @@ function _reset() {
|
||||
postHooks.clear()
|
||||
teamProvider = null
|
||||
slashCommands.clear()
|
||||
triggers.clear()
|
||||
audiences.clear()
|
||||
coreRegistered = false
|
||||
}
|
||||
|
||||
@@ -672,6 +1017,14 @@ module.exports = {
|
||||
hasTeamProvider,
|
||||
slashCommandDefinitions,
|
||||
slashCommand,
|
||||
allTriggers,
|
||||
eventTrigger,
|
||||
eventOwner,
|
||||
allAudiences,
|
||||
audience,
|
||||
resolveAudience,
|
||||
VARIABLE_TYPES,
|
||||
TRIGGER_KINDS,
|
||||
stage,
|
||||
apply,
|
||||
registerCore,
|
||||
|
||||
Reference in New Issue
Block a user