From ab647756f0a7bd2f068139fae91ebd9d5ad8f0f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 02:02:40 -0500 Subject: [PATCH 01/12] Add uo-link sidecar foundation: config store + REST client (phase 0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3 --- .env.example | 12 ++ server/db/schema.sql | 30 +++++ .../src/model/uoLinkConfig/uoLinkConfig.db.js | 28 ++++ .../model/uoLinkConfig/uoLinkConfig.model.js | 85 ++++++++++++ server/src/utils/uoLinkClient.js | 125 ++++++++++++++++++ 5 files changed, 280 insertions(+) create mode 100644 server/src/model/uoLinkConfig/uoLinkConfig.db.js create mode 100644 server/src/model/uoLinkConfig/uoLinkConfig.model.js create mode 100644 server/src/utils/uoLinkClient.js diff --git a/.env.example b/.env.example index 3a711a0..49682ec 100644 --- a/.env.example +++ b/.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. BOT_INTERNAL_URL=http://bot:4100 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 diff --git a/server/db/schema.sql b/server/db/schema.sql index 504260e..8563126 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -259,6 +259,36 @@ CREATE TABLE IF NOT EXISTS email_config ( CONSTRAINT chk_email_config_singleton CHECK (id = 1) ) 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 -- 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 diff --git a/server/src/model/uoLinkConfig/uoLinkConfig.db.js b/server/src/model/uoLinkConfig/uoLinkConfig.db.js new file mode 100644 index 0000000..63f66be --- /dev/null +++ b/server/src/model/uoLinkConfig/uoLinkConfig.db.js @@ -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 } diff --git a/server/src/model/uoLinkConfig/uoLinkConfig.model.js b/server/src/model/uoLinkConfig/uoLinkConfig.model.js new file mode 100644 index 0000000..5b3736e --- /dev/null +++ b/server/src/model/uoLinkConfig/uoLinkConfig.model.js @@ -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 } diff --git a/server/src/utils/uoLinkClient.js b/server/src/utils/uoLinkClient.js new file mode 100644 index 0000000..b925ace --- /dev/null +++ b/server/src/utils/uoLinkClient.js @@ -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 ` 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)}`) + +// ── 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, +} From 9d9f5aac28ed1b263c5427646884967eb0d89d07 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 02:08:56 -0500 Subject: [PATCH 02/12] Add uo-link WS ingest, storage tables and SSE broadcaster (phase 1) The site now ingests the sidecar's live WebSocket feed and persists it to its own MariaDB, and re-broadcasts curated events to browsers over SSE. - schema: shard_events (append-only notable-kind log, sha1 dedupe_key + INSERT IGNORE for idempotent reconnect backfill), shard_online (current players, upsert/refresh/remove), shard_economy (gold-supply series), shard_houses (per-house decay stage + derived is_idoc). - model/shardEvents + model/shardState: the .db.js/.model.js split; writes take camelCase event data, reads are shaped; online upsert uses COALESCE so a partial char.vitals refresh never blanks login fields. - utils/shardIngest: single dispatcher routing each kind to state writes and/or the event log, then the broadcaster. High-frequency kinds (char.vitals, economy.supply) update state only. A changed server.hello bootId clears the stale online roster. Deps are injected for unit testing. - utils/uoLinkSocket: the server's first outbound WS client (ws dep). Verifies the ws.hello protocol, backfills via /history + /economy on every (re)connect (dedupe handles overlap), reconnects with capped backoff, and mirrors connection state into uo_link_config. Self-guards: only connects when the integration is enabled with a token. - utils/shardBroadcast: SSE fan-out with public (safe kinds only) and admin (all) channels, keepalive pings, per-client cleanup. - server.js: start the ingest socket on boot (no-op until configured) and stop it + close SSE streams on graceful shutdown. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3 --- server/db/schema.sql | 79 +++++++ server/package-lock.json | 24 ++- server/package.json | 3 +- .../src/model/shardEvents/shardEvents.db.js | 31 +++ .../model/shardEvents/shardEvents.model.js | 52 +++++ server/src/model/shardState/shardState.db.js | 83 ++++++++ .../src/model/shardState/shardState.model.js | 141 +++++++++++++ server/src/server.js | 14 ++ server/src/utils/shardBroadcast.js | 116 +++++++++++ server/src/utils/shardIngest.js | 187 +++++++++++++++++ server/src/utils/uoLinkSocket.js | 195 ++++++++++++++++++ 11 files changed, 923 insertions(+), 2 deletions(-) create mode 100644 server/src/model/shardEvents/shardEvents.db.js create mode 100644 server/src/model/shardEvents/shardEvents.model.js create mode 100644 server/src/model/shardState/shardState.db.js create mode 100644 server/src/model/shardState/shardState.model.js create mode 100644 server/src/utils/shardBroadcast.js create mode 100644 server/src/utils/shardIngest.js create mode 100644 server/src/utils/uoLinkSocket.js diff --git a/server/db/schema.sql b/server/db/schema.sql index 8563126..992f43b 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -289,6 +289,85 @@ CREATE TABLE IF NOT EXISTS uo_link_config ( CONSTRAINT chk_uo_link_config_singleton CHECK (id = 1) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- Append-only log of notable shard events ingested from the uo-link WebSocket +-- feed. The site OWNS this data (it does not query the sidecar's SQLite): the WS +-- client writes here, and the public/admin read endpoints + live feeds read from +-- here. Only "notable" kinds are logged (sales, deaths, murders, mob.killed, +-- IDOC transitions, quests, skill.gain, fame/karma, audit.*, cheat.*, link.*, +-- server.*). High-frequency kinds (char.vitals, economy.supply) are NOT logged +-- here — they update shard_online / shard_economy instead, keeping the log lean. +-- dedupe_key = sha1(kind + t + stable-json(payload)); with the UNIQUE index it +-- makes INSERT IGNORE idempotent so WS-reconnect backfill never double-inserts. +CREATE TABLE IF NOT EXISTS shard_events ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + kind VARCHAR(48) NOT NULL, + t BIGINT NOT NULL, -- event time, epoch ms (from the sidecar) + boot_id VARCHAR(64) NULL, -- shard boot id at ingest (server.hello.bootId) + payload JSON NOT NULL, -- the full event object + dedupe_key CHAR(40) NOT NULL UNIQUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_shard_events_kind_t (kind, t), + INDEX idx_shard_events_t (t) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Current online players. Upserted on mob.login, refreshed on char.vitals, and +-- removed on mob.logout. Cleared wholesale when the shard restarts (a new +-- server.hello.bootId). web_id is the linked website user id (present when the +-- account is linked), so the roster can be correlated to site accounts. +CREATE TABLE IF NOT EXISTS shard_online ( + serial VARCHAR(20) NOT NULL PRIMARY KEY, -- mobile serial (opaque hex key) + name VARCHAR(120) NULL, + acct VARCHAR(120) NULL, + web_id INT NULL, + map VARCHAR(40) NULL, + x INT NULL, + y INT NULL, + z INT NULL, + hits INT NULL, + hits_max INT NULL, + mana INT NULL, + mana_max INT NULL, + stam INT NULL, + stam_max INT NULL, + str INT NULL, + dex INT NULL, + `int` INT NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_shard_online_acct (acct) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Total-gold-supply time series (from the periodic economy.supply event). Kept +-- append-only so the public status page can render a supply-over-time sparkline. +CREATE TABLE IF NOT EXISTS shard_economy ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + accounts INT NULL, -- number of accounts included in the total + gold BIGINT NULL, -- total gold supply across all accounts + t BIGINT NOT NULL, -- sample time, epoch ms + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_shard_economy_t (t) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Current decay stage per house, upserted on house.decay. is_idoc is a derived +-- flag (stage == 'IDOC') so the public "houses in danger" list is a cheap +-- indexed lookup rather than a scan. +CREATE TABLE IF NOT EXISTS shard_houses ( + serial VARCHAR(20) NOT NULL PRIMARY KEY, + stage VARCHAR(24) NULL, -- Somewhat | Fairly | Greatly | IDOC | Collapsed | ... + map VARCHAR(40) NULL, + x INT NULL, + y INT NULL, + z INT NULL, + region VARCHAR(120) NULL, + name VARCHAR(160) NULL, + owner_serial VARCHAR(20) NULL, + owner_acct VARCHAR(120) NULL, + built_on DATETIME NULL, + last_refreshed DATETIME NULL, + is_idoc TINYINT(1) NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_shard_houses_idoc (is_idoc) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- 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 -- writes them. They live in the same physical database as everything else diff --git a/server/package-lock.json b/server/package-lock.json index 8deee00..92df4a2 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -26,7 +26,8 @@ "qrcode": "^1.5.4", "sanitize-html": "^2.17.5", "speakeasy": "^2.0.0", - "swagger-ui-express": "^5.0.1" + "swagger-ui-express": "^5.0.1", + "ws": "^8.21.0" }, "devDependencies": { "nodemon": "^3.1.4", @@ -2386,6 +2387,27 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", diff --git a/server/package.json b/server/package.json index de1b81f..8c5a859 100644 --- a/server/package.json +++ b/server/package.json @@ -36,7 +36,8 @@ "qrcode": "^1.5.4", "sanitize-html": "^2.17.5", "speakeasy": "^2.0.0", - "swagger-ui-express": "^5.0.1" + "swagger-ui-express": "^5.0.1", + "ws": "^8.21.0" }, "devDependencies": { "nodemon": "^3.1.4", diff --git a/server/src/model/shardEvents/shardEvents.db.js b/server/src/model/shardEvents/shardEvents.db.js new file mode 100644 index 0000000..792672a --- /dev/null +++ b/server/src/model/shardEvents/shardEvents.db.js @@ -0,0 +1,31 @@ +const { query } = require('../../utils/db') + +// INSERT IGNORE on the UNIQUE dedupe_key — a re-ingested event (WS-reconnect +// backfill overlap) is silently skipped rather than duplicated. Returns true if +// a new row was actually inserted. +async function insertIgnore({ kind, t, bootId, payload, dedupeKey }) { + const res = await query( + `INSERT IGNORE INTO shard_events (kind, t, boot_id, payload, dedupe_key) + VALUES (?, ?, ?, ?, ?)`, + [kind, t, bootId || null, JSON.stringify(payload), dedupeKey], + ) + return res.affectedRows > 0 +} + +// Recent events, newest first. Optional kind filter; limit is clamped by the model. +async function list({ kind, limit }) { + if (kind) { + return query( + `SELECT id, kind, t, boot_id, payload, created_at + FROM shard_events WHERE kind = ? ORDER BY t DESC LIMIT ?`, + [kind, limit], + ) + } + return query( + `SELECT id, kind, t, boot_id, payload, created_at + FROM shard_events ORDER BY t DESC LIMIT ?`, + [limit], + ) +} + +module.exports = { insertIgnore, list } diff --git a/server/src/model/shardEvents/shardEvents.model.js b/server/src/model/shardEvents/shardEvents.model.js new file mode 100644 index 0000000..27818a0 --- /dev/null +++ b/server/src/model/shardEvents/shardEvents.model.js @@ -0,0 +1,52 @@ +// Append-only shard event log. The WS ingest dispatcher calls append() for the +// notable kinds; the public/admin read endpoints call list(). The DB layer only +// sees an already-computed dedupe_key so INSERT IGNORE is idempotent across +// WS-reconnect backfill. + +const crypto = require('crypto') +const db = require('./shardEvents.db') + +const MAX_LIMIT = 1000 +const DEFAULT_LIMIT = 100 + +// Stable stringify — keys sorted — so the dedupe hash is independent of the +// property order the sidecar happened to serialize with. +function stableStringify(value) { + if (value === null || typeof value !== 'object') return JSON.stringify(value) + if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]` + const keys = Object.keys(value).sort() + return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}` +} + +// dedupe_key = sha1(kind + t + stable-json(payload)). Two identical events (same +// kind, same timestamp, same body) collapse to one row. +function dedupeKey(kind, t, payload) { + return crypto.createHash('sha1').update(`${kind}|${t}|${stableStringify(payload)}`).digest('hex') +} + +// Append one event. Returns true if a new row was inserted (false = deduped). +async function append({ kind, t, bootId, payload }) { + return db.insertIgnore({ kind, t, bootId, payload, dedupeKey: dedupeKey(kind, t, payload) }) +} + +function normalizeLimit(limit) { + const n = Number(limit) + if (!Number.isFinite(n) || n <= 0) return DEFAULT_LIMIT + return Math.min(Math.floor(n), MAX_LIMIT) +} + +// Recent events, newest first. Each row's JSON payload is parsed back to an object. +async function list({ kind, limit } = {}) { + const rows = await db.list({ kind, limit: normalizeLimit(limit) }) + return rows.map((row) => ({ + id: row.id, + kind: row.kind, + t: row.t, + bootId: row.boot_id || null, + // mariadb returns JSON columns as strings on some versions; parse defensively. + payload: typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload, + createdAt: row.created_at, + })) +} + +module.exports = { append, list, dedupeKey } diff --git a/server/src/model/shardState/shardState.db.js b/server/src/model/shardState/shardState.db.js new file mode 100644 index 0000000..0e35276 --- /dev/null +++ b/server/src/model/shardState/shardState.db.js @@ -0,0 +1,83 @@ +const { query } = require('../../utils/db') + +// ── Online players ───────────────────────────────────────────────────────── +const ONLINE_COLS = + 'serial, name, acct, web_id, map, x, y, z, hits, hits_max, mana, mana_max, stam, stam_max, str, dex, `int`, updated_at' + +// Upsert one online player. `fields` already prepared by the model (only the +// columns it wants to write); serial is required and is the primary key. +async function upsertOnline(serial, fields) { + const cols = Object.keys(fields) + const allCols = ['serial', ...cols] + const insertCols = allCols.map((c) => `\`${c}\``).join(', ') + const placeholders = allCols.map(() => '?').join(', ') + // Never overwrite an existing column with NULL on refresh (a char.vitals frame + // that omits acct/name shouldn't blank what mob.login set) — COALESCE keeps the + // prior value when the incoming one is NULL. + const updates = cols.map((c) => `\`${c}\` = COALESCE(VALUES(\`${c}\`), \`${c}\`)`).join(', ') + await query( + `INSERT INTO shard_online (${insertCols}) VALUES (${placeholders}) + ON DUPLICATE KEY UPDATE ${updates}`, + [serial, ...cols.map((c) => fields[c])], + ) +} + +const removeOnline = (serial) => query('DELETE FROM shard_online WHERE serial = ?', [serial]) +const clearOnline = () => query('DELETE FROM shard_online') + +async function countOnline() { + const rows = await query('SELECT COUNT(*) AS n FROM shard_online') + return rows[0] ? Number(rows[0].n) : 0 +} + +const listOnline = () => + query(`SELECT ${ONLINE_COLS} FROM shard_online ORDER BY name ASC`) + +// ── Economy supply series ──────────────────────────────────────────────── +const insertEconomy = ({ accounts, gold, t }) => + query('INSERT INTO shard_economy (accounts, gold, t) VALUES (?, ?, ?)', [ + accounts ?? null, + gold ?? null, + t, + ]) + +const listEconomy = (limit) => + query('SELECT accounts, gold, t FROM shard_economy ORDER BY t DESC LIMIT ?', [limit]) + +async function latestEconomy() { + const rows = await query('SELECT accounts, gold, t FROM shard_economy ORDER BY t DESC LIMIT 1') + return rows[0] || null +} + +// ── Houses / IDOC ──────────────────────────────────────────────────────── +const HOUSE_COLS = + 'serial, stage, map, x, y, z, region, name, owner_serial, owner_acct, built_on, last_refreshed, is_idoc, updated_at' + +async function upsertHouse(serial, fields) { + const cols = Object.keys(fields) + const allCols = ['serial', ...cols] + const insertCols = allCols.map((c) => `\`${c}\``).join(', ') + const placeholders = allCols.map(() => '?').join(', ') + const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ') + await query( + `INSERT INTO shard_houses (${insertCols}) VALUES (${placeholders}) + ON DUPLICATE KEY UPDATE ${updates}`, + [serial, ...cols.map((c) => fields[c])], + ) +} + +const listIdocHouses = () => + query(`SELECT ${HOUSE_COLS} FROM shard_houses WHERE is_idoc = 1 ORDER BY updated_at DESC`) + +module.exports = { + upsertOnline, + removeOnline, + clearOnline, + countOnline, + listOnline, + insertEconomy, + listEconomy, + latestEconomy, + upsertHouse, + listIdocHouses, +} diff --git a/server/src/model/shardState/shardState.model.js b/server/src/model/shardState/shardState.model.js new file mode 100644 index 0000000..bee0b96 --- /dev/null +++ b/server/src/model/shardState/shardState.model.js @@ -0,0 +1,141 @@ +// Live shard state derived from the WS feed: who is online, the gold-supply +// series, and per-house decay stage. The ingest dispatcher calls the write +// methods; the public read endpoints call the list/count methods. Writes take +// camelCase semantic objects and map to the snake_case columns; only the keys +// present are written (so a char.vitals refresh doesn't clobber login fields). + +const db = require('./shardState.db') + +const MAX_ECONOMY = 1000 + +// Map a camelCase online descriptor to DB columns, dropping undefined keys so a +// partial refresh only touches the fields it carries. +function onlineFields(data) { + const map = { + name: data.name, + acct: data.acct, + web_id: data.webId, + map: data.map, + x: data.x, + y: data.y, + z: data.z, + hits: data.hits, + hits_max: data.hitsMax, + mana: data.mana, + mana_max: data.manaMax, + stam: data.stam, + stam_max: data.stamMax, + str: data.str, + dex: data.dex, + int: data.int, + } + const fields = {} + for (const [k, v] of Object.entries(map)) if (v !== undefined) fields[k] = v + return fields +} + +// Upsert an online player (mob.login) or refresh their vitals (char.vitals). +async function upsertOnline(data) { + if (!data || !data.serial) return + await db.upsertOnline(data.serial, onlineFields(data)) +} + +const setOffline = (serial) => db.removeOnline(serial) +const clearOnline = () => db.clearOnline() +const onlineCount = () => db.countOnline() + +async function listOnline() { + const rows = await db.listOnline() + return rows.map((r) => ({ + serial: r.serial, + name: r.name, + acct: r.acct, + webId: r.web_id, + map: r.map, + x: r.x, + y: r.y, + z: r.z, + hits: r.hits, + hitsMax: r.hits_max, + mana: r.mana, + manaMax: r.mana_max, + stam: r.stam, + stamMax: r.stam_max, + str: r.str, + dex: r.dex, + int: r.int, + updatedAt: r.updated_at, + })) +} + +// Append a gold-supply sample (economy.supply). +async function addEconomySample({ accounts, gold, t }) { + await db.insertEconomy({ accounts, gold, t }) +} + +async function listEconomy(limit = 100) { + const n = Math.min(Math.max(Number(limit) || 100, 1), MAX_ECONOMY) + const rows = await db.listEconomy(n) + // Return oldest → newest for charting. + return rows + .map((r) => ({ accounts: r.accounts, gold: r.gold == null ? null : Number(r.gold), t: r.t })) + .reverse() +} + +async function latestEconomy() { + const r = await db.latestEconomy() + return r ? { accounts: r.accounts, gold: r.gold == null ? null : Number(r.gold), t: r.t } : null +} + +// Upsert a house's decay stage (house.decay). is_idoc is derived from the stage. +async function upsertHouse(data) { + if (!data || !data.serial) return + const fields = { + stage: data.stage ?? null, + map: data.map ?? null, + x: data.x ?? null, + y: data.y ?? null, + z: data.z ?? null, + region: data.region ?? null, + name: data.name ?? null, + owner_serial: data.ownerSerial ?? null, + owner_acct: data.ownerAcct ?? null, + built_on: data.builtOn ? new Date(data.builtOn) : null, + last_refreshed: data.lastRefreshed ? new Date(data.lastRefreshed) : null, + is_idoc: String(data.stage).toUpperCase() === 'IDOC' ? 1 : 0, + } + await db.upsertHouse(data.serial, fields) +} + +async function listIdoc() { + const rows = await db.listIdocHouses() + return rows.map((r) => ({ + serial: r.serial, + stage: r.stage, + map: r.map, + x: r.x, + y: r.y, + z: r.z, + region: r.region, + name: r.name, + ownerSerial: r.owner_serial, + ownerAcct: r.owner_acct, + builtOn: r.built_on, + lastRefreshed: r.last_refreshed, + isIdoc: Boolean(r.is_idoc), + updatedAt: r.updated_at, + })) +} + +module.exports = { + upsertOnline, + setOffline, + clearOnline, + onlineCount, + listOnline, + addEconomySample, + listEconomy, + latestEconomy, + upsertHouse, + listIdoc, +} diff --git a/server/src/server.js b/server/src/server.js index 8ad8e81..12cd45c 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -4,6 +4,8 @@ const http = require('http') const app = require('./app') const internalApp = require('./internalApp') const botScore = require('./middleware/botScore') +const uoLinkSocket = require('./utils/uoLinkSocket') +const shardBroadcast = require('./utils/shardBroadcast') const { ensureSchema, close } = require('./utils/db') const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed') const settings = require('./model/settings/settings.model') @@ -77,6 +79,16 @@ async function start() { log.info(`internal API listening on http://${HOST}:${INTERNAL_PORT} (server<->bot only — do NOT proxy)`) }) + // Start the uo-link WebSocket ingest client. Self-guards: it only actually + // connects when the admin has enabled the integration and saved a token, so + // this is a no-op on shards that haven't configured the sidecar. Never let a + // sidecar problem block server startup. + try { + await uoLinkSocket.start() + } catch (err) { + log.warn('uo-link socket failed to start (continuing)', { error: err.message }) + } + setupShutdown(server, internalServer) } @@ -87,6 +99,8 @@ function setupShutdown(server, internalServer) { closing = true log.warn(`${signal} received — shutting down gracefully`) botScore.stopSweeper() // stop the bot-store cleanup interval + uoLinkSocket.stop() // close the uo-link WS ingest client + shardBroadcast.closeAll() // end any open shard live-feed SSE streams server.close(() => log.info('http server closed')) if (internalServer) internalServer.close(() => log.info('internal http server closed')) try { diff --git a/server/src/utils/shardBroadcast.js b/server/src/utils/shardBroadcast.js new file mode 100644 index 0000000..13f740c --- /dev/null +++ b/server/src/utils/shardBroadcast.js @@ -0,0 +1,116 @@ +// ── Shard live-feed SSE broadcaster ──────────────────────────────────────── +// +// The browser can't talk to the sidecar's WebSocket directly (the token must +// never reach it, and the WS may be on another host). Instead the server ingests +// the WS feed and re-broadcasts curated events to browsers over Server-Sent +// Events (plain HTTP — works through any reverse proxy). +// +// Two channels: +// • public — safe kinds only (sales, deaths, IDOC, logins, economy). No IPs, +// no account-login attempts, no staff audit / cheat events. +// • admin — everything, including the sensitive kinds above. +// +// shardIngest calls broadcast(event) for each ingested event; the public/admin +// SSE route handlers call subscribe(req, res, channel). + +const log = require('./logger')('shard-broadcast') + +// Kinds safe to expose to unauthenticated browsers. +const PUBLIC_KINDS = new Set([ + 'vendor.sale', + 'player.death', + 'player.murdered', + 'mob.killed', + 'house.decay', + 'quest.complete', + 'skill.gain', + 'fame.change', + 'karma.change', + 'mob.login', + 'mob.logout', + 'economy.supply', + 'server.hello', + 'server.shutdown', + 'server.crashed', +]) + +// Open response streams per channel. +const clients = { public: new Set(), admin: new Set() } + +const KEEPALIVE_MS = 25000 + +// Register an SSE stream on a channel. Sets the SSE headers, sends an initial +// comment, keeps the connection warm with periodic pings, and cleans up on close. +function subscribe(req, res, channel) { + const bucket = clients[channel] + if (!bucket) { + res.status(400).end() + return + } + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', // disable proxy buffering so events flush immediately + }) + res.write('retry: 5000\n\n') // tell EventSource to reconnect after 5s if dropped + res.write(': connected\n\n') + + bucket.add(res) + + const ping = setInterval(() => { + try { + res.write(': ping\n\n') + } catch { + /* write after close — cleanup below handles it */ + } + }, KEEPALIVE_MS) + + const cleanup = () => { + clearInterval(ping) + bucket.delete(res) + } + req.on('close', cleanup) + res.on('error', cleanup) +} + +function writeTo(bucket, payload) { + for (const res of bucket) { + try { + res.write(payload) + } catch (err) { + log.warn('sse write failed; dropping client', { message: err.message }) + bucket.delete(res) + } + } +} + +// Fan an ingested event out to the admin channel (always) and the public +// channel (safe kinds only). A no-op when nobody is subscribed. +function broadcast(event) { + if (!event || !event.kind) return + const frame = `data: ${JSON.stringify(event)}\n\n` + if (clients.admin.size) writeTo(clients.admin, frame) + if (clients.public.size && PUBLIC_KINDS.has(event.kind)) writeTo(clients.public, frame) +} + +// Close every open stream (graceful shutdown). +function closeAll() { + for (const channel of Object.values(clients)) { + for (const res of channel) { + try { + res.end() + } catch { + /* ignore */ + } + } + channel.clear() + } +} + +function stats() { + return { publicClients: clients.public.size, adminClients: clients.admin.size } +} + +module.exports = { subscribe, broadcast, closeAll, stats, PUBLIC_KINDS } diff --git a/server/src/utils/shardIngest.js b/server/src/utils/shardIngest.js new file mode 100644 index 0000000..8b82c4a --- /dev/null +++ b/server/src/utils/shardIngest.js @@ -0,0 +1,187 @@ +// ── Shard event ingest dispatcher ────────────────────────────────────────── +// +// The single entry point for every event that arrives on the uo-link WebSocket +// feed (and for backfilled /history events on reconnect). It routes by kind: +// • state-changing kinds update shard_online / shard_economy / shard_houses, +// • notable kinds are appended to the append-only shard_events log, +// • every kind is fanned out to the SSE broadcaster (which decides public vs +// admin visibility). +// High-frequency kinds (char.vitals, economy.supply) are deliberately NOT logged +// to shard_events — they only update state — keeping the event log lean. +// +// Dependencies are injected (defaulting to the real models) so the routing can +// be unit-tested with mocked writes. + +const shardEventsModel = require('../model/shardEvents/shardEvents.model') +const shardStateModel = require('../model/shardState/shardState.model') +const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model') +const broadcaster = require('./shardBroadcast') +const defaultLog = require('./logger')('shard-ingest') + +// Notable kinds appended to the shard_events log. High-frequency/session kinds +// (char.vitals, economy.supply, mob.login/logout, account.login.attempt, +// gold.change, vendor.buy/sell) are excluded on purpose. house.decay is handled +// specially — logged only on the transition INTO IDOC. +const LOGGED_KINDS = new Set([ + 'vendor.sale', + 'player.death', + 'player.murdered', + 'mob.killed', + 'quest.complete', + 'skill.gain', + 'fame.change', + 'karma.change', + 'audit.set', + 'audit.command', + 'cheat.fastwalk', + 'link.request', + 'server.hello', + 'server.shutdown', + 'server.crashed', +]) + +// Tracks the current shard boot id so a restart (changed bootId on server.hello) +// can be detected and stale online state dropped. Module-level so it survives +// across events within a process; reset() is exposed for tests. +const state = { bootId: null } +function reset() { + state.bootId = null +} + +// Should this event be written to the append-only log? +function shouldLog(event) { + if (event.kind === 'house.decay') return String(event.to).toUpperCase() === 'IDOC' + return LOGGED_KINDS.has(event.kind) +} + +// Apply the state-change side effect for a kind (if any). Returns a promise. +async function applyStateChange(event, deps) { + const { shardState, uoLinkConfig, log } = deps + switch (event.kind) { + case 'server.hello': { + const incoming = event.bootId || null + if (incoming && state.bootId && incoming !== state.bootId) { + log.warn('shard restarted (bootId changed) — clearing online roster', { + from: state.bootId, + to: incoming, + }) + await shardState.clearOnline() + } + if (incoming) state.bootId = incoming + await uoLinkConfig.recordStatus({ pluginConnected: true, bootId: incoming, lastEventAt: event.t }) + return + } + case 'server.shutdown': + case 'server.crashed': + // Shard is going away — nobody is online anymore. + await shardState.clearOnline() + await uoLinkConfig.recordStatus({ pluginConnected: false }) + return + case 'mob.login': { + const who = event.who || {} + await shardState.upsertOnline({ + serial: who.serial, + name: who.name, + acct: who.acct, + webId: event.webId, + map: event.map, + x: event.x, + y: event.y, + z: event.z, + }) + return + } + case 'mob.logout': { + const who = event.who || {} + if (who.serial) await shardState.setOffline(who.serial) + return + } + case 'char.vitals': + await shardState.upsertOnline({ + serial: event.serial, + hits: event.hits, + hitsMax: event.hitsMax, + mana: event.mana, + manaMax: event.manaMax, + stam: event.stam, + stamMax: event.stamMax, + str: event.str, + dex: event.dex, + int: event.int, + map: event.map, + x: event.x, + y: event.y, + }) + return + case 'economy.supply': + await shardState.addEconomySample({ accounts: event.accounts, gold: event.gold, t: event.t }) + return + case 'house.decay': + await shardState.upsertHouse({ + serial: event.serial, + stage: event.to, + map: event.map, + x: event.x, + y: event.y, + z: event.z, + region: event.region, + name: event.name, + ownerSerial: event.ownerSerial, + ownerAcct: event.ownerAcct, + builtOn: event.builtOn, + lastRefreshed: event.lastRefreshed, + }) + return + default: + // No state side effect (e.g. vendor.sale, audit.*, cheat.*) — logging and + // broadcasting still happen in ingest(). + } +} + +// Ingest one event. Returns { logged, stored } for tests/stats. `fromBackfill` +// suppresses the SSE broadcast (a reconnect replay shouldn't re-animate the +// live ticker). Never throws — a bad single event must not kill the feed. +async function ingest(event, deps = {}) { + const d = { + shardEvents: deps.shardEvents || shardEventsModel, + shardState: deps.shardState || shardStateModel, + uoLinkConfig: deps.uoLinkConfig || uoLinkConfigModel, + broadcast: deps.broadcast || broadcaster.broadcast, + log: deps.log || defaultLog, + } + + if (!event || typeof event.kind !== 'string') return { logged: false, stored: false } + // ws.hello / pong are transport frames, not game events. + if (event.kind === 'ws.hello' || event.kind === 'pong') return { logged: false, stored: false } + + const t = Number.isFinite(event.t) ? event.t : Date.now() + let stored = false + let logged = false + + try { + await applyStateChange(event, d) + } catch (err) { + d.log.warn('state-change write failed', { kind: event.kind, message: err.message }) + } + + if (shouldLog(event)) { + logged = true + try { + stored = await d.shardEvents.append({ kind: event.kind, t, bootId: state.bootId, payload: event }) + } catch (err) { + d.log.warn('event log write failed', { kind: event.kind, message: err.message }) + } + } + + if (!deps.fromBackfill) { + try { + d.broadcast(event) + } catch (err) { + d.log.warn('broadcast failed', { kind: event.kind, message: err.message }) + } + } + + return { logged, stored } +} + +module.exports = { ingest, shouldLog, reset, LOGGED_KINDS, state } diff --git a/server/src/utils/uoLinkSocket.js b/server/src/utils/uoLinkSocket.js new file mode 100644 index 0000000..e82370e --- /dev/null +++ b/server/src/utils/uoLinkSocket.js @@ -0,0 +1,195 @@ +// ── uo-link WebSocket ingest client ──────────────────────────────────────── +// +// Long-lived client that connects to the sidecar's push-only WS feed and pumps +// every frame through the ingest dispatcher. This is the server's first +// outbound WebSocket. Lifecycle: +// • start() — connect if the config is enabled and has a token; verify the +// ws.hello protocol; backfill missed events via /history on every +// (re)connect (INSERT IGNORE dedupes the overlap); reconnect with +// capped backoff. +// • stop() — close the socket and stop reconnecting (graceful shutdown). +// Connection state is mirrored into uo_link_config (plugin_connected / status / +// last_event_at) so the admin panel and public status endpoint have live data. + +const WebSocket = require('ws') + +const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model') +const uoLinkClient = require('./uoLinkClient') +const shardIngest = require('./shardIngest') +const log = require('./logger')('uo-link-socket') + +const BACKOFF_MIN_MS = 1000 +const BACKOFF_MAX_MS = 30000 +const BACKFILL_LIMIT = 500 + +let ws = null +let reconnectTimer = null +let backoff = BACKOFF_MIN_MS +let running = false // set by start()/stop(); guards auto-reconnect +let helloSeen = false + +const state = { + connected: false, + lastEventAt: null, + lastConnectedAt: null, + reconnects: 0, + protocol: null, +} + +function buildUrl(wsUrl, token) { + const sep = wsUrl.includes('?') ? '&' : '?' + return token ? `${wsUrl}${sep}token=${encodeURIComponent(token)}` : wsUrl +} + +// Pull recent events from the sidecar's own store and replay them through the +// dispatcher (fromBackfill = no SSE re-broadcast). dedupe_key + INSERT IGNORE +// make this idempotent, so overlap with what we already stored is harmless. +async function backfill() { + try { + const hist = await uoLinkClient.getHistory({ limit: BACKFILL_LIMIT }) + if (hist.ok && hist.data && Array.isArray(hist.data.events)) { + // History is newest-first; replay oldest-first so latest-wins state (e.g. + // house.decay stage) settles correctly. + const events = [...hist.data.events].reverse() + for (const ev of events) await shardIngest.ingest(ev, { fromBackfill: true }) + log.info('backfilled events from /history', { count: events.length }) + } + const eco = await uoLinkClient.getEconomy(200) + if (eco.ok && eco.data && Array.isArray(eco.data.series)) { + const series = [...eco.data.series].reverse() + for (const ev of series) await shardIngest.ingest(ev, { fromBackfill: true }) + } + } catch (err) { + log.warn('backfill failed (continuing on live feed)', { message: err.message }) + } +} + +function scheduleReconnect() { + if (!running) return + clearTimeout(reconnectTimer) + reconnectTimer = setTimeout(connect, backoff) + log.info(`reconnecting in ${backoff}ms`) + backoff = Math.min(backoff * 2, BACKOFF_MAX_MS) +} + +async function connect() { + if (!running) return + let config + try { + config = await uoLinkConfig.getWithToken() + } catch (err) { + log.error('could not read uo-link config', err) + scheduleReconnect() + return + } + if (!config || !config.enabled || !config.wsUrl || !config.token) { + log.info('uo-link WS not started (disabled or missing url/token)') + running = false + return + } + + state.protocol = config.protocol || 1 + helloSeen = false + const url = buildUrl(config.wsUrl, config.token) + + try { + ws = new WebSocket(url) + } catch (err) { + log.error('failed to open WS', err) + scheduleReconnect() + return + } + + ws.on('open', async () => { + log.info('uo-link WS connected') + state.connected = true + state.lastConnectedAt = Date.now() + backoff = BACKOFF_MIN_MS + await uoLinkConfig.recordStatus({ status: 'connected', statusDetail: null, pluginConnected: true }).catch(() => {}) + await backfill() + }) + + ws.on('message', async (raw) => { + let event + try { + event = JSON.parse(raw.toString()) + } catch { + log.warn('dropping non-JSON WS frame') + return + } + + if (event.kind === 'ws.hello') { + helloSeen = true + if (event.protocol && event.protocol !== state.protocol) { + log.error('uo-link protocol mismatch on ws.hello — closing', { + expected: state.protocol, + got: event.protocol, + }) + await uoLinkConfig + .recordStatus({ status: 'error', statusDetail: `protocol mismatch: expected ${state.protocol}, got ${event.protocol}` }) + .catch(() => {}) + running = false + try { + ws.close() + } catch { + /* ignore */ + } + } + return + } + if (event.kind === 'pong') return // sidecar heartbeat — ignore + + state.lastEventAt = Number.isFinite(event.t) ? event.t : Date.now() + await shardIngest.ingest(event) + }) + + ws.on('close', async () => { + state.connected = false + if (running) state.reconnects += 1 + log.warn('uo-link WS closed') + await uoLinkConfig + .recordStatus({ status: running ? 'reconnecting' : 'disconnected', pluginConnected: false }) + .catch(() => {}) + ws = null + scheduleReconnect() + }) + + ws.on('error', (err) => { + log.warn('uo-link WS error', { message: err.message }) + // 'close' fires after 'error'; reconnect is scheduled there. + }) +} + +// Begin (or restart) the WS client. Idempotent — a running client is stopped +// first so a config save can re-point it at a new URL/token. +async function start() { + stop() + running = true + backoff = BACKOFF_MIN_MS + await connect() +} + +// Stop the client and cancel any pending reconnect. Called on shutdown and +// before a restart. +function stop() { + running = false + clearTimeout(reconnectTimer) + reconnectTimer = null + if (ws) { + try { + ws.removeAllListeners() + ws.close() + } catch { + /* ignore */ + } + ws = null + } + state.connected = false +} + +// Ingestion stats for the admin panel. +function getState() { + return { ...state, running } +} + +module.exports = { start, stop, getState } From 523113f013dc804f1d46258a7b0d4be5a436ddb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 02:11:28 -0500 Subject: [PATCH 03/12] Add public shard read endpoints + live SSE stream (phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Curated, same-origin, token-free reads so the browser never sees the sidecar URL or token: - public/shard.controller.js: - GET /public/shard/status — connection state + online count + latest economy (from the site's ingested data). - GET /public/shard/feed?kind=&limit= — recent notable events from the log. - GET /public/shard/economy — gold-supply series (oldest → newest). - GET /public/shard/idoc — houses currently at IDOC. - GET /public/shard/char/:serial — live sheet round-trip via uoLinkClient, briefly cached; 503 (shard restarting) serves a stale cache or a retry banner rather than an error. - GET /public/shard/stream — public SSE channel (safe kinds only). - Wired into public.routes.js with express-validator guards and #swagger annotations; new "Public · Shard" tag + ShardStatus/ShardEvent/ ShardEconomyPoint/ShardHouse schemas; swagger-output.json regenerated. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3 --- server/src/router/v1/public/public.routes.js | 65 +- .../src/router/v1/public/shard.controller.js | 121 +++ server/swagger/swagger-output.json | 724 ++++++++++++++++++ server/swagger/swagger.js | 56 ++ 4 files changed, 965 insertions(+), 1 deletion(-) create mode 100644 server/src/router/v1/public/shard.controller.js diff --git a/server/src/router/v1/public/public.routes.js b/server/src/router/v1/public/public.routes.js index 36103a0..f741838 100644 --- a/server/src/router/v1/public/public.routes.js +++ b/server/src/router/v1/public/public.routes.js @@ -1,7 +1,8 @@ const express = require('express') -const { body } = require('express-validator') +const { body, param, query } = require('express-validator') const ctrl = require('./public.controller') +const shard = require('./shard.controller') const siteMode = require('../../../middleware/siteMode') const validate = require('../../../middleware/validate') const { contactLimiter } = require('../../../middleware/rateLimit') @@ -128,4 +129,66 @@ publicRouter.get( ctrl.getPage, ) +// ── Shard live data (uo-link) ────────────────────────────────────────────── +// Token-free, same-origin reads. The status/feed/economy/idoc endpoints read +// the site's own ingested data; /char round-trips the live shard (cached). Not +// site-mode gated — shard status is useful even during site maintenance. +publicRouter.get( + '/shard/status', + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'Shard connection state, online count and latest economy' + /* #swagger.responses[200] = { description: 'Shard status', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardStatus" } } } } */ + shard.getStatus, +) +publicRouter.get( + '/shard/feed', + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'Recent notable shard events (from the ingested log)' + // #swagger.parameters['kind'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Filter to a single event kind, e.g. vendor.sale.' } + // #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max rows (default 100, max 1000).' } + /* #swagger.responses[200] = { description: 'Events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEvent" } } } } } */ + query('kind').optional({ values: 'falsy' }).isString().isLength({ max: 48 }), + query('limit').optional().isInt({ min: 1, max: 1000 }), + validate, + shard.getFeed, +) +publicRouter.get( + '/shard/economy', + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'Gold-supply time series (oldest → newest)' + // #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max samples (default 100, max 1000).' } + /* #swagger.responses[200] = { description: 'Economy samples', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEconomyPoint" } } } } } */ + query('limit').optional().isInt({ min: 1, max: 1000 }), + validate, + shard.getEconomy, +) +publicRouter.get( + '/shard/idoc', + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'Houses currently in danger (IDOC)' + /* #swagger.responses[200] = { description: 'IDOC houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */ + shard.getIdoc, +) +publicRouter.get( + '/shard/char/:serial', + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'Live character sheet by serial (cached; degrades on shard restart)' + // #swagger.parameters['serial'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Mobile serial, e.g. 0x24C.' } + /* #swagger.responses[200] = { description: 'Character profile', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[400] = { description: 'Invalid serial', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'Character not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[503] = { description: 'Shard restarting — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('serial').matches(/^0x[0-9a-fA-F]+$/), + validate, + shard.getChar, +) +publicRouter.get( + '/shard/stream', + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'Live shard event stream (Server-Sent Events, public/safe kinds)' + // #swagger.description = 'text/event-stream of curated live events. Sensitive kinds (staff audit, cheat detection, login attempts, IPs) are NOT sent on this channel.' + /* #swagger.responses[200] = { description: 'An SSE stream (Content-Type: text/event-stream).' } */ + shard.stream, +) + module.exports = publicRouter diff --git a/server/src/router/v1/public/shard.controller.js b/server/src/router/v1/public/shard.controller.js new file mode 100644 index 0000000..5b09c04 --- /dev/null +++ b/server/src/router/v1/public/shard.controller.js @@ -0,0 +1,121 @@ +// ── Public: shard live data ──────────────────────────────────────────────── +// +// Same-origin, token-free read endpoints backed by the data the WS ingest +// pipeline persists (shard_online / shard_events / shard_economy / shard_houses) +// plus a live character round-trip to the sidecar. The browser never sees the +// sidecar URL or token — every sidecar call is server-side (uoLinkClient). +// +// The stored-data endpoints are cheap DB reads. The live /char endpoint hits the +// running shard, so it is briefly cached and degrades gracefully: a 503 (shard +// restarting) surfaces as a retry-able banner rather than an error. + +const shardEvents = require('../../../model/shardEvents/shardEvents.model') +const shardState = require('../../../model/shardState/shardState.model') +const uoLinkConfig = require('../../../model/uoLinkConfig/uoLinkConfig.model') +const uoLinkClient = require('../../../utils/uoLinkClient') +const broadcast = require('../../../utils/shardBroadcast') + +const log = require('../../../utils/logger')('public-shard') + +// Serials are opaque hex keys like "0x24C" — validate before hitting the sidecar. +const SERIAL_RE = /^0x[0-9a-fA-F]+$/ + +// Tiny in-memory cache for live character sheets (the sidecar warns these hit the +// live shard, so cache them). Keyed by serial; short TTL. +const CHAR_TTL_MS = 20000 +const charCache = new Map() + +// GET /public/shard/status — connection state + online count + latest economy. +async function getStatus(req, res) { + try { + const config = await uoLinkConfig.getSafe() + const [online, economy] = await Promise.all([ + shardState.onlineCount(), + shardState.latestEconomy(), + ]) + return res.json({ + enabled: config.enabled, + status: config.status, + pluginConnected: config.pluginConnected, + lastEventAt: config.lastEventAt, + onlineCount: online, + economy, + }) + } catch (err) { + log.error('shard.getStatus', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/shard/feed?kind=&limit= — recent notable events from the log. +async function getFeed(req, res) { + try { + const { kind, limit } = req.query + const events = await shardEvents.list({ kind, limit }) + return res.json(events) + } catch (err) { + log.error('shard.getFeed', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/shard/economy — gold-supply series, oldest → newest. +async function getEconomy(req, res) { + try { + return res.json(await shardState.listEconomy(req.query.limit)) + } catch (err) { + log.error('shard.getEconomy', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/shard/idoc — houses currently in danger (stage IDOC). +async function getIdoc(req, res) { + try { + return res.json(await shardState.listIdoc()) + } catch (err) { + log.error('shard.getIdoc', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/shard/char/:serial — live character sheet (cached briefly). A 503 +// from the sidecar means the shard is restarting: report it as such so the UI +// can show a retry banner instead of an error. +async function getChar(req, res) { + const { serial } = req.params + if (!SERIAL_RE.test(serial)) { + return res.status(400).json({ message: 'Invalid serial.' }) + } + + const cached = charCache.get(serial) + if (cached && Date.now() - cached.at < CHAR_TTL_MS) { + return res.json(cached.data) + } + + try { + const result = await uoLinkClient.getCharBySerial(serial) + if (result.ok) { + charCache.set(serial, { at: Date.now(), data: result.data }) + return res.json(result.data) + } + if (result.status === 404) return res.status(404).json({ message: 'Character not found.' }) + if (result.status === 503) { + // Serve a stale cache if we have one; otherwise the restart banner. + if (cached) return res.json(cached.data) + return res.status(503).json({ message: 'The game server is restarting — try again shortly.' }) + } + if (result.status === 0) return res.status(503).json({ message: 'Shard data is unavailable right now.' }) + return res.status(502).json({ message: 'Could not reach the shard.' }) + } catch (err) { + log.error('shard.getChar', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/shard/stream — public live-event SSE channel (safe kinds only). +function stream(req, res) { + broadcast.subscribe(req, res, 'public') +} + +module.exports = { getStatus, getFeed, getEconomy, getIdoc, getChar, stream } diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 9e1f6a0..801145f 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -36,6 +36,10 @@ "name": "Public", "description": "Unauthenticated site content (settings, posts, wiki, contact)" }, + { + "name": "Public · Shard", + "description": "Live shard data ingested from the uo-link sidecar (status, feed, economy, IDOC, characters)" + }, { "name": "Admin · Account", "description": "Self-service account security (2FA, linked identities)" @@ -1283,6 +1287,231 @@ } } }, + "/api/v1/public/shard/status": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Shard connection state, online count and latest economy", + "description": "", + "responses": { + "200": { + "description": "Shard status", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShardStatus" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/feed": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Recent notable shard events (from the ingested log)", + "description": "", + "parameters": [ + { + "name": "kind", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Filter to a single event kind, e.g. vendor.sale." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Max rows (default 100, max 1000)." + } + ], + "responses": { + "200": { + "description": "Events, newest first", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShardEvent" + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/economy": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Gold-supply time series (oldest → newest)", + "description": "", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Max samples (default 100, max 1000)." + } + ], + "responses": { + "200": { + "description": "Economy samples", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShardEconomyPoint" + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/idoc": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Houses currently in danger (IDOC)", + "description": "", + "responses": { + "200": { + "description": "IDOC houses", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShardHouse" + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/char/{serial}": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Live character sheet by serial (cached; degrades on shard restart)", + "description": "", + "parameters": [ + { + "name": "serial", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Mobile serial, e.g. 0x24C." + } + ], + "responses": { + "200": { + "description": "Character profile", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Invalid serial", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Character not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + }, + "502": { + "description": "Bad Gateway" + }, + "503": { + "description": "Shard restarting — retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/v1/public/shard/stream": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Live shard event stream (Server-Sent Events, public/safe kinds)", + "description": "text/event-stream of curated live events. Sensitive kinds (staff audit, cheat detection, login attempts, IPs) are NOT sent on this channel.", + "responses": { + "200": { + "description": "An SSE stream (Content-Type: text/event-stream)." + } + } + } + }, "/api/v1/admin/account": { "get": { "tags": [ @@ -9274,6 +9503,501 @@ } } } + }, + "ShardStatus": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Public shard status (GET /public/shard/status)." + }, + "properties": { + "type": "object", + "properties": { + "enabled": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "connected" + }, + "description": { + "type": "string", + "example": "connected | reconnecting | disconnected | error" + } + } + }, + "pluginConnected": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "Is the shard link up right now?" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "lastEventAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "onlineCount": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 12 + } + } + }, + "economy": { + "$ref": "#/components/schemas/ShardEconomyPoint" + } + } + } + } + }, + "ShardEvent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "A logged shard event." + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 4821 + } + } + }, + "kind": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "vendor.sale" + } + } + }, + "t": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "Event time, epoch ms." + }, + "example": { + "type": "number", + "example": 1783720195626 + } + } + }, + "bootId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "boot-abc123" + } + } + }, + "payload": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "additionalProperties": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "The full event object." + } + } + }, + "createdAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + } + } + } + } + }, + "ShardEconomyPoint": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "One gold-supply sample." + }, + "properties": { + "type": "object", + "properties": { + "accounts": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "number", + "example": 240 + } + } + }, + "gold": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "number", + "example": 1028983421 + } + } + }, + "t": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "Sample time, epoch ms." + }, + "example": { + "type": "number", + "example": 1783720000000 + } + } + } + } + } + } + }, + "ShardHouse": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "A house at its current decay stage." + }, + "properties": { + "type": "object", + "properties": { + "serial": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "0x4004705F" + } + } + }, + "stage": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "IDOC" + } + } + }, + "map": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Trammel" + } + } + }, + "x": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "y": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "z": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "region": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "An Unnamed House" + } + } + }, + "ownerSerial": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "ownerAcct": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "builtOn": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "lastRefreshed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "isIdoc": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "updatedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + } + } + } + } } } } diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index 24bd8db..6df598f 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -46,6 +46,7 @@ const doc = { { name: 'Auth · Mobile', description: 'Native bearer-token login, refresh and logout' }, { name: 'Auth · SSO', description: 'OAuth2 / OIDC provider discovery and redirect flow' }, { name: 'Public', description: 'Unauthenticated site content (settings, posts, wiki, contact)' }, + { name: 'Public · Shard', description: 'Live shard data ingested from the uo-link sidecar (status, feed, economy, IDOC, characters)' }, { name: 'Admin · Account', description: 'Self-service account security (2FA, linked identities)' }, { name: 'Player', description: 'Self-service player accounts (register, credentials, 2FA, linked identities)' }, { name: 'Admin · Dashboard', description: 'Dashboard summary and site mode' }, @@ -506,6 +507,61 @@ const doc = { removed: { type: 'boolean', description: 'Whether the IP had an entry that was cleared.', example: true }, }, }, + // ── uo-link shard data ────────────────────────────────────────────── + ShardStatus: { + type: 'object', + description: 'Public shard status (GET /public/shard/status).', + properties: { + enabled: { type: 'boolean', example: true }, + status: { type: 'string', example: 'connected', description: 'connected | reconnecting | disconnected | error' }, + pluginConnected: { type: 'boolean', description: 'Is the shard link up right now?', example: true }, + lastEventAt: { type: 'string', format: 'date-time', nullable: true }, + onlineCount: { type: 'integer', example: 12 }, + economy: { $ref: '#/components/schemas/ShardEconomyPoint' }, + }, + }, + ShardEvent: { + type: 'object', + description: 'A logged shard event.', + properties: { + id: { type: 'integer', example: 4821 }, + kind: { type: 'string', example: 'vendor.sale' }, + t: { type: 'integer', description: 'Event time, epoch ms.', example: 1783720195626 }, + bootId: { type: 'string', nullable: true, example: 'boot-abc123' }, + payload: { type: 'object', additionalProperties: true, description: 'The full event object.' }, + createdAt: { type: 'string', format: 'date-time' }, + }, + }, + ShardEconomyPoint: { + type: 'object', + nullable: true, + description: 'One gold-supply sample.', + properties: { + accounts: { type: 'integer', nullable: true, example: 240 }, + gold: { type: 'integer', nullable: true, example: 1028983421 }, + t: { type: 'integer', description: 'Sample time, epoch ms.', example: 1783720000000 }, + }, + }, + ShardHouse: { + type: 'object', + description: 'A house at its current decay stage.', + properties: { + serial: { type: 'string', example: '0x4004705F' }, + stage: { type: 'string', example: 'IDOC' }, + map: { type: 'string', nullable: true, example: 'Trammel' }, + x: { type: 'integer', nullable: true }, + y: { type: 'integer', nullable: true }, + z: { type: 'integer', nullable: true }, + region: { type: 'string', nullable: true }, + name: { type: 'string', nullable: true, example: 'An Unnamed House' }, + ownerSerial: { type: 'string', nullable: true }, + ownerAcct: { type: 'string', nullable: true }, + builtOn: { type: 'string', format: 'date-time', nullable: true }, + lastRefreshed: { type: 'string', format: 'date-time', nullable: true }, + isIdoc: { type: 'boolean', example: true }, + updatedAt: { type: 'string', format: 'date-time' }, + }, + }, }, }, } From 064f02c4b68fb331f5dbb8de0a1fdb2a403ab840 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 02:13:46 -0500 Subject: [PATCH 04/12] Add player account linking + roster/vendor reads (phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ties an in-game account to a website user and gates reads on ownership. - schema: shard_account_links (account PK → user_id, char_name, linked_at; FK users ON DELETE CASCADE) — the site-side mirror of the sidecar's authoritative link. - model/shardLinks: upsert/list/ownership-check/getByAccount/unlink. - player/shard.controller.js: - POST /player/shard/link — confirm a one-time [link code via uoLinkClient.confirmLink(code, req.user.id); on link.ok mirror the link and activity.log it; bad/expired codes → 400, shard down → 503. - GET /player/shard/accounts — the caller's linked accounts. - GET /player/shard/roster/:account and /vendors/:account — live round-trips, ownership-checked against the mirror (403 otherwise), 503 on shard restart. - player.routes.js: mounted under the existing requireRole('player') gate with express-validator guards + #swagger annotations; new "Player · Shard" tag and ShardLinkRequest/ShardLinkResult/ShardLink schemas; spec regenerated. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3 --- server/db/schema.sql | 15 + server/src/model/shardLinks/shardLinks.db.js | 36 ++ .../src/model/shardLinks/shardLinks.model.js | 34 ++ server/src/router/v1/player/player.routes.js | 54 +++ .../src/router/v1/player/shard.controller.js | 81 ++++ server/swagger/swagger-output.json | 410 ++++++++++++++++++ server/swagger/swagger.js | 25 ++ 7 files changed, 655 insertions(+) create mode 100644 server/src/model/shardLinks/shardLinks.db.js create mode 100644 server/src/model/shardLinks/shardLinks.model.js create mode 100644 server/src/router/v1/player/shard.controller.js diff --git a/server/db/schema.sql b/server/db/schema.sql index 992f43b..e2c61c4 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -368,6 +368,21 @@ CREATE TABLE IF NOT EXISTS shard_houses ( INDEX idx_shard_houses_idoc (is_idoc) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- Site-side mirror of in-game-account → website-user links. The sidecar is the +-- source of truth (it tags the game account with the websiteUserId on +-- /link/confirm); this table mirrors it so the player portal can list a user's +-- linked accounts and enforce ownership on roster/vendor reads without a shard +-- round-trip. account is unique (one game account maps to at most one site user); +-- a single user may link several game accounts. +CREATE TABLE IF NOT EXISTS shard_account_links ( + account VARCHAR(120) NOT NULL PRIMARY KEY, + user_id INT NOT NULL, + char_name VARCHAR(120) NULL, + linked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_shard_links_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_shard_links_user (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- 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 -- writes them. They live in the same physical database as everything else diff --git a/server/src/model/shardLinks/shardLinks.db.js b/server/src/model/shardLinks/shardLinks.db.js new file mode 100644 index 0000000..915b53d --- /dev/null +++ b/server/src/model/shardLinks/shardLinks.db.js @@ -0,0 +1,36 @@ +const { query } = require('../../utils/db') + +const COLS = 'account, user_id, char_name, linked_at' + +// Upsert a link. account is the PK, so a re-link moves the account to the new +// user (the sidecar already treats /link/confirm as authoritative). +async function upsert({ account, userId, charName }) { + await query( + `INSERT INTO shard_account_links (account, user_id, char_name) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), char_name = VALUES(char_name)`, + [account, userId, charName || null], + ) + return getByAccount(account) +} + +async function getByAccount(account) { + const rows = await query(`SELECT ${COLS} FROM shard_account_links WHERE account = ? LIMIT 1`, [account]) + return rows[0] || null +} + +const listByUser = (userId) => + query(`SELECT ${COLS} FROM shard_account_links WHERE user_id = ? ORDER BY linked_at DESC`, [userId]) + +async function isOwnedBy(account, userId) { + const rows = await query( + 'SELECT 1 FROM shard_account_links WHERE account = ? AND user_id = ? LIMIT 1', + [account, userId], + ) + return rows.length > 0 +} + +const remove = (account, userId) => + query('DELETE FROM shard_account_links WHERE account = ? AND user_id = ?', [account, userId]) + +module.exports = { upsert, getByAccount, listByUser, isOwnedBy, remove } diff --git a/server/src/model/shardLinks/shardLinks.model.js b/server/src/model/shardLinks/shardLinks.model.js new file mode 100644 index 0000000..9e1c7fd --- /dev/null +++ b/server/src/model/shardLinks/shardLinks.model.js @@ -0,0 +1,34 @@ +// Site-side mirror of in-game-account → website-user links. The sidecar owns the +// authoritative link (it tags the game account on /link/confirm); this model +// records it locally so the player portal can list links and enforce ownership. + +const db = require('./shardLinks.db') + +function toSafe(row) { + if (!row) return null + return { + account: row.account, + userId: row.user_id, + charName: row.char_name || null, + linkedAt: row.linked_at, + } +} + +async function link({ account, userId, charName }) { + return toSafe(await db.upsert({ account, userId, charName })) +} + +async function listForUser(userId) { + const rows = await db.listByUser(userId) + return rows.map(toSafe) +} + +const ownsAccount = (account, userId) => db.isOwnedBy(account, userId) + +async function getByAccount(account) { + return toSafe(await db.getByAccount(account)) +} + +const unlink = (account, userId) => db.remove(account, userId) + +module.exports = { link, listForUser, ownsAccount, getByAccount, unlink } diff --git a/server/src/router/v1/player/player.routes.js b/server/src/router/v1/player/player.routes.js index 4cfce19..7af0cdb 100644 --- a/server/src/router/v1/player/player.routes.js +++ b/server/src/router/v1/player/player.routes.js @@ -10,6 +10,7 @@ const express = require('express') const { body, param } = require('express-validator') const account = require('../admin/account.controller') +const shard = require('./shard.controller') const { requireAuth, requireRole } = require('../../../auth/session.middleware') const noindex = require('../../../middleware/noindex') const validate = require('../../../middleware/validate') @@ -129,4 +130,57 @@ playerRouter.delete( account.unlinkIdentity, ) +// ── Game account linking (uo-link) ───────────────────────────────────────── +// Link an in-game account with a one-time code from [link, then read the +// account's roster / vendors (ownership-checked against the local link mirror). +const ACCOUNT_RE = /^[A-Za-z0-9_.-]{1,120}$/ +playerRouter.post( + '/shard/link', + // #swagger.tags = ['Player · Shard'] + // #swagger.summary = 'Link an in-game account with a one-time code' + // #swagger.description = 'The player runs [link in game to get a code, then submits it here. The server confirms it with the sidecar and mirrors the link.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkRequest" } } } } */ + /* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkResult" } } } } */ + /* #swagger.responses[400] = { description: 'Unknown or expired code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + body('code').isString().trim().isLength({ min: 4, max: 32 }), + validate, + shard.link, +) +playerRouter.get( + '/shard/accounts', + // #swagger.tags = ['Player · Shard'] + // #swagger.summary = 'List the caller’s linked game accounts' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */ + shard.listAccounts, +) +playerRouter.get( + '/shard/roster/:account', + // #swagger.tags = ['Player · Shard'] + // #swagger.summary = 'Character roster for a linked account' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' } + /* #swagger.responses[200] = { description: 'Account roster', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[403] = { description: 'Account not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('account').matches(ACCOUNT_RE), + validate, + shard.roster, +) +playerRouter.get( + '/shard/vendors/:account', + // #swagger.tags = ['Player · Shard'] + // #swagger.summary = 'Player vendors for a linked account' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' } + /* #swagger.responses[200] = { description: 'Vendor snapshot', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[403] = { description: 'Account not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('account').matches(ACCOUNT_RE), + validate, + shard.vendors, +) + module.exports = playerRouter diff --git a/server/src/router/v1/player/shard.controller.js b/server/src/router/v1/player/shard.controller.js new file mode 100644 index 0000000..c6c230f --- /dev/null +++ b/server/src/router/v1/player/shard.controller.js @@ -0,0 +1,81 @@ +// ── Player: game-account linking + reads ─────────────────────────────────── +// +// The player-facing surface for the uo-link integration. A logged-in player +// runs [link in game, gets a one-time code, and enters it here — the server +// confirms it with the sidecar (which permanently tags the game account with the +// website user id) and mirrors the link locally. Roster/vendor reads are +// ownership-checked against that mirror so a player can only see accounts they +// have linked. The sidecar token stays server-side throughout. + +const uoLinkClient = require('../../../utils/uoLinkClient') +const shardLinks = require('../../../model/shardLinks/shardLinks.model') +const activity = require('../../../model/activity/activity.model') + +const log = require('../../../utils/logger')('player-shard') + +// POST /player/shard/link — confirm an in-game link code. +async function link(req, res) { + const { code } = req.body + try { + const result = await uoLinkClient.confirmLink(code, req.user.id) + + if (result.ok && result.data && result.data.kind === 'link.ok') { + const account = result.data.account + await shardLinks.link({ account, userId: req.user.id, charName: result.data.char || null }) + await activity.log({ req, action: 'uoLink.account.link', detail: { account } }) + log.info('player linked game account', { user: req.user.username, account }) + return res.json({ linked: true, account }) + } + + // Sidecar reports bad/expired codes as 400 link.error or 404. + if (result.status === 400 || result.status === 404) { + return res.status(400).json({ message: 'That code is unknown or has expired. Run [link in game for a new one.' }) + } + if (result.status === 503 || result.status === 0) { + return res.status(503).json({ message: 'The shard is unavailable right now — try again shortly.' }) + } + return res.status(502).json({ message: 'Could not confirm the link with the shard.' }) + } catch (err) { + log.error('player.shard.link', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /player/shard/accounts — the caller's linked game accounts. +async function listAccounts(req, res) { + try { + return res.json(await shardLinks.listForUser(req.user.id)) + } catch (err) { + log.error('player.shard.listAccounts', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// Shared ownership gate + live round-trip for roster/vendors. `fetcher` is the +// uoLinkClient method to call with the account. +async function ownedRoundTrip(req, res, fetcher, label) { + const { account } = req.params + try { + const owns = await shardLinks.ownsAccount(account, req.user.id) + if (!owns) return res.status(403).json({ message: 'That account is not linked to your profile.' }) + + const result = await fetcher(account) + if (result.ok) return res.json(result.data) + if (result.status === 404) return res.status(404).json({ message: 'Not found.' }) + if (result.status === 503 || result.status === 0) { + return res.status(503).json({ message: 'The shard is unavailable right now — try again shortly.' }) + } + return res.status(502).json({ message: 'Could not reach the shard.' }) + } catch (err) { + log.error(`player.shard.${label}`, err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /player/shard/roster/:account — characters on a linked account. +const roster = (req, res) => ownedRoundTrip(req, res, uoLinkClient.getRoster, 'roster') + +// GET /player/shard/vendors/:account — player vendors on a linked account. +const vendors = (req, res) => ownedRoundTrip(req, res, uoLinkClient.getVendors, 'vendors') + +module.exports = { link, listAccounts, roster, vendors } diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 801145f..918fbc3 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -48,6 +48,10 @@ "name": "Player", "description": "Self-service player accounts (register, credentials, 2FA, linked identities)" }, + { + "name": "Player · Shard", + "description": "Link an in-game account and read its roster / vendors (uo-link)" + }, { "name": "Admin · Dashboard", "description": "Dashboard summary and site mode" @@ -6250,6 +6254,258 @@ } ] } + }, + "/api/v1/player/shard/link": { + "post": { + "tags": [ + "Player · Shard" + ], + "summary": "Link an in-game account with a one-time code", + "description": "The player runs [link in game to get a code, then submits it here. The server confirms it with the sidecar and mirrors the link.", + "responses": { + "200": { + "description": "Linked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShardLinkResult" + } + } + } + }, + "400": { + "description": "Unknown or expired code", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + }, + "502": { + "description": "Bad Gateway" + }, + "503": { + "description": "Shard unavailable — retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShardLinkRequest" + } + } + } + } + } + }, + "/api/v1/player/shard/accounts": { + "get": { + "tags": [ + "Player · Shard" + ], + "summary": "List the caller’s linked game accounts", + "description": "", + "responses": { + "200": { + "description": "Linked accounts", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShardLink" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/player/shard/roster/{account}": { + "get": { + "tags": [ + "Player · Shard" + ], + "summary": "Character roster for a linked account", + "description": "", + "parameters": [ + { + "name": "account", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "A game account linked to the caller." + } + ], + "responses": { + "200": { + "description": "Account roster", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Account not linked to the caller", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + }, + "503": { + "description": "Shard unavailable — retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/player/shard/vendors/{account}": { + "get": { + "tags": [ + "Player · Shard" + ], + "summary": "Player vendors for a linked account", + "description": "", + "parameters": [ + { + "name": "account", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "A game account linked to the caller." + } + ], + "responses": { + "200": { + "description": "Vendor snapshot", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Account not linked to the caller", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + }, + "503": { + "description": "Shard unavailable — retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } } }, "components": { @@ -9998,6 +10254,160 @@ } } } + }, + "ShardLinkRequest": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "required": { + "type": "array", + "example": [ + "code" + ], + "items": { + "type": "string" + } + }, + "properties": { + "type": "object", + "properties": { + "code": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "The one-time code shown by [link in game." + }, + "example": { + "type": "string", + "example": "AB12CD" + } + } + } + } + } + } + }, + "ShardLinkResult": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "linked": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "account": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "whitlocktech" + } + } + } + } + } + } + }, + "ShardLink": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "A linked in-game account (GET /player/shard/accounts)." + }, + "properties": { + "type": "object", + "properties": { + "account": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "whitlocktech" + } + } + }, + "userId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 42 + } + } + }, + "charName": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Darrow" + } + } + }, + "linkedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + } + } + } + } } } } diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index 6df598f..7b407e7 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -49,6 +49,7 @@ const doc = { { name: 'Public · Shard', description: 'Live shard data ingested from the uo-link sidecar (status, feed, economy, IDOC, characters)' }, { name: 'Admin · Account', description: 'Self-service account security (2FA, linked identities)' }, { name: 'Player', description: 'Self-service player accounts (register, credentials, 2FA, linked identities)' }, + { name: 'Player · Shard', description: 'Link an in-game account and read its roster / vendors (uo-link)' }, { name: 'Admin · Dashboard', description: 'Dashboard summary and site mode' }, { name: 'Admin · Posts', description: 'News / five-on-friday / newsletter / screenshots + uploads' }, { name: 'Admin · Wiki', description: 'Wiki pages, categories, tags and revisions' }, @@ -562,6 +563,30 @@ const doc = { updatedAt: { type: 'string', format: 'date-time' }, }, }, + ShardLinkRequest: { + type: 'object', + required: ['code'], + properties: { + code: { type: 'string', description: 'The one-time code shown by [link in game.', example: 'AB12CD' }, + }, + }, + ShardLinkResult: { + type: 'object', + properties: { + linked: { type: 'boolean', example: true }, + account: { type: 'string', example: 'whitlocktech' }, + }, + }, + ShardLink: { + type: 'object', + description: 'A linked in-game account (GET /player/shard/accounts).', + properties: { + account: { type: 'string', example: 'whitlocktech' }, + userId: { type: 'integer', example: 42 }, + charName: { type: 'string', nullable: true, example: 'Darrow' }, + linkedAt: { type: 'string', format: 'date-time' }, + }, + }, }, }, } From 1c9a9d26e1f23f158b4315118757b58c2ae15c87 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 02:17:45 -0500 Subject: [PATCH 05/12] Add public Shard page + player Game Accounts UI (phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend for the uo-link integration, matching the existing site styling. - api/client.js: api.shard.* (status/feed/economy/idoc/char), the shardStreamUrl SSE endpoint, and api.player.shard.* (link/accounts/roster/ vendors). - lib/useShardFeed.js: EventSource hook over /public/shard/stream with a rolling buffer and a connected flag (browser never touches the sidecar WS). - routes/public/Shard.jsx: connection banner, stat tiles (online / gold supply / link), a gold-supply sparkline, "recent vendor sales" and "IDOC houses" lists, and a live event ticker — built from the shared panel/grid/format vocabulary. Registered at /site/shard under the maintenance gate and linked from the site header. - routes/player/PlayerAccount.jsx: a "Game accounts" section — enter a [link code to link an account, then expand it to see characters and player vendors on demand (503 shows a retry banner). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3 --- client/src/App.jsx | 2 + client/src/api/client.js | 28 +++ client/src/components/SiteHeader.jsx | 1 + client/src/lib/useShardFeed.js | 53 +++++ client/src/routes/player/PlayerAccount.jsx | 170 ++++++++++++++++ client/src/routes/public/Shard.jsx | 226 +++++++++++++++++++++ 6 files changed, 480 insertions(+) create mode 100644 client/src/lib/useShardFeed.js create mode 100644 client/src/routes/public/Shard.jsx diff --git a/client/src/App.jsx b/client/src/App.jsx index 99a54dc..591df74 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -16,6 +16,7 @@ import Newsletter from './routes/public/Newsletter.jsx' import NewsletterIssue from './routes/public/NewsletterIssue.jsx' import About from './routes/public/About.jsx' import Status from './routes/public/Status.jsx' +import Shard from './routes/public/Shard.jsx' import Wiki from './routes/wiki/Wiki.jsx' import WikiArticle from './routes/wiki/WikiArticle.jsx' import CmsPage from './routes/public/CmsPage.jsx' @@ -66,6 +67,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> {/* CMS pages: top-level /:slug, matched only after the named routes diff --git a/client/src/api/client.js b/client/src/api/client.js index 3357c1a..b3021dd 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -79,6 +79,26 @@ export const api = { pagePreview: (id, token) => req(`/public/pages/${id}/preview/${token}`), contact: (payload) => req('/public/contact', { method: 'POST', body: payload }), + // ----- shard live data (uo-link) ----- + // Token-free, same-origin reads backed by the ingested feed + a cached live + // character round-trip. shardStreamUrl is the SSE endpoint for useShardFeed. + shard: { + status: () => req('/public/shard/status'), + feed: (opts = {}) => { + const qs = new URLSearchParams() + if (opts.kind) qs.set('kind', opts.kind) + if (opts.limit) qs.set('limit', opts.limit) + const s = qs.toString() + return req(`/public/shard/feed${s ? `?${s}` : ''}`) + }, + economy: (limit) => req(`/public/shard/economy${limit ? `?limit=${limit}` : ''}`), + idoc: () => req('/public/shard/idoc'), + char: (serial) => req(`/public/shard/char/${encodeURIComponent(serial)}`), + }, + // Full path (incl. /api/v1) for the browser EventSource — the req() wrapper is + // fetch-only, so SSE subscribers build the URL from here. + shardStreamUrl: `${BASE}/public/shard/stream`, + // ----- admin ----- admin: { dashboard: () => req('/admin/dashboard'), @@ -225,6 +245,14 @@ export const api = { totpDisable: (code) => req('/player/account/totp/disable', { method: 'POST', body: { code } }), linkedIdentities: () => req('/player/account/identities'), unlinkIdentity: (provider) => req(`/player/account/identities/${provider}`, { method: 'DELETE' }), + + // ----- game account linking (uo-link) ----- + shard: { + link: (code) => req('/player/shard/link', { method: 'POST', body: { code } }), + accounts: () => req('/player/shard/accounts'), + roster: (account) => req(`/player/shard/roster/${encodeURIComponent(account)}`), + vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`), + }, }, } diff --git a/client/src/components/SiteHeader.jsx b/client/src/components/SiteHeader.jsx index 2d9ae4d..16fea82 100644 --- a/client/src/components/SiteHeader.jsx +++ b/client/src/components/SiteHeader.jsx @@ -3,6 +3,7 @@ import MoonDot from './MoonDot.jsx' const NAV = { website: [ + { label: 'Shard', to: '/site/shard' }, { label: 'News', to: '/site/news' }, { label: 'Screenshots', to: '/site/screenshots' }, { label: 'Five on Friday', to: '/site/five-on-friday' }, diff --git a/client/src/lib/useShardFeed.js b/client/src/lib/useShardFeed.js new file mode 100644 index 0000000..d4069e8 --- /dev/null +++ b/client/src/lib/useShardFeed.js @@ -0,0 +1,53 @@ +import { useEffect, useRef, useState } from 'react' +import { api } from '../api/client.js' + +// Subscribe to the public shard live-event SSE stream and keep a rolling buffer +// of the most recent events. The browser talks to our own /public/shard/stream +// route (plain HTTP EventSource) — never the sidecar's WebSocket — so the token +// stays server-side and it works through any reverse proxy. +// +// EventSource auto-reconnects on drop, so there is no manual retry loop here; a +// `connected` flag is exposed for a small live/offline indicator. `filter` (a +// Set of kinds, optional) limits which events are buffered. `max` caps the +// buffer length. +export function useShardFeed({ filter, max = 40 } = {}) { + const [events, setEvents] = useState([]) + const [connected, setConnected] = useState(false) + // Keep the latest filter in a ref so re-renders don't tear down the stream. + const filterRef = useRef(filter) + filterRef.current = filter + + useEffect(() => { + // EventSource isn't available during SSR / very old browsers — degrade to + // "no live feed" rather than throwing. + if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') return undefined + + const es = new EventSource(api.shardStreamUrl, { withCredentials: true }) + + es.onopen = () => setConnected(true) + es.onerror = () => setConnected(false) // EventSource will retry on its own + + es.onmessage = (msg) => { + let event + try { + event = JSON.parse(msg.data) + } catch { + return + } + if (!event || !event.kind) return + const f = filterRef.current + if (f && !f.has(event.kind)) return + setEvents((prev) => { + // Tag with a stable-ish local id for React keys (events carry t but can + // collide within a ms) and cap the buffer. + const next = [{ ...event, _id: `${event.kind}-${event.t}-${prev.length}` }, ...prev] + return next.slice(0, max) + }) + } + + return () => es.close() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [max]) + + return { events, connected } +} diff --git a/client/src/routes/player/PlayerAccount.jsx b/client/src/routes/player/PlayerAccount.jsx index bb567c0..6d0ea7d 100644 --- a/client/src/routes/player/PlayerAccount.jsx +++ b/client/src/routes/player/PlayerAccount.jsx @@ -297,6 +297,175 @@ function LinkedAccounts() { ) } +// ── Game accounts (uo-link) ──────────────────────────────────────────────── +function GameAccounts() { + const [accounts, setAccounts] = useState(null) + const [error, setError] = useState('') + const [code, setCode] = useState('') + const [busy, setBusy] = useState(false) + const [msg, setMsg] = useState('') + const [linkError, setLinkError] = useState('') + const [selected, setSelected] = useState(null) // account being inspected + + const load = useCallback(async () => { + try { + setAccounts(await api.player.shard.accounts()) + } catch { + setError('Could not load your linked game accounts.') + } + }, []) + useEffect(() => { load() }, [load]) + + async function link(e) { + e.preventDefault() + setMsg('') + setLinkError('') + if (!code.trim()) return + setBusy(true) + try { + const { account } = await api.player.shard.link(code.trim()) + setMsg(`Linked ${account}.`) + setCode('') + await load() + } catch (err) { + setLinkError(err.message || 'Could not link that code.') + } finally { + setBusy(false) + } + } + + if (error) return + if (!accounts) return null + + return ( +
+

+ Link your in-game account to see your characters and player vendors here. In game, type{' '} + [link to get a one-time code, then enter it below. +

+ +
+ + +
+ + + {accounts.length > 0 && ( +
+ {accounts.map((a) => ( +
+
+
+
{a.account}
+
Linked {new Date(a.linkedAt).toLocaleDateString()}
+
+ +
+ {selected === a.account && } +
+ ))} +
+ )} + {accounts.length === 0 && ( +

No game accounts linked yet.

+ )} +
+ ) +} + +// Roster + vendors for one linked account, loaded on demand. Handles the shard +// restart (503) path with a retry-able banner. +function AccountDetail({ account }) { + const [roster, setRoster] = useState(null) + const [vendors, setVendors] = useState(null) + const [error, setError] = useState('') + const [unavailable, setUnavailable] = useState(false) + + const load = useCallback(async () => { + setError('') + setUnavailable(false) + try { + const [r, v] = await Promise.all([ + api.player.shard.roster(account), + api.player.shard.vendors(account).catch(() => null), + ]) + setRoster(r) + setVendors(v) + } catch (err) { + if (err.status === 503) setUnavailable(true) + else setError(err.message || 'Could not load this account.') + } + }, [account]) + useEffect(() => { load() }, [load]) + + if (unavailable) { + return ( +
+

+ The game server is restarting — try again shortly. +

+ +
+ ) + } + if (error) return

{error}

+ if (!roster) return

Loading…

+ + const chars = roster.chars || [] + const shops = (vendors && vendors.vendors) || [] + + return ( +
+
+
Characters
+ {chars.length === 0 ? ( +

No characters found.

+ ) : ( +
+ {chars.map((c) => ( +
+ {c.name} + {c.online ? 'Online' : 'Offline'} +
+ ))} +
+ )} +
+ {shops.length > 0 && ( +
+
Player vendors
+
+ {shops.map((s) => ( +
+ {s.shopName || 'Vendor'} + {Number(s.holdGold || 0).toLocaleString()}gp +
+ ))} +
+
+ )} +
+ ) +} + // ── Shared bits ──────────────────────────────────────────────────────────── function Section({ title, children }) { return ( @@ -363,6 +532,7 @@ export default function PlayerAccount() { {account.email ? ` · ${account.email}` : ''}

+ diff --git a/client/src/routes/public/Shard.jsx b/client/src/routes/public/Shard.jsx new file mode 100644 index 0000000..d6e768a --- /dev/null +++ b/client/src/routes/public/Shard.jsx @@ -0,0 +1,226 @@ +import PublicLayout from '../../components/PublicLayout.jsx' +import PageHeader from '../../components/PageHeader.jsx' +import { Loading, ErrorState } from '../../components/PageState.jsx' +import { useAsync } from '../../lib/useAsync.js' +import { useShardFeed } from '../../lib/useShardFeed.js' +import { ago } from '../../lib/format.js' +import { api } from '../../api/client.js' + +// ── Gold-supply sparkline ─────────────────────────────────────────────────── +function Sparkline({ series }) { + if (!series || series.length < 2) return null + const w = 320 + const h = 56 + const golds = series.map((s) => Number(s.gold) || 0) + const min = Math.min(...golds) + const max = Math.max(...golds) + const span = max - min || 1 + const pts = series + .map((s, i) => { + const x = (i / (series.length - 1)) * w + const y = h - ((Number(s.gold) || 0) - min) / span * h + return `${x.toFixed(1)},${y.toFixed(1)}` + }) + .join(' ') + return ( + + ) +} + +// ── Stat tile (matches Status.jsx) ────────────────────────────────────────── +function Stat({ value, label }) { + return ( +
+
{value}
+
+ {label} +
+
+ ) +} + +// A one-line human description of a feed event. +function describe(ev) { + const p = ev.payload || ev + switch (ev.kind) { + case 'vendor.sale': + return `${p.itemType || 'An item'}${p.amount > 1 ? ` ×${p.amount}` : ''} sold for ${Number(p.price || 0).toLocaleString()}gp` + case 'player.death': + return `${nameOf(p.who)} was slain${p.killer ? ` by ${nameOf(p.killer)}` : ''}` + case 'player.murdered': + return `${nameOf(p.victim)} was murdered${p.murderer ? ` by ${nameOf(p.murderer)}` : ''}` + case 'mob.killed': + return `${nameOf(p.killer)} killed ${nameOf(p.killed)}` + case 'house.decay': + return `${p.name || 'A house'} is now ${p.to || p.stage}` + case 'quest.complete': + return `${nameOf(p.who)} completed “${p.quest}”` + case 'skill.gain': + return `${nameOf(p.who)} gained ${p.skill}` + case 'mob.login': + return `${nameOf(p.who)} entered the world` + case 'mob.logout': + return `${nameOf(p.who)} left the world` + default: + return ev.kind + } +} +function nameOf(who) { + if (!who) return 'Someone' + if (typeof who === 'string') return who + return who.name || who.acct || 'Someone' +} + +export default function Shard() { + const { loading, error, data } = useAsync(() => + Promise.all([api.shard.status(), api.shard.feed({ kind: 'vendor.sale', limit: 8 }), api.shard.idoc(), api.shard.economy(60)]).then( + ([status, sales, idoc, economy]) => ({ status, sales, idoc, economy }), + ), + ) + const { events, connected } = useShardFeed({ max: 30 }) + + const status = data?.status + const online = status?.pluginConnected + const gold = status?.economy?.gold + + return ( + +
+ + + {loading && } + {error && } + + {!loading && !error && data && ( + <> + {/* Connection banner */} +
+ +
+ + {online ? 'The shard is online' : 'The shard is offline'} + + + {online + ? 'The gate to Britannia stands open.' + : status?.enabled + ? 'The link to the game world is down — checking back automatically.' + : 'Live shard data is not configured yet.'} + +
+
+ + {/* Stat tiles */} +
+ + + +
+ + {/* Economy sparkline */} + {data.economy && data.economy.length > 1 && ( +
+
+ Gold supply over time +
+ +
+ )} + +
+ {/* Recent vendor sales */} + ({ id: s.id, text: describe(s), when: s.t }))} + /> + {/* Latest IDOC */} + ({ + id: h.serial, + text: `${h.name || 'A house'}${h.region ? ` — ${h.region}` : ''}`, + when: h.updatedAt, + }))} + /> +
+ + {/* Live ticker */} +
+
+
+ Live feed +
+ + + {connected ? 'Live' : 'Offline'} + +
+ {events.length === 0 ? ( +

+ Waiting for something to happen in the world… +

+ ) : ( +
    + {events.map((ev) => ( +
  • + {describe(ev)} + {ago(ev.t)} +
  • + ))} +
+ )} +
+ + )} +
+
+ ) +} + +function FeedList({ title, items, empty }) { + return ( +
+
+ {title} +
+ {items.length === 0 ? ( +

{empty}

+ ) : ( +
    + {items.map((it) => ( +
  • + {it.text} + {ago(it.when)} +
  • + ))} +
+ )} +
+ ) +} From e7bc3168636bcb808a6b164d13239889f9b4637b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 02:22:17 -0500 Subject: [PATCH 06/12] Add admin shard control: config, status, town crier (phase 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - admin/uoLink.controller.js: GET /admin/uo-link/config (masked config + live health + ingestion stats from the socket/broadcaster); PUT to save base/ws URL + write-only token + protocol + enabled, which (re)starts or stops the WS ingest client and activity-logs the change; POST/DELETE /uo-link/towncrier to publish/remove town-crier messages; GET /uo-link/stream (admin SSE channel, full feed incl. audit/cheat). Mounted adminOnly with express-validator guards + #swagger annotations (new "Admin · Shard" tag, TownCrierRequest schema). - server.js: startup probe (checkUoLink) that logs reachability and warns loudly on a protocol mismatch when the integration is enabled. - client: api.admin uo-link methods; ShardAdmin.jsx control panel (status panel with ingestion stats, config form, town crier) modeled on DiscordBotAdmin; wired into AdminLayout nav/titles + the /admin/shard route. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3 --- client/src/App.jsx | 2 + client/src/api/client.js | 6 + client/src/routes/admin/AdminLayout.jsx | 3 + client/src/routes/admin/views/ShardAdmin.jsx | 213 ++++++++++ server/src/router/v1/admin/admin.routes.js | 72 ++++ .../src/router/v1/admin/uoLink.controller.js | 123 ++++++ server/src/server.js | 30 ++ server/swagger/swagger-output.json | 366 ++++++++++++++++++ server/swagger/swagger.js | 10 + 9 files changed, 825 insertions(+) create mode 100644 client/src/routes/admin/views/ShardAdmin.jsx create mode 100644 server/src/router/v1/admin/uoLink.controller.js diff --git a/client/src/App.jsx b/client/src/App.jsx index 591df74..1560d18 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -34,6 +34,7 @@ import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx' import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx' import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx' import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx' +import ShardAdmin from './routes/admin/views/ShardAdmin.jsx' import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx' import UsersAdmin from './routes/admin/views/UsersAdmin.jsx' import AccountAdmin from './routes/admin/views/AccountAdmin.jsx' @@ -111,6 +112,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/client/src/api/client.js b/client/src/api/client.js index b3021dd..8263791 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -223,6 +223,12 @@ export const api = { getDiscordBotConfig: () => req('/admin/discord-bot/config'), saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }), + // ----- uo-link sidecar control (admin only) ----- + getUoLinkConfig: () => req('/admin/uo-link/config'), + saveUoLinkConfig: (data) => req('/admin/uo-link/config', { method: 'PUT', body: data }), + postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }), + deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }), + // ----- Email delivery / Gmail OAuth2 (admin only) ----- getEmailConfig: () => req('/admin/email/config'), saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }), diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index 9cf3c69..4c6358b 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -37,6 +37,7 @@ const IconKey = () => const IconPulse = () => const IconUser = () => +const IconShard = () => // Nav is grouped into collapsible categories. A group with no `title` renders // its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles` @@ -72,6 +73,7 @@ const NAV = [ { to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] }, { to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] }, { to: '/admin/discord-bot', label: 'Discord Bot', icon: IconBot, roles: ['admin'] }, + { to: '/admin/shard', label: 'Shard (uo-link)', icon: IconShard, roles: ['admin'] }, { to: '/admin/bot-activity', label: 'Web Bot Activity', icon: IconPulse, roles: ['admin'] }, ], }, @@ -95,6 +97,7 @@ const TITLES = { '/admin/activity': 'Activity Log', '/admin/bot-activity': 'Web Bot Activity', '/admin/discord-bot': 'Discord Bot', + '/admin/shard': 'Shard (uo-link)', '/admin/auth-providers': 'Authentication', '/admin/users': 'Users', '/admin/account': 'Account Security', diff --git a/client/src/routes/admin/views/ShardAdmin.jsx b/client/src/routes/admin/views/ShardAdmin.jsx new file mode 100644 index 0000000..cc854aa --- /dev/null +++ b/client/src/routes/admin/views/ShardAdmin.jsx @@ -0,0 +1,213 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Loading, ErrorState } from '../../../components/PageState.jsx' +import { api } from '../../../api/client.js' + +// uo-link sidecar control panel. The auth token is write-only over this API — +// stored encrypted, never returned — same convention as the Discord bot token. +// Saving (re)starts the WS ingest client, so Enabled/URL/token changes take +// effect immediately with no redeploy. + +function Toggle({ checked, onChange, label }) { + return ( + + ) +} + +const STATUS_COLOR = { + connected: '#7fd0a4', + reconnecting: '#e0b070', + error: '#d98b84', + disconnected: 'var(--muted)', +} + +function StatusPanel({ config }) { + const color = STATUS_COLOR[config.status] || 'var(--muted)' + const ingest = config.ingest || {} + const health = config.health || {} + return ( +
+
+ + + {config.status || 'disconnected'} + +
+ {config.statusDetail && ( +

{config.statusDetail}

+ )} +
+ Shard link: {config.pluginConnected ? 'up' : 'down'} + WS ingest: {ingest.connected ? 'connected' : 'offline'} + Reconnects: {ingest.reconnects ?? 0} + SSE clients: {(config.sse?.publicClients ?? 0) + (config.sse?.adminClients ?? 0)} + {config.lastEventAt && Last event: {new Date(config.lastEventAt).toLocaleString()}} + {health.uptime && Sidecar uptime: {health.uptime}} +
+
+ ) +} + +// ── Town crier ────────────────────────────────────────────────────────────── +function TownCrier() { + const [id, setId] = useState('') + const [text, setText] = useState('') + const [durationSec, setDurationSec] = useState(3600) + const [busy, setBusy] = useState(false) + const [msg, setMsg] = useState('') + const [error, setError] = useState('') + + async function post() { + setBusy(true); setMsg(''); setError('') + const lines = text.split('\n').map((l) => l.trim()).filter(Boolean) + if (!id.trim() || lines.length === 0) { + setBusy(false) + return setError('An id and at least one line are required.') + } + try { + await api.admin.postTownCrier({ id: id.trim(), lines, durationSec: Number(durationSec) || undefined }) + setMsg(`Posted “${id.trim()}”.`) + } catch (err) { + setError(err.message || 'Could not post.') + } finally { + setBusy(false) + } + } + async function remove() { + if (!id.trim()) return setError('Enter the id to remove.') + setBusy(true); setMsg(''); setError('') + try { + await api.admin.deleteTownCrier(id.trim()) + setMsg(`Removed “${id.trim()}”.`) + } catch (err) { + setError(err.message || 'Could not remove.') + } finally { + setBusy(false) + } + } + + return ( +
+

Town crier

+

+ Broadcast a message that every in-game town crier announces until it expires. Re-posting the same id replaces it. +

+ +