feat(shard): admin-configurable visibility for every shard surface #109
@@ -40,6 +40,7 @@ import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
|
|||||||
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
|
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
|
||||||
import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx'
|
import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx'
|
||||||
import ShardAdmin from './routes/admin/views/ShardAdmin.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 ShardOps from './routes/admin/views/ShardOps.jsx'
|
||||||
import AdminCharacters from './routes/admin/views/AdminCharacters.jsx'
|
import AdminCharacters from './routes/admin/views/AdminCharacters.jsx'
|
||||||
import AdminCharacter from './routes/admin/views/AdminCharacter.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="bot-activity" element={<BotActivityAdmin />} />
|
||||||
<Route path="discord-bot" element={<DiscordBotAdmin />} />
|
<Route path="discord-bot" element={<DiscordBotAdmin />} />
|
||||||
<Route path="shard" element={<ShardAdmin />} />
|
<Route path="shard" element={<ShardAdmin />} />
|
||||||
|
<Route path="shard-visibility" element={<ShardVisibility />} />
|
||||||
<Route
|
<Route
|
||||||
path="shard-ops"
|
path="shard-ops"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -147,6 +147,9 @@ export const api = {
|
|||||||
},
|
},
|
||||||
presence: () => req('/public/shard/presence'),
|
presence: () => req('/public/shard/presence'),
|
||||||
houses: () => req('/public/shard/houses'),
|
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
|
// 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
|
// 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 }),
|
saveUoLinkConfig: (data) => req('/admin/uo-link/config', { method: 'PUT', body: data }),
|
||||||
postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }),
|
postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }),
|
||||||
deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
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) -----
|
// ----- in-game staff operations: write plane + support queue (admin/moderator) -----
|
||||||
// `actor` is stamped server-side from the session — never sent from here.
|
// `actor` is stamped server-side from the session — never sent from here.
|
||||||
|
|||||||
@@ -2,9 +2,15 @@ import { Link, NavLink } from 'react-router-dom'
|
|||||||
import MoonDot from './MoonDot.jsx'
|
import MoonDot from './MoonDot.jsx'
|
||||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||||
import { useSite } from '../contexts/SiteContext.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
|
// 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).
|
// 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 = [
|
const NAV = [
|
||||||
{ label: 'Home', to: '/', end: true },
|
{ label: 'Home', to: '/', end: true },
|
||||||
{ label: 'News', to: '/site/news' },
|
{ label: 'News', to: '/site/news' },
|
||||||
@@ -12,11 +18,11 @@ const NAV = [
|
|||||||
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
||||||
{ label: 'Newsletter', to: '/site/newsletter' },
|
{ label: 'Newsletter', to: '/site/newsletter' },
|
||||||
{ label: 'Wiki', to: '/wiki' },
|
{ label: 'Wiki', to: '/wiki' },
|
||||||
{ label: 'Shard', to: '/site/shard' },
|
{ label: 'Shard', to: '/site/shard', feature: 'status' },
|
||||||
{ label: 'Champions', to: '/site/champs' },
|
{ label: 'Champions', to: '/site/champs', feature: 'champs' },
|
||||||
{ label: 'Guilds', to: '/site/guilds' },
|
{ label: 'Guilds', to: '/site/guilds', feature: 'guilds' },
|
||||||
{ label: 'Governors', to: '/site/governors' },
|
{ label: 'Governors', to: '/site/governors', feature: 'governors' },
|
||||||
{ label: 'Houses', to: '/site/houses' },
|
{ label: 'Houses', to: '/site/houses', feature: 'houses' },
|
||||||
{ label: 'About', to: '/site/about' },
|
{ label: 'About', to: '/site/about' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -29,6 +35,8 @@ const linkStyle = ({ isActive }) => ({
|
|||||||
export default function SiteHeader() {
|
export default function SiteHeader() {
|
||||||
const { user, loading } = useAuth()
|
const { user, loading } = useAuth()
|
||||||
const { siteTitle } = useSite()
|
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.
|
// Where the auth entry points: staff → admin, player → portal, else sign in.
|
||||||
let account
|
let account
|
||||||
@@ -60,7 +68,7 @@ export default function SiteHeader() {
|
|||||||
{siteTitle}
|
{siteTitle}
|
||||||
</Link>
|
</Link>
|
||||||
<nav style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
<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}>
|
<NavLink key={l.to} to={l.to} end={l.end} className="pill" style={linkStyle}>
|
||||||
{l.label}
|
{l.label}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|||||||
58
client/src/lib/useShardFeatures.js
Normal file
58
client/src/lib/useShardFeatures.js
Normal 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)
|
||||||
|
}
|
||||||
@@ -78,6 +78,7 @@ const NAV = [
|
|||||||
{ to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] },
|
{ to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] },
|
||||||
{ to: '/admin/discord-bot', label: 'Discord Bot', icon: IconBot, 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', 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'] },
|
{ 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/bot-activity': 'Web Bot Activity',
|
||||||
'/admin/discord-bot': 'Discord Bot',
|
'/admin/discord-bot': 'Discord Bot',
|
||||||
'/admin/shard': 'Shard (uo-link)',
|
'/admin/shard': 'Shard (uo-link)',
|
||||||
|
'/admin/shard-visibility': 'Shard Visibility',
|
||||||
'/admin/characters': 'My Characters',
|
'/admin/characters': 'My Characters',
|
||||||
'/admin/auth-providers': 'Authentication',
|
'/admin/auth-providers': 'Authentication',
|
||||||
'/admin/users': 'Users',
|
'/admin/users': 'Users',
|
||||||
|
|||||||
318
client/src/routes/admin/views/ShardVisibility.jsx
Normal file
318
client/src/routes/admin/views/ShardVisibility.jsx
Normal 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
|
||||||
|
aren’t visible in game either, so publishing them would disclose something the shard
|
||||||
|
itself doesn’t.
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -597,6 +597,29 @@ CREATE TABLE IF NOT EXISTS shard_presence (
|
|||||||
CONSTRAINT chk_shard_presence_singleton CHECK (id = 1)
|
CONSTRAINT chk_shard_presence_singleton CHECK (id = 1)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) 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
|
-- 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,
|
-- 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
|
-- which creates their website user at that role (and optionally a linked game
|
||||||
|
|||||||
@@ -782,6 +782,26 @@
|
|||||||
"validate"
|
"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",
|
"method": "PUT",
|
||||||
"path": "/api/v1/admin/site-mode",
|
"path": "/api/v1/admin/site-mode",
|
||||||
@@ -1809,22 +1829,28 @@
|
|||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/champs",
|
"path": "/api/v1/public/shard/champs",
|
||||||
"handlers": 1,
|
"handlers": 2,
|
||||||
"gates": []
|
"gates": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/economy",
|
"path": "/api/v1/public/shard/economy",
|
||||||
"handlers": 3,
|
"handlers": 4,
|
||||||
"gates": [
|
"gates": [
|
||||||
"middleware",
|
"middleware",
|
||||||
"validate"
|
"validate"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/features",
|
||||||
|
"handlers": 1,
|
||||||
|
"gates": []
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/feed",
|
"path": "/api/v1/public/shard/feed",
|
||||||
"handlers": 4,
|
"handlers": 5,
|
||||||
"gates": [
|
"gates": [
|
||||||
"middleware",
|
"middleware",
|
||||||
"validate"
|
"validate"
|
||||||
@@ -1833,13 +1859,13 @@
|
|||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/governors",
|
"path": "/api/v1/public/shard/governors",
|
||||||
"handlers": 1,
|
"handlers": 2,
|
||||||
"gates": []
|
"gates": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/governors/:city/history",
|
"path": "/api/v1/public/shard/governors/:city/history",
|
||||||
"handlers": 4,
|
"handlers": 5,
|
||||||
"gates": [
|
"gates": [
|
||||||
"middleware",
|
"middleware",
|
||||||
"validate"
|
"validate"
|
||||||
@@ -1848,37 +1874,37 @@
|
|||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/guilds",
|
"path": "/api/v1/public/shard/guilds",
|
||||||
"handlers": 1,
|
"handlers": 2,
|
||||||
"gates": []
|
"gates": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/houses",
|
"path": "/api/v1/public/shard/houses",
|
||||||
"handlers": 1,
|
"handlers": 2,
|
||||||
"gates": []
|
"gates": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/idoc",
|
"path": "/api/v1/public/shard/idoc",
|
||||||
"handlers": 1,
|
"handlers": 2,
|
||||||
"gates": []
|
"gates": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/online",
|
"path": "/api/v1/public/shard/online",
|
||||||
"handlers": 1,
|
"handlers": 2,
|
||||||
"gates": []
|
"gates": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/presence",
|
"path": "/api/v1/public/shard/presence",
|
||||||
"handlers": 1,
|
"handlers": 2,
|
||||||
"gates": []
|
"gates": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/status",
|
"path": "/api/v1/public/shard/status",
|
||||||
"handlers": 1,
|
"handlers": 2,
|
||||||
"gates": []
|
"gates": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -313,6 +313,14 @@
|
|||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/shard/vendors/:account"
|
"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",
|
"method": "PUT",
|
||||||
"path": "/api/v1/admin/site-mode"
|
"path": "/api/v1/admin/site-mode"
|
||||||
@@ -737,6 +745,10 @@
|
|||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/economy"
|
"path": "/api/v1/public/shard/economy"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/features"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/feed"
|
"path": "/api/v1/public/shard/feed"
|
||||||
|
|||||||
37
server/src/model/shardVisibility/shardVisibility.db.js
Normal file
37
server/src/model/shardVisibility/shardVisibility.db.js
Normal 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 }
|
||||||
44
server/src/model/shardVisibility/shardVisibility.model.js
Normal file
44
server/src/model/shardVisibility/shardVisibility.model.js
Normal 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 }
|
||||||
@@ -23,6 +23,7 @@ const express = require('express')
|
|||||||
const { body, param } = require('express-validator')
|
const { body, param } = require('express-validator')
|
||||||
|
|
||||||
const shardOps = require('./shardOps.controller')
|
const shardOps = require('./shardOps.controller')
|
||||||
|
const shardVisibility = require('./shardVisibility.controller')
|
||||||
const selfShard = require('../player/shard.controller')
|
const selfShard = require('../player/shard.controller')
|
||||||
const { requireRole } = require('../../../utils/auth')
|
const { requireRole } = require('../../../utils/auth')
|
||||||
const validate = require('../../../middleware/validate')
|
const validate = require('../../../middleware/validate')
|
||||||
@@ -31,6 +32,8 @@ const shardRouter = express.Router()
|
|||||||
|
|
||||||
// Moderator gate. Admins can do everything a moderator can.
|
// Moderator gate. Admins can do everything a moderator can.
|
||||||
const modAccess = requireRole('admin', 'moderator')
|
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) ───────────────
|
// ── Game account linking (self-service, any staff role) ───────────────
|
||||||
// Staff link their OWN in-game account here, exactly like players do under
|
// Staff link their OWN in-game account here, exactly like players do under
|
||||||
@@ -232,4 +235,33 @@ shardRouter.get(
|
|||||||
shardOps.listHouses,
|
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
|
module.exports = shardRouter
|
||||||
|
|||||||
95
server/src/router/v1/admin/shardVisibility.controller.js
Normal file
95
server/src/router/v1/admin/shardVisibility.controller.js
Normal 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 }
|
||||||
@@ -13,7 +13,7 @@ const shardEvents = require('../../../model/shardEvents/shardEvents.model')
|
|||||||
const shardState = require('../../../model/shardState/shardState.model')
|
const shardState = require('../../../model/shardState/shardState.model')
|
||||||
const uoLinkConfig = require('../../../model/uoLinkConfig/uoLinkConfig.model')
|
const uoLinkConfig = require('../../../model/uoLinkConfig/uoLinkConfig.model')
|
||||||
const broadcast = require('../../../utils/shardBroadcast')
|
const broadcast = require('../../../utils/shardBroadcast')
|
||||||
const auth = require('../../../utils/auth')
|
const visibility = require('../../../utils/shardVisibility')
|
||||||
|
|
||||||
const log = require('../../../utils/logger')('public-shard')
|
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
|
// 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
|
// STAFF website user (admin/editor/moderator). Everyone sees that a staff member
|
||||||
// is online (name + serial); their in-game location (map + coordinates) is only
|
// is online (name + serial); their in-game location (map + coordinates) is gated
|
||||||
// included for privileged viewers (admin/moderator) so it is never exposed to
|
// on the `presence` feature's `location` field rule, which defaults to `staff`
|
||||||
// players or the public via the network tab. Non-staff players are never listed.
|
// — the same admin/moderator set this used to hardcode. Non-staff players are
|
||||||
function canSeeStaffLocation(req) {
|
// never listed.
|
||||||
const viewer = auth.getUserFromRequest(req)
|
async function canSeeStaffLocation(req) {
|
||||||
return !!viewer && (viewer.role === 'admin' || viewer.role === 'moderator')
|
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) {
|
async function getOnline(req, res) {
|
||||||
try {
|
try {
|
||||||
const rows = await shardState.listOnlineLinked()
|
const rows = await shardState.listOnlineLinked()
|
||||||
const showLocation = canSeeStaffLocation(req)
|
const showLocation = await canSeeStaffLocation(req)
|
||||||
return res.json(
|
return res.json(
|
||||||
rows.map((r) => {
|
rows.map((r) => {
|
||||||
const entry = { serial: r.serial, name: r.name }
|
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;
|
// 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.
|
// 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) {
|
async function getGuilds(req, res) {
|
||||||
try {
|
try {
|
||||||
return res.json(await shardState.listGuilds())
|
return res.json(await visibility.project('guilds', await shardState.listGuilds(), req))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('shard.getGuilds', err)
|
log.error('shard.getGuilds', err)
|
||||||
return res.status(500).json({ message: 'Internal Server Error' })
|
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
|
// 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) {
|
async function getGovernors(req, res) {
|
||||||
try {
|
try {
|
||||||
return res.json(await shardState.listGovernors())
|
return res.json(await visibility.project('governors', await shardState.listGovernors(), req))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('shard.getGovernors', err)
|
log.error('shard.getGovernors', err)
|
||||||
return res.status(500).json({ message: 'Internal Server Error' })
|
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.
|
// (look-back: "who were all the governors of Britain?"), newest first.
|
||||||
async function getGovernorHistory(req, res) {
|
async function getGovernorHistory(req, res) {
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
log.error('shard.getGovernorHistory', err)
|
log.error('shard.getGovernorHistory', err)
|
||||||
return res.status(500).json({ message: 'Internal Server Error' })
|
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) {
|
function stream(req, res) {
|
||||||
broadcast.subscribe(req, res, 'public')
|
return broadcast.subscribe(req, res, 'public')
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
@@ -209,5 +234,6 @@ module.exports = {
|
|||||||
getGovernorHistory,
|
getGovernorHistory,
|
||||||
getPresence,
|
getPresence,
|
||||||
getHouses,
|
getHouses,
|
||||||
|
getFeatures,
|
||||||
stream,
|
stream,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,19 +10,28 @@
|
|||||||
// visitors *and* by the Android ShardStreamClient, neither of which sends an
|
// visitors *and* by the Android ShardStreamClient, neither of which sends an
|
||||||
// Authorization header; adding requireAuth here blacks out the public live boards
|
// Authorization header; adding requireAuth here blacks out the public live boards
|
||||||
// on web and mobile. The sensitive kinds (staff audit, cheat detection, login
|
// 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
|
// attempts, IPs) are withheld by utils/shardBroadcast.js, not by a route gate —
|
||||||
// a route gate — that allowlist split is the security boundary, not this file.
|
// 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 express = require('express')
|
||||||
const { param, query } = require('express-validator')
|
const { param, query } = require('express-validator')
|
||||||
|
|
||||||
const shard = require('./shard.controller')
|
const shard = require('./shard.controller')
|
||||||
const validate = require('../../../middleware/validate')
|
const validate = require('../../../middleware/validate')
|
||||||
|
const { requireFeature } = require('../../../utils/shardVisibility')
|
||||||
|
|
||||||
const shardRouter = express.Router()
|
const shardRouter = express.Router()
|
||||||
|
|
||||||
shardRouter.get(
|
shardRouter.get(
|
||||||
'/status',
|
'/status',
|
||||||
|
requireFeature('status'),
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Shard connection state, online count and latest economy'
|
// #swagger.summary = 'Shard connection state, online count and latest economy'
|
||||||
/* #swagger.responses[200] = { description: 'Shard status', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardStatus" } } } } */
|
/* #swagger.responses[200] = { description: 'Shard status', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardStatus" } } } } */
|
||||||
@@ -30,6 +39,7 @@ shardRouter.get(
|
|||||||
)
|
)
|
||||||
shardRouter.get(
|
shardRouter.get(
|
||||||
'/feed',
|
'/feed',
|
||||||
|
requireFeature('activity'),
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Recent notable shard events (from the ingested log)'
|
// #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.' }
|
// #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(
|
shardRouter.get(
|
||||||
'/economy',
|
'/economy',
|
||||||
|
requireFeature('status'),
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Gold-supply time series (oldest → newest)'
|
// #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).' }
|
// #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(
|
shardRouter.get(
|
||||||
'/online',
|
'/online',
|
||||||
|
requireFeature('presence'),
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Staff online now (linked staff accounts; location is admin/moderator-only)'
|
// #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" } } } } } */
|
/* #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(
|
shardRouter.get(
|
||||||
'/idoc',
|
'/idoc',
|
||||||
|
requireFeature('houses'),
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Houses currently in danger (IDOC)'
|
// #swagger.summary = 'Houses currently in danger (IDOC)'
|
||||||
/* #swagger.responses[200] = { description: 'IDOC houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
|
/* #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(
|
shardRouter.get(
|
||||||
'/champs',
|
'/champs',
|
||||||
|
requireFeature('champs'),
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Current champion-spawn board (all categories)'
|
// #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.'
|
// #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(
|
shardRouter.get(
|
||||||
'/guilds',
|
'/guilds',
|
||||||
|
requireFeature('guilds'),
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Current guild board (rosters, alliances, leaders)'
|
// #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.'
|
// #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(
|
shardRouter.get(
|
||||||
'/governors',
|
'/governors',
|
||||||
|
requireFeature('governors'),
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Current town-governor board (City Loyalty)'
|
// #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.'
|
// #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(
|
shardRouter.get(
|
||||||
'/governors/:city/history',
|
'/governors/:city/history',
|
||||||
|
requireFeature('governors'),
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Governor term history for a city'
|
// #swagger.summary = 'Governor term history for a city'
|
||||||
// #swagger.parameters['city'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'City name, e.g. Britain.' }
|
// #swagger.parameters['city'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'City name, e.g. Britain.' }
|
||||||
@@ -102,6 +119,7 @@ shardRouter.get(
|
|||||||
)
|
)
|
||||||
shardRouter.get(
|
shardRouter.get(
|
||||||
'/presence',
|
'/presence',
|
||||||
|
requireFeature('presence'),
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Online population aggregate (count + per-facet + per-region)'
|
// #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.'
|
// #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(
|
shardRouter.get(
|
||||||
'/houses',
|
'/houses',
|
||||||
|
requireFeature('houses'),
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'House registry (owner, co-owners, price, decay)'
|
// #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.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" } } } } } */
|
/* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
|
||||||
shard.getHouses,
|
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(
|
shardRouter.get(
|
||||||
'/stream',
|
'/stream',
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Live shard event stream (Server-Sent Events, public/safe kinds)'
|
// #swagger.summary = 'Live shard event stream (Server-Sent Events, filtered by audience)'
|
||||||
// #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.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).' } */
|
/* #swagger.responses[200] = { description: 'An SSE stream (Content-Type: text/event-stream).' } */
|
||||||
shard.stream,
|
shard.stream,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,67 +2,66 @@
|
|||||||
//
|
//
|
||||||
// The browser can't talk to the sidecar's WebSocket directly (the token must
|
// 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
|
// 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
|
// the WS feed and re-broadcasts events to browsers over Server-Sent Events
|
||||||
// Events (plain HTTP — works through any reverse proxy).
|
// (plain HTTP — works through any reverse proxy).
|
||||||
//
|
//
|
||||||
// Two channels:
|
// Since Protocol 3.0 the split is no longer "one public channel with a static
|
||||||
// • public — safe kinds only (sales, deaths, IDOC, logins, economy). No IPs,
|
// allowlist plus one admin channel". Each subscriber carries the audience rung
|
||||||
// no account-login attempts, no staff audit / cheat events.
|
// it resolved to at subscribe time, and every frame is
|
||||||
// • admin — everything, including the sensitive kinds above.
|
//
|
||||||
|
// 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
|
// shardIngest calls broadcast(event) for each ingested event; the public/admin
|
||||||
// SSE route handlers call subscribe(req, res, channel).
|
// SSE route handlers call subscribe(req, res, channel).
|
||||||
|
|
||||||
|
const visibility = require('./shardVisibility')
|
||||||
const log = require('./logger')('shard-broadcast')
|
const log = require('./logger')('shard-broadcast')
|
||||||
|
|
||||||
// Kinds safe to expose to unauthenticated browsers. Note: vendor.sale is
|
// Re-exported for back-compat: shardEvents `/feed` filtering and
|
||||||
// deliberately NOT here — sales are owner-private (a linked player sees only
|
// config/notificationStreams.js both ask "is this kind public-safe?".
|
||||||
// their own, via /player/shard/sales).
|
const { PUBLIC_KINDS } = visibility
|
||||||
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.
|
|
||||||
])
|
|
||||||
|
|
||||||
// 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 clients = { public: new Set(), admin: new Set() }
|
||||||
|
|
||||||
const KEEPALIVE_MS = 25000
|
const KEEPALIVE_MS = 25000
|
||||||
|
|
||||||
// Register an SSE stream on a channel. Sets the SSE headers, sends an initial
|
// 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.
|
// 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]
|
const bucket = clients[channel]
|
||||||
if (!bucket) {
|
if (!bucket) {
|
||||||
res.status(400).end()
|
res.status(400).end()
|
||||||
return
|
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, {
|
res.writeHead(200, {
|
||||||
'Content-Type': 'text/event-stream',
|
'Content-Type': 'text/event-stream',
|
||||||
'Cache-Control': 'no-cache, no-transform',
|
'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('retry: 5000\n\n') // tell EventSource to reconnect after 5s if dropped
|
||||||
res.write(': connected\n\n')
|
res.write(': connected\n\n')
|
||||||
|
|
||||||
bucket.add(res)
|
const client = { res, level, ping: null }
|
||||||
|
bucket.add(client)
|
||||||
|
|
||||||
const ping = setInterval(() => {
|
client.ping = setInterval(() => {
|
||||||
try {
|
try {
|
||||||
res.write(': ping\n\n')
|
res.write(': ping\n\n')
|
||||||
} catch {
|
} catch {
|
||||||
@@ -82,45 +82,80 @@ function subscribe(req, res, channel) {
|
|||||||
}
|
}
|
||||||
}, KEEPALIVE_MS)
|
}, KEEPALIVE_MS)
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => drop(bucket, client)
|
||||||
clearInterval(ping)
|
|
||||||
bucket.delete(res)
|
|
||||||
}
|
|
||||||
req.on('close', cleanup)
|
req.on('close', cleanup)
|
||||||
res.on('error', cleanup)
|
res.on('error', cleanup)
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeTo(bucket, payload) {
|
// The ONLY way a client leaves a bucket. Clearing the keepalive here (rather
|
||||||
for (const res of bucket) {
|
// than only in the close handler) matters: a client dropped because its write
|
||||||
try {
|
// threw never fires `req.close`, so its interval would otherwise keep firing on
|
||||||
res.write(payload)
|
// a dead socket for the life of the process.
|
||||||
} catch (err) {
|
function drop(bucket, client) {
|
||||||
log.warn('sse write failed; dropping client', { message: err.message })
|
clearInterval(client.ping)
|
||||||
bucket.delete(res)
|
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
|
// Fan an ingested event out. The admin channel gets it verbatim, always. Public
|
||||||
// channel (safe kinds only). A no-op when nobody is subscribed.
|
// subscribers are filtered and projected per their own rung — so two viewers on
|
||||||
function broadcast(event) {
|
// 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
|
if (!event || !event.kind) return
|
||||||
const frame = `data: ${JSON.stringify(event)}\n\n`
|
|
||||||
if (clients.admin.size) writeTo(clients.admin, frame)
|
if (clients.admin.size) {
|
||||||
if (clients.public.size && PUBLIC_KINDS.has(event.kind)) writeTo(clients.public, frame)
|
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() {
|
function closeAll() {
|
||||||
for (const channel of Object.values(clients)) {
|
for (const bucket of Object.values(clients)) {
|
||||||
for (const res of channel) {
|
for (const client of [...bucket]) {
|
||||||
|
drop(bucket, client)
|
||||||
try {
|
try {
|
||||||
res.end()
|
client.res.end()
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
channel.clear()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -229,11 +229,12 @@ async function ingest(event, deps = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!deps.fromBackfill) {
|
if (!deps.fromBackfill) {
|
||||||
try {
|
// Broadcast is async since v3 (it reads the visibility config to decide what
|
||||||
d.broadcast(event)
|
// each subscriber may see). Fire-and-forget, like the push fan-out below: a
|
||||||
} catch (err) {
|
// slow config read must never delay or fail ingest.
|
||||||
d.log.warn('broadcast failed', { kind: event.kind, message: err.message })
|
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.
|
// 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);
|
// Fire-and-forget (a slow/dead ntfy relay must never delay or fail ingest);
|
||||||
// fromShardEvent is self-guarding, but .catch() covers any lookup rejection.
|
// fromShardEvent is self-guarding, but .catch() covers any lookup rejection.
|
||||||
|
|||||||
358
server/src/utils/shardVisibility.js
Normal file
358
server/src/utils/shardVisibility.js
Normal 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,
|
||||||
|
}
|
||||||
@@ -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": {
|
"/api/v1/admin/site-mode": {
|
||||||
"put": {
|
"put": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -10849,6 +10941,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found"
|
||||||
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Internal Server Error"
|
"description": "Internal Server Error"
|
||||||
}
|
}
|
||||||
@@ -10890,6 +10988,36 @@
|
|||||||
"400": {
|
"400": {
|
||||||
"description": "Bad Request"
|
"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": {
|
"500": {
|
||||||
"description": "Internal Server Error"
|
"description": "Internal Server Error"
|
||||||
}
|
}
|
||||||
@@ -10940,6 +11068,12 @@
|
|||||||
"400": {
|
"400": {
|
||||||
"description": "Bad Request"
|
"description": "Bad Request"
|
||||||
},
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found"
|
||||||
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Internal Server Error"
|
"description": "Internal Server Error"
|
||||||
}
|
}
|
||||||
@@ -10968,6 +11102,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found"
|
||||||
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Internal Server Error"
|
"description": "Internal Server Error"
|
||||||
}
|
}
|
||||||
@@ -11019,6 +11159,12 @@
|
|||||||
"400": {
|
"400": {
|
||||||
"description": "Bad Request"
|
"description": "Bad Request"
|
||||||
},
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found"
|
||||||
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Internal Server Error"
|
"description": "Internal Server Error"
|
||||||
}
|
}
|
||||||
@@ -11047,6 +11193,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found"
|
||||||
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Internal Server Error"
|
"description": "Internal Server Error"
|
||||||
}
|
}
|
||||||
@@ -11074,6 +11226,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found"
|
||||||
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Internal Server Error"
|
"description": "Internal Server Error"
|
||||||
}
|
}
|
||||||
@@ -11101,6 +11259,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found"
|
||||||
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Internal Server Error"
|
"description": "Internal Server Error"
|
||||||
}
|
}
|
||||||
@@ -11128,6 +11292,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found"
|
||||||
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Internal Server Error"
|
"description": "Internal Server Error"
|
||||||
}
|
}
|
||||||
@@ -11153,6 +11323,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found"
|
||||||
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Internal Server Error"
|
"description": "Internal Server Error"
|
||||||
}
|
}
|
||||||
@@ -11177,6 +11353,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found"
|
||||||
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Internal Server Error"
|
"description": "Internal Server Error"
|
||||||
}
|
}
|
||||||
@@ -11188,8 +11370,8 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Public · Shard"
|
"Public · Shard"
|
||||||
],
|
],
|
||||||
"summary": "Live shard event stream (Server-Sent Events, public/safe kinds)",
|
"summary": "Live shard event stream (Server-Sent Events, filtered by audience)",
|
||||||
"description": "text/event-stream of curated live events. Sensitive kinds (staff audit, cheat detection, login attempts, IPs) are NOT sent on this channel.",
|
"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": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "An SSE stream (Content-Type: text/event-stream)."
|
"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": {
|
"ShardLinkRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -881,6 +881,78 @@ const doc = {
|
|||||||
updatedAt: { type: 'string', format: 'date-time' },
|
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: {
|
ShardLinkRequest: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['code'],
|
required: ['code'],
|
||||||
|
|||||||
225
server/test/shardBroadcast.visibility.test.js
Normal file
225
server/test/shardBroadcast.visibility.test.js
Normal 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(), [])
|
||||||
|
})
|
||||||
319
server/test/shardVisibility.test.js
Normal file
319
server/test/shardVisibility.test.js
Normal 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')
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user