feat(push): M7 backend — opt-in push notifications via self-hosted ntfy #78

Merged
whitlocktech merged 1 commits from feat/push-notifications-backend into main 2026-07-20 15:28:01 +00:00
Member

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

  • Schemapush_devices (per-device endpoint) + notification_subscriptions (per-user opted-in streams), FK→users ON DELETE CASCADE, mirroring mobile_refresh_tokens.
  • Stream catalog + mapping (config/notificationStreams.js) — public streams (news.post, server.status, idoc.warning, champ.start, governor.election) are drawn only from the SSE PUBLIC_KINDS allowlist; personal owner-keyed streams (vendor.sale, house.idoc, account.login). champ.update/city.update are full-state upserts, so champ.start/governor.election 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 (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 via shardLinks — the same ownership source as /player/shard/*.
  • SSRF guard — a device endpoint is a client-supplied URL the server POSTs to, so registration and every publish validate it is HTTPS, non-private/loopback, and (when configured) on the NTFY_BASE_URL/NTFY_ALLOWED_ORIGINS allow-set.
  • 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 new paths, PushDevice/NotificationStreams/… schemas; 153→157 paths, none removed).
  • ntfy service in 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); optional NTFY_PUBLISH_TOKEN honored.

Security notes

  • Same public/admin split as the SSE feed — a sensitive kind (audit/cheat/IP/login-attempt) can never produce a public push.
  • Personal events are owner-keyed — delivered only to the owning user's devices.
  • ntfy is treated as an untrusted relay: leaked topic reveals nothing (content-free); the app pulls real content over the authenticated, ownership-checked API.

Testing

  • New test/pushDispatch.test.js (mapping, PUBLIC_KINDS gate, owner-keying, SSRF isAllowedEndpoint, content-free payload, subscription filtering) + test/notificationsRoutes.test.js (auth gate on all 6 routes).
  • Full suite green: 247 pass / 0 fail. npm run swagger regenerates cleanly.
  • docker compose config parses with the new ntfy service.

AI disclosure

Authored with Claude Code (Claude Opus). AI-authored commits carry a Co-Authored-By: Claude trailer per org policy.

🤖 Generated with Claude Code

## 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 - **Schema** — `push_devices` (per-device endpoint) + `notification_subscriptions` (per-user opted-in streams), FK→`users` ON DELETE CASCADE, mirroring `mobile_refresh_tokens`. - **Stream catalog + mapping** (`config/notificationStreams.js`) — public streams (`news.post`, `server.status`, `idoc.warning`, `champ.start`, `governor.election`) are drawn **only** from the SSE `PUBLIC_KINDS` allowlist; personal owner-keyed streams (`vendor.sale`, `house.idoc`, `account.login`). `champ.update`/`city.update` are full-state upserts, so `champ.start`/`governor.election` 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 (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 via `shardLinks` — the same ownership source as `/player/shard/*`. - **SSRF guard** — a device `endpoint` is a client-supplied URL the server POSTs to, so registration **and** every publish validate it is HTTPS, non-private/loopback, and (when configured) on the `NTFY_BASE_URL`/`NTFY_ALLOWED_ORIGINS` allow-set. - **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 new paths, `PushDevice`/`NotificationStreams`/… schemas; 153→157 paths, none removed). - **ntfy** service in `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); optional `NTFY_PUBLISH_TOKEN` honored. ## Security notes - Same public/admin split as the SSE feed — a sensitive kind (audit/cheat/IP/login-attempt) can never produce a public push. - Personal events are owner-keyed — delivered only to the owning user's devices. - ntfy is treated as an **untrusted relay**: leaked topic reveals nothing (content-free); the app pulls real content over the authenticated, ownership-checked API. ## Testing - New `test/pushDispatch.test.js` (mapping, `PUBLIC_KINDS` gate, owner-keying, SSRF `isAllowedEndpoint`, content-free payload, subscription filtering) + `test/notificationsRoutes.test.js` (auth gate on all 6 routes). - Full suite green: **247 pass / 0 fail**. `npm run swagger` regenerates cleanly. - `docker compose config` parses with the new `ntfy` service. ## AI disclosure Authored with **Claude Code** (Claude Opus). AI-authored commits carry a `Co-Authored-By: Claude` trailer per org policy. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
wtclaude added 1 commit 2026-07-20 10:14:28 +00:00
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
416761f8f7
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>
whitlocktech approved these changes 2026-07-20 15:27:48 +00:00
whitlocktech merged commit 4fa73d3ccf into main 2026-07-20 15:28:01 +00:00
whitlocktech deleted branch feat/push-notifications-backend 2026-07-20 15:28:02 +00:00
Sign in to join this conversation.
No description provided.