fix(shard): enforce visibility on the REST reads that bypassed it #110
Reference in New Issue
Block a user
No description provided.
Delete Branch "fix/shard-visibility-rest-projection"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What & why
Protocol 3.0 Part A follow-up. Found by running the
v3.md§11 visibility smoke test live — all five rungs against a real MariaDB and a stub sidecar.Part A implemented the framework correctly on the SSE path and on
/guilds+/governors, but the remaining public REST reads never called into it. The result: the same event was projected live and served verbatim from stored history.The four gaps
GET /public/shard/feedreturned the stored payload as-isactor.acct/actor.webIdreadable 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 §3.1 leak Part A closed, which only covered board holders.GET /public/shard/idocreturnedownerAccthousesfield rules were dead configgetIdocnorgetHousesprojected — the admin panel offered toggles that did nothing./feedfiltered onPUBLIC_KINDSguildsmoved tostaff,/guilds403'd while/feedstill servedguild.jointo anonymous.Plus a latent fail-open:
shardEvents.db.listtreated an emptykindsarray as "no filter" and fell through to an unfilteredSELECT. A fully-gated config would have dumped the entire event log, staff audit included.The fixes, at the root rather than per-route
leader.acct); 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 inacct/webId, case-insensitively — so it fails closed for shapes nobody has written yet. The adminPUTrejects those spellings too.visibleKinds(level, config)resolves readable kinds from the live config;getFeeduses it and projects each row against its own kind's feature. Deliberately independent of thestreamflag, which governs SSE fan-out only — so market history stays readable with its firehose off.getIdoc/getHouses/getChamps/getPresenceproject, so every shard surface honours one config.Also fixes a bug introduced while wiring this up:
projectValuerecursed into any object, so aDatecolumn 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/idocread did, which is the argument for the §11 smoke test over tests alone.Docs: RunicGateway/docs#65.
How it was tested
Live, five rungs (
anonymous/logged_in/player/staff/admin) against local MariaDB + a stub sidecar WS pushing real frames, with seeded guild/governor/house/online fixtures carryingacct/webId:acct/webIdoccurrences belowadminacross every shard read (was 90 on/feedalone).editor(unlinked) correctly resolves tologged_in— no shard privilege./onlinelocation gates atstaff;house.decaygives anonymous noowner, staffownerwithoutacct/webId, admin everything.staff.command,cheat.detect,login.attempt) reach only admin — rule 2 fails closed.guild.updatestopping mid-connection);stream=falsesuppresses SSE while REST stays200;enabled=false→404; out-of-rung →403.400— including the flattenedownerAcct.adminOnly(401/403/403/403/200).Automated:
DB_HOST=127.0.0.1 DB_PORT=59999 npm test→ 487 pass / 0 fail (+9 new). Swagger regenerated; route manifest unchanged (no route signatures changed). Also cut the two slowest tests inshardControllerPublicfrom ~10s each to instant by stubbing the model rather than the util's exports — an exports-level stub doesn't intercept the module-internalgetConfig.Checklist
AI-assisted contributions (required)
Claude Code (Opus 5). I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with aCo-Authored-Bytrailer.License
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>