Files
website/server/src/utils/uoLinkClient.js
wtclaude 61d6bfaca2 feat(shard): ingest world.ruleset and publish it at /site/rules
Protocol 3.0 §5 (docs/link/v3.md). The shard publishes its own ruleset —
expansion, which optional systems are on, skill/stat caps, account and house
limits, champion scroll rules, the save/restart schedule — and the site renders
it, so the rules page cannot drift from how the shard actually plays.

Server
  - shard_ruleset: a singleton table (id = 1) holding the whole frame in
    `payload`, with `rev` and `expansion` hoisted. Nothing is normalized out:
    the frame is a flat description of config read as one page, and splitting it
    into columns would mean a schema change every time the shard grows a block.
  - shardIngest routes world.ruleset to setRuleset and deliberately does NOT
    log it — the shard re-emits the whole ruleset on every sidecar connect, so
    logging would append a duplicate row per reconnect, and server.hello already
    marks each of those.
  - uoLinkSocket backfills GET /ruleset explicitly rather than via snapshot(),
    which asserts an array; this covers the order where the sidecar was already
    up and holding the ruleset when we reconnected.
  - GET /public/shard/ruleset behind requireFeature('ruleset') and projected,
    per §3.6.1's rule that a shard read which doesn't project is a bug. `null`
    means the shard has never published one — a real answer, distinct from a
    published ruleset, and the page says so.

Client
  - routes/public/Rules.jsx at /site/rules, live via world.ruleset (a frame is a
    complete ruleset, not a delta, so the newest one wins outright). Caps are
    rendered from tenths — 7000 is 700.0, and showing the raw number would
    mislead. A systems key this build doesn't know still renders, humanised, so
    a newer plugin can't go invisible against an older client.
  - Nav entry gated on the `ruleset` feature, so it hides rather than 403s.

Verified end to end against the local MariaDB and a sidecar fed by a fake shard:
backfill snapshot, live SSE delivery of a changed ruleset, REST reflecting the
overwrite, an empty /feed (not logged), and the gate — 200 by default, 403 at
audience=staff (and dropped from /features so nav hides it), 404 when disabled.
Page rendered clean at all breakpoints checked, no console errors.

497 server tests pass; routes.manifest.json, routes.guards.json and the OpenAPI
spec regenerated.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 14:35:24 -05:00

213 lines
9.7 KiB
JavaScript

// ── uo-link sidecar REST client ────────────────────────────────────────────
//
// Server-side HTTP client for the uo-link sidecar (the bridge to the ServUO
// shard). Same shape as botInternalClient: never throws — every call returns
// { ok, data, status, error } so an admin poll or a public page never 500s just
// because the sidecar/shard is down or restarting.
//
// The base URL + shared-secret token come from the DB-backed uoLinkConfig
// (admin-managed, encrypted at rest) — NOT env vars, and the token is NEVER sent
// to the browser. Every request carries `Authorization: Bearer <token>` and
// `X-UOLink-Version: <protocol>` so a protocol mismatch is caught (409) rather
// than mis-parsed. Config is cached for a few seconds to avoid decrypting the
// token on every call.
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
const log = require('./logger')('uo-link-client')
const TIMEOUT_MS = 12000 // sidecar waits up to 10s on the shard before 504
const CONFIG_TTL_MS = 5000
let cachedConfig = null
let cachedAt = 0
// Read (and briefly cache) the connection config incl. decrypted token.
async function resolveConfig() {
const now = Date.now()
if (cachedConfig && now - cachedAt < CONFIG_TTL_MS) return cachedConfig
cachedConfig = await uoLinkConfig.getWithToken()
cachedAt = now
return cachedConfig
}
// Drop the cache after a save so the next call picks up new URL/token immediately.
function invalidateConfig() {
cachedConfig = null
cachedAt = 0
}
// Core request. Returns { ok, data, status, error }. `ok` is true only on a 2xx
// with a parseable JSON body. Non-2xx responses still return their status + body
// so callers can distinguish 503 (shard restarting — transient) from 404.
async function call(path, { method = 'GET', body } = {}) {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS)
// resolveConfig() decrypts the stored auth token, and decryption THROWS when the
// ciphertext can't be authenticated — SECRET_ENC_KEY was rotated, or a DB dump was
// restored into an environment keyed differently. It must stay INSIDE the try: out
// here it escaped `call()` entirely and 500'd every live-shard route (admin and
// player character/roster/vendor lookups, GET /admin/uo-link/config) instead of
// degrading to "shard unavailable". This module never throws — see the header.
let configResolved = false
try {
const config = await resolveConfig()
configResolved = true
if (!config || !config.baseUrl) {
return { ok: false, status: 0, error: 'uo-link is not configured' }
}
const headers = {
'Content-Type': 'application/json',
'X-UOLink-Version': String(config.protocol || 1),
}
if (config.token) headers.Authorization = `Bearer ${config.token}`
const res = await fetch(`${config.baseUrl}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
})
let data = null
try {
data = await res.json()
} catch {
// Non-JSON (or empty) body — leave data null; status still reported.
}
if (!res.ok) {
if (res.status === 401) log.warn('uo-link rejected auth token (401)', { path })
if (res.status === 409) log.error('uo-link protocol mismatch (409)', { path, body: data })
return { ok: false, status: res.status, data, error: `sidecar responded ${res.status}` }
}
return { ok: true, status: res.status, data }
} catch (err) {
// A failure before the config resolved is a misconfiguration, not a flaky
// sidecar: log it loudly (and distinctly) so "the shard looks offline" doesn't
// silently mean "the token can no longer be decrypted".
if (!configResolved) {
log.error('uo-link config unreadable — is SECRET_ENC_KEY the key the stored token was encrypted with?', {
path,
message: err.message,
})
return { ok: false, status: 0, error: 'uo-link config unreadable' }
}
log.warn('uo-link call failed', { path, message: err.message })
return { ok: false, status: 0, error: err.message }
} finally {
clearTimeout(timeout)
}
}
// ── Read queries ───────────────────────────────────────────────────────────
// Liveness (no auth required by the sidecar, but we send it anyway).
const health = () => call('/health')
const getCharBySerial = (serial) => call(`/char/serial/${encodeURIComponent(serial)}`)
const getCharBySlot = (account, slot) =>
call(`/char/${encodeURIComponent(account)}/${encodeURIComponent(slot)}`)
const getRoster = (account) => call(`/roster/${encodeURIComponent(account)}`)
const getVendors = (account) => call(`/vendors/${encodeURIComponent(account)}`)
// History / economy series — used for WS-reconnect backfill and public feeds.
function getHistory({ kind, limit = 100 } = {}) {
const params = new URLSearchParams()
if (kind) params.set('kind', kind)
if (limit) params.set('limit', String(limit))
const qs = params.toString()
const suffix = qs ? `?${qs}` : ''
return call(`/history${suffix}`)
}
const getEconomy = (limit = 100) => call(`/economy?limit=${encodeURIComponent(limit)}`)
// Live board / queue projections — snapshotted on WS (re)connect and served from
// our own store thereafter.
const getChamps = () => call('/champs')
const getPages = () => call('/pages')
// Protocol 2.0 board projections — same snapshot-on-connect pattern.
const getGuilds = () => call('/guilds')
const getGovernors = () => call('/governors')
const getHouses = () => call('/houses')
const getPresence = () => call('/online') // aggregate population (count + byFacet/byRegion)
// Protocol 3.0: the shard's published ruleset. Object-shaped, not a board — the
// sidecar answers `{ ruleset: null }` until the shard has published one.
const getRuleset = () => call('/ruleset')
// ── Commands ──────────────────────────────────────────────────────────────
const confirmLink = (code, websiteUserId) =>
call('/link/confirm', { method: 'POST', body: { code, websiteUserId: String(websiteUserId) } })
const linkLookup = (account) => call(`/link/${encodeURIComponent(account)}`)
// Account provisioning (Protocol 2.0). createAccount provisions a game account and
// auto-links it to the website user in one step; `ip` is the END USER's browser IP
// (read from the request), which the shard needs for its per-IP account cap — the
// sidecar only sees our server. The password is hashed on the shard and never
// appears in any reply/event/log. unlinkAccount severs a game account's tie from
// the site side. `actor` is the staff/website id, recorded in the shard audit.
const createAccount = ({ actor, account, password, websiteUserId, ip }) =>
call('/accounts/create', {
method: 'POST',
body: { actor, account, password, websiteUserId: websiteUserId == null ? undefined : String(websiteUserId), ip },
})
const unlinkAccount = ({ actor, account }) =>
call(`/link/${encodeURIComponent(account)}`, { method: 'DELETE', body: { actor } })
const postTownCrier = ({ id, lines, durationSec }) =>
call('/towncrier', { method: 'POST', body: { id, lines, durationSec } })
const deleteTownCrier = (id) => call(`/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' })
// Town Cryer News gump (Protocol 2.1). A full article (title/HTML body/image/URL)
// in the in-game News window; re-posting the same id REPLACES it. `announce`
// (default true on the sidecar) controls whether the criers proclaim the title.
const postNews = ({ id, title, body, image, url, announce }) =>
call('/news', { method: 'POST', body: { id: String(id), title, body, image, url, announce } })
const deleteNews = (id) => call(`/news/${encodeURIComponent(id)}`, { method: 'DELETE' })
// ── Staff write plane (§6) ─────────────────────────────────────────────────
// Every call carries `actor` — the website username of the staff member — set by
// the controller from the session, NEVER from the browser. The shard records it
// for attribution and echoes an admin.audit event back over the WS feed.
const adminKick = ({ actor, account, serial }) =>
call('/admin/kick', { method: 'POST', body: { actor, account, serial } })
const adminBan = ({ actor, account, serial, durationSec, reason }) =>
call('/admin/ban', { method: 'POST', body: { actor, account, serial, durationSec, reason } })
const adminUnban = ({ actor, account }) =>
call('/admin/unban', { method: 'POST', body: { actor, account } })
const adminBroadcast = ({ actor, text, hue }) =>
call('/admin/broadcast', { method: 'POST', body: { actor, text, hue } })
// ── Help-page (support) queue commands (§6) ────────────────────────────────
const respondPage = (pageId, { message, close }) =>
call(`/pages/${encodeURIComponent(pageId)}/respond`, { method: 'POST', body: { message, close } })
const closePage = (pageId) => call(`/pages/${encodeURIComponent(pageId)}/close`, { method: 'POST' })
module.exports = {
invalidateConfig,
health,
getCharBySerial,
getCharBySlot,
getRoster,
getVendors,
getHistory,
getEconomy,
getChamps,
getPages,
getGuilds,
getGovernors,
getHouses,
getPresence,
getRuleset,
confirmLink,
linkLookup,
createAccount,
unlinkAccount,
postTownCrier,
deleteTownCrier,
postNews,
deleteNews,
adminKick,
adminBan,
adminUnban,
adminBroadcast,
respondPage,
closePage,
}