feat(push): M7 backend — opt-in push notifications via self-hosted ntfy
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m37s
PR Checks / client-build (pull_request) Successful in 9m21s
PR Checks / bot-install (pull_request) Successful in 9m17s

Additive, v1-only backend contract for the Android app's opt-in push (Part 1 of
M7; docs/android/PLAN.md §11). The app is a pure consumer — this lands the
endpoints, fan-out, and relay it needs.

- Schema: push_devices (per-device endpoint) + notification_subscriptions
  (per-user opted-in streams), FK→users ON DELETE CASCADE.
- Stream catalog + event→stream mapping (config/notificationStreams.js): public
  streams (news.post, server.status, idoc.warning, champ.start, governor.election)
  drawn ONLY from the SSE PUBLIC_KINDS allowlist; personal owner-keyed streams
  (vendor.sale, house.idoc, account.login). Full-state upserts (champ/city) fire
  only on a real transition via an injectable tracker.
- Fan-out (utils/pushDispatch.js): content-free tickles ({ stream, ref }) POSTed
  to each subscribed device; never throws. Two producers — shardIngest.ingest
  (beside the SSE broadcast) and the create/publish-post path (news.post).
  Personal events resolve to the owner via shardLinks. SSRF guard: endpoints must
  be HTTPS, non-private, and on the NTFY_BASE_URL/NTFY_ALLOWED_ORIGINS allow-set —
  enforced at registration and every publish.
- Routes under the role-agnostic self surface (never /admin): POST|GET
  /auth/me/devices, DELETE /auth/me/devices/:id, GET
  /auth/me/notifications/streams, GET|PUT /auth/me/notifications/subscriptions.
  Swagger regenerated (4 paths, PushDevice/NotificationStreams/etc. schemas).
- ntfy service in docker-compose.yml: pinned image, declarative ./ntfy/server.yml,
  no published host port, anonymous unguessable topics (no accounts) — zero
  interactive setup. No publish token required (content-free design); optional
  NTFY_PUBLISH_TOKEN honored.
- Tests: pushDispatch (mapping, PUBLIC_KINDS gate, owner-keying, SSRF guard,
  content-free payload) + notifications route auth gate. Full suite green (247).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-20 05:13:48 -05:00
parent 030414f13d
commit 416761f8f7
22 changed files with 1778 additions and 1 deletions

View File

@@ -0,0 +1,135 @@
// ── Push-notification fan-out (content-free tickles) ───────────────────────
//
// The transport-agnostic publisher that turns an event into opt-in push
// notifications. Two producers call in:
// • utils/shardIngest.js → fromShardEvent(event) for shard-derived streams
// (beside the existing SSE broadcast — same event source, same allowlist).
// • the admin create/publish-post path → publish('news.post', …).
//
// 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 shardLinks = require('../model/shardLinks/shardLinks.model')
const pushDevicesModel = require('../model/pushDevices/pushDevices.model')
const { mapShardEvent } = require('../config/notificationStreams')
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)))
}
// Fan a shard event out to push. Resolves personal (owner-keyed) targets to the
// owning website user via shardLinks (an unlinked account → nobody to notify).
// Never throws — a dead relay must never affect ingest.
async function fromShardEvent(event, deps = {}) {
const links = deps.shardLinks || shardLinks
const targets = mapShardEvent(event, deps.tracker)
for (const t of targets) {
try {
if (t.ownerAccount) {
let owner = null
try {
owner = await links.getByAccount(t.ownerAccount)
} catch {
owner = null
}
if (!owner || owner.userId == null) continue
await publish(t.streamId, { ref: t.ref, ownerUserId: owner.userId }, deps)
} else {
await publish(t.streamId, { ref: t.ref }, deps)
}
} catch (err) {
log.warn('push dispatch target failed', { streamId: t.streamId, message: err.message })
}
}
}
module.exports = { publish, fromShardEvent, isAllowedEndpoint }

View File

@@ -17,6 +17,7 @@ const shardStateModel = require('../model/shardState/shardState.model')
const shardLinksModel = require('../model/shardLinks/shardLinks.model')
const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model')
const broadcaster = require('./shardBroadcast')
const pushDispatch = require('./pushDispatch')
const defaultLog = require('./logger')('shard-ingest')
// Notable kinds appended to the shard_events log. High-frequency/session kinds
@@ -194,6 +195,7 @@ async function ingest(event, deps = {}) {
shardLinks: deps.shardLinks || shardLinksModel,
uoLinkConfig: deps.uoLinkConfig || uoLinkConfigModel,
broadcast: deps.broadcast || broadcaster.broadcast,
pushDispatch: deps.pushDispatch || pushDispatch.fromShardEvent,
log: deps.log || defaultLog,
}
@@ -226,6 +228,12 @@ async function ingest(event, deps = {}) {
} catch (err) {
d.log.warn('broadcast failed', { kind: event.kind, message: err.message })
}
// Opt-in push fan-out, off the same event source as the SSE broadcast.
// Fire-and-forget (a slow/dead ntfy relay must never delay or fail ingest);
// fromShardEvent is self-guarding, but .catch() covers any lookup rejection.
Promise.resolve(d.pushDispatch(event, { shardLinks: d.shardLinks })).catch((err) =>
d.log.warn('push dispatch failed', { kind: event.kind, message: err.message }),
)
}
return { logged, stored }