Add shard activity feed + admin live feed; fix public-feed leak

Front ends for the rest of the sidecar data, plus a security fix the live data
surfaced.

- lib/shardEvents.js: shared describe()/category/label for every event kind
  (sales, deaths & PvP, skills, fame/karma, quests, world, and staff kinds).
- Public /site/shard/activity (ShardActivity): the full event log with category
  filter tabs and a live tail (history + SSE merged, de-duped). Linked from the
  Shard page. Shard page now reuses the shared describe().
- Admin: a "Live feed (all events)" panel on the Shard admin page subscribing to
  the admin SSE channel — shows every kind incl. audit/cheat/login attempts.
  useShardFeed generalized to take a stream url; api.adminShardStreamUrl added.

Security fix: GET /public/shard/feed now restricts to the public-safe kind
allowlist (shardEvents.list gains a `kinds` IN-filter). Previously it returned
whatever was logged — including audit.* / cheat.* / link.request. Those are
still stored for the admin channel but never served publicly (verified: a
public request for audit.command returns 0 rows).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
This commit is contained in:
2026-07-11 03:10:03 -05:00
parent 74d2ead958
commit 49d0c1bd11
10 changed files with 249 additions and 48 deletions

View File

@@ -4,6 +4,7 @@ import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { useShardFeed } from '../../lib/useShardFeed.js'
import { describe } from '../../lib/shardEvents.js'
import { ago } from '../../lib/format.js'
import { api } from '../../api/client.js'
@@ -42,38 +43,6 @@ function Stat({ value, label }) {
)
}
// A one-line human description of a feed event.
function describe(ev) {
const p = ev.payload || ev
switch (ev.kind) {
case 'vendor.sale':
return `${p.itemType || 'An item'}${p.amount > 1 ? ` ×${p.amount}` : ''} sold for ${Number(p.price || 0).toLocaleString()}gp`
case 'player.death':
return `${nameOf(p.who)} was slain${p.killer ? ` by ${nameOf(p.killer)}` : ''}`
case 'player.murdered':
return `${nameOf(p.victim)} was murdered${p.murderer ? ` by ${nameOf(p.murderer)}` : ''}`
case 'mob.killed':
return `${nameOf(p.killer)} killed ${nameOf(p.killed)}`
case 'house.decay':
return `${p.name || 'A house'} is now ${p.to || p.stage}`
case 'quest.complete':
return `${nameOf(p.who)} completed “${p.quest}`
case 'skill.gain':
return `${nameOf(p.who)} gained ${p.skill}`
case 'mob.login':
return `${nameOf(p.who)} entered the world`
case 'mob.logout':
return `${nameOf(p.who)} left the world`
default:
return ev.kind
}
}
function nameOf(who) {
if (!who) return 'Someone'
if (typeof who === 'string') return who
return who.name || who.acct || 'Someone'
}
export default function Shard() {
const { loading, error, data } = useAsync(() =>
Promise.all([
@@ -206,10 +175,15 @@ export default function Shard() {
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
Live feed
</div>
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)' }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<Link to="/site/shard/activity" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.78rem' }}>
View all activity
</Link>
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)' }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
</span>
</div>
</div>
{events.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>