Files
website/server/src/utils/shardBroadcast.js
Claude c4245e3f6a Restrict public presence to staff + let admins view any character
Public "Online now" now lists only players whose game account is linked
to a STAFF website user (admin/editor/moderator) — linked players are no
longer exposed publicly with their name and location. listOnlineLinked
joins through to users and filters on role; the section is relabeled
"Staff online".

Character/roster/vendor reads gain an admin bypass: admins may view any
character's data, while players (and editor/moderator staff) stay limited
to accounts they have personally linked. The bypass lives in the shared
player controller and only ever widens access for genuine admins.

Also finalizes the uo-link character/vendor front end (player + admin
character sheets, VendorSales component, ShardChar removed) and
regenerates swagger-output.json.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kj5s1QCKobuFPYmqxjy1q
2026-07-11 09:15:18 -05:00

118 lines
3.5 KiB
JavaScript

// ── Shard live-feed SSE broadcaster ────────────────────────────────────────
//
// The browser can't talk to the sidecar's WebSocket directly (the token must
// never reach it, and the WS may be on another host). Instead the server ingests
// the WS feed and re-broadcasts curated events to browsers over Server-Sent
// Events (plain HTTP — works through any reverse proxy).
//
// Two channels:
// • public — safe kinds only (sales, deaths, IDOC, logins, economy). No IPs,
// no account-login attempts, no staff audit / cheat events.
// • admin — everything, including the sensitive kinds above.
//
// shardIngest calls broadcast(event) for each ingested event; the public/admin
// SSE route handlers call subscribe(req, res, channel).
const log = require('./logger')('shard-broadcast')
// Kinds safe to expose to unauthenticated browsers. Note: vendor.sale is
// deliberately NOT here — sales are owner-private (a linked player sees only
// their own, via /player/shard/sales).
const PUBLIC_KINDS = new Set([
'player.death',
'player.murdered',
'mob.killed',
'house.decay',
'quest.complete',
'skill.gain',
'fame.change',
'karma.change',
'mob.login',
'mob.logout',
'economy.supply',
'server.hello',
'server.shutdown',
'server.crashed',
])
// Open response streams per channel.
const clients = { public: new Set(), admin: new Set() }
const KEEPALIVE_MS = 25000
// Register an SSE stream on a channel. Sets the SSE headers, sends an initial
// comment, keeps the connection warm with periodic pings, and cleans up on close.
function subscribe(req, res, channel) {
const bucket = clients[channel]
if (!bucket) {
res.status(400).end()
return
}
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no', // disable proxy buffering so events flush immediately
})
res.write('retry: 5000\n\n') // tell EventSource to reconnect after 5s if dropped
res.write(': connected\n\n')
bucket.add(res)
const ping = setInterval(() => {
try {
res.write(': ping\n\n')
} catch {
/* write after close — cleanup below handles it */
}
}, KEEPALIVE_MS)
const cleanup = () => {
clearInterval(ping)
bucket.delete(res)
}
req.on('close', cleanup)
res.on('error', cleanup)
}
function writeTo(bucket, payload) {
for (const res of bucket) {
try {
res.write(payload)
} catch (err) {
log.warn('sse write failed; dropping client', { message: err.message })
bucket.delete(res)
}
}
}
// Fan an ingested event out to the admin channel (always) and the public
// channel (safe kinds only). A no-op when nobody is subscribed.
function broadcast(event) {
if (!event || !event.kind) return
const frame = `data: ${JSON.stringify(event)}\n\n`
if (clients.admin.size) writeTo(clients.admin, frame)
if (clients.public.size && PUBLIC_KINDS.has(event.kind)) writeTo(clients.public, frame)
}
// Close every open stream (graceful shutdown).
function closeAll() {
for (const channel of Object.values(clients)) {
for (const res of channel) {
try {
res.end()
} catch {
/* ignore */
}
}
channel.clear()
}
}
function stats() {
return { publicClients: clients.public.size, adminClients: clients.admin.size }
}
module.exports = { subscribe, broadcast, closeAll, stats, PUBLIC_KINDS }