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