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:
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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
124
website/SHARD_VISIBILITY.md
Normal 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.
|
||||||
Reference in New Issue
Block a user