Add admin shard control: config, status, town crier (phase 5)
- 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
This commit is contained in:
@@ -34,6 +34,7 @@ import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
|
|||||||
import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
|
import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
|
||||||
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
|
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
|
||||||
import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.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 AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx'
|
||||||
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
||||||
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
||||||
@@ -111,6 +112,7 @@ export default function App() {
|
|||||||
<Route path="activity" element={<ActivityAdmin />} />
|
<Route path="activity" element={<ActivityAdmin />} />
|
||||||
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
||||||
<Route path="discord-bot" element={<DiscordBotAdmin />} />
|
<Route path="discord-bot" element={<DiscordBotAdmin />} />
|
||||||
|
<Route path="shard" element={<ShardAdmin />} />
|
||||||
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
|
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
|
||||||
<Route path="users" element={<UsersAdmin />} />
|
<Route path="users" element={<UsersAdmin />} />
|
||||||
<Route path="account" element={<AccountAdmin />} />
|
<Route path="account" element={<AccountAdmin />} />
|
||||||
|
|||||||
@@ -223,6 +223,12 @@ export const api = {
|
|||||||
getDiscordBotConfig: () => req('/admin/discord-bot/config'),
|
getDiscordBotConfig: () => req('/admin/discord-bot/config'),
|
||||||
saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }),
|
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) -----
|
// ----- Email delivery / Gmail OAuth2 (admin only) -----
|
||||||
getEmailConfig: () => req('/admin/email/config'),
|
getEmailConfig: () => req('/admin/email/config'),
|
||||||
saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }),
|
saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }),
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const IconKey = () => <Icon><circle cx="8" cy="12" r="4" /><path d="M12 12h9M18
|
|||||||
const IconBot = () => <Icon><rect x="4" y="8" width="16" height="11" rx="2" /><path d="M12 8V4M8 13h.01M16 13h.01M9 17h6" /></Icon>
|
const IconBot = () => <Icon><rect x="4" y="8" width="16" height="11" rx="2" /><path d="M12 8V4M8 13h.01M16 13h.01M9 17h6" /></Icon>
|
||||||
const IconPulse = () => <Icon><path d="M3 12h3l2 6 4-14 2 8h7" /></Icon>
|
const IconPulse = () => <Icon><path d="M3 12h3l2 6 4-14 2 8h7" /></Icon>
|
||||||
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
|
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
|
||||||
|
const IconShard = () => <Icon><path d="M12 2l7 6-7 14-7-14z" /><path d="M5 8h14" /></Icon>
|
||||||
|
|
||||||
// Nav is grouped into collapsible categories. A group with no `title` renders
|
// 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`
|
// 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/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] },
|
||||||
{ to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, 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/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'] },
|
{ to: '/admin/bot-activity', label: 'Web Bot Activity', icon: IconPulse, roles: ['admin'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -95,6 +97,7 @@ const TITLES = {
|
|||||||
'/admin/activity': 'Activity Log',
|
'/admin/activity': 'Activity Log',
|
||||||
'/admin/bot-activity': 'Web Bot Activity',
|
'/admin/bot-activity': 'Web Bot Activity',
|
||||||
'/admin/discord-bot': 'Discord Bot',
|
'/admin/discord-bot': 'Discord Bot',
|
||||||
|
'/admin/shard': 'Shard (uo-link)',
|
||||||
'/admin/auth-providers': 'Authentication',
|
'/admin/auth-providers': 'Authentication',
|
||||||
'/admin/users': 'Users',
|
'/admin/users': 'Users',
|
||||||
'/admin/account': 'Account Security',
|
'/admin/account': 'Account Security',
|
||||||
|
|||||||
213
client/src/routes/admin/views/ShardAdmin.jsx
Normal file
213
client/src/routes/admin/views/ShardAdmin.jsx
Normal file
@@ -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 (
|
||||||
|
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||||
|
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||||
|
{label}
|
||||||
|
</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 (
|
||||||
|
<div style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ width: 9, height: 9, borderRadius: '50%', background: color, boxShadow: `0 0 8px ${color}` }} />
|
||||||
|
<span className="sans" style={{ fontSize: '0.9rem', color: 'var(--ink)', textTransform: 'capitalize' }}>
|
||||||
|
{config.status || 'disconnected'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{config.statusDetail && (
|
||||||
|
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: 'var(--muted)' }}>{config.statusDetail}</p>
|
||||||
|
)}
|
||||||
|
<div className="sans dim" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 16px', fontSize: '0.78rem', marginTop: 2 }}>
|
||||||
|
<span>Shard link: <strong style={{ color: 'var(--ink)' }}>{config.pluginConnected ? 'up' : 'down'}</strong></span>
|
||||||
|
<span>WS ingest: <strong style={{ color: 'var(--ink)' }}>{ingest.connected ? 'connected' : 'offline'}</strong></span>
|
||||||
|
<span>Reconnects: <strong style={{ color: 'var(--ink)' }}>{ingest.reconnects ?? 0}</strong></span>
|
||||||
|
<span>SSE clients: <strong style={{ color: 'var(--ink)' }}>{(config.sse?.publicClients ?? 0) + (config.sse?.adminClients ?? 0)}</strong></span>
|
||||||
|
{config.lastEventAt && <span style={{ gridColumn: '1 / -1' }}>Last event: {new Date(config.lastEventAt).toLocaleString()}</span>}
|
||||||
|
{health.uptime && <span style={{ gridColumn: '1 / -1' }}>Sidecar uptime: {health.uptime}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Town crier</h3>
|
||||||
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
|
||||||
|
Broadcast a message that every in-game town crier announces until it expires. Re-posting the same id replaces it.
|
||||||
|
</p>
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Message id</span>
|
||||||
|
<input type="text" value={id} onChange={(e) => setId(e.target.value)} className="input" placeholder="news-42" autoComplete="off" style={{ maxWidth: 220 }} />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Lines (one per line)</span>
|
||||||
|
<textarea value={text} onChange={(e) => setText(e.target.value)} className="input" rows={3} placeholder={'Hear ye!\nMarket tax is now 5%.'} style={{ resize: 'vertical' }} />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Duration (seconds)</span>
|
||||||
|
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={1} max={86400} style={{ maxWidth: 160 }} />
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||||
|
<button onClick={post} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Working…' : 'Post message'}</button>
|
||||||
|
<button onClick={remove} disabled={busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>Remove by id</button>
|
||||||
|
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||||
|
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <ErrorState message={error} />
|
||||||
|
if (!config) return <Loading />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ maxWidth: 560, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
|
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>Shard (uo-link)</h2>
|
||||||
|
|
||||||
|
<StatusPanel config={config} />
|
||||||
|
|
||||||
|
<Toggle checked={enabled} onChange={setEnabled} label="Enable the shard integration" />
|
||||||
|
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Base URL (REST)</span>
|
||||||
|
<input type="text" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} className="input" autoComplete="off" placeholder="http://127.0.0.1:8080" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">WebSocket URL (feed)</span>
|
||||||
|
<input type="text" value={wsUrl} onChange={(e) => setWsUrl(e.target.value)} className="input" autoComplete="off" placeholder="ws://127.0.0.1:8080/ws" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Auth token</span>
|
||||||
|
<input type="password" value={token} onChange={(e) => setToken(e.target.value)} className="input" autoComplete="new-password" placeholder={config.hasToken ? '•••••••• configured — leave blank to keep' : 'Shared secret from sidecar.toml'} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'block', maxWidth: 140 }}>
|
||||||
|
<span className="field-label">Protocol</span>
|
||||||
|
<input type="number" value={protocol} onChange={(e) => setProtocol(e.target.value)} className="input" min={1} max={99} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 4 }}>
|
||||||
|
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Saving…' : 'Save changes'}</button>
|
||||||
|
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||||
|
{saveError && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{saveError}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TownCrier />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ const botActivity = require('./botActivity.controller')
|
|||||||
const authProviders = require('./authProviders.controller')
|
const authProviders = require('./authProviders.controller')
|
||||||
const discordBot = require('./discordBot.controller')
|
const discordBot = require('./discordBot.controller')
|
||||||
const emailConfig = require('./emailConfig.controller')
|
const emailConfig = require('./emailConfig.controller')
|
||||||
|
const uoLink = require('./uoLink.controller')
|
||||||
const moderation = require('./moderation.controller')
|
const moderation = require('./moderation.controller')
|
||||||
const pagesCtrl = require('./pages.controller')
|
const pagesCtrl = require('./pages.controller')
|
||||||
const { isLoggedIn, requireRole } = require('../../../utils/auth')
|
const { isLoggedIn, requireRole } = require('../../../utils/auth')
|
||||||
@@ -985,4 +986,75 @@ adminRouter.delete(
|
|||||||
ctrl.deleteUser,
|
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
|
module.exports = adminRouter
|
||||||
|
|||||||
123
server/src/router/v1/admin/uoLink.controller.js
Normal file
123
server/src/router/v1/admin/uoLink.controller.js
Normal file
@@ -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 }
|
||||||
@@ -5,6 +5,8 @@ const app = require('./app')
|
|||||||
const internalApp = require('./internalApp')
|
const internalApp = require('./internalApp')
|
||||||
const botScore = require('./middleware/botScore')
|
const botScore = require('./middleware/botScore')
|
||||||
const uoLinkSocket = require('./utils/uoLinkSocket')
|
const uoLinkSocket = require('./utils/uoLinkSocket')
|
||||||
|
const uoLinkClient = require('./utils/uoLinkClient')
|
||||||
|
const uoLinkConfig = require('./model/uoLinkConfig/uoLinkConfig.model')
|
||||||
const shardBroadcast = require('./utils/shardBroadcast')
|
const shardBroadcast = require('./utils/shardBroadcast')
|
||||||
const { ensureSchema, close } = require('./utils/db')
|
const { ensureSchema, close } = require('./utils/db')
|
||||||
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
|
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
|
||||||
@@ -85,6 +87,7 @@ async function start() {
|
|||||||
// sidecar problem block server startup.
|
// sidecar problem block server startup.
|
||||||
try {
|
try {
|
||||||
await uoLinkSocket.start()
|
await uoLinkSocket.start()
|
||||||
|
await checkUoLink()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn('uo-link socket failed to start (continuing)', { error: err.message })
|
log.warn('uo-link socket failed to start (continuing)', { error: err.message })
|
||||||
}
|
}
|
||||||
@@ -92,6 +95,33 @@ async function start() {
|
|||||||
setupShutdown(server, internalServer)
|
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) {
|
function setupShutdown(server, internalServer) {
|
||||||
let closing = false
|
let closing = false
|
||||||
const shutdown = async (signal) => {
|
const shutdown = async (signal) => {
|
||||||
|
|||||||
@@ -80,6 +80,10 @@
|
|||||||
"name": "Admin · Discord Bot",
|
"name": "Admin · Discord Bot",
|
||||||
"description": "Discord bot token/config and live status (admin only)"
|
"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",
|
"name": "Admin · Auth Providers",
|
||||||
"description": "SSO provider configuration (admin only)"
|
"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": {
|
"/api/v1/player/account": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ const doc = {
|
|||||||
{ name: 'Admin · Activity', description: 'Admin activity log' },
|
{ name: 'Admin · Activity', description: 'Admin activity log' },
|
||||||
{ name: 'Admin · Bot Activity', description: 'Bot-scoring/ban state and emergency unban (admin only)' },
|
{ 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 · 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 · Auth Providers', description: 'SSO provider configuration (admin only)' },
|
||||||
{ name: 'Admin · Users', description: 'User management (admin only)' },
|
{ name: 'Admin · Users', description: 'User management (admin only)' },
|
||||||
],
|
],
|
||||||
@@ -587,6 +588,15 @@ const doc = {
|
|||||||
linkedAt: { type: 'string', format: 'date-time' },
|
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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user