// ── Push-notification stream catalog + event → stream mapping ─────────────── // // The single source of truth for which streams a user can subscribe to, and how // a shard event maps onto them. 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') // The subscribable catalog. `news.post` is produced by the website's own posts // path (not the shard feed) — see utils/pushDispatch — so it has no mapShardEvent // case; every other stream is shard-derived below. const STREAMS = [ { id: 'news.post', label: 'News posts', description: 'New news / Five-on-Friday / newsletter posts.', personal: false, requiresLinkedAccount: false, }, { 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, }, ] const STREAM_IDS = new Set(STREAMS.map((s) => s.id)) const isValidStream = (id) => STREAM_IDS.has(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() // 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 = [] switch (kind) { case 'server.hello': out.push({ streamId: 'server.status', ref: `up:${event.bootId || ''}` }) break case 'server.shutdown': case 'server.crashed': out.push({ streamId: 'server.status', ref: 'down' }) break case 'house.decay': { if (String(event.to).toUpperCase() !== 'IDOC') break 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 } break } case 'champ.update': { const { serial } = event if (serial == null) break 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) }) break } case 'champ.remove': if (event.serial != null) tracker.champActive.delete(event.serial) break case 'city.update': { const { city } = event if (!city) break 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) }) } break } case 'vendor.sale': if (event.ownerAcct) { out.push({ streamId: 'vendor.sale', ref: String(event.t ?? ''), ownerAccount: event.ownerAcct }) } break case 'account.login.attempt': if (event.acct) { out.push({ streamId: 'account.login', ref: String(event.t ?? ''), ownerAccount: event.acct }) } break default: break } // 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. return out.filter((t) => (PERSONAL_STREAMS.has(t.streamId) ? true : PUBLIC_KINDS.has(kind))) } module.exports = { STREAMS, isValidStream, mapShardEvent, createTracker, PERSONAL_STREAMS }