Files
website/server/src/model/shardEvents/shardEvents.model.js
Claude 49d0c1bd11 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
2026-07-11 03:10:03 -05:00

54 lines
2.1 KiB
JavaScript

// Append-only shard event log. The WS ingest dispatcher calls append() for the
// notable kinds; the public/admin read endpoints call list(). The DB layer only
// sees an already-computed dedupe_key so INSERT IGNORE is idempotent across
// WS-reconnect backfill.
const crypto = require('crypto')
const db = require('./shardEvents.db')
const MAX_LIMIT = 1000
const DEFAULT_LIMIT = 100
// Stable stringify — keys sorted — so the dedupe hash is independent of the
// property order the sidecar happened to serialize with.
function stableStringify(value) {
if (value === null || typeof value !== 'object') return JSON.stringify(value)
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
const keys = Object.keys(value).sort()
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`
}
// dedupe_key = sha1(kind + t + stable-json(payload)). Two identical events (same
// kind, same timestamp, same body) collapse to one row.
function dedupeKey(kind, t, payload) {
return crypto.createHash('sha1').update(`${kind}|${t}|${stableStringify(payload)}`).digest('hex')
}
// Append one event. Returns true if a new row was inserted (false = deduped).
async function append({ kind, t, bootId, payload }) {
return db.insertIgnore({ kind, t, bootId, payload, dedupeKey: dedupeKey(kind, t, payload) })
}
function normalizeLimit(limit) {
const n = Number(limit)
if (!Number.isFinite(n) || n <= 0) return DEFAULT_LIMIT
return Math.min(Math.floor(n), MAX_LIMIT)
}
// Recent events, newest first. Each row's JSON payload is parsed back to an
// object. `kinds` (array) restricts to an allowlist; `kind` filters a single kind.
async function list({ kind, kinds, limit } = {}) {
const rows = await db.list({ kind, kinds, limit: normalizeLimit(limit) })
return rows.map((row) => ({
id: row.id,
kind: row.kind,
t: row.t,
bootId: row.boot_id || null,
// mariadb returns JSON columns as strings on some versions; parse defensively.
payload: typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload,
createdAt: row.created_at,
}))
}
module.exports = { append, list, dedupeKey }