feat(shard): admin-configurable visibility for every shard surface #109

Merged
whitlocktech merged 1 commits from feat/shard-visibility-framework into edge 2026-07-28 15:08:14 +00:00
Member

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_KINDS allowlist — 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() writes acct and webId; shardState.model.js:346 shapeGuild() returned the stored payload verbatim; GET /api/v1/public/shard/guilds is anonymous. Guild leaders' game account names and website user ids are readable by anyone today, and the same path exists for /public/shard/governors. Actor also feeds guild.join, city.update and region.enter on 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.

  • Staff satisfy player without a linked account, consistent with /player/* already being role-agnostic self-service.
  • editor gets no shard privilege — it's a content role, and mapping it to staff would silently widen what editors see. Today's modAccess is admin|moderator.

Two invariants that are code, not configuration

Both reject rather than silently ignore:

  1. acct / webId are admin-only, always. Not exposed as configurable fields; a stored row trying to loosen them is discarded on read and 400'd on write.
  2. A kind absent from KIND_FEATURE never reaches anyone below admin. Fail closed — a shard that starts emitting an unknown event degrades to staff-only, never to public.

Enforcement — three points, one config

Where Mechanism
Routes requireFeature(name)404 when disabled (don't leak that it exists), 403 when below the audience — plus field projection in the controllers.
SSE Per-connection filtering. shardBroadcast.js is 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.
Nav GET /public/shard/features returns only what the caller may reach. Presentation only.

Compatibility

PUBLIC_KINDS still exists and is still exported (/feed filtering, 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: ActorDto declares acct/webId as nullable with = null defaults 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 setInterval was never cleared, firing forever on a dead socket. Both paths now go through a single drop().

Self-review

Ran /security-review on the diff. One finding, fixed in-branch: meets(viewer, required) used one rank() for both operands with an admin fallback — 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), but projectFeature/kindVisibleTo are exported and it fails silently and open. Split into viewerRank (floors to anonymous) and requiredRank (ceils to admin) so an unknown value loses on both sides, with tests covering unknown/undefined/null viewers against every rung.

How it was tested

  • DB_HOST=127.0.0.1 DB_PORT=59999 npm test478 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), viewerLevel resolution incl. the editor case
    • test/shardBroadcast.visibility.test.js — admin gets frames verbatim; no rung below admin sees acct/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 dropped
  • npm test (client) → 43 pass, 0 fail; npm run build clean
  • npm run routes:manifest + npm run swagger regenerated and committed; both manifest tests green

Not 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

  • I have read CONTRIBUTING.md.
  • The change builds and existing tests/checks pass locally.
  • I have added or updated tests/docs where it makes sense.
  • My commits are reasonably scoped with clear messages.

AI-assisted contributions (required)

  • AI tools were used. Tool(s): Claude Code (Opus 5). I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a Co-Authored-By trailer.

License

  • I agree that my contribution is licensed under this project's license (GNU GPL v3.0 or later), and I have the right to contribute it.
## What & why Protocol 3.0 **Part A**, the foundation the rest of v3 builds on. Design: [`docs/link/v3.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/edge/link/v3.md) §3 (docs#63), admin guide in docs#64. Replaces the static `PUBLIC_KINDS` allowlist — 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()` writes `acct` and `webId`; `shardState.model.js:346 shapeGuild()` returned the stored payload verbatim; `GET /api/v1/public/shard/guilds` is anonymous. **Guild leaders' game account names and website user ids are readable by anyone today**, and the same path exists for `/public/shard/governors`. `Actor` also feeds `guild.join`, `city.update` and `region.enter` on 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. - **Staff satisfy `player` without a linked account**, consistent with `/player/*` already being role-agnostic self-service. - **`editor` gets no shard privilege** — it's a content role, and mapping it to `staff` would silently widen what editors see. Today's `modAccess` is admin|moderator. ### Two invariants that are code, not configuration Both reject rather than silently ignore: 1. **`acct` / `webId` are admin-only, always.** Not exposed as configurable fields; a stored row trying to loosen them is discarded on read *and* 400'd on write. 2. **A kind absent from `KIND_FEATURE` never reaches anyone below `admin`.** Fail closed — a shard that starts emitting an unknown event degrades to staff-only, never to public. ### Enforcement — three points, one config | Where | Mechanism | |---|---| | Routes | `requireFeature(name)` — **404** when disabled (don't leak that it exists), **403** when below the audience — plus field projection in the controllers. | | SSE | Per-connection filtering. **`shardBroadcast.js` is 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. | | Nav | `GET /public/shard/features` returns only what the caller may reach. Presentation only. | ### Compatibility `PUBLIC_KINDS` still exists and is still exported (`/feed` filtering, `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`: `ActorDto` declares `acct`/`webId` as nullable with `= null` defaults 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 `setInterval` was never cleared, firing forever on a dead socket. Both paths now go through a single `drop()`. ### Self-review Ran `/security-review` on the diff. One finding, fixed in-branch: `meets(viewer, required)` used one `rank()` for both operands with an `admin` fallback — 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), but `projectFeature`/`kindVisibleTo` are exported and it fails *silently* and *open*. Split into `viewerRank` (floors to anonymous) and `requiredRank` (ceils to admin) so an unknown value loses on both sides, with tests covering unknown/`undefined`/`null` viewers 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), `viewerLevel` resolution incl. the editor case - `test/shardBroadcast.visibility.test.js` — admin gets frames verbatim; no rung below admin sees `acct`/`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 dropped - `npm test` (client) → **43 pass, 0 fail**; `npm run build` clean - `npm run routes:manifest` + `npm run swagger` regenerated and committed; both manifest tests green Not 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 - [x] I have read [CONTRIBUTING.md](CONTRIBUTING.md). - [x] The change builds and existing tests/checks pass locally. - [x] I have added or updated tests/docs where it makes sense. <!-- 33 new tests; docs in docs#64 --> - [x] My commits are reasonably scoped with clear messages. ## AI-assisted contributions (required) - [x] AI tools were used. Tool(s): `Claude Code (Opus 5)`. I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a `Co-Authored-By` trailer. ## License - [x] I agree that my contribution is licensed under this project's license (**GNU GPL v3.0 or later**), and I have the right to contribute it.
wtclaude added 1 commit 2026-07-28 15:05:43 +00:00
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>
whitlocktech approved these changes 2026-07-28 15:08:07 +00:00
whitlocktech merged commit cd56af3f12 into edge 2026-07-28 15:08:14 +00:00
whitlocktech deleted branch feat/shard-visibility-framework 2026-07-28 15:08:15 +00:00
Sign in to join this conversation.
No description provided.