docs(website): record the shard visibility framework

Protocol 3.0 Part A. Admin-configurable, per-feature and per-field
audience control over every shard-derived surface, replacing the static
PUBLIC_KINDS allowlist that used to be the whole boundary.

- SHARD_VISIBILITY.md (new): the admin-facing guide - the ladder, what
  each of the ten features exposes, the defaults, the two rules that are
  code rather than configuration, and worked examples.
- BACKEND_DESIGN.md 6.5 (new): the same thing as a security contract -
  the ladder and how viewerLevel resolves it, the locked acct/webId rule,
  the fail-closed kind map, the asymmetric ladder fallbacks, and the
  three enforcement points. Plus the shard_feature_visibility schema, the
  /public/shard/features route, and the adminOnly tier on
  /admin/shard/visibility.

Defaults reproduce pre-3.0 behavior everywhere, with one deliberate
exception which is the leak Part A was written to close: guilds and
governors previously returned the raw stored payload, whose leader and
governor actors carry acct and webId, to anonymous callers.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-28 10:04:29 -05:00
parent b523336313
commit bf41105ec0
3 changed files with 194 additions and 1 deletions

View File

@@ -99,7 +99,7 @@ server/
pages.router.js (2) /public/pages — the draft-preview
route precedes /:slug and is
deliberately not site-mode gated
shard.router.js (12) /public/shard/* incl. the anonymous
shard.router.js (13) /public/shard/* incl. the anonymous
SSE stream; never site-mode gated
site.router.js (4) /settings /status /version /contact —
the group-root singletons; declares no
@@ -358,6 +358,19 @@ analogue to a password — and there is no hash-lookup constraint (verification
unused rows and `bcrypt.compare`s each, like password verification). `used_at` is the single-use
marker. Cleared wholesale on TOTP disable / password change / password reset.
### shard_feature_visibility — per-feature audience config (Protocol 3.0)
One row per shard feature: `feature` (PK), `enabled`, `audience` (a rung on the ladder in §6.5),
`stream` (whether the feature's kinds fan out over SSE at all), `field_rules` JSON (`{field: rung}`
for the sensitive fields only), `updated_by`, `updated_at`.
**An absent row means "use the compiled default", and the compiled defaults reproduce pre-3.0
behavior — so an empty table is a no-op and there is nothing to seed.** Stored rows are merged over
the defaults on read, which is also where the invariants are re-applied: a row naming an unknown
feature is ignored (a stale row must not resurrect a removed feature), an invalid rung falls back to
the default rather than failing open, and a rule touching a locked field (`acct` / `webId`) is
discarded. See §6.5.
---
## 4. API contract
@@ -556,6 +569,7 @@ from the per-route **siteMode** middleware (§5), never from an auth gate.
| GET | `/wiki` | list of pages (slug + title) |
| GET | `/wiki/:slug` | single page |
| POST | `/contact` | (rate-limited) send mail via SMTP; if unconfigured, respond `{fallback:"mailto", email}` |
| GET | `/shard/features` | the shard features **this caller** may reach plus the audience rung they resolved to (§6.5), so a client hides nav it can't follow. Reports only what the caller can see — the list itself never discloses a gated feature. Consumed by the SPA header and (pending) the Android nav. |
Public content GETs pass through the **siteMode** gate (§5).
@@ -570,6 +584,10 @@ the whole gate. The ops/config capabilities — `uo-link`, `email`, `discord-bot
linking carries no extra gate and the in-game staff operations carry `modAccess`. There is no residual
file: every admin route is declared in a capability router.
`GET`/`PUT /admin/shard/visibility` are the third tier on that mixed prefix: **`adminOnly`**, because
they decide what *anonymous* visitors can see (§6.5). They sit above `modAccess` deliberately — a
moderator can ban a player but cannot decide what the public internet reads.
`GET /dashboard` and `PUT /site-mode` are the one place where a **single screen spans two tiers**: the
dashboard is staff-wide, but the site-mode toggle on it is `adminOnly`. The client must therefore gate
that control on its own (`Dashboard.jsx` renders it only for `role === 'admin'`) rather than relying on
@@ -672,6 +690,56 @@ who"; `activity_log` provides the history feed.
- **`app.set('trust proxy', 1)`** so secure cookies, `req.ip`, and rate-limiting work behind Pangolin.
- **CORS**: same-origin in prod (SPA served by Express). Dev only: allow `CLIENT_ORIGIN` (Vite, `http://localhost:5173`) with `credentials:true`.
### 6.5 Shard visibility — the audience boundary (Protocol 3.0)
Every shard-derived surface is gated by an **admin-configurable, per-feature and per-field** audience
setting. This **replaces** the static `PUBLIC_KINDS` allowlist that used to be the whole boundary.
Policy lives in `utils/shardVisibility.js`; rows live in `shard_feature_visibility`; the admin surface
is `GET`/`PUT /admin/shard/visibility` (`adminOnly`). Admin-facing guide:
[`SHARD_VISIBILITY.md`](SHARD_VISIBILITY.md). Design: [`../link/v3.md`](../link/v3.md) §3.
**The ladder.** `anonymous < logged_in < player < staff < admin`, each rung implying the ones below.
`viewerLevel(req)` resolves it: no session ⇒ `anonymous`; authenticated ⇒ `logged_in`; authenticated
with a linked game account ⇒ `player`; moderator ⇒ `staff`; admin ⇒ `admin`. **Staff satisfy the
`player` rung without a linked account** (consistent with `/player/*` being role-agnostic).
**`editor` gets no shard privilege** — it is a content role, and mapping it to `staff` would silently
widen what editors see.
**Two invariants that are code, not configuration.** Both are enforced server-side and both reject
rather than silently ignore:
1. **`acct` and `webId` are admin-only, always.** They are not exposed as configurable fields, and a
stored row attempting to loosen them is discarded on read as well as rejected on write. A character
name is visible in game; the account behind it and the website user it links to are not.
2. **A kind absent from `KIND_FEATURE` is never broadcast below `admin`.** Fail closed. This is what
keeps the kind map a security boundary rather than a convenience filter, and it means a shard that
starts emitting an unknown event degrades to staff-only, never to public.
**Fail-closed everywhere else too.** An unreadable visibility config withholds every public frame; a
DB failure falls back to the compiled defaults (pre-3.0 behavior), not to open; an unresolvable viewer
subscribes as `anonymous`. The ladder comparison uses **asymmetric** fallbacks by design — an unknown
*viewer* level floors to the bottom rung and an unknown *requirement* ceils to admin, so an
unrecognised value loses on both sides. (A single shared fallback cannot do that: whichever direction
it picks, it fails open on one side.)
**Three enforcement points, one config:**
| Where | Mechanism |
|---|---|
| Routes | `requireFeature(name)`**404** when the feature is disabled (don't leak that it exists), **403** when the caller is below its audience. `projectFeature` then strips out-of-rung fields from the body. |
| SSE (`utils/shardBroadcast.js`) | Per-connection filtering. A subscriber's rung is resolved **once at subscribe time and frozen** for that connection, so a long-lived stream can't gain privilege; each frame is then mapped kind→feature, gated, and field-projected per viewer. Two subscribers can legitimately receive different versions of one event, or one of them nothing. |
| Nav | `GET /public/shard/features` returns only what the caller may reach, so the SPA never renders a link that would 403. Presentation only. |
Config reads are cached ~5s, so admin changes take effect within seconds **including on already-open
streams**. `PUBLIC_KINDS` still exists and is still exported (`/feed` filtering, `notificationStreams.js`)
but is now **derived** from the kind map rather than hand-maintained, so the two cannot drift.
**Defaults reproduce pre-3.0 behavior exactly**, so installing the framework is a no-op until an admin
changes something — with one deliberate exception, which is the leak it was written to close:
`/public/shard/guilds` and `/public/shard/governors` previously returned the raw stored payload, whose
`leader` / `governor` actors carry `acct` and `webId`. Those fields are now stripped for every caller
below admin.
---
## 7. Email