Files
website/server/src/utils/shardBroadcast.js
wtclaude f3450686e0 feat(shard): admin-configurable visibility for every shard surface
Protocol 3.0 Part A. Replaces the static PUBLIC_KINDS allowlist - which
was the entire public/admin boundary - with per-feature, per-field
audience control an admin owns from Admin -> Shard Visibility.

Closes a live leak. BridgeJson.Actor() writes acct and webId;
shapeGuild() returned the stored payload verbatim; GET
/api/v1/public/shard/guilds is anonymous. Guild leaders' game account
names and website user ids were readable by anyone, and the same path
existed for governors. Both are now projected.

The ladder is anonymous < logged_in < player < staff < admin, each rung
implying the ones below. Staff satisfy `player` without a linked account
(as /player/* already does); `editor` is a content role and gets no
shard privilege, since mapping it to staff would silently widen what
editors see.

Two invariants are code, not configuration, and both reject rather than
silently ignore:

  1. acct/webId are admin-only always - not configurable, discarded on
     read as well as rejected on write.
  2. A kind absent from KIND_FEATURE never reaches anyone below admin.
     Fail closed, so a shard emitting a new event degrades to staff-only
     rather than to public.

Enforcement is three points over one config: requireFeature() on routes
(404 disabled, 403 out-of-rung) plus field projection; per-connection
filtering on SSE, where a subscriber's rung is resolved once at subscribe
time and frozen so a long-open stream cannot gain privilege; and
/public/shard/features so the SPA hides links it cannot follow.

PUBLIC_KINDS still exists and is still exported (/feed filtering,
notificationStreams) but is now derived from the kind map, so the two
can no longer drift. Defaults reproduce pre-3.0 behavior exactly - a
test pins the derived set against the old allowlist.

Also fixes an SSE resource leak found while testing: a client dropped
because its write threw was removed from the bucket but its keepalive
interval was never cleared, firing forever on a dead socket. Both paths
now go through one drop().

Tests: 478 server (33 new across shardVisibility + shardBroadcast),
43 client. Route manifest and OpenAPI spec regenerated.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 10:04:48 -05:00

167 lines
6.1 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 events to browsers over Server-Sent Events
// (plain HTTP — works through any reverse proxy).
//
// Since Protocol 3.0 the split is no longer "one public channel with a static
// allowlist plus one admin channel". Each subscriber carries the audience rung
// it resolved to at subscribe time, and every frame is
//
// 1. mapped kind → feature (an UNMAPPED kind reaches nobody below admin —
// fail closed; see utils/shardVisibility.js rule 2),
// 2. gated on that feature being enabled, streamed, and within the viewer's
// rung, and
// 3. passed through field projection, so `acct` / `webId` and any field an
// admin has re-gated are stripped per viewer.
//
// **This is the security boundary.** It used to be the PUBLIC_KINDS set in this
// file; it is now the kind map plus the visibility config. PUBLIC_KINDS still
// exists and is still exported, but it is now DERIVED from the kind map (see
// shardVisibility.js) so the two can no longer drift.
//
// shardIngest calls broadcast(event) for each ingested event; the public/admin
// SSE route handlers call subscribe(req, res, channel).
const visibility = require('./shardVisibility')
const log = require('./logger')('shard-broadcast')
// Re-exported for back-compat: shardEvents `/feed` filtering and
// config/notificationStreams.js both ask "is this kind public-safe?".
const { PUBLIC_KINDS } = visibility
// Open streams. Each entry is { res, level }. The admin bucket is kept separate
// because it is unconditional and must not depend on a config read.
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.
//
// The viewer's rung is resolved ONCE, here, and frozen for the life of the
// connection — a long-lived stream must not silently gain privilege because the
// caller's session changed underneath it. (Config changes, by contrast, DO take
// effect live: the config is read per broadcast, cached ~5s.)
async function subscribe(req, res, channel) {
const bucket = clients[channel]
if (!bucket) {
res.status(400).end()
return
}
let level = 'admin'
if (channel === 'public') {
try {
level = await visibility.viewerLevel(req)
} catch (err) {
// Fail closed: an unresolvable viewer is anonymous, not privileged.
log.warn('viewerLevel failed on subscribe; treating as anonymous', { message: err.message })
level = 'anonymous'
}
}
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')
const client = { res, level, ping: null }
bucket.add(client)
client.ping = setInterval(() => {
try {
res.write(': ping\n\n')
} catch {
/* write after close — cleanup below handles it */
}
}, KEEPALIVE_MS)
const cleanup = () => drop(bucket, client)
req.on('close', cleanup)
res.on('error', cleanup)
}
// The ONLY way a client leaves a bucket. Clearing the keepalive here (rather
// than only in the close handler) matters: a client dropped because its write
// threw never fires `req.close`, so its interval would otherwise keep firing on
// a dead socket for the life of the process.
function drop(bucket, client) {
clearInterval(client.ping)
bucket.delete(client)
}
function writeTo(bucket, client, payload) {
try {
client.res.write(payload)
} catch (err) {
log.warn('sse write failed; dropping client', { message: err.message })
drop(bucket, client)
}
}
// Fan an ingested event out. The admin channel gets it verbatim, always. Public
// subscribers are filtered and projected per their own rung — so two viewers on
// the same channel can legitimately receive different versions of one frame, or
// one of them nothing at all.
async function broadcast(event) {
if (!event || !event.kind) return
if (clients.admin.size) {
const frame = `data: ${JSON.stringify(event)}\n\n`
for (const client of [...clients.admin]) writeTo(clients.admin, client, frame)
}
if (!clients.public.size) return
let config
try {
config = await visibility.getConfig()
} catch (err) {
// Fail closed: without a config we cannot prove a frame is safe to send.
log.error('visibility config unavailable; withholding public frame', err)
return
}
// Most frames land on one rung set, so cache the serialised payload per level
// instead of re-projecting and re-stringifying for every subscriber.
const byLevel = new Map()
for (const client of [...clients.public]) {
let frame = byLevel.get(client.level)
if (frame === undefined) {
frame = visibility.kindVisibleTo(event.kind, client.level, config)
? `data: ${JSON.stringify(visibility.projectFeature(visibility.KIND_FEATURE.get(event.kind), event, client.level, config))}\n\n`
: null
byLevel.set(client.level, frame)
}
if (frame) writeTo(clients.public, client, frame)
}
}
// Close every open stream (graceful shutdown). Clears each keepalive timer too —
// without that the intervals keep the event loop alive after the streams are
// gone, and the process won't exit.
function closeAll() {
for (const bucket of Object.values(clients)) {
for (const client of [...bucket]) {
drop(bucket, client)
try {
client.res.end()
} catch {
/* ignore */
}
}
}
}
function stats() {
return { publicClients: clients.public.size, adminClients: clients.admin.size }
}
module.exports = { subscribe, broadcast, closeAll, stats, PUBLIC_KINDS }