feat(push): M7 backend — opt-in push notifications via self-hosted ntfy
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:
135
server/src/utils/pushDispatch.js
Normal file
135
server/src/utils/pushDispatch.js
Normal 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 }
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user