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()}
+
+
setSelected(selected === a.account ? null : a.account)}
+ >
+ {selected === a.account ? 'Hide' : 'View'}
+
+
+ {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.
+
+
Retry
+
+ )
+ }
+ 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 (
+
+ onChange(e.target.checked)} />
+ {label}
+
+ )
+}
+
+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 (
+
+ )
+}
+
+export default function ShardAdmin() {
+ const [config, setConfig] = useState(null)
+ const [error, setError] = useState('')
+ const [baseUrl, setBaseUrl] = useState('')
+ const [wsUrl, setWsUrl] = useState('')
+ const [token, setToken] = useState('')
+ const [protocol, setProtocol] = useState(1)
+ const [enabled, setEnabled] = useState(false)
+ const [busy, setBusy] = useState(false)
+ const [msg, setMsg] = useState('')
+ const [saveError, setSaveError] = useState('')
+ const pollRef = useRef(null)
+ const initializedRef = useRef(false)
+
+ const load = useCallback(async () => {
+ try {
+ const c = await api.admin.getUoLinkConfig()
+ setConfig(c)
+ // Seed the editable fields once; later polls only refresh the status panel
+ // so they never clobber what the admin is mid-typing.
+ if (!initializedRef.current) {
+ setBaseUrl(c.baseUrl || '')
+ setWsUrl(c.wsUrl || '')
+ setProtocol(c.protocol || 1)
+ setEnabled(c.enabled)
+ initializedRef.current = true
+ }
+ } catch {
+ setError('Could not load uo-link config.')
+ }
+ }, [])
+
+ useEffect(() => {
+ load()
+ pollRef.current = setInterval(load, 5000)
+ return () => clearInterval(pollRef.current)
+ }, [load])
+
+ async function save() {
+ setBusy(true); setMsg(''); setSaveError('')
+ try {
+ const body = { baseUrl, wsUrl, protocol: Number(protocol), enabled }
+ if (token) body.token = token
+ const saved = await api.admin.saveUoLinkConfig(body)
+ setConfig(saved)
+ setToken('')
+ setMsg('Saved.')
+ } catch (err) {
+ setSaveError(err.message || 'Could not save.')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ if (error) return
+ if (!config) return
+
+ return (
+
+ )
+}
diff --git a/server/src/router/v1/admin/admin.routes.js b/server/src/router/v1/admin/admin.routes.js
index 95d9f6a..91e60ff 100644
--- a/server/src/router/v1/admin/admin.routes.js
+++ b/server/src/router/v1/admin/admin.routes.js
@@ -11,6 +11,7 @@ const botActivity = require('./botActivity.controller')
const authProviders = require('./authProviders.controller')
const discordBot = require('./discordBot.controller')
const emailConfig = require('./emailConfig.controller')
+const uoLink = require('./uoLink.controller')
const moderation = require('./moderation.controller')
const pagesCtrl = require('./pages.controller')
const { isLoggedIn, requireRole } = require('../../../utils/auth')
@@ -985,4 +986,75 @@ adminRouter.delete(
ctrl.deleteUser,
)
+// ── uo-link sidecar control (admin only) ──────────────────────────────────
+// Connection config (base/ws URL + token + protocol + enabled) and the town
+// crier. The token is write-only (SECURITY note in uoLink.controller.js).
+adminRouter.get(
+ '/uo-link/config',
+ // #swagger.tags = ['Admin · Shard']
+ // #swagger.summary = 'Get uo-link config + live status + ingestion stats (admin only)'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.responses[200] = { description: 'Masked config, health and ingestion stats', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
+ /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ adminOnly,
+ uoLink.getConfig,
+)
+adminRouter.put(
+ '/uo-link/config',
+ // #swagger.tags = ['Admin · Shard']
+ // #swagger.summary = 'Save uo-link connection config (admin only)'
+ // #swagger.description = 'token is write-only — omit/blank it to keep the existing one. Saving (re)starts the WS ingest client.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { baseUrl: { type: "string" }, wsUrl: { type: "string" }, token: { type: "string" }, protocol: { type: "integer" }, enabled: { type: "boolean" } } } } } } */
+ /* #swagger.responses[200] = { description: 'Updated config + live status', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
+ /* #swagger.responses[400] = { description: 'Validation error, or missing token while enabling', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ adminOnly,
+ body('baseUrl').optional({ values: 'falsy' }).isString().trim().isURL({ require_tld: false, protocols: ['http', 'https'] }),
+ body('wsUrl').optional({ values: 'falsy' }).isString().trim().isURL({ require_tld: false, protocols: ['ws', 'wss'] }),
+ body('token').optional({ values: 'falsy' }).isString().trim(),
+ body('protocol').optional().isInt({ min: 1, max: 99 }),
+ body('enabled').optional().isBoolean(),
+ validate,
+ uoLink.saveConfig,
+)
+adminRouter.post(
+ '/uo-link/towncrier',
+ // #swagger.tags = ['Admin · Shard']
+ // #swagger.summary = 'Publish / replace a town-crier message (admin only)'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TownCrierRequest" } } } } */
+ /* #swagger.responses[200] = { description: 'Posted', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
+ /* #swagger.responses[400] = { description: 'Rejected (over caps)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ /* #swagger.responses[503] = { description: 'Shard unavailable', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ adminOnly,
+ body('id').isString().trim().isLength({ min: 1, max: 64 }),
+ body('lines').isArray({ min: 1, max: 8 }),
+ body('lines.*').isString().isLength({ max: 200 }),
+ body('durationSec').optional().isInt({ min: 1, max: 86400 }),
+ validate,
+ uoLink.postTownCrier,
+)
+adminRouter.delete(
+ '/uo-link/towncrier/:id',
+ // #swagger.tags = ['Admin · Shard']
+ // #swagger.summary = 'Remove a town-crier message (admin only)'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Town-crier message id.' }
+ /* #swagger.responses[200] = { description: 'Removed', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
+ /* #swagger.responses[404] = { description: 'Unknown id', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ adminOnly,
+ param('id').isString().trim().isLength({ min: 1, max: 64 }),
+ validate,
+ uoLink.deleteTownCrier,
+)
+adminRouter.get(
+ '/uo-link/stream',
+ // #swagger.tags = ['Admin · Shard']
+ // #swagger.summary = 'Full live shard event stream incl. audit/cheat (SSE, admin only)'
+ /* #swagger.responses[200] = { description: 'An SSE stream (Content-Type: text/event-stream).' } */
+ adminOnly,
+ uoLink.stream,
+)
+
module.exports = adminRouter
diff --git a/server/src/router/v1/admin/uoLink.controller.js b/server/src/router/v1/admin/uoLink.controller.js
new file mode 100644
index 0000000..04d89be
--- /dev/null
+++ b/server/src/router/v1/admin/uoLink.controller.js
@@ -0,0 +1,123 @@
+// ── Admin: uo-link sidecar control ─────────────────────────────────────────
+//
+// Configure the connection to the uo-link sidecar (base/ws URL, shared-secret
+// token, protocol pin, enabled) and drive the town crier. SECURITY: the token
+// is write-only over this API — stored encrypted, NEVER returned; responses
+// expose only `hasToken` (same convention as the Discord bot token). Saving
+// (re)starts the WS ingest client so a change takes effect with no redeploy.
+
+const uoLinkConfig = require('../../../model/uoLinkConfig/uoLinkConfig.model')
+const uoLinkClient = require('../../../utils/uoLinkClient')
+const uoLinkSocket = require('../../../utils/uoLinkSocket')
+const shardBroadcast = require('../../../utils/shardBroadcast')
+const activity = require('../../../model/activity/activity.model')
+
+const log = require('../../../utils/logger')('admin-uolink')
+
+// Assemble the masked config + live health + ingestion stats for the panel.
+async function buildStatus() {
+ const config = await uoLinkConfig.getSafe()
+ const health = await uoLinkClient.health()
+ return {
+ ...config,
+ health: health.ok ? health.data : { ok: false, error: health.error || `status ${health.status}` },
+ ingest: uoLinkSocket.getState(),
+ sse: shardBroadcast.stats(),
+ }
+}
+
+// GET /admin/uo-link/config — masked config + live status + ingestion stats.
+async function getConfig(req, res) {
+ try {
+ return res.json(await buildStatus())
+ } catch (err) {
+ log.error('uoLink.getConfig', err)
+ return res.status(500).json({ message: 'Internal Server Error' })
+ }
+}
+
+// PUT /admin/uo-link/config — save connection settings + (re)start the socket.
+async function saveConfig(req, res) {
+ const { baseUrl, wsUrl, token, protocol, enabled } = req.body
+ try {
+ const current = await uoLinkConfig.getSafe()
+ const willHaveToken = Boolean(token) || current.hasToken
+ if (enabled && !willHaveToken) {
+ return res.status(400).json({ message: 'An auth token is required before enabling.' })
+ }
+
+ await uoLinkConfig.save({
+ baseUrl,
+ wsUrl,
+ token,
+ protocol: protocol !== undefined ? Number(protocol) : undefined,
+ enabled,
+ updatedBy: req.user.id,
+ })
+ // Drop the client's cached config so the health check below uses the new values.
+ uoLinkClient.invalidateConfig()
+
+ // (Re)start or stop the ingest socket to match the new enabled/URL/token.
+ const saved = await uoLinkConfig.getSafe()
+ if (saved.enabled && saved.hasToken) {
+ await uoLinkSocket.start()
+ } else {
+ uoLinkSocket.stop()
+ await uoLinkConfig.recordStatus({ status: 'disconnected', pluginConnected: false })
+ }
+
+ await activity.log({ req, action: 'uoLink.config.update', detail: { baseUrl: saved.baseUrl, enabled: saved.enabled } })
+ log.info('uo-link config updated', { by: req.user.username, enabled: saved.enabled })
+ return res.json(await buildStatus())
+ } catch (err) {
+ log.error('uoLink.saveConfig', err)
+ return res.status(500).json({ message: 'Internal Server Error' })
+ }
+}
+
+// POST /admin/uo-link/towncrier — publish/replace a town-crier message.
+async function postTownCrier(req, res) {
+ const { id, lines, durationSec } = req.body
+ try {
+ const result = await uoLinkClient.postTownCrier({ id, lines, durationSec })
+ if (result.ok) {
+ await activity.log({ req, action: 'uoLink.towncrier.post', detail: { id } })
+ return res.json(result.data || { ok: true, id })
+ }
+ if (result.status === 400) return res.status(400).json({ message: 'The shard rejected that message (over the line/duration caps?).' })
+ if (result.status === 503 || result.status === 0) {
+ return res.status(503).json({ message: 'The shard is unavailable right now.' })
+ }
+ return res.status(502).json({ message: 'Could not reach the shard.' })
+ } catch (err) {
+ log.error('uoLink.postTownCrier', err)
+ return res.status(500).json({ message: 'Internal Server Error' })
+ }
+}
+
+// DELETE /admin/uo-link/towncrier/:id — remove a town-crier message.
+async function deleteTownCrier(req, res) {
+ const { id } = req.params
+ try {
+ const result = await uoLinkClient.deleteTownCrier(id)
+ if (result.ok) {
+ await activity.log({ req, action: 'uoLink.towncrier.delete', detail: { id } })
+ return res.json(result.data || { ok: true, id })
+ }
+ if (result.status === 404) return res.status(404).json({ message: 'No town-crier message with that id.' })
+ if (result.status === 503 || result.status === 0) {
+ return res.status(503).json({ message: 'The shard is unavailable right now.' })
+ }
+ return res.status(502).json({ message: 'Could not reach the shard.' })
+ } catch (err) {
+ log.error('uoLink.deleteTownCrier', err)
+ return res.status(500).json({ message: 'Internal Server Error' })
+ }
+}
+
+// GET /admin/uo-link/stream — the full live feed (incl. audit/cheat), staff only.
+function stream(req, res) {
+ shardBroadcast.subscribe(req, res, 'admin')
+}
+
+module.exports = { getConfig, saveConfig, postTownCrier, deleteTownCrier, stream }
diff --git a/server/src/server.js b/server/src/server.js
index 12cd45c..dff53ec 100644
--- a/server/src/server.js
+++ b/server/src/server.js
@@ -5,6 +5,8 @@ const app = require('./app')
const internalApp = require('./internalApp')
const botScore = require('./middleware/botScore')
const uoLinkSocket = require('./utils/uoLinkSocket')
+const uoLinkClient = require('./utils/uoLinkClient')
+const uoLinkConfig = require('./model/uoLinkConfig/uoLinkConfig.model')
const shardBroadcast = require('./utils/shardBroadcast')
const { ensureSchema, close } = require('./utils/db')
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
@@ -85,6 +87,7 @@ async function start() {
// sidecar problem block server startup.
try {
await uoLinkSocket.start()
+ await checkUoLink()
} catch (err) {
log.warn('uo-link socket failed to start (continuing)', { error: err.message })
}
@@ -92,6 +95,33 @@ async function start() {
setupShutdown(server, internalServer)
}
+// Best-effort startup probe of the uo-link sidecar: if the integration is
+// enabled, log whether it is reachable and warn loudly on a protocol mismatch
+// (fail-fast visibility rather than silently mis-parsing a newer wire format).
+async function checkUoLink() {
+ const config = await uoLinkConfig.getSafe()
+ if (!config.enabled) return
+ const health = await uoLinkClient.health()
+ if (!health.ok) {
+ log.warn('uo-link is enabled but the sidecar is unreachable at startup', {
+ baseUrl: config.baseUrl,
+ error: health.error || `status ${health.status}`,
+ })
+ return
+ }
+ if (health.data && health.data.protocol && health.data.protocol !== config.protocol) {
+ log.error('uo-link PROTOCOL MISMATCH — pinned vs sidecar', {
+ pinned: config.protocol,
+ sidecar: health.data.protocol,
+ })
+ } else {
+ log.info('uo-link sidecar reachable', {
+ pluginConnected: health.data && health.data.plugin_connected,
+ protocol: health.data && health.data.protocol,
+ })
+ }
+}
+
function setupShutdown(server, internalServer) {
let closing = false
const shutdown = async (signal) => {
diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json
index 918fbc3..f7770f8 100644
--- a/server/swagger/swagger-output.json
+++ b/server/swagger/swagger-output.json
@@ -80,6 +80,10 @@
"name": "Admin · Discord Bot",
"description": "Discord bot token/config and live status (admin only)"
},
+ {
+ "name": "Admin · Shard",
+ "description": "uo-link sidecar connection config, live status and town crier (admin only)"
+ },
{
"name": "Admin · Auth Providers",
"description": "SSO provider configuration (admin only)"
@@ -5761,6 +5765,270 @@
]
}
},
+ "/api/v1/admin/uo-link/config": {
+ "get": {
+ "tags": [
+ "Admin · Shard"
+ ],
+ "summary": "Get uo-link config + live status + ingestion stats (admin only)",
+ "description": "",
+ "responses": {
+ "200": {
+ "description": "Masked config, health and ingestion stats",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Admin role required",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ },
+ "put": {
+ "tags": [
+ "Admin · Shard"
+ ],
+ "summary": "Save uo-link connection config (admin only)",
+ "description": "token is write-only — omit/blank it to keep the existing one. Saving (re)starts the WS ingest client.",
+ "responses": {
+ "200": {
+ "description": "Updated config + live status",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Validation error, or missing token while enabling",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Admin role required",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "baseUrl": {
+ "type": "string"
+ },
+ "wsUrl": {
+ "type": "string"
+ },
+ "token": {
+ "type": "string"
+ },
+ "protocol": {
+ "type": "integer"
+ },
+ "enabled": {
+ "type": "boolean"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/admin/uo-link/towncrier": {
+ "post": {
+ "tags": [
+ "Admin · Shard"
+ ],
+ "summary": "Publish / replace a town-crier message (admin only)",
+ "description": "",
+ "responses": {
+ "200": {
+ "description": "Posted",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Rejected (over caps)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ },
+ "502": {
+ "description": "Bad Gateway"
+ },
+ "503": {
+ "description": "Shard unavailable",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TownCrierRequest"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/admin/uo-link/towncrier/{id}": {
+ "delete": {
+ "tags": [
+ "Admin · Shard"
+ ],
+ "summary": "Remove a town-crier message (admin only)",
+ "description": "",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Town-crier message id."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Removed",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "404": {
+ "description": "Unknown id",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ },
+ "502": {
+ "description": "Bad Gateway"
+ },
+ "503": {
+ "description": "Service Unavailable"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/admin/uo-link/stream": {
+ "get": {
+ "tags": [
+ "Admin · Shard"
+ ],
+ "summary": "Full live shard event stream incl. audit/cheat (SSE, admin only)",
+ "description": "",
+ "responses": {
+ "200": {
+ "description": "An SSE stream (Content-Type: text/event-stream)."
+ }
+ }
+ }
+ },
"/api/v1/player/account": {
"get": {
"tags": [
@@ -10408,6 +10676,104 @@
}
}
}
+ },
+ "TownCrierRequest": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "required": {
+ "type": "array",
+ "example": [
+ "id",
+ "lines"
+ ],
+ "items": {
+ "type": "string"
+ }
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "maxLength": {
+ "type": "number",
+ "example": 64
+ },
+ "description": {
+ "type": "string",
+ "example": "Re-posting the same id replaces the prior entry."
+ },
+ "example": {
+ "type": "string",
+ "example": "news-42"
+ }
+ }
+ },
+ "lines": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "maxLength": {
+ "type": "number",
+ "example": 200
+ }
+ }
+ },
+ "example": {
+ "type": "array",
+ "example": [
+ "Hear ye!",
+ "Market tax is now 5%."
+ ],
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "durationSec": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "minimum": {
+ "type": "number",
+ "example": 1
+ },
+ "maximum": {
+ "type": "number",
+ "example": 86400
+ },
+ "example": {
+ "type": "number",
+ "example": 3600
+ }
+ }
+ }
+ }
+ }
+ }
}
}
}
diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js
index 7b407e7..56a1bfe 100644
--- a/server/swagger/swagger.js
+++ b/server/swagger/swagger.js
@@ -57,6 +57,7 @@ const doc = {
{ name: 'Admin · Activity', description: 'Admin activity log' },
{ name: 'Admin · Bot Activity', description: 'Bot-scoring/ban state and emergency unban (admin only)' },
{ name: 'Admin · Discord Bot', description: 'Discord bot token/config and live status (admin only)' },
+ { name: 'Admin · Shard', description: 'uo-link sidecar connection config, live status and town crier (admin only)' },
{ name: 'Admin · Auth Providers', description: 'SSO provider configuration (admin only)' },
{ name: 'Admin · Users', description: 'User management (admin only)' },
],
@@ -587,6 +588,15 @@ const doc = {
linkedAt: { type: 'string', format: 'date-time' },
},
},
+ TownCrierRequest: {
+ type: 'object',
+ required: ['id', 'lines'],
+ properties: {
+ id: { type: 'string', maxLength: 64, description: 'Re-posting the same id replaces the prior entry.', example: 'news-42' },
+ lines: { type: 'array', items: { type: 'string', maxLength: 200 }, example: ['Hear ye!', 'Market tax is now 5%.'] },
+ durationSec: { type: 'integer', minimum: 1, maximum: 86400, example: 3600 },
+ },
+ },
},
},
}
From fe6f93481bb11b6fab6a4be6d189345fe65c1487 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 11 Jul 2026 02:51:50 -0500
Subject: [PATCH 07/12] Add player portal + character-sheet front end (phase 4
follow-up)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Turns the raw shard endpoints into proper, navigable pages in the site's visual
language.
- components/CharacterSheet.jsx: reusable sheet — attribute tiles, vitals bars,
resistances, skills (with bars), and equipment — styled with the shared
panel/grid vocabulary.
- Player portal with a nav bar: PlayerPortalLayout (Characters / Account tabs +
sign-out) wraps /player and /account. /player (PlayerCharacters) tells the
logged-in player if they haven't linked a game account (with the [link code
prompt) or, once linked, shows their characters grouped by account; each
character opens its sheet at /player/char/:serial. Account security moved into
the same shell (the buried "Game accounts" block was removed from it).
Login/register now land on /player.
- Public: GET /public/shard/online (redacted name+serial+map) drives an
"Online now" list on /site/shard that links to public character sheets at
/site/shard/char/:serial (ShardChar). Swagger: ShardOnlinePlayer + regenerated.
- api.shard.online added.
Verified live against the running shard: Darrow's full sheet (STR 120, 58
skills, 3 equipment) renders through the browser-facing proxy; the online list
returns the live roster; player routes 401 without a session.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
---
client/src/App.jsx | 14 +-
client/src/api/client.js | 1 +
client/src/components/CharacterSheet.jsx | 141 +++++++++++
client/src/routes/player/PlayerAccount.jsx | 226 ++----------------
client/src/routes/player/PlayerCharacter.jsx | 27 +++
client/src/routes/player/PlayerCharacters.jsx | 166 +++++++++++++
client/src/routes/player/PlayerLogin.jsx | 2 +-
.../src/routes/player/PlayerPortalLayout.jsx | 54 +++++
client/src/routes/player/PlayerRegister.jsx | 4 +-
client/src/routes/public/Shard.jsx | 36 ++-
client/src/routes/public/ShardChar.jsx | 37 +++
server/src/router/v1/public/public.routes.js | 7 +
.../src/router/v1/public/shard.controller.js | 15 +-
server/swagger/swagger-output.json | 88 +++++++
server/swagger/swagger.js | 9 +
15 files changed, 609 insertions(+), 218 deletions(-)
create mode 100644 client/src/components/CharacterSheet.jsx
create mode 100644 client/src/routes/player/PlayerCharacter.jsx
create mode 100644 client/src/routes/player/PlayerCharacters.jsx
create mode 100644 client/src/routes/player/PlayerPortalLayout.jsx
create mode 100644 client/src/routes/public/ShardChar.jsx
diff --git a/client/src/App.jsx b/client/src/App.jsx
index 1560d18..9149a78 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -17,6 +17,7 @@ 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 ShardChar from './routes/public/ShardChar.jsx'
import Wiki from './routes/wiki/Wiki.jsx'
import WikiArticle from './routes/wiki/WikiArticle.jsx'
import CmsPage from './routes/public/CmsPage.jsx'
@@ -44,6 +45,9 @@ import ModerationUser from './routes/admin/views/ModerationUser.jsx'
// Player portal
import PlayerLogin from './routes/player/PlayerLogin.jsx'
import PlayerRegister from './routes/player/PlayerRegister.jsx'
+import PlayerPortalLayout from './routes/player/PlayerPortalLayout.jsx'
+import PlayerCharacters from './routes/player/PlayerCharacters.jsx'
+import PlayerCharacter from './routes/player/PlayerCharacter.jsx'
import PlayerAccount from './routes/player/PlayerAccount.jsx'
export default function App() {
@@ -69,6 +73,7 @@ export default function App() {
} />
} />
} />
+ } />
} />
} />
{/* CMS pages: top-level /:slug, matched only after the named routes
@@ -123,13 +128,16 @@ export default function App() {
} />
} />
-
+
}
- />
+ >
+ } />
+ } />
+ } />
+
} />
diff --git a/client/src/api/client.js b/client/src/api/client.js
index 8263791..648cf04 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -92,6 +92,7 @@ export const api = {
return req(`/public/shard/feed${s ? `?${s}` : ''}`)
},
economy: (limit) => req(`/public/shard/economy${limit ? `?limit=${limit}` : ''}`),
+ online: () => req('/public/shard/online'),
idoc: () => req('/public/shard/idoc'),
char: (serial) => req(`/public/shard/char/${encodeURIComponent(serial)}`),
},
diff --git a/client/src/components/CharacterSheet.jsx b/client/src/components/CharacterSheet.jsx
new file mode 100644
index 0000000..34bdb9e
--- /dev/null
+++ b/client/src/components/CharacterSheet.jsx
@@ -0,0 +1,141 @@
+// Reusable character-sheet renderer for the char.profile shape returned by
+// /public/shard/char/:serial. Presentational only — the parent handles loading
+// and errors. Styled with the shared theme vocabulary (panel/grid/stat tiles).
+
+const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' }
+
+function StatTile({ value, label }) {
+ return (
+
+ )
+}
+
+function Vital({ label, cur, max }) {
+ const pct = max ? Math.min(100, Math.round((cur / max) * 100)) : 0
+ return (
+
+
+ {label}
+ {cur ?? '—'} / {max ?? '—'}
+
+
+
+ )
+}
+
+export default function CharacterSheet({ char }) {
+ if (!char) return null
+ const stats = char.stats || {}
+ const resist = stats.resist || {}
+ // Skills the character actually has, best first.
+ const skills = (char.skills || [])
+ .filter((s) => (s.value || s.base || 0) > 0)
+ .sort((a, b) => (b.value || 0) - (a.value || 0))
+ const equipment = char.equipment || []
+
+ return (
+
+ {/* Identity */}
+
+
{char.name || 'Unknown'}
+ {char.title && {char.title} }
+
+
+ {char.online ? 'Online' : 'Offline'}
+
+ {char.serial}
+
+
+ {/* Core stats */}
+
+ Attributes
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Resistances */}
+ {Object.keys(resist).length > 0 && (
+
+ Resistances
+
+ {['phys', 'fire', 'cold', 'pois', 'energy'].map((k) => (
+
+
{resist[k] ?? 0}
+
{RESIST_LABELS[k]}
+
+ ))}
+
+
+ )}
+
+ {/* Skills */}
+ {skills.length > 0 && (
+
+ Skills ({skills.length})
+
+ {skills.map((s) => {
+ const cap = s.cap || 100
+ const pct = Math.min(100, Math.round(((s.value || 0) / cap) * 100))
+ return (
+
+
+ {s.n}
+ {s.value}
+
+
+
+ )
+ })}
+
+
+ )}
+
+ {/* Equipment */}
+ {equipment.length > 0 && (
+
+ Equipment
+
+ {equipment.map((it) => (
+
+
+
+
{it.layer || 'Item'}
+
id {it.itemId}{it.hue ? ` · hue ${it.hue}` : ''}
+
+ {it.mods && Object.keys(it.mods).length > 0 && (
+
+ {Object.entries(it.mods).map(([k, v]) => (
+ {k} {v}
+ ))}
+
+ )}
+
+ ))}
+
+
+ )}
+
+ )
+}
diff --git a/client/src/routes/player/PlayerAccount.jsx b/client/src/routes/player/PlayerAccount.jsx
index 6d0ea7d..ee240bd 100644
--- a/client/src/routes/player/PlayerAccount.jsx
+++ b/client/src/routes/player/PlayerAccount.jsx
@@ -1,6 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
-import { Link } from 'react-router-dom'
-import MoonDot from '../../components/MoonDot.jsx'
import ProviderIcon from '../../components/ProviderIcon.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
@@ -297,175 +295,6 @@ 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()}
-
-
setSelected(selected === a.account ? null : a.account)}
- >
- {selected === a.account ? 'Hide' : 'View'}
-
-
- {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.
-
-
Retry
-
- )
- }
- 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 (
@@ -482,7 +311,7 @@ function Note({ msg, error }) {
// ── Page ───────────────────────────────────────────────────────────────────
export default function PlayerAccount() {
- const { logout, refresh } = useAuth()
+ const { refresh } = useAuth()
const [account, setAccount] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
@@ -505,41 +334,22 @@ export default function PlayerAccount() {
}, [load, refresh])
return (
-
-
-
-
- {loading &&
}
- {error &&
}
- {!loading && !error && account && (
- <>
-
-
- Signed in as {account.username}
- {account.email ? ` · ${account.email}` : ''}
-
-
-
-
-
-
-
- >
- )}
-
-
+
+
Account
+ {loading &&
}
+ {error &&
}
+ {!loading && !error && account && (
+ <>
+
+ Signed in as {account.username}
+ {account.email ? ` · ${account.email}` : ''}
+
+
+
+
+
+ >
+ )}
+
)
}
diff --git a/client/src/routes/player/PlayerCharacter.jsx b/client/src/routes/player/PlayerCharacter.jsx
new file mode 100644
index 0000000..2ddf27e
--- /dev/null
+++ b/client/src/routes/player/PlayerCharacter.jsx
@@ -0,0 +1,27 @@
+import { useParams, Link } from 'react-router-dom'
+import { Loading, ErrorState } from '../../components/PageState.jsx'
+import CharacterSheet from '../../components/CharacterSheet.jsx'
+import { useAsync } from '../../lib/useAsync.js'
+import { api } from '../../api/client.js'
+
+// A player's character sheet inside the portal. Character data is public MMO
+// data, so it uses the same cached public endpoint the site does.
+export default function PlayerCharacter() {
+ const { serial } = useParams()
+ const { loading, error, data } = useAsync(() => api.shard.char(serial), [serial])
+ const restarting = error && error.status === 503
+
+ return (
+
+
+
+ ← Back to characters
+
+
+ {loading &&
}
+ {restarting &&
}
+ {error && !restarting &&
}
+ {!loading && !error && data &&
}
+
+ )
+}
diff --git a/client/src/routes/player/PlayerCharacters.jsx b/client/src/routes/player/PlayerCharacters.jsx
new file mode 100644
index 0000000..07cf03a
--- /dev/null
+++ b/client/src/routes/player/PlayerCharacters.jsx
@@ -0,0 +1,166 @@
+import { useCallback, useEffect, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { Loading, ErrorState } from '../../components/PageState.jsx'
+import { api } from '../../api/client.js'
+
+// One-time-code linking form (shared by the empty state and "add another").
+function LinkForm({ onLinked, compact }) {
+ const [code, setCode] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [msg, setMsg] = useState('')
+ const [error, setError] = useState('')
+
+ async function submit(e) {
+ e.preventDefault()
+ setMsg(''); setError('')
+ if (!code.trim()) return
+ setBusy(true)
+ try {
+ const { account } = await api.player.shard.link(code.trim())
+ setMsg(`Linked ${account}.`)
+ setCode('')
+ await onLinked()
+ } catch (err) {
+ setError(err.message || 'Could not link that code.')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+ )
+}
+
+// Roster of one linked account → character cards linking to the sheet.
+function AccountRoster({ account }) {
+ const [roster, setRoster] = useState(null)
+ const [error, setError] = useState('')
+ const [unavailable, setUnavailable] = useState(false)
+
+ const load = useCallback(async () => {
+ setError(''); setUnavailable(false)
+ try {
+ setRoster(await api.player.shard.roster(account))
+ } 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.
+
Retry
+
+ )
+ }
+ if (error) return {error}
+ if (!roster) return Loading…
+
+ const chars = roster.chars || []
+ if (chars.length === 0) return No characters on this account.
+
+ return (
+
+ {chars.map((c) => (
+
+
+ {(c.name || '?').charAt(0)}
+
+
+
{c.name}
+
{c.online ? 'Online' : 'Offline'}
+
+
›
+
+ ))}
+
+ )
+}
+
+export default function PlayerCharacters() {
+ const [accounts, setAccounts] = useState(null)
+ const [error, setError] = useState('')
+
+ const load = useCallback(async () => {
+ setError('')
+ try {
+ setAccounts(await api.player.shard.accounts())
+ } catch {
+ setError('Could not load your game accounts.')
+ }
+ }, [])
+ useEffect(() => { load() }, [load])
+
+ if (error) return
+ if (!accounts) return
+
+ // Not linked yet — prompt to link.
+ if (accounts.length === 0) {
+ return (
+
+
Your characters
+
+ You haven’t linked a game account yet. Link one to see your characters, stats, skills and vendors here.
+
+
+
Link your game account
+
+ In game, type [link to get a one-time code, then enter it below.
+
+
+
+
+ )
+ }
+
+ // Linked — show characters grouped by account.
+ return (
+
+
+
Your characters
+
+
+
+ {accounts.map((a) => (
+
+ ))}
+
+
+
+ Link another account
+
+
+
+ )
+}
diff --git a/client/src/routes/player/PlayerLogin.jsx b/client/src/routes/player/PlayerLogin.jsx
index 4958c46..8e054e6 100644
--- a/client/src/routes/player/PlayerLogin.jsx
+++ b/client/src/routes/player/PlayerLogin.jsx
@@ -20,7 +20,7 @@ export default function PlayerLogin() {
const { user, login, loginTotp, ssoLoginTotp } = useAuth()
const navigate = useNavigate()
const location = useLocation()
- const dest = location.state?.from?.pathname || '/account'
+ const dest = location.state?.from?.pathname || '/player'
// A staff member who signs in here belongs in the admin shell, not the portal.
const destFor = (u) => (u && u.role !== 'player' ? '/admin' : dest)
diff --git a/client/src/routes/player/PlayerPortalLayout.jsx b/client/src/routes/player/PlayerPortalLayout.jsx
new file mode 100644
index 0000000..4c6a878
--- /dev/null
+++ b/client/src/routes/player/PlayerPortalLayout.jsx
@@ -0,0 +1,54 @@
+import { NavLink, Link, Outlet, useNavigate } from 'react-router-dom'
+import MoonDot from '../../components/MoonDot.jsx'
+import { useAuth } from '../../contexts/AuthContext.jsx'
+
+// Shared shell for the logged-in player portal: a header with a nav bar
+// (Characters / Account) and the page content in an . Matches the
+// site's dark theme vocabulary.
+const tab = ({ isActive }) => ({
+ textDecoration: 'none',
+ fontFamily: 'var(--sans)',
+ fontSize: '0.9rem',
+ padding: '8px 4px',
+ color: isActive ? 'var(--head)' : 'var(--muted)',
+ borderBottom: `2px solid ${isActive ? 'var(--accent)' : 'transparent'}`,
+})
+
+export default function PlayerPortalLayout() {
+ const { user, logout } = useAuth()
+ const navigate = useNavigate()
+
+ async function signOut() {
+ await logout()
+ navigate('/account/login', { replace: true })
+ }
+
+ return (
+
+
+
+
+
+
+
+ )
+}
diff --git a/client/src/routes/player/PlayerRegister.jsx b/client/src/routes/player/PlayerRegister.jsx
index 9aaf2b1..4da1c5f 100644
--- a/client/src/routes/player/PlayerRegister.jsx
+++ b/client/src/routes/player/PlayerRegister.jsx
@@ -21,7 +21,7 @@ export default function PlayerRegister() {
const [providers, setProviders] = useState([])
useEffect(() => {
- if (user && user.role === 'player') navigate('/account', { replace: true })
+ if (user && user.role === 'player') navigate('/player', { replace: true })
}, [user, navigate])
useEffect(() => {
@@ -51,7 +51,7 @@ export default function PlayerRegister() {
setBusy(true)
try {
await register(username.trim(), password, { email: email.trim() || undefined, company })
- navigate('/account', { replace: true })
+ navigate('/player', { replace: true })
} catch (err) {
if (err.status === 409) setError('That username is already taken.')
else if (err.status === 403) setError('Registration is not open right now.')
diff --git a/client/src/routes/public/Shard.jsx b/client/src/routes/public/Shard.jsx
index d6e768a..9917524 100644
--- a/client/src/routes/public/Shard.jsx
+++ b/client/src/routes/public/Shard.jsx
@@ -1,3 +1,4 @@
+import { Link } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
@@ -75,9 +76,13 @@ function nameOf(who) {
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 }),
- ),
+ Promise.all([
+ api.shard.status(),
+ api.shard.feed({ kind: 'vendor.sale', limit: 8 }),
+ api.shard.idoc(),
+ api.shard.economy(60),
+ api.shard.online(),
+ ]).then(([status, sales, idoc, economy, online]) => ({ status, sales, idoc, economy, online })),
)
const { events, connected } = useShardFeed({ max: 30 })
@@ -141,6 +146,31 @@ export default function Shard() {
+ {/* Online now */}
+
+
+ Online now
+
+ {(!data.online || data.online.length === 0) ? (
+ No one is online right now.
+ ) : (
+
+ {data.online.map((p) => (
+
+
+ {p.name || p.serial}
+ {p.map && · {p.map} }
+
+ ))}
+
+ )}
+
+
{/* Economy sparkline */}
{data.economy && data.economy.length > 1 && (
diff --git a/client/src/routes/public/ShardChar.jsx b/client/src/routes/public/ShardChar.jsx
new file mode 100644
index 0000000..9104d85
--- /dev/null
+++ b/client/src/routes/public/ShardChar.jsx
@@ -0,0 +1,37 @@
+import { useParams, Link } from 'react-router-dom'
+import PublicLayout from '../../components/PublicLayout.jsx'
+import PageHeader from '../../components/PageHeader.jsx'
+import { Loading, ErrorState } from '../../components/PageState.jsx'
+import CharacterSheet from '../../components/CharacterSheet.jsx'
+import { useAsync } from '../../lib/useAsync.js'
+import { api } from '../../api/client.js'
+
+// Public character viewer: /site/shard/char/:serial. Renders the live sheet from
+// the sidecar (cached server-side). A 503 means the shard is restarting.
+export default function ShardChar() {
+ const { serial } = useParams()
+ const { loading, error, data } = useAsync(() => api.shard.char(serial), [serial])
+
+ const restarting = error && error.status === 503
+ const notFound = error && error.status === 404
+
+ return (
+
+
+
+
+
+
+ ← Back to shard
+
+
+
+ {loading &&
}
+ {restarting &&
}
+ {notFound &&
}
+ {error && !restarting && !notFound &&
}
+ {!loading && !error && data &&
}
+
+
+ )
+}
diff --git a/server/src/router/v1/public/public.routes.js b/server/src/router/v1/public/public.routes.js
index f741838..3f77764 100644
--- a/server/src/router/v1/public/public.routes.js
+++ b/server/src/router/v1/public/public.routes.js
@@ -162,6 +162,13 @@ publicRouter.get(
validate,
shard.getEconomy,
)
+publicRouter.get(
+ '/shard/online',
+ // #swagger.tags = ['Public · Shard']
+ // #swagger.summary = 'Players online now (name + serial + map only)'
+ /* #swagger.responses[200] = { description: 'Online players', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardOnlinePlayer" } } } } } */
+ shard.getOnline,
+)
publicRouter.get(
'/shard/idoc',
// #swagger.tags = ['Public · Shard']
diff --git a/server/src/router/v1/public/shard.controller.js b/server/src/router/v1/public/shard.controller.js
index 5b09c04..14e72d7 100644
--- a/server/src/router/v1/public/shard.controller.js
+++ b/server/src/router/v1/public/shard.controller.js
@@ -69,6 +69,19 @@ async function getEconomy(req, res) {
}
}
+// GET /public/shard/online — who is online now (redacted: name + serial + map,
+// no coordinates, vitals or account). Feeds the public "online now" list, which
+// links to the public character sheet.
+async function getOnline(req, res) {
+ try {
+ const rows = await shardState.listOnline()
+ return res.json(rows.map((r) => ({ serial: r.serial, name: r.name, map: r.map })))
+ } catch (err) {
+ log.error('shard.getOnline', 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 {
@@ -118,4 +131,4 @@ function stream(req, res) {
broadcast.subscribe(req, res, 'public')
}
-module.exports = { getStatus, getFeed, getEconomy, getIdoc, getChar, stream }
+module.exports = { getStatus, getFeed, getEconomy, getOnline, getIdoc, getChar, stream }
diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json
index f7770f8..f91b8b0 100644
--- a/server/swagger/swagger-output.json
+++ b/server/swagger/swagger-output.json
@@ -1410,6 +1410,33 @@
}
}
},
+ "/api/v1/public/shard/online": {
+ "get": {
+ "tags": [
+ "Public · Shard"
+ ],
+ "summary": "Players online now (name + serial + map only)",
+ "description": "",
+ "responses": {
+ "200": {
+ "description": "Online players",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ShardOnlinePlayer"
+ }
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
"/api/v1/public/shard/idoc": {
"get": {
"tags": [
@@ -10307,6 +10334,67 @@
}
}
},
+ "ShardOnlinePlayer": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "A player online now (redacted for the public list)."
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "serial": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "0x24C"
+ }
+ }
+ },
+ "name": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "Darrow"
+ }
+ }
+ },
+ "map": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "example": {
+ "type": "string",
+ "example": "Trammel"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"ShardHouse": {
"type": "object",
"properties": {
diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js
index 56a1bfe..0d245b8 100644
--- a/server/swagger/swagger.js
+++ b/server/swagger/swagger.js
@@ -544,6 +544,15 @@ const doc = {
t: { type: 'integer', description: 'Sample time, epoch ms.', example: 1783720000000 },
},
},
+ ShardOnlinePlayer: {
+ type: 'object',
+ description: 'A player online now (redacted for the public list).',
+ properties: {
+ serial: { type: 'string', example: '0x24C' },
+ name: { type: 'string', example: 'Darrow' },
+ map: { type: 'string', nullable: true, example: 'Trammel' },
+ },
+ },
ShardHouse: {
type: 'object',
description: 'A house at its current decay stage.',
From 49ce230c3a7b3f1ab7b2c8f3278fe5c60ab0bc66 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 11 Jul 2026 02:59:19 -0500
Subject: [PATCH 08/12] Full-site nav: one auth-aware nav bar on every page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- SiteHeader: a single consistent main nav (Home, News, Screenshots, Five on
Friday, Newsletter, Wiki, Shard, About) with active-state highlighting, plus
an auth-aware entry on the right — Sign in when logged out, My Account
(player) or Admin (staff) when logged in.
- Portal (landing) now renders the site header too, so the nav is present
across the entire site, not just interior pages.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
---
client/src/components/SiteHeader.jsx | 87 ++++++++++++++--------------
client/src/routes/public/Portal.jsx | 2 +-
2 files changed, 46 insertions(+), 43 deletions(-)
diff --git a/client/src/components/SiteHeader.jsx b/client/src/components/SiteHeader.jsx
index 16fea82..e8df12f 100644
--- a/client/src/components/SiteHeader.jsx
+++ b/client/src/components/SiteHeader.jsx
@@ -1,27 +1,37 @@
-import { Link } from 'react-router-dom'
+import { Link, NavLink } from 'react-router-dom'
import MoonDot from './MoonDot.jsx'
+import { useAuth } from '../contexts/AuthContext.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' },
- { label: 'Newsletter', to: '/site/newsletter' },
- { label: 'About', to: '/site/about' },
- { label: 'Wiki', to: '/wiki' },
- ],
- wiki: [
- { label: 'Website', to: '/site' },
- { label: 'New Player Guide', to: '/wiki/new-player-guide' },
- { label: 'Maps & Atlas', to: '/wiki/maps-atlas' },
- { label: 'Systems', to: '/wiki/systems' },
- { label: 'Rules', to: '/wiki/rules' },
- ],
-}
+// One consistent top nav for the whole public site. Every page gets the same
+// main links plus an auth-aware entry on the right (Sign in / My Account / Admin).
+const NAV = [
+ { label: 'Home', to: '/', end: true },
+ { label: 'News', to: '/site/news' },
+ { label: 'Screenshots', to: '/site/screenshots' },
+ { label: 'Five on Friday', to: '/site/five-on-friday' },
+ { label: 'Newsletter', to: '/site/newsletter' },
+ { label: 'Wiki', to: '/wiki' },
+ { label: 'Shard', to: '/site/shard' },
+ { label: 'About', to: '/site/about' },
+]
+
+const linkStyle = ({ isActive }) => ({
+ background: isActive ? 'var(--accent)' : undefined,
+ color: isActive ? 'var(--bg-deep)' : undefined,
+ borderColor: isActive ? 'var(--accent)' : undefined,
+})
+
+export default function SiteHeader() {
+ const { user, loading } = useAuth()
+
+ // Where the auth entry points: staff → admin, player → portal, else sign in.
+ const account =
+ user && user.role && user.role !== 'player'
+ ? { label: 'Admin', to: '/admin' }
+ : user
+ ? { label: 'My Account', to: '/player' }
+ : { label: 'Sign in', to: '/account/login' }
-export default function SiteHeader({ section = 'website' }) {
- const links = NAV[section] || NAV.website
return (
diff --git a/client/src/routes/public/Portal.jsx b/client/src/routes/public/Portal.jsx
index 9cf1fd6..1851b35 100644
--- a/client/src/routes/public/Portal.jsx
+++ b/client/src/routes/public/Portal.jsx
@@ -40,7 +40,7 @@ export default function Portal() {
const elements = [...layout.elements].sort((a, b) => (a.z || 0) - (b.z || 0))
return (
-
+
{PREVIEW && draft && (
Date: Sat, 11 Jul 2026 03:05:43 -0500
Subject: [PATCH 09/12] Let staff link their own characters + share the
game-accounts UI
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Backend: /admin/shard/{link,accounts,roster/:account,vendors/:account} —
staff self-service, reusing the player/shard controller (it keys off
req.user.id, so the same handlers serve any logged-in role). Swagger under
Admin · Account; spec regenerated.
- components/GameAccounts.jsx: the link-prompt + character-roster UI extracted
into one reusable component parametrized by an api scope and a charTo(serial)
route builder.
- PlayerCharacters now renders it (player scope → /player/char/:serial).
- Admin: "My Characters" nav item + /admin/characters (AdminCharacters) and
/admin/characters/:serial (AdminCharacter, in-shell sheet), using the admin
self-service scope. api.admin.shard.* added.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
---
client/src/App.jsx | 4 +
client/src/api/client.js | 8 +
client/src/components/GameAccounts.jsx | 156 ++++++++++++++
client/src/routes/admin/AdminLayout.jsx | 8 +-
.../src/routes/admin/views/AdminCharacter.jsx | 27 +++
.../routes/admin/views/AdminCharacters.jsx | 15 ++
client/src/routes/player/PlayerCharacters.jsx | 163 +-------------
server/src/router/v1/admin/admin.routes.js | 50 +++++
server/swagger/swagger-output.json | 201 ++++++++++++++++++
9 files changed, 473 insertions(+), 159 deletions(-)
create mode 100644 client/src/components/GameAccounts.jsx
create mode 100644 client/src/routes/admin/views/AdminCharacter.jsx
create mode 100644 client/src/routes/admin/views/AdminCharacters.jsx
diff --git a/client/src/App.jsx b/client/src/App.jsx
index 9149a78..5963be8 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -36,6 +36,8 @@ 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 AdminCharacters from './routes/admin/views/AdminCharacters.jsx'
+import AdminCharacter from './routes/admin/views/AdminCharacter.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'
@@ -118,6 +120,8 @@ export default function App() {
} />
} />
} />
+ } />
+ } />
} />
} />
} />
diff --git a/client/src/api/client.js b/client/src/api/client.js
index 648cf04..34f9409 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -214,6 +214,14 @@ export const api = {
linkedIdentities: () => req('/admin/account/identities'),
unlinkIdentity: (provider) => req(`/admin/account/identities/${provider}`, { method: 'DELETE' }),
+ // ----- game account linking (self-service, staff) -----
+ shard: {
+ link: (code) => req('/admin/shard/link', { method: 'POST', body: { code } }),
+ accounts: () => req('/admin/shard/accounts'),
+ roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
+ vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
+ },
+
// ----- auth providers / SSO config (admin only) -----
listAuthProviders: () => req('/admin/auth/providers'),
createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }),
diff --git a/client/src/components/GameAccounts.jsx b/client/src/components/GameAccounts.jsx
new file mode 100644
index 0000000..c4617e7
--- /dev/null
+++ b/client/src/components/GameAccounts.jsx
@@ -0,0 +1,156 @@
+import { useCallback, useEffect, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { Loading, ErrorState } from './PageState.jsx'
+
+// Shared game-account linking + character roster, used by both the player portal
+// (/player) and the staff account page (/admin/account). `scope` is the api
+// object with { link, accounts, roster } (player or admin self-service); `charTo`
+// maps a serial to the route for that character's sheet.
+
+function LinkForm({ scope, onLinked, compact }) {
+ const [code, setCode] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [msg, setMsg] = useState('')
+ const [error, setError] = useState('')
+
+ async function submit(e) {
+ e.preventDefault()
+ setMsg(''); setError('')
+ if (!code.trim()) return
+ setBusy(true)
+ try {
+ const { account } = await scope.link(code.trim())
+ setMsg(`Linked ${account}.`)
+ setCode('')
+ await onLinked()
+ } catch (err) {
+ setError(err.message || 'Could not link that code.')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+ )
+}
+
+function AccountRoster({ scope, account, charTo }) {
+ const [roster, setRoster] = useState(null)
+ const [error, setError] = useState('')
+ const [unavailable, setUnavailable] = useState(false)
+
+ const load = useCallback(async () => {
+ setError(''); setUnavailable(false)
+ try {
+ setRoster(await scope.roster(account))
+ } catch (err) {
+ if (err.status === 503) setUnavailable(true)
+ else setError(err.message || 'Could not load this account.')
+ }
+ }, [scope, account])
+ useEffect(() => { load() }, [load])
+
+ if (unavailable) {
+ return (
+
+
The game server is restarting — try again shortly.
+
Retry
+
+ )
+ }
+ if (error) return {error}
+ if (!roster) return Loading…
+
+ const chars = roster.chars || []
+ if (chars.length === 0) return No characters on this account.
+
+ return (
+
+ {chars.map((c) => (
+
+
+ {(c.name || '?').charAt(0)}
+
+
+
{c.name}
+
{c.online ? 'Online' : 'Offline'}
+
+
›
+
+ ))}
+
+ )
+}
+
+export default function GameAccounts({ scope, charTo }) {
+ const [accounts, setAccounts] = useState(null)
+ const [error, setError] = useState('')
+
+ const load = useCallback(async () => {
+ setError('')
+ try {
+ setAccounts(await scope.accounts())
+ } catch {
+ setError('Could not load your game accounts.')
+ }
+ }, [scope])
+ useEffect(() => { load() }, [load])
+
+ if (error) return
+ if (!accounts) return
+
+ // Not linked yet — prompt to link.
+ if (accounts.length === 0) {
+ return (
+
+
Link your game account
+
+ You haven’t linked a game account yet. In game, type [link to get a
+ one-time code, then enter it below to see your characters, stats, skills and vendors here.
+
+
+
+ )
+ }
+
+ // Linked — characters grouped by account.
+ return (
+
+ {accounts.map((a) => (
+
+ ))}
+
+ Link another account
+
+
+
+ )
+}
diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx
index 4c6358b..30bdd32 100644
--- a/client/src/routes/admin/AdminLayout.jsx
+++ b/client/src/routes/admin/AdminLayout.jsx
@@ -79,6 +79,7 @@ const NAV = [
},
{
items: [
+ { to: '/admin/characters', label: 'My Characters', icon: IconShard },
{ to: '/admin/account', label: 'Account', icon: IconUser },
],
},
@@ -98,6 +99,7 @@ const TITLES = {
'/admin/bot-activity': 'Web Bot Activity',
'/admin/discord-bot': 'Discord Bot',
'/admin/shard': 'Shard (uo-link)',
+ '/admin/characters': 'My Characters',
'/admin/auth-providers': 'Authentication',
'/admin/users': 'Users',
'/admin/account': 'Account Security',
@@ -123,7 +125,11 @@ export default function AdminLayout() {
const location = useLocation()
const title =
TITLES[location.pathname] ||
- (location.pathname.startsWith('/admin/moderation') ? 'Moderation' : 'Admin')
+ (location.pathname.startsWith('/admin/moderation')
+ ? 'Moderation'
+ : location.pathname.startsWith('/admin/characters')
+ ? 'My Characters'
+ : 'Admin')
// The hero canvas editor needs room — let it use the full content width.
const wide = location.pathname === '/admin/hero'
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
diff --git a/client/src/routes/admin/views/AdminCharacter.jsx b/client/src/routes/admin/views/AdminCharacter.jsx
new file mode 100644
index 0000000..1767a84
--- /dev/null
+++ b/client/src/routes/admin/views/AdminCharacter.jsx
@@ -0,0 +1,27 @@
+import { useParams, Link } from 'react-router-dom'
+import { Loading, ErrorState } from '../../../components/PageState.jsx'
+import CharacterSheet from '../../../components/CharacterSheet.jsx'
+import { useAsync } from '../../../lib/useAsync.js'
+import { api } from '../../../api/client.js'
+
+// A staff member's character sheet inside the admin shell. Character data is
+// public MMO data, so it uses the same cached public endpoint.
+export default function AdminCharacter() {
+ const { serial } = useParams()
+ const { loading, error, data } = useAsync(() => api.shard.char(serial), [serial])
+ const restarting = error && error.status === 503
+
+ return (
+
+
+
+ ← Back to my characters
+
+
+ {loading &&
}
+ {restarting &&
}
+ {error && !restarting &&
}
+ {!loading && !error && data &&
}
+
+ )
+}
diff --git a/client/src/routes/admin/views/AdminCharacters.jsx b/client/src/routes/admin/views/AdminCharacters.jsx
new file mode 100644
index 0000000..b2a7ec2
--- /dev/null
+++ b/client/src/routes/admin/views/AdminCharacters.jsx
@@ -0,0 +1,15 @@
+import GameAccounts from '../../../components/GameAccounts.jsx'
+import { api } from '../../../api/client.js'
+
+// Staff link their OWN in-game account and view their characters — the same
+// shared component players use, pointed at the staff self-service endpoints.
+export default function AdminCharacters() {
+ return (
+
+
+ Link your own game account to view your characters, stats, skills and vendors.
+
+ `/admin/characters/${serial}`} />
+
+ )
+}
diff --git a/client/src/routes/player/PlayerCharacters.jsx b/client/src/routes/player/PlayerCharacters.jsx
index 07cf03a..bc48dee 100644
--- a/client/src/routes/player/PlayerCharacters.jsx
+++ b/client/src/routes/player/PlayerCharacters.jsx
@@ -1,166 +1,13 @@
-import { useCallback, useEffect, useState } from 'react'
-import { Link } from 'react-router-dom'
-import { Loading, ErrorState } from '../../components/PageState.jsx'
+import GameAccounts from '../../components/GameAccounts.jsx'
import { api } from '../../api/client.js'
-// One-time-code linking form (shared by the empty state and "add another").
-function LinkForm({ onLinked, compact }) {
- const [code, setCode] = useState('')
- const [busy, setBusy] = useState(false)
- const [msg, setMsg] = useState('')
- const [error, setError] = useState('')
-
- async function submit(e) {
- e.preventDefault()
- setMsg(''); setError('')
- if (!code.trim()) return
- setBusy(true)
- try {
- const { account } = await api.player.shard.link(code.trim())
- setMsg(`Linked ${account}.`)
- setCode('')
- await onLinked()
- } catch (err) {
- setError(err.message || 'Could not link that code.')
- } finally {
- setBusy(false)
- }
- }
-
- return (
-
- )
-}
-
-// Roster of one linked account → character cards linking to the sheet.
-function AccountRoster({ account }) {
- const [roster, setRoster] = useState(null)
- const [error, setError] = useState('')
- const [unavailable, setUnavailable] = useState(false)
-
- const load = useCallback(async () => {
- setError(''); setUnavailable(false)
- try {
- setRoster(await api.player.shard.roster(account))
- } 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.
-
Retry
-
- )
- }
- if (error) return {error}
- if (!roster) return Loading…
-
- const chars = roster.chars || []
- if (chars.length === 0) return No characters on this account.
-
- return (
-
- {chars.map((c) => (
-
-
- {(c.name || '?').charAt(0)}
-
-
-
{c.name}
-
{c.online ? 'Online' : 'Offline'}
-
-
›
-
- ))}
-
- )
-}
-
+// The logged-in player's characters. Shows the link prompt when no game account
+// is linked, otherwise their characters grouped by account (shared component).
export default function PlayerCharacters() {
- const [accounts, setAccounts] = useState(null)
- const [error, setError] = useState('')
-
- const load = useCallback(async () => {
- setError('')
- try {
- setAccounts(await api.player.shard.accounts())
- } catch {
- setError('Could not load your game accounts.')
- }
- }, [])
- useEffect(() => { load() }, [load])
-
- if (error) return
- if (!accounts) return
-
- // Not linked yet — prompt to link.
- if (accounts.length === 0) {
- return (
-
-
Your characters
-
- You haven’t linked a game account yet. Link one to see your characters, stats, skills and vendors here.
-
-
-
Link your game account
-
- In game, type [link to get a one-time code, then enter it below.
-
-
-
-
- )
- }
-
- // Linked — show characters grouped by account.
return (
-
-
Your characters
-
-
-
- {accounts.map((a) => (
-
- ))}
-
-
-
- Link another account
-
-
+
Your characters
+
`/player/char/${serial}`} />
)
}
diff --git a/server/src/router/v1/admin/admin.routes.js b/server/src/router/v1/admin/admin.routes.js
index 91e60ff..3134e0a 100644
--- a/server/src/router/v1/admin/admin.routes.js
+++ b/server/src/router/v1/admin/admin.routes.js
@@ -12,6 +12,7 @@ const authProviders = require('./authProviders.controller')
const discordBot = require('./discordBot.controller')
const emailConfig = require('./emailConfig.controller')
const uoLink = require('./uoLink.controller')
+const selfShard = require('../player/shard.controller')
const moderation = require('./moderation.controller')
const pagesCtrl = require('./pages.controller')
const { isLoggedIn, requireRole } = require('../../../utils/auth')
@@ -110,6 +111,55 @@ adminRouter.delete(
account.unlinkIdentity,
)
+// ── Game account linking (self-service, any staff role) ───────────────
+// Staff link their OWN in-game account here, exactly like players do under
+// /player/shard. The controller keys off req.user.id, so the same handlers work.
+const SHARD_ACCOUNT_RE = /^[A-Za-z0-9_.-]{1,120}$/
+adminRouter.post(
+ '/shard/link',
+ // #swagger.tags = ['Admin · Account']
+ // #swagger.summary = 'Link an in-game account with a one-time code (self)'
+ // #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" } } } } */
+ body('code').isString().trim().isLength({ min: 4, max: 32 }),
+ validate,
+ selfShard.link,
+)
+adminRouter.get(
+ '/shard/accounts',
+ // #swagger.tags = ['Admin · Account']
+ // #swagger.summary = 'List the caller’s linked game accounts (self)'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
+ selfShard.listAccounts,
+)
+adminRouter.get(
+ '/shard/roster/:account',
+ // #swagger.tags = ['Admin · Account']
+ // #swagger.summary = 'Character roster for a linked account (self)'
+ // #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" } } } } */
+ param('account').matches(SHARD_ACCOUNT_RE),
+ validate,
+ selfShard.roster,
+)
+adminRouter.get(
+ '/shard/vendors/:account',
+ // #swagger.tags = ['Admin · Account']
+ // #swagger.summary = 'Player vendors for a linked account (self)'
+ // #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" } } } } */
+ param('account').matches(SHARD_ACCOUNT_RE),
+ validate,
+ selfShard.vendors,
+)
+
// ── Image uploads (screenshots/gallery) ───────────────────────────────
const UPLOAD_DIR =
process.env.UPLOAD_DIR || path.join(__dirname, '..', '..', '..', '..', 'uploads')
diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json
index f91b8b0..6978c0f 100644
--- a/server/swagger/swagger-output.json
+++ b/server/swagger/swagger-output.json
@@ -1886,6 +1886,207 @@
]
}
},
+ "/api/v1/admin/shard/link": {
+ "post": {
+ "tags": [
+ "Admin · Account"
+ ],
+ "summary": "Link an in-game account with a one-time code (self)",
+ "description": "",
+ "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"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ },
+ "502": {
+ "description": "Bad Gateway"
+ },
+ "503": {
+ "description": "Service Unavailable"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ShardLinkRequest"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/admin/shard/accounts": {
+ "get": {
+ "tags": [
+ "Admin · Account"
+ ],
+ "summary": "List the caller’s linked game accounts (self)",
+ "description": "",
+ "responses": {
+ "200": {
+ "description": "Linked accounts",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ShardLink"
+ }
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/admin/shard/roster/{account}": {
+ "get": {
+ "tags": [
+ "Admin · Account"
+ ],
+ "summary": "Character roster for a linked account (self)",
+ "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"
+ },
+ "403": {
+ "description": "Account not linked to the caller",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/admin/shard/vendors/{account}": {
+ "get": {
+ "tags": [
+ "Admin · Account"
+ ],
+ "summary": "Player vendors for a linked account (self)",
+ "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"
+ },
+ "403": {
+ "description": "Account not linked to the caller",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"/api/v1/admin/dashboard": {
"get": {
"tags": [
From 49d0c1bd11bb3b8263fe41c429e512ecb7df5560 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 11 Jul 2026 03:10:03 -0500
Subject: [PATCH 10/12] Add shard activity feed + admin live feed; fix
public-feed leak
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Front ends for the rest of the sidecar data, plus a security fix the live data
surfaced.
- lib/shardEvents.js: shared describe()/category/label for every event kind
(sales, deaths & PvP, skills, fame/karma, quests, world, and staff kinds).
- Public /site/shard/activity (ShardActivity): the full event log with category
filter tabs and a live tail (history + SSE merged, de-duped). Linked from the
Shard page. Shard page now reuses the shared describe().
- Admin: a "Live feed (all events)" panel on the Shard admin page subscribing to
the admin SSE channel — shows every kind incl. audit/cheat/login attempts.
useShardFeed generalized to take a stream url; api.adminShardStreamUrl added.
Security fix: GET /public/shard/feed now restricts to the public-safe kind
allowlist (shardEvents.list gains a `kinds` IN-filter). Previously it returned
whatever was logged — including audit.* / cheat.* / link.request. Those are
still stored for the admin channel but never served publicly (verified: a
public request for audit.command returns 0 rows).
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/lib/shardEvents.js | 86 +++++++++++++++++++
client/src/lib/useShardFeed.js | 7 +-
client/src/routes/admin/views/ShardAdmin.jsx | 35 ++++++++
client/src/routes/public/Shard.jsx | 46 +++-------
client/src/routes/public/ShardActivity.jsx | 81 +++++++++++++++++
.../src/model/shardEvents/shardEvents.db.js | 14 ++-
.../model/shardEvents/shardEvents.model.js | 7 +-
.../src/router/v1/public/shard.controller.js | 13 ++-
10 files changed, 249 insertions(+), 48 deletions(-)
create mode 100644 client/src/lib/shardEvents.js
create mode 100644 client/src/routes/public/ShardActivity.jsx
diff --git a/client/src/App.jsx b/client/src/App.jsx
index 5963be8..e7e98bf 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -18,6 +18,7 @@ import About from './routes/public/About.jsx'
import Status from './routes/public/Status.jsx'
import Shard from './routes/public/Shard.jsx'
import ShardChar from './routes/public/ShardChar.jsx'
+import ShardActivity from './routes/public/ShardActivity.jsx'
import Wiki from './routes/wiki/Wiki.jsx'
import WikiArticle from './routes/wiki/WikiArticle.jsx'
import CmsPage from './routes/public/CmsPage.jsx'
@@ -75,6 +76,7 @@ export default function App() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/client/src/api/client.js b/client/src/api/client.js
index 34f9409..b2599c2 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -96,9 +96,11 @@ export const api = {
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.
+ // Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is
+ // fetch-only, so SSE subscribers build the URL from here. The admin stream
+ // carries every kind (incl. audit/cheat) and needs the staff session cookie.
shardStreamUrl: `${BASE}/public/shard/stream`,
+ adminShardStreamUrl: `${BASE}/admin/uo-link/stream`,
// ----- admin -----
admin: {
diff --git a/client/src/lib/shardEvents.js b/client/src/lib/shardEvents.js
new file mode 100644
index 0000000..45a4305
--- /dev/null
+++ b/client/src/lib/shardEvents.js
@@ -0,0 +1,86 @@
+// Shared formatting for shard events — used by the public Shard page, the
+// Activity feed, and the admin live feed. One place decides how each kind reads
+// and which category/badge it belongs to.
+
+function nameOf(who) {
+ if (!who) return 'Someone'
+ if (typeof who === 'string') return who
+ return who.name || who.acct || 'Someone'
+}
+
+const n = (v) => Number(v || 0).toLocaleString()
+
+// A one-line human description of an event. Accepts either a stored event
+// (with .payload) or a raw live frame (fields at top level).
+export 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 ${n(p.price)}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 'skill.gain':
+ return `${nameOf(p.who)} gained ${p.skill}${p.base != null ? ` (${p.base})` : ''}`
+ case 'fame.change':
+ return `${nameOf(p.who)}’s fame changed to ${n(p.new)}`
+ case 'karma.change':
+ return `${nameOf(p.who)}’s karma changed to ${n(p.new)}`
+ case 'quest.complete':
+ return `${nameOf(p.who)} completed “${p.quest}”`
+ case 'house.decay':
+ return `${p.name || 'A house'} is now ${p.to || p.stage}${p.region ? ` — ${p.region}` : ''}`
+ case 'mob.login':
+ return `${nameOf(p.who)} entered the world`
+ case 'mob.logout':
+ return `${nameOf(p.who)} left the world`
+ case 'economy.supply':
+ return `Gold supply: ${n(p.gold)} across ${n(p.accounts)} accounts`
+ case 'server.hello':
+ return `Shard online — ${n(p.accounts)} accounts, ${n(p.mobiles)} mobiles`
+ case 'server.shutdown':
+ return 'Shard shut down'
+ case 'server.crashed':
+ return `Shard crashed${p.error ? `: ${p.error}` : ''}`
+ // Staff / sensitive (admin channel only)
+ case 'audit.set':
+ return `${nameOf(p.staff) || 'Staff'} set ${p.prop} on ${p.target || p.targetSerial} (${p.old} → ${p.new})`
+ case 'audit.command':
+ return `${nameOf(p.staff) || 'Staff'} ran ${p.command}${p.args ? ` ${p.args}` : ''}`
+ case 'cheat.fastwalk':
+ return `Fast-walk flagged: ${nameOf(p.who)}${p.ip ? ` (${p.ip})` : ''}`
+ case 'account.login.attempt':
+ return `Login attempt: ${p.acct}${p.ip ? ` from ${p.ip}` : ''}`
+ case 'gold.change':
+ return `${p.acct}: gold ${p.delta >= 0 ? '+' : ''}${n(p.delta)} → ${n(p.new)}`
+ default:
+ return ev.kind
+ }
+}
+
+// Category grouping for the filter tabs.
+export const CATEGORIES = [
+ { id: 'all', label: 'All', kinds: null },
+ { id: 'sales', label: 'Vendor sales', kinds: ['vendor.sale'] },
+ { id: 'pvp', label: 'Deaths & PvP', kinds: ['player.death', 'player.murdered', 'mob.killed'] },
+ { id: 'progress', label: 'Progression', kinds: ['skill.gain', 'fame.change', 'karma.change', 'quest.complete'] },
+ { id: 'world', label: 'World', kinds: ['house.decay', 'mob.login', 'mob.logout', 'server.hello', 'server.shutdown', 'server.crashed', 'economy.supply'] },
+]
+
+const CATEGORY_OF = (() => {
+ const m = {}
+ for (const c of CATEGORIES) if (c.kinds) for (const k of c.kinds) m[k] = c.id
+ return m
+})()
+
+export function categoryOf(kind) {
+ return CATEGORY_OF[kind] || 'other'
+}
+
+// Short badge label for a kind (the part after the dot, title-cased-ish).
+export function kindLabel(kind) {
+ return String(kind || '').replace(/[._]/g, ' ')
+}
diff --git a/client/src/lib/useShardFeed.js b/client/src/lib/useShardFeed.js
index d4069e8..60e9558 100644
--- a/client/src/lib/useShardFeed.js
+++ b/client/src/lib/useShardFeed.js
@@ -10,19 +10,20 @@ import { api } from '../api/client.js'
// `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 } = {}) {
+export function useShardFeed({ url, 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
+ const streamUrl = url || api.shardStreamUrl
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 })
+ const es = new EventSource(streamUrl, { withCredentials: true })
es.onopen = () => setConnected(true)
es.onerror = () => setConnected(false) // EventSource will retry on its own
@@ -47,7 +48,7 @@ export function useShardFeed({ filter, max = 40 } = {}) {
return () => es.close()
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [max])
+ }, [max, streamUrl])
return { events, connected }
}
diff --git a/client/src/routes/admin/views/ShardAdmin.jsx b/client/src/routes/admin/views/ShardAdmin.jsx
index cc854aa..e59a405 100644
--- a/client/src/routes/admin/views/ShardAdmin.jsx
+++ b/client/src/routes/admin/views/ShardAdmin.jsx
@@ -1,7 +1,40 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
+import { useShardFeed } from '../../../lib/useShardFeed.js'
+import { describe, kindLabel } from '../../../lib/shardEvents.js'
+import { ago } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
+// Full live feed from the admin SSE channel — every kind, incl. staff audit,
+// cheat detection and login attempts that the public channel never carries.
+function AdminLiveFeed() {
+ const { events, connected } = useShardFeed({ url: api.adminShardStreamUrl, max: 60 })
+ return (
+
+
+
Live feed (all events)
+
+
+ {connected ? 'Live' : 'Offline'}
+
+
+ {events.length === 0 ? (
+ Waiting for shard events…
+ ) : (
+
+ {events.map((e) => (
+
+ {kindLabel(e.kind)}
+ {describe(e)}
+ {ago(e.t)}
+
+ ))}
+
+ )}
+
+ )
+}
+
// 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
@@ -208,6 +241,8 @@ export default function ShardAdmin() {
+
+
)
}
diff --git a/client/src/routes/public/Shard.jsx b/client/src/routes/public/Shard.jsx
index 9917524..304765d 100644
--- a/client/src/routes/public/Shard.jsx
+++ b/client/src/routes/public/Shard.jsx
@@ -4,6 +4,7 @@ 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 { describe } from '../../lib/shardEvents.js'
import { ago } from '../../lib/format.js'
import { api } from '../../api/client.js'
@@ -42,38 +43,6 @@ function Stat({ 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([
@@ -206,10 +175,15 @@ export default function Shard() {
Live feed
-
-
- {connected ? 'Live' : 'Offline'}
-
+
+
+ View all activity →
+
+
+
+ {connected ? 'Live' : 'Offline'}
+
+
{events.length === 0 ? (
diff --git a/client/src/routes/public/ShardActivity.jsx b/client/src/routes/public/ShardActivity.jsx
new file mode 100644
index 0000000..3068d08
--- /dev/null
+++ b/client/src/routes/public/ShardActivity.jsx
@@ -0,0 +1,81 @@
+import { useMemo, useState } from 'react'
+import { Link } from 'react-router-dom'
+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 { describe, categoryOf, kindLabel, CATEGORIES } from '../../lib/shardEvents.js'
+import { ago } from '../../lib/format.js'
+import { api } from '../../api/client.js'
+
+// Public activity feed: the full shard event log, filterable by category, with a
+// live tail that prepends new events as they happen.
+export default function ShardActivity() {
+ const { loading, error, data } = useAsync(() => api.shard.feed({ limit: 150 }))
+ const { events: live } = useShardFeed({ max: 60 })
+ const [cat, setCat] = useState('all')
+
+ // Merge the live tail with the loaded history, de-duped by kind+t, newest first.
+ const merged = useMemo(() => {
+ const seen = new Set()
+ const out = []
+ for (const e of [...live, ...(data || [])]) {
+ const key = `${e.kind}-${e.t}`
+ if (seen.has(key)) continue
+ seen.add(key)
+ out.push(e)
+ }
+ return out.sort((a, b) => (b.t || 0) - (a.t || 0))
+ }, [live, data])
+
+ const filtered = cat === 'all' ? merged : merged.filter((e) => categoryOf(e.kind) === cat)
+
+ return (
+
+
+
+
+ ← Back to shard
+
+
+ {/* Category tabs */}
+
+ {CATEGORIES.map((c) => (
+ setCat(c.id)}
+ className="pill"
+ style={cat === c.id ? { background: 'var(--accent)', color: 'var(--bg-deep)', borderColor: 'var(--accent)' } : undefined}
+ >
+ {c.label}
+
+ ))}
+
+
+ {loading &&
}
+ {error &&
}
+
+ {!loading && !error && (
+ filtered.length === 0 ? (
+
+
Nothing here yet — events will appear as they happen in the world.
+
+ ) : (
+
+ {filtered.map((e) => (
+
+
+ {kindLabel(e.kind)}
+
+ {describe(e)}
+ {ago(e.t)}
+
+ ))}
+
+ )
+ )}
+
+
+ )
+}
diff --git a/server/src/model/shardEvents/shardEvents.db.js b/server/src/model/shardEvents/shardEvents.db.js
index 792672a..6c8f864 100644
--- a/server/src/model/shardEvents/shardEvents.db.js
+++ b/server/src/model/shardEvents/shardEvents.db.js
@@ -12,8 +12,18 @@ async function insertIgnore({ kind, t, bootId, payload, dedupeKey }) {
return res.affectedRows > 0
}
-// Recent events, newest first. Optional kind filter; limit is clamped by the model.
-async function list({ kind, limit }) {
+// Recent events, newest first. Filter by a single `kind`, or an allowlist of
+// `kinds` (IN clause) — the public feed uses the allowlist so it can never leak
+// staff/sensitive kinds. limit is clamped by the model.
+async function list({ kind, kinds, limit }) {
+ if (kinds && kinds.length) {
+ const placeholders = kinds.map(() => '?').join(', ')
+ return query(
+ `SELECT id, kind, t, boot_id, payload, created_at
+ FROM shard_events WHERE kind IN (${placeholders}) ORDER BY t DESC LIMIT ?`,
+ [...kinds, limit],
+ )
+ }
if (kind) {
return query(
`SELECT id, kind, t, boot_id, payload, created_at
diff --git a/server/src/model/shardEvents/shardEvents.model.js b/server/src/model/shardEvents/shardEvents.model.js
index 27818a0..1518204 100644
--- a/server/src/model/shardEvents/shardEvents.model.js
+++ b/server/src/model/shardEvents/shardEvents.model.js
@@ -35,9 +35,10 @@ function normalizeLimit(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) })
+// Recent events, newest first. Each row's JSON payload is parsed back to an
+// object. `kinds` (array) restricts to an allowlist; `kind` filters a single kind.
+async function list({ kind, kinds, limit } = {}) {
+ const rows = await db.list({ kind, kinds, limit: normalizeLimit(limit) })
return rows.map((row) => ({
id: row.id,
kind: row.kind,
diff --git a/server/src/router/v1/public/shard.controller.js b/server/src/router/v1/public/shard.controller.js
index 14e72d7..28be23d 100644
--- a/server/src/router/v1/public/shard.controller.js
+++ b/server/src/router/v1/public/shard.controller.js
@@ -47,11 +47,20 @@ async function getStatus(req, res) {
}
}
-// GET /public/shard/feed?kind=&limit= — recent notable events from the log.
+// GET /public/shard/feed?kind=&limit= — recent notable events from the log,
+// restricted to the public-safe allowlist so staff audit / cheat / link events
+// (which are stored for the admin channel) can never leak to the public.
async function getFeed(req, res) {
try {
const { kind, limit } = req.query
- const events = await shardEvents.list({ kind, limit })
+ let events
+ if (kind) {
+ // A specific kind is only served if it is itself public-safe.
+ if (!broadcast.PUBLIC_KINDS.has(kind)) return res.json([])
+ events = await shardEvents.list({ kind, limit })
+ } else {
+ events = await shardEvents.list({ kinds: [...broadcast.PUBLIC_KINDS], limit })
+ }
return res.json(events)
} catch (err) {
log.error('shard.getFeed', err)
From c4245e3f6aae34603bcc355df9557f184a7c16c9 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 11 Jul 2026 09:15:18 -0500
Subject: [PATCH 11/12] Restrict public presence to staff + let admins view any
character
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Public "Online now" now lists only players whose game account is linked
to a STAFF website user (admin/editor/moderator) — linked players are no
longer exposed publicly with their name and location. listOnlineLinked
joins through to users and filters on role; the section is relabeled
"Staff online".
Character/roster/vendor reads gain an admin bypass: admins may view any
character's data, while players (and editor/moderator staff) stay limited
to accounts they have personally linked. The bypass lives in the shared
player controller and only ever widens access for genuine admins.
Also finalizes the uo-link character/vendor front end (player + admin
character sheets, VendorSales component, ShardChar removed) and
regenerates swagger-output.json.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_018kj5s1QCKobuFPYmqxjy1q
---
client/src/App.jsx | 2 -
client/src/api/client.js | 5 +-
client/src/components/VendorSales.jsx | 41 ++
client/src/lib/shardEvents.js | 4 +-
.../src/routes/admin/views/AdminCharacter.jsx | 10 +-
.../routes/admin/views/AdminCharacters.jsx | 2 +
client/src/routes/player/PlayerCharacter.jsx | 10 +-
client/src/routes/player/PlayerCharacters.jsx | 5 +-
client/src/routes/public/Shard.jsx | 38 +-
client/src/routes/public/ShardChar.jsx | 37 --
server/src/model/shardState/shardState.db.js | 21 +
.../src/model/shardState/shardState.model.js | 30 ++
server/src/router/v1/admin/admin.routes.js | 24 +-
server/src/router/v1/player/player.routes.js | 21 +
.../src/router/v1/player/shard.controller.js | 67 ++-
server/src/router/v1/public/public.routes.js | 15 +-
.../src/router/v1/public/shard.controller.js | 55 +--
server/src/utils/shardBroadcast.js | 5 +-
server/swagger/swagger-output.json | 450 +++++++++++++++---
server/swagger/swagger.js | 17 +-
20 files changed, 643 insertions(+), 216 deletions(-)
create mode 100644 client/src/components/VendorSales.jsx
delete mode 100644 client/src/routes/public/ShardChar.jsx
diff --git a/client/src/App.jsx b/client/src/App.jsx
index e7e98bf..56caeee 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -17,7 +17,6 @@ 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 ShardChar from './routes/public/ShardChar.jsx'
import ShardActivity from './routes/public/ShardActivity.jsx'
import Wiki from './routes/wiki/Wiki.jsx'
import WikiArticle from './routes/wiki/WikiArticle.jsx'
@@ -77,7 +76,6 @@ 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 b2599c2..a880c5f 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -94,7 +94,6 @@ export const api = {
economy: (limit) => req(`/public/shard/economy${limit ? `?limit=${limit}` : ''}`),
online: () => req('/public/shard/online'),
idoc: () => req('/public/shard/idoc'),
- char: (serial) => req(`/public/shard/char/${encodeURIComponent(serial)}`),
},
// Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is
// fetch-only, so SSE subscribers build the URL from here. The admin stream
@@ -222,6 +221,8 @@ export const api = {
accounts: () => req('/admin/shard/accounts'),
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
+ char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
+ sales: () => req('/admin/shard/sales'),
},
// ----- auth providers / SSO config (admin only) -----
@@ -269,6 +270,8 @@ export const api = {
accounts: () => req('/player/shard/accounts'),
roster: (account) => req(`/player/shard/roster/${encodeURIComponent(account)}`),
vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`),
+ char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`),
+ sales: () => req('/player/shard/sales'),
},
},
}
diff --git a/client/src/components/VendorSales.jsx b/client/src/components/VendorSales.jsx
new file mode 100644
index 0000000..81cacb7
--- /dev/null
+++ b/client/src/components/VendorSales.jsx
@@ -0,0 +1,41 @@
+import { useEffect, useState } from 'react'
+import { ago } from '../lib/format.js'
+
+// Owner-private recent player-vendor sales. `fetchSales` is the scope method
+// (api.player.shard.sales / api.admin.shard.sales) — the server only returns
+// sales for accounts linked to the caller.
+export default function VendorSales({ fetchSales }) {
+ const [sales, setSales] = useState(null)
+ const [error, setError] = useState('')
+
+ useEffect(() => {
+ let active = true
+ fetchSales()
+ .then((rows) => active && setSales(rows))
+ .catch(() => active && setError('Could not load your vendor sales.'))
+ return () => { active = false }
+ }, [fetchSales])
+
+ if (error) return null
+ if (!sales) return null
+
+ return (
+
+ Recent vendor sales
+ {sales.length === 0 ? (
+ No vendor sales recorded yet.
+ ) : (
+
+ {sales.map((s, i) => (
+
+
+ {s.itemType || 'An item'}{s.amount > 1 ? ` ×${s.amount}` : ''} — {Number(s.price || 0).toLocaleString()}gp
+
+ {ago(s.t)}
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/client/src/lib/shardEvents.js b/client/src/lib/shardEvents.js
index 45a4305..963cd71 100644
--- a/client/src/lib/shardEvents.js
+++ b/client/src/lib/shardEvents.js
@@ -62,9 +62,11 @@ export function describe(ev) {
}
// Category grouping for the filter tabs.
+// Vendor sales are intentionally NOT a public category — they are owner-private
+// (a linked player sees their own under the portal). The admin live feed still
+// describes vendor.sale via describe() below.
export const CATEGORIES = [
{ id: 'all', label: 'All', kinds: null },
- { id: 'sales', label: 'Vendor sales', kinds: ['vendor.sale'] },
{ id: 'pvp', label: 'Deaths & PvP', kinds: ['player.death', 'player.murdered', 'mob.killed'] },
{ id: 'progress', label: 'Progression', kinds: ['skill.gain', 'fame.change', 'karma.change', 'quest.complete'] },
{ id: 'world', label: 'World', kinds: ['house.decay', 'mob.login', 'mob.logout', 'server.hello', 'server.shutdown', 'server.crashed', 'economy.supply'] },
diff --git a/client/src/routes/admin/views/AdminCharacter.jsx b/client/src/routes/admin/views/AdminCharacter.jsx
index 1767a84..db54934 100644
--- a/client/src/routes/admin/views/AdminCharacter.jsx
+++ b/client/src/routes/admin/views/AdminCharacter.jsx
@@ -4,12 +4,13 @@ import CharacterSheet from '../../../components/CharacterSheet.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { api } from '../../../api/client.js'
-// A staff member's character sheet inside the admin shell. Character data is
-// public MMO data, so it uses the same cached public endpoint.
+// A staff member's own character sheet inside the admin shell. Owner-checked —
+// the endpoint only returns a sheet for a character on the caller's linked account.
export default function AdminCharacter() {
const { serial } = useParams()
- const { loading, error, data } = useAsync(() => api.shard.char(serial), [serial])
+ const { loading, error, data } = useAsync(() => api.admin.shard.char(serial), [serial])
const restarting = error && error.status === 503
+ const forbidden = error && error.status === 403
return (
@@ -20,7 +21,8 @@ export default function AdminCharacter() {
{loading && }
{restarting && }
- {error && !restarting && }
+ {forbidden && }
+ {error && !restarting && !forbidden && }
{!loading && !error && data && }
)
diff --git a/client/src/routes/admin/views/AdminCharacters.jsx b/client/src/routes/admin/views/AdminCharacters.jsx
index b2a7ec2..7167603 100644
--- a/client/src/routes/admin/views/AdminCharacters.jsx
+++ b/client/src/routes/admin/views/AdminCharacters.jsx
@@ -1,4 +1,5 @@
import GameAccounts from '../../../components/GameAccounts.jsx'
+import VendorSales from '../../../components/VendorSales.jsx'
import { api } from '../../../api/client.js'
// Staff link their OWN in-game account and view their characters — the same
@@ -10,6 +11,7 @@ export default function AdminCharacters() {
Link your own game account to view your characters, stats, skills and vendors.
`/admin/characters/${serial}`} />
+
)
}
diff --git a/client/src/routes/player/PlayerCharacter.jsx b/client/src/routes/player/PlayerCharacter.jsx
index 2ddf27e..3205a7a 100644
--- a/client/src/routes/player/PlayerCharacter.jsx
+++ b/client/src/routes/player/PlayerCharacter.jsx
@@ -4,12 +4,13 @@ import CharacterSheet from '../../components/CharacterSheet.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
-// A player's character sheet inside the portal. Character data is public MMO
-// data, so it uses the same cached public endpoint the site does.
+// A player's character sheet inside the portal. Owner-checked: the endpoint only
+// returns a sheet for a character on an account linked to the caller.
export default function PlayerCharacter() {
const { serial } = useParams()
- const { loading, error, data } = useAsync(() => api.shard.char(serial), [serial])
+ const { loading, error, data } = useAsync(() => api.player.shard.char(serial), [serial])
const restarting = error && error.status === 503
+ const forbidden = error && error.status === 403
return (
@@ -20,7 +21,8 @@ export default function PlayerCharacter() {
{loading && }
{restarting && }
- {error && !restarting && }
+ {forbidden && }
+ {error && !restarting && !forbidden && }
{!loading && !error && data && }
)
diff --git a/client/src/routes/player/PlayerCharacters.jsx b/client/src/routes/player/PlayerCharacters.jsx
index bc48dee..65617b7 100644
--- a/client/src/routes/player/PlayerCharacters.jsx
+++ b/client/src/routes/player/PlayerCharacters.jsx
@@ -1,13 +1,16 @@
import GameAccounts from '../../components/GameAccounts.jsx'
+import VendorSales from '../../components/VendorSales.jsx'
import { api } from '../../api/client.js'
// The logged-in player's characters. Shows the link prompt when no game account
-// is linked, otherwise their characters grouped by account (shared component).
+// is linked, otherwise their characters grouped by account (shared component),
+// plus their own recent vendor sales.
export default function PlayerCharacters() {
return (
Your characters
`/player/char/${serial}`} />
+
)
}
diff --git a/client/src/routes/public/Shard.jsx b/client/src/routes/public/Shard.jsx
index 304765d..1f6ea5c 100644
--- a/client/src/routes/public/Shard.jsx
+++ b/client/src/routes/public/Shard.jsx
@@ -47,11 +47,10 @@ 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),
api.shard.online(),
- ]).then(([status, sales, idoc, economy, online]) => ({ status, sales, idoc, economy, online })),
+ ]).then(([status, idoc, economy, online]) => ({ status, idoc, economy, online })),
)
const { events, connected } = useShardFeed({ max: 30 })
@@ -115,26 +114,25 @@ export default function Shard() {
- {/* Online now */}
+ {/* Staff online — linked staff accounts only, with location */}
- Online now
+ Staff online
{(!data.online || data.online.length === 0) ? (
- No one is online right now.
+ No staff are online right now.
) : (
-
+
{data.online.map((p) => (
-
-
- {p.name || p.serial}
- {p.map &&
· {p.map} }
-
+
+
+
+ {p.name || p.serial}
+
+
+ {p.map || '—'}{p.x != null ? ` (${p.x}, ${p.y})` : ''}
+
+
))}
)}
@@ -150,13 +148,7 @@ export default function Shard() {
)}
-
- {/* Recent vendor sales */}
-
({ id: s.id, text: describe(s), when: s.t }))}
- />
+
{/* Latest IDOC */}
api.shard.char(serial), [serial])
-
- const restarting = error && error.status === 503
- const notFound = error && error.status === 404
-
- return (
-
-
-
-
-
-
- ← Back to shard
-
-
-
- {loading &&
}
- {restarting &&
}
- {notFound &&
}
- {error && !restarting && !notFound &&
}
- {!loading && !error && data &&
}
-
-
- )
-}
diff --git a/server/src/model/shardState/shardState.db.js b/server/src/model/shardState/shardState.db.js
index 0e35276..74384f7 100644
--- a/server/src/model/shardState/shardState.db.js
+++ b/server/src/model/shardState/shardState.db.js
@@ -33,6 +33,26 @@ async function countOnline() {
const listOnline = () =>
query(`SELECT ${ONLINE_COLS} FROM shard_online ORDER BY name ASC`)
+// Staff roles whose online presence is shown on the public Shard page. Players
+// who link an account are NOT surfaced publicly — only staff opt into visibility
+// by virtue of being staff.
+const PUBLIC_ONLINE_ROLES = ['admin', 'editor', 'moderator']
+
+// Online players whose game account is linked to a STAFF website user. Joined
+// against shard_account_links (not the sidecar-supplied web_id) so a link takes
+// effect immediately, regardless of whether the player has re-logged since
+// linking, then through to users so only staff roles are surfaced publicly.
+const listOnlineLinked = () =>
+ query(
+ `SELECT ${ONLINE_COLS.split(', ').map((c) => `o.${c}`).join(', ')}
+ FROM shard_online o
+ JOIN shard_account_links l ON l.account = o.acct
+ JOIN users u ON u.id = l.user_id
+ WHERE u.role IN (${PUBLIC_ONLINE_ROLES.map(() => '?').join(', ')})
+ ORDER BY o.name ASC`,
+ PUBLIC_ONLINE_ROLES,
+ )
+
// ── Economy supply series ────────────────────────────────────────────────
const insertEconomy = ({ accounts, gold, t }) =>
query('INSERT INTO shard_economy (accounts, gold, t) VALUES (?, ?, ?)', [
@@ -75,6 +95,7 @@ module.exports = {
clearOnline,
countOnline,
listOnline,
+ listOnlineLinked,
insertEconomy,
listEconomy,
latestEconomy,
diff --git a/server/src/model/shardState/shardState.model.js b/server/src/model/shardState/shardState.model.js
index bee0b96..87e31c4 100644
--- a/server/src/model/shardState/shardState.model.js
+++ b/server/src/model/shardState/shardState.model.js
@@ -44,6 +44,35 @@ const setOffline = (serial) => db.removeOnline(serial)
const clearOnline = () => db.clearOnline()
const onlineCount = () => db.countOnline()
+function shapeOnline(r) {
+ return {
+ 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,
+ }
+}
+
+// Only players whose account is linked to a website user (opt-in visibility).
+async function listOnlineLinked() {
+ const rows = await db.listOnlineLinked()
+ return rows.map(shapeOnline)
+}
+
async function listOnline() {
const rows = await db.listOnline()
return rows.map((r) => ({
@@ -133,6 +162,7 @@ module.exports = {
clearOnline,
onlineCount,
listOnline,
+ listOnlineLinked,
addEconomySample,
listEconomy,
latestEconomy,
diff --git a/server/src/router/v1/admin/admin.routes.js b/server/src/router/v1/admin/admin.routes.js
index 3134e0a..cab78a4 100644
--- a/server/src/router/v1/admin/admin.routes.js
+++ b/server/src/router/v1/admin/admin.routes.js
@@ -138,7 +138,7 @@ adminRouter.get(
adminRouter.get(
'/shard/roster/:account',
// #swagger.tags = ['Admin · Account']
- // #swagger.summary = 'Character roster for a linked account (self)'
+ // #swagger.summary = 'Character roster for an account (self; admins: any 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 } } } } */
@@ -150,7 +150,7 @@ adminRouter.get(
adminRouter.get(
'/shard/vendors/:account',
// #swagger.tags = ['Admin · Account']
- // #swagger.summary = 'Player vendors for a linked account (self)'
+ // #swagger.summary = 'Player vendors for an account (self; admins: any 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 } } } } */
@@ -159,6 +159,26 @@ adminRouter.get(
validate,
selfShard.vendors,
)
+adminRouter.get(
+ '/shard/char/:serial',
+ // #swagger.tags = ['Admin · Account']
+ // #swagger.summary = 'Character sheet (self-linked characters; admins: any character)'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ // #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[403] = { description: 'Character not on an account linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ param('serial').matches(/^0x[0-9a-fA-F]+$/),
+ validate,
+ selfShard.getChar,
+)
+adminRouter.get(
+ '/shard/sales',
+ // #swagger.tags = ['Admin · Account']
+ // #swagger.summary = 'Recent player-vendor sales for the caller’s linked accounts (self)'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
+ selfShard.getSales,
+)
// ── Image uploads (screenshots/gallery) ───────────────────────────────
const UPLOAD_DIR =
diff --git a/server/src/router/v1/player/player.routes.js b/server/src/router/v1/player/player.routes.js
index 7af0cdb..80da8b7 100644
--- a/server/src/router/v1/player/player.routes.js
+++ b/server/src/router/v1/player/player.routes.js
@@ -182,5 +182,26 @@ playerRouter.get(
validate,
shard.vendors,
)
+playerRouter.get(
+ '/shard/char/:serial',
+ // #swagger.tags = ['Player · Shard']
+ // #swagger.summary = 'Character sheet — only for a character on the caller’s linked account'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ // #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[403] = { description: 'Character not on an account 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('serial').matches(/^0x[0-9a-fA-F]+$/),
+ validate,
+ shard.getChar,
+)
+playerRouter.get(
+ '/shard/sales',
+ // #swagger.tags = ['Player · Shard']
+ // #swagger.summary = 'Recent player-vendor sales for the caller’s linked accounts'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
+ shard.getSales,
+)
module.exports = playerRouter
diff --git a/server/src/router/v1/player/shard.controller.js b/server/src/router/v1/player/shard.controller.js
index c6c230f..0f9359c 100644
--- a/server/src/router/v1/player/shard.controller.js
+++ b/server/src/router/v1/player/shard.controller.js
@@ -9,10 +9,13 @@
const uoLinkClient = require('../../../utils/uoLinkClient')
const shardLinks = require('../../../model/shardLinks/shardLinks.model')
+const shardEvents = require('../../../model/shardEvents/shardEvents.model')
const activity = require('../../../model/activity/activity.model')
const log = require('../../../utils/logger')('player-shard')
+const SERIAL_RE = /^0x[0-9a-fA-F]+$/
+
// POST /player/shard/link — confirm an in-game link code.
async function link(req, res) {
const { code } = req.body
@@ -51,12 +54,18 @@ async function listAccounts(req, res) {
}
}
+// Admins may view any character's data; everyone else is limited to accounts
+// they have personally linked. The same handlers back /player/shard (role
+// `player`, never admin) and /admin/shard (staff), so this bypass only ever
+// widens access for genuine admins.
+const isAdmin = (req) => req.user && req.user.role === 'admin'
+
// 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)
+ const owns = isAdmin(req) || (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)
@@ -78,4 +87,58 @@ const roster = (req, res) => ownedRoundTrip(req, res, uoLinkClient.getRoster, 'r
// 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 }
+// GET /player/shard/char/:serial — a character sheet, but ONLY if the character's
+// account is linked to the caller. The sidecar returns the owning account in the
+// profile, which we check against the caller's links before returning anything.
+async function getChar(req, res) {
+ const { serial } = req.params
+ if (!SERIAL_RE.test(serial)) return res.status(400).json({ message: 'Invalid serial.' })
+ try {
+ const result = await uoLinkClient.getCharBySerial(serial)
+ if (result.ok) {
+ // Admins see any character; others only characters on an account they linked.
+ if (!isAdmin(req)) {
+ const acct = result.data && result.data.acct
+ const owns = acct ? await shardLinks.ownsAccount(acct, req.user.id) : false
+ if (!owns) return res.status(403).json({ message: 'That character is not on an account linked to you.' })
+ }
+ return res.json(result.data)
+ }
+ if (result.status === 404) return res.status(404).json({ message: 'Character not found.' })
+ if (result.status === 503 || result.status === 0) {
+ return res.status(503).json({ message: 'The game server is restarting — try again shortly.' })
+ }
+ return res.status(502).json({ message: 'Could not reach the shard.' })
+ } catch (err) {
+ log.error('player.shard.getChar', err)
+ return res.status(500).json({ message: 'Internal Server Error' })
+ }
+}
+
+// GET /player/shard/sales — recent player-vendor sales for the caller's linked
+// accounts only (as seller/owner). Read from the site's own event log.
+async function getSales(req, res) {
+ try {
+ const links = await shardLinks.listForUser(req.user.id)
+ const accounts = new Set(links.map((l) => l.account))
+ if (accounts.size === 0) return res.json([])
+ const events = await shardEvents.list({ kind: 'vendor.sale', limit: 500 })
+ const mine = events
+ .filter((e) => e.payload && accounts.has(e.payload.ownerAcct))
+ .slice(0, 50)
+ .map((e) => ({
+ t: e.t,
+ itemType: e.payload.itemType,
+ amount: e.payload.amount,
+ price: e.payload.price,
+ commission: e.payload.commission,
+ ownerAcct: e.payload.ownerAcct,
+ }))
+ return res.json(mine)
+ } catch (err) {
+ log.error('player.shard.getSales', err)
+ return res.status(500).json({ message: 'Internal Server Error' })
+ }
+}
+
+module.exports = { link, listAccounts, roster, vendors, getChar, getSales }
diff --git a/server/src/router/v1/public/public.routes.js b/server/src/router/v1/public/public.routes.js
index 3f77764..992a7a1 100644
--- a/server/src/router/v1/public/public.routes.js
+++ b/server/src/router/v1/public/public.routes.js
@@ -165,7 +165,7 @@ publicRouter.get(
publicRouter.get(
'/shard/online',
// #swagger.tags = ['Public · Shard']
- // #swagger.summary = 'Players online now (name + serial + map only)'
+ // #swagger.summary = 'Staff online now (linked staff accounts; name + serial + map only)'
/* #swagger.responses[200] = { description: 'Online players', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardOnlinePlayer" } } } } } */
shard.getOnline,
)
@@ -176,19 +176,6 @@ publicRouter.get(
/* #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']
diff --git a/server/src/router/v1/public/shard.controller.js b/server/src/router/v1/public/shard.controller.js
index 28be23d..a62b8f7 100644
--- a/server/src/router/v1/public/shard.controller.js
+++ b/server/src/router/v1/public/shard.controller.js
@@ -12,19 +12,10 @@
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 {
@@ -78,13 +69,13 @@ async function getEconomy(req, res) {
}
}
-// GET /public/shard/online — who is online now (redacted: name + serial + map,
-// no coordinates, vitals or account). Feeds the public "online now" list, which
-// links to the public character sheet.
+// GET /public/shard/online — players online now whose account is linked to a
+// STAFF website user (admin/editor/moderator). Shows name + location (map +
+// coordinates); no vitals or account. Non-staff players are never listed.
async function getOnline(req, res) {
try {
- const rows = await shardState.listOnline()
- return res.json(rows.map((r) => ({ serial: r.serial, name: r.name, map: r.map })))
+ const rows = await shardState.listOnlineLinked()
+ return res.json(rows.map((r) => ({ serial: r.serial, name: r.name, map: r.map, x: r.x, y: r.y, z: r.z })))
} catch (err) {
log.error('shard.getOnline', err)
return res.status(500).json({ message: 'Internal Server Error' })
@@ -101,43 +92,9 @@ async function getIdoc(req, res) {
}
}
-// 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, getOnline, getIdoc, getChar, stream }
+module.exports = { getStatus, getFeed, getEconomy, getOnline, getIdoc, stream }
diff --git a/server/src/utils/shardBroadcast.js b/server/src/utils/shardBroadcast.js
index 13f740c..c12637f 100644
--- a/server/src/utils/shardBroadcast.js
+++ b/server/src/utils/shardBroadcast.js
@@ -15,9 +15,10 @@
const log = require('./logger')('shard-broadcast')
-// Kinds safe to expose to unauthenticated browsers.
+// Kinds safe to expose to unauthenticated browsers. Note: vendor.sale is
+// deliberately NOT here — sales are owner-private (a linked player sees only
+// their own, via /player/shard/sales).
const PUBLIC_KINDS = new Set([
- 'vendor.sale',
'player.death',
'player.murdered',
'mob.killed',
diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json
index 6978c0f..a9090cd 100644
--- a/server/swagger/swagger-output.json
+++ b/server/swagger/swagger-output.json
@@ -1415,7 +1415,7 @@
"tags": [
"Public · Shard"
],
- "summary": "Players online now (name + serial + map only)",
+ "summary": "Staff online now (linked staff accounts; name + serial + map only)",
"description": "",
"responses": {
"200": {
@@ -1464,75 +1464,6 @@
}
}
},
- "/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": [
@@ -1984,7 +1915,7 @@
"tags": [
"Admin · Account"
],
- "summary": "Character roster for a linked account (self)",
+ "summary": "Character roster for an account (self; admins: any account)",
"description": "",
"parameters": [
{
@@ -2038,7 +1969,7 @@
"tags": [
"Admin · Account"
],
- "summary": "Player vendors for a linked account (self)",
+ "summary": "Player vendors for an account (self; admins: any account)",
"description": "",
"parameters": [
{
@@ -2087,6 +2018,107 @@
]
}
},
+ "/api/v1/admin/shard/char/{serial}": {
+ "get": {
+ "tags": [
+ "Admin · Account"
+ ],
+ "summary": "Character sheet (self-linked characters; admins: any character)",
+ "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": "Bad Request"
+ },
+ "403": {
+ "description": "Character not on an account linked to the caller",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ },
+ "502": {
+ "description": "Bad Gateway"
+ },
+ "503": {
+ "description": "Service Unavailable"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/admin/shard/sales": {
+ "get": {
+ "tags": [
+ "Admin · Account"
+ ],
+ "summary": "Recent player-vendor sales for the caller’s linked accounts (self)",
+ "description": "",
+ "responses": {
+ "200": {
+ "description": "Vendor sales",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ShardVendorSale"
+ }
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"/api/v1/admin/dashboard": {
"get": {
"tags": [
@@ -7002,6 +7034,123 @@
}
]
}
+ },
+ "/api/v1/player/shard/char/{serial}": {
+ "get": {
+ "tags": [
+ "Player · Shard"
+ ],
+ "summary": "Character sheet — only for a character on the caller’s linked account",
+ "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": "Bad Request"
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "403": {
+ "description": "Character not on an account linked to the caller",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found"
+ },
+ "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": []
+ }
+ ]
+ }
+ },
+ "/api/v1/player/shard/sales": {
+ "get": {
+ "tags": [
+ "Player · Shard"
+ ],
+ "summary": "Recent player-vendor sales for the caller’s linked accounts",
+ "description": "",
+ "responses": {
+ "200": {
+ "description": "Vendor sales",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ShardVendorSale"
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "403": {
+ "description": "Forbidden"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
}
},
"components": {
@@ -10544,7 +10693,7 @@
},
"description": {
"type": "string",
- "example": "A player online now (redacted for the public list)."
+ "example": "A LINKED player online now (only accounts linked to a website user are listed)."
},
"properties": {
"type": "object",
@@ -10591,6 +10740,161 @@
"example": "Trammel"
}
}
+ },
+ "x": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "example": {
+ "type": "number",
+ "example": 1402
+ }
+ }
+ },
+ "y": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "example": {
+ "type": "number",
+ "example": 1604
+ }
+ }
+ },
+ "z": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "example": {
+ "type": "number",
+ "example": 0
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "ShardVendorSale": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "A player-vendor sale (visible only to the linked owner)."
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "t": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "description": {
+ "type": "string",
+ "example": "Sale time, epoch ms."
+ },
+ "example": {
+ "type": "number",
+ "example": 1783720195626
+ }
+ }
+ },
+ "itemType": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "Longsword"
+ }
+ }
+ },
+ "amount": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "example": {
+ "type": "number",
+ "example": 1
+ }
+ }
+ },
+ "price": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "example": {
+ "type": "number",
+ "example": 100
+ }
+ }
+ },
+ "commission": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "example": {
+ "type": "number",
+ "example": 5
+ }
+ }
+ },
+ "ownerAcct": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "whitlocktech"
+ }
+ }
}
}
}
diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js
index 0d245b8..a6838f8 100644
--- a/server/swagger/swagger.js
+++ b/server/swagger/swagger.js
@@ -546,11 +546,26 @@ const doc = {
},
ShardOnlinePlayer: {
type: 'object',
- description: 'A player online now (redacted for the public list).',
+ description: 'A LINKED player online now (only accounts linked to a website user are listed).',
properties: {
serial: { type: 'string', example: '0x24C' },
name: { type: 'string', example: 'Darrow' },
map: { type: 'string', nullable: true, example: 'Trammel' },
+ x: { type: 'integer', nullable: true, example: 1402 },
+ y: { type: 'integer', nullable: true, example: 1604 },
+ z: { type: 'integer', nullable: true, example: 0 },
+ },
+ },
+ ShardVendorSale: {
+ type: 'object',
+ description: 'A player-vendor sale (visible only to the linked owner).',
+ properties: {
+ t: { type: 'integer', description: 'Sale time, epoch ms.', example: 1783720195626 },
+ itemType: { type: 'string', example: 'Longsword' },
+ amount: { type: 'integer', example: 1 },
+ price: { type: 'integer', example: 100 },
+ commission: { type: 'integer', nullable: true, example: 5 },
+ ownerAcct: { type: 'string', example: 'whitlocktech' },
},
},
ShardHouse: {
From d72c2dadfc60065a3e8a1812c20e09532216e280 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 11 Jul 2026 09:29:45 -0500
Subject: [PATCH 12/12] docs: document the uo-link shard integration in the
README
Add a "Shard integration (uo-link)" section explaining that the live
shard bridge is a separate sidecar service at UOM/link, how the site
talks to it (admin-managed encrypted config, WebSocket ingest + REST
round-trips, SSE fan-out with public vs admin channels), the in-game
[link account-linking flow, and what the public / player / admin
surfaces each expose. Also add an intro bullet, a contents entry, and
the shard endpoint groups to the API endpoints table.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_018kj5s1QCKobuFPYmqxjy1q
---
README.md | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 73 insertions(+)
diff --git a/README.md b/README.md
index 5887e73..a22b14a 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,7 @@ shard — a full-stack app in one repo:
- **Backend** — Node.js + Express REST API (layered `router → controller → model → db`), MariaDB, a provider-agnostic session layer (JWT cookie for web, bearer tokens for mobile, pluggable SSO).
- **Frontend** — React + Vite single-page app (public site, wiki, and the admin panel), dark "gothic" theme (Cinzel + Georgia).
- **Deploy** — Docker Compose (app + MariaDB) behind a Pangolin reverse proxy. Express serves the built SPA in production.
+- **Shard link** — a live bridge to the in-game ServUO shard through the **uo-link** sidecar ([UOM/link](https://gitea.whitlocktech.com/UOM/link)): the site ingests a live event feed and makes server-side REST calls to show shard status, economy, staff presence, IDOCs, live activity, and per-character sheets. See [Shard integration (uo-link)](#shard-integration-uo-link).
The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, schema, security).
@@ -24,6 +25,7 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
- [Pages & routes](#pages--routes)
- [API endpoints](#api-endpoints)
- [API documentation (Swagger)](#api-documentation-swagger)
+- [Shard integration (uo-link)](#shard-integration-uo-link)
- [Environment variables](#environment-variables)
- [Security](#security)
- [Logging](#logging)
@@ -207,6 +209,9 @@ npm start # node server → serves API + SPA at http://localhost:3
| SSO | `/api/v1/auth` (`providers` — public discovery; `sso/:provider/start`, `sso/:provider/link`, `sso/:provider/callback`) | redirect flow |
| Public | `/api/v1/public` (`settings`, `status`, `posts/:category`, `posts/:category/:idOrSlug`, `wiki`, `wiki/:slug`, `contact`) | none |
| Admin | `/api/v1/admin` (`dashboard`, `site-mode`, `posts`, `posts/upload`, `wiki`, `settings`, `activity`, `bot-activity`, `bot-activity/unban`, `auth/providers` (CRUD), `users`, `account`, `account/totp/*`, `account/identities`) | cookie (admin) |
+| Public · Shard | `/api/v1/public/shard` (`status`, `feed`, `economy`, `online`, `idoc`, `stream`) | none |
+| Player · Shard | `/api/v1/player/shard` (`link`, `accounts`, `roster/:account`, `vendors/:account`, `char/:serial`, `sales`) | cookie/bearer (player) |
+| Admin · Shard | `/api/v1/admin/shard` (self linking, same as player) · `/api/v1/admin/uo-link` (`config`, `towncrier`, `stream`) | cookie (staff / admin) |
Post categories (URL form): `news`, `five-on-friday`, `newsletter`, `screenshots`.
`authMethod` on a session ∈ `local · totp · mobile · google · discord · oidc`.
@@ -251,6 +256,74 @@ not crash).
---
+## Shard integration (uo-link)
+
+The site is wired to the live in-game world through **uo-link**, a standalone sidecar service that
+runs next to the ServUO shard. Its source lives in a separate repo:
+**[UOM/link](https://gitea.whitlocktech.com/UOM/link)**. uo-link speaks the shard's internals and
+exposes a small, authenticated HTTP + WebSocket API; this website is a *client* of it. The shard
+itself is never exposed to the internet — only the sidecar is, and only the website's backend talks
+to it.
+
+### How it works
+
+```
+ServUO shard ──▶ uo-link sidecar (UOM/link) ──▶ website backend ──▶ browser
+ REST + WebSocket, bearer-auth ingest + REST same-origin JSON/SSE
+```
+
+- **Connection is admin-managed, not env.** The sidecar's base URL, WebSocket URL, shared-secret
+ token, and protocol version are stored in the database (`uoLinkConfig`), edited from the
+ **Admin → Shard** panel. The token is **encrypted at rest** (AES-256-GCM) and is **write-only** in
+ the API — it is never returned to any client and never sent to the browser. Every call the backend
+ makes carries `Authorization: Bearer ` and an `X-UOLink-Version` header (a protocol
+ mismatch fails fast with `409` instead of being mis-parsed).
+- **Live ingest (WebSocket).** When enabled, the backend opens an outbound WebSocket to the sidecar
+ and receives a stream of game events — `mob.login`/`logout`, `char.vitals`, `economy.supply`,
+ `vendor.sale`, `player.death`/`murdered`, `house.decay` (IDOC), staff `audit.*`/`cheat.*`,
+ `link.request`, and `server.hello`/`shutdown`. A single dispatcher (`utils/shardIngest.js`) routes
+ each event: state-changing kinds update `shard_online` / `shard_economy` / `shard_houses`; notable
+ kinds are appended to an append-only `shard_events` log; high-frequency kinds (vitals, supply
+ ticks) only update state and are not logged. A changed boot id on `server.hello` is detected as a
+ restart and stale "online" rows are cleared. On reconnect the backend backfills missed events via
+ the sidecar's `/history`.
+- **Live round-trips (REST).** For point-in-time reads the backend calls the sidecar directly —
+ `/char/serial/:serial`, `/roster/:account`, `/vendors/:account`, `/economy`, `/history` — plus
+ commands `/link/confirm` and `/towncrier`. The REST client (`utils/uoLinkClient.js`) **never
+ throws**: every call returns `{ ok, data, status }`, so a shard that is down or mid-restart
+ degrades to a `503`/retry banner instead of a 500.
+- **Fan-out to the browser.** Ingested events are pushed to browsers over **Server-Sent Events**.
+ Two channels exist: a **public** stream carrying only a safe allowlist of kinds, and an
+ **admin-only** stream that also includes sensitive kinds (staff audit, cheat detection, login
+ attempts, IPs). Sensitive kinds can never leak onto the public channel.
+
+### Account linking
+
+A player (or staff member) proves ownership of a game account without sharing any game credentials:
+
+1. In game, the player runs **`[link`** and receives a one-time code.
+2. On the website (Player portal, or Admin → Account for staff) they enter the code.
+3. The backend confirms the code with the sidecar (`POST /link/confirm`), which permanently tags the
+ game account with the website user id, and mirrors the link locally in `shard_account_links`.
+
+That mirror is the authorization basis for character reads: roster/vendor/character-sheet endpoints
+are **ownership-checked** so a user only sees accounts they linked. **Admins may view any
+character**; players and editor/moderator staff are limited to their own linked accounts.
+
+### What each audience sees
+
+| Surface | Endpoints | Who | Data |
+|---|---|---|---|
+| **Public** | `/api/v1/public/shard/*` (`status`, `feed`, `economy`, `online`, `idoc`, `stream`) | anyone | Shard up/down, gold-supply series, IDOC houses, a curated live feed, and **"Staff online"** — only players whose account is linked to a **staff** user (admin/editor/moderator), shown with name + map location. Linked *players* are never listed publicly; no vitals or account are exposed. |
+| **Player** | `/api/v1/player/shard/*` (`link`, `accounts`, `roster/:account`, `vendors/:account`, `char/:serial`, `sales`) | logged-in player | Their own linked accounts: character rosters, character sheets, player-vendor snapshots, and recent vendor sales. |
+| **Admin** | `/api/v1/admin/shard/*` (self-linking, same as player) · `/api/v1/admin/uo-link/*` (`config`, `towncrier`, `stream`) | staff / admin | Staff link their own accounts like players; **admins** additionally read *any* character's data, edit the sidecar connection config, publish/remove **town-crier** messages, and subscribe to the full event stream (incl. audit/cheat). |
+
+The sidecar URL and token are set once in **Admin → Shard**; if uo-link is not configured (or the
+shard is offline), every shard surface degrades gracefully — the public page still renders, showing
+the shard as offline.
+
+---
+
## Environment variables
Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.env` is git-ignored.**