module-uo's half of ENGAGEMENT.md Phase 11: every trigger DECLARATION, the
wire-kind mapping that fires them, and the three registered audiences. No rule
and no template is seeded here -- that is 11b -- so nothing this adds sends
anybody anything until an operator writes a rule.
server/config/shardTriggers.js declares the 24, grouped by the audience kind
each family exercises, and every variable carries the `example` the template
editor previews and test-sends with. Ceilings: 10 `owner`, 2 `members`, 7
`authenticated`, 2 `staff`, 3 `admin` (the value core adds in the same window).
`uo.cheat.detected` at `staff` is the declaration the lattice exists for.
server/utils/shardEngagement.js maps the wire to those ids, hung off
shardIngest.ingest beside the SSE broadcast and the push tickle, and reads like
shardPush.js on purpose -- owner resolution is why neither can be a pure mapper.
Three things live here because a rule cannot express them:
* Transitions. champ.update and city.update are full-state upserts, so without
a per-process tracker a sidecar reconnect reads as twenty spawns starting.
A FIRST sighting is never a transition.
* Thresholds. conditions.js compares a declared variable against a LITERAL, so
"within 24 hours of dismissal" is not expressible; and vendor.listing is a
sweep frame re-emitted on any price change, so per-frame would flood. The
crossing is tracked here and `hoursRemaining` is declared so an operator can
still narrow with `is at most`.
* The members audience. "The members of THIS guild" differs every firing, so
it travels on the envelope as recipientUserIds (Phase 6 decision 2).
**The fan-out runs BEFORE the state write, and that ordering is load-bearing.**
account.unlinked drops the shard_account_links row that names the one person who
needs to be told; house.remove drops the house whose stored ownerAcct is the only
place a collapsed house's owner appears; guild.leave/remove need the roster and
board mirrors to name who left. Resolving afterwards finds nobody, every time.
Four rows of 8.6 deliberately do not ship, each with its reason recorded in
docs (docs#194): uo.market.item_listed (a saved search, no per-user query store),
uo.guild.joined (core's team.member.joined already fires for it -- a UO guild IS
a Team and this module is the provider), uo.link.requested (no addressable
recipient by construction, ~5-minute TTL), and uo.points.rank_changed's personal
half (top[] names a serial, links are keyed by account).
coreApi -> ^1.8.0: the module now calls registerEventTriggers and declares
`ceiling: 'admin'`, so a 1.7.0 core would refuse the ceiling and a 1.6.0 one
would not have the method at all.
39 new tests; 509/509 pass. check:imports, check:bundle and check:swagger clean.
Co-Authored-By: Claude <noreply@anthropic.com>
148 lines
7.8 KiB
JavaScript
148 lines
7.8 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 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)
|
|
|
|
// 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])
|
|
|
|
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,
|
|
})
|
|
}
|