A UO guild is a Team. This registers module-uo as the authoritative source of
them (MODULE_API 1.6.0, docs/website/TEAMS.md §2.3) and answers the three
questions core asks, from the board and the roster Protocol 4 put there.
`externalId` is the persistent ServUO `Guild.Id`, which survives a rename -- so
core sees "an id whose name changed" and applies its rename rule rather than an
unrelated new guild appearing beside the old one. That mapping is this module's
to make: only the game knows what identity survives what.
The most important code here is the refusal guard, and it is deliberately
conservative. Core's contract is that module unavailability becomes staleness and
never emptiness, and this module is the only thing that can honour it -- an empty
array from here reads as an authoritative "there are none", and core archives
Teams and departs members from an authoritative answer. Three states refuse: no
uo-link configured, the integration disabled, and the socket not connected.
**The third is the one worth arguing about.** The board is durable and survives an
outage, so serving it while disconnected looks harmless. It is not: core cannot
tell a board five minutes stale from one five days stale, and a complete answer
licenses destruction. There is a test named for that.
A fourth refusal has no equivalent anywhere else: a guild whose roster has not
arrived. Protocol 4's roster comes on its own frames, separately from the
`guild.update` that creates the board row, so there is a real window where a
155-member guild has zero roster rows. The board's own `members` count is the only
thing that distinguishes "the roster is late" from "this guild is empty", and it
is checked -- with the count in the refusal message, because it is the evidence.
The other side is tested too: when the board says zero, an empty roster is the
truth and withholding it would freeze a disbanding guild's membership forever.
Two limitations, both honest and both in the code as comments:
- **`rankLabel` is null.** The wire's roster member is the standard actor object
(`serial`, `name`, `player`, `acct?`, `webId?`) and carries no guild rank.
Inventing a label from the leader flag would be core displaying something this
module made up.
- **One leader, not several.** TEAMS.md §2.5 expects multiple leaders from
`GuildRank.Rank >= 4` and core supports them, but Protocol 4 does not put rank
on the wire, so the only leadership visible here is the board's single
`leader_serial`. Raising it to the full set is a protocol change, not
something this module can fix.
`online` comes from `shard_online` rather than the roster, which carries no
per-member presence and only a board-level count -- the same source the public
"who's online" surface already uses. `userId` prefers the roster's own `web_id`
(what the shard asserted at roster time) and falls back to the `shard_account_links`
join for a member whose row predates their link; resolving it here rather than in
core is the contract, since core reading `shard_account_links` would be core
naming a module's table.
`coreApi` stays `^1.3.0` -- 1.6.0 satisfies it, which is what makes the bump minor.
18 provider tests plus two on the entry point: that all three methods are
registered, and that registration performs no query. The second matters because
register() runs while core's app.js is still being required with the pool pointed
at a dead port, which both routeManifest.js and swagger.js depend on.
`fakeApi` gained `registerTeamProvider` with the same `once` rule core applies --
one provider per deployment, so a second registration has to fail here too rather
than passing a shape core rejects at load.
411 -> 413 tests, all passing.
Refs docs/website/TEAMS.md §2.3, Part 12 phase 2
Co-Authored-By: Claude <noreply@anthropic.com>
108 lines
5.5 KiB
JavaScript
108 lines
5.5 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 townCrierLeg = require('./utils/shardAnnounce')
|
|
const teamProvider = require('./model/teamProvider/teamProvider.model')
|
|
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)
|
|
|
|
// 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)
|
|
|
|
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,
|
|
})
|
|
}
|