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
166 lines
8.9 KiB
JavaScript
166 lines
8.9 KiB
JavaScript
// ── The server entry point ─────────────────────────────────────────────────
|
|
//
|
|
// Core requires this file once, synchronously, while its own `app.js` is still
|
|
// being required, and calls the exported function with `(ctx, api)`. That is the
|
|
// entire server-side handshake: everything this module can reach arrives on
|
|
// `ctx`, and everything it can offer is registered through `api`.
|
|
//
|
|
// Normative: MODULE_API.md §2.2 (the entry point) and §2.4 (what you register).
|
|
//
|
|
// ── Three rules, and each one has a failure behind it ──────────────────────
|
|
//
|
|
// 1. **No `await`, and no database.** Core requires `app.js` in two build tools
|
|
// with the connection pool pointed at a dead port — the route-manifest
|
|
// generator and the OpenAPI generator both do it — so a module that queried
|
|
// at registration time would hang both. Anything that needs a live database
|
|
// goes in `onBoot`, which runs after the schema is up.
|
|
//
|
|
// 2. **Never resolve what core owns.** This module lives at
|
|
// `<website>/modules/rust/`, outside core's `server/`, so Node's resolver
|
|
// never reaches core's `node_modules` and `require('express')` from here
|
|
// simply fails. express, express-validator, the database, the logger and the
|
|
// middleware all arrive on `ctx` (§2.3) and are re-exported by `./core`. A
|
|
// second express in the process would be a second `Router` prototype, exactly
|
|
// as a second React would be a second renderer.
|
|
//
|
|
// 3. **Never reach into core's tree.** No relative path may escape this module's
|
|
// root. `scripts/checkImports.js` enforces it (§5.1) and CI runs it.
|
|
//
|
|
// ── Why the requires are INSIDE the function ───────────────────────────────
|
|
//
|
|
// Every file below reaches core through `./core`, whose members resolve `ctx`
|
|
// when they are CALLED. But a router writes `const express = core.express` at its
|
|
// own file scope, and that runs the moment the file is required. So
|
|
// `core.init(ctx)` has to happen before the first `require` of anything under
|
|
// `router/`. Hoisting these to the top of the file breaks the module with an
|
|
// error about a missing `ctx`, thrown from a file that never mentions one.
|
|
//
|
|
// Node caches modules, so requiring here costs nothing after the first call.
|
|
|
|
const core = require('./core')
|
|
|
|
/**
|
|
* @param {object} ctx what core hands the module (MODULE_API.md §2.3), frozen
|
|
* @param {object} api what the module registers (§2.4)
|
|
*/
|
|
module.exports = function register(ctx, api) {
|
|
core.init(ctx)
|
|
|
|
/* eslint-disable global-require */
|
|
const publicRust = require('./router/public/rust.router')
|
|
const playerRust = require('./router/player/rust.router')
|
|
const adminRust = require('./router/admin/rust.router')
|
|
const usersRust = require('./router/admin/usersRust.router')
|
|
const teamProvider = require('./model/clans/teamProvider')
|
|
const { TRIGGERS } = require('./engagement/triggers')
|
|
const { STREAMS } = require('./engagement/streams')
|
|
const { AUDIENCES } = require('./engagement/audiences')
|
|
const seeds = require('./engagement/seeds')
|
|
const boot = require('./boot')
|
|
/* eslint-enable global-require */
|
|
|
|
const log = core.logger()
|
|
|
|
// One prefix, on each of the three tiers (R14). The keys here must match
|
|
// `module.json`'s `mounts` exactly — the loader compares the two and rejects a
|
|
// mismatch in EITHER direction, so a route never declared and a prefix declared
|
|
// and never registered both fail loudly at boot rather than quietly at runtime.
|
|
//
|
|
// Each router sits INSIDE its tier router, so it structurally cannot reach
|
|
// above its prefix, and the tier's gate is already applied: `public` is behind
|
|
// nothing by design, `admin` behind `noindex, isLoggedIn, requireRole(...)` and
|
|
// `player` behind `noindex, requireAuth`. Per-route gates go on top; the tier
|
|
// gate is never re-implemented.
|
|
//
|
|
// **Prefixes share ONE namespace with core's own, and the collision probe
|
|
// cannot see all of it.** Core answers several public routes mounted at the
|
|
// tier root rather than under a prefix — `/status` and `/version` among them —
|
|
// and the loader's check cannot find those. `/rust` collides with nothing on
|
|
// any of the three tiers, checked against core's mount tables rather than
|
|
// assumed.
|
|
api.registerRoutes({
|
|
public: { '/rust': publicRust },
|
|
player: { '/rust': playerRust },
|
|
admin: { '/rust': adminRust },
|
|
})
|
|
|
|
// R13's first extension slot (§2.4). Core declares `admin.users.detail` on
|
|
// `/api/v1/admin/users/:id` and we fill it; the router receives the parent's
|
|
// `req.params.id` through `mergeParams`. Core's own routes on the resource are
|
|
// declared before the slot is mounted, so core wins any path conflict — it owns
|
|
// the user, and this module owns what it can say about one.
|
|
//
|
|
// **It is declared twice, in two different places, on purpose.** This call is
|
|
// the SERVER half and `module.json`'s `extensions` array is held against it by
|
|
// the loader. The CLIENT half is `registry.registerExtension(ID,
|
|
// 'admin.users.detail', …)` in `entry.jsx` and must NOT appear in that array —
|
|
// phase 1 found that the hard way with `site.footer.status`, which is a client
|
|
// slot and fails the load outright when named there.
|
|
api.registerExtension('admin.users.detail', usersRust)
|
|
|
|
// Teams (R5, PLAN.md §24). A first-party Rust clan is a Team, and this module
|
|
// becomes the deployment's one authoritative source of them. Core asks; the
|
|
// provider answers from the clan boards (`model/clans`), and refuses rather
|
|
// than guessing whenever no board is current.
|
|
//
|
|
// **One provider per deployment**, so a site running module-uo as well cannot
|
|
// have both — the second registration is a collision core reports against the
|
|
// module that made it. That is core's rule and a real constraint on a mixed
|
|
// UO + Rust site; it is recorded in §24 rather than worked around here.
|
|
api.registerTeamProvider(teamProvider)
|
|
|
|
// Notifications and engagement (R7, PLAN.md §25). Four registrations that are
|
|
// one decision, because they only mean something together:
|
|
//
|
|
// triggers what can happen, what a template may say about it, and the
|
|
// widest audience a rule on it may EVER have — the security
|
|
// boundary; core refuses a rule that widens a ceiling
|
|
// streams which of those may reach a phone. Core pushes an engagement
|
|
// rule only to devices subscribed to a stream of the SAME id, so
|
|
// a trigger missing here can never buzz anybody (D65)
|
|
// audiences named sets of people over this module's data, for an operator
|
|
// to point a rule at; each answers user ids and nothing else
|
|
// seeds the two bodies worth writing, and one disabled rule group per
|
|
// family — installing this module mails nobody
|
|
//
|
|
// What fires them is `engagement/emit.js`, off the ingest cursor and the
|
|
// refresh. Registration is a claim, not a call: nothing here touches the
|
|
// database, and the seeds are written by core after the schema is up.
|
|
//
|
|
// **Not registered, and that is D62:** no announce leg and no post hook. Both
|
|
// need something in game to deliver to, and phase 10 reaches no game.
|
|
api.registerEventTriggers(TRIGGERS)
|
|
api.registerNotificationStreams(STREAMS)
|
|
api.registerAudiences(AUDIENCES)
|
|
api.registerEngagementSeeds({ templates: seeds.TEMPLATES, ruleGroups: seeds.RULE_GROUPS })
|
|
|
|
// 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.
|
|
// It has no timeout, deliberately: a slow boot delays the listener, which is the
|
|
// guarantee rather than a problem to be timed out.
|
|
//
|
|
// `onShutdown` runs while core's database pool and push dispatcher are still
|
|
// open, because flushing through them is the only thing it is for. It gets a
|
|
// five-second budget and is abandoned past it.
|
|
api.onBoot(boot.onBoot)
|
|
api.onShutdown(boot.onShutdown)
|
|
|
|
// Everything else this module will register — the four event catalogues, the
|
|
// announce leg and the slash commands — is deliberately absent. Each arrives
|
|
// with the phase that has something real to put in it. A registration
|
|
// with nothing behind it is worse than a missing one: a declared trigger
|
|
// nothing emits and a declared slot nothing fills are both surfaces an operator
|
|
// can configure and then wait on.
|
|
|
|
log.info('registered', {
|
|
version: require('../module.json').version,
|
|
routes: 'public:/rust player:/rust admin:/rust',
|
|
extensions: 'admin.users.detail',
|
|
teams: 'first-party clans',
|
|
triggers: TRIGGERS.length,
|
|
streams: STREAMS.length,
|
|
audiences: AUDIENCES.length,
|
|
})
|
|
}
|