From a51017f4c4e0eaff9e36f8872fe79470cd8131b7 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 20 Jul 2026 04:51:43 -0500 Subject: [PATCH 1/2] docs(android): record M7 push-notifications plan (Part 1 backend + docs) Capture the M7 backend/docs breakdown in PLAN.md before the code lands: two event sources / one publisher, the stream catalog + PUBLIC_KINDS-gated mapping, push_devices + notification_subscriptions tables, the /auth/me routes, the SSRF endpoint guard, and the content-free-tickle ntfy service (no publish token). Co-Authored-By: Claude --- android/PLAN.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/android/PLAN.md b/android/PLAN.md index 6a0f77e..e90dc00 100644 --- a/android/PLAN.md +++ b/android/PLAN.md @@ -138,6 +138,46 @@ hard reset (M3) were already in place. Biometric app-lock is **descoped from v1* the first `v*` release tag (once the signing secrets are set + the on-device QA pass is done) and M7 push notifications are what remain.** +### M7 plan — push notifications (in progress) + +M7 spans three repos, so it ships in **two parts**; the backend contract lands first because the app +is a pure consumer of it (§8/§11). + +**Part 1 — `website/` backend + `docs/` (this pass).** Additive, v1-only (new tables/routes/compose +service; no existing response shape changes). Decision: **no ntfy publish token** — publishes go over +the internal compose network to unguessable per-device topics carrying **content-free tickles** +(`{ stream, ref }`); the publisher honors an optional `NTFY_PUBLISH_TOKEN` if ever set but requires +none (keeps §11's zero-interaction promise). +- **Two event sources, one publisher.** The fan-out is a small transport-agnostic + `utils/pushDispatch.js` that both producers call: `utils/shardIngest.js` (`ingest()`, beside the + existing `broadcast(event)`) for shard-derived streams, and the admin create-post path for the + `news.post` stream (§11 lists news posts as a public stream, but they originate in the website, not + the shard feed). +- **Stream catalog** (`config/notificationStreams.js`): public/opt-in — `news.post`, + `server.status`, `idoc.warning`, `champ.start`, `governor.election`; personal/owner-keyed + (require a linked game account) — `vendor.sale`, `house.idoc`, `account.login`. `mapShardEvent()` + maps event kinds → streams, drawing public streams **only** from the SSE `PUBLIC_KINDS` allowlist; + sensitive kinds are never fanned out publicly. Personal events are delivered only to the owning + user's devices, resolved via `shardLinks.getByAccount` (same ownership source as `/player/shard/*`). +- **Tables:** `push_devices` (per-device endpoint) and `notification_subscriptions` (per-user opted-in + streams), FK → `users` ON DELETE CASCADE, mirroring `mobile_refresh_tokens`. +- **Routes** under the role-agnostic self surface (never `/admin`): `POST|GET /auth/me/devices`, + `DELETE /auth/me/devices/:id`, `GET /auth/me/notifications/streams` (catalog), + `GET|PUT /auth/me/notifications/subscriptions`. All bearer/cookie auth; Swagger regenerated. +- **SSRF guard (important):** a device `endpoint` is a client-supplied URL the backend POSTs to, so + registration and every publish validate it is HTTPS and its origin is in the shard's ntfy + allow-set (`NTFY_BASE_URL` / `NTFY_ALLOWED_ORIGINS`), rejecting loopback/private hosts. +- **ntfy** added to `website/docker-compose.yml` as a pinned upstream image with a committed + declarative `./ntfy/server.yml` and named volume, **no published host port** (reached via the + reverse proxy; internal-only for the publisher), anonymous read-write to unguessable topics (no + per-user accounts — safe because tickles are content-free). + +**Part 2 — the Android app (next pass).** UnifiedPush distributor integration, device registration +against `POST /auth/me/devices`, the Notifications settings screen (per-stream toggles; personal +streams greyed until a game account is linked), notification-tap deep-links, and the notification +channel/icon. Built against the Part-1 contract; the app is architected for push from M0 (§11) so it +adds no data-flow change to the existing screens. + **Prerequisite progress (§8):** all v1 prerequisites are **done** (2026-07-19) — ✅ password reset (item 2; website#75 + docs#8), ✅ role-agnostic `/auth/me/*` self surface (item 1; website#76 + docs#10), ✅ version/health surfacing (item 4) and ✅ branding for mobile (item 6). **Push notifications (item 3) -- 2.49.1 From cb10cee6f13c66648ffc1ce059e6bb9b8678df3d Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 20 Jul 2026 05:15:25 -0500 Subject: [PATCH 2/2] docs: M7 push backend contract + status (website#78) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BACKEND_DESIGN.md: push_devices + notification_subscriptions tables (§3), the /auth/me/devices* + /auth/me/notifications/* API rows (§4), a push-notification design + security section (content-free tickles, PUBLIC_KINDS split, owner-keyed personal streams, SSRF endpoint guard, untrusted-relay model), and the ntfy compose service in the deploy section (§8). - PLAN.md: flip M7 Part 1 (backend + docs) to in-review — status line, §8 item 3, §9 M7. Co-Authored-By: Claude --- android/PLAN.md | 10 ++++++- website/BACKEND_DESIGN.md | 57 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/android/PLAN.md b/android/PLAN.md index e90dc00..cadf43f 100644 --- a/android/PLAN.md +++ b/android/PLAN.md @@ -1,6 +1,6 @@ # Android App — Plan -Status: **M0–M6 landed; the functional build, design pass, and release mechanics are complete (cut the v1 tag, then M7 push notifications).** This document is the +Status: **M0–M6 landed; M7 (push notifications) Part 1 — backend + docs — in review (website#78). Remaining: cut the v1 tag, then M7 Part 2 (the app's UnifiedPush integration).** This document is the design contract for the `RunicGateway/Android-app` repo. It was written before implementation so the API changes it depends on could be landed in `website/` and `docs/` first. The authoritative API reference is the committed OpenAPI spec at `website/server/swagger/swagger-output.json` (regenerated @@ -490,6 +490,11 @@ maintenance cost. Reserve v2 for a real breaking re-shape if one ever arises. 3. **Push notifications** — see §11. Additive v1 endpoints under `/auth/me/devices*` and `/auth/me/notifications*`, plus a **self-hosted `ntfy` service added to `website/docker-compose.yml`** with fully declarative, zero-interaction config. Not required for the first release (M7, not M1–M6). + 🚧 **Backend + docs IN REVIEW (2026-07-20, RunicGateway/website#78 (+ this docs PR)).** The + contract Part 1 is built: the two tables, the stream catalog + `PUBLIC_KINDS`-gated event mapping, + the content-free-tickle fan-out (`utils/pushDispatch`, SSRF-guarded endpoints, owner-keyed personal + streams), the six `/auth/me/*` routes (Swagger regenerated), and the declarative `ntfy` compose + service. Full server suite green. The app (Part 2, §9 M7) consumes this next. 4. **Version/health surfacing.** ✅ **DONE (2026-07-19, RunicGateway/website#77 (+ this docs PR)).** A dependency-free `config/version.js` (`{ service:'runic-gateway', api:'v1', server: }`) is surfaced on @@ -552,6 +557,9 @@ push, and Play (M6–M8) follow the designed app. `website/docker-compose.yml` (declarative, zero-interaction config), UnifiedPush integration in the app, device registration, the subscriptions UI, and the content-free-tickle backend fan-out (see §11). The app is built with room for this from M0 but it does not gate the first release. + 🚧 **Part 1 (backend + docs) in review** 2026-07-20 (`RunicGateway/website#78` + this docs PR) — see + the "M7 plan" build-progress block above and §8 item 3. **Part 2 (the app: UnifiedPush, device + registration, subscriptions UI, notification handling) is next.** 9. **M8 — Google Play**: Play Console listing, signing/upload key, and (optionally) an FCM build flavor — after the direct-APK release is stable. diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index 22ac11b..e36a65b 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -156,6 +156,28 @@ Seeded keys: `site_mode` (default `maintenance`), `site_mode_changed_at`, Same "store only the hash of an opaque token" pattern as `user_invites` / `mobile_refresh_tokens`. A DB read never yields a usable reset link. See §4 `/auth/password/*`. +### push_devices — opt-in push endpoints (M7) +| col | type | notes | +|---|---|---| +| id | INT PK AUTO_INCREMENT | | +| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | owner | +| transport | ENUM('unifiedpush','fcm') DEFAULT 'unifiedpush' | UnifiedPush for the sideloaded APK; FCM reserved for a later Play flavor | +| endpoint | VARCHAR(512) NOT NULL | the distributor URL the app's ntfy topic was handed (or an FCM token). Unguessable but **not a secret** — stored in the clear (unlike refresh tokens), because pushes are content-free tickles | +| platform | VARCHAR(40) NULL | free-form label, e.g. `android` | +| created_at / last_seen_at | DATETIME | | + +`UNIQUE(user_id, endpoint)` — re-registering the same endpoint is an idempotent upsert. + +### notification_subscriptions — which streams a user opted into (M7) +| col | type | notes | +|---|---|---| +| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | | +| stream_id | VARCHAR(64) NOT NULL | an id from the catalog (`config/notificationStreams.js`), validated on write | +| created_at | DATETIME | | + +`PRIMARY KEY(user_id, stream_id)`. Subscriptions are per-user (applied to every device); a PUT +replaces the whole set. Nothing is pushed unless the user subscribed. + --- ## 4. API contract @@ -177,6 +199,10 @@ accepts `Authorization: Bearer` for API testing). | PATCH | `/me/account/password` | cookie / bearer (rate-limited) | `{newPassword, currentPassword?}` | change/set own password (current required unless the account has none); revokes other sessions, keeps the caller's | | POST | `/me/account/totp/setup` · `…/enable` · `…/disable` | cookie / bearer | `{code}` on enable/disable | self 2FA enrollment (disable needs a valid current code, not a password) | | GET | `/me/account/identities` · DELETE `…/:provider` | cookie / bearer | — | list / unlink own SSO identities | +| POST | `/me/devices` | cookie / bearer | `{endpoint, transport?, platform?}` | register a push endpoint; **rejects a disallowed endpoint 400** (SSRF guard). Idempotent per (user, endpoint) | +| GET | `/me/devices` · DELETE `…/:id` | cookie / bearer | — | list / unregister own push devices | +| GET | `/me/notifications/streams` | cookie / bearer | — | the subscribable catalog (`personal`/`requiresLinkedAccount` flags) | +| GET · PUT | `/me/notifications/subscriptions` | cookie / bearer | `{streams:[id]}` on PUT | get / replace own opted-in streams (unknown ids dropped) | **Role-agnostic self-service (`/auth/me/*`).** The canonical "me" surface for **every** authenticated role. It reuses the exact `account.controller` handlers as `/player/account/*` and `/admin/account/*` @@ -192,6 +218,26 @@ reset link points at the web front end (`/account/reset/:token`); the Android ap rather than shipping its own reset screen (docs/android/PLAN.md §4.2). First admin is bootstrapped by `seed.js` from env (see §6); further staff are created under `/admin/users` or via email invites. +**Push notifications (M7, opt-in).** The app subscribes per stream (`/auth/me/notifications/*`) and +registers device endpoints (`/auth/me/devices`); nothing is pushed unless subscribed. Delivery is a +**content-free tickle** — `{ stream, ref }`, no sensitive data — POSTed to each subscribed device's +self-hosted **ntfy** endpoint (`utils/pushDispatch`); the app wakes and pulls the real, ownership- +checked content over the authenticated API. Two producers fan out through the one publisher: the shard +ingest dispatcher (`utils/shardIngest`, beside the SSE broadcast) for shard-derived streams, and the +create/publish-post path for `news.post`. The stream catalog + event→stream mapping is +`config/notificationStreams.js`. Security invariants: +- **Same public/admin split as the SSE feed.** Public streams are drawn *only* from the SSE + `PUBLIC_KINDS` allowlist; a sensitive kind (audit/cheat/IP/login-attempt) can never produce a public + push. +- **Personal streams are owner-keyed.** `vendor.sale` / `house.idoc` / `account.login` are delivered + only to the *owning* user's devices, resolved via `shardLinks` (the same ownership check 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 shard's ntfy + allow-set (`NTFY_BASE_URL` / `NTFY_ALLOWED_ORIGINS`). +- ntfy is treated as an **untrusted relay** — no per-user accounts, unguessable topics; an optional + `NTFY_PUBLISH_TOKEN` hardens backend→ntfy publishes but is not required. See docs/android/PLAN.md §11. + ### /public (public.routes.js → public.controller.js) — all GET, no auth | Method | Path | Notes | |---|---|---| @@ -304,7 +350,13 @@ subsystem (`[server]`, `[http]`, `[db]`, `[auth]`, `[admin]`, `[ratelimit]`, … - `app`: builds the Dockerfile (installs client+server, builds Vite, serves via Express), `env_file: .env`, `DB_HOST=db`, `depends_on: db (healthy)`, volume `uploads:/app/uploads`, `ports: "3000:3000"` — **binds 0.0.0.0** (no `127.0.0.1:` prefix) so Pangolin reaches it. -- Volumes: `dbdata`, `uploads`. +- `ntfy` (M7): pinned upstream `binwiederhier/ntfy` image, declarative config only + (`./ntfy/server.yml` mounted `:ro` + `NTFY_BASE_URL`), volume `ntfydata:/var/lib/ntfy`, **no + published host port** — devices reach it via the reverse proxy; the backend publisher reaches it + over the private compose network. Anonymous read-write to unguessable topics (no accounts to + provision) — safe because pushes are content-free tickles. Bringing the stack up provisions a + working push relay with **zero interactive setup**. +- Volumes: `dbdata`, `uploads`, `ntfydata`. Express listens on `0.0.0.0:${PORT||3000}`. Pangolin terminates TLS and proxies to `app`. @@ -326,6 +378,9 @@ ADMIN_USERNAME= ADMIN_PASSWORD= # Email: configured in Admin → Settings → Email (Gmail OAuth2), not via env CLIENT_ORIGIN=http://localhost:5173 +# Push (M7): the ntfy relay URL — also the backend's SSRF allow-set for device +# endpoints. NTFY_ALLOWED_ORIGINS / NTFY_PUBLISH_TOKEN are optional. +NTFY_BASE_URL=https://ntfy.example.com ``` `.gitignore`: `node_modules/`, `.env`, `_reference/`, `client/dist/`, `uploads/`. -- 2.49.1