diff --git a/client/src/App.jsx b/client/src/App.jsx
index 591df74..1560d18 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -34,6 +34,7 @@ import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx'
+import ShardAdmin from './routes/admin/views/ShardAdmin.jsx'
import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx'
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
@@ -111,6 +112,7 @@ export default function App() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/client/src/api/client.js b/client/src/api/client.js
index b3021dd..8263791 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -223,6 +223,12 @@ export const api = {
getDiscordBotConfig: () => req('/admin/discord-bot/config'),
saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }),
+ // ----- uo-link sidecar control (admin only) -----
+ getUoLinkConfig: () => req('/admin/uo-link/config'),
+ saveUoLinkConfig: (data) => req('/admin/uo-link/config', { method: 'PUT', body: data }),
+ postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }),
+ deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }),
+
// ----- Email delivery / Gmail OAuth2 (admin only) -----
getEmailConfig: () => req('/admin/email/config'),
saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }),
diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx
index 9cf3c69..4c6358b 100644
--- a/client/src/routes/admin/AdminLayout.jsx
+++ b/client/src/routes/admin/AdminLayout.jsx
@@ -37,6 +37,7 @@ const IconKey = () =>
const IconPulse = () =>
const IconUser = () =>
+const IconShard = () =>
// Nav is grouped into collapsible categories. A group with no `title` renders
// its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles`
@@ -72,6 +73,7 @@ const NAV = [
{ to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] },
{ to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] },
{ to: '/admin/discord-bot', label: 'Discord Bot', icon: IconBot, roles: ['admin'] },
+ { to: '/admin/shard', label: 'Shard (uo-link)', icon: IconShard, roles: ['admin'] },
{ to: '/admin/bot-activity', label: 'Web Bot Activity', icon: IconPulse, roles: ['admin'] },
],
},
@@ -95,6 +97,7 @@ const TITLES = {
'/admin/activity': 'Activity Log',
'/admin/bot-activity': 'Web Bot Activity',
'/admin/discord-bot': 'Discord Bot',
+ '/admin/shard': 'Shard (uo-link)',
'/admin/auth-providers': 'Authentication',
'/admin/users': 'Users',
'/admin/account': 'Account Security',
diff --git a/client/src/routes/admin/views/ShardAdmin.jsx b/client/src/routes/admin/views/ShardAdmin.jsx
new file mode 100644
index 0000000..cc854aa
--- /dev/null
+++ b/client/src/routes/admin/views/ShardAdmin.jsx
@@ -0,0 +1,213 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { Loading, ErrorState } from '../../../components/PageState.jsx'
+import { api } from '../../../api/client.js'
+
+// uo-link sidecar control panel. The auth token is write-only over this API —
+// stored encrypted, never returned — same convention as the Discord bot token.
+// Saving (re)starts the WS ingest client, so Enabled/URL/token changes take
+// effect immediately with no redeploy.
+
+function Toggle({ checked, onChange, label }) {
+ return (
+
+ )
+}
+
+const STATUS_COLOR = {
+ connected: '#7fd0a4',
+ reconnecting: '#e0b070',
+ error: '#d98b84',
+ disconnected: 'var(--muted)',
+}
+
+function StatusPanel({ config }) {
+ const color = STATUS_COLOR[config.status] || 'var(--muted)'
+ const ingest = config.ingest || {}
+ const health = config.health || {}
+ return (
+
+
+
+
+ {config.status || 'disconnected'}
+
+
+ {config.statusDetail && (
+
{config.statusDetail}
+ )}
+
+ Shard link: {config.pluginConnected ? 'up' : 'down'}
+ WS ingest: {ingest.connected ? 'connected' : 'offline'}
+ Reconnects: {ingest.reconnects ?? 0}
+ SSE clients: {(config.sse?.publicClients ?? 0) + (config.sse?.adminClients ?? 0)}
+ {config.lastEventAt && Last event: {new Date(config.lastEventAt).toLocaleString()}}
+ {health.uptime && Sidecar uptime: {health.uptime}}
+
+
+ )
+}
+
+// ── Town crier ──────────────────────────────────────────────────────────────
+function TownCrier() {
+ const [id, setId] = useState('')
+ const [text, setText] = useState('')
+ const [durationSec, setDurationSec] = useState(3600)
+ const [busy, setBusy] = useState(false)
+ const [msg, setMsg] = useState('')
+ const [error, setError] = useState('')
+
+ async function post() {
+ setBusy(true); setMsg(''); setError('')
+ const lines = text.split('\n').map((l) => l.trim()).filter(Boolean)
+ if (!id.trim() || lines.length === 0) {
+ setBusy(false)
+ return setError('An id and at least one line are required.')
+ }
+ try {
+ await api.admin.postTownCrier({ id: id.trim(), lines, durationSec: Number(durationSec) || undefined })
+ setMsg(`Posted “${id.trim()}”.`)
+ } catch (err) {
+ setError(err.message || 'Could not post.')
+ } finally {
+ setBusy(false)
+ }
+ }
+ async function remove() {
+ if (!id.trim()) return setError('Enter the id to remove.')
+ setBusy(true); setMsg(''); setError('')
+ try {
+ await api.admin.deleteTownCrier(id.trim())
+ setMsg(`Removed “${id.trim()}”.`)
+ } catch (err) {
+ setError(err.message || 'Could not remove.')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+ )
+}
+
+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 },
+ },
+ },
},
},
}