feat(modules): event triggers, audiences and the ceiling lattice (engagement Phase 2)
The contract half of the engagement system: a module (and core) can DECLARE an
event with a payload contract and fire it. Nothing delivers yet — `emit`
validates, logs and stops, and Phase 4 replaces that log line with the engine.
`api.registerEventTriggers` and `api.registerAudiences` ride the existing
stage()/apply() validate-then-commit discipline, so a registrant that throws
halfway leaves nothing behind. `ctx.events.emit` is fire-and-forget and binds
the owner from the calling module — a module fires its own triggers and no one
else's. `ctx.inbox.push` is present and throws until Phase 7, the shape 1.6.0
settled on for a member that arrives a phase late.
MODULE_API_VERSION 1.7.0 on both halves. Additions only; module-uo's
`coreApi: "^1.3.0"` still resolves.
Three design decisions, approved by the org lead before any code:
ONE NAMESPACE for trigger ids and notification-stream ids (ENGAGEMENT.md §7.2,
against the recommendation in the text). A trigger is a payload contract
attached to an id that may also carry a subscription toggle, so an id has
exactly one owner across both facets, checked in both directions. Core's five
trigger ids ARE its five stream ids, so the same-owner upgrade case is
exercised on every boot rather than only by a module. It keeps
notification_channel_prefs single-keyed in Phase 3, where two namespaces would
have forced a `kind` discriminator into its primary key.
Two knock-on effects appeared only once it was implemented. The id grammar had
to be RELAXED to admit `_` inside a segment — §4.3's own worked example is
`uo.house.idoc_warning`, and two grammars over one namespace would mean an id
legal as a trigger and illegal as the stream it is the same event as. And the
seven grandfathered `uo.*` ids had to share their legacy allowlist with
triggers, because under one namespace `idoc.warning` is a single id. The push
catalog is untouched either way: allStreams() still serves the stream facet
only, so the shipped Android client sees exactly what it saw before.
THE CEILING LATTICE (G24), which the plan named everywhere and defined nowhere.
It is containment, not size: everyone ⊃ authenticated ⊃ {subscribers, members,
staff, owner}, with the four leaves mutually incomparable. The flat total order
the plan's wording invites would let a `staff`-ceilinged trigger be given an
`owner` audience — a rule that mails cheat detection to the player it detected.
Fewer people is not less exposure. Two incomparable ceilings have no meet at
all, so a composition is refused rather than guessed; union-widens is the
intuitive implementation and it is the wrong one.
`kind: 'event' | 'scheduled'` is declarable now and no evaluator exists (§7.1
Q6). Registration accepts `scheduled` and emit refuses to fire one, so `kind`
means something from the moment it can be written rather than from the moment
it is honoured.
Also: `GET /admin/engagement/{triggers,audiences}`, served from the registries
rather than a table so an uninstalled module simply stops appearing;
`npm run engagement:manifest` plus its CI `--check`, the twin of the route
manifest, because renaming a variable breaks stored templates silently, at send
time, in mail someone already received.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
86
server/test/engagementCeilings.test.js
Normal file
86
server/test/engagementCeilings.test.js
Normal 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)
|
||||
})
|
||||
77
server/test/engagementManifest.test.js
Normal file
77
server/test/engagementManifest.test.js
Normal 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/)
|
||||
})
|
||||
435
server/test/engagementTriggers.test.js
Normal file
435
server/test/engagementTriggers.test.js
Normal 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')
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user