docs(website): record the shard visibility framework #64

Merged
whitlocktech merged 2 commits from docs/shard-visibility into edge 2026-07-28 15:06:59 +00:00
3 changed files with 194 additions and 1 deletions

View File

@@ -19,6 +19,7 @@ ci/ cross-cutting CI/quality notes
| [BACKEND_DESIGN.md](website/BACKEND_DESIGN.md) | API contract, DB schema, security model | | [BACKEND_DESIGN.md](website/BACKEND_DESIGN.md) | API contract, DB schema, security model |
| [HERO_EDITOR.md](website/HERO_EDITOR.md) | Hero canvas editor feature spec | | [HERO_EDITOR.md](website/HERO_EDITOR.md) | Hero canvas editor feature spec |
| [WIKI_UPGRADE.md](website/WIKI_UPGRADE.md) | Wiki subsystem upgrade notes | | [WIKI_UPGRADE.md](website/WIKI_UPGRADE.md) | Wiki subsystem upgrade notes |
| [SHARD_VISIBILITY.md](website/SHARD_VISIBILITY.md) | Who sees which shard data — the admin-configurable audience framework |
| [website-README.md](website/website-README.md) | Snapshot of the website repo's README (setup/run reference) | | [website-README.md](website/website-README.md) | Snapshot of the website repo's README (setup/run reference) |
| [PROJECT_TREE.md](website/PROJECT_TREE.md) | Auto-generated snapshot of the repo's tracked file layout | | [PROJECT_TREE.md](website/PROJECT_TREE.md) | Auto-generated snapshot of the repo's tracked file layout |

View File

@@ -99,7 +99,7 @@ server/
pages.router.js (2) /public/pages — the draft-preview pages.router.js (2) /public/pages — the draft-preview
route precedes /:slug and is route precedes /:slug and is
deliberately not site-mode gated 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 SSE stream; never site-mode gated
site.router.js (4) /settings /status /version /contact — site.router.js (4) /settings /status /version /contact —
the group-root singletons; declares no 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 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. 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 ## 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` | list of pages (slug + title) |
| GET | `/wiki/:slug` | single page | | GET | `/wiki/:slug` | single page |
| POST | `/contact` | (rate-limited) send mail via SMTP; if unconfigured, respond `{fallback:"mailto", email}` | | 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). 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 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. 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 `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 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 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. - **`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`. - **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 ## 7. Email

124
website/SHARD_VISIBILITY.md Normal file
View File

@@ -0,0 +1,124 @@
# Shard visibility — who sees which shard data
**Status:** Built (Protocol 3.0 Part A). Admin → Shard Visibility.
**Audience:** shard owners and admins.
**Companion to** [`../link/v3.md`](../link/v3.md) §3 (the design) and
[`BACKEND_DESIGN.md`](BACKEND_DESIGN.md) §6 (the security contract).
The website surfaces a lot of live shard data. What your players, your staff and the anonymous
internet may each see is **yours to decide**, per feature, from Admin → Shard Visibility.
Nothing changes until you change it: every setting ships at the value that reproduces how the site
behaved before this panel existed.
---
## 1. The audience ladder
Five rungs. Each one includes everyone below it.
| Rung | In the UI | Who that is |
|---|---|---|
| `anonymous` | **Everyone** | Anyone at all, signed in or not. |
| `logged_in` | **Signed in** | Any registered account, whether or not they've linked a game account. |
| `player` | **Linked players** | Accounts with a linked in-game account. **Staff always qualify**, linked or not. |
| `staff` | **Staff** | Admins and moderators. |
| `admin` | **Admins only** | Admins. |
Two notes that surprise people:
- **`editor` is a content role, not a shard role.** Editors write news and wiki pages; they get no
shard privilege from that. An editor is treated by link status like any other member. This matches
the rest of the site, where shard staff powers are admin-or-moderator.
- **Staff satisfy `player` without linking.** Otherwise an admin would be locked out of surfaces
they'd gated to players, which is how the `/player/*` routes already behave.
## 2. What you can set per feature
**Enabled.** Off means gone. The feature's pages return “not found”, not “forbidden” — a disabled
feature doesn't advertise that it exists.
**Who can see it.** The minimum rung, from the ladder above.
**Live updates.** Whether this feature pushes changes to open pages in real time. Turning it off
doesn't break the page; it just refreshes on load instead of updating in place.
**Sensitive fields.** Some features expose a field that deserves its own rung — you can publish the
board while holding back one column. See the table in §3.
## 3. The features, and their defaults
| Feature | What it exposes | Default | Sensitive fields |
|---|---|---|---|
| **Shard status** | Connection state, online count, gold-supply series | Everyone | — |
| **Activity feed** | Deaths, kills, skill gains, quests, logins | Everyone | — |
| **Champion spawns** | The live champion / mini-champ / sea-boss board | Everyone | — |
| **Guilds** | Guild rosters, alliances, leaders | Everyone | — |
| **Town governors** | City Loyalty governors, elections, term history | Everyone | — |
| **Houses / IDOC** | Houses in danger | Everyone | House owner → Staff · House price → Staff |
| **Players online** | Population aggregate, staff-online widget | Everyone | In-game location → Staff |
| **Shard rules** | Skill/stat caps, house limits, vet rewards, the ruleset | Everyone | Connect address → Everyone |
| **Spawn atlas** | Bestiary and spawn locations (static content) | Everyone | — |
| **Leaderboards** | Point and loyalty standings | Everyone | Character names → Everyone |
| **Marketplace** | The shard-wide player-vendor index | Everyone, **live updates off** | Vendor owner name → Everyone · Location → Everyone |
**Why the marketplace ships with live updates off.** A live feed of every vendor's full inventory
would be the single largest thing the site sends. No page needs it — the marketplace is a search over
stored data with a “prices last refreshed N minutes ago” stamp. Turn it on only if you want it.
**Why house owner/price default to Staff.** The public Houses page has always been a "where are the
falling houses" board — location only. Owner and price are the staff view. That split is preserved.
## 4. What you cannot change
Two rules are enforced in code and are not settings. Attempting to set them returns an error rather
than silently ignoring you.
**1. Game account names and website user ids are admin-only, always.**
`acct` and `webId` never appear below the admin rung on any surface. A character *name* is visible in
game to anyone standing next to them; the **account** behind it is not, and neither is the website
user it's linked to. Publishing those would disclose something the shard itself doesn't, and would
tie a player's in-game identity to their forum identity without their consent.
**2. Unknown event kinds are never broadcast below admin.**
The live stream maps each event kind to a feature. A kind with no mapping — a new event from a shard
plugin the site doesn't know yet, say — goes to admins only. It fails closed. This is what keeps the
stream safe by default when the shard starts sending something new: the worst case is that staff see
it and players don't, never the reverse.
## 5. How it's enforced
Three places, one config:
- **Page and API requests** are checked before the handler runs, and the response is then stripped of
any field above the caller's rung.
- **The live stream** resolves a viewer's rung once, when they connect, and freezes it for that
connection — a long-open page can't gain privilege because something changed underneath it. Each
event is then gated and stripped per viewer, so two people watching the same page can legitimately
receive different versions of the same event, or one of them nothing.
- **Navigation** hides links a viewer can't follow, so they don't hit a wall. This is presentation
only — the gate is server-side either way.
Changes take effect within about five seconds, **including on streams that are already open**. You
don't need to restart anything.
If the database is briefly unreachable, the site falls back to the built-in defaults — the pre-v3
behavior — rather than to "everything is public".
## 6. Worked examples
**"I want a private shard — nothing public until people register."**
Set every feature to **Signed in**. Anonymous visitors still get the site itself; the shard data
disappears from the nav.
**"Publish the market, but don't tie vendors to players."**
Marketplace → Everyone, with **Vendor owner name** → Staff. Prices, items and locations stay public;
who owns each vendor doesn't.
**"Leaderboards for members only."**
Leaderboards → **Linked players**. Anyone who's linked a game account sees the standings; drive-by
visitors don't.
**"Let players see house owners."**
Houses → Everyone, **House owner** → Linked players. Note this is a real disclosure: house ownership
is visible in game, but the website makes it searchable in a way the game doesn't.