feat(modules): event triggers, audiences and the ceiling lattice (engagement Phase 2) #168

Merged
whitlocktech merged 1 commits from feature/engagement-trigger-registry into edge 2026-08-29 11:48:25 +00:00
21 changed files with 2132 additions and 6 deletions
Showing only changes of commit 563199a096 - Show all commits

View File

@@ -75,6 +75,15 @@ jobs:
# of a reviewer instead of letting it pass silently.
run: npm run routes:manifest --prefix server -- --check
- name: Check the engagement trigger manifest is current
# ENGAGEMENT.md 4.3 property 4 - the same mechanism as the route manifest
# above, for the event contract instead of the URL surface. A trigger
# declaration is what a stored template interpolates and what a stored
# rule is written against, so renaming a variable or widening a ceiling
# breaks them silently, at send time, in mail someone already received.
# Regenerating and diffing makes that change something a reviewer reads.
run: npm run engagement:manifest --prefix server -- --check
client-build:
runs-on: ubuntu-latest
steps:

View File

@@ -11,6 +11,14 @@
// that the two files can drift, so a test asserts they agree
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
// both.
// 1.7.0 — the engagement contract (docs/website/ENGAGEMENT.md Phase 2). Nothing
// on this half changed: every member the version adds is on the server's `api`
// and `ctx` (registerEventTriggers, registerAudiences, ctx.events.emit,
// ctx.inbox.push). This file bumps anyway, for the reason at the top — the two
// halves state ONE version, and a module declares one `coreApi` range against
// both. The web surfaces the engagement system needs (the rules and template
// editors, the in-app inbox) land in Phases 4, 5 and 7 and will add to this half
// then.
// 1.6.0 — the Team surface (docs/website/TEAMS.md Part 11). Nothing on this half
// changed yet: the two client additions the version covers are the `team.overview`
// and `team.member.row` slots, and a slot can only be declared by the page that
@@ -45,4 +53,4 @@
// but the two halves state ONE version: a module declares a single coreApi range
// and is served one chunk, so a client that claimed 1.0.0 while the server
// answered 1.1.0 would be two answers to one question.
export const MODULE_API_VERSION = '1.6.0'
export const MODULE_API_VERSION = '1.7.0'

View File

@@ -0,0 +1,211 @@
{
"_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.",
"moduleApiVersion": "1.7.0",
"triggers": [
{
"id": "news.post",
"owner": "core",
"label": "News post published",
"description": "A news / Five-on-Friday / newsletter post was published.",
"kind": "event",
"subjectKey": null,
"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."
}
]
},
{
"id": "team.announcement",
"owner": "core",
"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."
}
]
},
{
"id": "team.forum.post",
"owner": "core",
"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.leadership.changed",
"owner": "core",
"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.member.joined",
"owner": "core",
"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."
}
]
}
]
}

View File

@@ -9,6 +9,7 @@
"seed": "node db/seed.js",
"swagger": "node swagger/swagger.js",
"routes:manifest": "node scripts/routeManifest.js",
"engagement:manifest": "node scripts/engagementManifest.js",
"test": "node --test --require ./test/_setup.js"
},
"keywords": [

View File

@@ -167,6 +167,24 @@
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/audiences",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/triggers",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/invites",

View File

@@ -73,6 +73,14 @@
"method": "POST",
"path": "/api/v1/admin/email/test"
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/audiences"
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/triggers"
},
{
"method": "GET",
"path": "/api/v1/admin/invites"

View File

@@ -0,0 +1,132 @@
#!/usr/bin/env node
/**
* Engagement trigger manifest — the machine-readable freeze of core's event
* contract (ENGAGEMENT.md §4.3, property 4).
*
* Why this exists: a trigger declaration is what a template interpolates and what
* a rule is written against. Renaming a variable, changing its type, or widening
* a ceiling breaks stored templates and stored rules — and does it silently, at
* send time, in an email someone already received. `routes.manifest.json` freezes
* the URL surface for exactly this reason and this is its twin: a generated
* artifact committed to the repo, whose DIFF is the review signal. Changing a
* declaration without regenerating is a red build; changing one deliberately puts
* the change in front of a reviewer instead of letting it pass as a comment edit.
*
* **Core's only.** A module ships its own `engagement-triggers.json` in its
* bundle, for the same reason it ships a prebuilt swagger fragment: core never
* has its sources to analyse (MODULE_API.md §6.1a). So this loads
* `config/coreTriggers.js` through the real `registerCore()` — the declarations
* as VALIDATED, not as authored — which means a shape error is a failure here
* rather than a surprise at boot.
*
* The `resolve` half of an audience cannot be frozen (it is a function over a
* module's own store), so audiences are deliberately absent: what a manifest can
* usefully freeze is the payload contract, and freezing half a declaration would
* suggest the other half was checked.
*
* Usage:
* npm run engagement:manifest # write server/engagement-triggers.json
* npm run engagement:manifest -- --check # exit 1 if the committed file is stale
*/
// registries.js -> config/coreStreams + utils/discordAnnounce, which reach
// utils/db and build a mariadb pool at require time. Point it at a closed port
// (the same trick routeManifest.js and the test suite use) so generating a
// manifest never opens a connection or hangs on a missing database.
process.env.DB_HOST = process.env.DB_HOST || '127.0.0.1'
process.env.DB_PORT = process.env.DB_PORT || '59999'
const fs = require('fs')
const path = require('path')
const registries = require('../src/modules/registries')
const db = require('../src/utils/db')
const { MODULE_API_VERSION } = require('../src/modules/version')
const SERVER_ROOT = path.join(__dirname, '..')
const MANIFEST_PATH = path.join(SERVER_ROOT, 'engagement-triggers.json')
const MANIFEST_COMMENT =
'Generated event-trigger inventory - the authoritative freeze of CORE\'s engagement ' +
'contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` ' +
'in website/server. A renamed variable, a changed type or a widened ceiling breaks stored ' +
'templates and rules, so the diff here is the review signal. A module ships its own copy ' +
'in its bundle; this file never contains one.'
function build() {
// Through registerCore(), not by reading the array: what a reviewer needs
// frozen is what the registry ACCEPTED — defaults filled in, audience resolved
// against the ceiling, variables normalised — because that is what the editor
// will read and the emit path will check against.
registries.registerCore()
const triggers = registries
.allTriggers()
.filter((t) => t.owner === 'core')
// Sorted by id rather than left in registration order, like the route
// manifest: reordering a declaration in the source is not a contract change
// and must not produce a diff that looks like one.
.sort((a, b) => a.id.localeCompare(b.id))
.map((t) => ({
id: t.id,
owner: t.owner,
label: t.label,
description: t.description,
kind: t.kind,
subjectKey: t.subjectKey,
audience: t.audience,
ceiling: t.ceiling,
version: t.version,
// Variables keep their DECLARED order. Here it is contract: it is the
// order the template editor lists them in, and an author reading the
// manifest should see what the editor will show.
variables: t.variables.map((v) => ({
name: v.name,
type: v.type,
required: v.required,
example: v.example,
description: v.description,
})),
}))
return {
_comment: MANIFEST_COMMENT,
// The contract version these declarations are shaped by. A reader looking at
// a stale manifest needs to know which API's rules produced it.
moduleApiVersion: MODULE_API_VERSION,
triggers,
}
}
function main() {
const check = process.argv.includes('--check')
const next = `${JSON.stringify(build(), null, 2)}\n`
if (!check) {
fs.writeFileSync(MANIFEST_PATH, next)
process.stdout.write(`wrote ${path.relative(SERVER_ROOT, MANIFEST_PATH)}\n`)
return
}
const current = fs.existsSync(MANIFEST_PATH) ? fs.readFileSync(MANIFEST_PATH, 'utf8') : ''
if (current === next) {
process.stdout.write('engagement-triggers.json is current\n')
return
}
process.stderr.write(
'engagement-triggers.json is stale.\n' +
'A trigger declaration changed without the manifest being regenerated.\n' +
'Run `npm run engagement:manifest` in website/server and commit the result —\n' +
'the diff is what a reviewer reads to see the contract change.\n',
)
process.exitCode = 1
}
if (require.main === module) {
main()
// The mariadb pool never connects here, but it keeps the loop alive even
// pointed at a dead port — the same exit routeManifest.js takes.
db.close().finally(() => process.exit(process.exitCode || 0))
}
module.exports = { build }

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 }

View File

@@ -1137,6 +1137,128 @@
}
}
},
"/api/v1/admin/engagement/audiences": {
"get": {
"tags": [
"Admin · Engagement"
],
"summary": "List every declared audience a rule may be pointed at",
"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.",
"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
}
}
}
}
}
}
},
"403": {
"description": "Not an admin",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/engagement/triggers": {
"get": {
"tags": [
"Admin · Engagement"
],
"summary": "List every declared event trigger, with its payload contract and audience ceiling",
"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.",
"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"
}
}
}
}
}
}
},
"403": {
"description": "Not an admin",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/invites": {
"post": {
"tags": [

View File

@@ -0,0 +1,86 @@
// ── The audience ceiling lattice ───────────────────────────────────────────
//
// ENGAGEMENT.md §5.1a / G24. These are the tests for the security property the
// whole rule model rests on: **composition may narrow, never widen**, and a
// ceiling is about WHICH people rather than how many.
//
// The case worth naming is `staff` vs `owner`. Under the flat total order the
// plan's wording invites — self < owner < staff < members < authenticated <
// everyone — a trigger ceilinged at `staff` also permits `owner`, so a rule
// could mail `uo.cheat.detected` to the player it detected. That is the bug this
// file exists to keep out, so it is asserted explicitly rather than left implied
// by the shape of the table.
const { test } = require('node:test')
const assert = require('node:assert/strict')
const ceilings = require('../src/modules/ceilings')
test('the six ceilings are the vocabulary, and nothing else is', () => {
assert.deepEqual(
[...ceilings.CEILINGS].sort(),
['authenticated', 'everyone', 'members', 'owner', 'staff', 'subscribers'],
)
for (const id of ceilings.CEILINGS) assert.ok(ceilings.LABELS[id], `${id} has an operator label`)
assert.equal(ceilings.isCeiling('nobody'), false)
assert.equal(ceilings.isCeiling(undefined), false)
})
test('everyone permits every ceiling; every ceiling permits itself', () => {
for (const id of ceilings.CEILINGS) {
assert.equal(ceilings.permits('everyone', id), true, `everyone permits ${id}`)
assert.equal(ceilings.permits(id, id), true, `${id} permits itself`)
}
})
test('authenticated permits the four leaves but not everyone', () => {
for (const leaf of ['subscribers', 'members', 'staff', 'owner']) {
assert.equal(ceilings.permits('authenticated', leaf), true)
}
assert.equal(ceilings.permits('authenticated', 'everyone'), false)
})
// The one that a flat ordering gets wrong.
test('a staff ceiling does NOT permit owner — fewer people is not less exposure', () => {
assert.equal(ceilings.permits('staff', 'owner'), false)
assert.equal(ceilings.permits('owner', 'staff'), false)
// …and the same for every other pair of leaves, so the property is the tree's
// and not a special case someone wrote for cheat detection.
const leaves = ['subscribers', 'members', 'staff', 'owner']
for (const a of leaves) {
for (const b of leaves) {
if (a === b) continue
assert.equal(ceilings.permits(a, b), false, `${a} must not permit ${b}`)
}
}
})
test('an unknown ceiling is permitted by nothing, on either side', () => {
assert.equal(ceilings.permits('everyone', 'god'), false)
assert.equal(ceilings.permits('god', 'owner'), false)
assert.equal(ceilings.permits('everyone', undefined), false)
})
test('A OR B takes the NARROWER of the two ceilings, not the wider', () => {
assert.equal(ceilings.meet('everyone', 'staff'), 'staff')
assert.equal(ceilings.meet('staff', 'everyone'), 'staff')
assert.equal(ceilings.meet('authenticated', 'members'), 'members')
assert.equal(ceilings.meet('members', 'members'), 'members')
})
test('incomparable ceilings have no meet — the save is refused, not guessed', () => {
assert.equal(ceilings.meet('staff', 'members'), null)
assert.equal(ceilings.meet('owner', 'subscribers'), null)
assert.equal(ceilings.meet('staff', 'nonsense'), null)
})
test('meetAll folds, short-circuits to null, and has no opinion about an empty list', () => {
assert.equal(ceilings.meetAll(['everyone', 'authenticated', 'members']), 'members')
// members ∧ staff is undefined, so the whole composition is.
assert.equal(ceilings.meetAll(['everyone', 'members', 'staff']), null)
assert.equal(ceilings.meetAll(['owner']), 'owner')
// Not 'everyone': an empty composition states no bound, and defaulting it to
// the top would make "no audiences selected" the widest possible rule.
assert.equal(ceilings.meetAll([]), null)
assert.equal(ceilings.meetAll(null), null)
})

View File

@@ -0,0 +1,77 @@
// ── The engagement trigger manifest ────────────────────────────────────────
//
// ENGAGEMENT.md §4.3 property 4. CI runs `npm run engagement:manifest -- --check`
// and that is the gate; this file is here for the reason
// `checkModuleIdentifiers.test.js` exists — **a check that silently stops
// checking is worse than no check**. So there are two tests: the committed file
// is current, and the generator actually notices a changed declaration.
//
// It also makes a stale manifest fail `npm test`, which is the run a developer
// does before pushing.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('fs')
const path = require('path')
const { build } = require('../scripts/engagementManifest')
const registries = require('../src/modules/registries')
const db = require('../src/utils/db')
after(() => db.close())
afterEach(() => registries._reset())
const MANIFEST_PATH = path.join(__dirname, '..', 'engagement-triggers.json')
const serialize = (m) => `${JSON.stringify(m, null, 2)}\n`
test('the committed manifest matches the declarations in the tree', () => {
const committed = fs.readFileSync(MANIFEST_PATH, 'utf8')
assert.equal(
serialize(build()),
committed,
'engagement-triggers.json is stale — run `npm run engagement:manifest` and commit the result',
)
})
test('the manifest holds core\'s triggers only, never a module\'s', () => {
// A module ships its own copy in its bundle (MODULE_API.md §6.1a), because
// core never has its sources to analyse. If a module's declarations leaked
// into core's manifest, the file would change depending on which modules
// happened to be installed on the machine that regenerated it.
registries._reset()
const api = registries.stage('uo')
api.registerEventTriggers([{
id: 'uo.house.idoc_warning',
label: 'House approaching collapse',
ceiling: 'owner',
variables: [{ name: 'house', type: 'string', required: true, example: 'The Silver Anvil' }],
}])
registries.apply(api.staged)
const manifest = build()
assert.equal(manifest.triggers.every((t) => t.owner === 'core'), true)
assert.equal(manifest.triggers.some((t) => t.id === 'uo.house.idoc_warning'), false)
})
test('and it notices a changed declaration — the check is live', () => {
const before = serialize(build())
// Register one more trigger AS CORE, then rebuild. `build()` calls
// registerCore(), which is a no-op once core has registered, so this lands
// beside core's five rather than replacing them.
registries._reset()
const api = registries.stage('core')
api.registerEventTriggers([{
id: 'core.probe',
label: 'A declaration the committed manifest does not have',
ceiling: 'staff',
variables: [{ name: 'why', type: 'string', required: true, example: 'proving the check works' }],
}])
registries.apply(api.staged)
const after_ = serialize(build())
assert.notEqual(after_, before)
assert.match(after_, /core\.probe/)
})

View File

@@ -0,0 +1,435 @@
// ── The trigger registry, the audience registry, and the emit path ─────────
//
// ENGAGEMENT.md Phase 2's acceptance criteria, one test apiece:
//
// • core's triggers appear in GET /admin/engagement/triggers
// • a module registering an un-namespaced trigger or audience fails, with the
// holder named
// • an audience whose module is uninstalled resolves EMPTY and dormant, never
// an error
// • a payload missing a `required` variable throws in dev, is dropped+logged
// in prod
// • engagement-triggers.json diffs zero, and is not a file that silently stops
// checking
//
// …plus the property the org lead's §7.2 decision creates and the plan never had
// to test before: **one namespace**. A trigger id and a stream id are the same
// id, so the interesting cases are the same-owner upgrade (core's five, on every
// boot) and the cross-owner collision (a module reaching for another's).
//
// Point the DB at a closed port BEFORE requiring anything: registries.js reaches
// utils/discordAnnounce, which reaches the pool at require time.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
const registries = require('../src/modules/registries')
const engagementEmit = require('../src/utils/engagementEmit')
const ctrl = require('../src/router/v1/admin/engagement.controller')
const coreTriggers = require('../src/config/coreTriggers')
const db = require('../src/utils/db')
after(() => db.close())
// Registries are process-global by design (there is one core), so a test that
// registers has to be able to undo it.
beforeEach(() => registries._reset())
afterEach(() => registries._reset())
// NODE_ENV decides throw-vs-drop, and node:test does not set it. Every emit test
// states the posture it is testing rather than inheriting whatever the shell had.
const originalEnv = process.env.NODE_ENV
afterEach(() => {
if (originalEnv === undefined) delete process.env.NODE_ENV
else process.env.NODE_ENV = originalEnv
})
function mockRes() {
return {
statusCode: 200,
body: null,
status(c) { this.statusCode = c; return this },
json(b) { this.body = b; return this },
}
}
/** A minimal valid declaration, for the tests that are about one field. */
const decl = (over = {}) => ({
id: 'uo.house.idoc_warning',
label: 'House approaching collapse',
ceiling: 'owner',
variables: [
{ name: 'house', type: 'string', required: true, example: 'The Silver Anvil' },
{ name: 'nextStage', type: 'datetime', required: false, example: '2026-08-30T04:00:00Z' },
],
...over,
})
/** Register a batch as `owner`, the way the loader's second pass commits one. */
function register(owner, fn) {
const api = registries.stage(owner)
fn(api)
registries.apply(api.staged)
}
// ── Core's own declarations ────────────────────────────────────────────────
test('core registers its five triggers, and they are the five stream ids', () => {
registries.registerCore()
const triggerIds = registries.allTriggers().map((t) => t.id).sort()
const streamIds = registries.allStreams().map((s) => s.id).sort()
assert.deepEqual(triggerIds, streamIds)
assert.deepEqual(triggerIds, [
'news.post', 'team.announcement', 'team.forum.post',
'team.leadership.changed', 'team.member.joined',
])
})
test('the four Team triggers ceiling at members — a private forum excerpt cannot be widened', () => {
registries.registerCore()
for (const id of ['team.member.joined', 'team.leadership.changed', 'team.forum.post', 'team.announcement']) {
assert.equal(registries.eventTrigger(id).ceiling, 'members', id)
}
// News is public content, so it may reach every signed-in user — but its
// DEFAULT is still the narrower `subscribers`, because a rule an operator has
// not thought about should not be a newsletter to the whole site.
const news = registries.eventTrigger('news.post')
assert.equal(news.ceiling, 'authenticated')
assert.equal(news.audience, 'subscribers')
})
test('every core variable carries an example — the preview and test-send depend on it', () => {
for (const t of coreTriggers.TRIGGERS) {
for (const v of t.variables) {
assert.ok(v.example !== undefined && v.example !== '', `${t.id}.${v.name} has an example`)
}
}
})
// ── One namespace (§7.2) ───────────────────────────────────────────────────
test('the same owner may hold an id as BOTH a stream and a trigger — that is the upgrade', () => {
register('uo', (api) => {
api.registerNotificationStreams([{ id: 'uo.market.sale', label: 'Vendor sales' }])
api.registerEventTriggers([decl({ id: 'uo.market.sale', label: 'Vendor sale', ceiling: 'owner' })])
})
assert.equal(registries.isValidStream('uo.market.sale'), true)
assert.equal(registries.eventTrigger('uo.market.sale').ceiling, 'owner')
assert.equal(registries.eventOwner('uo.market.sale'), 'uo')
})
test('a module cannot attach a payload contract to another owner\'s stream', () => {
register('uo', (api) => {
api.registerNotificationStreams([{ id: 'uo.market.sale', label: 'Vendor sales' }])
})
assert.throws(
() => register('rust', (api) => api.registerEventTriggers([decl({ id: 'uo.market.sale' })])),
// The holder is named, and so is the facet it holds it as: under one
// namespace "no such trigger" and "that id is someone's stream" are
// different problems with the same symptom.
/already registered as a notification stream by "uo"/,
)
assert.equal(registries.eventTrigger('uo.market.sale'), null)
})
test('and the collision is symmetric — a stream cannot take another owner\'s trigger id', () => {
register('uo', (api) => api.registerEventTriggers([decl({ id: 'uo.market.sale' })]))
assert.throws(
() => register('rust', (api) => api.registerNotificationStreams([{ id: 'uo.market.sale', label: 'x' }])),
/already registered as an event trigger by "uo"/,
)
})
test('a trigger must be namespaced under its owner, with the seven legacy ids exempt', () => {
assert.throws(
() => register('rust', (api) => api.registerEventTriggers([decl({ id: 'house.collapsed' })])),
/not namespaced "rust\."/,
)
// The same allowlist streams use, and it has to be the same one: under one
// namespace `idoc.warning` is a single id, so if `uo` may hold it unprefixed
// as a stream it may hold it unprefixed as a trigger.
register('uo', (api) => api.registerEventTriggers([decl({ id: 'idoc.warning' })]))
assert.equal(registries.eventTrigger('idoc.warning').owner, 'uo')
})
test('an id with an underscore is legal — the grammar was relaxed, not replaced', () => {
register('uo', (api) => api.registerEventTriggers([decl({ id: 'uo.house.idoc_warning' })]))
assert.ok(registries.eventTrigger('uo.house.idoc_warning'))
assert.throws(
() => register('uo', (api) => api.registerEventTriggers([decl({ id: 'uo.House.Warning' })])),
/bad trigger id/,
)
})
test('nothing commits when a later claim in the same batch fails', () => {
// A well-formed id that is not the owner's. Shape errors throw at the CALL
// (checkTriggerShape, so the stack points at the module); this one survives to
// apply(), which is where the all-or-nothing rule lives.
assert.throws(() => register('uo', (api) => {
api.registerEventTriggers([decl({ id: 'uo.a.one' }), decl({ id: 'other.thing' })])
}), /not namespaced/)
assert.equal(registries.eventTrigger('uo.a.one'), null)
})
// ── Declaration shape (§4.3) ───────────────────────────────────────────────
test('a ceiling is required and has no default — there is no safe value to guess', () => {
assert.throws(
() => register('uo', (api) => api.registerEventTriggers([decl({ ceiling: undefined })])),
/needs a ceiling/,
)
assert.throws(
() => register('uo', (api) => api.registerEventTriggers([decl({ ceiling: 'everybody' })])),
/needs a ceiling/,
)
})
test('a default audience wider than the ceiling is refused at registration', () => {
assert.throws(
() => register('uo', (api) => api.registerEventTriggers([decl({ ceiling: 'staff', audience: 'everyone' })])),
/is not permitted by ceiling "staff"/,
)
// Incomparable is refused too, which is the case a total order would allow.
assert.throws(
() => register('uo', (api) => api.registerEventTriggers([decl({ ceiling: 'staff', audience: 'owner' })])),
/is not permitted by ceiling "staff"/,
)
// Omitted, it defaults to the ceiling itself.
register('uo', (api) => api.registerEventTriggers([decl({ ceiling: 'staff', audience: undefined })]))
assert.equal(registries.eventTrigger('uo.house.idoc_warning').audience, 'staff')
})
test('a variable without an example is refused — that is what makes preview possible', () => {
assert.throws(
() => register('uo', (api) => api.registerEventTriggers([
decl({ variables: [{ name: 'house', type: 'string', required: true }] }),
])),
/needs an example/,
)
})
test('a subjectKey naming no declared variable is refused', () => {
assert.throws(
() => register('uo', (api) => api.registerEventTriggers([decl({ subjectKey: 'serial' })])),
/subjectKey "serial" is not one of its variables/,
)
register('uo', (api) => api.registerEventTriggers([decl({ subjectKey: 'house' })]))
assert.equal(registries.eventTrigger('uo.house.idoc_warning').subjectKey, 'house')
})
test('kind defaults to event and only the two declared kinds are accepted', () => {
register('uo', (api) => api.registerEventTriggers([
decl({ id: 'uo.a.one' }),
decl({ id: 'uo.a.two', kind: 'scheduled' }),
]))
assert.equal(registries.eventTrigger('uo.a.one').kind, 'event')
assert.equal(registries.eventTrigger('uo.a.two').kind, 'scheduled')
assert.throws(
() => register('uo', (api) => api.registerEventTriggers([decl({ id: 'uo.a.three', kind: 'cron' })])),
/unknown kind "cron"/,
)
})
test('a declaration keeps only what the contract names', () => {
register('uo', (api) => api.registerEventTriggers([decl({ handler: () => 'nope', secret: 'x' })]))
const t = registries.eventTrigger('uo.house.idoc_warning')
assert.equal(t.handler, undefined)
assert.equal(t.secret, undefined)
assert.deepEqual(Object.keys(t).sort(), [
'audience', 'ceiling', 'description', 'id', 'kind', 'label', 'owner', 'subjectKey',
'variables', 'version',
])
})
// ── Audiences (§5.1a) ──────────────────────────────────────────────────────
const aud = (over = {}) => ({
id: 'uo.team.members',
label: 'Members of a team',
ceiling: 'members',
params: [{ id: 'teamId', type: 'int', required: true }],
resolve: async () => [4, 9],
...over,
})
test('an audience registers, resolves to user ids, and never leaks its resolver', async () => {
register('uo', (api) => api.registerAudiences([aud()]))
const listed = registries.allAudiences()
assert.equal(listed.length, 1)
assert.equal(listed[0].resolve, undefined)
assert.deepEqual((await registries.resolveAudience('uo.team.members', { teamId: 3 })).userIds, [4, 9])
})
test('an audience whose module is uninstalled is DORMANT and empty, never an error', async () => {
const gone = await registries.resolveAudience('uo.team.members', { teamId: 3 })
assert.deepEqual(gone, { dormant: true, userIds: [] })
})
test('a resolver that throws or answers rubbish costs an empty set, not a wrong one', async () => {
register('uo', (api) => api.registerAudiences([
aud({ id: 'uo.a.boom', resolve: async () => { throw new Error('db down') } }),
aud({ id: 'uo.a.junk', resolve: async () => 'everyone' }),
aud({ id: 'uo.a.dirty', resolve: async () => [4, '9', 0, -2, 4, null, 'x'] }),
]))
assert.deepEqual((await registries.resolveAudience('uo.a.boom')).userIds, [])
assert.deepEqual((await registries.resolveAudience('uo.a.junk')).userIds, [])
// Filtered to positive integers and de-duplicated. This is the one value a
// module hands core that decides who receives mail.
assert.deepEqual((await registries.resolveAudience('uo.a.dirty')).userIds, [4, 9])
// Not dormant: the module IS installed. Dormant is a different answer from
// "resolved to nobody", and Phase 4's admin UI shows them differently.
assert.equal((await registries.resolveAudience('uo.a.boom')).dormant, false)
})
test('an audience needs a ceiling, a resolve, and its owner\'s prefix', () => {
assert.throws(() => register('uo', (api) => api.registerAudiences([aud({ ceiling: undefined })])), /needs a ceiling/)
assert.throws(() => register('uo', (api) => api.registerAudiences([aud({ resolve: undefined })])), /has no resolve\(\)/)
assert.throws(() => register('rust', (api) => api.registerAudiences([aud()])), /not namespaced "rust\."/)
})
test('audiences are their own id space — an audience may share a name with a trigger', () => {
register('uo', (api) => {
api.registerEventTriggers([decl({ id: 'uo.team.members' })])
api.registerAudiences([aud({ id: 'uo.team.members' })])
})
assert.ok(registries.eventTrigger('uo.team.members'))
assert.ok(registries.audience('uo.team.members'))
})
// ── The emit path (§4.3 property 1) ────────────────────────────────────────
const emitOk = () => {
register('uo', (api) => api.registerEventTriggers([decl({ subjectKey: 'house' })]))
}
test('a valid emit validates, normalises and returns the event', () => {
process.env.NODE_ENV = 'development'
emitOk()
const out = engagementEmit.emit('uo', 'uo.house.idoc_warning', {
data: { house: 'The Silver Anvil', nextStage: '2026-08-30T04:00:00Z' },
ownerUserId: 7,
})
assert.equal(out.ok, true)
assert.equal(out.event.subject, 'The Silver Anvil') // derived from subjectKey
assert.equal(out.event.ownerUserId, 7)
assert.equal(out.event.data.nextStage, '2026-08-30T04:00:00.000Z') // normalised
assert.ok(out.event.occurredAt)
})
test('a payload missing a required variable throws in dev and is dropped in prod', () => {
emitOk()
process.env.NODE_ENV = 'development'
assert.throws(
() => engagementEmit.emit('uo', 'uo.house.idoc_warning', { data: { nextStage: '2026-08-30T04:00:00Z' } }),
/house: required/,
)
// Same call, production posture: no throw, and an unmistakable failure result.
// This is called from inside a game-event handler; a contract problem of
// core's must not become the module's control flow.
process.env.NODE_ENV = 'production'
const out = engagementEmit.emit('uo', 'uo.house.idoc_warning', { data: {} })
assert.equal(out.ok, false)
assert.match(out.reason, /payload for "uo\.house\.idoc_warning" is invalid/)
})
test('every payload problem is reported at once, not one per round trip', () => {
process.env.NODE_ENV = 'development'
register('uo', (api) => api.registerEventTriggers([decl({
variables: [
{ name: 'house', type: 'string', required: true, example: 'x' },
{ name: 'count', type: 'int', required: true, example: 2 },
{ name: 'link', type: 'url', required: true, example: '/a' },
],
})]))
assert.throws(
() => engagementEmit.emit('uo', 'uo.house.idoc_warning', { data: { house: 5, count: 1.5, link: 'x' } }),
/house: expected a string; count: expected an integer; link: expected a site-relative path/,
)
})
test('a url variable is relative-only — a protocol-relative path never reaches an href', () => {
process.env.NODE_ENV = 'production'
register('uo', (api) => api.registerEventTriggers([decl({
variables: [{ name: 'link', type: 'url', required: true, example: '/houses/1' }],
})]))
const bad = (link) => engagementEmit.emit('uo', 'uo.house.idoc_warning', { data: { link } }).ok
assert.equal(bad('//evil.test/x'), false)
assert.equal(bad('https://evil.test/x'), false)
assert.equal(bad('houses/1'), false)
assert.equal(bad('/houses/1?stage=2'), true)
})
test('a module cannot emit another owner\'s trigger, nor an unknown one', () => {
process.env.NODE_ENV = 'production'
emitOk()
const foreign = engagementEmit.emit('rust', 'uo.house.idoc_warning', { data: { house: 'x' } })
assert.equal(foreign.ok, false)
assert.match(foreign.reason, /belongs to "uo"/)
const unknown = engagementEmit.emit('uo', 'uo.nope.gone', { data: {} })
assert.equal(unknown.ok, false)
assert.match(unknown.reason, /unknown event trigger/)
})
test('a scheduled trigger is not emitted directly — the evaluator fires it (Q6)', () => {
process.env.NODE_ENV = 'production'
register('uo', (api) => api.registerEventTriggers([decl({ kind: 'scheduled' })]))
const out = engagementEmit.emit('uo', 'uo.house.idoc_warning', { data: { house: 'x' } })
assert.equal(out.ok, false)
assert.match(out.reason, /is kind "scheduled" and is not emitted directly/)
})
test('an explicit subject beats the declared subjectKey; envelope fields are bounded', () => {
process.env.NODE_ENV = 'production'
emitOk()
const call = (envelope) => engagementEmit.emit('uo', 'uo.house.idoc_warning', {
data: { house: 'The Silver Anvil' }, ...envelope,
})
assert.equal(call({ subject: 4141 }).event.subject, '4141')
assert.equal(call({}).event.subject, 'The Silver Anvil')
assert.equal(call({ subject: {} }).ok, false)
assert.equal(call({ ownerUserId: 0 }).ok, false)
assert.equal(call({ ownerUserId: '7' }).ok, false)
assert.equal(call({ dedupeKey: 'x'.repeat(191) }).ok, false)
assert.equal(call({ occurredAt: 'not a date' }).ok, false)
assert.equal(call({ occurredAt: new Date('2026-01-02T03:04:05Z') }).event.occurredAt, '2026-01-02T03:04:05.000Z')
})
test('undeclared payload keys are dropped rather than rejected', () => {
process.env.NODE_ENV = 'development'
emitOk()
const out = engagementEmit.emit('uo', 'uo.house.idoc_warning', {
data: { house: 'The Silver Anvil', ownerIp: '10.0.0.4' },
})
assert.equal(out.ok, true)
assert.equal(out.event.data.ownerIp, undefined)
})
// ── The admin catalog (G3) ─────────────────────────────────────────────────
test('GET /admin/engagement/triggers serves core\'s declarations and the ceiling vocabulary', () => {
registries.registerCore()
const res = mockRes()
ctrl.listTriggers({}, res)
assert.equal(res.body.triggers.length, 5)
const news = res.body.triggers.find((t) => t.id === 'news.post')
assert.equal(news.owner, 'core')
assert.ok(news.variables.some((v) => v.name === 'title' && v.example))
// The lattice travels with the catalog so the rule editor never offers an
// audience the server will refuse.
const staff = res.body.ceilings.find((c) => c.id === 'staff')
assert.deepEqual(staff.permits, ['staff'])
const everyone = res.body.ceilings.find((c) => c.id === 'everyone')
assert.equal(everyone.permits.length, 6)
})
test('GET /admin/engagement/audiences never serves a resolver', () => {
register('uo', (api) => api.registerAudiences([aud()]))
const res = mockRes()
ctrl.listAudiences({}, res)
assert.equal(res.body.audiences.length, 1)
assert.equal(res.body.audiences[0].resolve, undefined)
assert.equal(res.body.audiences[0].ceiling, 'members')
})

View File

@@ -511,8 +511,15 @@ test('ctx exposes exactly the documented surface, and is frozen', () => {
// (TEAMS.md §2.3). Read-only by omission: a module answers questions about
// Teams and never asks them, so there is no getter here to add later by
// accident.
// API 1.7.0 added `events` and `inbox` (ENGAGEMENT.md Phase 2), and they are
// two members rather than one on purpose: `events.emit` fires a DECLARED event
// for the engine to decide the consequence of, and `inbox.push` writes a
// user's in-app inbox with no rule in between. `inbox.push` is present and
// throws until Phase 7 builds the channel — which is why it has to be in this
// list now: a member of a declared version that were absent would make
// MODULE_API_VERSION a lie, and this test is what says so.
assert.deepEqual(probe.keys, [
'activity', 'auth', 'db', 'express', 'log', 'middleware', 'moduleId', 'paths',
'activity', 'auth', 'db', 'events', 'express', 'inbox', 'log', 'middleware', 'moduleId', 'paths',
'posts', 'push', 'secretBox', 'settings', 'site', 'teams', 'uploads', 'users', 'validator',
])
// is core's limiter FACTORY, not a limiter: a module states its own