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

31
ntfy/server.yml Normal file
View File

@@ -0,0 +1,31 @@
# ── ntfy self-hosted server config (UnifiedPush relay) ─────────────────────
#
# Backs the Android app's opt-in push notifications (docs/android/PLAN.md §11).
# Declarative + committed: `docker compose up` provisions a working relay with
# NO interactive setup — no `ntfy user add`, no per-user accounts, no post-deploy
# steps. The website backend treats ntfy as an UNTRUSTED relay and only ever
# publishes content-free tickles ({ stream, ref }); the real, ownership-checked
# content is pulled by the app over the authenticated website API. That is why
# anonymous access to unguessable topics is intentional and safe here.
#
# The public base URL is provided per-deploy via the NTFY_BASE_URL env var in
# docker-compose.yml (ntfy env vars override this file), so this default is only
# a placeholder for a bare `ntfy serve`.
base-url: "https://ntfy.localhost"
# Served on the private compose network; the public reverse proxy terminates TLS
# and forwards to this port. docker-compose.yml publishes NO host port for ntfy.
listen-http: ":80"
behind-proxy: true
# Persist the message cache + (empty) auth db on the named volume.
cache-file: "/var/lib/ntfy/cache.db"
auth-file: "/var/lib/ntfy/auth.db"
# No accounts to administer — anonymous read+write to unguessable topics. Safe
# because payloads are content-free; the security boundary is the authenticated
# website API, not ntfy (see the header note).
auth-default-access: "read-write"
# Pure relay: no attachments.
attachment-cache-dir: ""