From f3450686e041e82d4b5b53b5c1af463e1c43d6ea Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 28 Jul 2026 10:04:48 -0500 Subject: [PATCH] feat(shard): admin-configurable visibility for every shard surface 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 --- client/src/App.jsx | 2 + client/src/api/client.js | 10 + client/src/components/SiteHeader.jsx | 20 +- client/src/lib/useShardFeatures.js | 58 ++ client/src/routes/admin/AdminLayout.jsx | 2 + .../routes/admin/views/ShardVisibility.jsx | 318 +++++++++++ server/db/schema.sql | 23 + server/routes.guards.json | 48 +- server/routes.manifest.json | 12 + .../shardVisibility/shardVisibility.db.js | 37 ++ .../shardVisibility/shardVisibility.model.js | 44 ++ server/src/router/v1/admin/shard.router.js | 32 ++ .../v1/admin/shardVisibility.controller.js | 95 ++++ .../src/router/v1/public/shard.controller.js | 54 +- server/src/router/v1/public/shard.router.js | 35 +- server/src/utils/shardBroadcast.js | 167 +++--- server/src/utils/shardIngest.js | 11 +- server/src/utils/shardVisibility.js | 358 ++++++++++++ server/swagger/swagger-output.json | 535 +++++++++++++++++- server/swagger/swagger.js | 72 +++ server/test/shardBroadcast.visibility.test.js | 225 ++++++++ server/test/shardVisibility.test.js | 319 +++++++++++ 22 files changed, 2369 insertions(+), 108 deletions(-) create mode 100644 client/src/lib/useShardFeatures.js create mode 100644 client/src/routes/admin/views/ShardVisibility.jsx create mode 100644 server/src/model/shardVisibility/shardVisibility.db.js create mode 100644 server/src/model/shardVisibility/shardVisibility.model.js create mode 100644 server/src/router/v1/admin/shardVisibility.controller.js create mode 100644 server/src/utils/shardVisibility.js create mode 100644 server/test/shardBroadcast.visibility.test.js create mode 100644 server/test/shardVisibility.test.js diff --git a/client/src/App.jsx b/client/src/App.jsx index 69386a0..c37bb73 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -40,6 +40,7 @@ import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx' import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx' import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx' import ShardAdmin from './routes/admin/views/ShardAdmin.jsx' +import ShardVisibility from './routes/admin/views/ShardVisibility.jsx' import ShardOps from './routes/admin/views/ShardOps.jsx' import AdminCharacters from './routes/admin/views/AdminCharacters.jsx' import AdminCharacter from './routes/admin/views/AdminCharacter.jsx' @@ -142,6 +143,7 @@ export default function App() { } /> } /> } /> + } /> req('/public/shard/presence'), houses: () => req('/public/shard/houses'), + // Which shard surfaces this caller may reach, plus the audience rung they + // resolved to. Drives nav so we never render a link that would 403. + features: () => req('/public/shard/features'), }, // Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is // fetch-only, so SSE subscribers build the URL from here. The admin stream @@ -346,6 +349,13 @@ export const api = { saveUoLinkConfig: (data) => req('/admin/uo-link/config', { method: 'PUT', body: data }), postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }), deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }), + // Per-feature shard visibility: who may see which shard surface, and which + // sensitive fields within it. Admin only — it decides what ANONYMOUS + // visitors get. acct/webId are admin-only always and the API rejects any + // attempt to configure them. + getShardVisibility: () => req('/admin/shard/visibility'), + saveShardVisibility: (features) => + req('/admin/shard/visibility', { method: 'PUT', body: { features } }), // ----- in-game staff operations: write plane + support queue (admin/moderator) ----- // `actor` is stamped server-side from the session — never sent from here. diff --git a/client/src/components/SiteHeader.jsx b/client/src/components/SiteHeader.jsx index 4394419..58a1dc6 100644 --- a/client/src/components/SiteHeader.jsx +++ b/client/src/components/SiteHeader.jsx @@ -2,9 +2,15 @@ import { Link, NavLink } from 'react-router-dom' import MoonDot from './MoonDot.jsx' import { useAuth } from '../contexts/AuthContext.jsx' import { useSite } from '../contexts/SiteContext.jsx' +import { useShardFeatures, canSee } from '../lib/useShardFeatures.js' // One consistent top nav for the whole public site. Every page gets the same // main links plus an auth-aware entry on the right (Sign in / My Account / Admin). +// +// Entries carrying a `feature` are shard surfaces an admin can disable or gate +// to a higher audience (Admin -> Shard Visibility). They are hidden when this +// viewer can't reach them, so we never render a link that would 403. The gate +// itself is server-side; this is only about not advertising a dead end. const NAV = [ { label: 'Home', to: '/', end: true }, { label: 'News', to: '/site/news' }, @@ -12,11 +18,11 @@ const NAV = [ { label: 'Five on Friday', to: '/site/five-on-friday' }, { label: 'Newsletter', to: '/site/newsletter' }, { label: 'Wiki', to: '/wiki' }, - { label: 'Shard', to: '/site/shard' }, - { label: 'Champions', to: '/site/champs' }, - { label: 'Guilds', to: '/site/guilds' }, - { label: 'Governors', to: '/site/governors' }, - { label: 'Houses', to: '/site/houses' }, + { label: 'Shard', to: '/site/shard', feature: 'status' }, + { label: 'Champions', to: '/site/champs', feature: 'champs' }, + { label: 'Guilds', to: '/site/guilds', feature: 'guilds' }, + { label: 'Governors', to: '/site/governors', feature: 'governors' }, + { label: 'Houses', to: '/site/houses', feature: 'houses' }, { label: 'About', to: '/site/about' }, ] @@ -29,6 +35,8 @@ const linkStyle = ({ isActive }) => ({ export default function SiteHeader() { const { user, loading } = useAuth() const { siteTitle } = useSite() + const shardFeatures = useShardFeatures() + const nav = NAV.filter((item) => !item.feature || canSee(shardFeatures, item.feature)) // Where the auth entry points: staff → admin, player → portal, else sign in. let account @@ -60,7 +68,7 @@ export default function SiteHeader() { {siteTitle}