feat(modules): event triggers, audiences and the ceiling lattice (engagement Phase 2) #168
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
|
||||
211
server/engagement-triggers.json
Normal file
211
server/engagement-triggers.json
Normal 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."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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": [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
132
server/scripts/engagementManifest.js
Normal file
132
server/scripts/engagementManifest.js
Normal 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 }
|
||||
147
server/src/config/coreTriggers.js
Normal file
147
server/src/config/coreTriggers.js
Normal 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 }
|
||||
107
server/src/modules/ceilings.js
Normal file
107
server/src/modules/ceilings.js
Normal file
@@ -0,0 +1,107 @@
|
||||
// ── Audience ceilings ──────────────────────────────────────────────────────
|
||||
//
|
||||
// G24, and the one piece of ENGAGEMENT.md that was named everywhere and defined
|
||||
// nowhere: §5.1a says a composed segment takes "the narrowest ceiling it
|
||||
// contains" and §4.3 says a trigger declares "the widest audience a rule may
|
||||
// ever give it", but neither says what narrower MEANS. This file is that
|
||||
// answer, settled by the org lead at the start of Phase 2.
|
||||
//
|
||||
// **It is a subset lattice, not a size ordering.** The tempting model is a flat
|
||||
// total order — self < owner < staff < members < authenticated < everyone,
|
||||
// compared with `<=` — and it is wrong in a way that matters. Under a total
|
||||
// order a trigger ceilinged at `staff` also permits `owner`, so a rule could
|
||||
// mail `uo.cheat.detected` to the player who was detected. "Fewer people" is not
|
||||
// "less exposure"; the question is always WHICH people.
|
||||
//
|
||||
// So the order is containment, and it is a TREE:
|
||||
//
|
||||
// everyone anyone at all, signed in or not
|
||||
// └── authenticated any logged-in user
|
||||
// ├── subscribers logged-in users who opted into this id
|
||||
// ├── members a module-declared list (a Team, the governors)
|
||||
// ├── staff admin / editor / moderator
|
||||
// └── owner the one user the event is about
|
||||
//
|
||||
// The four leaves are mutually INCOMPARABLE, deliberately. `owner` is not a
|
||||
// subset of `subscribers` (an owner need not have subscribed), `staff` is not a
|
||||
// subset of `members`, and no pair of them has a common descendant. That is what
|
||||
// makes `meet()` below return null rather than guessing, and a null meet is a
|
||||
// refused save (§5.1a rule 3) rather than a silent widening.
|
||||
//
|
||||
// Nothing here reaches the database, the network or a user record. It is
|
||||
// arithmetic over six constants, so it is safe to require anywhere.
|
||||
|
||||
// child → parent. A tree, which is what makes `permits` a walk to the root and
|
||||
// `meet` a comparison rather than a search: two nodes in a tree have a greatest
|
||||
// lower bound only when one of them IS the bound.
|
||||
const PARENT = {
|
||||
everyone: null,
|
||||
authenticated: 'everyone',
|
||||
subscribers: 'authenticated',
|
||||
members: 'authenticated',
|
||||
staff: 'authenticated',
|
||||
owner: 'authenticated',
|
||||
}
|
||||
|
||||
// Operator-facing text. Lives beside the lattice rather than in the admin client
|
||||
// so the rule editor and the trigger catalog describe a ceiling the same way.
|
||||
const LABELS = {
|
||||
everyone: 'Everyone, including signed-out visitors',
|
||||
authenticated: 'Any signed-in user',
|
||||
subscribers: 'Signed-in users subscribed to this event',
|
||||
members: 'Members of a module-declared list',
|
||||
staff: 'Staff only',
|
||||
owner: 'Only the user the event is about',
|
||||
}
|
||||
|
||||
const CEILINGS = Object.keys(PARENT)
|
||||
|
||||
/** Is this one of the six? The gate every registration and every rule save runs. */
|
||||
const isCeiling = (value) => Object.prototype.hasOwnProperty.call(PARENT, value)
|
||||
|
||||
/**
|
||||
* May `ceiling` reach as widely as `candidate`?
|
||||
*
|
||||
* True when `candidate` is `ceiling` itself or sits below it — i.e. walking
|
||||
* `candidate` up the tree reaches `ceiling`. Everything else is false, including
|
||||
* every incomparable pair, so this FAILS CLOSED on an id it does not know.
|
||||
*/
|
||||
function permits(ceiling, candidate) {
|
||||
if (!isCeiling(ceiling) || !isCeiling(candidate)) return false
|
||||
for (let at = candidate; at; at = PARENT[at]) {
|
||||
if (at === ceiling) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* The narrower of two ceilings, or `null` when they are incomparable.
|
||||
*
|
||||
* This is the greatest lower bound, and in a tree it exists only when one node
|
||||
* is an ancestor of the other — so `meet('authenticated', 'staff')` is `staff`
|
||||
* and `meet('staff', 'owner')` is `null`. Returning null is the point:
|
||||
* §5.1a rule 3 says composition must never widen, and the intuitive
|
||||
* union-widens implementation is the wrong one. A caller that cannot name a
|
||||
* bound must refuse the save, not pick a side.
|
||||
*/
|
||||
function meet(a, b) {
|
||||
if (!isCeiling(a) || !isCeiling(b)) return null
|
||||
if (permits(a, b)) return b
|
||||
if (permits(b, a)) return a
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold `meet` across a whole expression's ceilings.
|
||||
*
|
||||
* `A OR B` takes the tighter of the two, and so does `A AND B` — the direction
|
||||
* of the boolean operator is irrelevant, because the ceiling is a statement
|
||||
* about what the operator is ALLOWED to reach, not about what it will resolve
|
||||
* to. An empty list has no bound to state and is null, not `everyone`.
|
||||
*/
|
||||
function meetAll(list) {
|
||||
if (!Array.isArray(list) || !list.length) return null
|
||||
return list.reduce((acc, next) => (acc === null ? null : meet(acc, next)), list[0])
|
||||
}
|
||||
|
||||
module.exports = { CEILINGS, LABELS, isCeiling, permits, meet, meetAll }
|
||||
@@ -121,6 +121,7 @@ function buildCtx(id, moduleRoot) {
|
||||
const users = require('../model/users/users.model')
|
||||
const teams = require('../model/teams/teamSync.model')
|
||||
const teamActivity = require('../model/teams/teamActivity.model')
|
||||
const engagementEmit = require('../utils/engagementEmit')
|
||||
const { makeLimiter, accountChangeLimiter } = require('../middleware/rateLimit')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
@@ -207,6 +208,40 @@ function buildCtx(id, moduleRoot) {
|
||||
),
|
||||
},
|
||||
},
|
||||
// Engagement (API 1.7.0, ENGAGEMENT.md §5.1). The push half of the trigger
|
||||
// contract the module registered with `api.registerEventTriggers`.
|
||||
//
|
||||
// `id` is bound here and is never taken from the arguments, exactly as
|
||||
// `teamActivity.push(id, …)` binds its source: a module fires its OWN
|
||||
// triggers. Without that binding, emit would be a way to fire another
|
||||
// module's event with a payload of your choosing, and every rule an operator
|
||||
// wrote against it would fire on that.
|
||||
//
|
||||
// Fire-and-forget and returns undefined. `emit()` answers a result its core
|
||||
// callers want; a module gets nothing back on purpose, because there is
|
||||
// nothing it could correctly do with a failure from inside a game-event
|
||||
// handler — and "never throws in production" is only true if there is also
|
||||
// nothing to await. The dev-time throw is inside `emit`, where the stack
|
||||
// still points at the module's own call.
|
||||
events: {
|
||||
emit: (triggerId, envelope) => {
|
||||
engagementEmit.emit(id, triggerId, envelope)
|
||||
},
|
||||
},
|
||||
// The in-app sink (§5.1) — a module writing the inbox directly, without a
|
||||
// rule. It is PRESENT AND THROWS until Phase 7 builds the channel and the
|
||||
// `user_notifications` table behind it.
|
||||
//
|
||||
// Present-and-throwing rather than absent is the shape 1.6.0 settled on for
|
||||
// exactly this situation (`ctx.teams.activity.push` before its phase landed):
|
||||
// the version number states a whole surface, so a member of 1.7.0 that is
|
||||
// missing would make the version a lie, and one that silently accepted data
|
||||
// into a table that does not exist would be the worst of the three.
|
||||
inbox: {
|
||||
push: () => {
|
||||
throw new Error('ctx.inbox.push is not available until the in-app channel lands (ENGAGEMENT.md Phase 7)')
|
||||
},
|
||||
},
|
||||
// One function, for one caller: the `admin.users.detail` slot router needs
|
||||
// the user its prefix names. Narrowed like `ctx.posts` — the users model
|
||||
// exports creation, role changes and password handling, none of which is a
|
||||
@@ -293,6 +328,24 @@ function buildApi(record) {
|
||||
once('registerSlashCommands')
|
||||
record.staged.registerSlashCommands(commands)
|
||||
},
|
||||
// The engagement contract (API 1.7.0, ENGAGEMENT.md §4.3 / §5.1a). Both
|
||||
// STAGE, like the registries above them, and both take `once` for the same
|
||||
// reason `registerNotificationStreams` does: a batch is a module's complete
|
||||
// statement about what it declares, and a second call is a module changing
|
||||
// its mind halfway through register() rather than adding to it.
|
||||
//
|
||||
// A trigger id and a stream id share one namespace (§7.2), so a module that
|
||||
// calls both may legitimately name the same id in each — that is one event
|
||||
// with a subscription toggle and a payload contract, and it is the case core
|
||||
// itself exercises on every boot.
|
||||
registerEventTriggers(triggers) {
|
||||
once('registerEventTriggers')
|
||||
record.staged.registerEventTriggers(triggers)
|
||||
},
|
||||
registerAudiences(audiences) {
|
||||
once('registerAudiences')
|
||||
record.staged.registerAudiences(audiences)
|
||||
},
|
||||
// The two lifecycle hooks (§2.5). Registered here, dispatched from
|
||||
// lifecycle.js — this file runs with no database and the hooks run with one.
|
||||
// Both are optional: a module with no warm-up and nothing to close simply
|
||||
|
||||
@@ -29,12 +29,28 @@
|
||||
// mount rule: nothing a module claims takes effect until the module as a whole is
|
||||
// known good.
|
||||
//
|
||||
// Two more arrived with the engagement system (ENGAGEMENT.md Phase 2), from a
|
||||
// different workstream but through the same door:
|
||||
//
|
||||
// 4. `registerEventTriggers(triggers)` — §4.3. The payload CONTRACT behind an
|
||||
// event id: what a template may interpolate, and how widely a rule may
|
||||
// ever send it (the ceiling, G24).
|
||||
// 5. `registerAudiences(audiences)` — §5.1a. Named sets of user ids a
|
||||
// module can resolve over its own data, for an operator to point a rule at.
|
||||
//
|
||||
// **Triggers and notification streams share ONE id namespace** (the org lead's
|
||||
// §7.2 decision). A stream entry is a subscription toggle and a trigger is a
|
||||
// payload contract, so they stay two REGISTRATIONS with two shapes — but an id
|
||||
// has exactly one owner across both, and `news.post` names one event whichever
|
||||
// question is being asked of it. See the cross-facet checks in `apply()`.
|
||||
//
|
||||
// Nothing here reaches the database or the network. It is a require-time-safe
|
||||
// collection of what core and modules have declared, read at request time.
|
||||
|
||||
const express = require('express')
|
||||
|
||||
const log = require('../utils/logger')('modules')
|
||||
const ceilings = require('./ceilings')
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -80,6 +96,21 @@ let teamProvider = null
|
||||
// calling `interaction.deferReply()` would be a module holding a Discord handle.
|
||||
const slashCommands = new Map()
|
||||
|
||||
// trigger id → { owner, id, label, description, kind, subjectKey, audience,
|
||||
// ceiling, version, variables } (ENGAGEMENT.md §4.3, API 1.7.0).
|
||||
//
|
||||
// A Map rather than an array, unlike `streams`: a stream catalog is READ WHOLE
|
||||
// (the app renders it in registration order) and a trigger is READ BY ID (the
|
||||
// emit path, the rule editor, the template editor), so insertion order is kept
|
||||
// for display and the lookup is the primary access.
|
||||
const triggers = new Map()
|
||||
|
||||
// audience id → { owner, id, label, description, params, ceiling, resolve }
|
||||
// (§5.1a). Its own id space, not the trigger/stream one: an audience names a set
|
||||
// of PEOPLE and a trigger names an EVENT, and `uo.team.members` colliding with a
|
||||
// trigger of the same name would be a collision between two unrelated things.
|
||||
const audiences = new Map()
|
||||
|
||||
let coreRegistered = false
|
||||
|
||||
// Stream ids that predate the module system and may not carry their owner's
|
||||
@@ -99,8 +130,17 @@ const LEGACY_STREAM_IDS = {
|
||||
// announce_job_legs.leg and the body of the admin retry endpoint.
|
||||
const LEGACY_LEGS = { uo: ['towncrier'] }
|
||||
|
||||
const STREAM_ID = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$/
|
||||
// ONE grammar for the one namespace streams and triggers share. It relaxes what
|
||||
// `STREAM_ID` used to allow by admitting `_` inside a segment, because the
|
||||
// trigger ids this contract is written for have them (`uo.house.idoc_warning`,
|
||||
// ENGAGEMENT.md §4.3) and two grammars over one namespace would mean an id that
|
||||
// is legal as a trigger and illegal as the stream it is the same event as.
|
||||
// Relaxation only: every id valid before is valid now, and no stored id changes.
|
||||
const EVENT_ID = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/
|
||||
const LEG_ID = /^[a-z][a-z0-9.]{1,62}$/
|
||||
// Audiences are their own id space (see the `audiences` Map), so they get their
|
||||
// own constant even though the grammar is the same one.
|
||||
const AUDIENCE_ID = EVENT_ID
|
||||
|
||||
// A module's claim must carry its id. Core's ids are its own namespace, and the
|
||||
// grandfathered names are the ones that predate all of this.
|
||||
@@ -243,6 +283,68 @@ const slashCommandDefinitions = () =>
|
||||
/** One command, handler included. The dispatcher's lookup. */
|
||||
const slashCommand = (name) => slashCommands.get(name) || null
|
||||
|
||||
// ── Event triggers (ENGAGEMENT.md §4.3) ────────────────────────────────────
|
||||
|
||||
/** Every declaration, core's first, in registration order. The admin catalog. */
|
||||
const allTriggers = () => [...triggers.values()]
|
||||
|
||||
/** One declaration, or null. The emit path's lookup and the rule editor's. */
|
||||
const eventTrigger = (id) => triggers.get(id) || null
|
||||
|
||||
/**
|
||||
* Who owns this id, across BOTH facets — the one-namespace question.
|
||||
*
|
||||
* A caller asking "may this module emit this?" wants this rather than
|
||||
* `eventTrigger(id).owner`, because an id can be held as a stream by one owner
|
||||
* and not yet declared as a trigger by anyone, and that id is still taken.
|
||||
*/
|
||||
const eventOwner = (id) => triggers.get(id)?.owner || streamOwners.get(id) || null
|
||||
|
||||
// ── Audiences (§5.1a) ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every declaration WITHOUT its resolver — what the admin surface serves.
|
||||
*
|
||||
* The resolver is stripped for the same reason a slash command's handler is:
|
||||
* this is the object that leaves the process, and `resolve` is a function over a
|
||||
* module's own store that no client has any business holding a reference to.
|
||||
*/
|
||||
const allAudiences = () => [...audiences.values()].map(({ resolve, ...rest }) => rest)
|
||||
|
||||
/** One declaration, resolver included. The engine's lookup. */
|
||||
const audience = (id) => audiences.get(id) || null
|
||||
|
||||
/**
|
||||
* Resolve a declared audience to user ids, never throwing.
|
||||
*
|
||||
* Three answers, and the middle one is the contract (§5.1a rule 4): a registered
|
||||
* audience answers `{ dormant: false, userIds }`; an audience whose module is
|
||||
* uninstalled answers `{ dormant: true, userIds: [] }` — the EMPTY set and a
|
||||
* flag, never an error and never a fallback to some other set of people; and a
|
||||
* resolver that throws or answers a non-array is logged and treated as empty,
|
||||
* because a module's storage problem must not become a send to the wrong people.
|
||||
*
|
||||
* `userIds` is filtered to positive integers here rather than trusted. It is the
|
||||
* one value a module hands core that decides who receives mail, and the resolver
|
||||
* is module code running over a module's own store.
|
||||
*/
|
||||
async function resolveAudience(id, params = {}) {
|
||||
const entry = audiences.get(id)
|
||||
if (!entry) return { dormant: true, userIds: [] }
|
||||
try {
|
||||
const raw = await entry.resolve(params)
|
||||
if (!Array.isArray(raw)) {
|
||||
log.warn('audience resolver did not return an array', { audience: id, owner: entry.owner })
|
||||
return { dormant: false, userIds: [] }
|
||||
}
|
||||
const userIds = [...new Set(raw.map(Number).filter((n) => Number.isInteger(n) && n > 0))]
|
||||
return { dormant: false, userIds }
|
||||
} catch (err) {
|
||||
log.error('audience resolver failed', { audience: id, owner: entry.owner, message: err.message })
|
||||
return { dormant: false, userIds: [] }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shape checks, run the moment a registrant calls ────────────────────────
|
||||
//
|
||||
// Split from the collision checks below on the same line PR 3 drew through
|
||||
@@ -251,7 +353,7 @@ const slashCommand = (name) => slashCommands.get(name) || null
|
||||
// depends on other registrants has to wait for the batch to be complete.
|
||||
|
||||
function checkStreamShape(entry) {
|
||||
if (!entry || !STREAM_ID.test(entry.id || '')) {
|
||||
if (!entry || !EVENT_ID.test(entry.id || '')) {
|
||||
throw new Error(`registerNotificationStreams: bad stream id "${entry && entry.id}"`)
|
||||
}
|
||||
if (!entry.label) throw new Error(`registerNotificationStreams: stream "${entry.id}" has no label`)
|
||||
@@ -448,6 +550,179 @@ function checkPostHookShape(entry) {
|
||||
return { onSaved, onDeleted }
|
||||
}
|
||||
|
||||
// ── Event trigger shape (ENGAGEMENT.md §4.3) ───────────────────────────────
|
||||
|
||||
// Deliberately small, and closed. A payload variable ends up interpolated into
|
||||
// an email, so the set is "things a template can render and a preview can fake",
|
||||
// not "things JSON can hold". No `object` and no `array`: a template that has to
|
||||
// walk a structure is a template that has outgrown interpolation, and a block
|
||||
// type is the right answer to that (§4.4).
|
||||
const VARIABLE_TYPES = ['string', 'int', 'float', 'boolean', 'datetime', 'url']
|
||||
|
||||
// `event` fires from ctx.events.emit; `scheduled` is evaluated periodically and
|
||||
// has no evaluator yet — the org lead's §7.1 Q6 answer is design now, build after
|
||||
// Phase 9. It is declarable from today so `kind` is in the contract, the manifest
|
||||
// and every stored declaration before there are rows to migrate.
|
||||
const TRIGGER_KINDS = ['event', 'scheduled']
|
||||
|
||||
const VARIABLE_NAME = /^[a-z][A-Za-z0-9]{0,39}$/
|
||||
|
||||
function checkTriggerVariable(triggerId, entry, seen) {
|
||||
const { name, type, required, example, description } = entry || {}
|
||||
const where = `registerEventTriggers: ${triggerId}`
|
||||
if (!VARIABLE_NAME.test(name || '')) throw new Error(`${where}: bad variable name "${name}"`)
|
||||
if (seen.has(name)) throw new Error(`${where}: variable "${name}" declared twice`)
|
||||
seen.add(name)
|
||||
if (!VARIABLE_TYPES.includes(type)) {
|
||||
throw new Error(`${where}: variable "${name}" has unsupported type "${type}"`)
|
||||
}
|
||||
// REQUIRED, and the one field of this shape that looks optional and is not
|
||||
// (§4.3 property 3). Without an example, previewing or test-sending a template
|
||||
// needs a live game event — which is exactly how template systems come to be
|
||||
// shipped untested. It is cheap to write at declaration time and impossible to
|
||||
// reconstruct later.
|
||||
if (example === undefined || example === null || example === '') {
|
||||
throw new Error(`${where}: variable "${name}" needs an example (§4.3 — it is the preview)`)
|
||||
}
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
required: Boolean(required),
|
||||
example,
|
||||
description: description || '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `registerEventTriggers([{ id, label, kind, subjectKey, audience, ceiling, version, variables }])`.
|
||||
*
|
||||
* Everything decidable from the argument alone is decided here, at the call, so
|
||||
* the error carries the registrant's own stack. The one-namespace collision — is
|
||||
* this id already someone's stream? — depends on other registrants and waits for
|
||||
* `apply()`, exactly as a stream's own collision does.
|
||||
*
|
||||
* The copy is explicit rather than a spread, like `checkTeamProviderShape`: this
|
||||
* object is served to the admin UI and frozen into a committed manifest, so
|
||||
* anything not named here is not part of the contract and must not ride along.
|
||||
*/
|
||||
function checkTriggerShape(entry) {
|
||||
const t = entry || {}
|
||||
if (!EVENT_ID.test(t.id || '')) {
|
||||
throw new Error(`registerEventTriggers: bad trigger id "${t.id}"`)
|
||||
}
|
||||
if (!t.label) throw new Error(`registerEventTriggers: trigger "${t.id}" has no label`)
|
||||
|
||||
const kind = t.kind || 'event'
|
||||
if (!TRIGGER_KINDS.includes(kind)) {
|
||||
throw new Error(`registerEventTriggers: ${t.id} has unknown kind "${t.kind}"`)
|
||||
}
|
||||
|
||||
// G24. Required with no default — a ceiling that could be forgotten is a
|
||||
// ceiling that gets forgotten on the one trigger it mattered for, and there is
|
||||
// no safe value to guess: `owner` would silently break a broadcast and
|
||||
// `authenticated` would silently widen a staff-only event.
|
||||
if (!ceilings.isCeiling(t.ceiling)) {
|
||||
throw new Error(
|
||||
`registerEventTriggers: ${t.id} needs a ceiling, one of ${ceilings.CEILINGS.join(', ')}`,
|
||||
)
|
||||
}
|
||||
// The DEFAULT a rule is created with; the ceiling is the maximum it may be
|
||||
// raised to. Defaulting it to the ceiling is right — a trigger that declares no
|
||||
// opinion gets the widest it permits, and an operator narrows from there.
|
||||
const audienceDefault = t.audience || t.ceiling
|
||||
if (!ceilings.permits(t.ceiling, audienceDefault)) {
|
||||
throw new Error(
|
||||
`registerEventTriggers: ${t.id} default audience "${audienceDefault}" is not permitted by ceiling "${t.ceiling}"`,
|
||||
)
|
||||
}
|
||||
|
||||
const version = t.version === undefined ? 1 : t.version
|
||||
if (!Number.isInteger(version) || version < 1) {
|
||||
throw new Error(`registerEventTriggers: ${t.id} has a bad version "${t.version}"`)
|
||||
}
|
||||
|
||||
if (t.variables !== undefined && !Array.isArray(t.variables)) {
|
||||
throw new Error(`registerEventTriggers: ${t.id} variables must be an array`)
|
||||
}
|
||||
const seen = new Set()
|
||||
const variables = (t.variables || []).map((v) => checkTriggerVariable(t.id, v, seen))
|
||||
|
||||
// A subjectKey naming a variable that does not exist would produce a cooldown
|
||||
// keyed on `undefined` — i.e. one cooldown for every subject at once, which
|
||||
// looks like the feature working until the day two houses share it (§4.1).
|
||||
if (t.subjectKey !== undefined && !seen.has(t.subjectKey)) {
|
||||
throw new Error(
|
||||
`registerEventTriggers: ${t.id} subjectKey "${t.subjectKey}" is not one of its variables`,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
id: t.id,
|
||||
label: t.label,
|
||||
description: t.description || '',
|
||||
kind,
|
||||
subjectKey: t.subjectKey === undefined ? null : t.subjectKey,
|
||||
audience: audienceDefault,
|
||||
ceiling: t.ceiling,
|
||||
version,
|
||||
variables,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Audience shape (§5.1a) ─────────────────────────────────────────────────
|
||||
|
||||
// Two types, and no more. A param is something an operator types into a rule
|
||||
// editor to point a declared audience at one row of a module's data ("which
|
||||
// Team?"), so it is an identifier or a word. Anything richer is a query, and a
|
||||
// query surface is the free-form list building Q7 rules out.
|
||||
const AUDIENCE_PARAM_TYPES = ['int', 'string']
|
||||
|
||||
function checkAudienceParam(audienceId, entry, seen) {
|
||||
const { id, type, required, label } = entry || {}
|
||||
const where = `registerAudiences: ${audienceId}`
|
||||
if (!VARIABLE_NAME.test(id || '')) throw new Error(`${where}: bad param id "${id}"`)
|
||||
if (seen.has(id)) throw new Error(`${where}: param "${id}" declared twice`)
|
||||
seen.add(id)
|
||||
if (!AUDIENCE_PARAM_TYPES.includes(type)) {
|
||||
throw new Error(`${where}: param "${id}" has unsupported type "${type}"`)
|
||||
}
|
||||
return { id, type, required: Boolean(required), label: label || id }
|
||||
}
|
||||
|
||||
/**
|
||||
* `registerAudiences([{ id, label, description, params, ceiling, resolve }])`.
|
||||
*
|
||||
* The resolver returns USER IDS and nothing else (§5.1a rule 2). It is not handed
|
||||
* a template, a channel or an address and it cannot enumerate them — a module
|
||||
* still cannot send mail, and this must not become the back door that lets it.
|
||||
* Core maps ids to addresses on its own side, after preferences, suppression and
|
||||
* the verification gate.
|
||||
*/
|
||||
function checkAudienceShape(entry) {
|
||||
const a = entry || {}
|
||||
if (!AUDIENCE_ID.test(a.id || '')) throw new Error(`registerAudiences: bad audience id "${a.id}"`)
|
||||
if (!a.label) throw new Error(`registerAudiences: audience "${a.id}" has no label`)
|
||||
if (!ceilings.isCeiling(a.ceiling)) {
|
||||
throw new Error(
|
||||
`registerAudiences: ${a.id} needs a ceiling, one of ${ceilings.CEILINGS.join(', ')}`,
|
||||
)
|
||||
}
|
||||
if (typeof a.resolve !== 'function') throw new Error(`registerAudiences: ${a.id} has no resolve()`)
|
||||
if (a.params !== undefined && !Array.isArray(a.params)) {
|
||||
throw new Error(`registerAudiences: ${a.id} params must be an array`)
|
||||
}
|
||||
const seen = new Set()
|
||||
const params = (a.params || []).map((p) => checkAudienceParam(a.id, p, seen))
|
||||
return {
|
||||
id: a.id,
|
||||
label: a.label,
|
||||
description: a.description || '',
|
||||
params,
|
||||
ceiling: a.ceiling,
|
||||
resolve: a.resolve,
|
||||
}
|
||||
}
|
||||
|
||||
// `specFile` is CORE-ONLY and is not on the module-facing signature. A slot's
|
||||
// router reaches the app through declareSlot(), which no static parse of app.js
|
||||
// can follow, so swagger-autogen would silently drop every route in it — the
|
||||
@@ -473,7 +748,15 @@ function checkExtensionShape(slot, router, specFile) {
|
||||
*/
|
||||
function stage(owner) {
|
||||
const staged = {
|
||||
owner, streams: [], legs: [], extensions: [], postHooks: [], teamProviders: [], slashCommands: [],
|
||||
owner,
|
||||
streams: [],
|
||||
legs: [],
|
||||
extensions: [],
|
||||
postHooks: [],
|
||||
teamProviders: [],
|
||||
slashCommands: [],
|
||||
triggers: [],
|
||||
audiences: [],
|
||||
}
|
||||
return {
|
||||
staged,
|
||||
@@ -497,6 +780,14 @@ function stage(owner) {
|
||||
if (!Array.isArray(entries)) throw new Error('registerSlashCommands: expected an array')
|
||||
for (const e of entries) staged.slashCommands.push(checkSlashCommandShape(e))
|
||||
},
|
||||
registerEventTriggers(entries) {
|
||||
if (!Array.isArray(entries)) throw new Error('registerEventTriggers: expected an array')
|
||||
for (const e of entries) staged.triggers.push(checkTriggerShape(e))
|
||||
},
|
||||
registerAudiences(entries) {
|
||||
if (!Array.isArray(entries)) throw new Error('registerAudiences: expected an array')
|
||||
for (const e of entries) staged.audiences.push(checkAudienceShape(e))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,6 +808,8 @@ function apply({
|
||||
postHooks: newPostHooks = [],
|
||||
teamProviders: newTeamProviders = [],
|
||||
slashCommands: newSlashCommands = [],
|
||||
triggers: newTriggers = [],
|
||||
audiences: newAudiences = [],
|
||||
}) {
|
||||
// ── validate ──
|
||||
const seenStreams = new Set()
|
||||
@@ -524,12 +817,54 @@ function apply({
|
||||
const held = streamOwners.get(s.id)
|
||||
if (held) throw new Error(`stream "${s.id}" is already registered by "${held}"`)
|
||||
if (seenStreams.has(s.id)) throw new Error(`stream "${s.id}" registered twice`)
|
||||
// The cross-facet half of the one-namespace rule (§7.2). A stream may share
|
||||
// its id with a TRIGGER — that is the whole point, `news.post` is one event
|
||||
// with two facets — but only when the same registrant owns both. Someone
|
||||
// else's trigger id is taken.
|
||||
const heldAsTrigger = triggers.get(s.id)
|
||||
if (heldAsTrigger && heldAsTrigger.owner !== owner) {
|
||||
throw new Error(`stream "${s.id}" is already registered as an event trigger by "${heldAsTrigger.owner}"`)
|
||||
}
|
||||
if (!namespaced(owner, s.id, LEGACY_STREAM_IDS)) {
|
||||
throw new Error(`stream "${s.id}" is not namespaced "${owner}."`)
|
||||
}
|
||||
seenStreams.add(s.id)
|
||||
}
|
||||
|
||||
// Triggers, against the SAME namespace and the SAME legacy allowlist as
|
||||
// streams above. Sharing LEGACY_STREAM_IDS is not laziness: under one
|
||||
// namespace `idoc.warning` is one id, so if `uo` may hold it as a stream
|
||||
// without the prefix it may hold it as a trigger without the prefix, and any
|
||||
// other answer would mean the seven grandfathered ids could never gain a
|
||||
// payload contract.
|
||||
const seenTriggers = new Set()
|
||||
for (const t of newTriggers) {
|
||||
const held = triggers.get(t.id)
|
||||
if (held) throw new Error(`event trigger "${t.id}" is already registered by "${held.owner}"`)
|
||||
if (seenTriggers.has(t.id)) throw new Error(`event trigger "${t.id}" registered twice`)
|
||||
const heldAsStream = streamOwners.get(t.id)
|
||||
if (heldAsStream && heldAsStream !== owner) {
|
||||
throw new Error(`event trigger "${t.id}" is already registered as a notification stream by "${heldAsStream}"`)
|
||||
}
|
||||
if (!namespaced(owner, t.id, LEGACY_STREAM_IDS)) {
|
||||
throw new Error(`event trigger "${t.id}" is not namespaced "${owner}."`)
|
||||
}
|
||||
seenTriggers.add(t.id)
|
||||
}
|
||||
|
||||
const seenAudiences = new Set()
|
||||
for (const a of newAudiences) {
|
||||
const held = audiences.get(a.id)
|
||||
if (held) throw new Error(`audience "${a.id}" is already registered by "${held.owner}"`)
|
||||
if (seenAudiences.has(a.id)) throw new Error(`audience "${a.id}" registered twice`)
|
||||
// No legacy allowlist — nothing predates audiences, so the prefix rule has no
|
||||
// exceptions and should never grow one.
|
||||
if (!namespaced(owner, a.id, {})) {
|
||||
throw new Error(`audience "${a.id}" is not namespaced "${owner}."`)
|
||||
}
|
||||
seenAudiences.add(a.id)
|
||||
}
|
||||
|
||||
const seenLegs = new Set()
|
||||
for (const l of newLegs) {
|
||||
const held = legs.get(l.leg)
|
||||
@@ -584,6 +919,8 @@ function apply({
|
||||
for (const h of newPostHooks) postHooks.set(owner, h)
|
||||
for (const p of newTeamProviders) teamProvider = { owner, ...p }
|
||||
for (const c of newSlashCommands) slashCommands.set(c.name, { owner, ...c })
|
||||
for (const t of newTriggers) triggers.set(t.id, { owner, ...t })
|
||||
for (const a of newAudiences) audiences.set(a.id, { owner, ...a })
|
||||
}
|
||||
|
||||
// ── Core's own registrations ───────────────────────────────────────────────
|
||||
@@ -604,12 +941,17 @@ function registerCore() {
|
||||
|
||||
/* eslint-disable global-require */
|
||||
const coreStreams = require('../config/coreStreams')
|
||||
const coreTriggers = require('../config/coreTriggers')
|
||||
const discordLeg = require('../utils/discordAnnounce')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
const api = stage('core')
|
||||
api.registerNotificationStreams(coreStreams.STREAMS)
|
||||
api.registerAnnounceLeg(discordLeg.leg)
|
||||
// The engagement contract (ENGAGEMENT.md Phase 2). Core's five trigger ids ARE
|
||||
// its five stream ids — the same-owner upgrade the one-namespace rule above is
|
||||
// written for — so this batch exercises the cross-facet check on every boot.
|
||||
api.registerEventTriggers(coreTriggers.TRIGGERS)
|
||||
|
||||
// The three lines that used to follow — the shard stream catalog, the town
|
||||
// crier leg and the `admin.users.detail` filling — were shard CONTENT held
|
||||
@@ -622,6 +964,7 @@ function registerCore() {
|
||||
|
||||
log.info('core registrations complete', {
|
||||
streams: streams.length,
|
||||
eventTriggers: triggers.size,
|
||||
announceLegs: legs.size,
|
||||
extensions: [...slots.keys()].filter(slotFilledBy),
|
||||
})
|
||||
@@ -651,6 +994,8 @@ function _reset() {
|
||||
postHooks.clear()
|
||||
teamProvider = null
|
||||
slashCommands.clear()
|
||||
triggers.clear()
|
||||
audiences.clear()
|
||||
coreRegistered = false
|
||||
}
|
||||
|
||||
@@ -672,6 +1017,14 @@ module.exports = {
|
||||
hasTeamProvider,
|
||||
slashCommandDefinitions,
|
||||
slashCommand,
|
||||
allTriggers,
|
||||
eventTrigger,
|
||||
eventOwner,
|
||||
allAudiences,
|
||||
audience,
|
||||
resolveAudience,
|
||||
VARIABLE_TYPES,
|
||||
TRIGGER_KINDS,
|
||||
stage,
|
||||
apply,
|
||||
registerCore,
|
||||
|
||||
@@ -9,6 +9,26 @@
|
||||
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
|
||||
// has nothing to say about a website module) and from any module's own version.
|
||||
|
||||
// 1.7.0 — the engagement contract (docs/website/ENGAGEMENT.md Phase 2).
|
||||
// Additions only, so minor: `api.registerEventTriggers([...])`,
|
||||
// `api.registerAudiences([...])`, `ctx.events.emit(triggerId, envelope)` and
|
||||
// `ctx.inbox.push(userId, item)`. module-uo's `coreApi: "^1.3.0"` still resolves.
|
||||
//
|
||||
// **As in 1.6.0, the number covers the whole surface and the members arrive by
|
||||
// phase.** `ctx.inbox.push` is present and THROWS until Phase 7 builds the
|
||||
// in-app channel and the table behind it — the same choice, for the same reason:
|
||||
// a member of 1.7.0 that were absent would make the version a lie, and one that
|
||||
// silently accepted data into a table that does not exist would be worse than
|
||||
// either. Everything else in 1.7.0 is live.
|
||||
//
|
||||
// One thing here is not a member and is still part of the contract: a trigger id
|
||||
// and a notification-stream id share ONE namespace (ENGAGEMENT.md §7.2, settled
|
||||
// by the org lead in Phase 2). An id has exactly one owner across both facets,
|
||||
// so a module cannot attach a payload contract to another module's stream. That
|
||||
// tightens a rule rather than changing a signature, and nothing registrable
|
||||
// before this bump becomes unregistrable after it — the id grammar was RELAXED
|
||||
// in the same change (`_` is now legal inside a segment).
|
||||
//
|
||||
// 1.6.0 — the Team surface (docs/website/TEAMS.md Part 11). Additions only, so
|
||||
// minor: `api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })`,
|
||||
// `ctx.teams.publish(event)`, `ctx.teams.reconcile({ reason })`,
|
||||
@@ -58,6 +78,6 @@
|
||||
// an admin action a module performs belongs in core's one audit log, the
|
||||
// extension slot needs the user its prefix names, and §2.7 forbids a module
|
||||
// reading core's `APP_BASE_URL` for itself. Additions only, so minor.
|
||||
const MODULE_API_VERSION = '1.6.0'
|
||||
const MODULE_API_VERSION = '1.7.0'
|
||||
|
||||
module.exports = { MODULE_API_VERSION }
|
||||
|
||||
54
server/src/router/v1/admin/engagement.controller.js
Normal file
54
server/src/router/v1/admin/engagement.controller.js
Normal 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() })
|
||||
}
|
||||
47
server/src/router/v1/admin/engagement.router.js
Normal file
47
server/src/router/v1/admin/engagement.router.js
Normal 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
|
||||
@@ -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,
|
||||
|
||||
223
server/src/utils/engagementEmit.js
Normal file
223
server/src/utils/engagementEmit.js
Normal 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 }
|
||||
@@ -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": [
|
||||
|
||||
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