// ── 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))) } // `Number.isInteger` alone is not enough: `Number(null)` is 0 and 0 is an // integer, so a null slipping into a caller's list would become user id 0 and // ride into an IN clause. No row has id 0, so it is harmless today — which is // exactly why it would never be noticed. const isUserId = (n) => Number.isInteger(n) && n > 0 /** * Publish one content-free tickle to a COMPUTED SET of users (TEAMS.md §6.2). * * The third fan-out shape. `publish` answers "everyone subscribed" and "this one * owner"; Team notifications need "these N users", because the four `team.*` * streams are global and which Team an event belongs to is expressed by who is in * the set. Nothing about the tickle changes — same `{ stream, ref }`, same * untrusted-relay assumption, same SSRF gate on every endpoint. * * The set arrives already resolved: the caller has asked the access resolver who * may read this Team and subtracted the per-Team mutes. What this function still * enforces is each recipient's own stream subscription, in the query. Never * throws — a notification failing must not fail the write that produced it. */ async function publishToUsers(streamId, { ref, userIds } = {}, deps = {}) { const devices = deps.pushDevices || pushDevicesModel const ids = [...new Set((userIds || []).map(Number).filter(isUserId))] if (ids.length === 0) return let rows try { rows = await devices.endpointsForUsersStream(ids, 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, publishToUsers, isAllowedEndpoint }