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,86 @@
// Self-service push-notification management for the logged-in user (any role).
// Mounted under /auth/me behind requireAuth, so req.user is the fresh DB row.
// Devices (endpoints) and stream subscriptions live here; the fan-out that
// actually delivers is utils/pushDispatch. See docs/android/PLAN.md §11.
const pushDevices = require('../../../model/pushDevices/pushDevices.model')
const notificationSubs = require('../../../model/notificationSubs/notificationSubs.model')
const { STREAMS } = require('../../../config/notificationStreams')
const { isAllowedEndpoint } = require('../../../utils/pushDispatch')
const log = require('../../../utils/logger')('notifications')
// POST /auth/me/devices — register (or refresh) a push endpoint for this user.
async function registerDevice(req, res) {
const { transport = 'unifiedpush', endpoint, platform } = req.body
// SSRF guard: the endpoint is a URL the server will later POST to. Reject
// anything that isn't an allowed HTTPS relay origin before storing it.
if (!isAllowedEndpoint(endpoint)) {
return res.status(400).json({ message: 'Endpoint is not an allowed push URL' })
}
try {
const device = await pushDevices.register({ userId: req.user.id, transport, endpoint, platform })
return res.status(201).json(device)
} catch (err) {
log.error('registerDevice', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /auth/me/devices — this user's registered devices.
async function listDevices(req, res) {
try {
return res.json(await pushDevices.listForUser(req.user.id))
} catch (err) {
log.error('listDevices', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// DELETE /auth/me/devices/:id — unregister a device (must belong to the caller).
async function removeDevice(req, res) {
try {
const ok = await pushDevices.remove(Number(req.params.id), req.user.id)
if (!ok) return res.status(404).json({ message: 'Not found' })
return res.json({ ok: true })
} catch (err) {
log.error('removeDevice', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /auth/me/notifications/streams — the subscribable catalog (static).
function getStreams(req, res) {
return res.json({ streams: STREAMS })
}
// GET /auth/me/notifications/subscriptions — the caller's opted-in stream ids.
async function getSubscriptions(req, res) {
try {
return res.json({ streams: await notificationSubs.getForUser(req.user.id) })
} catch (err) {
log.error('getSubscriptions', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// PUT /auth/me/notifications/subscriptions — replace the caller's stream set.
// Unknown ids are dropped; the stored (cleaned) set is echoed back.
async function putSubscriptions(req, res) {
try {
const streams = await notificationSubs.setForUser(req.user.id, req.body.streams)
return res.json({ streams })
} catch (err) {
log.error('putSubscriptions', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = {
registerDevice,
listDevices,
removeDevice,
getStreams,
getSubscriptions,
putSubscriptions,
}