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:
@@ -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
|
||||
|
||||
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 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) => {
|
||||
|
||||
Reference in New Issue
Block a user