feat(shard): admin-configurable visibility for every shard surface #109
Reference in New Issue
Block a user
No description provided.
Delete Branch "feat/shard-visibility-framework"
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, the foundation the rest of v3 builds on. Design:
docs/link/v3.md§3 (docs#63), admin guide in docs#64.Replaces the static
PUBLIC_KINDSallowlist — which was the entire public/admin boundary — with per-feature, per-field audience control that a shard admin owns from Admin → Shard Visibility. Covers all ten shard surfaces: the six that already ship plus the four v3 adds.It closes a live leak
BridgeJson.Actor()writesacctandwebId;shardState.model.js:346 shapeGuild()returned the stored payload verbatim;GET /api/v1/public/shard/guildsis anonymous. Guild leaders' game account names and website user ids are readable by anyone today, and the same path exists for/public/shard/governors.Actoralso feedsguild.join,city.updateandregion.enteron the anonymous SSE stream. Both REST paths are now projected and the stream is field-filtered per viewer.This is why Part A ships first and alone.
The ladder
anonymous < logged_in < player < staff < admin, each rung implying the ones below.playerwithout a linked account, consistent with/player/*already being role-agnostic self-service.editorgets no shard privilege — it's a content role, and mapping it tostaffwould silently widen what editors see. Today'smodAccessis admin|moderator.Two invariants that are code, not configuration
Both reject rather than silently ignore:
acct/webIdare admin-only, always. Not exposed as configurable fields; a stored row trying to loosen them is discarded on read and 400'd on write.KIND_FEATUREnever reaches anyone belowadmin. Fail closed — a shard that starts emitting an unknown event degrades to staff-only, never to public.Enforcement — three points, one config
requireFeature(name)— 404 when disabled (don't leak that it exists), 403 when below the audience — plus field projection in the controllers.shardBroadcast.jsis the highest-risk file here: the boundary moves into it. A subscriber's rung is resolved once at subscribe time and frozen, so a long-open stream can't gain privilege; config changes still apply live.GET /public/shard/featuresreturns only what the caller may reach. Presentation only.Compatibility
PUBLIC_KINDSstill exists and is still exported (/feedfiltering,notificationStreams.js) but is now derived from the kind map, so the two can't drift — a test pins the derived set against the pre-v3 allowlist so any widening or narrowing has to be deliberate.Defaults reproduce pre-v3 behavior exactly, so this is inert until an admin changes something — with the one deliberate exception above (the leak fix). Checked
android-app:ActorDtodeclaresacct/webIdas nullable with= nulldefaults and no UI reads them, so dropping the fields doesn't break the client.Found while testing (not security)
An SSE resource leak: a client dropped because its write threw was removed from the bucket but its keepalive
setIntervalwas never cleared, firing forever on a dead socket. Both paths now go through a singledrop().Self-review
Ran
/security-reviewon the diff. One finding, fixed in-branch:meets(viewer, required)used onerank()for both operands with anadminfallback — correct for a requirement (unknown ⇒ nobody passes) but inverted for a viewer, where an unrecognised level resolved to admin and passed every gate. No live path reached it (every producer emits a valid rung), butprojectFeature/kindVisibleToare exported and it fails silently and open. Split intoviewerRank(floors to anonymous) andrequiredRank(ceils to admin) so an unknown value loses on both sides, with tests covering unknown/undefined/nullviewers against every rung.How it was tested
DB_HOST=127.0.0.1 DB_PORT=59999 npm test→ 478 pass, 0 fail (33 new)test/shardVisibility.test.js— ladder math both directions, locked fields, fail-closed on unmapped kinds, defaults-vs-pre-v3 pin, config merge (unknown feature ignored, invalid rung falls back, DB failure degrades to defaults not to open),viewerLevelresolution incl. the editor casetest/shardBroadcast.visibility.test.js— admin gets frames verbatim; no rung below admin seesacct/webId; unmapped kinds reach nobody; one event yields different frames per rung; rung frozen at subscribe; unresolvable viewer → anonymous; unreadable config withholds everything; dead client droppednpm test(client) → 43 pass, 0 fail;npm run buildcleannpm run routes:manifest+npm run swaggerregenerated and committed; both manifest tests greenNot yet exercised against a live shard — the new surfaces have no data until Part B lands. The retrofitted surfaces are covered by the existing suite.
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. 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>