// ── 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 ` and // `X-UOLink-Version: ` 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 config = await resolveConfig() if (!config || !config.baseUrl) { return { ok: false, status: 0, error: 'uo-link is not configured' } } const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS) try { 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) { 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() return call(`/history${qs ? `?${qs}` : ''}`) } 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) // ── 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, confirmLink, linkLookup, createAccount, unlinkAccount, postTownCrier, deleteTownCrier, postNews, deleteNews, adminKick, adminBan, adminUnban, adminBroadcast, respondPage, closePage, }