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>
This commit is contained in:
2026-07-28 10:49:55 -05:00
parent cd56af3f12
commit f30ea66fce
8 changed files with 306 additions and 22 deletions

View File

@@ -11030,7 +11030,7 @@
"Public · Shard"
],
"summary": "Recent notable shard events (from the ingested log)",
"description": "",
"description": "The stored-history twin of /shard/stream, and it reaches the same verdict: which kinds are returned is resolved against the caller\\'s audience rung under the live visibility config, and each event\\'s payload is field-projected against its own kind\\'s feature. Kinds the caller may not read are omitted (an explicit ?kind= for one of them returns []), and acct/webId never appear below admin.",
"parameters": [
{
"name": "kind",
@@ -11039,7 +11039,7 @@
"schema": {
"type": "string"
},
"description": "Filter to a single event kind, e.g. vendor.sale."
"description": "Filter to a single event kind, e.g. vendor.sale. Returns [] if the caller may not read that kind."
},
{
"name": "limit",
@@ -11244,7 +11244,7 @@
"Public · Shard"
],
"summary": "Houses currently in danger (IDOC)",
"description": "",
"description": "Location-level board of the houses about to collapse. Owner identity and price are gated by the `houses` feature\\'s field rules (default `staff`), and the owner\\'s game account is admin-only always — so an anonymous caller sees name, region and coordinates only.",
"responses": {
"200": {
"description": "IDOC houses",