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:
2026-07-11 02:02:40 -05:00
parent d49008e9f2
commit ab647756f0
5 changed files with 280 additions and 0 deletions

View 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 }

View 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 }