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:
@@ -383,6 +383,54 @@ CREATE TABLE IF NOT EXISTS shard_account_links (
|
||||
INDEX idx_shard_links_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Current champion-spawn board, upserted on champ.update and removed on
|
||||
-- champ.remove. Mirrors the sidecar's /champs projection into our own store so
|
||||
-- the public Champions page (and its live deltas) survive a shard outage, the
|
||||
-- same way shard_online / shard_houses do. Three families share one table, told
|
||||
-- apart by `category` (champion | mini | sea); category-specific fields (level,
|
||||
-- kills, boss, restartAt, hits, …) live in the JSON `payload` so the schema does
|
||||
-- not have to model every variant.
|
||||
CREATE TABLE IF NOT EXISTS shard_champs (
|
||||
serial VARCHAR(20) NOT NULL PRIMARY KEY, -- controller/mobile serial (opaque hex)
|
||||
category VARCHAR(16) NULL, -- champion | mini | sea
|
||||
type VARCHAR(80) NULL,
|
||||
name VARCHAR(120) NULL,
|
||||
status VARCHAR(16) NULL, -- active | cooldown | dormant
|
||||
active TINYINT(1) NOT NULL DEFAULT 0,
|
||||
map VARCHAR(40) NULL,
|
||||
x INT NULL,
|
||||
y INT NULL,
|
||||
z INT NULL,
|
||||
boss_up TINYINT(1) NOT NULL DEFAULT 0,
|
||||
payload JSON NOT NULL, -- the full champ.update object
|
||||
t BIGINT NULL, -- event time, epoch ms
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_shard_champs_category (category)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Current open help-page (support ticket) queue, upserted on page.new/page.updated
|
||||
-- and removed on page.closed. Snapshotted authoritatively from the sidecar's
|
||||
-- GET /pages on every (re)connect. page_id is the sender's serial (one page per
|
||||
-- player). Staff-only data — served on the admin channel, never public.
|
||||
CREATE TABLE IF NOT EXISTS shard_pages (
|
||||
page_id VARCHAR(20) NOT NULL PRIMARY KEY, -- sender serial (one page per player)
|
||||
type VARCHAR(40) NULL, -- Bug | Stuck | Account | Question | ...
|
||||
sender_name VARCHAR(120) NULL,
|
||||
sender_acct VARCHAR(120) NULL,
|
||||
web_id INT NULL, -- linked website user id, if any
|
||||
message TEXT NULL,
|
||||
map VARCHAR(40) NULL,
|
||||
x INT NULL,
|
||||
y INT NULL,
|
||||
z INT NULL,
|
||||
sent_ms BIGINT NULL, -- when the page was opened, epoch ms
|
||||
handled TINYINT(1) NOT NULL DEFAULT 0, -- a staffer claimed it in game
|
||||
handler VARCHAR(120) NULL,
|
||||
payload JSON NOT NULL, -- the full page.new/updated object
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_shard_pages_handled (handled)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Discord bot moderation core (Phase 2). These tables are owned by the bot
|
||||
-- process (its own DB pool, bot/src/db.js) — the main server never reads or
|
||||
-- writes them. They live in the same physical database as everything else
|
||||
|
||||
@@ -113,6 +113,50 @@ const listHousesByAccounts = (accounts) =>
|
||||
accounts,
|
||||
)
|
||||
|
||||
// ── Champion spawns ────────────────────────────────────────────────────────
|
||||
const CHAMP_COLS =
|
||||
'serial, category, type, name, status, active, map, x, y, z, boss_up, payload, t, updated_at'
|
||||
|
||||
async function upsertChamp(serial, fields) {
|
||||
const cols = Object.keys(fields)
|
||||
const allCols = ['serial', ...cols]
|
||||
const insertCols = allCols.map((c) => `\`${c}\``).join(', ')
|
||||
const placeholders = allCols.map(() => '?').join(', ')
|
||||
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
|
||||
await query(
|
||||
`INSERT INTO shard_champs (${insertCols}) VALUES (${placeholders})
|
||||
ON DUPLICATE KEY UPDATE ${updates}`,
|
||||
[serial, ...cols.map((c) => fields[c])],
|
||||
)
|
||||
}
|
||||
|
||||
const removeChamp = (serial) => query('DELETE FROM shard_champs WHERE serial = ?', [serial])
|
||||
const clearChamps = () => query('DELETE FROM shard_champs')
|
||||
// Ordered by name (matches the sidecar's /champs ordering).
|
||||
const listChamps = () => query(`SELECT ${CHAMP_COLS} FROM shard_champs ORDER BY name ASC`)
|
||||
|
||||
// ── Help-page (support) queue ──────────────────────────────────────────────
|
||||
const PAGE_COLS =
|
||||
'page_id, type, sender_name, sender_acct, web_id, message, map, x, y, z, sent_ms, handled, handler, payload, updated_at'
|
||||
|
||||
async function upsertPage(pageId, fields) {
|
||||
const cols = Object.keys(fields)
|
||||
const allCols = ['page_id', ...cols]
|
||||
const insertCols = allCols.map((c) => `\`${c}\``).join(', ')
|
||||
const placeholders = allCols.map(() => '?').join(', ')
|
||||
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
|
||||
await query(
|
||||
`INSERT INTO shard_pages (${insertCols}) VALUES (${placeholders})
|
||||
ON DUPLICATE KEY UPDATE ${updates}`,
|
||||
[pageId, ...cols.map((c) => fields[c])],
|
||||
)
|
||||
}
|
||||
|
||||
const removePage = (pageId) => query('DELETE FROM shard_pages WHERE page_id = ?', [pageId])
|
||||
const clearPages = () => query('DELETE FROM shard_pages')
|
||||
// Oldest-open first so the queue reads like a work list.
|
||||
const listPages = () => query(`SELECT ${PAGE_COLS} FROM shard_pages ORDER BY sent_ms ASC`)
|
||||
|
||||
module.exports = {
|
||||
upsertOnline,
|
||||
removeOnline,
|
||||
@@ -127,4 +171,12 @@ module.exports = {
|
||||
upsertHouse,
|
||||
listIdocHouses,
|
||||
listHousesByAccounts,
|
||||
upsertChamp,
|
||||
removeChamp,
|
||||
clearChamps,
|
||||
listChamps,
|
||||
upsertPage,
|
||||
removePage,
|
||||
clearPages,
|
||||
listPages,
|
||||
}
|
||||
|
||||
@@ -172,6 +172,129 @@ async function listOnlineForAccounts(accounts) {
|
||||
return rows.map(shapeOnline)
|
||||
}
|
||||
|
||||
// ── Champion spawns ────────────────────────────────────────────────────────
|
||||
// Upsert a champ spawn's state (champ.update). The full event is stored in
|
||||
// `payload` for the category-specific fields; a few columns are hoisted out for
|
||||
// querying/ordering. is-boss-up is derived from bossUp (sea bosses are always up).
|
||||
async function upsertChamp(ev) {
|
||||
if (!ev || !ev.serial) return
|
||||
await db.upsertChamp(ev.serial, {
|
||||
category: ev.category ?? null,
|
||||
type: ev.type ?? null,
|
||||
name: ev.name ?? null,
|
||||
status: ev.status ?? null,
|
||||
active: ev.active ? 1 : 0,
|
||||
map: ev.map ?? null,
|
||||
x: ev.x ?? null,
|
||||
y: ev.y ?? null,
|
||||
z: ev.z ?? null,
|
||||
boss_up: ev.bossUp ? 1 : 0,
|
||||
payload: JSON.stringify(ev),
|
||||
t: Number.isFinite(ev.t) ? ev.t : null,
|
||||
})
|
||||
}
|
||||
|
||||
const removeChamp = (serial) => (serial ? db.removeChamp(serial) : Promise.resolve())
|
||||
const clearChamps = () => db.clearChamps()
|
||||
|
||||
// Return the stored champ.update payload (the shape the sidecar/UI expect),
|
||||
// falling back to the hoisted columns if an older row lacks a payload.
|
||||
function shapeChamp(r) {
|
||||
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
|
||||
return payload || {
|
||||
kind: 'champ.update',
|
||||
serial: r.serial,
|
||||
category: r.category,
|
||||
type: r.type,
|
||||
name: r.name,
|
||||
status: r.status,
|
||||
active: Boolean(r.active),
|
||||
map: r.map,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
z: r.z,
|
||||
bossUp: Boolean(r.boss_up),
|
||||
t: r.t,
|
||||
}
|
||||
}
|
||||
|
||||
async function listChamps() {
|
||||
const rows = await db.listChamps()
|
||||
return rows.map(shapeChamp)
|
||||
}
|
||||
|
||||
// Replace the whole board with a fresh snapshot (sidecar GET /champs on connect).
|
||||
async function replaceChamps(spawns) {
|
||||
await db.clearChamps()
|
||||
for (const ev of spawns || []) await upsertChamp(ev)
|
||||
}
|
||||
|
||||
// ── Help-page (support) queue ──────────────────────────────────────────────
|
||||
// Upsert a page (page.new / page.updated). The `sender` actor object carries the
|
||||
// name/acct/webId; the rest are top-level fields.
|
||||
async function upsertPage(ev) {
|
||||
const pageId = ev && (ev.pageId || (ev.sender && ev.sender.serial))
|
||||
if (!pageId) return
|
||||
const sender = ev.sender || {}
|
||||
await db.upsertPage(pageId, {
|
||||
type: ev.type ?? null,
|
||||
sender_name: sender.name ?? null,
|
||||
sender_acct: sender.acct ?? null,
|
||||
web_id: sender.webId ?? null,
|
||||
message: ev.message ?? null,
|
||||
map: ev.map ?? null,
|
||||
x: ev.x ?? null,
|
||||
y: ev.y ?? null,
|
||||
z: ev.z ?? null,
|
||||
sent_ms: Number.isFinite(ev.sentMs) ? ev.sentMs : null,
|
||||
handled: ev.handled ? 1 : 0,
|
||||
handler: ev.handler ?? null,
|
||||
payload: JSON.stringify(ev),
|
||||
})
|
||||
}
|
||||
|
||||
const removePage = (pageId) => (pageId ? db.removePage(pageId) : Promise.resolve())
|
||||
const clearPages = () => db.clearPages()
|
||||
|
||||
function shapePage(r) {
|
||||
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
|
||||
return {
|
||||
pageId: r.page_id,
|
||||
type: r.type,
|
||||
sender: { serial: r.page_id, name: r.sender_name, acct: r.sender_acct, webId: r.web_id },
|
||||
message: r.message,
|
||||
map: r.map,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
z: r.z,
|
||||
sentMs: r.sent_ms == null ? null : Number(r.sent_ms),
|
||||
handled: Boolean(r.handled),
|
||||
handler: r.handler,
|
||||
updatedAt: r.updated_at,
|
||||
// Keep the raw payload available for any field not hoisted above.
|
||||
payload: payload || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
async function listPages() {
|
||||
const rows = await db.listPages()
|
||||
return rows.map(shapePage)
|
||||
}
|
||||
|
||||
// Replace the whole queue with a fresh snapshot (sidecar GET /pages on connect).
|
||||
async function replacePages(pages) {
|
||||
await db.clearPages()
|
||||
for (const ev of pages || []) await upsertPage(ev)
|
||||
}
|
||||
|
||||
function safeJson(s) {
|
||||
try {
|
||||
return JSON.parse(s)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
upsertOnline,
|
||||
setOffline,
|
||||
@@ -186,4 +309,14 @@ module.exports = {
|
||||
upsertHouse,
|
||||
listIdoc,
|
||||
listHousesForAccounts,
|
||||
upsertChamp,
|
||||
removeChamp,
|
||||
clearChamps,
|
||||
listChamps,
|
||||
replaceChamps,
|
||||
upsertPage,
|
||||
removePage,
|
||||
clearPages,
|
||||
listPages,
|
||||
replacePages,
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -33,6 +33,9 @@ const PUBLIC_KINDS = new Set([
|
||||
'server.hello',
|
||||
'server.shutdown',
|
||||
'server.crashed',
|
||||
// Champion-spawn board deltas — the public Champions page renders these live.
|
||||
'champ.update',
|
||||
'champ.remove',
|
||||
])
|
||||
|
||||
// Open response streams per channel.
|
||||
|
||||
@@ -33,6 +33,7 @@ const LOGGED_KINDS = new Set([
|
||||
'karma.change',
|
||||
'audit.set',
|
||||
'audit.command',
|
||||
'admin.audit',
|
||||
'cheat.fastwalk',
|
||||
'link.request',
|
||||
'server.hello',
|
||||
@@ -132,6 +133,19 @@ async function applyStateChange(event, deps) {
|
||||
lastRefreshed: event.lastRefreshed,
|
||||
})
|
||||
return
|
||||
case 'champ.update':
|
||||
await shardState.upsertChamp(event)
|
||||
return
|
||||
case 'champ.remove':
|
||||
await shardState.removeChamp(event.serial)
|
||||
return
|
||||
case 'page.new':
|
||||
case 'page.updated':
|
||||
await shardState.upsertPage(event)
|
||||
return
|
||||
case 'page.closed':
|
||||
await shardState.removePage(event.pageId)
|
||||
return
|
||||
default:
|
||||
// No state side effect (e.g. vendor.sale, audit.*, cheat.*) — logging and
|
||||
// broadcasting still happen in ingest().
|
||||
|
||||
@@ -100,6 +100,10 @@ function getHistory({ kind, limit = 100 } = {}) {
|
||||
return call(`/history${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
const getEconomy = (limit = 100) => call(`/economy?limit=${encodeURIComponent(limit)}`)
|
||||
// Live board / queue projections — snapshotted on WS (re)connect and served from
|
||||
// our own store thereafter.
|
||||
const getChamps = () => call('/champs')
|
||||
const getPages = () => call('/pages')
|
||||
|
||||
// ── Commands ──────────────────────────────────────────────────────────────
|
||||
const confirmLink = (code, websiteUserId) =>
|
||||
@@ -109,6 +113,24 @@ const postTownCrier = ({ id, lines, durationSec }) =>
|
||||
call('/towncrier', { method: 'POST', body: { id, lines, durationSec } })
|
||||
const deleteTownCrier = (id) => call(`/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||
|
||||
// ── Staff write plane (§6) ─────────────────────────────────────────────────
|
||||
// Every call carries `actor` — the website username of the staff member — set by
|
||||
// the controller from the session, NEVER from the browser. The shard records it
|
||||
// for attribution and echoes an admin.audit event back over the WS feed.
|
||||
const adminKick = ({ actor, account, serial }) =>
|
||||
call('/admin/kick', { method: 'POST', body: { actor, account, serial } })
|
||||
const adminBan = ({ actor, account, serial, durationSec, reason }) =>
|
||||
call('/admin/ban', { method: 'POST', body: { actor, account, serial, durationSec, reason } })
|
||||
const adminUnban = ({ actor, account }) =>
|
||||
call('/admin/unban', { method: 'POST', body: { actor, account } })
|
||||
const adminBroadcast = ({ actor, text, hue }) =>
|
||||
call('/admin/broadcast', { method: 'POST', body: { actor, text, hue } })
|
||||
|
||||
// ── Help-page (support) queue commands (§6) ────────────────────────────────
|
||||
const respondPage = (pageId, { message, close }) =>
|
||||
call(`/pages/${encodeURIComponent(pageId)}/respond`, { method: 'POST', body: { message, close } })
|
||||
const closePage = (pageId) => call(`/pages/${encodeURIComponent(pageId)}/close`, { method: 'POST' })
|
||||
|
||||
module.exports = {
|
||||
invalidateConfig,
|
||||
health,
|
||||
@@ -118,8 +140,16 @@ module.exports = {
|
||||
getVendors,
|
||||
getHistory,
|
||||
getEconomy,
|
||||
getChamps,
|
||||
getPages,
|
||||
confirmLink,
|
||||
linkLookup,
|
||||
postTownCrier,
|
||||
deleteTownCrier,
|
||||
adminKick,
|
||||
adminBan,
|
||||
adminUnban,
|
||||
adminBroadcast,
|
||||
respondPage,
|
||||
closePage,
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ const WebSocket = require('ws')
|
||||
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||
const uoLinkClient = require('./uoLinkClient')
|
||||
const shardIngest = require('./shardIngest')
|
||||
const shardState = require('../model/shardState/shardState.model')
|
||||
const log = require('./logger')('uo-link-socket')
|
||||
|
||||
const BACKOFF_MIN_MS = 1000
|
||||
@@ -59,6 +60,21 @@ async function backfill() {
|
||||
const series = [...eco.data.series].reverse()
|
||||
for (const ev of series) await shardIngest.ingest(ev, { fromBackfill: true })
|
||||
}
|
||||
|
||||
// Champ board + help-page queue have no replay stream — snapshot the
|
||||
// authoritative current state directly (the sidecar guide's advice for both),
|
||||
// reconciling our tables to it so a stale row from before a disconnect can't
|
||||
// linger. Live champ.*/page.* deltas keep them fresh thereafter.
|
||||
const champs = await uoLinkClient.getChamps()
|
||||
if (champs.ok && champs.data && Array.isArray(champs.data.spawns)) {
|
||||
await shardState.replaceChamps(champs.data.spawns)
|
||||
log.info('snapshotted champ board from /champs', { count: champs.data.spawns.length })
|
||||
}
|
||||
const pages = await uoLinkClient.getPages()
|
||||
if (pages.ok && pages.data && Array.isArray(pages.data.pages)) {
|
||||
await shardState.replacePages(pages.data.pages)
|
||||
log.info('snapshotted help-page queue from /pages', { count: pages.data.pages.length })
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('backfill failed (continuing on live feed)', { message: err.message })
|
||||
}
|
||||
|
||||
@@ -1464,6 +1464,34 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/shard/champs": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Public · Shard"
|
||||
],
|
||||
"summary": "Current champion-spawn board (all categories)",
|
||||
"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.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Champion spawns, ordered by name",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/shard/stream": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -2119,6 +2147,456 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/shard/kick": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin · Shard"
|
||||
],
|
||||
"summary": "Kick every live session of an account (admin/moderator)",
|
||||
"description": "",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Kicked",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"403": {
|
||||
"description": "Protected target or write plane disabled",
|
||||
"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": {
|
||||
"account": {
|
||||
"type": "string"
|
||||
},
|
||||
"serial": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/shard/ban": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin · Shard"
|
||||
],
|
||||
"summary": "Ban an account, timed or indefinite (admin/moderator)",
|
||||
"description": "",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Banned",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"403": {
|
||||
"description": "Protected target or write plane disabled",
|
||||
"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": {
|
||||
"account": {
|
||||
"type": "string"
|
||||
},
|
||||
"serial": {
|
||||
"type": "string"
|
||||
},
|
||||
"durationSec": {
|
||||
"type": "integer"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/shard/unban": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin · Shard"
|
||||
],
|
||||
"summary": "Clear an account ban (admin/moderator)",
|
||||
"description": "",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Unbanned",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"account": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"account"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/shard/broadcast": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin · Shard"
|
||||
],
|
||||
"summary": "Broadcast a system message to everyone online (admin/moderator)",
|
||||
"description": "",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Broadcast",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"hue": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"text"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/shard/pages": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin · Shard"
|
||||
],
|
||||
"summary": "Open help-page (support) queue (admin/moderator)",
|
||||
"description": "",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Open pages",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/shard/pages/{id}/respond": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin · Shard"
|
||||
],
|
||||
"summary": "Reply to a help page, optionally closing it (admin/moderator)",
|
||||
"description": "",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Page id (sender serial)."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Responded",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"404": {
|
||||
"description": "Unknown page",
|
||||
"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": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"close": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"message"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/shard/pages/{id}/close": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin · Shard"
|
||||
],
|
||||
"summary": "Resolve a help page without a reply (admin/moderator)",
|
||||
"description": "",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Page id (sender serial)."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Closed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/shard/audit": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin · Shard"
|
||||
],
|
||||
"summary": "Recent in-game moderation audit events (admin/moderator)",
|
||||
"description": "",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "admin.audit events, newest first",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ShardEvent"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/dashboard": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -2819,6 +3297,143 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/posts/{id}/announce": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin · Posts"
|
||||
],
|
||||
"summary": "Get the announcement pipeline status for a post",
|
||||
"description": "",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "Post id."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The announce job for the post, or null if never announced",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"nullable": true,
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"401": {
|
||||
"description": "Not authenticated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/posts/{id}/announce/retry": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin · Posts"
|
||||
],
|
||||
"summary": "Retry one announcement delivery leg (town crier or Discord)",
|
||||
"description": "",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "Post id."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Updated announce job",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"404": {
|
||||
"description": "No announcement job for this post",
|
||||
"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": {
|
||||
"leg": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"towncrier",
|
||||
"discord"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"leg"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/wiki/categories": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -6023,6 +6638,298 @@
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin · Users"
|
||||
],
|
||||
"summary": "Get a single user (admin only)",
|
||||
"description": "",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "User id."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The user",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/User"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/users/{id}/shard/accounts": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin · Users"
|
||||
],
|
||||
"summary": "A user’s linked game accounts (admin only)",
|
||||
"description": "",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "User id."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Linked accounts",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ShardLink"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/users/{id}/shard/sales": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin · Users"
|
||||
],
|
||||
"summary": "Recent vendor sales on a user’s accounts (admin only)",
|
||||
"description": "",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "User id."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Vendor sales",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ShardVendorSale"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/users/{id}/shard/houses": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin · Users"
|
||||
],
|
||||
"summary": "Houses owned by a user’s accounts (admin only)",
|
||||
"description": "",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "User id."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Houses (IDOC first)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/users/{id}/shard/online": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin · Users"
|
||||
],
|
||||
"summary": "A user’s characters currently online (admin only)",
|
||||
"description": "",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "User id."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Online characters",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/uo-link/config": {
|
||||
|
||||
69
server/test/shardIngest.champsPages.test.js
Normal file
69
server/test/shardIngest.champsPages.test.js
Normal file
@@ -0,0 +1,69 @@
|
||||
const { test, beforeEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const shardIngest = require('../src/utils/shardIngest')
|
||||
|
||||
// Build a set of stub deps that record the champ/page/state calls the dispatcher
|
||||
// makes, plus a spy shardEvents.append and broadcast. Only the methods the tested
|
||||
// kinds touch need to be real; the rest are no-op async so ingest() never throws.
|
||||
function makeDeps() {
|
||||
const calls = { champUpsert: [], champRemove: [], pageUpsert: [], pageRemove: [], appended: [], broadcast: [] }
|
||||
const noop = async () => {}
|
||||
return {
|
||||
calls,
|
||||
shardEvents: { append: async (row) => { calls.appended.push(row); return true } },
|
||||
shardState: {
|
||||
upsertChamp: async (ev) => { calls.champUpsert.push(ev) },
|
||||
removeChamp: async (serial) => { calls.champRemove.push(serial) },
|
||||
upsertPage: async (ev) => { calls.pageUpsert.push(ev) },
|
||||
removePage: async (id) => { calls.pageRemove.push(id) },
|
||||
// Unused by these kinds but present so any stray routing is a no-op.
|
||||
clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop, addEconomySample: noop,
|
||||
},
|
||||
uoLinkConfig: { recordStatus: noop },
|
||||
broadcast: (ev) => { calls.broadcast.push(ev) },
|
||||
log: { warn() {}, info() {}, error() {} },
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => shardIngest.reset())
|
||||
|
||||
test('champ.update routes to shardState.upsertChamp and is not written to the event log', async () => {
|
||||
const deps = makeDeps()
|
||||
const ev = { kind: 'champ.update', serial: '0x1', category: 'champion', name: 'Abyss', status: 'active', t: 1 }
|
||||
const r = await shardIngest.ingest(ev, deps)
|
||||
assert.equal(deps.calls.champUpsert.length, 1)
|
||||
assert.equal(deps.calls.champUpsert[0].serial, '0x1')
|
||||
assert.equal(r.logged, false) // champ.* is state-only, not appended to shard_events
|
||||
assert.equal(deps.calls.appended.length, 0)
|
||||
assert.equal(deps.calls.broadcast.length, 1) // still broadcast live
|
||||
})
|
||||
|
||||
test('champ.remove routes to shardState.removeChamp', async () => {
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest({ kind: 'champ.remove', serial: '0x2', t: 2 }, deps)
|
||||
assert.deepEqual(deps.calls.champRemove, ['0x2'])
|
||||
})
|
||||
|
||||
test('page.new and page.updated upsert the page; page.closed removes it', async () => {
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest({ kind: 'page.new', pageId: '0x24C', type: 'Bug', sender: { name: 'Al' }, t: 3 }, deps)
|
||||
await shardIngest.ingest({ kind: 'page.updated', pageId: '0x24C', handled: true, t: 4 }, deps)
|
||||
await shardIngest.ingest({ kind: 'page.closed', pageId: '0x24C', t: 5 }, deps)
|
||||
assert.equal(deps.calls.pageUpsert.length, 2)
|
||||
assert.deepEqual(deps.calls.pageRemove, ['0x24C'])
|
||||
})
|
||||
|
||||
test('admin.audit is appended to the event log (moderation history)', async () => {
|
||||
const deps = makeDeps()
|
||||
const r = await shardIngest.ingest({ kind: 'admin.audit', action: 'ban', actor: 'web:jane', target: 'griefer', t: 6 }, deps)
|
||||
assert.equal(r.logged, true)
|
||||
assert.equal(deps.calls.appended.length, 1)
|
||||
assert.equal(deps.calls.appended[0].kind, 'admin.audit')
|
||||
})
|
||||
|
||||
test('champ.remove without a serial is a harmless no-op', async () => {
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest({ kind: 'champ.remove', t: 7 }, deps)
|
||||
assert.deepEqual(deps.calls.champRemove, [undefined])
|
||||
})
|
||||
Reference in New Issue
Block a user