Per request, split the single public house registry into three role-scoped views: - Public /site/houses → only houses in DANGER (IDOC), by LOCATION (region + map/ coords). No owner, price, co-owners or decay detail. Renamed "Houses in danger"; kept live via the public house.decay feed. The full-registry deltas (house.update / house.remove — which carry owner/price) are REMOVED from the public SSE allowlist so they never reach the public channel. - Staff full registry → new /admin/houses (admin + moderator, RoleGate + MOD_PATHS) backed by GET /admin/shard/houses (modAccess), with owner/price/co-owners/decay and search, kept live on the admin SSE channel. - Player portal → "My houses" home-status section (own houses only, with decay/ IDOC status) via GET /player/shard/houses, scoped to the caller's linked accounts. Server tests green, client build clean, swagger regenerated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
172 lines
7.0 KiB
JavaScript
172 lines
7.0 KiB
JavaScript
// ── 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' })
|
|
}
|
|
}
|
|
|
|
// GET /admin/shard/houses — the FULL house registry (owner, price, co-owners,
|
|
// decay), staff-only (modAccess). The public /public/shard/houses shows only IDOC
|
|
// houses with location; this is the complete board, kept live for staff on the
|
|
// admin SSE channel (house.update / house.remove).
|
|
async function listHouses(req, res) {
|
|
try {
|
|
return res.json(await shardState.listHouses())
|
|
} catch (err) {
|
|
log.error('shardOps.listHouses', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
module.exports = { kick, ban, unban, broadcast, listPages, respondPage, closePage, listAudit, listHouses }
|