// ── Admin: in-game staff operations (uo-link write plane + support queue) ──── // // The privileged "write plane" (§6 of the sidecar guide): kick / ban / unban / // broadcast against the live shard, plus the help-page (support ticket) queue. // Gated admin+moderator at the route (modAccess) — the sidecar trusts the // loopback socket, so authorization is entirely the site's responsibility. // // SECURITY: `actor` (who is taking the action) is ALWAYS set here from the // authenticated session (req.user.username), never from the request body, so an // action can't be attributed to someone else. The shard records it in its console // log, the ban's BanDealer tag, and the admin.audit event it echoes back. const uoLinkClient = require('../../../utils/uoLinkClient') const shardState = require('../../../model/shardState/shardState.model') const shardEvents = require('../../../model/shardEvents/shardEvents.model') const activity = require('../../../model/activity/activity.model') const log = require('../../../utils/logger')('admin-shard-ops') // Map a never-throw uoLinkClient result onto an HTTP response. `okData` shapes the // success body. Mirrors the sidecar's documented status codes so the UI can tell a // transient outage (503/504 — retry) from a real rejection (403/404). function relay(res, result, okData) { if (result.ok) return res.json(okData(result.data)) switch (result.status) { case 400: return res.status(400).json({ message: (result.data && result.data.error) || 'The shard rejected that request.' }) case 403: return res.status(403).json({ message: (result.data && result.data.error) || 'That action was refused — the target is protected, or the write plane is disabled on the shard.', }) case 404: return res.status(404).json({ message: 'No such account or target on the shard.' }) case 503: case 504: case 0: return res.status(503).json({ message: 'The shard is unavailable right now — try again shortly.' }) default: return res.status(502).json({ message: 'Could not reach the shard.' }) } } // POST /admin/shard/kick — disconnect every live session of an account (or serial). async function kick(req, res) { const { account, serial } = req.body const actor = req.user.username try { const result = await uoLinkClient.adminKick({ actor, account, serial }) if (result.ok) await activity.log({ req, action: 'shard.kick', detail: { account, serial } }) return relay(res, result, (d) => d || { ok: true }) } catch (err) { log.error('shardOps.kick', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // POST /admin/shard/ban — ban an account (works offline); durationSec 0/absent = indefinite. async function ban(req, res) { const { account, serial, durationSec, reason } = req.body const actor = req.user.username try { const result = await uoLinkClient.adminBan({ actor, account, serial, durationSec, reason }) if (result.ok) await activity.log({ req, action: 'shard.ban', detail: { account, serial, durationSec, reason } }) return relay(res, result, (d) => d || { ok: true }) } catch (err) { log.error('shardOps.ban', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // POST /admin/shard/unban — clear an account's ban. async function unban(req, res) { const { account } = req.body const actor = req.user.username try { const result = await uoLinkClient.adminUnban({ actor, account }) if (result.ok) await activity.log({ req, action: 'shard.unban', detail: { account } }) return relay(res, result, (d) => d || { ok: true }) } catch (err) { log.error('shardOps.unban', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // POST /admin/shard/broadcast — a system message to everyone online. async function broadcast(req, res) { const { text, hue } = req.body const actor = req.user.username try { const result = await uoLinkClient.adminBroadcast({ actor, text, hue }) if (result.ok) await activity.log({ req, action: 'shard.broadcast', detail: { text } }) return relay(res, result, (d) => d || { ok: true }) } catch (err) { log.error('shardOps.broadcast', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // GET /admin/shard/pages — the open help-page (support) queue, from our store. async function listPages(req, res) { try { return res.json(await shardState.listPages()) } catch (err) { log.error('shardOps.listPages', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // POST /admin/shard/pages/:id/respond — reply to a player (optionally close). async function respondPage(req, res) { const { id } = req.params const { message, close } = req.body try { const result = await uoLinkClient.respondPage(id, { message, close: Boolean(close) }) if (result.ok) { await activity.log({ req, action: 'shard.page.respond', detail: { pageId: id, close: Boolean(close) } }) // Close removes the page from the queue; reflect it locally at once (the // page.closed event will confirm it, but the UI shouldn't wait a poll cycle). if (close) await shardState.removePage(id).catch(() => {}) } return relay(res, result, (d) => d || { ok: true }) } catch (err) { log.error('shardOps.respondPage', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // POST /admin/shard/pages/:id/close — resolve a page without a reply. async function closePage(req, res) { const { id } = req.params try { const result = await uoLinkClient.closePage(id) if (result.ok) { await activity.log({ req, action: 'shard.page.close', detail: { pageId: id } }) await shardState.removePage(id).catch(() => {}) } return relay(res, result, (d) => d || { ok: true }) } catch (err) { log.error('shardOps.closePage', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // GET /admin/shard/audit — recent moderation audit events (admin.audit), from the // ingested event log. Seeds the live audit log the panel keeps current over SSE. async function listAudit(req, res) { try { const limit = req.query.limit return res.json(await shardEvents.list({ kind: 'admin.audit', limit })) } catch (err) { log.error('shardOps.listAudit', err) return res.status(500).json({ message: 'Internal Server Error' }) } } module.exports = { kick, ban, unban, broadcast, listPages, respondPage, closePage, listAudit }