Introduces the DB-backed connection config for the uo-link sidecar (the
HTTP + WebSocket bridge to the ServUO shard) and a never-throw REST client,
mirroring the existing Discord-bot integration:
- uo_link_config singleton table (base/ws URL, AES-256-GCM-encrypted shared
token, protocol pin, enabled, and last-known status/plugin_connected/
last_event_at/boot_id mirrors for the admin panel).
- model/uoLinkConfig: getSafe (never returns the token — only hasToken),
getWithToken (server-side decrypt), save (blank token = unchanged),
recordStatus (mirror the sidecar's reported state).
- utils/uoLinkClient: never-throw fetch client returning {ok,data,status,
error}; Bearer token + X-UOLink-Version on every call; brief config cache;
helpers for health/char/roster/vendors/history/economy/link/towncrier.
- .env.example: UOLINK_BASE_URL/WS_URL/PROTOCOL defaults (token stays
admin-managed in the DB, never an env var).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
126 lines
5.1 KiB
JavaScript
126 lines
5.1 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 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)}`)
|
|
|
|
// ── Commands ──────────────────────────────────────────────────────────────
|
|
const confirmLink = (code, websiteUserId) =>
|
|
call('/link/confirm', { method: 'POST', body: { code, websiteUserId: String(websiteUserId) } })
|
|
const linkLookup = (account) => call(`/link/${encodeURIComponent(account)}`)
|
|
const postTownCrier = ({ id, lines, durationSec }) =>
|
|
call('/towncrier', { method: 'POST', body: { id, lines, durationSec } })
|
|
const deleteTownCrier = (id) => call(`/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
|
|
|
module.exports = {
|
|
invalidateConfig,
|
|
health,
|
|
getCharBySerial,
|
|
getCharBySlot,
|
|
getRoster,
|
|
getVendors,
|
|
getHistory,
|
|
getEconomy,
|
|
confirmLink,
|
|
linkLookup,
|
|
postTownCrier,
|
|
deleteTownCrier,
|
|
}
|