feat(shard): admin-configurable visibility for every shard surface #109

Merged
whitlocktech merged 1 commits from feat/shard-visibility-framework into edge 2026-07-28 15:08:14 +00:00
22 changed files with 2369 additions and 108 deletions
Showing only changes of commit f3450686e0 - Show all commits

View File

@@ -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() {
<Route path="bot-activity" element={<BotActivityAdmin />} />
<Route path="discord-bot" element={<DiscordBotAdmin />} />
<Route path="shard" element={<ShardAdmin />} />
<Route path="shard-visibility" element={<ShardVisibility />} />
<Route
path="shard-ops"
element={

View File

@@ -147,6 +147,9 @@ export const api = {
},
presence: () => 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.

View File

@@ -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}
</Link>
<nav style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
{NAV.map((l) => (
{nav.map((l) => (
<NavLink key={l.to} to={l.to} end={l.end} className="pill" style={linkStyle}>
{l.label}
</NavLink>

View File

@@ -0,0 +1,58 @@
import { useEffect, useState } from 'react'
import { api } from '../api/client.js'
// Which shard surfaces the current viewer may reach, from
// GET /public/shard/features. Admins configure this per feature (Admin → Shard
// Visibility), so the nav can't be a static list any more.
//
// This is PRESENTATION only. The gate is server-side: a disabled feature 404s
// and an out-of-rung one 403s whether or not the link is rendered. So while the
// answer is still in flight we return `null` and callers show their default set
// — better a link that briefly 403s than a nav that flickers in on every load.
//
// Cached module-level: the answer is per-viewer but stable for a session, and
// every consumer would otherwise refetch it on mount.
let cached = null
let inFlight = null
export function resetShardFeatures() {
cached = null
inFlight = null
}
export function useShardFeatures() {
const [features, setFeatures] = useState(cached)
useEffect(() => {
if (cached) return undefined
let alive = true
inFlight =
inFlight ||
api.shard
.features()
.then((data) => {
cached = { level: data.level, set: new Set(data.features || []) }
return cached
})
.catch(() => {
// A failed lookup must not blank the nav — fall back to "show
// everything" and let the server do the gating.
cached = null
inFlight = null
return null
})
inFlight.then((result) => {
if (alive) setFeatures(result)
})
return () => {
alive = false
}
}, [])
return features
}
// Convenience: true when `name` is visible, or when we don't know yet.
export function canSee(features, name) {
return !features || features.set.has(name)
}

View File

@@ -78,6 +78,7 @@ const NAV = [
{ to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] },
{ to: '/admin/discord-bot', label: 'Discord Bot', icon: IconBot, roles: ['admin'] },
{ to: '/admin/shard', label: 'Shard (uo-link)', icon: IconShard, roles: ['admin'] },
{ to: '/admin/shard-visibility', label: 'Shard Visibility', icon: IconShard, roles: ['admin'] },
{ to: '/admin/bot-activity', label: 'Web Bot Activity', icon: IconPulse, roles: ['admin'] },
],
},
@@ -106,6 +107,7 @@ const TITLES = {
'/admin/bot-activity': 'Web Bot Activity',
'/admin/discord-bot': 'Discord Bot',
'/admin/shard': 'Shard (uo-link)',
'/admin/shard-visibility': 'Shard Visibility',
'/admin/characters': 'My Characters',
'/admin/auth-providers': 'Authentication',
'/admin/users': 'Users',

View File

@@ -0,0 +1,318 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
// ── Admin · Shard visibility ────────────────────────────────────────────────
//
// Who may see which shard surface, and which sensitive fields within it.
// Admin-only, because this decides what ANONYMOUS visitors get.
//
// Two things the UI must communicate honestly, because they are not negotiable
// server-side (see docs/link/v3.md §3.4):
// • acct / webId are admin-only always and are not listed as editable fields.
// • an event kind the server doesn't know about never reaches anyone below
// admin, whatever is set here.
//
// Defaults reproduce the behavior the site had before this panel existed, so a
// fresh install shows "everything as it was" rather than an empty form.
const RUNG_LABEL = {
anonymous: 'Everyone',
logged_in: 'Signed in',
player: 'Linked players',
staff: 'Staff',
admin: 'Admins only',
}
const RUNG_HINT = {
anonymous: 'Visible to anyone, signed in or not.',
logged_in: 'Any signed-in account, linked or not.',
player: 'Accounts with a linked game account. Staff always qualify.',
staff: 'Admins and moderators.',
admin: 'Admins only.',
}
const FEATURE_LABEL = {
status: 'Shard status',
activity: 'Activity feed',
champs: 'Champion spawns',
guilds: 'Guilds',
governors: 'Town governors',
houses: 'Houses / IDOC',
presence: 'Players online',
ruleset: 'Shard rules',
atlas: 'Spawn atlas',
leaderboards: 'Leaderboards',
market: 'Marketplace',
}
const FEATURE_HINT = {
status: 'Connection state, online count, gold-supply series.',
activity: 'Deaths, kills, skill gains, quests, logins.',
champs: 'The live champion / mini-champ / sea-boss board.',
guilds: 'Guild rosters, alliances and leaders.',
governors: 'City Loyalty governors, elections and term history.',
houses: 'Houses in danger (IDOC). Owner and price are separate fields below.',
presence: 'Population aggregate and the staff-online widget.',
ruleset: 'Skill/stat caps, house limits, vet rewards and the rest of the ruleset.',
atlas: 'The spawn atlas and bestiary. Static shard content, not live state.',
leaderboards: 'Point and loyalty standings across every points system.',
market: 'The shard-wide player-vendor index.',
}
const FIELD_LABEL = {
owner: 'House owner',
price: 'House price',
location: 'In-game location (map + coordinates)',
connect: 'Server connect address',
characterName: 'Character names',
ownerName: 'Vendor owner name',
}
function RungSelect({ value, onChange, ladder, disabled }) {
return (
<select
className="input"
value={value}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
style={{ maxWidth: 200 }}
>
{ladder.map((rung) => (
<option key={rung} value={rung}>
{RUNG_LABEL[rung] || rung}
</option>
))}
</select>
)
}
function FeatureRow({ name, settings, defaults, ladder, onPatch }) {
const fields = Object.entries(settings.fields || {})
const changed =
defaults &&
(settings.enabled !== defaults.enabled ||
settings.audience !== defaults.audience ||
settings.stream !== defaults.stream ||
JSON.stringify(settings.fields) !== JSON.stringify(defaults.fields))
return (
<div
style={{
border: '1px solid var(--line)',
borderRadius: 10,
padding: 16,
display: 'flex',
flexDirection: 'column',
gap: 12,
opacity: settings.enabled ? 1 : 0.62,
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<div style={{ minWidth: 0 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1rem', color: 'var(--head)' }}>
{FEATURE_LABEL[name] || name}
{changed && (
<span
className="sans"
style={{ marginLeft: 8, fontSize: '0.62rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--accent)' }}
>
changed
</span>
)}
</h3>
<p className="sans" style={{ margin: '4px 0 0', fontSize: '0.82rem', color: 'var(--muted)', lineHeight: 1.5 }}>
{FEATURE_HINT[name]}
</p>
</div>
<label
className="sans"
style={{ flex: 'none', display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: '0.86rem', color: 'var(--ink)' }}
>
<input
type="checkbox"
checked={settings.enabled}
onChange={(e) => onPatch(name, { enabled: e.target.checked })}
/>
Enabled
</label>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 20, alignItems: 'flex-end' }}>
<label style={{ display: 'block' }}>
<span className="field-label">Who can see it</span>
<RungSelect
value={settings.audience}
ladder={ladder}
disabled={!settings.enabled}
onChange={(audience) => onPatch(name, { audience })}
/>
<span className="sans dim" style={{ display: 'block', marginTop: 4, fontSize: '0.75rem' }}>
{RUNG_HINT[settings.audience]}
</span>
</label>
<label
className="sans"
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: '0.86rem', color: 'var(--ink)', paddingBottom: 22 }}
>
<input
type="checkbox"
checked={settings.stream}
disabled={!settings.enabled}
onChange={(e) => onPatch(name, { stream: e.target.checked })}
/>
Live updates
</label>
</div>
{fields.length > 0 && (
<div style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 12 }}>
<span className="field-label" style={{ display: 'block', marginBottom: 8 }}>
Sensitive fields
</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 16 }}>
{fields.map(([field, rung]) => (
<label key={field} style={{ display: 'block' }}>
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginBottom: 4 }}>
{FIELD_LABEL[field] || field}
</span>
<RungSelect
value={rung}
ladder={ladder}
disabled={!settings.enabled}
onChange={(level) =>
onPatch(name, { fieldRules: { ...settings.fields, [field]: level } })
}
/>
</label>
))}
</div>
</div>
)}
</div>
)
}
export default function ShardVisibility() {
const [config, setConfig] = useState(null)
const [defaults, setDefaults] = useState(null)
const [ladder, setLadder] = useState([])
const [lockedFields, setLockedFields] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState('')
const load = useCallback(async () => {
setLoading(true)
setError('')
try {
const data = await api.admin.getShardVisibility()
setConfig(data.features)
setDefaults(data.defaults)
setLadder(data.ladder || [])
setLockedFields(data.lockedFields || [])
} catch (err) {
setError(err.message || 'Could not load visibility settings.')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load])
function patch(name, changes) {
setMsg('')
setConfig((prev) => {
const next = { ...prev[name], ...changes }
// `fieldRules` in the API is `fields` in the effective config.
if (changes.fieldRules) {
next.fields = changes.fieldRules
delete next.fieldRules
}
return { ...prev, [name]: next }
})
}
async function save() {
setSaving(true)
setMsg('')
setError('')
try {
const body = {}
for (const [name, s] of Object.entries(config)) {
body[name] = {
enabled: s.enabled,
audience: s.audience,
stream: s.stream,
fieldRules: s.fields || {},
}
}
const data = await api.admin.saveShardVisibility(body)
setConfig(data.features)
setMsg('Saved. Changes take effect within a few seconds, including on open live streams.')
} catch (err) {
setError(err.message || 'Could not save.')
} finally {
setSaving(false)
}
}
function resetToDefaults() {
setMsg('')
setConfig(structuredClone(defaults))
}
if (loading) return <Loading />
if (error && !config) return <ErrorState message={error} onRetry={load} />
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<header>
<h2 className="display" style={{ margin: 0, fontSize: '1.3rem', color: 'var(--head)' }}>
Shard visibility
</h2>
<p className="sans" style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 760 }}>
Choose who can see each shard surface on the public site, and how much detail they get.
Turning a feature off hides it entirely its pages return not found rather than
revealing that it exists. Live updates controls whether the feature streams changes in
real time; the pages still work without it, they just refresh on load.
</p>
{lockedFields.length > 0 && (
<p className="sans dim" style={{ margin: '8px 0 0', fontSize: '0.82rem', lineHeight: 1.6, maxWidth: 760 }}>
Not configurable: <strong style={{ color: 'var(--ink)' }}>{lockedFields.join(', ')}</strong>
game account names and website user ids are never shown below admin, on any surface. They
arent visible in game either, so publishing them would disclose something the shard
itself doesnt.
</p>
)}
</header>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{Object.entries(config).map(([name, settings]) => (
<FeatureRow
key={name}
name={name}
settings={settings}
defaults={defaults?.[name]}
ladder={ladder}
onPatch={patch}
/>
))}
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={save} disabled={saving} className="btn btn-primary btn-sq">
{saving ? 'Saving…' : 'Save changes'}
</button>
<button onClick={resetToDefaults} disabled={saving} className="btn btn-sq">
Restore defaults
</button>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</div>
</div>
)
}

View File

@@ -597,6 +597,29 @@ CREATE TABLE IF NOT EXISTS shard_presence (
CONSTRAINT chk_shard_presence_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Per-feature visibility for every shard-derived surface (Protocol 3.0). One row
-- per feature; an absent row means "use the compiled default", and the compiled
-- defaults reproduce the behavior that shipped before v3 — so an empty table is
-- a no-op. See utils/shardVisibility.js for the catalog and the ladder, and
-- docs/link/v3.md §3 for the contract.
--
-- audience the minimum rung on anonymous < logged_in < player < staff < admin
-- stream whether this feature's kinds fan out over SSE at all (the market
-- index ships with this off: no page needs a live firehose of
-- whole vendor inventories)
-- field_rules {"<field>": "<rung>"} for SENSITIVE fields only. `acct` and
-- `webId` are admin-only always and are rejected here — they are
-- not in-game visible and are deliberately not configurable.
CREATE TABLE IF NOT EXISTS shard_feature_visibility (
feature VARCHAR(48) NOT NULL PRIMARY KEY,
enabled TINYINT(1) NOT NULL DEFAULT 1,
audience VARCHAR(20) NOT NULL DEFAULT 'anonymous',
stream TINYINT(1) NOT NULL DEFAULT 1,
field_rules JSON NULL,
updated_by INT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Admin email invites (Protocol 2.0 provisioning). A staff member invites someone
-- by email at a pre-chosen access level; the invitee accepts via a tokened link,
-- which creates their website user at that role (and optionally a linked game

View File

@@ -782,6 +782,26 @@
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/admin/shard/visibility",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "PUT",
"path": "/api/v1/admin/shard/visibility",
"handlers": 4,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "PUT",
"path": "/api/v1/admin/site-mode",
@@ -1809,22 +1829,28 @@
{
"method": "GET",
"path": "/api/v1/public/shard/champs",
"handlers": 1,
"handlers": 2,
"gates": []
},
{
"method": "GET",
"path": "/api/v1/public/shard/economy",
"handlers": 3,
"handlers": 4,
"gates": [
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/public/shard/features",
"handlers": 1,
"gates": []
},
{
"method": "GET",
"path": "/api/v1/public/shard/feed",
"handlers": 4,
"handlers": 5,
"gates": [
"middleware",
"validate"
@@ -1833,13 +1859,13 @@
{
"method": "GET",
"path": "/api/v1/public/shard/governors",
"handlers": 1,
"handlers": 2,
"gates": []
},
{
"method": "GET",
"path": "/api/v1/public/shard/governors/:city/history",
"handlers": 4,
"handlers": 5,
"gates": [
"middleware",
"validate"
@@ -1848,37 +1874,37 @@
{
"method": "GET",
"path": "/api/v1/public/shard/guilds",
"handlers": 1,
"handlers": 2,
"gates": []
},
{
"method": "GET",
"path": "/api/v1/public/shard/houses",
"handlers": 1,
"handlers": 2,
"gates": []
},
{
"method": "GET",
"path": "/api/v1/public/shard/idoc",
"handlers": 1,
"handlers": 2,
"gates": []
},
{
"method": "GET",
"path": "/api/v1/public/shard/online",
"handlers": 1,
"handlers": 2,
"gates": []
},
{
"method": "GET",
"path": "/api/v1/public/shard/presence",
"handlers": 1,
"handlers": 2,
"gates": []
},
{
"method": "GET",
"path": "/api/v1/public/shard/status",
"handlers": 1,
"handlers": 2,
"gates": []
},
{

View File

@@ -313,6 +313,14 @@
"method": "GET",
"path": "/api/v1/admin/shard/vendors/:account"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/visibility"
},
{
"method": "PUT",
"path": "/api/v1/admin/shard/visibility"
},
{
"method": "PUT",
"path": "/api/v1/admin/site-mode"
@@ -737,6 +745,10 @@
"method": "GET",
"path": "/api/v1/public/shard/economy"
},
{
"method": "GET",
"path": "/api/v1/public/shard/features"
},
{
"method": "GET",
"path": "/api/v1/public/shard/feed"

View File

@@ -0,0 +1,37 @@
const { query } = require('../../utils/db')
// One row per shard feature. Absent rows are fine — utils/shardVisibility.js
// compiles a default for every known feature and merges stored rows over it, so
// a fresh install with an empty table behaves exactly as the site did pre-v3.
const COLS = 'feature, enabled, audience, stream, field_rules, updated_by, updated_at'
const listAll = () => query(`SELECT ${COLS} FROM shard_feature_visibility`)
const getOne = (feature) =>
query(`SELECT ${COLS} FROM shard_feature_visibility WHERE feature = ?`, [feature])
// Upsert one feature's settings. `fieldRules` is stored as a JSON object of
// {field: rung}; the caller has already stripped locked fields and validated
// every rung against the ladder.
const upsert = ({ feature, enabled, audience, stream, fieldRules, updatedBy }) =>
query(
`INSERT INTO shard_feature_visibility (feature, enabled, audience, stream, field_rules, updated_by)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
enabled = VALUES(enabled),
audience = VALUES(audience),
stream = VALUES(stream),
field_rules = VALUES(field_rules),
updated_by = VALUES(updated_by)`,
[
feature,
enabled ? 1 : 0,
audience,
stream ? 1 : 0,
fieldRules == null ? null : JSON.stringify(fieldRules),
updatedBy ?? null,
],
)
module.exports = { listAll, getOne, upsert }

View File

@@ -0,0 +1,44 @@
// ── Shard feature visibility (model) ───────────────────────────────────────
//
// Thin row-shaping layer over shardVisibility.db. The policy — the ladder, the
// feature catalog, the locked fields, the kind→feature map — lives in
// utils/shardVisibility.js; this file only reads and writes rows.
const db = require('./shardVisibility.db')
// The `field_rules` JSON column comes back as a string on the mariadb driver.
function parseRules(raw) {
if (raw == null) return {}
if (typeof raw === 'object') return raw
try {
const parsed = JSON.parse(raw)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
} catch {
return {}
}
}
const toSafe = (row) =>
row && {
feature: row.feature,
enabled: !!row.enabled,
audience: row.audience,
stream: row.stream == null ? null : !!row.stream,
fieldRules: parseRules(row.field_rules),
updatedBy: row.updated_by,
updatedAt: row.updated_at,
}
async function listAll() {
const rows = await db.listAll()
return rows.map(toSafe)
}
async function getOne(feature) {
const rows = await db.getOne(feature)
return toSafe(rows[0])
}
const upsert = (entry) => db.upsert(entry)
module.exports = { listAll, getOne, upsert }

View File

@@ -23,6 +23,7 @@ const express = require('express')
const { body, param } = require('express-validator')
const shardOps = require('./shardOps.controller')
const shardVisibility = require('./shardVisibility.controller')
const selfShard = require('../player/shard.controller')
const { requireRole } = require('../../../utils/auth')
const validate = require('../../../middleware/validate')
@@ -31,6 +32,8 @@ const shardRouter = express.Router()
// Moderator gate. Admins can do everything a moderator can.
const modAccess = requireRole('admin', 'moderator')
// Admin-only gate, for settings that decide what the PUBLIC sees.
const adminOnly = requireRole('admin')
// ── Game account linking (self-service, any staff role) ───────────────
// Staff link their OWN in-game account here, exactly like players do under
@@ -232,4 +235,33 @@ shardRouter.get(
shardOps.listHouses,
)
// ── Feature visibility (admin only) ───────────────────────────────────
// Who can see which shard surface, and which sensitive fields within it. This
// decides what ANONYMOUS visitors get, so it sits above the moderator tier.
shardRouter.get(
'/visibility',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Get per-feature shard visibility config (admin only)'
// #swagger.description = 'The effective config (compiled defaults merged with stored overrides) plus the vocabulary the admin UI renders from: the audience ladder and the always-locked fields. Defaults reproduce pre-v3 behavior.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Visibility config', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardVisibilityConfig" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
shardVisibility.getVisibility,
)
shardRouter.put(
'/visibility',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Update per-feature shard visibility config (admin only)'
// #swagger.description = 'Patch one or more features. Unknown feature names, unknown rungs, and any attempt to configure a locked field (acct / webId — admin-only always) are rejected with 400 rather than silently dropped.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ShardVisibilityUpdate" } } } } */
/* #swagger.responses[200] = { description: 'Updated config', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardVisibilityConfig" } } } } */
/* #swagger.responses[400] = { description: 'Unknown feature, rung, or a locked field', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
body('features').isObject(),
validate,
shardVisibility.putVisibility,
)
module.exports = shardRouter

View File

@@ -0,0 +1,95 @@
// ── Admin · Shard visibility ───────────────────────────────────────────────
//
// Read/write the per-feature audience config that gates every shard-derived
// surface. Admin-only: this decides what anonymous visitors can see, so it is
// not part of the moderator tier.
//
// The policy itself (the ladder, the feature catalog, which fields are locked)
// lives in utils/shardVisibility.js. This controller only validates input
// against that policy and persists it.
const model = require('../../../model/shardVisibility/shardVisibility.model')
const visibility = require('../../../utils/shardVisibility')
const log = require('../../../utils/logger')('admin-shard-visibility')
// GET /admin/shard/visibility — the effective config (defaults merged with any
// stored overrides), plus the vocabulary the admin UI needs to render itself:
// the ladder, and which fields each feature exposes as configurable.
async function getVisibility(req, res) {
try {
const config = await visibility.getConfig()
return res.json({
ladder: visibility.LADDER,
lockedFields: Object.keys(visibility.LOCKED_FIELDS),
defaults: visibility.compileDefaults(),
features: config,
})
} catch (err) {
log.error('getVisibility', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// PUT /admin/shard/visibility — replace the settings for one or more features.
// Body: { features: { <name>: { enabled, audience, stream, fieldRules } } }
//
// Rejects unknown feature names, unknown rungs, and any attempt to configure a
// locked field — a 400 rather than a silent drop, so an admin who tries to make
// `acct` public learns that it is not negotiable.
async function putVisibility(req, res) {
try {
const incoming = req.body?.features
if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) {
return res.status(400).json({ message: 'features object required' })
}
const entries = []
for (const [name, patch] of Object.entries(incoming)) {
if (!visibility.isFeature(name)) {
return res.status(400).json({ message: `Unknown feature: ${name}` })
}
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
return res.status(400).json({ message: `Invalid settings for ${name}` })
}
if (patch.audience != null && !visibility.isLevel(patch.audience)) {
return res.status(400).json({ message: `Unknown audience for ${name}: ${patch.audience}` })
}
const fieldRules = {}
for (const [field, level] of Object.entries(patch.fieldRules || {})) {
if (Object.hasOwn(visibility.LOCKED_FIELDS, field)) {
return res.status(400).json({ message: `Field '${field}' is admin-only and cannot be configured` })
}
if (!visibility.isLevel(level)) {
return res.status(400).json({ message: `Unknown rung for ${name}.${field}: ${level}` })
}
fieldRules[field] = level
}
const current = (await visibility.getConfig())[name]
entries.push({
feature: name,
enabled: patch.enabled == null ? current.enabled : !!patch.enabled,
audience: patch.audience ?? current.audience,
stream: patch.stream == null ? current.stream : !!patch.stream,
fieldRules,
updatedBy: req.user?.id ?? null,
})
}
for (const entry of entries) await model.upsert(entry)
visibility.invalidate()
log.info('shard visibility updated', {
by: req.user?.id,
features: entries.map((e) => e.feature),
})
return res.json({ features: await visibility.getConfig() })
} catch (err) {
log.error('putVisibility', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { getVisibility, putVisibility }

View File

@@ -13,7 +13,7 @@ const shardEvents = require('../../../model/shardEvents/shardEvents.model')
const shardState = require('../../../model/shardState/shardState.model')
const uoLinkConfig = require('../../../model/uoLinkConfig/uoLinkConfig.model')
const broadcast = require('../../../utils/shardBroadcast')
const auth = require('../../../utils/auth')
const visibility = require('../../../utils/shardVisibility')
const log = require('../../../utils/logger')('public-shard')
@@ -72,18 +72,21 @@ async function getEconomy(req, res) {
// GET /public/shard/online — players online now whose account is linked to a
// STAFF website user (admin/editor/moderator). Everyone sees that a staff member
// is online (name + serial); their in-game location (map + coordinates) is only
// included for privileged viewers (admin/moderator) so it is never exposed to
// players or the public via the network tab. Non-staff players are never listed.
function canSeeStaffLocation(req) {
const viewer = auth.getUserFromRequest(req)
return !!viewer && (viewer.role === 'admin' || viewer.role === 'moderator')
// is online (name + serial); their in-game location (map + coordinates) is gated
// on the `presence` feature's `location` field rule, which defaults to `staff`
// — the same admin/moderator set this used to hardcode. Non-staff players are
// never listed.
async function canSeeStaffLocation(req) {
const config = await visibility.getConfig()
const required = config.presence?.fields?.location || 'staff'
const level = req.viewerLevel || (await visibility.viewerLevel(req))
return visibility.meets(level, required)
}
async function getOnline(req, res) {
try {
const rows = await shardState.listOnlineLinked()
const showLocation = canSeeStaffLocation(req)
const showLocation = await canSeeStaffLocation(req)
return res.json(
rows.map((r) => {
const entry = { serial: r.serial, name: r.name }
@@ -126,9 +129,13 @@ async function getChamps(req, res) {
// GET /public/shard/guilds — the current guild board. Served from our store;
// live via guild.update / guild.remove / guild.join on the public SSE stream.
//
// Projected: the stored payload is the raw guild.update frame, whose `leader`
// actor carries `acct` and `webId`. Those are admin-only and were previously
// returned verbatim to anonymous callers.
async function getGuilds(req, res) {
try {
return res.json(await shardState.listGuilds())
return res.json(await visibility.project('guilds', await shardState.listGuilds(), req))
} catch (err) {
log.error('shard.getGuilds', err)
return res.status(500).json({ message: 'Internal Server Error' })
@@ -136,10 +143,11 @@ async function getGuilds(req, res) {
}
// GET /public/shard/governors — the current town-governor board (empty on shards
// without City Loyalty). Live via city.update on the public SSE stream.
// without City Loyalty). Live via city.update on the public SSE stream. Projected
// for the same reason as getGuilds: `governor` / `governorElect` are actors.
async function getGovernors(req, res) {
try {
return res.json(await shardState.listGovernors())
return res.json(await visibility.project('governors', await shardState.listGovernors(), req))
} catch (err) {
log.error('shard.getGovernors', err)
return res.status(500).json({ message: 'Internal Server Error' })
@@ -150,7 +158,8 @@ async function getGovernors(req, res) {
// (look-back: "who were all the governors of Britain?"), newest first.
async function getGovernorHistory(req, res) {
try {
return res.json(await shardState.listGovernorHistory(req.params.city, req.query.limit))
const terms = await shardState.listGovernorHistory(req.params.city, req.query.limit)
return res.json(await visibility.project('governors', terms, req))
} catch (err) {
log.error('shard.getGovernorHistory', err)
return res.status(500).json({ message: 'Internal Server Error' })
@@ -192,9 +201,25 @@ async function getHouses(req, res) {
}
}
// GET /public/shard/stream — public live-event SSE channel (safe kinds only).
// GET /public/shard/features — the shard features THIS caller can actually see,
// so the SPA (and the Android client) can hide nav entries instead of rendering
// links that 403. Deliberately reports only what the viewer may reach: the list
// itself must not disclose the existence of a feature they're gated out of.
async function getFeatures(req, res) {
try {
const config = await visibility.getConfig()
const level = await visibility.viewerLevel(req)
return res.json({ level, features: visibility.visibleFeatures(level, config) })
} catch (err) {
log.error('shard.getFeatures', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /public/shard/stream — live-event SSE channel. What arrives depends on the
// caller's audience rung, resolved once at subscribe time; see shardBroadcast.js.
function stream(req, res) {
broadcast.subscribe(req, res, 'public')
return broadcast.subscribe(req, res, 'public')
}
module.exports = {
@@ -209,5 +234,6 @@ module.exports = {
getGovernorHistory,
getPresence,
getHouses,
getFeatures,
stream,
}

View File

@@ -10,19 +10,28 @@
// visitors *and* by the Android ShardStreamClient, neither of which sends an
// Authorization header; adding requireAuth here blacks out the public live boards
// on web and mobile. The sensitive kinds (staff audit, cheat detection, login
// attempts, IPs) are withheld by the allowlist in utils/shardBroadcast.js, not by
// a route gate — that allowlist split is the security boundary, not this file.
// attempts, IPs) are withheld by utils/shardBroadcast.js, not by a route gate —
// that per-frame filtering is the security boundary, not this file. /stream is
// deliberately NOT wrapped in requireFeature either: it spans every feature, and
// each frame is gated individually against the subscriber's rung.
//
// Every other route carries `requireFeature(<name>)` (utils/shardVisibility.js),
// which 404s when an admin has disabled the feature and 403s when the caller sits
// below its configured audience. Defaults reproduce pre-v3 behavior exactly, so
// these gates are inert until an admin changes something.
const express = require('express')
const { param, query } = require('express-validator')
const shard = require('./shard.controller')
const validate = require('../../../middleware/validate')
const { requireFeature } = require('../../../utils/shardVisibility')
const shardRouter = express.Router()
shardRouter.get(
'/status',
requireFeature('status'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Shard connection state, online count and latest economy'
/* #swagger.responses[200] = { description: 'Shard status', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardStatus" } } } } */
@@ -30,6 +39,7 @@ shardRouter.get(
)
shardRouter.get(
'/feed',
requireFeature('activity'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Recent notable shard events (from the ingested log)'
// #swagger.parameters['kind'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Filter to a single event kind, e.g. vendor.sale.' }
@@ -42,6 +52,7 @@ shardRouter.get(
)
shardRouter.get(
'/economy',
requireFeature('status'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Gold-supply time series (oldest → newest)'
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max samples (default 100, max 1000).' }
@@ -52,6 +63,7 @@ shardRouter.get(
)
shardRouter.get(
'/online',
requireFeature('presence'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Staff online now (linked staff accounts; location is admin/moderator-only)'
/* #swagger.responses[200] = { description: 'Online players', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardOnlinePlayer" } } } } } */
@@ -59,6 +71,7 @@ shardRouter.get(
)
shardRouter.get(
'/idoc',
requireFeature('houses'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Houses currently in danger (IDOC)'
/* #swagger.responses[200] = { description: 'IDOC houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
@@ -66,6 +79,7 @@ shardRouter.get(
)
shardRouter.get(
'/champs',
requireFeature('champs'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Current champion-spawn board (all categories)'
// #swagger.description = 'The live board of every champion / mini-champ / sea-boss spawn. Update in place via the champ.update / champ.remove frames on /shard/stream.'
@@ -74,6 +88,7 @@ shardRouter.get(
)
shardRouter.get(
'/guilds',
requireFeature('guilds'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Current guild board (rosters, alliances, leaders)'
// #swagger.description = 'The live board of every guild. Update in place via the guild.update / guild.remove / guild.join frames on /shard/stream.'
@@ -82,6 +97,7 @@ shardRouter.get(
)
shardRouter.get(
'/governors',
requireFeature('governors'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Current town-governor board (City Loyalty)'
// #swagger.description = 'One entry per city with its governor and election phase. Empty if the shard does not run the City Loyalty system. Live via city.update on /shard/stream.'
@@ -90,6 +106,7 @@ shardRouter.get(
)
shardRouter.get(
'/governors/:city/history',
requireFeature('governors'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Governor term history for a city'
// #swagger.parameters['city'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'City name, e.g. Britain.' }
@@ -102,6 +119,7 @@ shardRouter.get(
)
shardRouter.get(
'/presence',
requireFeature('presence'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Online population aggregate (count + per-facet + per-region)'
// #swagger.description = 'The latest presence.online snapshot powering the "Players Online" widget. Live via presence.online on /shard/stream.'
@@ -110,17 +128,26 @@ shardRouter.get(
)
shardRouter.get(
'/houses',
requireFeature('houses'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'House registry (owner, co-owners, price, decay)'
// #swagger.description = 'Every house seen via the house.update registry feed. `price` is the placement value, not a for-sale flag. Live via house.update / house.remove on /shard/stream.'
/* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
shard.getHouses,
)
shardRouter.get(
'/features',
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Shard features visible to the caller (drives client nav)'
// #swagger.description = 'The caller\'s audience rung plus the shard features they may reach, so a client can hide nav entries instead of rendering links that 403. Reports only what the caller can see — the list itself does not disclose gated features.'
/* #swagger.responses[200] = { description: 'Visible features', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardFeatures" } } } } */
shard.getFeatures,
)
shardRouter.get(
'/stream',
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Live shard event stream (Server-Sent Events, public/safe kinds)'
// #swagger.description = 'text/event-stream of curated live events. Sensitive kinds (staff audit, cheat detection, login attempts, IPs) are NOT sent on this channel.'
// #swagger.summary = 'Live shard event stream (Server-Sent Events, filtered by audience)'
// #swagger.description = 'text/event-stream of live events. The caller\'s audience rung is resolved once at subscribe time and frozen for the connection; each frame is then gated on its feature and field-projected, so sensitive kinds and fields (staff audit, cheat detection, login attempts, IPs, acct/webId) never reach a caller below their configured rung.'
/* #swagger.responses[200] = { description: 'An SSE stream (Content-Type: text/event-stream).' } */
shard.stream,
)

View File

@@ -2,67 +2,66 @@
//
// The browser can't talk to the sidecar's WebSocket directly (the token must
// never reach it, and the WS may be on another host). Instead the server ingests
// the WS feed and re-broadcasts curated events to browsers over Server-Sent
// Events (plain HTTP — works through any reverse proxy).
// the WS feed and re-broadcasts events to browsers over Server-Sent Events
// (plain HTTP — works through any reverse proxy).
//
// Two channels:
// • public — safe kinds only (sales, deaths, IDOC, logins, economy). No IPs,
// no account-login attempts, no staff audit / cheat events.
// • admin — everything, including the sensitive kinds above.
// Since Protocol 3.0 the split is no longer "one public channel with a static
// allowlist plus one admin channel". Each subscriber carries the audience rung
// it resolved to at subscribe time, and every frame is
//
// 1. mapped kind → feature (an UNMAPPED kind reaches nobody below admin —
// fail closed; see utils/shardVisibility.js rule 2),
// 2. gated on that feature being enabled, streamed, and within the viewer's
// rung, and
// 3. passed through field projection, so `acct` / `webId` and any field an
// admin has re-gated are stripped per viewer.
//
// **This is the security boundary.** It used to be the PUBLIC_KINDS set in this
// file; it is now the kind map plus the visibility config. PUBLIC_KINDS still
// exists and is still exported, but it is now DERIVED from the kind map (see
// shardVisibility.js) so the two can no longer drift.
//
// shardIngest calls broadcast(event) for each ingested event; the public/admin
// SSE route handlers call subscribe(req, res, channel).
const visibility = require('./shardVisibility')
const log = require('./logger')('shard-broadcast')
// Kinds safe to expose to unauthenticated browsers. Note: vendor.sale is
// deliberately NOT here — sales are owner-private (a linked player sees only
// their own, via /player/shard/sales).
const PUBLIC_KINDS = new Set([
'player.death',
'player.murdered',
'mob.killed',
'house.decay',
'quest.complete',
'skill.gain',
'fame.change',
'karma.change',
'mob.login',
'mob.logout',
'economy.supply',
'server.hello',
'server.shutdown',
'server.crashed',
// Champion-spawn board deltas — the public Champions page renders these live.
'champ.update',
'champ.remove',
// Protocol 2.0 boards — public, rendered live on their respective pages.
'guild.update',
'guild.remove',
'guild.join',
'city.update',
'presence.online',
'region.enter',
// NOTE: house.update / house.remove (the full registry — owner, price, co-owners)
// are deliberately NOT public. The public Houses page shows only IDOC houses (via
// house.decay, which is public above) with location only; the full registry is
// staff-only and rides the admin SSE channel. See public/shard.controller getHouses.
])
// Re-exported for back-compat: shardEvents `/feed` filtering and
// config/notificationStreams.js both ask "is this kind public-safe?".
const { PUBLIC_KINDS } = visibility
// Open response streams per channel.
// Open streams. Each entry is { res, level }. The admin bucket is kept separate
// because it is unconditional and must not depend on a config read.
const clients = { public: new Set(), admin: new Set() }
const KEEPALIVE_MS = 25000
// Register an SSE stream on a channel. Sets the SSE headers, sends an initial
// comment, keeps the connection warm with periodic pings, and cleans up on close.
function subscribe(req, res, channel) {
//
// The viewer's rung is resolved ONCE, here, and frozen for the life of the
// connection — a long-lived stream must not silently gain privilege because the
// caller's session changed underneath it. (Config changes, by contrast, DO take
// effect live: the config is read per broadcast, cached ~5s.)
async function subscribe(req, res, channel) {
const bucket = clients[channel]
if (!bucket) {
res.status(400).end()
return
}
let level = 'admin'
if (channel === 'public') {
try {
level = await visibility.viewerLevel(req)
} catch (err) {
// Fail closed: an unresolvable viewer is anonymous, not privileged.
log.warn('viewerLevel failed on subscribe; treating as anonymous', { message: err.message })
level = 'anonymous'
}
}
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
@@ -72,9 +71,10 @@ function subscribe(req, res, channel) {
res.write('retry: 5000\n\n') // tell EventSource to reconnect after 5s if dropped
res.write(': connected\n\n')
bucket.add(res)
const client = { res, level, ping: null }
bucket.add(client)
const ping = setInterval(() => {
client.ping = setInterval(() => {
try {
res.write(': ping\n\n')
} catch {
@@ -82,45 +82,80 @@ function subscribe(req, res, channel) {
}
}, KEEPALIVE_MS)
const cleanup = () => {
clearInterval(ping)
bucket.delete(res)
}
const cleanup = () => drop(bucket, client)
req.on('close', cleanup)
res.on('error', cleanup)
}
function writeTo(bucket, payload) {
for (const res of bucket) {
try {
res.write(payload)
} catch (err) {
log.warn('sse write failed; dropping client', { message: err.message })
bucket.delete(res)
}
// The ONLY way a client leaves a bucket. Clearing the keepalive here (rather
// than only in the close handler) matters: a client dropped because its write
// threw never fires `req.close`, so its interval would otherwise keep firing on
// a dead socket for the life of the process.
function drop(bucket, client) {
clearInterval(client.ping)
bucket.delete(client)
}
function writeTo(bucket, client, payload) {
try {
client.res.write(payload)
} catch (err) {
log.warn('sse write failed; dropping client', { message: err.message })
drop(bucket, client)
}
}
// Fan an ingested event out to the admin channel (always) and the public
// channel (safe kinds only). A no-op when nobody is subscribed.
function broadcast(event) {
// Fan an ingested event out. The admin channel gets it verbatim, always. Public
// subscribers are filtered and projected per their own rung — so two viewers on
// the same channel can legitimately receive different versions of one frame, or
// one of them nothing at all.
async function broadcast(event) {
if (!event || !event.kind) return
const frame = `data: ${JSON.stringify(event)}\n\n`
if (clients.admin.size) writeTo(clients.admin, frame)
if (clients.public.size && PUBLIC_KINDS.has(event.kind)) writeTo(clients.public, frame)
if (clients.admin.size) {
const frame = `data: ${JSON.stringify(event)}\n\n`
for (const client of [...clients.admin]) writeTo(clients.admin, client, frame)
}
if (!clients.public.size) return
let config
try {
config = await visibility.getConfig()
} catch (err) {
// Fail closed: without a config we cannot prove a frame is safe to send.
log.error('visibility config unavailable; withholding public frame', err)
return
}
// Most frames land on one rung set, so cache the serialised payload per level
// instead of re-projecting and re-stringifying for every subscriber.
const byLevel = new Map()
for (const client of [...clients.public]) {
let frame = byLevel.get(client.level)
if (frame === undefined) {
frame = visibility.kindVisibleTo(event.kind, client.level, config)
? `data: ${JSON.stringify(visibility.projectFeature(visibility.KIND_FEATURE.get(event.kind), event, client.level, config))}\n\n`
: null
byLevel.set(client.level, frame)
}
if (frame) writeTo(clients.public, client, frame)
}
}
// Close every open stream (graceful shutdown).
// Close every open stream (graceful shutdown). Clears each keepalive timer too —
// without that the intervals keep the event loop alive after the streams are
// gone, and the process won't exit.
function closeAll() {
for (const channel of Object.values(clients)) {
for (const res of channel) {
for (const bucket of Object.values(clients)) {
for (const client of [...bucket]) {
drop(bucket, client)
try {
res.end()
client.res.end()
} catch {
/* ignore */
}
}
channel.clear()
}
}

View File

@@ -229,11 +229,12 @@ async function ingest(event, deps = {}) {
}
if (!deps.fromBackfill) {
try {
d.broadcast(event)
} catch (err) {
d.log.warn('broadcast failed', { kind: event.kind, message: err.message })
}
// Broadcast is async since v3 (it reads the visibility config to decide what
// each subscriber may see). Fire-and-forget, like the push fan-out below: a
// slow config read must never delay or fail ingest.
Promise.resolve(d.broadcast(event)).catch((err) =>
d.log.warn('broadcast failed', { kind: event.kind, message: err.message }),
)
// Opt-in push fan-out, off the same event source as the SSE broadcast.
// Fire-and-forget (a slow/dead ntfy relay must never delay or fail ingest);
// fromShardEvent is self-guarding, but .catch() covers any lookup rejection.

View File

@@ -0,0 +1,358 @@
// ── Shard feature visibility ───────────────────────────────────────────────
//
// Admin-configurable, per-feature and per-field audience control over every
// shard-derived surface on the site. Replaces the hardcoded split that used to
// live in two places (the PUBLIC_KINDS allowlist in shardBroadcast.js, and the
// ad-hoc `canSeeStaffLocation` style checks in the public controllers).
//
// Design rules (docs/link/v3.md §3):
//
// • Visibility lives HERE, on the website — never in the sidecar. The sidecar
// is a dumb forwarder: it accepts frames, stores them, forwards them
// verbatim, and serves store-backed reads. It defines no audiences.
// • Every default reproduces the behavior that shipped before this module, so
// installing it changes nothing until an admin edits the config.
// • Two rules an admin CANNOT override:
// 1. `acct` / `webId` are admin-only, always. They are not in-game
// visible (unlike a character name) and are not configurable fields.
// 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.
//
// The audience ladder is ordered; each rung implies the ones below it.
const db = require('../model/shardVisibility/shardVisibility.model')
const shardLinks = require('../model/shardLinks/shardLinks.model')
const auth = require('./auth')
const log = require('./logger')('shard-visibility')
// ── The ladder ─────────────────────────────────────────────────────────────
const LADDER = ['anonymous', 'logged_in', 'player', 'staff', 'admin']
const RANK = new Map(LADDER.map((level, i) => [level, i]))
const isLevel = (level) => RANK.has(level)
// The two fallbacks are deliberately ASYMMETRIC, and the asymmetry is the whole
// point: an unrecognised value must always lose. A single shared fallback cannot
// do that — whichever direction it picks, it fails open on one side. So:
//
// • an unknown VIEWER level floors to the bottom rung (grants nothing), and
// • an unknown REQUIREMENT ceils to the top rung (satisfied by nobody but admin).
//
// With one `rank()` defaulting to admin, a viewer level that fell through (a
// typo, a future rung this build doesn't know, a value from a caller that
// skipped viewerLevel) would have been treated as an ADMIN and passed every gate.
const viewerRank = (level) => RANK.get(level) ?? 0
const requiredRank = (level) => RANK.get(level) ?? RANK.get('admin')
// True when a viewer at `viewer` satisfies a requirement of `required`.
const meets = (viewer, required) => viewerRank(viewer) >= requiredRank(required)
// Exported for tests/diagnostics; `meets` is what callers should use.
const rank = viewerRank
// ── Features ───────────────────────────────────────────────────────────────
//
// All ten shard surfaces: the six that shipped before v3 plus the four v3 adds.
// `fields` lists only the SENSITIVE fields — those an admin may re-gate. A field
// not listed here is visible whenever the feature itself is.
//
// LOCKED_FIELDS are exempt from configuration entirely (rule 1 above).
const LOCKED_FIELDS = { acct: 'admin', webId: 'admin' }
const FEATURES = {
// ── Shipped before v3. Defaults reproduce the previous hardcoded behavior. ──
status: { audience: 'anonymous', fields: {} },
activity: { audience: 'anonymous', fields: {} },
champs: { audience: 'anonymous', fields: {} },
guilds: { audience: 'anonymous', fields: {} },
governors: { audience: 'anonymous', fields: {} },
// The public Houses page showed IDOC location only; owner/price were staff.
houses: { audience: 'anonymous', fields: { owner: 'staff', price: 'staff' } },
// /public/shard/online listed linked staff to everyone but gated location to
// admin+moderator — which is exactly the `staff` rung.
presence: { audience: 'anonymous', fields: { location: 'staff' } },
// ── New in v3. ──
ruleset: { audience: 'anonymous', fields: { connect: 'anonymous' } },
atlas: { audience: 'anonymous', fields: {} },
leaderboards: { audience: 'anonymous', fields: { characterName: 'anonymous' } },
// Shop name, owner character name and vendor location are already globally
// visible in-game via the stock Vendor Search gump, so publishing them is not
// a new disclosure — but they stay configurable so an admin can tighten them.
market: { audience: 'anonymous', fields: { ownerName: 'anonymous', location: 'anonymous' } },
}
const FEATURE_NAMES = Object.keys(FEATURES)
const isFeature = (name) => Object.hasOwn(FEATURES, name)
// ── Kind → feature ─────────────────────────────────────────────────────────
//
// Every event kind that may ever leave the admin channel must appear here.
// Anything else is admin-only by omission (rule 2). This map is seeded from
// what PUBLIC_KINDS listed before v3, so the public stream carries exactly the
// same kinds it did — now attributed to a feature that an admin can re-gate.
const KIND_FEATURE = new Map(
Object.entries({
// status / lifecycle
'server.hello': 'status',
'server.shutdown': 'status',
'server.crashed': 'status',
'economy.supply': 'status',
// activity feed
'player.death': 'activity',
'player.murdered': 'activity',
'mob.killed': 'activity',
'quest.complete': 'activity',
'skill.gain': 'activity',
'fame.change': 'activity',
'karma.change': 'activity',
'mob.login': 'activity',
'mob.logout': 'activity',
// boards
'champ.update': 'champs',
'champ.remove': 'champs',
'guild.update': 'guilds',
'guild.remove': 'guilds',
'guild.join': 'guilds',
'city.update': 'governors',
'presence.online': 'presence',
'region.enter': 'presence',
// house.decay is the IDOC signal the public Houses page renders. The full
// registry (house.update / house.remove — owner, price, co-owners) stays
// off the map deliberately, so it remains admin-only exactly as before.
'house.decay': 'houses',
// v3
'world.ruleset': 'ruleset',
'points.board': 'leaderboards',
// vendor.listing IS mapped, but the market feature ships with its stream
// disabled (see DEFAULT_STREAM_OFF): a live firehose of full vendor
// inventories would be the site's biggest bandwidth consumer and no page
// needs it live. An admin can turn it on.
'vendor.listing': 'market',
'vendor.listing.remove': 'market',
}),
)
// Features whose SSE fan-out is off unless an admin enables it. The REST reads
// are unaffected; only the live stream is suppressed.
const DEFAULT_STREAM_OFF = new Set(['market'])
// Back-compat: the set of kinds that reach an anonymous viewer under the default
// config. shardEvents `/feed` filtering and notificationStreams.js both consume
// this. Derived from the map above rather than hand-maintained, so the two can
// no longer drift.
const PUBLIC_KINDS = new Set(
[...KIND_FEATURE.entries()]
.filter(([, feature]) => {
if (DEFAULT_STREAM_OFF.has(feature)) return false
return FEATURES[feature].audience === 'anonymous'
})
.map(([kind]) => kind),
)
// ── Config (DB-backed, cached) ─────────────────────────────────────────────
const CONFIG_TTL_MS = 5000
let cache = null
let cachedAt = 0
// Merge a stored row over its compiled default. Unknown feature names in the DB
// are ignored (a stale row from a removed feature must not resurrect it), and an
// invalid rung falls back to the default rather than failing open.
function applyRow(name, row) {
const base = FEATURES[name]
const audience = isLevel(row?.audience) ? row.audience : base.audience
const fields = { ...base.fields }
for (const [field, level] of Object.entries(row?.fieldRules || {})) {
if (Object.hasOwn(LOCKED_FIELDS, field)) continue // rule 1: not configurable
if (isLevel(level)) fields[field] = level
}
return {
enabled: row ? !!row.enabled : true,
audience,
fields,
stream: row?.stream == null ? !DEFAULT_STREAM_OFF.has(name) : !!row.stream,
}
}
function compileDefaults() {
const out = {}
for (const name of FEATURE_NAMES) out[name] = applyRow(name, null)
return out
}
// Read the config, cached briefly. Falls back to compiled defaults if the DB is
// unreachable — the defaults reproduce pre-v3 behavior, so a DB blip degrades to
// "what the site did before" rather than to "everything is public".
async function getConfig() {
const now = Date.now()
if (cache && now - cachedAt < CONFIG_TTL_MS) return cache
try {
const rows = await db.listAll()
const byName = new Map(rows.map((r) => [r.feature, r]))
const out = {}
for (const name of FEATURE_NAMES) out[name] = applyRow(name, byName.get(name))
cache = out
cachedAt = now
} catch (err) {
log.error('getConfig; falling back to defaults', err)
cache = cache || compileDefaults()
cachedAt = now
}
return cache
}
const invalidate = () => {
cache = null
cachedAt = 0
}
// ── Viewer level ───────────────────────────────────────────────────────────
//
// anonymous no session
// logged_in authenticated, no linked game account
// player authenticated with a linked game account
// staff admin | moderator — the same set as the existing `modAccess` gate.
// `editor` is a CONTENT role with no shard privilege today, so it
// resolves by link status like any other member; mapping it to staff
// here would silently widen what editors can see.
// admin admin
//
// Staff always satisfy the `player` rung (rank order guarantees it) even without
// a linked account, matching the existing rule that /player/* is role-agnostic
// self-service.
// Same TTL as the config cache: this decides a privilege rung, so an unlinked
// (or newly relinked) account must not keep the old answer for long. Anonymous,
// staff and admin callers short-circuit before this runs, so the lookup only
// costs a query on the logged-in-member path.
const LINK_TTL_MS = CONFIG_TTL_MS
const linkCache = new Map() // userId → { hasLink, at }
async function hasLinkedAccount(userId) {
const hit = linkCache.get(userId)
const now = Date.now()
if (hit && now - hit.at < LINK_TTL_MS) return hit.hasLink
let hasLink = false
try {
const links = await shardLinks.listForUser(userId)
hasLink = Array.isArray(links) && links.length > 0
} catch (err) {
log.warn('hasLinkedAccount failed; treating as unlinked', { message: err.message })
}
linkCache.set(userId, { hasLink, at: now })
return hasLink
}
// Drop a user's cached link status (called when a link is created or removed so
// the rung takes effect immediately rather than up to LINK_TTL_MS later).
const forgetUser = (userId) => linkCache.delete(userId)
async function viewerLevel(req) {
const viewer = req.user || auth.getUserFromRequest(req)
if (!viewer) return 'anonymous'
if (viewer.role === 'admin') return 'admin'
if (viewer.role === 'moderator') return 'staff'
return (await hasLinkedAccount(viewer.id)) ? 'player' : 'logged_in'
}
// ── Enforcement ────────────────────────────────────────────────────────────
// Route gate. 404 when the feature is disabled (do not leak that it exists);
// 403 when it exists but the viewer sits below its audience. Stashes the
// resolved level on the request so controllers can project without re-resolving.
function requireFeature(name) {
return async (req, res, next) => {
try {
const config = await getConfig()
const feature = config[name]
if (!feature || !feature.enabled) return res.status(404).json({ message: 'Not Found' })
const level = await viewerLevel(req)
req.viewerLevel = level
if (!meets(level, feature.audience)) return res.status(403).json({ message: 'Forbidden' })
return next()
} catch (err) {
log.error(`requireFeature(${name})`, err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
}
// Strip the fields a viewer at `level` may not see. Applies the locked rules
// first (so acct/webId can never survive below admin), then the feature's
// configured field rules. Recurses into arrays and nested objects because the
// sensitive fields sit inside actor sub-objects (guild.leader, city.governor).
function projectValue(value, rules, level) {
if (Array.isArray(value)) return value.map((v) => projectValue(v, rules, level))
if (!value || typeof value !== 'object') return value
const out = {}
for (const [key, v] of Object.entries(value)) {
const required = rules[key]
if (required && !meets(level, required)) continue
out[key] = projectValue(v, rules, level)
}
return out
}
// Project a payload for one feature. `level` defaults to admin-equivalent only
// when explicitly passed; callers should always pass a resolved level.
function projectFeature(name, payload, level, config) {
const feature = config?.[name]
const rules = { ...LOCKED_FIELDS, ...(feature ? feature.fields : {}) }
return projectValue(payload, rules, level)
}
// Convenience for controllers: resolve config once, project, return.
async function project(name, payload, req) {
const config = await getConfig()
const level = req.viewerLevel || (await viewerLevel(req))
return projectFeature(name, payload, level, config)
}
// Is this event kind allowed to reach a viewer at `level`? Fail closed on an
// unmapped kind (rule 2), and honour both the feature gate and its stream flag.
function kindVisibleTo(kind, level, config) {
if (level === 'admin') return true
const name = KIND_FEATURE.get(kind)
if (!name) return false // rule 2: unmapped ⇒ admin-only
const feature = config?.[name]
if (!feature || !feature.enabled || !feature.stream) return false
return meets(level, feature.audience)
}
// The features a viewer at `level` can actually see — drives SPA nav so it never
// renders a link that would 403.
function visibleFeatures(level, config) {
return FEATURE_NAMES.filter((name) => {
const feature = config[name]
return feature.enabled && meets(level, feature.audience)
})
}
module.exports = {
LADDER,
FEATURES,
FEATURE_NAMES,
LOCKED_FIELDS,
KIND_FEATURE,
PUBLIC_KINDS,
DEFAULT_STREAM_OFF,
isLevel,
isFeature,
rank,
meets,
getConfig,
invalidate,
compileDefaults,
viewerLevel,
forgetUser,
requireFeature,
projectFeature,
project,
kindVisibleTo,
visibleFeatures,
}

View File

@@ -4368,6 +4368,98 @@
]
}
},
"/api/v1/admin/shard/visibility": {
"get": {
"tags": [
"Admin · Shard"
],
"summary": "Get per-feature shard visibility config (admin only)",
"description": "The effective config (compiled defaults merged with stored overrides) plus the vocabulary the admin UI renders from: the audience ladder and the always-locked fields. Defaults reproduce pre-v3 behavior.",
"responses": {
"200": {
"description": "Visibility config",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ShardVisibilityConfig"
}
}
}
},
"403": {
"description": "Admin role required",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
},
"put": {
"tags": [
"Admin · Shard"
],
"summary": "Update per-feature shard visibility config (admin only)",
"description": "Patch one or more features. Unknown feature names, unknown rungs, and any attempt to configure a locked field (acct / webId — admin-only always) are rejected with 400 rather than silently dropped.",
"responses": {
"200": {
"description": "Updated config",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ShardVisibilityConfig"
}
}
}
},
"400": {
"description": "Unknown feature, rung, or a locked field",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ShardVisibilityUpdate"
}
}
}
}
}
},
"/api/v1/admin/site-mode": {
"put": {
"tags": [
@@ -10849,6 +10941,12 @@
}
}
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
},
"500": {
"description": "Internal Server Error"
}
@@ -10890,6 +10988,36 @@
"400": {
"description": "Bad Request"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
},
"500": {
"description": "Internal Server Error"
}
}
}
},
"/api/v1/public/shard/features": {
"get": {
"tags": [
"Public · Shard"
],
"summary": "Shard features visible to the caller (drives client nav)",
"description": "The caller\\'s audience rung plus the shard features they may reach, so a client can hide nav entries instead of rendering links that 403. Reports only what the caller can see — the list itself does not disclose gated features.",
"responses": {
"200": {
"description": "Visible features",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ShardFeatures"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
@@ -10940,6 +11068,12 @@
"400": {
"description": "Bad Request"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
},
"500": {
"description": "Internal Server Error"
}
@@ -10968,6 +11102,12 @@
}
}
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
},
"500": {
"description": "Internal Server Error"
}
@@ -11019,6 +11159,12 @@
"400": {
"description": "Bad Request"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
},
"500": {
"description": "Internal Server Error"
}
@@ -11047,6 +11193,12 @@
}
}
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
},
"500": {
"description": "Internal Server Error"
}
@@ -11074,6 +11226,12 @@
}
}
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
},
"500": {
"description": "Internal Server Error"
}
@@ -11101,6 +11259,12 @@
}
}
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
},
"500": {
"description": "Internal Server Error"
}
@@ -11128,6 +11292,12 @@
}
}
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
},
"500": {
"description": "Internal Server Error"
}
@@ -11153,6 +11323,12 @@
}
}
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
},
"500": {
"description": "Internal Server Error"
}
@@ -11177,6 +11353,12 @@
}
}
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
},
"500": {
"description": "Internal Server Error"
}
@@ -11188,8 +11370,8 @@
"tags": [
"Public · Shard"
],
"summary": "Live shard event stream (Server-Sent Events, public/safe kinds)",
"description": "text/event-stream of curated live events. Sensitive kinds (staff audit, cheat detection, login attempts, IPs) are NOT sent on this channel.",
"summary": "Live shard event stream (Server-Sent Events, filtered by audience)",
"description": "text/event-stream of live events. The caller\\'s audience rung is resolved once at subscribe time and frozen for the connection; each frame is then gated on its feature and field-projected, so sensitive kinds and fields (staff audit, cheat detection, login attempts, IPs, acct/webId) never reach a caller below their configured rung.",
"responses": {
"200": {
"description": "An SSE stream (Content-Type: text/event-stream)."
@@ -17416,6 +17598,355 @@
}
}
},
"ShardFeatures": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "The shard features the caller may reach, plus the audience rung they resolved to. Drives client nav so it never renders a link that would 403."
},
"properties": {
"type": "object",
"properties": {
"level": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"enum": {
"type": "array",
"example": [
"anonymous",
"logged_in",
"player",
"staff",
"admin"
],
"items": {
"type": "string"
}
},
"example": {
"type": "string",
"example": "anonymous"
}
}
},
"features": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
}
}
},
"example": {
"type": "array",
"example": [
"status",
"activity",
"champs",
"guilds",
"governors",
"houses",
"presence"
],
"items": {
"type": "string"
}
}
}
}
}
}
}
},
"ShardFeatureVisibility": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "Visibility settings for one shard feature."
},
"properties": {
"type": "object",
"properties": {
"enabled": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": true
}
}
},
"audience": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"enum": {
"type": "array",
"example": [
"anonymous",
"logged_in",
"player",
"staff",
"admin"
],
"items": {
"type": "string"
}
},
"description": {
"type": "string",
"example": "Minimum rung that may reach this feature. Each rung implies the ones below it."
},
"example": {
"type": "string",
"example": "anonymous"
}
}
},
"stream": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"description": {
"type": "string",
"example": "Whether this feature's event kinds fan out over SSE at all."
},
"example": {
"type": "boolean",
"example": true
}
}
},
"fieldRules": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"additionalProperties": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
}
}
},
"description": {
"type": "string",
"example": "Per-field rung overrides for the sensitive fields this feature exposes. acct / webId are admin-only always and are rejected here."
},
"example": {
"type": "object",
"properties": {
"location": {
"type": "string",
"example": "staff"
}
}
}
}
}
}
}
}
},
"ShardVisibilityConfig": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"ladder": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
}
}
},
"example": {
"type": "array",
"example": [
"anonymous",
"logged_in",
"player",
"staff",
"admin"
],
"items": {
"type": "string"
}
}
}
},
"lockedFields": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
}
}
},
"example": {
"type": "array",
"example": [
"acct",
"webId"
],
"items": {
"type": "string"
}
}
}
},
"defaults": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"additionalProperties": {
"$ref": "#/components/schemas/ShardFeatureVisibility"
}
}
},
"features": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"additionalProperties": {
"$ref": "#/components/schemas/ShardFeatureVisibility"
}
}
}
}
}
}
},
"ShardVisibilityUpdate": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"required": {
"type": "array",
"example": [
"features"
],
"items": {
"type": "string"
}
},
"properties": {
"type": "object",
"properties": {
"features": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"additionalProperties": {
"$ref": "#/components/schemas/ShardFeatureVisibility"
},
"example": {
"type": "object",
"properties": {
"market": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"example": true
},
"audience": {
"type": "string",
"example": "player"
},
"stream": {
"type": "boolean",
"example": false
},
"fieldRules": {
"type": "object",
"properties": {
"ownerName": {
"type": "string",
"example": "player"
}
}
}
}
}
}
}
}
}
}
}
}
},
"ShardLinkRequest": {
"type": "object",
"properties": {

View File

@@ -881,6 +881,78 @@ const doc = {
updatedAt: { type: 'string', format: 'date-time' },
},
},
ShardFeatures: {
type: 'object',
description:
"The shard features the caller may reach, plus the audience rung they resolved to. Drives client nav so it never renders a link that would 403.",
properties: {
level: {
type: 'string',
enum: ['anonymous', 'logged_in', 'player', 'staff', 'admin'],
example: 'anonymous',
},
features: {
type: 'array',
items: { type: 'string' },
example: ['status', 'activity', 'champs', 'guilds', 'governors', 'houses', 'presence'],
},
},
},
ShardFeatureVisibility: {
type: 'object',
description: 'Visibility settings for one shard feature.',
properties: {
enabled: { type: 'boolean', example: true },
audience: {
type: 'string',
enum: ['anonymous', 'logged_in', 'player', 'staff', 'admin'],
description: 'Minimum rung that may reach this feature. Each rung implies the ones below it.',
example: 'anonymous',
},
stream: {
type: 'boolean',
description: "Whether this feature's event kinds fan out over SSE at all.",
example: true,
},
fieldRules: {
type: 'object',
additionalProperties: { type: 'string' },
description:
'Per-field rung overrides for the sensitive fields this feature exposes. acct / webId are admin-only always and are rejected here.',
example: { location: 'staff' },
},
},
},
ShardVisibilityConfig: {
type: 'object',
properties: {
ladder: {
type: 'array',
items: { type: 'string' },
example: ['anonymous', 'logged_in', 'player', 'staff', 'admin'],
},
lockedFields: { type: 'array', items: { type: 'string' }, example: ['acct', 'webId'] },
defaults: {
type: 'object',
additionalProperties: { $ref: '#/components/schemas/ShardFeatureVisibility' },
},
features: {
type: 'object',
additionalProperties: { $ref: '#/components/schemas/ShardFeatureVisibility' },
},
},
},
ShardVisibilityUpdate: {
type: 'object',
required: ['features'],
properties: {
features: {
type: 'object',
additionalProperties: { $ref: '#/components/schemas/ShardFeatureVisibility' },
example: { market: { enabled: true, audience: 'player', stream: false, fieldRules: { ownerName: 'player' } } },
},
},
},
ShardLinkRequest: {
type: 'object',
required: ['code'],

View File

@@ -0,0 +1,225 @@
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, after, afterEach, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const { EventEmitter } = require('node:events')
// The SSE fan-out is the security boundary (docs/link/v3.md §3.6). Before v3 it
// was a static kind allowlist; now each subscriber carries the audience rung it
// resolved to at subscribe time, and every frame is gated + field-projected per
// viewer. These tests pin the properties that must hold no matter how the config
// is set:
//
// - the admin channel always gets the frame verbatim;
// - a public subscriber never receives an unmapped kind;
// - acct / webId never reach a public subscriber, at any rung below admin;
// - two subscribers at different rungs get different frames from one event;
// - a viewer's rung is frozen at subscribe time, not re-read per frame;
// - if the visibility config can't be read, nothing goes out on public.
const broadcast = require('../src/utils/shardBroadcast')
const visibility = require('../src/utils/shardVisibility')
const model = require('../src/model/shardVisibility/shardVisibility.model')
const shardLinks = require('../src/model/shardLinks/shardLinks.model')
const db = require('../src/utils/db')
after(() => db.close())
const originals = {
listAll: model.listAll,
listForUser: shardLinks.listForUser,
getConfig: visibility.getConfig,
viewerLevel: visibility.viewerLevel,
}
beforeEach(() => {
model.listAll = async () => []
shardLinks.listForUser = async () => []
visibility.invalidate()
})
afterEach(() => {
broadcast.closeAll()
model.listAll = originals.listAll
shardLinks.listForUser = originals.listForUser
visibility.getConfig = originals.getConfig
visibility.viewerLevel = originals.viewerLevel
visibility.invalidate()
})
// A fake req/res pair that records everything written to the stream.
function fakeClient() {
const req = new EventEmitter()
const writes = []
const res = {
writeHead() {},
write(chunk) {
writes.push(chunk)
},
end() {},
on() {},
}
// Frames only — drop the SSE comments/retry preamble and keepalive pings.
const frames = () =>
writes
.filter((w) => w.startsWith('data: '))
.map((w) => JSON.parse(w.slice('data: '.length).trim()))
return { req, res, frames }
}
async function subscribeAt(level, channel = 'public') {
const client = fakeClient()
visibility.viewerLevel = async () => level
await broadcast.subscribe(client.req, client.res, channel)
return client
}
const GUILD_FRAME = {
kind: 'guild.update',
id: 7,
name: 'The Nameless',
abbr: 'TN',
leader: { serial: '0x1A2B', name: 'Darrow', acct: 'whitlocktech', webId: '42', player: true },
}
test('the admin channel receives the frame verbatim, acct and webId included', async () => {
const admin = await subscribeAt('admin', 'admin')
await broadcast.broadcast(GUILD_FRAME)
const [frame] = admin.frames()
assert.deepEqual(frame, GUILD_FRAME)
})
test('a public subscriber never sees acct or webId, at any rung below admin', async () => {
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
const client = await subscribeAt(level)
await broadcast.broadcast(GUILD_FRAME)
const [frame] = client.frames()
assert.ok(frame, `${level} should receive the guild frame`)
assert.equal(frame.leader.name, 'Darrow')
assert.equal('acct' in frame.leader, false, `${level} must not see acct`)
assert.equal('webId' in frame.leader, false, `${level} must not see webId`)
broadcast.closeAll()
}
})
test('an unmapped kind reaches the admin channel and nobody else', async () => {
const anon = await subscribeAt('anonymous')
const staff = await subscribeAt('staff')
const admin = await subscribeAt('admin', 'admin')
for (const kind of ['audit.command', 'cheat.fastwalk', 'account.login.attempt', 'vendor.sale']) {
await broadcast.broadcast({ kind, secret: true })
}
assert.deepEqual(anon.frames(), [])
assert.deepEqual(staff.frames(), [])
assert.equal(admin.frames().length, 4)
})
test('one event yields different frames for subscribers at different rungs', async () => {
model.listAll = async () => [
{
feature: 'guilds',
enabled: true,
audience: 'anonymous',
stream: true,
fieldRules: { abbr: 'staff' },
},
]
visibility.invalidate()
const anon = await subscribeAt('anonymous')
const staff = await subscribeAt('staff')
await broadcast.broadcast(GUILD_FRAME)
assert.equal('abbr' in anon.frames()[0], false)
assert.equal(staff.frames()[0].abbr, 'TN')
// Both still lose the locked fields.
assert.equal('acct' in staff.frames()[0].leader, false)
})
test('raising a feature audience cuts off the lower rungs mid-stream', async () => {
const anon = await subscribeAt('anonymous')
const player = await subscribeAt('player')
await broadcast.broadcast(GUILD_FRAME)
assert.equal(anon.frames().length, 1)
assert.equal(player.frames().length, 1)
// Config changes DO take effect live — only the viewer's rung is frozen.
model.listAll = async () => [
{ feature: 'guilds', enabled: true, audience: 'player', stream: true, fieldRules: {} },
]
visibility.invalidate()
await broadcast.broadcast(GUILD_FRAME)
assert.equal(anon.frames().length, 1, 'anonymous stops receiving')
assert.equal(player.frames().length, 2, 'player keeps receiving')
})
test("a subscriber's rung is frozen at subscribe time", async () => {
const client = await subscribeAt('anonymous')
// Even if the resolver would now say "admin", the open connection must not
// gain privilege — its level was captured when it subscribed.
visibility.viewerLevel = async () => 'admin'
await broadcast.broadcast({ kind: 'audit.command', command: 'ban' })
assert.deepEqual(client.frames(), [])
})
test('an unresolvable viewer subscribes as anonymous, not as privileged', async () => {
const client = fakeClient()
visibility.viewerLevel = async () => {
throw new Error('session lookup exploded')
}
await broadcast.subscribe(client.req, client.res, 'public')
await broadcast.broadcast({ kind: 'audit.command', command: 'ban' })
assert.deepEqual(client.frames(), [])
// ...but it still receives ordinary public traffic.
await broadcast.broadcast({ kind: 'champ.update', serial: '0x1' })
assert.equal(client.frames().length, 1)
})
test('an unreadable visibility config withholds every public frame', async () => {
const client = await subscribeAt('anonymous')
visibility.getConfig = async () => {
throw new Error('db down')
}
await broadcast.broadcast(GUILD_FRAME)
assert.deepEqual(client.frames(), [])
})
test('a disabled feature stops its kinds without touching others', async () => {
model.listAll = async () => [
{ feature: 'champs', enabled: false, audience: 'anonymous', stream: true, fieldRules: {} },
]
visibility.invalidate()
const client = await subscribeAt('anonymous')
await broadcast.broadcast({ kind: 'champ.update', serial: '0x1' })
await broadcast.broadcast({ kind: 'guild.update', id: 7 })
const kinds = client.frames().map((f) => f.kind)
assert.deepEqual(kinds, ['guild.update'])
})
test('a dead client is dropped rather than repeatedly retried', async () => {
const client = fakeClient()
visibility.viewerLevel = async () => 'anonymous'
await broadcast.subscribe(client.req, client.res, 'public')
assert.equal(broadcast.stats().publicClients, 1)
client.res.write = () => {
throw new Error('EPIPE')
}
await broadcast.broadcast({ kind: 'champ.update', serial: '0x1' })
assert.equal(broadcast.stats().publicClients, 0)
})
test('broadcast is a no-op for a malformed event', async () => {
const client = await subscribeAt('anonymous')
await broadcast.broadcast(null)
await broadcast.broadcast({})
assert.deepEqual(client.frames(), [])
})

View File

@@ -0,0 +1,319 @@
// Point the DB at a closed port BEFORE requiring anything that builds a pool.
// Every DB call this suite would make is monkeypatched.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, after, afterEach, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
// Unit-test the visibility framework's INVARIANTS — the rules that make it a
// security boundary rather than a convenience filter (docs/link/v3.md §3):
//
// 1. acct / webId are admin-only ALWAYS and cannot be configured down.
// 2. A kind absent from KIND_FEATURE reaches nobody below admin (fail closed).
// 3. The compiled defaults reproduce pre-v3 behavior, so installing this
// module changes nothing until an admin edits the config.
// 4. The ladder is ordered and each rung implies the ones below it.
const visibility = require('../src/utils/shardVisibility')
const model = require('../src/model/shardVisibility/shardVisibility.model')
const shardLinks = require('../src/model/shardLinks/shardLinks.model')
const db = require('../src/utils/db')
after(() => db.close())
const originals = { listAll: model.listAll, listForUser: shardLinks.listForUser }
// Default both DB reads to "no rows" so a test that doesn't care never blocks on
// the dead pool (each such call would otherwise burn the 10s acquire timeout).
// Tests that exercise stored config or a DB failure override these.
beforeEach(() => {
model.listAll = async () => []
shardLinks.listForUser = async () => []
visibility.invalidate()
})
afterEach(() => {
model.listAll = originals.listAll
shardLinks.listForUser = originals.listForUser
visibility.invalidate()
})
// Stub the stored config; the framework merges rows over compiled defaults.
function withRows(rows) {
model.listAll = async () => rows
visibility.invalidate()
}
// ── The ladder ─────────────────────────────────────────────────────────────
test('ladder is ordered and each rung implies the ones below it', () => {
assert.deepEqual(visibility.LADDER, ['anonymous', 'logged_in', 'player', 'staff', 'admin'])
for (let i = 0; i < visibility.LADDER.length; i += 1) {
for (let j = 0; j <= i; j += 1) {
assert.equal(visibility.meets(visibility.LADDER[i], visibility.LADDER[j]), true)
}
for (let j = i + 1; j < visibility.LADDER.length; j += 1) {
assert.equal(visibility.meets(visibility.LADDER[i], visibility.LADDER[j]), false)
}
}
})
test('an unknown rung always loses, on BOTH sides of the comparison', () => {
assert.equal(visibility.isLevel('not-a-rung'), false)
// An unknown REQUIREMENT is satisfied by nobody below admin...
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
assert.equal(visibility.meets(level, 'not-a-rung'), false, `${level} vs unknown requirement`)
}
assert.equal(visibility.meets('admin', 'not-a-rung'), true)
// ...and an unknown VIEWER level grants nothing. This is the direction that
// matters: a shared admin fallback would have made a garbage viewer level
// pass every gate.
for (const required of visibility.LADDER.slice(1)) {
assert.equal(visibility.meets('not-a-rung', required), false, `unknown viewer vs ${required}`)
assert.equal(visibility.meets(undefined, required), false, `undefined viewer vs ${required}`)
assert.equal(visibility.meets(null, required), false, `null viewer vs ${required}`)
}
})
test('an unknown viewer level cannot see a gated kind or a locked field', async () => {
const config = await visibility.getConfig()
assert.equal(visibility.kindVisibleTo('champ.update', 'not-a-rung', config), true) // anonymous-tier: fine
assert.equal(visibility.kindVisibleTo('audit.command', 'not-a-rung', config), false)
const out = visibility.projectFeature(
'guilds',
{ leader: { name: 'Darrow', acct: 'whitlocktech', webId: '42' } },
'not-a-rung',
config,
)
assert.equal('acct' in out.leader, false)
assert.equal('webId' in out.leader, false)
})
// ── Rule 1: locked fields ──────────────────────────────────────────────────
test('acct and webId are stripped below admin regardless of feature config', () => {
const config = visibility.compileDefaults()
const frame = {
kind: 'guild.update',
name: 'The Nameless',
leader: { serial: '0x1A2B', name: 'Darrow', acct: 'whitlocktech', webId: '42', player: true },
}
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
const out = visibility.projectFeature('guilds', frame, level, config)
assert.equal(out.leader.name, 'Darrow', `${level} keeps the character name`)
assert.equal(out.leader.serial, '0x1A2B')
assert.equal('acct' in out.leader, false, `${level} must not see acct`)
assert.equal('webId' in out.leader, false, `${level} must not see webId`)
}
const asAdmin = visibility.projectFeature('guilds', frame, 'admin', config)
assert.equal(asAdmin.leader.acct, 'whitlocktech')
assert.equal(asAdmin.leader.webId, '42')
})
test('a stored rule trying to loosen a locked field is ignored', async () => {
withRows([
{ feature: 'guilds', enabled: true, audience: 'anonymous', stream: true, fieldRules: { acct: 'anonymous', webId: 'anonymous' } },
])
const config = await visibility.getConfig()
const out = visibility.projectFeature(
'guilds',
{ leader: { name: 'Darrow', acct: 'whitlocktech', webId: '42' } },
'anonymous',
config,
)
assert.equal('acct' in out.leader, false)
assert.equal('webId' in out.leader, false)
})
test('projection recurses into arrays and nested actors', () => {
const config = visibility.compileDefaults()
const rows = [
{ city: 'Britain', governor: { name: 'A', acct: 'a', webId: '1' } },
{ city: 'Vesper', governor: { name: 'B', acct: 'b' } },
]
const out = visibility.projectFeature('governors', rows, 'anonymous', config)
assert.equal(out.length, 2)
assert.equal(out[0].governor.name, 'A')
assert.equal('acct' in out[0].governor, false)
assert.equal('webId' in out[0].governor, false)
assert.equal('acct' in out[1].governor, false)
})
// ── Rule 2: fail closed on unmapped kinds ──────────────────────────────────
test('an unmapped kind reaches nobody below admin', async () => {
const config = await visibility.getConfig()
for (const kind of ['audit.command', 'cheat.fastwalk', 'account.login.attempt', 'gold.change', 'made.up.kind']) {
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
assert.equal(visibility.kindVisibleTo(kind, level, config), false, `${kind} @ ${level}`)
}
assert.equal(visibility.kindVisibleTo(kind, 'admin', config), true, `${kind} @ admin`)
}
})
test('the full house registry stays off the kind map (owner/price are staff-only)', () => {
assert.equal(visibility.KIND_FEATURE.has('house.update'), false)
assert.equal(visibility.KIND_FEATURE.has('house.remove'), false)
// house.decay — the IDOC signal the public page renders — IS mapped.
assert.equal(visibility.KIND_FEATURE.get('house.decay'), 'houses')
})
test('vendor.sale is not public (sales are owner-private)', async () => {
const config = await visibility.getConfig()
assert.equal(visibility.kindVisibleTo('vendor.sale', 'anonymous', config), false)
assert.equal(visibility.PUBLIC_KINDS.has('vendor.sale'), false)
})
// ── Rule 3: defaults reproduce pre-v3 behavior ─────────────────────────────
// The exact allowlist that shipped in shardBroadcast.js before v3. If a change
// makes the derived PUBLIC_KINDS differ from this, it is a deliberate widening
// or narrowing of what anonymous visitors see and must be reviewed as such.
const PRE_V3_PUBLIC_KINDS = [
'player.death',
'player.murdered',
'mob.killed',
'house.decay',
'quest.complete',
'skill.gain',
'fame.change',
'karma.change',
'mob.login',
'mob.logout',
'economy.supply',
'server.hello',
'server.shutdown',
'server.crashed',
'champ.update',
'champ.remove',
'guild.update',
'guild.remove',
'guild.join',
'city.update',
'presence.online',
'region.enter',
]
// The kinds v3 deliberately ADDS to the anonymous set. vendor.listing is
// pointedly not among them (its feature ships with stream off).
const V3_ADDED_PUBLIC_KINDS = ['world.ruleset', 'points.board']
test('derived PUBLIC_KINDS is exactly the pre-v3 allowlist plus the v3 additions', () => {
assert.deepEqual(
[...visibility.PUBLIC_KINDS].sort(),
[...PRE_V3_PUBLIC_KINDS, ...V3_ADDED_PUBLIC_KINDS].sort(),
)
})
test('no pre-v3 public kind was dropped', () => {
for (const kind of PRE_V3_PUBLIC_KINDS) {
assert.equal(visibility.PUBLIC_KINDS.has(kind), true, `${kind} fell out of the public set`)
}
})
test('the market stream is off by default but its REST feature is not', async () => {
const config = await visibility.getConfig()
assert.equal(config.market.enabled, true)
assert.equal(config.market.audience, 'anonymous')
assert.equal(config.market.stream, false)
assert.equal(visibility.kindVisibleTo('vendor.listing', 'anonymous', config), false)
assert.equal(visibility.PUBLIC_KINDS.has('vendor.listing'), false)
})
test('presence location defaults to staff, matching the old admin/moderator gate', async () => {
const config = await visibility.getConfig()
assert.equal(config.presence.fields.location, 'staff')
assert.equal(visibility.meets('player', 'staff'), false)
assert.equal(visibility.meets('staff', 'staff'), true)
})
test('every mapped kind names a real feature', () => {
for (const [kind, feature] of visibility.KIND_FEATURE) {
assert.equal(visibility.isFeature(feature), true, `${kind} → unknown feature ${feature}`)
}
})
// ── Config merge ───────────────────────────────────────────────────────────
test('a disabled feature is invisible to everyone below admin', async () => {
withRows([{ feature: 'champs', enabled: false, audience: 'anonymous', stream: true, fieldRules: {} }])
const config = await visibility.getConfig()
assert.equal(config.champs.enabled, false)
assert.equal(visibility.kindVisibleTo('champ.update', 'anonymous', config), false)
assert.equal(visibility.kindVisibleTo('champ.update', 'staff', config), false)
assert.equal(visibility.visibleFeatures('staff', config).includes('champs'), false)
})
test('raising a feature audience gates the lower rungs out', async () => {
withRows([{ feature: 'guilds', enabled: true, audience: 'player', stream: true, fieldRules: {} }])
const config = await visibility.getConfig()
assert.equal(visibility.kindVisibleTo('guild.update', 'anonymous', config), false)
assert.equal(visibility.kindVisibleTo('guild.update', 'logged_in', config), false)
assert.equal(visibility.kindVisibleTo('guild.update', 'player', config), true)
assert.equal(visibility.kindVisibleTo('guild.update', 'staff', config), true)
})
test('an unknown stored feature name is ignored, not resurrected', async () => {
withRows([{ feature: 'sekrit', enabled: true, audience: 'anonymous', stream: true, fieldRules: {} }])
const config = await visibility.getConfig()
assert.equal('sekrit' in config, false)
assert.deepEqual(Object.keys(config).sort(), [...visibility.FEATURE_NAMES].sort())
})
test('an invalid stored rung falls back to the default rather than failing open', async () => {
withRows([{ feature: 'houses', enabled: true, audience: 'nonsense', stream: true, fieldRules: { owner: 'nonsense' } }])
const config = await visibility.getConfig()
assert.equal(config.houses.audience, 'anonymous') // the compiled default
assert.equal(config.houses.fields.owner, 'staff') // the compiled default
})
test('a DB failure degrades to compiled defaults, not to everything-public', async () => {
model.listAll = async () => {
throw new Error('db down')
}
visibility.invalidate()
const config = await visibility.getConfig()
assert.deepEqual(Object.keys(config).sort(), [...visibility.FEATURE_NAMES].sort())
assert.equal(config.presence.fields.location, 'staff')
assert.equal(visibility.kindVisibleTo('audit.command', 'anonymous', config), false)
})
// ── Viewer level ───────────────────────────────────────────────────────────
test('viewerLevel resolves the ladder from role and link status', async () => {
shardLinks.listForUser = async () => []
assert.equal(await visibility.viewerLevel({}), 'anonymous')
visibility.forgetUser(1)
assert.equal(await visibility.viewerLevel({ user: { id: 1, role: 'admin' } }), 'admin')
visibility.forgetUser(2)
assert.equal(await visibility.viewerLevel({ user: { id: 2, role: 'moderator' } }), 'staff')
// A member with no linked game account sits at logged_in...
visibility.forgetUser(3)
assert.equal(await visibility.viewerLevel({ user: { id: 3, role: 'player' } }), 'logged_in')
// ...and reaches `player` once a link exists.
shardLinks.listForUser = async () => [{ account: 'whitlocktech' }]
visibility.forgetUser(4)
assert.equal(await visibility.viewerLevel({ user: { id: 4, role: 'player' } }), 'player')
})
test('editor is a content role and gets no shard privilege', async () => {
// Mapping editor to `staff` here would silently widen what editors can see;
// today's modAccess gate is admin|moderator only.
shardLinks.listForUser = async () => []
visibility.forgetUser(5)
assert.equal(await visibility.viewerLevel({ user: { id: 5, role: 'editor' } }), 'logged_in')
})
test('a link lookup failure downgrades rather than escalating', async () => {
shardLinks.listForUser = async () => {
throw new Error('db down')
}
visibility.forgetUser(6)
assert.equal(await visibility.viewerLevel({ user: { id: 6, role: 'player' } }), 'logged_in')
})