Files
Module-uo/server/index.js
wtclaude 57419111e6 feat(events): UO wave 1 — the verbs that need no protocol change (Phase 9)
module-uo registers its first event actions: `uo.broadcast`,
`uo.towncrier.post` and `uo.news.post`, plus the `uo.broadcasts` budget
dimension and the three spawn-atlas option sources. The write plane they use
has existed since protocol 2.1; what is new is the declaration that lets the
event engine drive it unattended.

Three things the tree corrected about the plan:

- The plan's `on_failure: 'skip'` for `uo.broadcast` is already the default for
  `risk: 'notify'`, and `on_failure` is what happens AFTER the retries. The
  lever a module actually has is the failure envelope, so the action answers
  `retry: false` to everything — and every action declares `budgetMs: 15000`,
  because core's 10s default deadline fires before `uoLinkClient`'s 12s timeout
  and `classify()` answers `retry` for a timeout without asking the module.
  Without the budget the retry refusal is unreachable.
- `reconcile()` needs no protocol work. A shard restart wipes both the crier
  lines and an event's news article, so `perform()` stamps the shard `bootId`
  into the resource payload and `reconcile()` reports in force exactly the rows
  whose stamp still matches — correct for the module's own trigger and for
  core's boot sweep alike. `shardIngest` fires `ctx.events.reconcile()` on a
  changed `bootId`, after `recordStatus` so the comparison reads the new boot.
- Event articles post under `evt-<idempotencyKey>`, because `newsGump.js` uses
  the bare website post id and re-pushes that set on every reconnect.

`ci/core-ref.json` moves to a website `edge` sha for the length of this
workstream: `registerEventActions` exists only from MODULE_API 1.10.0, so under
the old `main` pin the module does not load at all. Verified locally — the
frozen-manifest rig passes against the new pin.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-04 07:23:03 -05:00

192 lines
10 KiB
JavaScript

// ── module-uo's server entry point ─────────────────────────────────────────
//
// Core requires this file once, synchronously, while `app.js` is still being
// required, and calls the exported function with `(ctx, api)`. The normative
// contract is docs/website/MODULE_API.md §2.2; the three rules that shape every
// line below are worth restating where they will be read:
//
// 1. **No `await`, and no database.** `scripts/routeManifest.js` and
// `swagger/swagger.js` both require core's `app.js` with the pool pointed
// at a dead port, so a module that queried at registration time would hang
// both. Everything needing a live database is in `onBoot`.
// 2. **Never resolve what core owns.** This module lives at
// `<website>/modules/uo/`, outside `server/`, so Node's resolver never
// reaches core's `node_modules` and `require('express')` fails outright.
// express, express-validator, the database, the logger, the middleware and
// the rest of §2.3 arrive on `ctx` and are re-exported by `./core`.
// 3. **Never reach into core's tree.** No relative path may escape this
// module's root; `scripts/checkImports.js` enforces that in CI (§5.1).
//
// **Require order is load-bearing, and it is why the requires below are inside
// the function.** Every ported file reaches core through `./core`, whose members
// resolve `ctx` when called — but a router does `const express = core.express` at
// its own file scope, which runs the moment it 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 would break the module with an error about `ctx`
// being missing, from a file that never mentions it. 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 publicShard = require('./router/public/shard.router')
const publicAtlas = require('./router/public/atlas.router')
const adminShard = require('./router/admin/shard.router')
const adminUoLink = require('./router/admin/uoLink.router')
const playerShard = require('./router/player/shard.router')
const usersShardExtension = require('./router/admin/usersShard.router')
const shardStreams = require('./config/shardStreams')
const shardTriggers = require('./config/shardTriggers')
const shardAudiences = require('./config/shardAudiences')
const engagementSeeds = require('./config/engagementSeeds')
const uoEventActions = require('./config/uoEventActions')
const townCrierLeg = require('./utils/shardAnnounce')
const teamProvider = require('./model/teamProvider/teamProvider.model')
const guildCommand = require('./commands/guild.command')
const boot = require('./boot')
/* eslint-enable global-require */
const log = core.logger()
// The five prefixes, exactly the ones `module.json` declares — the loader
// compares the two and rejects a mismatch in either direction. Each router
// mounts INSIDE its tier, so it structurally cannot reach above its prefix,
// and the tier's own gate is already applied: `/admin` sits behind
// `noindex, isLoggedIn, requireRole(...)`, `/player` behind
// `noindex, requireAuth`, `/public` behind nothing by design.
//
// The URLs these produce are byte-identical to the ones core served before the
// extraction (§1.2). That is the whole point of moving the code and not the
// paths: the shipped Android app calls `POST /api/v1/admin/shard/kick`, and the
// Discord bot reads `/api/v1/public/shard/*`, and neither knows or needs to
// know that a module answers now.
api.registerRoutes({
public: { '/shard': publicShard, '/atlas': publicAtlas },
admin: { '/shard': adminShard, '/uo-link': adminUoLink },
player: { '/shard': playerShard },
})
// The six `/admin/users/:id/shard/*` URLs, which hang off a CORE resource and
// therefore cannot be a mount of our own (§1.9). Core declares the slot in
// `users.router.js` and we fill it; the router gets `req.params.id` from the
// parent via `mergeParams`. Core's own routes on the resource win any path
// conflict, which is correct — it owns the user.
api.registerExtension('admin.users.detail', usersShardExtension)
// The push catalog and the news leg. Core kept the push infrastructure and the
// announce worker; what it never had was an opinion about *shard* streams or
// about talking to a town crier, and those are content (MODULE_SYSTEM.md §1.8).
//
// Seven of these stream ids and the leg id `towncrier` are grandfathered
// (§6.5) — they are stored in `notification_subs` and `announce_job_legs.leg`
// and read by the shipped Android app, so a rename here is a data migration
// plus a client break rather than a tidy-up.
api.registerNotificationStreams(shardStreams.STREAMS)
api.registerAnnounceLeg(townCrierLeg.leg)
// The engagement contract (MODULE_API 1.7.0, ENGAGEMENT.md Phase 11). Triggers
// are PAYLOAD contracts: what a rule may fire on, what a template may
// interpolate, and — the part that is a security boundary — the widest audience
// an operator may ever give each one. `uo.cheat.detected` ceilings at `staff`
// and the three operator-facing ones at `admin` (added to the lattice in 1.8.0),
// and core refuses a rule that widens either.
//
// **Triggers and notification streams share ONE id namespace** (§7.2), so this
// registration and the one above are two facets of one space and core enforces
// that an id has exactly one owner across both. None of the ids below reuses a
// stream id: the stream catalog keeps its seven grandfathered names and these
// are the `uo.*`-prefixed ones §8.6 specifies. A trigger-only id gets email and
// in-app preferences and no push toggle, which is correct — there is nothing to
// push it to, and the shipped Android client's catalog is unchanged.
api.registerEventTriggers(shardTriggers.TRIGGERS)
// Audiences are named sets of PEOPLE an operator composes rules and segments
// out of (§5.1a). Their own id space, and their own ceiling arithmetic: a
// composition takes the narrowest ceiling it contains, never the widest.
//
// Registration is a claim; nothing resolves until the engine asks, which is
// after `onBoot` — and it must be, because every resolver reads the database
// and registration must not (§2.2 rule 1).
api.registerAudiences(shardAudiences.AUDIENCES)
// What this module SHIPS behind those two (MODULE_API 1.9.0, ENGAGEMENT.md
// Phase 11b): sixteen in-universe message bodies on two channels each, and
// twenty-five rules — every one of them `enabled = 0`, which the registry
// enforces rather than trusts.
//
// **A catalogue an operator turns on, not a switch that fires on upgrade.**
// Nothing here mails anybody: a rule that is off produces nothing, and a rule
// that is on still passes the ceiling, the per-user preference, the suppression
// list and the verification gate before anything is sent — all of them core's.
//
// The nine security and operational triggers point at core's generic bodies
// (decision 9). A cheat report should read like a cheat report.
//
// ONE rule group, and the choice is deliberate: a group is seeded once, so a
// twenty-sixth rule appended to `triggers-v1` in a later version would reach
// fresh installs ONLY. A future trigger wants its own group key.
api.registerEngagementSeeds({
templates: engagementSeeds.TEMPLATES,
ruleGroups: engagementSeeds.RULE_GROUPS,
})
// Teams: a UO guild is a Team, and this module is the authoritative source of
// them for this deployment (MODULE_API 1.6.0). Core asks the three questions;
// everything about what a guild IS stays here.
//
// Registration is a claim, not a call — nothing below runs until core
// reconciles, which is after `onBoot`. That matters because every method reads
// the database, and registration must not.
api.registerTeamProvider(teamProvider)
// `/guild` — the chat surface for the same guilds (MODULE_API 1.6.0, TEAMS.md
// §7.1). The definition travels to the bot; the handler stays here and runs in
// the website process, because the bot container has no `modules` volume and
// cannot load a line of this module's code.
//
// Core registers NO commands of its own. "Guild" is this module's word — core
// does not own it on a page (phase 3) and does not publish it in a channel
// either.
api.registerSlashCommands([guildCommand])
// The event contract (MODULE_API 1.10.0, EVENTS.md F, EVENTS_PLAN.md Phase 9).
// Three verbs an event author can put in a step, the one budget dimension that
// bounds a broadcast, and the three option sources the spawn atlas answers.
//
// **All of it is optional, by the contract's own posture.** A deployment
// without this module still has an event engine that can announce, wait, cue a
// human and publish results; what these add is the ability for an event to
// reach the GAME. Nothing here is a precondition for anything of core's.
//
// The wave is deliberately the verbs that need no protocol change: the write
// plane they use has existed since protocol 2.1 and the admin screens have
// driven it by hand for months. The world verbs -- creatures, gates, leases --
// wait for Phase 11 to put an idempotency key and a lease deadline on the wire,
// because a world write core cannot prove ran exactly once is not one this
// module is willing to make unattended.
api.registerEventBudgets(uoEventActions.BUDGETS)
api.registerEventActions(uoEventActions.ACTIONS)
api.registerEventOptionSources(uoEventActions.OPTION_SOURCES)
api.onBoot(boot.onBoot)
api.onShutdown(boot.onShutdown)
log.info('registered', {
version: require('../module.json').version,
routes: 'public:/shard,/atlas admin:/shard,/uo-link player:/shard',
streams: shardStreams.STREAMS.length,
triggers: shardTriggers.TRIGGERS.length,
audiences: shardAudiences.AUDIENCES.length,
eventActions: uoEventActions.ACTIONS.length,
})
}