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>
This commit is contained in:
2026-07-28 10:04:48 -05:00
parent a3407ae654
commit f3450686e0
22 changed files with 2369 additions and 108 deletions

View File

@@ -0,0 +1,37 @@
const { query } = require('../../utils/db')
// One row per shard feature. Absent rows are fine — utils/shardVisibility.js
// compiles a default for every known feature and merges stored rows over it, so
// a fresh install with an empty table behaves exactly as the site did pre-v3.
const COLS = 'feature, enabled, audience, stream, field_rules, updated_by, updated_at'
const listAll = () => query(`SELECT ${COLS} FROM shard_feature_visibility`)
const getOne = (feature) =>
query(`SELECT ${COLS} FROM shard_feature_visibility WHERE feature = ?`, [feature])
// Upsert one feature's settings. `fieldRules` is stored as a JSON object of
// {field: rung}; the caller has already stripped locked fields and validated
// every rung against the ladder.
const upsert = ({ feature, enabled, audience, stream, fieldRules, updatedBy }) =>
query(
`INSERT INTO shard_feature_visibility (feature, enabled, audience, stream, field_rules, updated_by)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
enabled = VALUES(enabled),
audience = VALUES(audience),
stream = VALUES(stream),
field_rules = VALUES(field_rules),
updated_by = VALUES(updated_by)`,
[
feature,
enabled ? 1 : 0,
audience,
stream ? 1 : 0,
fieldRules == null ? null : JSON.stringify(fieldRules),
updatedBy ?? null,
],
)
module.exports = { listAll, getOne, upsert }

View File

@@ -0,0 +1,44 @@
// ── Shard feature visibility (model) ───────────────────────────────────────
//
// Thin row-shaping layer over shardVisibility.db. The policy — the ladder, the
// feature catalog, the locked fields, the kind→feature map — lives in
// utils/shardVisibility.js; this file only reads and writes rows.
const db = require('./shardVisibility.db')
// The `field_rules` JSON column comes back as a string on the mariadb driver.
function parseRules(raw) {
if (raw == null) return {}
if (typeof raw === 'object') return raw
try {
const parsed = JSON.parse(raw)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
} catch {
return {}
}
}
const toSafe = (row) =>
row && {
feature: row.feature,
enabled: !!row.enabled,
audience: row.audience,
stream: row.stream == null ? null : !!row.stream,
fieldRules: parseRules(row.field_rules),
updatedBy: row.updated_by,
updatedAt: row.updated_at,
}
async function listAll() {
const rows = await db.listAll()
return rows.map(toSafe)
}
async function getOne(feature) {
const rows = await db.getOne(feature)
return toSafe(rows[0])
}
const upsert = (entry) => db.upsert(entry)
module.exports = { listAll, getOne, upsert }

View File

@@ -23,6 +23,7 @@ const express = require('express')
const { body, param } = require('express-validator')
const shardOps = require('./shardOps.controller')
const shardVisibility = require('./shardVisibility.controller')
const selfShard = require('../player/shard.controller')
const { requireRole } = require('../../../utils/auth')
const validate = require('../../../middleware/validate')
@@ -31,6 +32,8 @@ const shardRouter = express.Router()
// Moderator gate. Admins can do everything a moderator can.
const modAccess = requireRole('admin', 'moderator')
// Admin-only gate, for settings that decide what the PUBLIC sees.
const adminOnly = requireRole('admin')
// ── Game account linking (self-service, any staff role) ───────────────
// Staff link their OWN in-game account here, exactly like players do under
@@ -232,4 +235,33 @@ shardRouter.get(
shardOps.listHouses,
)
// ── Feature visibility (admin only) ───────────────────────────────────
// Who can see which shard surface, and which sensitive fields within it. This
// decides what ANONYMOUS visitors get, so it sits above the moderator tier.
shardRouter.get(
'/visibility',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Get per-feature shard visibility config (admin only)'
// #swagger.description = 'The effective config (compiled defaults merged with stored overrides) plus the vocabulary the admin UI renders from: the audience ladder and the always-locked fields. Defaults reproduce pre-v3 behavior.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Visibility config', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardVisibilityConfig" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
shardVisibility.getVisibility,
)
shardRouter.put(
'/visibility',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Update per-feature shard visibility config (admin only)'
// #swagger.description = 'Patch one or more features. Unknown feature names, unknown rungs, and any attempt to configure a locked field (acct / webId — admin-only always) are rejected with 400 rather than silently dropped.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ShardVisibilityUpdate" } } } } */
/* #swagger.responses[200] = { description: 'Updated config', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardVisibilityConfig" } } } } */
/* #swagger.responses[400] = { description: 'Unknown feature, rung, or a locked field', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
body('features').isObject(),
validate,
shardVisibility.putVisibility,
)
module.exports = shardRouter

View File

@@ -0,0 +1,95 @@
// ── Admin · Shard visibility ───────────────────────────────────────────────
//
// Read/write the per-feature audience config that gates every shard-derived
// surface. Admin-only: this decides what anonymous visitors can see, so it is
// not part of the moderator tier.
//
// The policy itself (the ladder, the feature catalog, which fields are locked)
// lives in utils/shardVisibility.js. This controller only validates input
// against that policy and persists it.
const model = require('../../../model/shardVisibility/shardVisibility.model')
const visibility = require('../../../utils/shardVisibility')
const log = require('../../../utils/logger')('admin-shard-visibility')
// GET /admin/shard/visibility — the effective config (defaults merged with any
// stored overrides), plus the vocabulary the admin UI needs to render itself:
// the ladder, and which fields each feature exposes as configurable.
async function getVisibility(req, res) {
try {
const config = await visibility.getConfig()
return res.json({
ladder: visibility.LADDER,
lockedFields: Object.keys(visibility.LOCKED_FIELDS),
defaults: visibility.compileDefaults(),
features: config,
})
} catch (err) {
log.error('getVisibility', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// PUT /admin/shard/visibility — replace the settings for one or more features.
// Body: { features: { <name>: { enabled, audience, stream, fieldRules } } }
//
// Rejects unknown feature names, unknown rungs, and any attempt to configure a
// locked field — a 400 rather than a silent drop, so an admin who tries to make
// `acct` public learns that it is not negotiable.
async function putVisibility(req, res) {
try {
const incoming = req.body?.features
if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) {
return res.status(400).json({ message: 'features object required' })
}
const entries = []
for (const [name, patch] of Object.entries(incoming)) {
if (!visibility.isFeature(name)) {
return res.status(400).json({ message: `Unknown feature: ${name}` })
}
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
return res.status(400).json({ message: `Invalid settings for ${name}` })
}
if (patch.audience != null && !visibility.isLevel(patch.audience)) {
return res.status(400).json({ message: `Unknown audience for ${name}: ${patch.audience}` })
}
const fieldRules = {}
for (const [field, level] of Object.entries(patch.fieldRules || {})) {
if (Object.hasOwn(visibility.LOCKED_FIELDS, field)) {
return res.status(400).json({ message: `Field '${field}' is admin-only and cannot be configured` })
}
if (!visibility.isLevel(level)) {
return res.status(400).json({ message: `Unknown rung for ${name}.${field}: ${level}` })
}
fieldRules[field] = level
}
const current = (await visibility.getConfig())[name]
entries.push({
feature: name,
enabled: patch.enabled == null ? current.enabled : !!patch.enabled,
audience: patch.audience ?? current.audience,
stream: patch.stream == null ? current.stream : !!patch.stream,
fieldRules,
updatedBy: req.user?.id ?? null,
})
}
for (const entry of entries) await model.upsert(entry)
visibility.invalidate()
log.info('shard visibility updated', {
by: req.user?.id,
features: entries.map((e) => e.feature),
})
return res.json({ features: await visibility.getConfig() })
} catch (err) {
log.error('putVisibility', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { getVisibility, putVisibility }

View File

@@ -13,7 +13,7 @@ const shardEvents = require('../../../model/shardEvents/shardEvents.model')
const shardState = require('../../../model/shardState/shardState.model')
const uoLinkConfig = require('../../../model/uoLinkConfig/uoLinkConfig.model')
const broadcast = require('../../../utils/shardBroadcast')
const auth = require('../../../utils/auth')
const visibility = require('../../../utils/shardVisibility')
const log = require('../../../utils/logger')('public-shard')
@@ -72,18 +72,21 @@ async function getEconomy(req, res) {
// GET /public/shard/online — players online now whose account is linked to a
// STAFF website user (admin/editor/moderator). Everyone sees that a staff member
// is online (name + serial); their in-game location (map + coordinates) is only
// included for privileged viewers (admin/moderator) so it is never exposed to
// players or the public via the network tab. Non-staff players are never listed.
function canSeeStaffLocation(req) {
const viewer = auth.getUserFromRequest(req)
return !!viewer && (viewer.role === 'admin' || viewer.role === 'moderator')
// is online (name + serial); their in-game location (map + coordinates) is gated
// on the `presence` feature's `location` field rule, which defaults to `staff`
// — the same admin/moderator set this used to hardcode. Non-staff players are
// never listed.
async function canSeeStaffLocation(req) {
const config = await visibility.getConfig()
const required = config.presence?.fields?.location || 'staff'
const level = req.viewerLevel || (await visibility.viewerLevel(req))
return visibility.meets(level, required)
}
async function getOnline(req, res) {
try {
const rows = await shardState.listOnlineLinked()
const showLocation = canSeeStaffLocation(req)
const showLocation = await canSeeStaffLocation(req)
return res.json(
rows.map((r) => {
const entry = { serial: r.serial, name: r.name }
@@ -126,9 +129,13 @@ async function getChamps(req, res) {
// GET /public/shard/guilds — the current guild board. Served from our store;
// live via guild.update / guild.remove / guild.join on the public SSE stream.
//
// Projected: the stored payload is the raw guild.update frame, whose `leader`
// actor carries `acct` and `webId`. Those are admin-only and were previously
// returned verbatim to anonymous callers.
async function getGuilds(req, res) {
try {
return res.json(await shardState.listGuilds())
return res.json(await visibility.project('guilds', await shardState.listGuilds(), req))
} catch (err) {
log.error('shard.getGuilds', err)
return res.status(500).json({ message: 'Internal Server Error' })
@@ -136,10 +143,11 @@ async function getGuilds(req, res) {
}
// GET /public/shard/governors — the current town-governor board (empty on shards
// without City Loyalty). Live via city.update on the public SSE stream.
// without City Loyalty). Live via city.update on the public SSE stream. Projected
// for the same reason as getGuilds: `governor` / `governorElect` are actors.
async function getGovernors(req, res) {
try {
return res.json(await shardState.listGovernors())
return res.json(await visibility.project('governors', await shardState.listGovernors(), req))
} catch (err) {
log.error('shard.getGovernors', err)
return res.status(500).json({ message: 'Internal Server Error' })
@@ -150,7 +158,8 @@ async function getGovernors(req, res) {
// (look-back: "who were all the governors of Britain?"), newest first.
async function getGovernorHistory(req, res) {
try {
return res.json(await shardState.listGovernorHistory(req.params.city, req.query.limit))
const terms = await shardState.listGovernorHistory(req.params.city, req.query.limit)
return res.json(await visibility.project('governors', terms, req))
} catch (err) {
log.error('shard.getGovernorHistory', err)
return res.status(500).json({ message: 'Internal Server Error' })
@@ -192,9 +201,25 @@ async function getHouses(req, res) {
}
}
// GET /public/shard/stream — public live-event SSE channel (safe kinds only).
// GET /public/shard/features — the shard features THIS caller can actually see,
// so the SPA (and the Android client) can hide nav entries instead of rendering
// links that 403. Deliberately reports only what the viewer may reach: the list
// itself must not disclose the existence of a feature they're gated out of.
async function getFeatures(req, res) {
try {
const config = await visibility.getConfig()
const level = await visibility.viewerLevel(req)
return res.json({ level, features: visibility.visibleFeatures(level, config) })
} catch (err) {
log.error('shard.getFeatures', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /public/shard/stream — live-event SSE channel. What arrives depends on the
// caller's audience rung, resolved once at subscribe time; see shardBroadcast.js.
function stream(req, res) {
broadcast.subscribe(req, res, 'public')
return broadcast.subscribe(req, res, 'public')
}
module.exports = {
@@ -209,5 +234,6 @@ module.exports = {
getGovernorHistory,
getPresence,
getHouses,
getFeatures,
stream,
}

View File

@@ -10,19 +10,28 @@
// visitors *and* by the Android ShardStreamClient, neither of which sends an
// Authorization header; adding requireAuth here blacks out the public live boards
// on web and mobile. The sensitive kinds (staff audit, cheat detection, login
// attempts, IPs) are withheld by the allowlist in utils/shardBroadcast.js, not by
// a route gate — that allowlist split is the security boundary, not this file.
// attempts, IPs) are withheld by utils/shardBroadcast.js, not by a route gate —
// that per-frame filtering is the security boundary, not this file. /stream is
// deliberately NOT wrapped in requireFeature either: it spans every feature, and
// each frame is gated individually against the subscriber's rung.
//
// Every other route carries `requireFeature(<name>)` (utils/shardVisibility.js),
// which 404s when an admin has disabled the feature and 403s when the caller sits
// below its configured audience. Defaults reproduce pre-v3 behavior exactly, so
// these gates are inert until an admin changes something.
const express = require('express')
const { param, query } = require('express-validator')
const shard = require('./shard.controller')
const validate = require('../../../middleware/validate')
const { requireFeature } = require('../../../utils/shardVisibility')
const shardRouter = express.Router()
shardRouter.get(
'/status',
requireFeature('status'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Shard connection state, online count and latest economy'
/* #swagger.responses[200] = { description: 'Shard status', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardStatus" } } } } */
@@ -30,6 +39,7 @@ shardRouter.get(
)
shardRouter.get(
'/feed',
requireFeature('activity'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Recent notable shard events (from the ingested log)'
// #swagger.parameters['kind'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Filter to a single event kind, e.g. vendor.sale.' }
@@ -42,6 +52,7 @@ shardRouter.get(
)
shardRouter.get(
'/economy',
requireFeature('status'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Gold-supply time series (oldest → newest)'
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max samples (default 100, max 1000).' }
@@ -52,6 +63,7 @@ shardRouter.get(
)
shardRouter.get(
'/online',
requireFeature('presence'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Staff online now (linked staff accounts; location is admin/moderator-only)'
/* #swagger.responses[200] = { description: 'Online players', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardOnlinePlayer" } } } } } */
@@ -59,6 +71,7 @@ shardRouter.get(
)
shardRouter.get(
'/idoc',
requireFeature('houses'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Houses currently in danger (IDOC)'
/* #swagger.responses[200] = { description: 'IDOC houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
@@ -66,6 +79,7 @@ shardRouter.get(
)
shardRouter.get(
'/champs',
requireFeature('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.'
@@ -74,6 +88,7 @@ shardRouter.get(
)
shardRouter.get(
'/guilds',
requireFeature('guilds'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Current guild board (rosters, alliances, leaders)'
// #swagger.description = 'The live board of every guild. Update in place via the guild.update / guild.remove / guild.join frames on /shard/stream.'
@@ -82,6 +97,7 @@ shardRouter.get(
)
shardRouter.get(
'/governors',
requireFeature('governors'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Current town-governor board (City Loyalty)'
// #swagger.description = 'One entry per city with its governor and election phase. Empty if the shard does not run the City Loyalty system. Live via city.update on /shard/stream.'
@@ -90,6 +106,7 @@ shardRouter.get(
)
shardRouter.get(
'/governors/:city/history',
requireFeature('governors'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Governor term history for a city'
// #swagger.parameters['city'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'City name, e.g. Britain.' }
@@ -102,6 +119,7 @@ shardRouter.get(
)
shardRouter.get(
'/presence',
requireFeature('presence'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Online population aggregate (count + per-facet + per-region)'
// #swagger.description = 'The latest presence.online snapshot powering the "Players Online" widget. Live via presence.online on /shard/stream.'
@@ -110,17 +128,26 @@ shardRouter.get(
)
shardRouter.get(
'/houses',
requireFeature('houses'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'House registry (owner, co-owners, price, decay)'
// #swagger.description = 'Every house seen via the house.update registry feed. `price` is the placement value, not a for-sale flag. Live via house.update / house.remove on /shard/stream.'
/* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
shard.getHouses,
)
shardRouter.get(
'/features',
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Shard features visible to the caller (drives client nav)'
// #swagger.description = 'The caller\'s audience rung plus the shard features they may reach, so a client can hide nav entries instead of rendering links that 403. Reports only what the caller can see — the list itself does not disclose gated features.'
/* #swagger.responses[200] = { description: 'Visible features', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardFeatures" } } } } */
shard.getFeatures,
)
shardRouter.get(
'/stream',
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Live shard event stream (Server-Sent Events, public/safe kinds)'
// #swagger.description = 'text/event-stream of curated live events. Sensitive kinds (staff audit, cheat detection, login attempts, IPs) are NOT sent on this channel.'
// #swagger.summary = 'Live shard event stream (Server-Sent Events, filtered by audience)'
// #swagger.description = 'text/event-stream of live events. The caller\'s audience rung is resolved once at subscribe time and frozen for the connection; each frame is then gated on its feature and field-projected, so sensitive kinds and fields (staff audit, cheat detection, login attempts, IPs, acct/webId) never reach a caller below their configured rung.'
/* #swagger.responses[200] = { description: 'An SSE stream (Content-Type: text/event-stream).' } */
shard.stream,
)

View File

@@ -2,67 +2,66 @@
//
// 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).
// the WS feed and re-broadcasts 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.
// 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')
// 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',
// Champion-spawn board deltas — the public Champions page renders these live.
'champ.update',
'champ.remove',
// Protocol 2.0 boards — public, rendered live on their respective pages.
'guild.update',
'guild.remove',
'guild.join',
'city.update',
'presence.online',
'region.enter',
// NOTE: house.update / house.remove (the full registry — owner, price, co-owners)
// are deliberately NOT public. The public Houses page shows only IDOC houses (via
// house.decay, which is public above) with location only; the full registry is
// staff-only and rides the admin SSE channel. See public/shard.controller getHouses.
])
// Re-exported for back-compat: shardEvents `/feed` filtering and
// config/notificationStreams.js both ask "is this kind public-safe?".
const { PUBLIC_KINDS } = visibility
// Open response streams per channel.
// 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.
function subscribe(req, res, channel) {
//
// 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',
@@ -72,9 +71,10 @@ function subscribe(req, res, channel) {
res.write('retry: 5000\n\n') // tell EventSource to reconnect after 5s if dropped
res.write(': connected\n\n')
bucket.add(res)
const client = { res, level, ping: null }
bucket.add(client)
const ping = setInterval(() => {
client.ping = setInterval(() => {
try {
res.write(': ping\n\n')
} catch {
@@ -82,45 +82,80 @@ function subscribe(req, res, channel) {
}
}, KEEPALIVE_MS)
const cleanup = () => {
clearInterval(ping)
bucket.delete(res)
}
const cleanup = () => drop(bucket, client)
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)
}
// 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 to the admin channel (always) and the public
// channel (safe kinds only). A no-op when nobody is subscribed.
function broadcast(event) {
// 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
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)
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).
// 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 channel of Object.values(clients)) {
for (const res of channel) {
for (const bucket of Object.values(clients)) {
for (const client of [...bucket]) {
drop(bucket, client)
try {
res.end()
client.res.end()
} catch {
/* ignore */
}
}
channel.clear()
}
}

View File

@@ -229,11 +229,12 @@ async function ingest(event, deps = {}) {
}
if (!deps.fromBackfill) {
try {
d.broadcast(event)
} catch (err) {
d.log.warn('broadcast failed', { kind: event.kind, message: err.message })
}
// Broadcast is async since v3 (it reads the visibility config to decide what
// each subscriber may see). Fire-and-forget, like the push fan-out below: a
// slow config read must never delay or fail ingest.
Promise.resolve(d.broadcast(event)).catch((err) =>
d.log.warn('broadcast failed', { kind: event.kind, message: err.message }),
)
// Opt-in push fan-out, off the same event source as the SSE broadcast.
// Fire-and-forget (a slow/dead ntfy relay must never delay or fail ingest);
// fromShardEvent is self-guarding, but .catch() covers any lookup rejection.

View File

@@ -0,0 +1,358 @@
// ── Shard feature visibility ───────────────────────────────────────────────
//
// Admin-configurable, per-feature and per-field audience control over every
// shard-derived surface on the site. Replaces the hardcoded split that used to
// live in two places (the PUBLIC_KINDS allowlist in shardBroadcast.js, and the
// ad-hoc `canSeeStaffLocation` style checks in the public controllers).
//
// Design rules (docs/link/v3.md §3):
//
// • Visibility lives HERE, on the website — never in the sidecar. The sidecar
// is a dumb forwarder: it accepts frames, stores them, forwards them
// verbatim, and serves store-backed reads. It defines no audiences.
// • Every default reproduces the behavior that shipped before this module, so
// installing it changes nothing until an admin edits the config.
// • Two rules an admin CANNOT override:
// 1. `acct` / `webId` are admin-only, always. They are not in-game
// visible (unlike a character name) and are not configurable fields.
// 2. A kind absent from KIND_FEATURE is never broadcast below `admin`.
// Fail closed — this is what keeps the kind map a security boundary
// rather than a convenience filter.
//
// The audience ladder is ordered; each rung implies the ones below it.
const db = require('../model/shardVisibility/shardVisibility.model')
const shardLinks = require('../model/shardLinks/shardLinks.model')
const auth = require('./auth')
const log = require('./logger')('shard-visibility')
// ── The ladder ─────────────────────────────────────────────────────────────
const LADDER = ['anonymous', 'logged_in', 'player', 'staff', 'admin']
const RANK = new Map(LADDER.map((level, i) => [level, i]))
const isLevel = (level) => RANK.has(level)
// The two fallbacks are deliberately ASYMMETRIC, and the asymmetry is the whole
// point: an unrecognised value must always lose. A single shared fallback cannot
// do that — whichever direction it picks, it fails open on one side. So:
//
// • an unknown VIEWER level floors to the bottom rung (grants nothing), and
// • an unknown REQUIREMENT ceils to the top rung (satisfied by nobody but admin).
//
// With one `rank()` defaulting to admin, a viewer level that fell through (a
// typo, a future rung this build doesn't know, a value from a caller that
// skipped viewerLevel) would have been treated as an ADMIN and passed every gate.
const viewerRank = (level) => RANK.get(level) ?? 0
const requiredRank = (level) => RANK.get(level) ?? RANK.get('admin')
// True when a viewer at `viewer` satisfies a requirement of `required`.
const meets = (viewer, required) => viewerRank(viewer) >= requiredRank(required)
// Exported for tests/diagnostics; `meets` is what callers should use.
const rank = viewerRank
// ── Features ───────────────────────────────────────────────────────────────
//
// All ten shard surfaces: the six that shipped before v3 plus the four v3 adds.
// `fields` lists only the SENSITIVE fields — those an admin may re-gate. A field
// not listed here is visible whenever the feature itself is.
//
// LOCKED_FIELDS are exempt from configuration entirely (rule 1 above).
const LOCKED_FIELDS = { acct: 'admin', webId: 'admin' }
const FEATURES = {
// ── Shipped before v3. Defaults reproduce the previous hardcoded behavior. ──
status: { audience: 'anonymous', fields: {} },
activity: { audience: 'anonymous', fields: {} },
champs: { audience: 'anonymous', fields: {} },
guilds: { audience: 'anonymous', fields: {} },
governors: { audience: 'anonymous', fields: {} },
// The public Houses page showed IDOC location only; owner/price were staff.
houses: { audience: 'anonymous', fields: { owner: 'staff', price: 'staff' } },
// /public/shard/online listed linked staff to everyone but gated location to
// admin+moderator — which is exactly the `staff` rung.
presence: { audience: 'anonymous', fields: { location: 'staff' } },
// ── New in v3. ──
ruleset: { audience: 'anonymous', fields: { connect: 'anonymous' } },
atlas: { audience: 'anonymous', fields: {} },
leaderboards: { audience: 'anonymous', fields: { characterName: 'anonymous' } },
// Shop name, owner character name and vendor location are already globally
// visible in-game via the stock Vendor Search gump, so publishing them is not
// a new disclosure — but they stay configurable so an admin can tighten them.
market: { audience: 'anonymous', fields: { ownerName: 'anonymous', location: 'anonymous' } },
}
const FEATURE_NAMES = Object.keys(FEATURES)
const isFeature = (name) => Object.hasOwn(FEATURES, name)
// ── Kind → feature ─────────────────────────────────────────────────────────
//
// Every event kind that may ever leave the admin channel must appear here.
// Anything else is admin-only by omission (rule 2). This map is seeded from
// what PUBLIC_KINDS listed before v3, so the public stream carries exactly the
// same kinds it did — now attributed to a feature that an admin can re-gate.
const KIND_FEATURE = new Map(
Object.entries({
// status / lifecycle
'server.hello': 'status',
'server.shutdown': 'status',
'server.crashed': 'status',
'economy.supply': 'status',
// activity feed
'player.death': 'activity',
'player.murdered': 'activity',
'mob.killed': 'activity',
'quest.complete': 'activity',
'skill.gain': 'activity',
'fame.change': 'activity',
'karma.change': 'activity',
'mob.login': 'activity',
'mob.logout': 'activity',
// boards
'champ.update': 'champs',
'champ.remove': 'champs',
'guild.update': 'guilds',
'guild.remove': 'guilds',
'guild.join': 'guilds',
'city.update': 'governors',
'presence.online': 'presence',
'region.enter': 'presence',
// house.decay is the IDOC signal the public Houses page renders. The full
// registry (house.update / house.remove — owner, price, co-owners) stays
// off the map deliberately, so it remains admin-only exactly as before.
'house.decay': 'houses',
// v3
'world.ruleset': 'ruleset',
'points.board': 'leaderboards',
// vendor.listing IS mapped, but the market feature ships with its stream
// disabled (see DEFAULT_STREAM_OFF): a live firehose of full vendor
// inventories would be the site's biggest bandwidth consumer and no page
// needs it live. An admin can turn it on.
'vendor.listing': 'market',
'vendor.listing.remove': 'market',
}),
)
// Features whose SSE fan-out is off unless an admin enables it. The REST reads
// are unaffected; only the live stream is suppressed.
const DEFAULT_STREAM_OFF = new Set(['market'])
// Back-compat: the set of kinds that reach an anonymous viewer under the default
// config. shardEvents `/feed` filtering and notificationStreams.js both consume
// this. Derived from the map above rather than hand-maintained, so the two can
// no longer drift.
const PUBLIC_KINDS = new Set(
[...KIND_FEATURE.entries()]
.filter(([, feature]) => {
if (DEFAULT_STREAM_OFF.has(feature)) return false
return FEATURES[feature].audience === 'anonymous'
})
.map(([kind]) => kind),
)
// ── Config (DB-backed, cached) ─────────────────────────────────────────────
const CONFIG_TTL_MS = 5000
let cache = null
let cachedAt = 0
// Merge a stored row over its compiled default. Unknown feature names in the DB
// are ignored (a stale row from a removed feature must not resurrect it), and an
// invalid rung falls back to the default rather than failing open.
function applyRow(name, row) {
const base = FEATURES[name]
const audience = isLevel(row?.audience) ? row.audience : base.audience
const fields = { ...base.fields }
for (const [field, level] of Object.entries(row?.fieldRules || {})) {
if (Object.hasOwn(LOCKED_FIELDS, field)) continue // rule 1: not configurable
if (isLevel(level)) fields[field] = level
}
return {
enabled: row ? !!row.enabled : true,
audience,
fields,
stream: row?.stream == null ? !DEFAULT_STREAM_OFF.has(name) : !!row.stream,
}
}
function compileDefaults() {
const out = {}
for (const name of FEATURE_NAMES) out[name] = applyRow(name, null)
return out
}
// Read the config, cached briefly. Falls back to compiled defaults if the DB is
// unreachable — the defaults reproduce pre-v3 behavior, so a DB blip degrades to
// "what the site did before" rather than to "everything is public".
async function getConfig() {
const now = Date.now()
if (cache && now - cachedAt < CONFIG_TTL_MS) return cache
try {
const rows = await db.listAll()
const byName = new Map(rows.map((r) => [r.feature, r]))
const out = {}
for (const name of FEATURE_NAMES) out[name] = applyRow(name, byName.get(name))
cache = out
cachedAt = now
} catch (err) {
log.error('getConfig; falling back to defaults', err)
cache = cache || compileDefaults()
cachedAt = now
}
return cache
}
const invalidate = () => {
cache = null
cachedAt = 0
}
// ── Viewer level ───────────────────────────────────────────────────────────
//
// anonymous no session
// logged_in authenticated, no linked game account
// player authenticated with a linked game account
// staff admin | moderator — the same set as the existing `modAccess` gate.
// `editor` is a CONTENT role with no shard privilege today, so it
// resolves by link status like any other member; mapping it to staff
// here would silently widen what editors can see.
// admin admin
//
// Staff always satisfy the `player` rung (rank order guarantees it) even without
// a linked account, matching the existing rule that /player/* is role-agnostic
// self-service.
// Same TTL as the config cache: this decides a privilege rung, so an unlinked
// (or newly relinked) account must not keep the old answer for long. Anonymous,
// staff and admin callers short-circuit before this runs, so the lookup only
// costs a query on the logged-in-member path.
const LINK_TTL_MS = CONFIG_TTL_MS
const linkCache = new Map() // userId → { hasLink, at }
async function hasLinkedAccount(userId) {
const hit = linkCache.get(userId)
const now = Date.now()
if (hit && now - hit.at < LINK_TTL_MS) return hit.hasLink
let hasLink = false
try {
const links = await shardLinks.listForUser(userId)
hasLink = Array.isArray(links) && links.length > 0
} catch (err) {
log.warn('hasLinkedAccount failed; treating as unlinked', { message: err.message })
}
linkCache.set(userId, { hasLink, at: now })
return hasLink
}
// Drop a user's cached link status (called when a link is created or removed so
// the rung takes effect immediately rather than up to LINK_TTL_MS later).
const forgetUser = (userId) => linkCache.delete(userId)
async function viewerLevel(req) {
const viewer = req.user || auth.getUserFromRequest(req)
if (!viewer) return 'anonymous'
if (viewer.role === 'admin') return 'admin'
if (viewer.role === 'moderator') return 'staff'
return (await hasLinkedAccount(viewer.id)) ? 'player' : 'logged_in'
}
// ── Enforcement ────────────────────────────────────────────────────────────
// Route gate. 404 when the feature is disabled (do not leak that it exists);
// 403 when it exists but the viewer sits below its audience. Stashes the
// resolved level on the request so controllers can project without re-resolving.
function requireFeature(name) {
return async (req, res, next) => {
try {
const config = await getConfig()
const feature = config[name]
if (!feature || !feature.enabled) return res.status(404).json({ message: 'Not Found' })
const level = await viewerLevel(req)
req.viewerLevel = level
if (!meets(level, feature.audience)) return res.status(403).json({ message: 'Forbidden' })
return next()
} catch (err) {
log.error(`requireFeature(${name})`, err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
}
// Strip the fields a viewer at `level` may not see. Applies the locked rules
// first (so acct/webId can never survive below admin), then the feature's
// configured field rules. Recurses into arrays and nested objects because the
// sensitive fields sit inside actor sub-objects (guild.leader, city.governor).
function projectValue(value, rules, level) {
if (Array.isArray(value)) return value.map((v) => projectValue(v, rules, level))
if (!value || typeof value !== 'object') return value
const out = {}
for (const [key, v] of Object.entries(value)) {
const required = rules[key]
if (required && !meets(level, required)) continue
out[key] = projectValue(v, rules, level)
}
return out
}
// Project a payload for one feature. `level` defaults to admin-equivalent only
// when explicitly passed; callers should always pass a resolved level.
function projectFeature(name, payload, level, config) {
const feature = config?.[name]
const rules = { ...LOCKED_FIELDS, ...(feature ? feature.fields : {}) }
return projectValue(payload, rules, level)
}
// Convenience for controllers: resolve config once, project, return.
async function project(name, payload, req) {
const config = await getConfig()
const level = req.viewerLevel || (await viewerLevel(req))
return projectFeature(name, payload, level, config)
}
// Is this event kind allowed to reach a viewer at `level`? Fail closed on an
// unmapped kind (rule 2), and honour both the feature gate and its stream flag.
function kindVisibleTo(kind, level, config) {
if (level === 'admin') return true
const name = KIND_FEATURE.get(kind)
if (!name) return false // rule 2: unmapped ⇒ admin-only
const feature = config?.[name]
if (!feature || !feature.enabled || !feature.stream) return false
return meets(level, feature.audience)
}
// The features a viewer at `level` can actually see — drives SPA nav so it never
// renders a link that would 403.
function visibleFeatures(level, config) {
return FEATURE_NAMES.filter((name) => {
const feature = config[name]
return feature.enabled && meets(level, feature.audience)
})
}
module.exports = {
LADDER,
FEATURES,
FEATURE_NAMES,
LOCKED_FIELDS,
KIND_FEATURE,
PUBLIC_KINDS,
DEFAULT_STREAM_OFF,
isLevel,
isFeature,
rank,
meets,
getConfig,
invalidate,
compileDefaults,
viewerLevel,
forgetUser,
requireFeature,
projectFeature,
project,
kindVisibleTo,
visibleFeatures,
}