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:
@@ -55,6 +55,29 @@ services:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
ntfy:
|
||||
# Self-hosted UnifiedPush relay for the app's opt-in push notifications
|
||||
# (docs/android/PLAN.md §11). Pinned upstream image — fits this file's
|
||||
# pull-only, never-build model. All config is declarative (./ntfy/server.yml
|
||||
# + the NTFY_BASE_URL override below), so bringing the stack up provisions a
|
||||
# working relay with NO interactive steps (no `ntfy user add`, no accounts).
|
||||
# The backend treats ntfy as an untrusted relay and publishes only
|
||||
# content-free tickles, so anonymous read-write to unguessable topics is safe.
|
||||
image: binwiederhier/ntfy:v2.11.0
|
||||
restart: unless-stopped
|
||||
command: ["serve"]
|
||||
environment:
|
||||
# Public URL devices reach it at (behind the reverse proxy). MUST match the
|
||||
# origin of the endpoints the app registers — the backend's SSRF allow-set
|
||||
# (NTFY_BASE_URL / NTFY_ALLOWED_ORIGINS on the app) is derived from it.
|
||||
NTFY_BASE_URL: ${NTFY_BASE_URL:-https://ntfy.localhost}
|
||||
volumes:
|
||||
- ntfydata:/var/lib/ntfy
|
||||
- ./ntfy/server.yml:/etc/ntfy/server.yml:ro
|
||||
# No published host port — devices reach ntfy through the public reverse proxy
|
||||
# on its own hostname; the backend publisher reaches it over the private
|
||||
# compose network. Never publish this directly.
|
||||
|
||||
bot:
|
||||
# Same as app: prebuilt bot image, pulled in production. Build locally via
|
||||
# docker-compose.dev.yml.
|
||||
@@ -90,3 +113,4 @@ services:
|
||||
volumes:
|
||||
dbdata:
|
||||
uploads:
|
||||
ntfydata:
|
||||
|
||||
Reference in New Issue
Block a user