Files
website/server/src/utils/pushDispatch.js
wtclaude 26c23bd603 feat(teams): the notification core — four streams, and a recipient set
Phase 6's foundation: the fan-out shape the existing pipeline could not express.

`pushDispatch.publish` answers "everyone subscribed to a stream" and "this one
owner". Team notifications need "these N users", because Team scoping cannot live
in a stream id: the catalog is a static registration validated at boot against a
namespaced pattern, so a stream per Team is unexpressible, and stream ids are
stored in `notification_subscriptions` rows that would need collecting every time
a Team archived. So there are FOUR fixed core streams and the Team lives entirely
in the recipient set.

`team_notification_prefs` is opt-out for push and opt-IN for email — the two sinks
default opposite ways, and the asymmetry lives in the column defaults so no
condition anywhere has to remember it.

One recipient query serves all four streams, because §6.2's two populations are
the same set written twice: "active members with a user_id plus active grants" IS
"everyone with resolved forum access". Mutes are subtracted in SQL rather than by
the caller — there is no function here that returns an unfiltered set.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 14:34:24 -05:00

148 lines
6.3 KiB
JavaScript

// ── 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 }