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,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 }