Files
website/server/src/server.js
wtclaude 2801ec8f4d refactor(atlas): derive the atlas from the shard's tree on every boot
Replaces the committed-artifact design from the first commit. Two problems with
it, both raised in review:

**Facets are not a fixed list.** The first pass carried a hardcoded table of the
six stock UO facets to reconcile the spelling drift between sources. That is
wrong: a shard may add facets, replace them outright, or rename them when its
maps are updated, and a built-in list quietly mishandles all three. Nothing in
the atlas names a facet any more. The facet set is discovered from the tree —
spawn records and region definitions are the authority — and the loose spellings
in Data/Locations are matched against it by key and prefix. Custom facets get
identical treatment; the tests use `Sosaria` and `Underdark` precisely so a
stock-facet assumption cannot creep back in.

**A snapshot goes stale.** Maps change over a server's life, so a build-once
artifact silently drifts from the world players actually see. The tree is now
the single source of truth and the atlas is re-derived on every boot.

## What that changed

- **The committed artifact is gone** — 1.41 MB of generated JSON removed, along
  with `scripts/buildSpawnAtlas.js` and the whole encode/decode seam it needed
  (`encodePoint`/`readPoint`, the tuple encoding, the omitted-defaults scheme and
  their round-trip tests). Nothing to keep in sync, nothing to go stale.
- **NEW `src/utils/spawnAtlasSource.js`** — the only thing that touches a ServUO
  tree; shared by the boot path and the CLI. Parsers stay pure and fs-free.
- **NEW `src/model/shardAtlas/`** — `.db.js` (the one-transaction replace) and
  `.model.js` (the refresh decision).
- **`scripts/importSpawnAtlas.js`** is now a thin CLI over the model:
  `--servuo`, `--force`, `--approve`, `--reject`, `--status`. `atlas:build` is
  gone; `atlas:import` remains.
- Path comes from the `spawn_atlas_servuo_path` admin setting, falling back to
  `SERVUO_PATH`. The setting wins, matching how the rest of the shard
  integration is admin-managed rather than env-configured.

## Two contracts on the boot path

**It never blocks startup.** No path, an unreadable mount, a malformed file, a
database error — every one is caught and logged, and the site comes up serving
whatever atlas it already had. Verified by booting the real server with no path,
a broken path, and a good path.

**A facet disappearing is never applied automatically.** Losing a facet is the
signature of a half-copied or mid-update tree as much as of a real map change,
and boot cannot tell them apart. The refresh is staged in `shard_atlas_pending`
for an admin to approve or reject, and startup continues regardless. Additions
and every other change apply immediately, since none of them can destroy
something an operator would miss.

Only the decision is stored, not the parsed world: a few KB of source hashes and
the facet diff. Approving re-parses, so what gets applied matches the tree at
approval time rather than at boot. A rejection is remembered against those exact
hashes, so a declined refresh does not re-prompt on every restart — changing the
tree changes the hashes and asks again.

Hash-gated, so the common case (restart, maps unchanged) reads and hashes the
tree (~120 ms) and writes nothing. A real change costs a ~400 ms parse.

The admin approve/reject UI is part of the second PR, with the rest of the
routes and pages. Until then the CLI covers it.

## Verification

- **564 server tests pass**, 28 new in `spawnAtlas.source.test.js` covering the
  custom-facet build, the spelling reconciliation, hash gating, and every branch
  of the refresh decision — including that `refreshOnBoot` survives a database
  that throws on every call.
- End-to-end against the local MariaDB and the real ServUO tree: 6,455 points,
  800 creatures, 23,927 point/type rows, 387 regions, 558 landmarks, 25 altars,
  83.2% of points resolved to a place name.
- The facet gate exercised against a real tree copy with `malas.xml` removed:
  staged rather than applied, atlas untouched with all 293 Malas points intact,
  reject then stays quiet on re-run, approve applies and drops the facet.
- Booted the real server under all three source conditions; none blocked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
2026-07-28 16:41:33 -05:00

189 lines
7.8 KiB
JavaScript

require('dotenv').config()
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 createLogger = require('./utils/logger')
const { evaluateBotInternalKey } = require('./utils/botInternalKey')
const brand = require('./config/brand')
const pkg = require('../package.json')
const log = createLogger('server')
const PORT = Number(process.env.PORT) || 3000
// Separate, UNPUBLISHED listener for server<->bot /internal/* traffic. Kept off
// the public PORT so the decrypted-token route can't ride the listener Pangolin
// proxies to the world (issue #33). Must match the port in the bot's
// SITE_INTERNAL_URL (docker-compose.yml).
const INTERNAL_PORT = Number(process.env.INTERNAL_PORT) || 3001
const HOST = '0.0.0.0' // bind all interfaces so Pangolin / the LAN can reach it
async function start() {
log.info(`starting ${brand.name} server v${pkg.version}`, {
node: process.version,
env: process.env.NODE_ENV || 'development',
logLevel: process.env.LOG_LEVEL || 'info',
logFile: createLogger.logFilePath || 'disabled (console only)',
db: `${process.env.DB_HOST || '127.0.0.1'}:${process.env.DB_PORT || 3306}/${process.env.DB_NAME || 'runic_gateway'}`,
cookieSecure: process.env.COOKIE_SECURE || 'auto',
email: 'gmail-oauth2 (configured in admin → settings)',
})
// Fail fast if the server<->bot shared secret is weak/placeholder. Fatal in
// production (the /internal/bot-config route hands back the decrypted Discord
// token and this key is its only guard); a warning otherwise.
const keyCheck = evaluateBotInternalKey({
key: process.env.BOT_INTERNAL_KEY,
nodeEnv: process.env.NODE_ENV,
})
if (keyCheck.fatal) {
log.error(keyCheck.message)
process.exit(1)
} else if (!keyCheck.ok) {
log.warn(keyCheck.message)
}
log.info('ensuring database schema...')
await ensureSchema()
log.info('seeding defaults...')
await seedDefaults()
await createInitialAdminFromEnv()
// Clear out session-denylist rows whose token has already expired (dead weight).
// Best-effort — a prune failure must never block startup.
try {
const pruned = await revokedSessions.pruneExpired()
if (pruned) log.info(`pruned ${pruned} expired revoked-session row(s)`)
} catch (err) {
log.warn('revoked-session prune failed', { error: err.message })
}
// Same treatment for the mobile SSO bridge tables (also pruned opportunistically
// on each bridge write). Boot-time sweep catches rows orphaned by a crash.
try {
const pruned = await mobileAuthBridge.pruneExpired()
if (pruned) log.info(`pruned ${pruned} expired mobile-auth-bridge row(s)`)
} catch (err) {
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()
const mode = await settings.get('site_mode')
log.info(`site mode: ${String(mode || 'live').toUpperCase()}`)
const server = http.createServer(app)
server.listen(PORT, HOST, () => {
log.info(`listening on http://${HOST}:${PORT} (API at /api/v1, health at /api/health)`)
})
// Internal server<->bot API on a separate, unpublished port. NEVER expose this
// through Pangolin/the public reverse proxy — it serves the decrypted Discord
// bot token to the bot process over the private compose network only (#33).
const internalServer = http.createServer(internalApp)
internalServer.listen(INTERNAL_PORT, HOST, () => {
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.
announceWorker.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) => {
if (closing) return
closing = true
log.warn(`${signal} received — shutting down gracefully`)
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 {
await close()
log.info('database pool closed')
} catch (err) {
log.error('error closing database pool', err)
}
await createLogger.close() // flush the log file
process.exit(0)
}
process.on('SIGINT', () => shutdown('SIGINT'))
process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('unhandledRejection', (reason) => log.error('unhandledRejection', { reason: String(reason) }))
process.on('uncaughtException', (err) => {
log.error('uncaughtException', err)
process.exit(1)
})
}
start().catch((err) => {
log.error('failed to start server', err)
process.exit(1)
})