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:
107
server/src/modules/ceilings.js
Normal file
107
server/src/modules/ceilings.js
Normal file
@@ -0,0 +1,107 @@
|
||||
// ── Audience ceilings ──────────────────────────────────────────────────────
|
||||
//
|
||||
// G24, and the one piece of ENGAGEMENT.md that was named everywhere and defined
|
||||
// nowhere: §5.1a says a composed segment takes "the narrowest ceiling it
|
||||
// contains" and §4.3 says a trigger declares "the widest audience a rule may
|
||||
// ever give it", but neither says what narrower MEANS. This file is that
|
||||
// answer, settled by the org lead at the start of Phase 2.
|
||||
//
|
||||
// **It is a subset lattice, not a size ordering.** The tempting model is a flat
|
||||
// total order — self < owner < staff < members < authenticated < everyone,
|
||||
// compared with `<=` — and it is wrong in a way that matters. Under a total
|
||||
// order a trigger ceilinged at `staff` also permits `owner`, so a rule could
|
||||
// mail `uo.cheat.detected` to the player who was detected. "Fewer people" is not
|
||||
// "less exposure"; the question is always WHICH people.
|
||||
//
|
||||
// So the order is containment, and it is a TREE:
|
||||
//
|
||||
// everyone anyone at all, signed in or not
|
||||
// └── authenticated any logged-in user
|
||||
// ├── subscribers logged-in users who opted into this id
|
||||
// ├── members a module-declared list (a Team, the governors)
|
||||
// ├── staff admin / editor / moderator
|
||||
// └── owner the one user the event is about
|
||||
//
|
||||
// The four leaves are mutually INCOMPARABLE, deliberately. `owner` is not a
|
||||
// subset of `subscribers` (an owner need not have subscribed), `staff` is not a
|
||||
// subset of `members`, and no pair of them has a common descendant. That is what
|
||||
// makes `meet()` below return null rather than guessing, and a null meet is a
|
||||
// refused save (§5.1a rule 3) rather than a silent widening.
|
||||
//
|
||||
// Nothing here reaches the database, the network or a user record. It is
|
||||
// arithmetic over six constants, so it is safe to require anywhere.
|
||||
|
||||
// child → parent. A tree, which is what makes `permits` a walk to the root and
|
||||
// `meet` a comparison rather than a search: two nodes in a tree have a greatest
|
||||
// lower bound only when one of them IS the bound.
|
||||
const PARENT = {
|
||||
everyone: null,
|
||||
authenticated: 'everyone',
|
||||
subscribers: 'authenticated',
|
||||
members: 'authenticated',
|
||||
staff: 'authenticated',
|
||||
owner: 'authenticated',
|
||||
}
|
||||
|
||||
// Operator-facing text. Lives beside the lattice rather than in the admin client
|
||||
// so the rule editor and the trigger catalog describe a ceiling the same way.
|
||||
const LABELS = {
|
||||
everyone: 'Everyone, including signed-out visitors',
|
||||
authenticated: 'Any signed-in user',
|
||||
subscribers: 'Signed-in users subscribed to this event',
|
||||
members: 'Members of a module-declared list',
|
||||
staff: 'Staff only',
|
||||
owner: 'Only the user the event is about',
|
||||
}
|
||||
|
||||
const CEILINGS = Object.keys(PARENT)
|
||||
|
||||
/** Is this one of the six? The gate every registration and every rule save runs. */
|
||||
const isCeiling = (value) => Object.prototype.hasOwnProperty.call(PARENT, value)
|
||||
|
||||
/**
|
||||
* May `ceiling` reach as widely as `candidate`?
|
||||
*
|
||||
* True when `candidate` is `ceiling` itself or sits below it — i.e. walking
|
||||
* `candidate` up the tree reaches `ceiling`. Everything else is false, including
|
||||
* every incomparable pair, so this FAILS CLOSED on an id it does not know.
|
||||
*/
|
||||
function permits(ceiling, candidate) {
|
||||
if (!isCeiling(ceiling) || !isCeiling(candidate)) return false
|
||||
for (let at = candidate; at; at = PARENT[at]) {
|
||||
if (at === ceiling) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* The narrower of two ceilings, or `null` when they are incomparable.
|
||||
*
|
||||
* This is the greatest lower bound, and in a tree it exists only when one node
|
||||
* is an ancestor of the other — so `meet('authenticated', 'staff')` is `staff`
|
||||
* and `meet('staff', 'owner')` is `null`. Returning null is the point:
|
||||
* §5.1a rule 3 says composition must never widen, and the intuitive
|
||||
* union-widens implementation is the wrong one. A caller that cannot name a
|
||||
* bound must refuse the save, not pick a side.
|
||||
*/
|
||||
function meet(a, b) {
|
||||
if (!isCeiling(a) || !isCeiling(b)) return null
|
||||
if (permits(a, b)) return b
|
||||
if (permits(b, a)) return a
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold `meet` across a whole expression's ceilings.
|
||||
*
|
||||
* `A OR B` takes the tighter of the two, and so does `A AND B` — the direction
|
||||
* of the boolean operator is irrelevant, because the ceiling is a statement
|
||||
* about what the operator is ALLOWED to reach, not about what it will resolve
|
||||
* to. An empty list has no bound to state and is null, not `everyone`.
|
||||
*/
|
||||
function meetAll(list) {
|
||||
if (!Array.isArray(list) || !list.length) return null
|
||||
return list.reduce((acc, next) => (acc === null ? null : meet(acc, next)), list[0])
|
||||
}
|
||||
|
||||
module.exports = { CEILINGS, LABELS, isCeiling, permits, meet, meetAll }
|
||||
@@ -121,6 +121,7 @@ function buildCtx(id, moduleRoot) {
|
||||
const users = require('../model/users/users.model')
|
||||
const teams = require('../model/teams/teamSync.model')
|
||||
const teamActivity = require('../model/teams/teamActivity.model')
|
||||
const engagementEmit = require('../utils/engagementEmit')
|
||||
const { makeLimiter, accountChangeLimiter } = require('../middleware/rateLimit')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
@@ -207,6 +208,40 @@ function buildCtx(id, moduleRoot) {
|
||||
),
|
||||
},
|
||||
},
|
||||
// Engagement (API 1.7.0, ENGAGEMENT.md §5.1). The push half of the trigger
|
||||
// contract the module registered with `api.registerEventTriggers`.
|
||||
//
|
||||
// `id` is bound here and is never taken from the arguments, exactly as
|
||||
// `teamActivity.push(id, …)` binds its source: a module fires its OWN
|
||||
// triggers. Without that binding, emit would be a way to fire another
|
||||
// module's event with a payload of your choosing, and every rule an operator
|
||||
// wrote against it would fire on that.
|
||||
//
|
||||
// Fire-and-forget and returns undefined. `emit()` answers a result its core
|
||||
// callers want; a module gets nothing back on purpose, because there is
|
||||
// nothing it could correctly do with a failure from inside a game-event
|
||||
// handler — and "never throws in production" is only true if there is also
|
||||
// nothing to await. The dev-time throw is inside `emit`, where the stack
|
||||
// still points at the module's own call.
|
||||
events: {
|
||||
emit: (triggerId, envelope) => {
|
||||
engagementEmit.emit(id, triggerId, envelope)
|
||||
},
|
||||
},
|
||||
// The in-app sink (§5.1) — a module writing the inbox directly, without a
|
||||
// rule. It is PRESENT AND THROWS until Phase 7 builds the channel and the
|
||||
// `user_notifications` table behind it.
|
||||
//
|
||||
// Present-and-throwing rather than absent is the shape 1.6.0 settled on for
|
||||
// exactly this situation (`ctx.teams.activity.push` before its phase landed):
|
||||
// the version number states a whole surface, so a member of 1.7.0 that is
|
||||
// missing would make the version a lie, and one that silently accepted data
|
||||
// into a table that does not exist would be the worst of the three.
|
||||
inbox: {
|
||||
push: () => {
|
||||
throw new Error('ctx.inbox.push is not available until the in-app channel lands (ENGAGEMENT.md Phase 7)')
|
||||
},
|
||||
},
|
||||
// One function, for one caller: the `admin.users.detail` slot router needs
|
||||
// the user its prefix names. Narrowed like `ctx.posts` — the users model
|
||||
// exports creation, role changes and password handling, none of which is a
|
||||
@@ -293,6 +328,24 @@ function buildApi(record) {
|
||||
once('registerSlashCommands')
|
||||
record.staged.registerSlashCommands(commands)
|
||||
},
|
||||
// The engagement contract (API 1.7.0, ENGAGEMENT.md §4.3 / §5.1a). Both
|
||||
// STAGE, like the registries above them, and both take `once` for the same
|
||||
// reason `registerNotificationStreams` does: a batch is a module's complete
|
||||
// statement about what it declares, and a second call is a module changing
|
||||
// its mind halfway through register() rather than adding to it.
|
||||
//
|
||||
// A trigger id and a stream id share one namespace (§7.2), so a module that
|
||||
// calls both may legitimately name the same id in each — that is one event
|
||||
// with a subscription toggle and a payload contract, and it is the case core
|
||||
// itself exercises on every boot.
|
||||
registerEventTriggers(triggers) {
|
||||
once('registerEventTriggers')
|
||||
record.staged.registerEventTriggers(triggers)
|
||||
},
|
||||
registerAudiences(audiences) {
|
||||
once('registerAudiences')
|
||||
record.staged.registerAudiences(audiences)
|
||||
},
|
||||
// The two lifecycle hooks (§2.5). Registered here, dispatched from
|
||||
// lifecycle.js — this file runs with no database and the hooks run with one.
|
||||
// Both are optional: a module with no warm-up and nothing to close simply
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,6 +9,26 @@
|
||||
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
|
||||
// has nothing to say about a website module) and from any module's own version.
|
||||
|
||||
// 1.7.0 — the engagement contract (docs/website/ENGAGEMENT.md Phase 2).
|
||||
// Additions only, so minor: `api.registerEventTriggers([...])`,
|
||||
// `api.registerAudiences([...])`, `ctx.events.emit(triggerId, envelope)` and
|
||||
// `ctx.inbox.push(userId, item)`. module-uo's `coreApi: "^1.3.0"` still resolves.
|
||||
//
|
||||
// **As in 1.6.0, the number covers the whole surface and the members arrive by
|
||||
// phase.** `ctx.inbox.push` is present and THROWS until Phase 7 builds the
|
||||
// in-app channel and the table behind it — the same choice, for the same reason:
|
||||
// a member of 1.7.0 that were absent would make the version a lie, and one that
|
||||
// silently accepted data into a table that does not exist would be worse than
|
||||
// either. Everything else in 1.7.0 is live.
|
||||
//
|
||||
// One thing here is not a member and is still part of the contract: a trigger id
|
||||
// and a notification-stream id share ONE namespace (ENGAGEMENT.md §7.2, settled
|
||||
// by the org lead in Phase 2). An id has exactly one owner across both facets,
|
||||
// so a module cannot attach a payload contract to another module's stream. That
|
||||
// tightens a rule rather than changing a signature, and nothing registrable
|
||||
// before this bump becomes unregistrable after it — the id grammar was RELAXED
|
||||
// in the same change (`_` is now legal inside a segment).
|
||||
//
|
||||
// 1.6.0 — the Team surface (docs/website/TEAMS.md Part 11). Additions only, so
|
||||
// minor: `api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })`,
|
||||
// `ctx.teams.publish(event)`, `ctx.teams.reconcile({ reason })`,
|
||||
@@ -58,6 +78,6 @@
|
||||
// an admin action a module performs belongs in core's one audit log, the
|
||||
// extension slot needs the user its prefix names, and §2.7 forbids a module
|
||||
// reading core's `APP_BASE_URL` for itself. Additions only, so minor.
|
||||
const MODULE_API_VERSION = '1.6.0'
|
||||
const MODULE_API_VERSION = '1.7.0'
|
||||
|
||||
module.exports = { MODULE_API_VERSION }
|
||||
|
||||
Reference in New Issue
Block a user