feat(server): port the UO models, utils and schema fragment
The data half of the extraction: 8 model directories, 13 utils, the shard
stream catalog and the 27-table schema fragment with its purge.
server/core.js is what makes the port a one-line import change per file rather
than a signature change per function. Ported code requires its dependencies at
file scope -- `const { query } = require('../../core')` -- which runs before
register() has been called and before any ctx exists. So every member is a
stable function that resolves ctx when CALLED, and nothing may be destructured
off ctx at init either, because core is free to hand over a getter.
Two helpers are vendored rather than taken from ctx, and the line between them
is the point. utils/excerpt.js is core's deriveExcerpt -- nine lines of pure
text handling. Core's sanitiser next to it was NOT copied: a second copy of a
security control diverges silently the moment either is fixed. announceLinks.js
vendors legError and articleUrl the same way, but baseUrl could not be: core's
reads APP_BASE_URL, and §2.7 forbids a module reading core's environment, so it
comes off ctx.site.baseUrl.
The schema fragment is core's 27 shard_*/uo_link_* statements, verbs CREATE,
ALTER and UPDATE only, every CREATE TABLE guarded. Two of its tables carry a
foreign key INTO users, which is allowed and is why the replay order matters --
core's schema is in place before this runs. The reverse never occurs and must
not: it would make core unable to boot without a module installed.
One real port bug caught by the integration run, not by tests: the atlas art
map resolved `../../../db/data`, which pointed at core's tree when this file
lived there and points outside server/ now. A path that happens to resolve is
exactly what survives a green suite, because the absent-file branch returns {}
and looks like the normal case.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
224
server/utils/uoLinkClient.js
Normal file
224
server/utils/uoLinkClient.js
Normal file
@@ -0,0 +1,224 @@
|
||||
// ── 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('../core').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 || 3),
|
||||
}
|
||||
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')
|
||||
// Protocol 3.0: points/loyalty leaderboards. `/points` is board-shaped (an array
|
||||
// under `boards`); the per-system read 404s for a system the shard never published.
|
||||
const getPoints = () => call('/points')
|
||||
const getPointsBoard = (system) => call(`/points/${encodeURIComponent(system)}`)
|
||||
// Protocol 3.0: the player-vendor market index. The one PAGED sidecar read — a
|
||||
// whole-world market does not fit in a response — so it answers with
|
||||
// `{ vendors, total, limit, offset }` and the caller walks it (see uoLinkSocket).
|
||||
const getMarket = ({ limit = 200, offset = 0 } = {}) =>
|
||||
call(`/market?limit=${encodeURIComponent(limit)}&offset=${encodeURIComponent(offset)}`)
|
||||
|
||||
// ── 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,
|
||||
getPoints,
|
||||
getPointsBoard,
|
||||
getMarket,
|
||||
confirmLink,
|
||||
linkLookup,
|
||||
createAccount,
|
||||
unlinkAccount,
|
||||
postTownCrier,
|
||||
deleteTownCrier,
|
||||
postNews,
|
||||
deleteNews,
|
||||
adminKick,
|
||||
adminBan,
|
||||
adminUnban,
|
||||
adminBroadcast,
|
||||
respondPage,
|
||||
closePage,
|
||||
}
|
||||
Reference in New Issue
Block a user