feat(modules): event triggers, audiences and the ceiling lattice (engagement Phase 2)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 25s
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / server-tests (pull_request) Successful in 10m29s

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:
2026-08-29 06:40:28 -05:00
parent 6016b325bb
commit 563199a096
21 changed files with 2132 additions and 6 deletions

View File

@@ -0,0 +1,147 @@
// ── Core's own engagement triggers ─────────────────────────────────────────
//
// ENGAGEMENT.md §4.3 and Phase 2. The twin of config/coreStreams.js, and
// deliberately the SAME FIVE IDS — that is the org lead's §7.2 decision, taken at
// the start of this phase: **one namespace.** A trigger is not a second thing
// standing next to a stream; it is a payload contract attached to an id that may
// also carry a subscription toggle. `news.post` names one event, whether the
// question being asked of it is "may I push this?" or "what may a template
// interpolate?".
//
// What that buys, concretely: `notification_channel_prefs.stream_id` (§4.5) stays
// single-keyed. Under two namespaces it would have needed a `kind` discriminator
// in its primary key, and `news.post` would have named two different things
// forever.
//
// What it costs is the rule enforced in registries.js: an id has ONE owner across
// both facets, so a module cannot attach a payload contract to another module's
// stream, and core cannot attach one to a module's. Core's five ids below are
// already core's five streams, so all five are the same-owner upgrade case.
//
// **These declare; nothing here emits yet.** Phase 2 is the contract only — the
// Team pipeline keeps its own hardcoded mail until Phase 6 migrates it onto the
// engine, and this file is what it migrates ONTO. Registering the declarations a
// phase early is the same decision registerCore() has always taken: a registry
// whose first real exercise is a module is a registry that has already drifted.
//
// Every variable carries an `example`, and that is required rather than
// decorative (§4.3 property 3). It is what lets the template editor preview and
// test-send without a live game event, which is the reason template systems go
// untested.
const TRIGGERS = [
{
id: 'news.post',
label: 'News post published',
description: 'A news / Five-on-Friday / newsletter post was published.',
kind: 'event',
// No subjectKey. The subject of a cooldown here is the USER, not the post —
// "do not mail me about news more than once an hour" is the useful rule, and
// keying it per post would make every cooldown a no-op. Compare the four
// Team triggers below, where the Team genuinely is the subject.
audience: 'subscribers',
ceiling: 'authenticated',
version: 1,
variables: [
{ name: 'title', type: 'string', required: true, example: 'Five on Friday — the Yew invasion',
description: 'The post title.' },
{ name: 'excerpt', type: 'string', required: false, example: 'Four new champion spawns, and the fate of the Yew moongate…',
description: 'A plain-text summary, already stripped of markup.' },
{ name: 'category', type: 'string', required: false, example: 'Five on Friday',
description: 'The post category, when it has one.' },
{ name: 'postUrl', type: 'url', required: true, example: '/news/five-on-friday-yew-invasion',
description: 'Site-relative path to the post.' },
],
},
// ── Teams (TEAMS.md Part 6) ─────────────────────────────────────────────
//
// All four ceiling at `members` and not one of them higher. Who may be told
// about a Team event is the access resolver's answer and always has been
// (coreStreams.js says the same thing about the push catalog); the ceiling is
// that rule written where a RULE EDITOR has to obey it too. Without it an
// operator could point a rule at `authenticated` and mail a private Team's
// forum excerpt to the whole site.
{
id: 'team.member.joined',
label: 'Team — new member',
description: 'Someone joined a Team.',
kind: 'event',
subjectKey: 'teamName',
audience: 'members',
ceiling: 'members',
version: 1,
variables: [
{ name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil',
description: 'The Team the event is about. Also the cooldown subject.' },
{ name: 'memberName', type: 'string', required: true, example: 'Darrow',
description: 'Display name of the member who joined.' },
{ name: 'teamUrl', type: 'url', required: false, example: '/guilds/the-silver-anvil',
description: 'Site-relative path to the Team page. Absent when no module supplies a pageUrlTemplate.' },
],
},
{
id: 'team.leadership.changed',
label: 'Team — leadership change',
description: 'Leadership changed in a Team.',
kind: 'event',
subjectKey: 'teamName',
audience: 'members',
ceiling: 'members',
version: 1,
variables: [
{ name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil',
description: 'The Team the event is about. Also the cooldown subject.' },
{ name: 'leaderName', type: 'string', required: true, example: 'Marisol',
description: 'Display name of the new leader.' },
{ name: 'teamUrl', type: 'url', required: false, example: '/guilds/the-silver-anvil',
description: 'Site-relative path to the Team page.' },
],
},
{
id: 'team.forum.post',
label: 'Team — new forum post',
description: 'A new thread or reply in a Team forum.',
kind: 'event',
subjectKey: 'teamName',
audience: 'members',
ceiling: 'members',
version: 1,
variables: [
{ name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil',
description: 'The Team the event is about. Also the cooldown subject.' },
{ name: 'authorName', type: 'string', required: true, example: 'Darrow',
description: 'Display name of the poster.' },
{ name: 'threadTitle', type: 'string', required: true, example: 'Tuesday champ rotation',
description: 'Title of the thread the post belongs to.' },
{ name: 'excerpt', type: 'string', required: false, example: 'Moving the Tuesday run an hour later…',
description: 'Plain-text excerpt of the post body, already stripped of markup.' },
{ name: 'postUrl', type: 'url', required: false, example: '/guilds/the-silver-anvil/forum/412',
description: 'Site-relative path to the post.' },
],
},
{
id: 'team.announcement',
label: 'Team — announcement',
description: 'A leader posted an announcement in a Team.',
kind: 'event',
subjectKey: 'teamName',
audience: 'members',
ceiling: 'members',
version: 1,
variables: [
{ name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil',
description: 'The Team the event is about. Also the cooldown subject.' },
{ name: 'authorName', type: 'string', required: true, example: 'Marisol',
description: 'Display name of the leader who posted.' },
{ name: 'title', type: 'string', required: true, example: 'Siege practice moved to Sunday',
description: 'The announcement title.' },
{ name: 'excerpt', type: 'string', required: false, example: 'We are moving practice to Sunday 8pm…',
description: 'Plain-text excerpt of the announcement body.' },
{ name: 'postUrl', type: 'url', required: false, example: '/guilds/the-silver-anvil/forum/419',
description: 'Site-relative path to the announcement.' },
],
},
]
module.exports = { TRIGGERS }

View 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 }

View File

@@ -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

View File

@@ -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,

View File

@@ -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 }

View File

@@ -0,0 +1,54 @@
// ── Admin: engagement ──────────────────────────────────────────────────────
//
// ENGAGEMENT.md Phase 2, G3 — the event catalog surface the admin UI needs in
// order to enumerate triggers. **Read-only, and entirely from the registries.**
// There is no table behind either route: a trigger is DECLARED in code by core
// or by a module (§4.3), so the catalog is whatever registered on this boot, and
// a module that was uninstalled simply stops appearing.
//
// That is also what makes the answer honest about dormancy later. §7.3's rule is
// that a rule pointing at an unregistered trigger shows as dormant, never as an
// error and never auto-deleted; a catalog served from a table would have to
// decide whether to delete rows on uninstall, and there is no right answer to
// that question. Serving it from the registry means there is no question.
//
// The rule and template editors (Phases 4 and 5) read these two endpoints: the
// variable list is what makes the editor's autocomplete real rather than blind
// interpolation (§4.3 property 2), the `example` on each variable is what makes
// preview and test-send possible without a live game event, and the ceilings are
// what the rule editor has to obey when it offers an audience (G24).
const registries = require('../../../modules/registries')
const ceilings = require('../../../modules/ceilings')
// The lattice, flattened for a client: for each ceiling, the ones a rule may
// choose under it. Served with the catalog rather than hardcoded in the admin
// client, because the client would be a second copy of a security rule and a
// second copy is a copy that drifts. The server is still the boundary — Phase 4
// re-checks every rule save against `ceilings.permits` — this is so the editor
// does not offer a choice it knows will be refused.
const ceilingVocabulary = () =>
ceilings.CEILINGS.map((id) => ({
id,
label: ceilings.LABELS[id],
permits: ceilings.CEILINGS.filter((other) => ceilings.permits(id, other)),
}))
/** GET /api/v1/admin/engagement/triggers */
exports.listTriggers = (req, res) => {
res.json({
triggers: registries.allTriggers(),
ceilings: ceilingVocabulary(),
variableTypes: registries.VARIABLE_TYPES,
kinds: registries.TRIGGER_KINDS,
})
}
/** GET /api/v1/admin/engagement/audiences */
exports.listAudiences = (req, res) => {
// `allAudiences()` has already stripped each `resolve`. That stripping is in
// the registry rather than here for the same reason a slash command's handler
// is stripped there: it is the boundary the function must not cross, and a
// second caller must not have to remember.
res.json({ audiences: registries.allAudiences(), ceilings: ceilingVocabulary() })
}

View File

@@ -0,0 +1,47 @@
// Admin · Engagement — the declared event catalog (ENGAGEMENT.md Phase 2).
//
// Mounted at /api/v1/admin/engagement by admin/index.js, which has already
// applied `noindex, isLoggedIn, staffOnly`. Both routes re-gate to `admin`.
//
// Admin rather than staff-wide, deliberately. Nothing here is writable yet, but
// this is the entry point of the screen that decides who receives mail, and the
// declarations it serves name every variable a template may interpolate. A
// capability is easier to widen later with a reason than to narrow after an
// editor has been using it.
//
// Rules, templates and the send log arrive under this same prefix in Phases 4
// and 5, which is why the group exists now with two read routes in it.
const express = require('express')
const controller = require('./engagement.controller')
const { requireRole } = require('../../../utils/auth')
const engagementRouter = express.Router()
const adminOnly = requireRole('admin')
engagementRouter.get(
'/triggers',
// #swagger.tags = ['Admin · Engagement']
// #swagger.summary = 'List every declared event trigger, with its payload contract and audience ceiling'
// #swagger.description = 'Served from the module registries, not from a table: a trigger is declared in code by core or by an installed module, so this is whatever registered on this boot. Each declaration carries the variables a template may interpolate (with an example per variable, for preview and test-send) and the widest audience a rule may ever give it.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The declared triggers, the audience-ceiling vocabulary, and the variable types', content: { "application/json": { schema: { type: "object", properties: { triggers: { type: "array", items: { type: "object", additionalProperties: true } }, ceilings: { type: "array", items: { type: "object", additionalProperties: true } }, variableTypes: { type: "array", items: { type: "string" } }, kinds: { type: "array", items: { type: "string" } } } } } } } */
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.listTriggers,
)
engagementRouter.get(
'/audiences',
// #swagger.tags = ['Admin · Engagement']
// #swagger.summary = 'List every declared audience a rule may be pointed at'
// #swagger.description = 'Module-declared named sets of users, resolved over the module own data. The resolver itself is never served — an audience answers with user ids on the server side only.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The declared audiences and the audience-ceiling vocabulary', content: { "application/json": { schema: { type: "object", properties: { audiences: { type: "array", items: { type: "object", additionalProperties: true } }, ceilings: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.listAudiences,
)
module.exports = engagementRouter

View File

@@ -30,6 +30,7 @@ const emailRouter = require('./email.router')
const discordBotRouter = require('./discordBot.router')
const settingsRouter = require('./settings.router')
const modulesRouter = require('./modules.router')
const engagementRouter = require('./engagement.router')
const teamsRouter = require('./teams.router')
const teamsVoiceRouter = require('./teamsVoice.router')
const dashboardRouter = require('./dashboard.router')
@@ -79,6 +80,13 @@ adminRouter.use('/settings', settingsRouter)
// here alongside the other configuration capabilities, and admin-only per route
// rather than at this line, so the gate sits next to what it is guarding.
adminRouter.use('/modules', modulesRouter)
// The engagement catalog (ENGAGEMENT.md Phase 2). Read-only for now — the two
// routes serve what core and the installed modules DECLARED, so there is no
// table behind it and nothing to configure yet. Rules, templates and the send log
// land under this same prefix in Phases 4 and 5. Admin-only per route, like
// /modules above and for a related reason: this is the surface that decides who
// the site sends mail to.
adminRouter.use('/engagement', engagementRouter)
// Teams. Staff-wide, like /activity: a moderator runs the reserved-name review
// queue. The three actions that PUBLISH untrusted game-sourced strings are gated
// per request inside the controller, not per route — a moderator may call them,

View File

@@ -0,0 +1,223 @@
// ── ctx.events.emit — the validating half of the engagement seam ────────────
//
// ENGAGEMENT.md §4.3 and §5.2, Phase 2. A registrant fires a declared event with
// a payload; this checks the payload against the declaration and stops there.
// **There is no delivery in this phase** — no rules, no cooldowns, no outbox, no
// mail. Phase 4 replaces the log line at the bottom with the engine call, and
// every validation rule below is already the one it will need.
//
// Landing the contract a phase before the engine is deliberate, and it is the
// same argument registerCore() has always made: a seam whose first real exercise
// is the thing that depends on it is a seam that has already drifted. Phase 6
// migrates the Team mail onto this, and it should be migrating onto a validator
// that has been running against core's own five triggers since Phase 2.
//
// **Two postures, one switch.** A malformed emit THROWS in development and is
// DROPPED AND LOGGED in production, which is `ctx.teams.activity.push`'s posture
// and it is not a compromise: this is called from inside a game-event handler,
// and a contract problem of core's must not become the module's control flow at
// three in the morning. In development it must be loud, because a payload that
// silently loses a variable is a template that silently renders `undefined`.
const registries = require('../modules/registries')
const createLogger = require('./logger')
const log = createLogger('engagement')
// The same character class `pageUrlTemplate` is validated with (registries.js),
// for the same reason: a `url` variable is a string that ends up in an href.
// Relative only — one leading slash, and the second character may not be
// another, because `//evil.test/x` passes an "is it rooted" check and is a
// PROTOCOL-RELATIVE url that would send a recipient off-site.
const RELATIVE_URL = /^\/(?!\/)[A-Za-z0-9\-._~/?#[\]@!$&'()*+,;=%]*$/
// A dedupe key is stored in a VARCHAR(190) (§4.5 user_notifications.dedupe_key),
// so it is bounded here rather than at the insert — a truncated key silently
// collides with a different event, which is the one failure mode dedupe exists
// to prevent.
const DEDUPE_KEY_MAX = 190
const isProd = () => process.env.NODE_ENV === 'production'
/** Coerce and check one declared variable. Returns `{ value }` or `{ error }`. */
function coerce(variable, raw) {
switch (variable.type) {
case 'string':
return typeof raw === 'string' ? { value: raw } : { error: 'expected a string' }
case 'int':
return Number.isInteger(raw) ? { value: raw } : { error: 'expected an integer' }
case 'float':
return typeof raw === 'number' && Number.isFinite(raw)
? { value: raw }
: { error: 'expected a finite number' }
case 'boolean':
return typeof raw === 'boolean' ? { value: raw } : { error: 'expected a boolean' }
// Normalised to an ISO string at the boundary, so a template, a manifest
// example and a stored outbox row all hold the same representation of a
// moment. A Date and its ISO string are the same value everywhere downstream
// only if one of them stops existing here.
case 'datetime': {
const d = raw instanceof Date ? raw : new Date(raw)
if (!(d instanceof Date) || Number.isNaN(d.getTime())) return { error: 'expected a date' }
return { value: d.toISOString() }
}
case 'url':
if (typeof raw !== 'string') return { error: 'expected a string' }
return RELATIVE_URL.test(raw)
? { value: raw }
: { error: 'expected a site-relative path beginning with a single "/"' }
default:
// Unreachable — registerEventTriggers refuses an undeclared type — and it
// fails CLOSED anyway rather than passing an unchecked value through.
return { error: `unsupported type "${variable.type}"` }
}
}
/**
* Check a payload against a trigger declaration.
*
* Returns `{ ok: true, data }` with a NEW object holding only declared
* variables, or `{ ok: false, errors }` listing every problem rather than the
* first — a module author fixing one emit at a time is a module author making
* six round trips through a game server restart.
*
* Undeclared keys are dropped rather than rejected. They can never be
* interpolated (the editor only offers declared names, §4.3 property 2), so
* refusing the whole emit over one would be strictness with no safety behind it;
* they are named in a debug line so a typo is still findable.
*/
function validatePayload(declaration, raw) {
const input = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}
const errors = []
const data = {}
for (const variable of declaration.variables) {
const present = Object.prototype.hasOwnProperty.call(input, variable.name)
const value = input[variable.name]
if (!present || value === undefined || value === null) {
if (variable.required) errors.push(`${variable.name}: required`)
continue
}
const { value: coerced, error } = coerce(variable, value)
if (error) errors.push(`${variable.name}: ${error}`)
else data[variable.name] = coerced
}
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
const declared = new Set(declaration.variables.map((v) => v.name))
const extra = Object.keys(raw).filter((k) => !declared.has(k))
if (extra.length) log.debug('emit carried undeclared variables', { trigger: declaration.id, extra })
}
return errors.length ? { ok: false, errors } : { ok: true, data }
}
/**
* Emit a declared event. Core's implementation; `ctx.events.emit` wraps it.
*
* `owner` is bound by the CALLER — the loader passes the module's own id and
* core passes `'core'` — and is never taken from the arguments. A module emits
* its own triggers and nothing else: without that, `ctx.events.emit` would be a
* way to fire another module's event with a payload of your choosing, and every
* rule an operator wrote against that trigger would fire on it.
*
* @returns {{ ok: true, event: object } | { ok: false, reason: string }}
*/
function emit(owner, triggerId, envelope = {}) {
const fail = (reason, detail) => {
if (!isProd()) {
const suffix = detail ? ` (${detail})` : ''
throw new Error(`ctx.events.emit: ${reason}${suffix}`)
}
log.warn('emit dropped', { owner, trigger: triggerId, reason, detail })
return { ok: false, reason }
}
const declaration = registries.eventTrigger(triggerId)
if (!declaration) {
// Names the holder when the id is taken by the OTHER facet, because under
// one namespace "there is no such trigger" and "that id is a stream nobody
// gave a payload contract to" are different problems with the same symptom.
const heldBy = registries.eventOwner(triggerId)
return fail(
`unknown event trigger "${triggerId}"`,
heldBy ? `the id is registered as a notification stream by "${heldBy}"` : null,
)
}
if (declaration.owner !== owner) {
return fail(`"${triggerId}" belongs to "${declaration.owner}"`, `emitted by "${owner}"`)
}
// A scheduled trigger is fired by the periodic evaluator, not by a caller
// (§7.1 Q6). There is no evaluator yet, and this is still the right refusal:
// it keeps `kind` meaning something from the day it is declarable.
if (declaration.kind !== 'event') {
return fail(`"${triggerId}" is kind "${declaration.kind}" and is not emitted directly`)
}
const { subject, data, ownerUserId, dedupeKey, occurredAt } = envelope || {}
const payload = validatePayload(declaration, data)
if (!payload.ok) return fail(`payload for "${triggerId}" is invalid`, payload.errors.join('; '))
// The subject is what a cooldown is keyed on (§4.1): "once per house", not
// "once per user". An explicit `subject` wins; otherwise it is read from the
// variable the declaration named, which is why checkTriggerShape insists that
// variable exists.
let resolvedSubject = null
if (subject !== undefined && subject !== null) {
if (typeof subject !== 'string' && typeof subject !== 'number') {
return fail('subject must be a string or a number')
}
resolvedSubject = String(subject)
} else if (declaration.subjectKey && payload.data[declaration.subjectKey] !== undefined) {
resolvedSubject = String(payload.data[declaration.subjectKey])
}
if (ownerUserId !== undefined && ownerUserId !== null) {
if (!Number.isInteger(ownerUserId) || ownerUserId < 1) {
return fail('ownerUserId must be a positive integer')
}
}
if (dedupeKey !== undefined && dedupeKey !== null) {
if (typeof dedupeKey !== 'string' || !dedupeKey || dedupeKey.length > DEDUPE_KEY_MAX) {
return fail(`dedupeKey must be a string of 1-${DEDUPE_KEY_MAX} characters`)
}
}
let at = new Date()
if (occurredAt !== undefined && occurredAt !== null) {
const parsed = occurredAt instanceof Date ? occurredAt : new Date(occurredAt)
if (Number.isNaN(parsed.getTime())) return fail('occurredAt is not a date')
at = parsed
}
const event = {
triggerId,
owner,
version: declaration.version,
subject: resolvedSubject,
ownerUserId: ownerUserId === undefined ? null : ownerUserId,
dedupeKey: dedupeKey === undefined ? null : dedupeKey,
occurredAt: at.toISOString(),
data: payload.data,
}
// Phase 2 ends here: validated, recorded, and deliberately undelivered.
//
// The values are NOT logged. A payload carries player names, house locations
// and forum excerpts, and an event log that reproduces them is a second copy
// of exactly the content §4.5 was careful to keep out of `engagement_sends`
// (which hashes the address rather than storing it). The keys are enough to
// debug a contract problem, which is what this line is for.
log.info('event emitted', {
trigger: triggerId,
owner,
subject: resolvedSubject,
variables: Object.keys(event.data),
})
return { ok: true, event }
}
module.exports = { emit, validatePayload, RELATIVE_URL, DEDUPE_KEY_MAX }