refactor(modules)!: move the UO server half out to module-uo

40 files, ~9,674 lines, 27 of 68 tables. Core no longer contains anything that
knows what a shard is.

BREAKING for a deployment only in the sense that the module must be installed
for these URLs to answer -- no URL moved. routes.manifest.json goes 228 -> 158
public routes here, and the 70 that left reappear byte-identical when the module
is loaded: verified by generating the manifest against core+module and diffing
it against the pre-extraction file. Zero missing, zero added, and routes.guards
identical across all 228, so no auth gate moved either.

The five tier mounts are gone from public/admin/player index.js and are still
served: the loader mounts them onto the same routers after every core mount.
That ordering is also what keeps the prefixes unclaimable -- the collision check
asks the live router what core owns, so a second module claiming /shard is
rejected against the mounts actually present rather than against a list.

server.js loses its eight UO call sites to the module's onBoot/onShutdown.
schema.sql loses its 27 shard_*/uo_link_* statements; the two that FK into users
are why the fragment replays AFTER core's schema, and no core table ever
referenced a module table, which is what makes core still able to boot alone.

Verified against a running server with the module installed: it loads, mounts
five prefixes, replays 35 statements, warms up and reaches `started`; public
shard and atlas routes answer 200 with real data (800 creatures, 6,455
spawners), admin and player answer 401 from core's tier gates, and the extension
slot answers at /admin/users/:id/shard/*. Core's own SPA renders the shard and
atlas pages unchanged against the module-served API, with no console errors and
no CSP reports.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-11 12:08:03 -05:00
committed by Claude
parent f50541f374
commit 39d731d87a
9 changed files with 27 additions and 5223 deletions

View File

@@ -27,8 +27,6 @@ const postsRouter = require('./posts.router')
const uploadsRouter = require('./uploads.router')
const wikiRouter = require('./wiki.router')
const pagesRouter = require('./pages.router')
const shardRouter = require('./shard.router')
const uoLinkRouter = require('./uoLink.router')
const emailRouter = require('./email.router')
const discordBotRouter = require('./discordBot.router')
const settingsRouter = require('./settings.router')
@@ -64,12 +62,14 @@ adminRouter.use('/posts', postsRouter)
adminRouter.use('/uploads', uploadsRouter)
adminRouter.use('/wiki', wikiRouter)
adminRouter.use('/pages', pagesRouter)
// Ops and configuration. /shard mixes tiers on one prefix — self-service game
// account linking (no extra gate) alongside modAccess in-game staff ops — so
// one router owns the prefix and gates per route. The rest are admin-only.
// /admin/shard/pages is the in-game help-page queue, unrelated to /admin/pages.
adminRouter.use('/shard', shardRouter)
adminRouter.use('/uo-link', uoLinkRouter)
// Ops and configuration.
//
// `/shard` and `/uo-link` are absent here and are still served: they are
// module-uo's, mounted onto this same router by the loader after every core
// mount above (MODULE_API.md §2.4). The URLs did not move — the code did. That
// ordering is also what makes the prefixes unclaimable by anyone else: the
// loader asks this live router what core owns, so a second module claiming
// `/shard` is rejected against the mounts actually present, not against a list.
adminRouter.use('/email', emailRouter)
adminRouter.use('/discord-bot', discordBotRouter)
adminRouter.use('/settings', settingsRouter)

View File

@@ -13,7 +13,9 @@
// Adding a requireRole('player') here would 403 an admin off their own characters
// (it happened once — see docs/website/BACKEND_DESIGN.md). Staff also reach the
// identical self-scoped handlers under /admin/shard and /auth/me/account; those
// are alternative URLs onto the same controllers, not duplicated logic.
// are alternative URLs onto the same controllers, not duplicated logic — and
// both of those live in module-uo now, which changes where they are defined and
// nothing about which URLs answer.
//
// See docs/website/API_V2_PLAN.md § Phase 2 for the split.
@@ -23,7 +25,6 @@ const { requireAuth } = require('../../../auth/session.middleware')
const noindex = require('../../../middleware/noindex')
const accountRouter = require('./account.router')
const shardRouter = require('./shard.router')
const appealsRouter = require('./appeals.router')
const playerRouter = express.Router()
@@ -37,7 +38,6 @@ const playerRouter = express.Router()
playerRouter.use(noindex, requireAuth)
playerRouter.use('/account', accountRouter)
playerRouter.use('/shard', shardRouter)
playerRouter.use('/appeals', appealsRouter)
module.exports = playerRouter

View File

@@ -20,8 +20,6 @@ const express = require('express')
const postsRouter = require('./posts.router')
const wikiRouter = require('./wiki.router')
const pagesRouter = require('./pages.router')
const shardRouter = require('./shard.router')
const atlasRouter = require('./atlas.router')
const modulesRouter = require('./modules.router')
const siteRouter = require('./site.router')
@@ -32,13 +30,6 @@ const publicRouter = express.Router()
publicRouter.use('/posts', postsRouter)
publicRouter.use('/wiki', wikiRouter)
publicRouter.use('/pages', pagesRouter)
// Live shard data, never site-mode gated.
publicRouter.use('/shard', shardRouter)
// The spawn atlas: static shard CONTENT, parsed from the shard's ServUO tree
// rather than fetched from the sidecar. Deliberately not under /shard — nothing
// here depends on the bridge — and site-mode gated per route like the content
// routers above, which is the other half of that distinction.
publicRouter.use('/atlas', atlasRouter)
// What this backend serves beyond core. A real prefix layer rather than a fifth
// singleton in site.router.js, because the loader's prefix-collision probe reads
// the live tier stack and skips root-mounted layers — this mount is what makes

View File

@@ -4,19 +4,12 @@ const http = require('http')
const app = require('./app')
const internalApp = require('./internalApp')
const botScore = require('./middleware/botScore')
const uoLinkSocket = require('./utils/uoLinkSocket')
const uoLinkClient = require('./utils/uoLinkClient')
const uoLinkConfig = require('./model/uoLinkConfig/uoLinkConfig.model')
const shardBroadcast = require('./utils/shardBroadcast')
const announceWorker = require('./utils/announceWorker')
const { ensureSchema, close } = require('./utils/db')
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
const settings = require('./model/settings/settings.model')
const revokedSessions = require('./model/revokedSessions/revokedSessions.model')
const mobileAuthBridge = require('./model/mobileAuthBridge/mobileAuthBridge.model')
const shardAtlas = require('./model/shardAtlas/shardAtlas.model')
const shardClilocs = require('./model/shardClilocs/shardClilocs.model')
const shardMarket = require('./model/shardMarket/shardMarket.model')
const moduleLifecycle = require('./modules/lifecycle')
const createLogger = require('./utils/logger')
const { evaluateBotInternalKey } = require('./utils/botInternalKey')
@@ -81,37 +74,18 @@ async function start() {
log.warn('mobile-auth-bridge prune failed', { error: err.message })
}
// Re-derive the spawn atlas from the shard's own ServUO tree. The shard's maps
// change over its lifetime — facets get added, replaced or renamed — so the
// atlas is rebuilt on every boot rather than shipped as a snapshot that would
// silently go stale. Hash-gated, so an unchanged tree costs one read pass and
// no database write.
//
// Best-effort by contract: no configured path, an unreadable mount or a
// malformed file must never stop the site coming up. A refresh that would
// REMOVE a facet is staged for admin approval instead of being applied.
await shardAtlas.refreshOnBoot()
// Refresh the cliloc table (UO's id → display-string map) from the file the
// operator converted out of their own client. Same contract as the atlas:
// hash-gated so an unchanged file costs one read, and best-effort so a missing
// or wrong-format file never stops the site coming up — it just means item
// names render as ids, which is what they did before the table existed.
const clilocResult = await shardClilocs.refreshOnBoot()
// A cliloc import changes what item names RESOLVE to, and the marketplace
// stores those names denormalized (shard_vendor_items.display_name) so it can
// index and search them. The shard's market sweep will not re-send an unchanged
// shop just because the site learned what its items are called, so the backfill
// has to be pulled rather than waited for. Only after an actual import — the
// common boot is hash-gated to a no-op and must stay one.
if (clilocResult && clilocResult.status === 'imported') await shardMarket.refreshDisplayNames()
const mode = await settings.get('site_mode')
log.info(`site mode: ${String(mode || 'live').toUpperCase()}`)
// Reconcile installed_modules with what the loader found on the volume at
// require time, then run each module's onBoot (MODULE_API.md §2.5). Placed
// require time, then run each module's onBoot (MODULE_API.md §2.5).
//
// This is where the shard now warms up. Core used to do it inline just above —
// rebuild the spawn atlas from the ServUO tree, refresh the cliloc table, open
// the uo-link WebSocket — and all of it is module-uo's `onBoot` since Phase 3.
// The ordering guarantee is unchanged and is why it belongs here rather than
// after the listener: the tables exist by now, and nothing is served until the
// warm-up finishes. Placed
// after core's own boot work and BEFORE the listener binds, for both reasons
// the contract gives: a module's warm-up may depend on core being up, and a
// module that must not serve traffic until it has warmed a cache gets that
@@ -132,17 +106,6 @@ async function start() {
log.info(`internal API listening on http://${HOST}:${INTERNAL_PORT} (server<->bot only — do NOT proxy)`)
})
// Start the uo-link WebSocket ingest client. Self-guards: it only actually
// connects when the admin has enabled the integration and saved a token, so
// this is a no-op on shards that haven't configured the sidecar. Never let a
// sidecar problem block server startup.
try {
await uoLinkSocket.start()
await checkUoLink()
} catch (err) {
log.warn('uo-link socket failed to start (continuing)', { error: err.message })
}
// Start the news-announcement dispatcher: a light in-process poller that pushes
// published news posts to the in-game town crier + Discord with independent
// retry per leg. No-op until a news post is actually published.
@@ -151,33 +114,6 @@ async function start() {
setupShutdown(server, internalServer)
}
// Best-effort startup probe of the uo-link sidecar: if the integration is
// enabled, log whether it is reachable and warn loudly on a protocol mismatch
// (fail-fast visibility rather than silently mis-parsing a newer wire format).
async function checkUoLink() {
const config = await uoLinkConfig.getSafe()
if (!config.enabled) return
const health = await uoLinkClient.health()
if (!health.ok) {
log.warn('uo-link is enabled but the sidecar is unreachable at startup', {
baseUrl: config.baseUrl,
error: health.error || `status ${health.status}`,
})
return
}
if (health.data && health.data.protocol && health.data.protocol !== config.protocol) {
log.error('uo-link PROTOCOL MISMATCH — pinned vs sidecar', {
pinned: config.protocol,
sidecar: health.data.protocol,
})
} else {
log.info('uo-link sidecar reachable', {
pluginConnected: health.data && health.data.plugin_connected,
protocol: health.data && health.data.protocol,
})
}
}
function setupShutdown(server, internalServer) {
let closing = false
const shutdown = async (signal) => {
@@ -192,8 +128,6 @@ function setupShutdown(server, internalServer) {
await moduleLifecycle.shutdown()
botScore.stopSweeper() // stop the bot-store cleanup interval
announceWorker.stop() // stop the news-announcement dispatcher poller
uoLinkSocket.stop() // close the uo-link WS ingest client
shardBroadcast.closeAll() // end any open shard live-feed SSE streams
server.close(() => log.info('http server closed'))
if (internalServer) internalServer.close(() => log.info('internal http server closed'))
try {