Files
website/server/src/router/v1/admin/shardVisibility.controller.js
wtclaude f30ea66fce fix(shard): enforce visibility on the REST reads that bypassed it
Protocol 3.0 Part A follow-up, found by the live five-rung smoke test.

Part A implemented the visibility framework correctly on the SSE path
and on /guilds + /governors, but the remaining public REST reads never
called into it. The result was that one event was projected live and
served verbatim from history:

  * GET /public/shard/feed returned the stored payload as-is, so
    actor.acct and actor.webId were readable ANONYMOUSLY for every
    logged kind - player.death, player.murdered, mob.killed,
    quest.complete, skill.gain, fame/karma.change, mob.login/logout,
    guild.join. Broader than the guild-leader leak Part A set out to
    close, since it covers every player rather than board holders.

  * GET /public/shard/idoc returned ownerAcct - the house owner's game
    account - to anonymous callers.

  * The `houses` field rules (owner/price -> staff) were dead config:
    neither getIdoc nor getHouses projected, so an admin could set them
    in the panel and nothing happened.

  * /feed filtered on PUBLIC_KINDS, a module-load constant derived from
    the compiled DEFAULTS, so live audience changes did not reach it.
    With `guilds` moved to staff, /guilds 403'd while /feed happily
    served guild.join to anonymous.

Four fixes, all at the root rather than per-route:

1. Rule 1 now matches a field's MEANING, not one spelling. The wire
   nests actors (leader.acct) but the read models flatten them
   (shapeHouse -> ownerAcct, shapeGuild -> leaderWebId), and an
   exact-key check missed every flattened one. isLockedField() locks a
   key that is or ends in acct/webId, case-insensitively, so it fails
   closed for shapes not yet written. The admin PUT rejects those
   spellings too - `ownerAcct` is no longer configurable.

2. visibleKinds(level, config) resolves readable kinds from the LIVE
   config; getFeed uses it and projects each row against its own kind's
   feature. Deliberately independent of the `stream` flag, which governs
   SSE fan-out only - so market history stays readable with its firehose
   off. This makes the set a superset of PUBLIC_KINDS by exactly the two
   vendor kinds.

3. getIdoc/getHouses/getChamps/getPresence project, so every shard
   surface honours the same config.

4. shardEvents.db.list treats an EMPTY kinds array as "serve nothing".
   It previously fell through to the unfiltered query, so a fully-gated
   config would have dumped the whole event log, staff audit included.

Also fixes a bug introduced while wiring this up: projectValue recursed
into any object, so a Date column came back as {}. It now walks arrays
and plain objects only. The unit tests used JSON fixtures and could not
have caught it - the live /idoc read did.

Verified live against MariaDB + a stub sidecar, all five rungs: 13
routes x 5 rungs, defaults reproducing pre-v3 access exactly, zero
acct/webId below admin on any read, unmapped kinds (staff.command,
cheat.detect, login.attempt) reaching only admin on SSE, and audience /
enabled / stream changes taking effect live on an already-open stream.

Tests: 487 server (+9). Swagger regenerated; route manifest unchanged.

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

99 lines
4.0 KiB
JavaScript

// ── 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 || {})) {
// Matches flattened spellings too (`ownerAcct`, `leaderWebId`), so the
// rejection covers every way the field can be named rather than the two
// canonical keys.
if (visibility.isLockedField(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 }