feat(shard): admin write plane, help-page queue, and public champion board
Wire up the three uo-link sidecar surfaces that weren't integrated yet. Champion spawns - Ingest champ.update/champ.remove into a new shard_champs table (served from our own store, like online/houses); public /site/champs board with a nav link, live via the existing SSE feed (champ.* added to the public allowlist). Staff write plane (admin + moderator) - kick / ban / unban / broadcast via /admin/shard/*; actor is stamped server-side from the session, never the browser. Sidecar status codes mapped (403 disabled/ protected, 404 unknown, 503/504 transient). admin.audit events are logged and surfaced at /admin/shard/audit. - New admin "In-Game Ops" view (/admin/shard-ops), plus per-account Kick/Ban/Unban on the user-detail and character views (ShardAccountActions, self-gated to staff). Help-page (support) queue - Ingest page.new/updated/closed into a new shard_pages table; respond/close via /admin/shard/pages/*. Champ board and page queue are snapshotted from the sidecar's /champs and /pages on every WS (re)connect (guarded so a failed call never wipes local state). Verified live end-to-end against MariaDB + the Rust sidecar + ServUO; unit tests cover ingest routing (shardIngest.champsPages.test.js). Swagger regenerated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
This commit is contained in:
@@ -12,6 +12,7 @@ const authProviders = require('./authProviders.controller')
|
||||
const discordBot = require('./discordBot.controller')
|
||||
const emailConfig = require('./emailConfig.controller')
|
||||
const uoLink = require('./uoLink.controller')
|
||||
const shardOps = require('./shardOps.controller')
|
||||
const usersShard = require('./usersShard.controller')
|
||||
const selfShard = require('../player/shard.controller')
|
||||
const moderation = require('./moderation.controller')
|
||||
@@ -181,6 +182,112 @@ adminRouter.get(
|
||||
selfShard.getSales,
|
||||
)
|
||||
|
||||
// ── In-game staff operations (uo-link write plane + support queue) ─────
|
||||
// Privileged live-shard actions and the help-page queue, open to moderators as
|
||||
// well as admins (modAccess). `actor` is stamped server-side from the session in
|
||||
// the controller — the body never carries it. See shardOps.controller.js.
|
||||
adminRouter.post(
|
||||
'/shard/kick',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Kick every live session of an account (admin/moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, serial: { type: "string" } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Kicked', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Protected target or write plane disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
modAccess,
|
||||
body('account').optional({ values: 'falsy' }).matches(SHARD_ACCOUNT_RE),
|
||||
body('serial').optional({ values: 'falsy' }).matches(/^0x[0-9a-fA-F]+$/),
|
||||
validate,
|
||||
shardOps.kick,
|
||||
)
|
||||
adminRouter.post(
|
||||
'/shard/ban',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Ban an account, timed or indefinite (admin/moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, serial: { type: "string" }, durationSec: { type: "integer" }, reason: { type: "string" } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Banned', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Protected target or write plane disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
modAccess,
|
||||
body('account').optional({ values: 'falsy' }).matches(SHARD_ACCOUNT_RE),
|
||||
body('serial').optional({ values: 'falsy' }).matches(/^0x[0-9a-fA-F]+$/),
|
||||
body('durationSec').optional().isInt({ min: 0, max: 315360000 }),
|
||||
body('reason').optional({ values: 'falsy' }).isString().trim().isLength({ max: 500 }),
|
||||
validate,
|
||||
shardOps.ban,
|
||||
)
|
||||
adminRouter.post(
|
||||
'/shard/unban',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Clear an account ban (admin/moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" } }, required: ["account"] } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Unbanned', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
modAccess,
|
||||
body('account').matches(SHARD_ACCOUNT_RE),
|
||||
validate,
|
||||
shardOps.unban,
|
||||
)
|
||||
adminRouter.post(
|
||||
'/shard/broadcast',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Broadcast a system message to everyone online (admin/moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { text: { type: "string" }, hue: { type: "integer" } }, required: ["text"] } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Broadcast', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
modAccess,
|
||||
body('text').isString().trim().isLength({ min: 1, max: 300 }),
|
||||
body('hue').optional().isInt({ min: 0, max: 3000 }),
|
||||
validate,
|
||||
shardOps.broadcast,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/shard/pages',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Open help-page (support) queue (admin/moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Open pages', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
modAccess,
|
||||
shardOps.listPages,
|
||||
)
|
||||
adminRouter.post(
|
||||
'/shard/pages/:id/respond',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Reply to a help page, optionally closing it (admin/moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Page id (sender serial).' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { message: { type: "string" }, close: { type: "boolean" } }, required: ["message"] } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Responded', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Unknown page', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
modAccess,
|
||||
param('id').matches(/^0x[0-9a-fA-F]+$/),
|
||||
body('message').isString().trim().isLength({ min: 1, max: 500 }),
|
||||
body('close').optional().isBoolean(),
|
||||
validate,
|
||||
shardOps.respondPage,
|
||||
)
|
||||
adminRouter.post(
|
||||
'/shard/pages/:id/close',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Resolve a help page without a reply (admin/moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Page id (sender serial).' }
|
||||
/* #swagger.responses[200] = { description: 'Closed', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
modAccess,
|
||||
param('id').matches(/^0x[0-9a-fA-F]+$/),
|
||||
validate,
|
||||
shardOps.closePage,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/shard/audit',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Recent in-game moderation audit events (admin/moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'admin.audit events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEvent" } } } } } */
|
||||
modAccess,
|
||||
shardOps.listAudit,
|
||||
)
|
||||
|
||||
// ── Image uploads (screenshots/gallery) ───────────────────────────────
|
||||
const UPLOAD_DIR =
|
||||
process.env.UPLOAD_DIR || path.join(__dirname, '..', '..', '..', '..', 'uploads')
|
||||
|
||||
158
server/src/router/v1/admin/shardOps.controller.js
Normal file
158
server/src/router/v1/admin/shardOps.controller.js
Normal file
@@ -0,0 +1,158 @@
|
||||
// ── 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 }
|
||||
@@ -176,6 +176,14 @@ publicRouter.get(
|
||||
/* #swagger.responses[200] = { description: 'IDOC houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
|
||||
shard.getIdoc,
|
||||
)
|
||||
publicRouter.get(
|
||||
'/shard/champs',
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Current champion-spawn board (all categories)'
|
||||
// #swagger.description = 'The live board of every champion / mini-champ / sea-boss spawn. Update in place via the champ.update / champ.remove frames on /shard/stream.'
|
||||
/* #swagger.responses[200] = { description: 'Champion spawns, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
shard.getChamps,
|
||||
)
|
||||
publicRouter.get(
|
||||
'/shard/stream',
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
|
||||
@@ -92,9 +92,21 @@ async function getIdoc(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/champs — the current champion-spawn board (all categories).
|
||||
// Served from our own store; live deltas (champ.update / champ.remove) arrive on
|
||||
// the public SSE stream so the page can update in place.
|
||||
async function getChamps(req, res) {
|
||||
try {
|
||||
return res.json(await shardState.listChamps())
|
||||
} catch (err) {
|
||||
log.error('shard.getChamps', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/stream — public live-event SSE channel (safe kinds only).
|
||||
function stream(req, res) {
|
||||
broadcast.subscribe(req, res, 'public')
|
||||
}
|
||||
|
||||
module.exports = { getStatus, getFeed, getEconomy, getOnline, getIdoc, stream }
|
||||
module.exports = { getStatus, getFeed, getEconomy, getOnline, getIdoc, getChamps, stream }
|
||||
|
||||
Reference in New Issue
Block a user