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>
99 lines
4.0 KiB
JavaScript
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 }
|