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>
192 lines
10 KiB
JavaScript
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,
|
|
})
|
|
}
|