feat(server): register the routes, the slot, the leg and the boot hooks

The entry point becomes real: five mount prefixes, the admin.users.detail
extension slot, the shard push catalog, the town-crier announce leg and both
lifecycle hooks. module.json declares all of it and the loader checks the
declaration against what register() actually registers, in both directions.

The URLs are byte-identical to the ones core served before the extraction. 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 a module answers now.

Require order is load-bearing and the requires are inside register() because of
it. Every ported file reaches core through ./core, whose members resolve ctx
when called -- but a router does `const express = core.express` at ITS file
scope, which runs the moment it is required. Hoisting these to the top of the
file breaks the module with an error about ctx being missing, from a file that
never mentions it.

boot.js takes the eight UO call sites out of core's server.js. One behavioural
change, deliberate: uoLinkSocket.start() and the sidecar health probe used to
run AFTER the listener bound and now run before it, because onBoot does. start()
returns as soon as the reconnecting client is armed, but the probe is a real
HTTP call, so it is fired and NOT awaited -- an unreachable sidecar must not
hold the site closed. Reporting that the bridge is down is diagnostics; being up
is not a precondition for serving a page.

router/rateLimits.js builds the market limiter through ctx.middleware.rateLimit,
core's factory. The policy is the module's -- only the module knows what its
endpoints cost -- and the plumbing is core's, so there is one express-rate-limit
in the process and one place a breach is logged.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-11 12:06:46 -05:00
committed by Claude
parent fe3251a543
commit 740a677f92
19 changed files with 2930 additions and 26 deletions

View File

@@ -8,44 +8,90 @@
// 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. Anything needing a live database belongs in `onBoot`.
// 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 and express-validator arrive on `ctx`; so do the database, the
// logger, the middleware and the rest of §2.3.
// 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)
// rather than leaving it to review.
// module's root; `scripts/checkImports.js` enforces that in CI (§5.1).
//
// Slice 0 of the Phase 3 extraction (MODULE_SYSTEM.md §2.7.1) deliberately
// registers NOTHING. The bundle exists, core discovers it, validates it, mounts
// its zero routes, serves its client chunk and reports it `started` — which is
// the whole delivery path proved end to end before a single UO file moves into
// it. Slice 1 brings the atlas; every slice after that adds registrations here
// and deletes the matching files from core.
// **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) {
const log = ctx.log()
core.init(ctx)
// Registrations land here, slice by slice:
/* 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 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.
//
// api.registerRoutes({ public: {...}, admin: {...}, player: {...} })
// api.registerExtension('admin.users.detail', usersShardRouter)
// api.registerNotificationStreams(streams)
// api.registerAnnounceLeg({ leg: 'towncrier', ... })
// api.onBoot(async (ctx) => { ... })
// api.onShutdown(async () => { ... })
// 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).
//
// `api` is referenced by this log line and nothing else yet, on purpose: an
// entry point that took `api` and never named it would read like an oversight
// rather than a stage of the extraction.
// 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)
api.onBoot(boot.onBoot)
api.onShutdown(boot.onShutdown)
log.info('registered', {
version: require('../module.json').version,
registers: Object.keys(api).length,
routes: 'public:/shard,/atlas admin:/shard,/uo-link player:/shard',
streams: shardStreams.STREAMS.length,
})
}