Files
website/server/test/engagementTriggers.test.js
wtclaude 563199a096
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 25s
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / server-tests (pull_request) Successful in 10m29s
feat(modules): event triggers, audiences and the ceiling lattice (engagement Phase 2)
The contract half of the engagement system: a module (and core) can DECLARE an
event with a payload contract and fire it. Nothing delivers yet — `emit`
validates, logs and stops, and Phase 4 replaces that log line with the engine.

`api.registerEventTriggers` and `api.registerAudiences` ride the existing
stage()/apply() validate-then-commit discipline, so a registrant that throws
halfway leaves nothing behind. `ctx.events.emit` is fire-and-forget and binds
the owner from the calling module — a module fires its own triggers and no one
else's. `ctx.inbox.push` is present and throws until Phase 7, the shape 1.6.0
settled on for a member that arrives a phase late.

MODULE_API_VERSION 1.7.0 on both halves. Additions only; module-uo's
`coreApi: "^1.3.0"` still resolves.

Three design decisions, approved by the org lead before any code:

ONE NAMESPACE for trigger ids and notification-stream ids (ENGAGEMENT.md §7.2,
against the recommendation in the text). A trigger is a payload contract
attached to an id that may also carry a subscription toggle, so an id has
exactly one owner across both facets, checked in both directions. Core's five
trigger ids ARE its five stream ids, so the same-owner upgrade case is
exercised on every boot rather than only by a module. It keeps
notification_channel_prefs single-keyed in Phase 3, where two namespaces would
have forced a `kind` discriminator into its primary key.

Two knock-on effects appeared only once it was implemented. The id grammar had
to be RELAXED to admit `_` inside a segment — §4.3's own worked example is
`uo.house.idoc_warning`, and two grammars over one namespace would mean an id
legal as a trigger and illegal as the stream it is the same event as. And the
seven grandfathered `uo.*` ids had to share their legacy allowlist with
triggers, because under one namespace `idoc.warning` is a single id. The push
catalog is untouched either way: allStreams() still serves the stream facet
only, so the shipped Android client sees exactly what it saw before.

THE CEILING LATTICE (G24), which the plan named everywhere and defined nowhere.
It is containment, not size: everyone ⊃ authenticated ⊃ {subscribers, members,
staff, owner}, with the four leaves mutually incomparable. The flat total order
the plan's wording invites would let a `staff`-ceilinged trigger be given an
`owner` audience — a rule that mails cheat detection to the player it detected.
Fewer people is not less exposure. Two incomparable ceilings have no meet at
all, so a composition is refused rather than guessed; union-widens is the
intuitive implementation and it is the wrong one.

`kind: 'event' | 'scheduled'` is declarable now and no evaluator exists (§7.1
Q6). Registration accepts `scheduled` and emit refuses to fire one, so `kind`
means something from the moment it can be written rather than from the moment
it is honoured.

Also: `GET /admin/engagement/{triggers,audiences}`, served from the registries
rather than a table so an uninstalled module simply stops appearing;
`npm run engagement:manifest` plus its CI `--check`, the twin of the route
manifest, because renaming a variable breaks stored templates silently, at send
time, in mail someone already received.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 06:40:28 -05:00

436 lines
19 KiB
JavaScript

// ── 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')
})