feat(push): M7 backend — opt-in push notifications via self-hosted ntfy #78
Reference in New Issue
Block a user
No description provided.
Delete Branch "feat/push-notifications-backend"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What & why
Part 1 of M7 — push notifications (docs/android/PLAN.md §11): the backend contract + relay the Android app consumes. The app is a pure consumer, so this lands first. Everything here is additive and v1-only — new tables, new routes, a new compose service; no existing response shape changes.
Companion docs PR: RunicGateway/docs#20 (BACKEND_DESIGN.md + PLAN.md M7 status).
Changes
push_devices(per-device endpoint) +notification_subscriptions(per-user opted-in streams), FK→usersON DELETE CASCADE, mirroringmobile_refresh_tokens.config/notificationStreams.js) — public streams (news.post,server.status,idoc.warning,champ.start,governor.election) are drawn only from the SSEPUBLIC_KINDSallowlist; personal owner-keyed streams (vendor.sale,house.idoc,account.login).champ.update/city.updateare full-state upserts, sochamp.start/governor.electionfire only on a real transition via an injectable tracker.utils/pushDispatch.js) — content-free tickles ({ stream, ref }) POSTed to each subscribed device; never throws (a dead relay can't affect ingest/posting). Two producers:shardIngest.ingest(beside the SSE broadcast) and the create/publish-post path (news.post, riding the existing "newly published news" transition). Personal events resolve to the owner viashardLinks— the same ownership source as/player/shard/*.endpointis a client-supplied URL the server POSTs to, so registration and every publish validate it is HTTPS, non-private/loopback, and (when configured) on theNTFY_BASE_URL/NTFY_ALLOWED_ORIGINSallow-set./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 new paths,PushDevice/NotificationStreams/… schemas; 153→157 paths, none removed).docker-compose.yml— pinned upstream image, declarative./ntfy/server.yml, named volume, no published host port (reached via the reverse proxy; internal-only for the publisher), anonymous unguessable topics (no accounts). No publish token required (content-free design); optionalNTFY_PUBLISH_TOKENhonored.Security notes
Testing
test/pushDispatch.test.js(mapping,PUBLIC_KINDSgate, owner-keying, SSRFisAllowedEndpoint, content-free payload, subscription filtering) +test/notificationsRoutes.test.js(auth gate on all 6 routes).npm run swaggerregenerates cleanly.docker compose configparses with the newntfyservice.AI disclosure
Authored with Claude Code (Claude Opus). AI-authored commits carry a
Co-Authored-By: Claudetrailer per org policy.🤖 Generated with Claude Code
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>