Files
Module-Rust/server/scripts/engagementManifest.js
wtclaude 285db0baa7 feat(rust): notifications and engagement (phase 10, protocol 7)
Registers the engagement set R7 put in v1: thirteen triggers, four push
streams, three audiences, four bodies (two triggers, email and in-app)
and thirteen disabled rules in seven groups (PLAN.md §25, D59-D68).

The raid alert goes to everyone authorised on the tool cupboard, one
emit per linked person with ownerUserId, so the owner ceiling holds per
emit. It covers doors and walls (protocol 7), never names the raider,
alerts nobody when there is no cupboard, and carries ownerOnline so
"offline only" is the seeded rule's condition rather than code.

The fan-out runs off ingest before a frame is applied, since applying a
disband deletes the roster the notice is sent to. A replayed event is
told only while it is news: 15 minutes for broadcasts, 24 hours for
personal and staff events. Dedupe keys come from the event, not the
sidecar's row id. Server online/offline and a new kills leader are
in-memory transitions, never on first sight, and a tie is not a lead.
A login with no approval within a minute becomes a staff notice via a
query, so a restart loses nothing.

Also fixes a phase-4 gap (D68): the refresh now asks /health, so a game
that hung, or whose bridge was unloaded, while the sidecar stayed up no
longer reads as online. It stops naming players as online, and a stale
board no longer moves "last seen".

engagement-triggers.json is the committed freeze of all of it, checked
in CI with line endings normalised. The check was verified by breaking
it both ways.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-23 06:06:19 -05:00

147 lines
5.4 KiB
JavaScript

#!/usr/bin/env node
//
// The engagement freeze: every trigger, stream and audience this module
// declares, and every rule it seeds, as one committed file whose DIFF is the
// review signal (MODULE_API.md §2.4: "a module ships a prebuilt
// `engagement-triggers.json` in its bundle").
//
// **Why it exists when core never reads it.** A trigger declaration is what an
// operator's templates interpolate and their rules are written against.
// Renaming a variable, changing its type or widening a ceiling breaks stored
// templates and rules — silently, at send time, in a mail somebody already
// got. Committing the declarations as data turns that edit into a visible diff
// in the PR that makes it, the same job `routes.manifest.json` does for URLs.
//
// **It is generated from the registrations, not from the source files**, by
// running `register()` against a recording api — so what is frozen is what core
// would be handed, including anything `index.js` does on the way.
//
// **It records `coreApi`, not core's `MODULE_API_VERSION`.** Core's own manifest
// embeds the API version, and every API bump then makes it stale with no change
// to a single declaration — which is how it once sat stale for a whole phase.
// The range this module declares moves only when this module decides it should.
//
// Usage (from server/):
// node scripts/engagementManifest.js write ../engagement-triggers.json
// node scripts/engagementManifest.js --check exit 1 if the committed file is stale
const fs = require('fs')
const path = require('path')
const { fakeCtx, fakeApi } = require('../test/_fakes')
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
const MANIFEST = path.join(MODULE_ROOT, 'engagement-triggers.json')
const COMMENT =
'Generated freeze of module-rust\'s engagement contract (docs/modules/rust/PLAN.md §25). ' +
'Regenerate with `npm run engagement:manifest` in server/. A renamed variable, a changed type ' +
'or a widened ceiling breaks stored templates and rules, so the diff here is the review signal.'
function build() {
require('../core')._reset()
const api = fakeApi()
require('../index')(fakeCtx(), api)
const { triggers, streams, audiences, engagementSeeds } = api.record
const manifest = require('../../module.json')
const byId = (a, b) => a.id.localeCompare(b.id)
return {
_comment: COMMENT,
coreApi: manifest.coreApi,
// Sorted by id: reordering a declaration in the source is not a contract
// change and must not produce a diff that looks like one. Variables keep
// their DECLARED order, which is the order the template editor shows.
triggers: [...triggers].sort(byId).map((t) => ({
id: t.id,
label: t.label,
description: t.description,
kind: t.kind,
subjectKey: t.subjectKey,
audience: t.audience,
ceiling: t.ceiling,
version: t.version,
variables: t.variables.map((v) => ({
name: v.name,
type: v.type,
required: v.required,
example: v.example,
description: v.description,
})),
})),
streams: [...streams].sort(byId).map((s) => ({
id: s.id,
label: s.label,
personal: s.personal,
requiresLinkedAccount: s.requiresLinkedAccount,
})),
// `resolve` is a function over this module's store and cannot be frozen.
// What is frozen is the part an operator's saved rule depends on.
audiences: [...audiences].sort(byId).map((a) => ({
id: a.id,
label: a.label,
params: a.params,
ceiling: a.ceiling,
})),
ruleGroups: engagementSeeds.ruleGroups.map((g) => ({
key: g.key,
rules: g.rules.map((r) => ({
trigger_id: r.trigger_id,
audience: r.audience,
channels: r.channels,
template_keys: r.template_keys,
conditions: r.conditions === undefined ? null : r.conditions,
cooldown_seconds: r.cooldown_seconds,
delay_seconds: r.delay_seconds || 0,
cancel_on: r.cancel_on || [],
})),
})),
templates: engagementSeeds.templates.map((t) => ({
key: t.key,
channel: t.channel,
triggerId: t.triggerId,
seedVersion: t.seedVersion,
})),
}
}
function main() {
const check = process.argv.includes('--check')
const next = `${JSON.stringify(build(), null, 2)}\n`
if (!check) {
fs.writeFileSync(MANIFEST, next)
process.stdout.write(`wrote ${path.relative(MODULE_ROOT, MANIFEST)}\n`)
return
}
// Line endings normalised, as `swaggerFragment.js` does: a Windows checkout
// under `core.autocrlf=true` turns the committed LF blob into CRLF, and a byte
// comparison would then call an unchanged file stale on every such machine —
// a check that cries wolf is a check nobody reads.
const lf = (s) => s.replace(/\r\n/g, '\n')
let committed
try {
committed = fs.readFileSync(MANIFEST, 'utf8')
} catch {
process.stderr.write('engagement-triggers.json is missing — run `npm run engagement:manifest`\n')
process.exit(1)
}
if (lf(committed) !== lf(next)) {
process.stderr.write(
'engagement-triggers.json is stale: a trigger, stream, audience or seeded rule changed.\n' +
'Run `npm run engagement:manifest` in server/ and commit the result — and read the diff,\n' +
'because a changed variable or ceiling is a change to every rule an operator has saved.\n',
)
process.exit(1)
}
process.stdout.write('engagement-triggers.json is current\n')
}
if (require.main === module) main()
module.exports = { build }