From 563199a096af6e200f8c44f3204a0cfab49add73 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 29 Aug 2026 06:40:28 -0500 Subject: [PATCH] feat(modules): event triggers, audiences and the ceiling lattice (engagement Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract half of the engagement system: a module (and core) can DECLARE an event with a payload contract and fire it. Nothing delivers yet — `emit` validates, logs and stops, and Phase 4 replaces that log line with the engine. `api.registerEventTriggers` and `api.registerAudiences` ride the existing stage()/apply() validate-then-commit discipline, so a registrant that throws halfway leaves nothing behind. `ctx.events.emit` is fire-and-forget and binds the owner from the calling module — a module fires its own triggers and no one else's. `ctx.inbox.push` is present and throws until Phase 7, the shape 1.6.0 settled on for a member that arrives a phase late. MODULE_API_VERSION 1.7.0 on both halves. Additions only; module-uo's `coreApi: "^1.3.0"` still resolves. Three design decisions, approved by the org lead before any code: ONE NAMESPACE for trigger ids and notification-stream ids (ENGAGEMENT.md §7.2, against the recommendation in the text). A trigger is a payload contract attached to an id that may also carry a subscription toggle, so an id has exactly one owner across both facets, checked in both directions. Core's five trigger ids ARE its five stream ids, so the same-owner upgrade case is exercised on every boot rather than only by a module. It keeps notification_channel_prefs single-keyed in Phase 3, where two namespaces would have forced a `kind` discriminator into its primary key. Two knock-on effects appeared only once it was implemented. The id grammar had to be RELAXED to admit `_` inside a segment — §4.3's own worked example is `uo.house.idoc_warning`, and two grammars over one namespace would mean an id legal as a trigger and illegal as the stream it is the same event as. And the seven grandfathered `uo.*` ids had to share their legacy allowlist with triggers, because under one namespace `idoc.warning` is a single id. The push catalog is untouched either way: allStreams() still serves the stream facet only, so the shipped Android client sees exactly what it saw before. THE CEILING LATTICE (G24), which the plan named everywhere and defined nowhere. It is containment, not size: everyone ⊃ authenticated ⊃ {subscribers, members, staff, owner}, with the four leaves mutually incomparable. The flat total order the plan's wording invites would let a `staff`-ceilinged trigger be given an `owner` audience — a rule that mails cheat detection to the player it detected. Fewer people is not less exposure. Two incomparable ceilings have no meet at all, so a composition is refused rather than guessed; union-widens is the intuitive implementation and it is the wrong one. `kind: 'event' | 'scheduled'` is declarable now and no evaluator exists (§7.1 Q6). Registration accepts `scheduled` and emit refuses to fire one, so `kind` means something from the moment it can be written rather than from the moment it is honoured. Also: `GET /admin/engagement/{triggers,audiences}`, served from the registries rather than a table so an uninstalled module simply stops appearing; `npm run engagement:manifest` plus its CI `--check`, the twin of the route manifest, because renaming a variable breaks stored templates silently, at send time, in mail someone already received. Co-Authored-By: Claude --- .gitea/workflows/pr-checks.yml | 9 + client/src/modules/version.js | 10 +- server/engagement-triggers.json | 211 +++++++++ server/package.json | 1 + server/routes.guards.json | 18 + server/routes.manifest.json | 8 + server/scripts/engagementManifest.js | 132 ++++++ server/src/config/coreTriggers.js | 147 ++++++ server/src/modules/ceilings.js | 107 +++++ server/src/modules/loader.js | 53 +++ server/src/modules/registries.js | 359 ++++++++++++++- server/src/modules/version.js | 22 +- .../router/v1/admin/engagement.controller.js | 54 +++ .../src/router/v1/admin/engagement.router.js | 47 ++ server/src/router/v1/admin/index.js | 8 + server/src/utils/engagementEmit.js | 223 +++++++++ server/swagger/swagger-output.json | 122 +++++ server/test/engagementCeilings.test.js | 86 ++++ server/test/engagementManifest.test.js | 77 ++++ server/test/engagementTriggers.test.js | 435 ++++++++++++++++++ server/test/moduleLoader.test.js | 9 +- 21 files changed, 2132 insertions(+), 6 deletions(-) create mode 100644 server/engagement-triggers.json create mode 100644 server/scripts/engagementManifest.js create mode 100644 server/src/config/coreTriggers.js create mode 100644 server/src/modules/ceilings.js create mode 100644 server/src/router/v1/admin/engagement.controller.js create mode 100644 server/src/router/v1/admin/engagement.router.js create mode 100644 server/src/utils/engagementEmit.js create mode 100644 server/test/engagementCeilings.test.js create mode 100644 server/test/engagementManifest.test.js create mode 100644 server/test/engagementTriggers.test.js diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml index 4188ea9..06251e5 100644 --- a/.gitea/workflows/pr-checks.yml +++ b/.gitea/workflows/pr-checks.yml @@ -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: diff --git a/client/src/modules/version.js b/client/src/modules/version.js index c0c833a..44d4942 100644 --- a/client/src/modules/version.js +++ b/client/src/modules/version.js @@ -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' diff --git a/server/engagement-triggers.json b/server/engagement-triggers.json new file mode 100644 index 0000000..80bf010 --- /dev/null +++ b/server/engagement-triggers.json @@ -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." + } + ] + } + ] +} diff --git a/server/package.json b/server/package.json index 2380ed4..a9e5966 100644 --- a/server/package.json +++ b/server/package.json @@ -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": [ diff --git a/server/routes.guards.json b/server/routes.guards.json index b931ae3..e89f215 100644 --- a/server/routes.guards.json +++ b/server/routes.guards.json @@ -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", diff --git a/server/routes.manifest.json b/server/routes.manifest.json index ea1427e..0ca2665 100644 --- a/server/routes.manifest.json +++ b/server/routes.manifest.json @@ -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" diff --git a/server/scripts/engagementManifest.js b/server/scripts/engagementManifest.js new file mode 100644 index 0000000..dd28be5 --- /dev/null +++ b/server/scripts/engagementManifest.js @@ -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 } diff --git a/server/src/config/coreTriggers.js b/server/src/config/coreTriggers.js new file mode 100644 index 0000000..8fa2db1 --- /dev/null +++ b/server/src/config/coreTriggers.js @@ -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 } diff --git a/server/src/modules/ceilings.js b/server/src/modules/ceilings.js new file mode 100644 index 0000000..5367551 --- /dev/null +++ b/server/src/modules/ceilings.js @@ -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 } diff --git a/server/src/modules/loader.js b/server/src/modules/loader.js index 1b6c598..a176f16 100644 --- a/server/src/modules/loader.js +++ b/server/src/modules/loader.js @@ -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 diff --git a/server/src/modules/registries.js b/server/src/modules/registries.js index 908b580..b35fc0b 100644 --- a/server/src/modules/registries.js +++ b/server/src/modules/registries.js @@ -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, diff --git a/server/src/modules/version.js b/server/src/modules/version.js index 4ff8c60..8dd05bf 100644 --- a/server/src/modules/version.js +++ b/server/src/modules/version.js @@ -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 } diff --git a/server/src/router/v1/admin/engagement.controller.js b/server/src/router/v1/admin/engagement.controller.js new file mode 100644 index 0000000..bd666f5 --- /dev/null +++ b/server/src/router/v1/admin/engagement.controller.js @@ -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() }) +} diff --git a/server/src/router/v1/admin/engagement.router.js b/server/src/router/v1/admin/engagement.router.js new file mode 100644 index 0000000..c596a25 --- /dev/null +++ b/server/src/router/v1/admin/engagement.router.js @@ -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 diff --git a/server/src/router/v1/admin/index.js b/server/src/router/v1/admin/index.js index 56d0614..f4bf0e2 100644 --- a/server/src/router/v1/admin/index.js +++ b/server/src/router/v1/admin/index.js @@ -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, diff --git a/server/src/utils/engagementEmit.js b/server/src/utils/engagementEmit.js new file mode 100644 index 0000000..c756738 --- /dev/null +++ b/server/src/utils/engagementEmit.js @@ -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 } diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 4198015..4d89318 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -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": [ diff --git a/server/test/engagementCeilings.test.js b/server/test/engagementCeilings.test.js new file mode 100644 index 0000000..a0a58b4 --- /dev/null +++ b/server/test/engagementCeilings.test.js @@ -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) +}) diff --git a/server/test/engagementManifest.test.js b/server/test/engagementManifest.test.js new file mode 100644 index 0000000..9743971 --- /dev/null +++ b/server/test/engagementManifest.test.js @@ -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/) +}) diff --git a/server/test/engagementTriggers.test.js b/server/test/engagementTriggers.test.js new file mode 100644 index 0000000..63c59c1 --- /dev/null +++ b/server/test/engagementTriggers.test.js @@ -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') +}) diff --git a/server/test/moduleLoader.test.js b/server/test/moduleLoader.test.js index 1ad1f78..90b5b7b 100644 --- a/server/test/moduleLoader.test.js +++ b/server/test/moduleLoader.test.js @@ -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 -- 2.49.1