// ── Shard-derived push streams + event → stream mapping ──────────────────── // // MODULE-UO CONTENT, still living in core. MODULE_SYSTEM.md §1.8 named // config/notificationStreams.js as one of the three genuinely entangled files: // most of its catalog and all of `mapShardEvent` are shard-derived, and it reads // `PUBLIC_KINDS` out of utils/shardBroadcast. PR 4 split it — core's one stream // is config/coreStreams.js, and everything shard-shaped is here, in a file that // moves to module-uo whole in Phase 3. Nothing in core imports it except // modules/registries.js's registerCore(), which is the one line Phase 3 deletes. // // Two families: // • public / opt-in — no linked game account required; delivered to every // subscriber. Drawn ONLY from the SSE public allowlist // (utils/shardBroadcast PUBLIC_KINDS) — a sensitive kind // can never produce a public push. // • personal / owner-keyed — require a linked game account; delivered ONLY to // the owning user's devices (resolved from the event's // game account via shardLinks), never fanned out publicly. // // The payload the relay ever carries is a CONTENT-FREE tickle ({ stream, ref }); // `ref` is an opaque hint (serial / city / timestamp) the app uses to pull the // real, ownership-checked content over the authenticated API. So even a leaked // ntfy topic reveals nothing (docs/android/PLAN.md §11). const { PUBLIC_KINDS } = require('../utils/shardBroadcast') const STREAMS = [ { id: 'server.status', label: 'Server up / down', description: 'The shard comes online or goes offline.', personal: false, requiresLinkedAccount: false, }, { id: 'idoc.warning', label: 'IDOC warnings', description: 'A house falls into its final (IDOC) decay stage.', personal: false, requiresLinkedAccount: false, }, { id: 'champ.start', label: 'Champion spawn starts', description: 'A champion spawn becomes active.', personal: false, requiresLinkedAccount: false, }, { id: 'governor.election', label: 'Governor elections', description: 'A town elects a new governor.', personal: false, requiresLinkedAccount: false, }, { id: 'vendor.sale', label: 'Your vendor sold an item', description: 'One of your player vendors made a sale.', personal: true, requiresLinkedAccount: true, }, { id: 'house.idoc', label: 'Your house entered IDOC', description: 'One of your houses fell into its final decay stage.', personal: true, requiresLinkedAccount: true, }, { id: 'account.login', label: 'A login to your account', description: 'An authentication attempt against your game account.', personal: true, requiresLinkedAccount: true, }, ] // The owner-keyed subset, needed by mapShardEvent's public-safety filter below. // Derived from this file's own catalog rather than read back out of the registry: // the filter is about THESE streams, and a module must not be able to weaken it // by registering something that happens to share an id. const PERSONAL_STREAMS = new Set(STREAMS.filter((s) => s.personal).map((s) => s.id)) // Per-process transition state so full-state upserts (champ.update / city.update // are upserts, not discrete "started"/"elected" events — see docs/link // PROTOCOL_2 §383) only fire once, on an actual transition. Injectable so tests // pass a fresh tracker; a module-level default backs the live dispatcher. function createTracker() { return { champActive: new Map(), cityGovernor: new Map() } } const defaultTracker = createTracker() // Per-kind mappers, each pushing 0+ targets onto `out` (and updating `tracker` // for the upsert-transition kinds). Split out of mapShardEvent so that function // stays a trivial dispatch + the public-safety filter. const serverStatusUp = (event, tracker, out) => out.push({ streamId: 'server.status', ref: `up:${event.bootId || ''}` }) const serverStatusDown = (event, tracker, out) => out.push({ streamId: 'server.status', ref: 'down' }) const EVENT_MAPPERS = { 'server.hello': serverStatusUp, 'server.shutdown': serverStatusDown, 'server.crashed': serverStatusDown, 'house.decay': (event, tracker, out) => { if (String(event.to).toUpperCase() !== 'IDOC') return const ref = String(event.serial ?? '') out.push({ streamId: 'idoc.warning', ref }) // public — location only if (event.ownerAcct) { out.push({ streamId: 'house.idoc', ref, ownerAccount: event.ownerAcct }) // personal } }, 'champ.update': (event, tracker, out) => { const { serial } = event if (serial == null) return const wasActive = tracker.champActive.get(serial) === true const isActive = event.active === true tracker.champActive.set(serial, isActive) if (isActive && !wasActive) out.push({ streamId: 'champ.start', ref: String(serial) }) }, 'champ.remove': (event, tracker) => { if (event.serial != null) tracker.champActive.delete(event.serial) }, 'city.update': (event, tracker, out) => { const { city } = event if (!city) return const gov = event.governor && event.governor.serial != null ? String(event.governor.serial) : null const prev = tracker.cityGovernor.get(city) tracker.cityGovernor.set(city, gov) // Only a real transition to a new governor, and never on first sight // (prev === undefined) so a reconnect snapshot isn't read as an election. if (prev !== undefined && gov && gov !== prev) { out.push({ streamId: 'governor.election', ref: String(city) }) } }, 'vendor.sale': (event, tracker, out) => { if (event.ownerAcct) { out.push({ streamId: 'vendor.sale', ref: String(event.t ?? ''), ownerAccount: event.ownerAcct }) } }, 'account.login.attempt': (event, tracker, out) => { if (event.acct) { out.push({ streamId: 'account.login', ref: String(event.t ?? ''), ownerAccount: event.acct }) } }, } // Map one shard event → an array of targets ({ streamId, ref, ownerAccount? }). // May yield 0, 1, or 2 targets (an owner house.decay produces both the public // idoc.warning and the personal house.idoc). Pure given `tracker`. function mapShardEvent(event, tracker = defaultTracker) { if (!event || typeof event.kind !== 'string') return [] const kind = event.kind const out = [] const mapper = EVENT_MAPPERS[kind] if (mapper) mapper(event, tracker, out) // Defense in depth: a PUBLIC (non-personal) target may only ride a public-safe // kind. Personal targets are owner-keyed and delivered solely to the owner, so // they are exempt from the public allowlist (that is the whole point of the // owner-keyed split). This guarantees a sensitive kind can never leak publicly // even if a future mapping case is added carelessly. // // This filter, the kinds it reads and the streams it protects now all live in // one file and move together — the reason PR 4 dropped the contract's // `mapEvent` half rather than leaving the mapping in core and the catalog in a // module (MODULE_API.md §2.4). return out.filter((t) => (PERSONAL_STREAMS.has(t.streamId) ? true : PUBLIC_KINDS.has(kind))) } module.exports = { STREAMS, mapShardEvent, createTracker, PERSONAL_STREAMS }