diff --git a/book/02-website-module.md b/book/02-website-module.md index 6c93f79..f73db4d 100644 --- a/book/02-website-module.md +++ b/book/02-website-module.md @@ -147,8 +147,9 @@ statement of your dependencies, and it makes a test double for it — see ## What you register -Eight calls, all synchronous, all documented in [§2.4][api]. What is worth knowing -is not their signatures but the model behind them. +Twelve calls — ten registrations and the two lifecycle hooks — all synchronous, +all listed in [§2.4][api]. What is worth knowing is not their signatures but the +model behind them. **Every call stages; nothing is committed until your whole module is known good.** The shape of a claim is checked at the call, so a malformed one throws with your @@ -279,6 +280,119 @@ Every hook is awaited and none may throw past core: a subscriber's failure costs neither another subscriber nor the save itself. A hiccup in your sidecar breaking somebody's blog post edit would be a worse bug than a stale mirror. +### Telling core something happened + +Three registrations and one call, and together they are the seam where a module +is most tempted to reach past the boundary. The rule that keeps them safe is one +sentence: **you declare what CAN happen; core decides who is told.** + +A **trigger** is not a notification stream, and the two are easy to confuse +because both are catalogs of things that happen in your game. A stream is a +subscribe toggle, and you publish to it yourself. A trigger is a **payload +contract**: it names the variables an event carries and how wide an audience it +may ever be given, an operator writes rules against it, and *core* does the +sending. Their ids share one namespace, so declaring both for the same id is +legal — that is one event with a toggle and a contract — while taking an id +another module owns is not. + +Two fields on a trigger are worth more than their size. + +**`ceiling` is required and has no default, and the values are ordered by +containment rather than by size.** It is the widest audience a rule on this +trigger may ever be given. There is no safe value to guess: `owner` silently +breaks a broadcast, `authenticated` silently widens something meant for staff. +And the ladder reading of the seven values is the trap — a `staff` ceiling does +**not** permit `owner`, because "one person" for a cheat-detection event is *the +player it was detected on*. Fewer people is not less exposure. + +**`subjectKey` must name one of your declared variables**, because it is what the +cooldown is keyed on — "once per house", not "once per user". Core checks it at +registration and refuses the module, so this is one you meet at your first boot +rather than in production. The check is there because the failure it prevents is +the silent kind: a subjectKey naming nothing keys every subject on `undefined`, +which looks exactly like the feature working right up until two houses share it. + +Every variable needs an `example`, and it is not decoration: it is what lets an +operator preview and test-send a body without waiting for a real game event, +which is the reason template systems ship untested. The type set is closed and +has no `object` or `array` — a message that has to walk a structure has outgrown +interpolation. + +An **audience** is a named set of *people* you can resolve over your own data, +for an operator to point a rule at. "This clan's members" is one. "Everyone who +opened the last mail" is not, and nothing here builds it. + +**Your resolver returns user ids and nothing else.** It is not handed a template, +a channel or an address, and it cannot enumerate them; core maps ids to addresses +on its own side, after preferences, suppression and the verification gate. That +narrowness is deliberate — a module still cannot send mail, and this is the +obvious place a back door would go. Two consequences follow from it: + +- **A resolver that fails resolves to NOBODY**, never to everybody and never to + its last good answer. Core enforces that, and your resolver should choose it + too, so the log can say which clan. +- **Its params are CONSTANT.** An operator fills them in when they save the rule. + There is no way to say "the clan this event was about" — if a rule needs that, + the *event* carries its own recipients instead. This is the constraint most + worth knowing before you design around it rather than after. + +The third registration ships the **content**: the bodies your messages use and +the rules that decide when one is sent. Both arrive **switched off**, and +`enabled` is not a parameter. An operator turns a module's mail on; installing a +module never does. + +The two halves have different lifetimes, and the asymmetry is the contract. +**Templates re-ensure on every boot** under a seed version, so a better default +reaches deployments that never edited it while one an operator *has* edited is +left alone. **Rule groups are offered once, per named group key**, because +re-offering would resurrect a rule somebody deleted and reset one they enabled. +The consequence is easy to trip over: a rule appended to an existing group +reaches **fresh installs only**. That is the guarantee rather than a limitation +to route around, and a rule that must reach existing deployments takes a new +group key. You name the groups, so the choice is yours to make knowingly. + +Core's generic bodies are a first-class answer rather than a fallback. Point a +channel at `notify.event` or `inapp.event` and author nothing; ship a body of your +own when the message has something to say that a structural projection of the +payload cannot. + +**One thing in a seeded body is not checked when you register it.** The call +asserts that `blocks` is a non-empty array and stops there; the body itself is +validated by the block registry, which runs in the editor and in the renderer. So +a malformed block registers cleanly, seeds cleanly, and first shows itself when an +operator opens the body or a rule fires. Build one, open it in Admin → Engagement → +Templates once, and you have checked the half that boot cannot. + +Finally the call. `ctx.events.emit(triggerId, envelope)` fires one of your own +triggers — core binds the owner from the calling module and never reads it from +the arguments, so there is no shape of this call that fires somebody else's +event. It returns nothing and, in production, never throws: there is nothing a +module could correctly do about a delivery failure from inside a game-event +handler, so there is nothing to await. **Outside production it does throw**, at +your call site — a payload that does not match the contract you declared is a bug +rather than a condition, and the throw is how you meet it in your own tests +instead of in an operator's log six weeks later. + +Beside it is the one call that skips the rules entirely. +`ctx.inbox.push(userId, item)` writes a single item into a single person's on-site +inbox. Reach for it when there is nothing for an operator to decide — a job that +person started has finished — and for anything else use a trigger, so the message +can be turned off, re-targeted, or sent by mail as well without a code change. The +posture is `emit`'s: the owner is bound from the calling module, it returns +nothing, and it will not tell you that the user has that channel switched off, +because a module that could see that could enumerate people's preferences one +write at a time. + +**Emit on the transition, not on the poll.** The template's `refresh()` runs every +thirty seconds and emits only when the world's online state actually changed. +Core's cooldown and hourly cap would both hold if it did not — but leaning on +them means emitting "the world is still up" and calling it news, and the operator +who tightens the cooldown to stop it has hidden your bug rather than fixed it. +Two smaller traps sit inside the same function and are worth reading in +`template/server/boot.js`: the previous state has to be read *before* the write, +or every poll looks like no change at all, and the very first boot has no previous +state, which is not a change either. + ### Becoming the source of Teams `api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })` — and diff --git a/ci/core-ref.json b/ci/core-ref.json index 29bd80b..a05c792 100644 --- a/ci/core-ref.json +++ b/ci/core-ref.json @@ -1,34 +1,47 @@ { "repo": "https://gitea.whitlocktech.com/RunicGateway/website.git", "branch": "main", - "ref": "963d734dcc09580a7d8bb676370b4faf9b8727b2", + "ref": "66bb3b9a3fad01112c06f32d931c9bae56d22de6", "why": [ "The core this kit is written against, pinned to a commit rather than a branch.", - "This one is the Teams cutover, the commit MODULE_API_VERSION 1.6.0 reached", - "`main` on, and 1.6.0 is what template/module.json declares. It moved here from", - "the 1.5.0 bump because Teams expanded the contract the book teaches: the", - "template now registers a Team provider and declares slots for core to fill,", - "and both are members that exist only at this ref and later.", + "This one is the engagement cutover, the commit MODULE_API_VERSION 1.9.0 reached", + "`main` on, and 1.9.0 is what template/module.json declares. It moved here from", + "1.6.0 (the Teams cutover) because engagement expanded the contract the book", + "teaches by three registrations and two calls: a module now declares what its", + "game can announce and never who is told.", "", "Moving this pin is the moment someone re-reads the chapters: CI asserts the", "version template/module.json declares still equals this core's", "MODULE_API_VERSION, so a contract bump turns this repo red on purpose", - "(MODULE_SYSTEM.md 2.11.1 d2, 2.10).", + "(MODULE_SYSTEM.md 2.11.1 d2, 2.10). Note what that means in the other", + "direction, because it is easy to misread as a safety net: the check clones", + "THIS ref, so a core that has moved past it does not turn the repo red on its", + "own. Nothing goes red until someone moves the pin. Between cutovers the kit is", + "not wrong, it is DATED - and this file is where the date is written down.", "", - "That mechanism earned its keep this time. Writing the chapters against 1.6.0", - "found that core's inverted-slot fills named three of module-uo's slots", - "literally, so the direction worked for that one module and silently did", - "nothing for any other game - an empty page with nothing logged. That is the", - "exact class of thing a book written for an audience outside this org is meant", - "to catch, and it was fixed in core before this pin moved.", + "The mechanism earned its keep again here. Writing chapter 2's engagement", + "section against 1.9.0 found that the seeded body a module ships is the one", + "thing registerEngagementSeeds does not validate - it checks that `blocks` is a", + "non-empty array and stops - so the template's own example body had a heading", + "level of 2 where the block registry takes 'h2', and no block ids at all. It", + "would have registered, seeded, and failed the first time an operator opened it.", + "Caught by running the template's register() through core's real registry at", + "this ref, which is what a re-read is for; both the fix and the gap are now in", + "the chapter and beside the code.", + "", + "That gap is also why this file's own instruction is not enough on its own. The", + "template job builds and tests the template against fakes and checks this", + "number; it does not load the module into core. A declaration a fake accepts", + "and core refuses would ship green, so a pin move is a run against a real core,", + "not just an edit here.", "", "The branch said `edge` until 2026-08-12, when the module system cut over and", - "that branch was deleted (MODULE_SYSTEM.md 2.9). Teams cut a second `edge` and", - "this pin skipped it entirely: the kit is written against what shipped, never", - "against what is in flight. Nothing in CI reads the branch field - it clones", - "the repo and checks out the sha - which is why a wrong label here would sit", - "unnoticed. It is for the person deciding whether a newer core is worth", - "re-reading the book for.", + "that branch was deleted (MODULE_SYSTEM.md 2.9). Two later workstreams cut an", + "`edge` of their own and this pin skipped both: the kit is written against what", + "shipped, never against what is in flight. Nothing in CI reads the branch field", + "- it clones the repo and checks out the sha - which is why a wrong label here", + "would sit unnoticed. It is for the person deciding whether a newer core is", + "worth re-reading the book for.", "", "Same convention as Module-uo's ci/core-ref.json, deliberately - one file, one", "sha, reviewable in a diff." diff --git a/scripts/checkLinks.js b/scripts/checkLinks.js index 63b6b06..b799b77 100644 --- a/scripts/checkLinks.js +++ b/scripts/checkLinks.js @@ -24,8 +24,14 @@ const { stripFences } = require('./lib/markdown') const ROOT = path.resolve(__dirname, '..') const QUIET = process.argv.includes('--quiet') -// Directories that hold no prose we own. -const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist']) +// Directories that hold no prose we own. `.core` and `core` are core's own +// checkout: .gitignore reserves both because moving `ci/core-ref.json` means +// cloning core in here first, and without this that clone hands the reader nine +// broken links in somebody else's README the moment they follow the pin-bump +// instructions. CI never saw it — the clone happens in the `template` job and +// this check runs in `prose` — which is exactly the kind of failure that only +// ever meets a person. +const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist', '.core', 'core']) /** Every markdown file in the repo, repo-relative, sorted. */ function markdownFiles(dir = ROOT, out = []) { diff --git a/template/README.md b/template/README.md index 4ef0c4c..8467f68 100644 --- a/template/README.md +++ b/template/README.md @@ -112,6 +112,7 @@ backticking table names**. | `.gitea/workflows/release.yml` | `GITEA_HOST` and `REPO`, under the `# CHANGE THESE` banner — the only two, and they are wrong until you do. (The `.github/` flavour needs nothing: GitHub supplies `GITHUB_REPOSITORY` and friends.) | | `server/package.json` | package `name` and `description` | | `server/core.js` | the message every accessor throws | +| `server/index.js` | the trigger, audience, template and rule-group ids — all four are namespaced with your module id, and core refuses them otherwise | | `server/boot.js` | the placeholder world name | | `server/db/schema.sql` | every table name — the prefix must be your id | | `server/db/purge.sql` | the same table names | @@ -123,6 +124,7 @@ backticking table names**. | `server/swagger/doc.js` | the tag, and the `Examplegame…` schema prefix | | `server/scripts/swaggerFragment.js` | the generated fragment's `info.title` | | `server/test/_fakes.js` | `ctx.moduleId` | +| `server/test/entry.test.js` | the trigger id the world-status test asserts | | `server/test/worldStatus.test.js` | the fixture's world name | | `server/test/clanProvider.test.js` | the fixture's world name | | `server/package-lock.json` | **regenerated** — `npm install --prefix server` | diff --git a/template/module.json b/template/module.json index 316772d..2db5f48 100644 --- a/template/module.json +++ b/template/module.json @@ -2,7 +2,7 @@ "id": "examplegame", "name": "Example Game", "version": "0.1.0", - "coreApi": "^1.6.0", + "coreApi": "^1.9.0", "server": "server/index.js", "client": { "entry": "client/dist/entry.js" }, "schema": "server/db/schema.sql", diff --git a/template/server/boot.js b/template/server/boot.js index 2c37ccf..f5ec8f1 100644 --- a/template/server/boot.js +++ b/template/server/boot.js @@ -52,7 +52,45 @@ async function refresh() { try { // A real module calls its sidecar's REST API here. Two hardcoded values // stand in, so that the page renders and the seam is visible. - await worldStatusDb.setStatus({ online: true, players: 0, worldName: 'Example World' }) + const next = { online: true, players: 0, worldName: 'Example World' } + + // ── Emitting a declared event ────────────────────────────────────────── + // + // **Emit on the TRANSITION, not on the poll.** This function runs every + // thirty seconds; a rule on an event fired every thirty seconds is a rule + // that mails somebody every thirty seconds. Core has a cooldown and an + // hourly cap and they would both hold, but leaning on them means the module + // is emitting "the world is still up" and calling it news. Read the previous + // state, compare, and emit only when the answer changed. + // + // The read is BEFORE the write for the same reason, and getting that + // backwards is the easy version of this bug: after `setStatus` the previous + // value is gone and every poll looks like no change at all — an emitter that + // never fires and never errors. + const previous = await worldStatusDb.getStatus() + await worldStatusDb.setStatus(next) + + // `previous === null` is the first boot on a fresh install, not a change. + // Treating it as one would announce the world coming online to everyone the + // first time an operator started the site. + if (previous && Boolean(previous.online) !== next.online) { + // Fire-and-forget: no await, no return value, nothing to handle. Core + // validates the payload against what `index.js` declared, and what a + // mismatch does depends on where you are running. **In production it is + // dropped and logged** against this module, because a notification must + // never be able to break the thing it is about. **Anywhere else it throws**, + // at this line, so the stack points at your own call instead of at a + // warning nobody reads. Neither is a condition to catch: a payload that + // does not match the contract you declared is a bug to fix. + core.emit('examplegame.world.status_changed', { + data: { + worldName: next.worldName, + status: next.online ? 'online' : 'offline', + players: next.players, + url: '/world', + }, + }) + } } catch (err) { log.warn('could not refresh world status', { error: err.message }) } diff --git a/template/server/core.js b/template/server/core.js index 765aad8..5a5dc46 100644 --- a/template/server/core.js +++ b/template/server/core.js @@ -91,6 +91,21 @@ module.exports = { // stack. Routers are built inside `register()`, so `ctx` is set by then. get middleware() { return need().middleware }, + // Firing a declared event (MODULE_API.md §2.3). Wrapped as a call rather than + // exposed as `get events()`, so that `require('../core').emit` taken at file + // scope still resolves `ctx` at call time like everything else here. + // + // **It returns nothing, and in production it never throws at the caller.** The + // emit is the end of this module's involvement: core validates the payload + // against the declared contract, decides which rules match, resolves who they + // reach and sends. A module cannot address a person, choose a channel or write + // a subject line, and this seam is deliberately too narrow to try (§2.7). + // + // Outside production a bad payload throws here rather than being logged, which + // is the point: you meet the mismatch in your own tests instead of in an + // operator's log six weeks later. + emit: (triggerId, envelope) => need().events.emit(triggerId, envelope), + // Deployment facts. `moduleRoot` is the absolute path to `modules//` — the // only correct way to find a file you shipped, because the working directory is // core's and the module's location is the loader's business. diff --git a/template/server/index.js b/template/server/index.js index 612c853..590992c 100644 --- a/template/server/index.js +++ b/template/server/index.js @@ -103,6 +103,165 @@ module.exports = function register(ctx, api) { // optional ones is an edit to the provider and not to this file. api.registerTeamProvider(clanProvider) + // ── Engagement: declaring what your game can announce ──────────────────── + // + // The three calls below are one seam, and it is the one where a module is most + // tempted to reach past the boundary. **You declare what CAN happen; core + // decides who is told.** A module never names a person, a channel or an + // address, and never sends anything (MODULE_API 1.7.0 and 1.9.0; §2.7). + // + // A TRIGGER is not a notification stream, and the two are easy to confuse + // because both are catalogs of things that happen. A stream is a subscribe + // toggle you publish to yourself. A trigger is a PAYLOAD CONTRACT an operator + // writes rules against — it says what variables the event carries and how wide + // an audience it may ever be given, and core does the sending. Their ids share + // one namespace, so declaring both for one id is legal and is one event with a + // toggle and a contract; taking an id another module owns is not. + api.registerEventTriggers([ + { + id: 'examplegame.world.status_changed', + label: 'World came up or went down', + description: 'The game server changed between online and offline.', + kind: 'event', + // The cooldown subject: "once per world", not "once per user". It must + // NAME one of the variables below — core refuses the registration + // otherwise, with this trigger's id in the message, and the module does not + // load. That check exists because the failure it prevents is silent: a + // subjectKey naming nothing keys every subject on `undefined`, which looks + // exactly like the feature working right up until two worlds share it. + subjectKey: 'worldName', + audience: 'authenticated', // what a rule is CREATED with + // ...and the widest it may EVER be given. Required, with no default, + // because there is no safe value to guess: `owner` would silently break a + // broadcast and `authenticated` would silently widen a staff-only event. + // The values are ordered by CONTAINMENT, not by size — see chapter 2. + ceiling: 'authenticated', + version: 1, + variables: [ + // Every variable needs an `example`, and it is not decoration: it is what + // lets an operator preview and test-send a template without waiting for a + // real game event, which is the reason template systems ship untested. + { name: 'worldName', type: 'string', required: true, example: 'Example World' }, + { name: 'status', type: 'string', required: true, example: 'online' }, + { name: 'players', type: 'int', required: false, example: 42 }, + // A `url` is validated SITE-RELATIVE, because it ends up in an href in a + // mail somebody opens days later. Never a full URL of your own. + { name: 'url', type: 'url', required: false, example: '/world' }, + ], + }, + ]) + + // An AUDIENCE is a named set of PEOPLE this module can resolve over its own + // data, for an operator to point a rule at. "This clan's members" is one; + // "everyone who opened the last mail" is not, and nothing here builds it. + // + // **The resolver returns user ids and nothing else.** It is not handed a + // template, a channel or an address and it cannot enumerate them — core maps + // ids to addresses on its own side, after preferences, suppression and the + // verification gate. That is what stops this becoming the back door §2.7 spends + // a section closing. + // + // Audiences are their OWN id space, unlike triggers and streams: an audience + // names a set of people and a trigger names an event, so the two may share a + // name without colliding. + api.registerAudiences([ + { + id: 'examplegame.clan.members', + label: 'Members of a clan', + // `int` or `string` only, and CONSTANT — an operator fills these in when + // they save the rule. There is no way to say "the clan this event was + // about"; if a rule needs that, the EVENT carries its own recipients + // instead. Finding that out late is a phase's worth of rework. + params: [{ id: 'clanId', type: 'string', required: true }], + ceiling: 'members', + resolve: async ({ clanId }) => clanProvider.listClanMemberUserIds({ clanId }), + }, + ]) + + // Finally the CONTENT: the bodies your messages use, and the rules that decide + // when one is sent. Both arrive **switched off** — `enabled` is not a parameter + // and there is no call that sets it. An operator turns a module's mail on; + // installing a module never does. + // + // The two halves have different lifetimes, and the asymmetry is the contract: + // + // • **Templates are re-ensured on every boot**, under `seedVersion`, so + // improving a default body reaches deployments that never edited it — and + // one an operator HAS edited is marked customized and left alone. Bump + // `seedVersion` when the body changes; never for a comment. + // • **Rule groups are offered ONCE, per named group key.** Re-offering would + // resurrect a rule an operator deleted and reset one they enabled. So a rule + // appended to an existing group reaches FRESH INSTALLS ONLY. That is the + // guarantee rather than a limitation to work around: a rule that has to + // reach existing deployments takes a NEW group key, and you choose that + // knowingly because you name the groups. + // + // Core's generic bodies are a first-class answer, not a fallback: point a + // channel at `notify.event` / `inapp.event` / `notify.digest` and author + // nothing. Ship a body of your own when the message has something to say that a + // structural projection of the payload cannot. Below, the mail does — a world + // coming back deserves a sentence — and the in-app item does not, so it uses + // core's. + // + // Note the two casings, which are not a slip: a TEMPLATE is an object this call + // shapes (`triggerId`), and a RULE is a row (`trigger_id`). Copy them as they + // are. + api.registerEngagementSeeds({ + templates: [ + { + // MUST be namespaced `.` — the key column is unique across the + // whole table, and an unprefixed `notify.event` from a module would + // collide with core's own body and win. + key: 'examplegame.world-status-changed', + name: 'World — status changed', + channel: 'email', + subject: '{{worldName}} is {{status}}', + triggerId: 'examplegame.world.status_changed', + triggerVersion: 1, + seedVersion: 1, + // The same block objects the template editor writes, so an operator can + // open this in the admin panel and keep editing from here. + // + // **This is the one thing in this file core does not check for you.** + // `registerEngagementSeeds` asserts that `blocks` is a non-empty array and + // stops; the BODY is validated by the block registry, which runs in the + // editor and in the renderer. So a malformed block registers, seeds, and + // first shows itself when an operator opens the body or a rule fires. + // Two that are easy to get wrong: every block carries its own `id`, and + // `email.heading`'s `level` is 'h1' | 'h2' | 'h3' — not a number. + blocks: [ + { id: 'h', type: 'email.heading', props: { level: 'h2', text: '{{worldName}} is {{status}}' } }, + { id: 'intro', type: 'email.text', props: { text: 'There are {{players}} players online right now.' } }, + { id: 'cta', type: 'email.button', props: { label: 'Open the world page', url: '{{url}}' } }, + ], + }, + ], + ruleGroups: [ + { + key: 'world-v1', + note: 'the world status rule, seeded once', + rules: [ + { + trigger_id: 'examplegame.world.status_changed', + name: 'World status changes', + audience: 'authenticated', + channels: ['email', 'inapp'], + template_keys: { + email: 'examplegame.world-status-changed', + inapp: 'inapp.event', + }, + // Two ceilings on volume, and they answer different questions. The + // cooldown is per SUBJECT — one mail per world per hour, however many + // times it flaps. The hourly cap is per RULE, and is the thing that + // keeps a misconfiguration from becoming a mail storm. + cooldown_seconds: 3600, + max_sends_per_hour: 200, + }, + ], + }, + ], + }) + // The lifecycle hooks (§2.5). `onBoot` runs after core's schema, after this // module's schema fragment, and BEFORE the HTTP listener binds — so a module // that must not serve traffic until it has warmed a cache gets that for free. diff --git a/template/server/model/clans/clanProvider.db.js b/template/server/model/clans/clanProvider.db.js index 0191bed..8320d6c 100644 --- a/template/server/model/clans/clanProvider.db.js +++ b/template/server/model/clans/clanProvider.db.js @@ -82,4 +82,25 @@ async function replaceClan({ externalId, name, abbr, memberCount, members }) { } } -module.exports = { listClans, findClan, listMembers, replaceClan, CLANS, MEMBERS } +/** + * The site accounts behind one clan's roster — the whole of an audience resolver. + * + * `user_id` is NULL for most characters, and the filter is the point: an audience + * resolves to PEOPLE WITH ACCOUNTS, and a character nobody has linked is not one. + * Returning its NULL would hand core a hole in an array it is about to mail. + * + * DISTINCT because one person may hold several characters in the same clan, and + * the resolver's contract is a set of users rather than a list of characters. + * Without it a three-character player is told three times. + */ +async function listMemberUserIds(clanId) { + const rows = await core.query( + `SELECT DISTINCT user_id AS userId + FROM ${MEMBERS} + WHERE clan_id = ? AND user_id IS NOT NULL`, + [clanId], + ) + return rows.map((r) => r.userId) +} + +module.exports = { listClans, findClan, listMembers, listMemberUserIds, replaceClan, CLANS, MEMBERS } diff --git a/template/server/model/clans/clanProvider.model.js b/template/server/model/clans/clanProvider.model.js index 633d684..aa3d34f 100644 --- a/template/server/model/clans/clanProvider.model.js +++ b/template/server/model/clans/clanProvider.model.js @@ -292,6 +292,36 @@ async function projectRoster(externalId, members, viewer) { // a page that does not exist, which is worse. const pageUrlTemplate = '/examplegame/clans/{externalId}' +// ── The audience resolver ───────────────────────────────────────────────── +// +// Registered in `index.js` as `examplegame.clan.members` and called by core when +// a rule pointed at that audience fires. It lives beside the provider because it +// answers a question about the same rows, and it is NOT part of the provider — +// core calls it through the audience registry, not through the five members +// above. +// +// **Three rules, and every one of them protects somebody's mailbox rather than +// this module's correctness.** +// +// 1. Return user ids and nothing else. You are not handed a template, a channel +// or an address, and you may not enumerate them; core maps ids to addresses +// on its own side, after preferences, suppression and the verification gate. +// 2. Never widen on failure. A resolver that cannot answer returns the EMPTY set +// — never "everyone", never the last good answer. Core treats a throw the +// same way, but doing it here is what lets the log say which clan. +// 3. It is a SET of people, not a list of characters. The `DISTINCT` is in the +// query for that reason (see `clanProvider.db.js`). +async function listClanMemberUserIds({ clanId }) { + try { + return await db.listMemberUserIds(clanId) + } catch (err) { + log.warn('could not resolve clan members; resolving to nobody', { + clanId, message: err.message, + }) + return [] + } +} + module.exports = { getTeams, getTeamMembers, @@ -300,4 +330,5 @@ module.exports = { rosterVisibleTo, pageUrlTemplate, gameIsReachable, + listClanMemberUserIds, } diff --git a/template/server/test/_fakes.js b/template/server/test/_fakes.js index 9d4e5ee..ff00e2b 100644 --- a/template/server/test/_fakes.js +++ b/template/server/test/_fakes.js @@ -49,6 +49,11 @@ function fakeCtx(overrides = {}) { return log }, auth: { getUserFromRequest: spy(null) }, + // The engagement seam (§2.3). One method, recording, because that is the + // whole of what a module may do with it: fire a declared event and stop. + // Core's own emit is fire-and-forget and returns nothing, so this does too — + // a fake that returned a receipt would invite a module to wait on one. + events: { emit: spy(undefined) }, middleware: { requireAuth: (req, res, next) => next(), requireRole: () => (req, res, next) => next(), @@ -85,7 +90,10 @@ function fakeCtx(overrides = {}) { * an operator's install. */ function fakeApi() { - const record = { routes: null, extensions: [], streams: null, legs: [], hooks: {}, teamProvider: null } + const record = { + routes: null, extensions: [], streams: null, legs: [], hooks: {}, teamProvider: null, + triggers: null, audiences: null, engagementSeeds: null, + } const called = new Set() const once = (name) => { if (called.has(name)) throw new Error(`${name}() called twice`) @@ -103,6 +111,9 @@ function fakeApi() { // module registering a provider collides with the first. A fake cannot see // the second module, and asserting the half it can see is still worth doing. registerTeamProvider(provider) { once('registerTeamProvider'); record.teamProvider = provider }, + registerEventTriggers(triggers) { once('registerEventTriggers'); record.triggers = triggers }, + registerAudiences(audiences) { once('registerAudiences'); record.audiences = audiences }, + registerEngagementSeeds(seeds) { once('registerEngagementSeeds'); record.engagementSeeds = seeds }, onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn }, onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn }, } diff --git a/template/server/test/entry.test.js b/template/server/test/entry.test.js index 6ff759e..4be258d 100644 --- a/template/server/test/entry.test.js +++ b/template/server/test/entry.test.js @@ -130,3 +130,163 @@ test('the manifest declares what the loader requires', () => { // serves, so an entry in the module root would publish the whole module. if (manifest.client) assert.ok(manifest.client.entry.includes('/'), 'client.entry must be in a subdirectory') }) + +// ── The engagement seam ─────────────────────────────────────────────────── +// +// Core validates most of what is declared here at registration, and a module +// that gets it wrong does not load. These tests are mostly NOT that validator +// restated: they are the rules a module can satisfy at boot and still have got +// WRONG in a way whose only symptom is mail somebody received. Where one does +// overlap core — the namespacing and subjectKey assertions below — it is because +// `npm test` is a cheaper place to meet the failure than a first boot, and the +// message here names the field. + +test('every declared trigger is namespaced, ceilinged, and carries examples', () => { + const { api } = register() + const triggers = api.record.triggers + assert.ok(Array.isArray(triggers) && triggers.length, 'no triggers were declared') + + for (const t of triggers) { + // Trigger ids and notification-stream ids are ONE namespace, so an id must + // carry this module's own prefix or it is a claim on somebody else's. + assert.ok(t.id.startsWith(`${manifest.id}.`), `${t.id} is not namespaced`) + + // `ceiling` is required and has no default: there is no safe value to guess. + assert.ok(t.ceiling, `${t.id} declares no ceiling`) + + // Core refuses this one too; failing it here just costs less. What the rule + // protects is the cooldown key — "once per world", not "once per user" — and + // a subjectKey naming nothing would key every subject on `undefined`. + const names = t.variables.map((v) => v.name) + assert.ok(names.includes(t.subjectKey), `${t.id}: subjectKey "${t.subjectKey}" is not a variable`) + + for (const v of t.variables) { + // Not decoration: the example is what makes a template previewable and + // test-sendable without waiting for a real game event. + assert.ok('example' in v, `${t.id}.${v.name} has no example`) + // The type set is closed. A payload that needs a structure has outgrown + // interpolation, and a template cannot walk one. + assert.ok( + ['string', 'int', 'float', 'boolean', 'datetime', 'url'].includes(v.type), + `${t.id}.${v.name} has type "${v.type}"`, + ) + // A url is site-relative, because it ends up in an href in a mail somebody + // opens days later. + if (v.type === 'url') assert.match(v.example, /^\/[^/]/, `${t.id}.${v.name} must be site-relative`) + } + } +}) + +test('an audience resolves to nobody rather than to everybody when it fails', async () => { + const ctx = fakeCtx({ db: { query: () => Promise.reject(new Error('database is down')), pool: {} } }) + const { api } = register(ctx) + const audience = api.record.audiences[0] + + // The one behaviour worth a test of its own. Core treats a throw the same way, + // so this is not core's guard restated — it is the module choosing the same + // answer deliberately, and the reason is that the alternatives are both worse: + // "everyone" mails the wrong people and a stale answer mails yesterday's. + assert.deepStrictEqual(await audience.resolve({ clanId: 'clan-1' }), []) +}) + +test('a seeded rule names only this module’s triggers, and its own or core’s templates', () => { + const { api } = register() + const seeds = api.record.engagementSeeds + const ownKeys = new Set(seeds.templates.map((t) => t.key)) + const coreKeys = new Set(['notify.event', 'inapp.event', 'notify.digest']) + + for (const t of seeds.templates) { + // The key column is UNIQUE across the whole table, so an unprefixed + // `notify.event` from a module would collide with core's body and win. + assert.ok(t.key.startsWith(`${manifest.id}.`), `template ${t.key} is not namespaced`) + // `protected` means "the system breaks without this body" — true of a + // password reset and of nothing a module ships. Core refuses a module + // template that sets it, because it would take an operator's delete button + // away. + assert.ok(!('protected' in t), `template ${t.key} may not mark itself protected`) + } + + for (const group of seeds.ruleGroups) { + for (const rule of group.rules) { + assert.ok( + api.record.triggers.some((t) => t.id === rule.trigger_id), + `${rule.name} names a trigger this module does not declare`, + ) + for (const key of Object.values(rule.template_keys)) { + assert.ok(ownKeys.has(key) || coreKeys.has(key), `${rule.name} names an unknown template ${key}`) + } + // `enabled` is not a parameter, and a value passed for it is ignored + // rather than refused. Passing one anyway states an intention the platform + // will not honour, so the honest thing is not to write it. + assert.ok(!('enabled' in rule), `${rule.name} may not seed itself enabled`) + } + } +}) + +test('every seeded body is a shape the block registry will accept', () => { + const { api } = register() + + // The gap this exists for: `registerEngagementSeeds` checks that `blocks` is a + // non-empty array and stops. The BODY is validated by core's block registry, + // which runs in the template editor and in the renderer — so a malformed block + // registers, seeds, and first shows itself when an operator opens the body or a + // rule fires. Core is not here to ask, so assert the two rules that are easy to + // get wrong and impossible to notice. + const HEADING_LEVELS = ['h1', 'h2', 'h3'] + + for (const t of api.record.engagementSeeds.templates) { + const ids = new Set() + for (const block of t.blocks) { + // Every block carries its own id, unique within the body: it is how the + // editor addresses one block, and how `inapp.event`'s renderer maps blocks + // onto the inbox row's columns by role. + assert.ok(block.id && typeof block.id === 'string', `${t.key}: a block has no id`) + assert.ok(!ids.has(block.id), `${t.key}: two blocks share the id "${block.id}"`) + ids.add(block.id) + assert.ok(block.type.startsWith('email.'), `${t.key}: ${block.type} is not an email block`) + + // A heading's `level` is a SIZE token, not a number. `{ level: 2 }` reads + // perfectly and is refused, and it renders at the default size in any + // preview that skips validation — which is the whole trap. + if (block.type === 'email.heading') { + assert.ok( + HEADING_LEVELS.includes(block.props.level), + `${t.key}: heading level "${block.props.level}" must be one of ${HEADING_LEVELS.join(', ')}`, + ) + } + } + } +}) + +test('the world event fires on the transition and not on the poll', async () => { + const boot = require('../boot') + + // Online already, and reporting online again. The refresh writes, and nothing + // is announced: this runs every thirty seconds, and a rule on an event fired + // every thirty seconds mails somebody every thirty seconds. Core's cooldown + // would hold — but leaning on it means emitting "still up" and calling it news. + const steady = fakeCtx({ db: { query: () => Promise.resolve([{ online: 1 }]), pool: {} } }) + require('../core')._reset() + require('../core').init(steady) + await boot.refresh() + assert.deepStrictEqual(steady.events.emit.calls, []) + + // Offline before, online now. One emit, with the declared payload. + const flipped = fakeCtx({ db: { query: () => Promise.resolve([{ online: 0 }]), pool: {} } }) + require('../core')._reset() + require('../core').init(flipped) + await boot.refresh() + assert.strictEqual(flipped.events.emit.calls.length, 1) + const [triggerId, envelope] = flipped.events.emit.calls[0] + assert.strictEqual(triggerId, 'examplegame.world.status_changed') + assert.strictEqual(envelope.data.status, 'online') + + // A fresh install, where there is no previous row at all. Not a change — and + // announcing it would tell everyone the world came online the first time an + // operator started the site. + const fresh = fakeCtx({ db: { query: () => Promise.resolve([]), pool: {} } }) + require('../core')._reset() + require('../core').init(fresh) + await boot.refresh() + assert.deepStrictEqual(fresh.events.emit.calls, []) +})