// ── Push-notification fan-out (content-free tickles) ─────────────────────── // // The transport-agnostic publisher that turns a stream id into opt-in push // notifications. It knows nothing about where the stream came from: the admin // create/publish-post path calls publish('news.post', …), and utils/shardPush.js // resolves a shard event to a stream and an owner and calls the same function. // // That split is MODULE_SYSTEM.md §1.8's second entanglement, inverted. This file // used to own `fromShardEvent()`, which required the shardLinks model and the // shard event mapper — core infrastructure reaching into game content. Now the // content side calls in, and a module reaches this through `ctx.push.publish`. // // What actually leaves the server is a CONTENT-FREE tickle — `{ stream, ref }`, // no sensitive data — POSTed to each subscribed device's UnifiedPush/ntfy // endpoint. The app wakes and PULLS the real content over the authenticated, // ownership-checked API. So ntfy is treated as an untrusted relay: a leaked topic // reveals nothing, which is what lets it run with no per-user accounts // (docs/android/PLAN.md §11). // // SECURITY: a device `endpoint` is a client-supplied URL the server makes // server-side POSTs to — a classic SSRF vector. isAllowedEndpoint() gates every // registration AND every publish: HTTPS only, never a private/loopback host, and // (when configured) the origin must be in the shard's ntfy allow-set. const pushDevicesModel = require('../model/pushDevices/pushDevices.model') const log = require('./logger')('push-dispatch') const TIMEOUT_MS = 5000 // Hosts that must never be POSTed to, even if the allow-set is empty (dev). This // is a coarse literal check (no DNS resolution) — the real protection in prod is // the configured allow-set below, which pins the single ntfy origin. const PRIVATE_HOST = /^(localhost|127\.|0\.0\.0\.0|10\.|192\.168\.|169\.254\.|::1|fc00:|fd00:|172\.(1[6-9]|2\d|3[01])\.)/i function toOrigin(u) { try { return new URL(u).origin } catch { return null } } // Allowed publish origins, from NTFY_ALLOWED_ORIGINS (comma-separated) or, failing // that, NTFY_BASE_URL's origin. Empty when neither is set (dev fallback). function allowedOrigins() { const raw = process.env.NTFY_ALLOWED_ORIGINS || process.env.NTFY_BASE_URL || '' return raw .split(',') .map((s) => toOrigin(s.trim())) .filter(Boolean) } // Is this endpoint safe to POST to? HTTPS + non-private host + (if an allow-set is // configured) an allowed origin. With no allow-set (dev), any public HTTPS host is // permitted; the private-host check still blocks the obvious SSRF targets. function isAllowedEndpoint(endpoint) { let url try { url = new URL(String(endpoint)) } catch { return false } if (url.protocol !== 'https:') return false if (PRIVATE_HOST.test(url.hostname)) return false const allow = allowedOrigins() if (allow.length === 0) return true return allow.includes(url.origin) } async function postTickle(endpoint, bodyStr, deps) { const doFetch = deps.fetchImpl || fetch if (!isAllowedEndpoint(endpoint)) { log.warn('skipping push to disallowed endpoint', { endpoint }) return } const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS) try { const headers = { 'Content-Type': 'application/json' } const token = process.env.NTFY_PUBLISH_TOKEN if (token) headers.Authorization = `Bearer ${token}` await doFetch(endpoint, { method: 'POST', headers, body: bodyStr, signal: controller.signal }) } catch (err) { log.warn('push tickle failed', { message: err.message }) } finally { clearTimeout(timeout) } } // Publish one content-free tickle. Public (ownerUserId absent) → every device // whose user subscribes to the stream. Personal (ownerUserId set) → only that // user's devices, and only if subscribed. Never throws. async function publish(streamId, { ref, ownerUserId } = {}, deps = {}) { const devices = deps.pushDevices || pushDevicesModel let rows try { rows = ownerUserId != null ? await devices.endpointsForUserStream(ownerUserId, streamId) : await devices.endpointsForStream(streamId) } catch (err) { log.warn('push endpoint lookup failed', { streamId, message: err.message }) return } if (!rows || rows.length === 0) return const bodyStr = JSON.stringify({ stream: streamId, ref: ref ?? null }) await Promise.all(rows.map((r) => postTickle(r.endpoint, bodyStr, deps))) } module.exports = { publish, isAllowedEndpoint }