Add uo-link sidecar foundation: config store + REST client (phase 0)
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
This commit is contained in:
12
.env.example
12
.env.example
@@ -76,3 +76,15 @@ CLIENT_ORIGIN=http://localhost:5173
|
|||||||
# longer rides the public listener, but an explicit deny rule is belt-and-braces.
|
# longer rides the public listener, but an explicit deny rule is belt-and-braces.
|
||||||
BOT_INTERNAL_URL=http://bot:4100
|
BOT_INTERNAL_URL=http://bot:4100
|
||||||
BOT_INTERNAL_KEY=change-me-to-a-long-random-string
|
BOT_INTERNAL_KEY=change-me-to-a-long-random-string
|
||||||
|
|
||||||
|
# uo-link sidecar — the HTTP + WebSocket bridge to the ServUO game server. The
|
||||||
|
# website ingests its live event feed and proxies its read queries/commands
|
||||||
|
# (shard status, online players, player-vendor sales, IDOC houses, character
|
||||||
|
# sheets, account linking, town-crier). In production the sidecar + shard run on
|
||||||
|
# a DIFFERENT host from the website, so both URLs are configurable. The
|
||||||
|
# shared-secret auth token is NOT an env var — it is entered in the admin panel
|
||||||
|
# (Shard page) and stored encrypted in the DB (same pattern as the Discord bot
|
||||||
|
# token). These URLs are just defaults; the admin can override them at runtime.
|
||||||
|
UOLINK_BASE_URL=http://127.0.0.1:8080
|
||||||
|
UOLINK_WS_URL=ws://127.0.0.1:8080/ws
|
||||||
|
UOLINK_PROTOCOL=1
|
||||||
|
|||||||
@@ -259,6 +259,36 @@ CREATE TABLE IF NOT EXISTS email_config (
|
|||||||
CONSTRAINT chk_email_config_singleton CHECK (id = 1)
|
CONSTRAINT chk_email_config_singleton CHECK (id = 1)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ── uo-link sidecar ────────────────────────────────────────────────────────
|
||||||
|
-- Connection config for the uo-link sidecar (the HTTP + WebSocket bridge to the
|
||||||
|
-- ServUO shard). Singleton row (id = 1), mirroring bot_config/email_config: the
|
||||||
|
-- DB only ever holds the AES-256-GCM-encrypted shared-secret auth token, never
|
||||||
|
-- plaintext, and it is only decrypted server-side (to call the sidecar). It is
|
||||||
|
-- never returned to the admin UI — responses expose only `hasToken`. base_url is
|
||||||
|
-- the REST endpoint, ws_url the live-feed endpoint; both are configurable because
|
||||||
|
-- in production the sidecar runs on a different host from the website. `status`/
|
||||||
|
-- `plugin_connected`/`last_event_at`/`boot_id` mirror the sidecar's last-known
|
||||||
|
-- state for the admin panel between polls; `boot_id` tracks server.hello.bootId
|
||||||
|
-- so a shard restart can be detected (and caches dropped).
|
||||||
|
CREATE TABLE IF NOT EXISTS uo_link_config (
|
||||||
|
id INT PRIMARY KEY DEFAULT 1,
|
||||||
|
base_url VARCHAR(255) NULL,
|
||||||
|
ws_url VARCHAR(255) NULL,
|
||||||
|
auth_token_enc TEXT NULL,
|
||||||
|
protocol INT NOT NULL DEFAULT 1,
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'disconnected',
|
||||||
|
status_detail VARCHAR(500) NULL,
|
||||||
|
plugin_connected TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
last_event_at DATETIME NULL,
|
||||||
|
boot_id VARCHAR(64) NULL,
|
||||||
|
updated_by INT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_uo_link_config_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT chk_uo_link_config_singleton CHECK (id = 1)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
-- Discord bot moderation core (Phase 2). These tables are owned by the bot
|
-- Discord bot moderation core (Phase 2). These tables are owned by the bot
|
||||||
-- process (its own DB pool, bot/src/db.js) — the main server never reads or
|
-- process (its own DB pool, bot/src/db.js) — the main server never reads or
|
||||||
-- writes them. They live in the same physical database as everything else
|
-- writes them. They live in the same physical database as everything else
|
||||||
|
|||||||
28
server/src/model/uoLinkConfig/uoLinkConfig.db.js
Normal file
28
server/src/model/uoLinkConfig/uoLinkConfig.db.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
const { query } = require('../../utils/db')
|
||||||
|
|
||||||
|
const COLS =
|
||||||
|
'id, base_url, ws_url, auth_token_enc, protocol, enabled, status, status_detail, plugin_connected, last_event_at, boot_id, updated_by, created_at, updated_at'
|
||||||
|
|
||||||
|
// Singleton row (id = 1). Returns null until the admin saves it for the first time.
|
||||||
|
async function get() {
|
||||||
|
const rows = await query(`SELECT ${COLS} FROM uo_link_config WHERE id = 1 LIMIT 1`)
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert the singleton row. `fields` are column values already prepared by the
|
||||||
|
// model (token pre-encrypted). Only the provided columns are written/updated.
|
||||||
|
async function upsert(fields) {
|
||||||
|
const cols = Object.keys(fields)
|
||||||
|
const vals = cols.map((c) => fields[c])
|
||||||
|
const insertCols = ['id', ...cols].map((c) => `\`${c}\``).join(', ')
|
||||||
|
const placeholders = ['1', ...cols.map(() => '?')].join(', ')
|
||||||
|
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
|
||||||
|
await query(
|
||||||
|
`INSERT INTO uo_link_config (${insertCols}) VALUES (${placeholders})
|
||||||
|
ON DUPLICATE KEY UPDATE ${updates}`,
|
||||||
|
vals,
|
||||||
|
)
|
||||||
|
return get()
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { get, upsert }
|
||||||
85
server/src/model/uoLinkConfig/uoLinkConfig.model.js
Normal file
85
server/src/model/uoLinkConfig/uoLinkConfig.model.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// uo-link sidecar connection config store. Mirrors botConfig/emailConfig: the DB
|
||||||
|
// layer only ever sees ciphertext, and only getWithToken() (used server-side to
|
||||||
|
// call the sidecar over REST/WS) decrypts it. The admin-facing getSafe() never
|
||||||
|
// includes the token — it exposes only `hasToken`. A blank `token` on save means
|
||||||
|
// "leave the existing token unchanged" (same convention as the other configs).
|
||||||
|
|
||||||
|
const db = require('./uoLinkConfig.db')
|
||||||
|
const secretBox = require('../../utils/secretBox')
|
||||||
|
|
||||||
|
const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 1
|
||||||
|
|
||||||
|
function toSafe(row) {
|
||||||
|
if (!row) {
|
||||||
|
return {
|
||||||
|
baseUrl: process.env.UOLINK_BASE_URL || null,
|
||||||
|
wsUrl: process.env.UOLINK_WS_URL || null,
|
||||||
|
protocol: DEFAULT_PROTOCOL,
|
||||||
|
enabled: false,
|
||||||
|
hasToken: false,
|
||||||
|
status: 'disconnected',
|
||||||
|
statusDetail: null,
|
||||||
|
pluginConnected: false,
|
||||||
|
lastEventAt: null,
|
||||||
|
bootId: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
baseUrl: row.base_url || null,
|
||||||
|
wsUrl: row.ws_url || null,
|
||||||
|
protocol: row.protocol || DEFAULT_PROTOCOL,
|
||||||
|
enabled: Boolean(row.enabled),
|
||||||
|
hasToken: Boolean(row.auth_token_enc),
|
||||||
|
status: row.status || 'disconnected',
|
||||||
|
statusDetail: row.status_detail || null,
|
||||||
|
pluginConnected: Boolean(row.plugin_connected),
|
||||||
|
lastEventAt: row.last_event_at || null,
|
||||||
|
bootId: row.boot_id || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSafe() {
|
||||||
|
return toSafe(await db.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypted token included — server-side only (calling the sidecar's REST/WS
|
||||||
|
// API). Returns null when nothing has been saved yet.
|
||||||
|
async function getWithToken() {
|
||||||
|
const row = await db.get()
|
||||||
|
if (!row) return null
|
||||||
|
return { ...toSafe(row), token: row.auth_token_enc ? secretBox.decrypt(row.auth_token_enc) : null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save admin-supplied config. `token` undefined or '' means "leave the existing
|
||||||
|
// token unchanged" (same convention as botConfig.save).
|
||||||
|
async function save({ baseUrl, wsUrl, token, protocol, enabled, updatedBy }) {
|
||||||
|
const fields = {}
|
||||||
|
if (baseUrl !== undefined) fields.base_url = baseUrl
|
||||||
|
if (wsUrl !== undefined) fields.ws_url = wsUrl
|
||||||
|
if (token) fields.auth_token_enc = secretBox.encrypt(token)
|
||||||
|
if (protocol !== undefined) fields.protocol = protocol
|
||||||
|
if (enabled !== undefined) fields.enabled = enabled ? 1 : 0
|
||||||
|
if (updatedBy !== undefined) fields.updated_by = updatedBy
|
||||||
|
const row = await db.upsert(fields)
|
||||||
|
return toSafe(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror the sidecar's last-reported connection state into the DB so the admin
|
||||||
|
// panel has something to show between polls and the public status endpoint can
|
||||||
|
// read it without a live round-trip.
|
||||||
|
async function recordStatus({ status, statusDetail, pluginConnected, lastEventAt, bootId }) {
|
||||||
|
const fields = {}
|
||||||
|
if (status !== undefined) fields.status = status
|
||||||
|
if (statusDetail !== undefined) fields.status_detail = statusDetail
|
||||||
|
if (pluginConnected !== undefined) fields.plugin_connected = pluginConnected ? 1 : 0
|
||||||
|
// lastEventAt may arrive as an ISO string (e.g. "2026-07-10T22:08:27Z"); the
|
||||||
|
// mariadb DATETIME parser rejects the "T"/"Z", so hand it a real Date (same
|
||||||
|
// fix as botConfig.recordStatus's last_connected_at).
|
||||||
|
if (lastEventAt !== undefined) fields.last_event_at = lastEventAt ? new Date(lastEventAt) : null
|
||||||
|
if (bootId !== undefined) fields.boot_id = bootId
|
||||||
|
if (Object.keys(fields).length === 0) return getSafe()
|
||||||
|
const row = await db.upsert(fields)
|
||||||
|
return toSafe(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getSafe, getWithToken, save, recordStatus }
|
||||||
125
server/src/utils/uoLinkClient.js
Normal file
125
server/src/utils/uoLinkClient.js
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
// ── 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,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user