diff --git a/link/v3.md b/link/v3.md index bd6e92c..e969e84 100644 --- a/link/v3.md +++ b/link/v3.md @@ -115,9 +115,13 @@ CREATE TABLE IF NOT EXISTS shard_feature_visibility ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ``` -Seeded on boot in `server.js`, one row per feature. **All ten shard features are covered — the four -new ones and the six that already ship — and every default reproduces today's behavior, so the -retrofit is a no-op until an admin changes something.** +**Not** seeded on boot (this changed during implementation): an **absent row means "use the compiled +default"**, so the table starts empty and only ever holds rows an admin has actually touched. The +defaults live in one place — `FEATURES` in `shardVisibility.js` — instead of being duplicated into a +seeder that could drift from it, and a DB blip degrades to those same defaults rather than to +"everything is public". **All ten shard features are covered — the four new ones and the six that +already ship — and every default reproduces today's behavior, so the retrofit is a no-op until an +admin changes something.** | Feature | Default audience | Sensitive fields (default rung) | |---|---|---| @@ -156,10 +160,42 @@ Applied at: 3. **Nav** — `GET /api/v1/public/shard/features` returns only the features the calling viewer can see, so the SPA hides nav entries rather than rendering links that 403. +### 3.6.1 What the first implementation missed (found by the §11 smoke test, fixed) + +Part A shipped enforcement on the SSE path and on `/guilds` + `/governors`, but the **remaining public +REST reads never called into it** — so the same event was projected live and served verbatim from +history. Recorded because each miss is a shape the next phase can repeat: + +- **`/public/shard/feed` returned the stored payload as-is.** `actor.acct` / `actor.webId` were + readable *anonymously* for every logged kind (`player.death`, `mob.killed`, `skill.gain`, + `guild.join`, …) — broader than the §3.1 leak, which was limited to board holders. +- **`/public/shard/idoc` returned `ownerAcct`.** Rule 1 keyed on the exact strings `acct`/`webId`, + but `shapeHouse` flattens the actor into `ownerAcct` / `ownerName` / `ownerSerial`. The lock is now + on the field's **meaning** — a key that is or ends in `acct`/`webId`, case-insensitively — so + flattened spellings are covered and unwritten shapes fail closed. +- **The `houses` field rules were dead config.** Neither `getIdoc` nor `getHouses` projected, so the + panel offered toggles that did nothing. **Every feature's declared fields must name the keys the + read model actually emits**, not just the wire frame's. +- **`/feed` filtered on `PUBLIC_KINDS`**, a module-load constant derived from the compiled defaults, + so live audience changes never reached it. `visibleKinds(level, config)` resolves the readable set + from live config; it deliberately ignores the `stream` flag, which governs SSE fan-out only (market + history stays readable with its firehose off). +- **`shardEvents.db.list` treated an empty `kinds` array as "no filter"** and fell through to an + unfiltered `SELECT`. A fully-gated config would have dumped the whole event log, staff audit + included. An empty allowlist now serves nothing. +- **`projectValue` recursed into every object**, so a `Date` column came back as `{}`. It walks + arrays and plain objects only. The unit tests used JSON fixtures and could not have caught this — + the live read did, which is the argument for §11's smoke test over tests alone. + +**The rule this leaves behind:** *a read path that returns shard data and does not call +`projectFeature` is a bug.* Every new surface in Parts B and C — `/ruleset`, `/points`, `/market`, +`/atlas` — must project, and must gate its kind set on live config rather than on `PUBLIC_KINDS`. + ### 3.7 Admin surface `GET` / `PUT /api/v1/admin/shard/visibility` (admin-only). Validate feature names against the known -set and rungs against the ladder; reject any attempt to set `acct`/`webId` below `admin`. Writes an +set and rungs against the ladder; reject any attempt to set a locked field below `admin` — including +its flattened spellings (`ownerAcct`, `leaderWebId`), see §3.6.1. Writes an `admin.audit`-style row so visibility changes are traceable. New client panel `routes/admin/ShardVisibility.jsx` at `/admin/shard-visibility`, linked from `ShardAdmin.jsx`. diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index c160e74..fb43fb9 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -711,6 +711,10 @@ 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. + The lock is on the field's **meaning, not one spelling**: `isLockedField(key)` matches a key that + *is* or *ends in* `acct`/`webId`, case-insensitively, so the flattened forms the read models emit + (`shapeHouse` → `ownerAcct`, `shapeGuild` → `leaderWebId`) are covered too. An exact-key check was + the original implementation and it let `GET /public/shard/idoc` serve `ownerAcct` anonymously. 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. @@ -731,14 +735,31 @@ it picks, it fails open on one side.) | 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. +streams**. `PUBLIC_KINDS` still exists and is still exported (`notificationStreams.js`) but is now +**derived** from the kind map rather than hand-maintained, so the two cannot drift. + +**`PUBLIC_KINDS` is a module-load constant and must not be used to answer "may this caller read this +kind?"** — it is computed from the compiled *defaults*, so it cannot see an admin's changes. Use +`visibleKinds(level, config)`, which resolves against the live config. `/feed` uses it; it originally +used `PUBLIC_KINDS` and consequently kept serving `guild.join` to anonymous callers after an admin had +moved `guilds` to `staff`. `visibleKinds` deliberately ignores the `stream` flag: that governs SSE +fan-out only, so a feature whose live firehose ships off (market) stays readable from stored history. + +**Every read path that returns shard data must call `projectFeature`.** The stored-history endpoints +are not exempt — `/feed` returns the same events the stream does, and returning them unprojected +reopens on the REST side exactly what the stream closes. Relatedly, `shardEvents.db.list` treats an +**empty** `kinds` array as "serve nothing", never "no filter"; the fall-through it used to take would +have turned a fully-gated config into a dump of the entire event log. + +`projectFeature` walks **arrays and plain objects only**. A `Date`, `Buffer` or other class instance +is passed through as a value — rebuilding one key-by-key yields `{}`, which is the difference between +the pure-JSON wire frames and the DB-backed read models whose rows carry real `Date` columns. **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. +changes something — with deliberate exceptions, which are the leaks it was written to close. +`/public/shard/guilds`, `/public/shard/governors` and `/public/shard/feed` previously returned the raw +stored payload, whose actors carry `acct` and `webId`; `/public/shard/idoc` returned the flattened +`ownerAcct`. All are now stripped for every caller below admin. --- diff --git a/website/SHARD_VISIBILITY.md b/website/SHARD_VISIBILITY.md index 4f092f8..c8e301f 100644 --- a/website/SHARD_VISIBILITY.md +++ b/website/SHARD_VISIBILITY.md @@ -80,6 +80,11 @@ game to anyone standing next to them; the **account** behind it is not, and neit 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. +This rule matches the *meaning* of a field, not one spelling of it. Some responses nest the player +who owns a record (`leader.acct`); others flatten it into the row (`ownerAcct`, `leaderWebId`, +`governorAcct`). Every one of those is locked, and the admin API refuses to configure any of them — +so a new response shape can't quietly reopen the hole by naming the field differently. + **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 @@ -99,6 +104,15 @@ Three places, one config: - **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. +**Stored history answers the same way the live stream does.** The activity feed reads from the event +log rather than the live stream, but it resolves the *same* question against the *same* config: which +kinds you may read, and which fields survive. So moving a feature up a rung hides it from the history +as well as the stream — there is no back door where yesterday's copy of an event is more revealing +than today's. + +One deliberate asymmetry: turning **live updates** off for a feature stops the push, not the reading. +The marketplace ships this way — its history and its pages are public, only the firehose is off. + Changes take effect within about five seconds, **including on streams that are already open**. You don't need to restart anything.