From 416761f8f77854a7903ce955847a22e8e6180252 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 20 Jul 2026 05:13:48 -0500 Subject: [PATCH] =?UTF-8?q?feat(push):=20M7=20backend=20=E2=80=94=20opt-in?= =?UTF-8?q?=20push=20notifications=20via=20self-hosted=20ntfy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 16 + docker-compose.yml | 24 + ntfy/server.yml | 31 + server/.env.example | 13 + server/db/schema.sql | 33 + server/src/config/notificationStreams.js | 167 +++++ .../notificationSubs/notificationSubs.db.js | 16 + .../notificationSubs.model.js | 18 + .../src/model/pushDevices/pushDevices.db.js | 51 ++ .../model/pushDevices/pushDevices.model.js | 32 + .../src/router/v1/admin/admin.controller.js | 14 +- server/src/router/v1/auth/auth.routes.js | 6 + .../v1/auth/notifications.controller.js | 86 +++ .../router/v1/auth/notifications.routes.js | 100 +++ server/src/utils/pushDispatch.js | 135 ++++ server/src/utils/shardIngest.js | 8 + server/swagger/swagger-output.json | 688 ++++++++++++++++++ server/swagger/swagger.js | 62 ++ server/test/notificationsRoutes.test.js | 40 + server/test/pushDispatch.test.js | 235 ++++++ server/test/shardIngest.champsPages.test.js | 2 + server/test/shardIngest.protocol2.test.js | 2 + 22 files changed, 1778 insertions(+), 1 deletion(-) create mode 100644 ntfy/server.yml create mode 100644 server/src/config/notificationStreams.js create mode 100644 server/src/model/notificationSubs/notificationSubs.db.js create mode 100644 server/src/model/notificationSubs/notificationSubs.model.js create mode 100644 server/src/model/pushDevices/pushDevices.db.js create mode 100644 server/src/model/pushDevices/pushDevices.model.js create mode 100644 server/src/router/v1/auth/notifications.controller.js create mode 100644 server/src/router/v1/auth/notifications.routes.js create mode 100644 server/src/utils/pushDispatch.js create mode 100644 server/test/notificationsRoutes.test.js create mode 100644 server/test/pushDispatch.test.js diff --git a/.env.example b/.env.example index c932e62..42bbafc 100644 --- a/.env.example +++ b/.env.example @@ -118,3 +118,19 @@ BOT_INTERNAL_KEY=change-me-to-a-long-random-string UOLINK_BASE_URL=http://127.0.0.1:8080 UOLINK_WS_URL=ws://127.0.0.1:8080/ws UOLINK_PROTOCOL=1 + +# ─── Push notifications (M7) — self-hosted ntfy UnifiedPush relay ─── +# The `ntfy` compose service and the backend's push fan-out (opt-in notifications +# for the Android app; docs/android/PLAN.md §11). +# NTFY_BASE_URL Public URL devices reach the relay at (behind the +# reverse proxy). Used BOTH to configure the ntfy service +# AND as the backend's SSRF allow-set — a device may only +# register an endpoint whose origin matches this. +# NTFY_ALLOWED_ORIGINS Optional, comma-separated extra allowed endpoint origins +# (defaults to NTFY_BASE_URL's origin). Set only if devices +# register endpoints on a different host than NTFY_BASE_URL. +# NTFY_PUBLISH_TOKEN Optional. The content-free-tickle design needs NO token; +# set one only to require auth on backend→ntfy publishes. +NTFY_BASE_URL=https://ntfy.example.com +# NTFY_ALLOWED_ORIGINS=https://ntfy.example.com +# NTFY_PUBLISH_TOKEN= diff --git a/docker-compose.yml b/docker-compose.yml index dca9ae5..c7b0a28 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/ntfy/server.yml b/ntfy/server.yml new file mode 100644 index 0000000..db6c9a0 --- /dev/null +++ b/ntfy/server.yml @@ -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: "" diff --git a/server/.env.example b/server/.env.example index 4a5e91a..bb7093f 100644 --- a/server/.env.example +++ b/server/.env.example @@ -97,3 +97,16 @@ BOT_INTERNAL_KEY=dev-only-change-me-bot-key # TOWNCRIER_DURATION_SEC how long the in-game town-crier message stays up (<= 86400) ANNOUNCE_POLL_MS=15000 TOWNCRIER_DURATION_SEC=3600 + +# Push notifications (M7) — opt-in fan-out to the Android app via a self-hosted +# ntfy UnifiedPush relay (docs/android/PLAN.md §11). The publisher POSTs +# content-free tickles to each device's endpoint, so no publish token is required. +# NTFY_BASE_URL Public relay URL; also the backend's SSRF allow-set — a +# device may only register an endpoint on this origin. +# NTFY_ALLOWED_ORIGINS Optional comma-separated extra allowed origins. +# NTFY_PUBLISH_TOKEN Optional bearer token for backend->ntfy publishes (off by default). +# Leave NTFY_BASE_URL unset in local dev to allow any public HTTPS endpoint +# (private/loopback hosts are always rejected). +# NTFY_BASE_URL=https://ntfy.example.com +# NTFY_ALLOWED_ORIGINS= +# NTFY_PUBLISH_TOKEN= diff --git a/server/db/schema.sql b/server/db/schema.sql index 3fbb88a..9edc0d9 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -557,6 +557,39 @@ CREATE TABLE IF NOT EXISTS password_resets ( INDEX idx_password_resets_status (status, expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- ── Push notifications (opt-in) ───────────────────────────────────────────── +-- One row per registered push endpoint (Android/UnifiedPush v1; FCM later). The +-- `endpoint` is the UnifiedPush distributor URL the app's ntfy topic was handed — +-- unguessable but NOT a secret (the security model treats ntfy as an untrusted +-- relay and only ever pushes content-free tickles), so it is stored in the clear, +-- unlike mobile_refresh_tokens. A device belongs to one user; re-registering the +-- same endpoint for the same user is an idempotent upsert (UNIQUE user_id+endpoint). +CREATE TABLE IF NOT EXISTS push_devices ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + transport ENUM('unifiedpush','fcm') NOT NULL DEFAULT 'unifiedpush', + endpoint VARCHAR(512) NOT NULL, -- distributor URL (or FCM token) + platform VARCHAR(40) NULL, -- e.g. 'android' (free-form label) + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_push_devices_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE KEY uq_push_devices_user_endpoint (user_id, endpoint), + INDEX idx_push_devices_user (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Which notification streams a user has opted into. Subscriptions are per-user +-- (applied to every device the user has registered), not per-device. stream_id is +-- an id from the notification catalog (config/notificationStreams.js), validated +-- in the model on write. One row per (user, stream); PUT replaces the whole set. +CREATE TABLE IF NOT EXISTS notification_subscriptions ( + user_id INT NOT NULL, + stream_id VARCHAR(64) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, stream_id), + CONSTRAINT fk_notif_subs_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_notif_subs_stream (stream_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- Discord bot moderation core (Phase 2). These tables are owned by the bot -- process (its own DB pool, bot/src/db.js) — the main server never reads or -- writes them. They live in the same physical database as everything else diff --git a/server/src/config/notificationStreams.js b/server/src/config/notificationStreams.js new file mode 100644 index 0000000..a13c6d9 --- /dev/null +++ b/server/src/config/notificationStreams.js @@ -0,0 +1,167 @@ +// ── Push-notification stream catalog + event → stream mapping ─────────────── +// +// The single source of truth for which streams a user can subscribe to, and how +// a shard event maps onto them. Two families: +// • public / opt-in — no linked game account required; delivered to every +// subscriber. Drawn ONLY from the SSE public allowlist +// (utils/shardBroadcast PUBLIC_KINDS) — a sensitive kind +// can never produce a public push. +// • personal / owner-keyed — require a linked game account; delivered ONLY to +// the owning user's devices (resolved from the event's +// game account via shardLinks), never fanned out publicly. +// +// The payload the relay ever carries is a CONTENT-FREE tickle ({ stream, ref }); +// `ref` is an opaque hint (serial / city / timestamp) the app uses to pull the +// real, ownership-checked content over the authenticated API. So even a leaked +// ntfy topic reveals nothing (docs/android/PLAN.md §11). + +const { PUBLIC_KINDS } = require('../utils/shardBroadcast') + +// The subscribable catalog. `news.post` is produced by the website's own posts +// path (not the shard feed) — see utils/pushDispatch — so it has no mapShardEvent +// case; every other stream is shard-derived below. +const STREAMS = [ + { + id: 'news.post', + label: 'News posts', + description: 'New news / Five-on-Friday / newsletter posts.', + personal: false, + requiresLinkedAccount: false, + }, + { + id: 'server.status', + label: 'Server up / down', + description: 'The shard comes online or goes offline.', + personal: false, + requiresLinkedAccount: false, + }, + { + id: 'idoc.warning', + label: 'IDOC warnings', + description: 'A house falls into its final (IDOC) decay stage.', + personal: false, + requiresLinkedAccount: false, + }, + { + id: 'champ.start', + label: 'Champion spawn starts', + description: 'A champion spawn becomes active.', + personal: false, + requiresLinkedAccount: false, + }, + { + id: 'governor.election', + label: 'Governor elections', + description: 'A town elects a new governor.', + personal: false, + requiresLinkedAccount: false, + }, + { + id: 'vendor.sale', + label: 'Your vendor sold an item', + description: 'One of your player vendors made a sale.', + personal: true, + requiresLinkedAccount: true, + }, + { + id: 'house.idoc', + label: 'Your house entered IDOC', + description: 'One of your houses fell into its final decay stage.', + personal: true, + requiresLinkedAccount: true, + }, + { + id: 'account.login', + label: 'A login to your account', + description: 'An authentication attempt against your game account.', + personal: true, + requiresLinkedAccount: true, + }, +] + +const STREAM_IDS = new Set(STREAMS.map((s) => s.id)) +const isValidStream = (id) => STREAM_IDS.has(id) +const PERSONAL_STREAMS = new Set(STREAMS.filter((s) => s.personal).map((s) => s.id)) + +// Per-process transition state so full-state upserts (champ.update / city.update +// are upserts, not discrete "started"/"elected" events — see docs/link +// PROTOCOL_2 §383) only fire once, on an actual transition. Injectable so tests +// pass a fresh tracker; a module-level default backs the live dispatcher. +function createTracker() { + return { champActive: new Map(), cityGovernor: new Map() } +} +const defaultTracker = createTracker() + +// Map one shard event → an array of targets ({ streamId, ref, ownerAccount? }). +// May yield 0, 1, or 2 targets (an owner house.decay produces both the public +// idoc.warning and the personal house.idoc). Pure given `tracker`. +function mapShardEvent(event, tracker = defaultTracker) { + if (!event || typeof event.kind !== 'string') return [] + const kind = event.kind + const out = [] + + switch (kind) { + case 'server.hello': + out.push({ streamId: 'server.status', ref: `up:${event.bootId || ''}` }) + break + case 'server.shutdown': + case 'server.crashed': + out.push({ streamId: 'server.status', ref: 'down' }) + break + case 'house.decay': { + if (String(event.to).toUpperCase() !== 'IDOC') break + const ref = String(event.serial ?? '') + out.push({ streamId: 'idoc.warning', ref }) // public — location only + if (event.ownerAcct) { + out.push({ streamId: 'house.idoc', ref, ownerAccount: event.ownerAcct }) // personal + } + break + } + case 'champ.update': { + const { serial } = event + if (serial == null) break + const wasActive = tracker.champActive.get(serial) === true + const isActive = event.active === true + tracker.champActive.set(serial, isActive) + if (isActive && !wasActive) out.push({ streamId: 'champ.start', ref: String(serial) }) + break + } + case 'champ.remove': + if (event.serial != null) tracker.champActive.delete(event.serial) + break + case 'city.update': { + const { city } = event + if (!city) break + const gov = event.governor && event.governor.serial != null ? String(event.governor.serial) : null + const prev = tracker.cityGovernor.get(city) + tracker.cityGovernor.set(city, gov) + // Only a real transition to a new governor, and never on first sight + // (prev === undefined) so a reconnect snapshot isn't read as an election. + if (prev !== undefined && gov && gov !== prev) { + out.push({ streamId: 'governor.election', ref: String(city) }) + } + break + } + case 'vendor.sale': + if (event.ownerAcct) { + out.push({ streamId: 'vendor.sale', ref: String(event.t ?? ''), ownerAccount: event.ownerAcct }) + } + break + case 'account.login.attempt': + if (event.acct) { + out.push({ streamId: 'account.login', ref: String(event.t ?? ''), ownerAccount: event.acct }) + } + break + default: + break + } + + // Defense in depth: a PUBLIC (non-personal) target may only ride a public-safe + // kind. Personal targets are owner-keyed and delivered solely to the owner, so + // they are exempt from the public allowlist (that is the whole point of the + // owner-keyed split). This guarantees a sensitive kind can never leak publicly + // even if a future mapping case is added carelessly. + return out.filter((t) => (PERSONAL_STREAMS.has(t.streamId) ? true : PUBLIC_KINDS.has(kind))) +} + +module.exports = { STREAMS, isValidStream, mapShardEvent, createTracker, PERSONAL_STREAMS } diff --git a/server/src/model/notificationSubs/notificationSubs.db.js b/server/src/model/notificationSubs/notificationSubs.db.js new file mode 100644 index 0000000..2a1e866 --- /dev/null +++ b/server/src/model/notificationSubs/notificationSubs.db.js @@ -0,0 +1,16 @@ +const { query } = require('../../utils/db') + +const listByUser = (userId) => + query('SELECT stream_id FROM notification_subscriptions WHERE user_id = ? ORDER BY stream_id', [userId]) + +// Replace the user's whole subscription set in one transaction-ish pass: delete +// all, then insert the new set. Called by PUT — the request body is the complete +// desired set. `streams` is already validated against the catalog by the model. +async function replaceForUser(userId, streams) { + await query('DELETE FROM notification_subscriptions WHERE user_id = ?', [userId]) + for (const streamId of streams) { + await query('INSERT INTO notification_subscriptions (user_id, stream_id) VALUES (?, ?)', [userId, streamId]) + } +} + +module.exports = { listByUser, replaceForUser } diff --git a/server/src/model/notificationSubs/notificationSubs.model.js b/server/src/model/notificationSubs/notificationSubs.model.js new file mode 100644 index 0000000..cff2872 --- /dev/null +++ b/server/src/model/notificationSubs/notificationSubs.model.js @@ -0,0 +1,18 @@ +// Per-user push-notification subscriptions (which streams a user opted into; +// applied to every device they register). The catalog is config/notificationStreams. + +const db = require('./notificationSubs.db') +const { isValidStream } = require('../../config/notificationStreams') + +const getForUser = async (userId) => (await db.listByUser(userId)).map((r) => r.stream_id) + +// Replace the user's subscription set. Ignores unknown ids and de-dupes, so a +// stale client can't create rows for streams that no longer exist. Returns the +// stored (cleaned) set. +async function setForUser(userId, streams) { + const clean = [...new Set((Array.isArray(streams) ? streams : []).filter(isValidStream))] + await db.replaceForUser(userId, clean) + return clean +} + +module.exports = { getForUser, setForUser } diff --git a/server/src/model/pushDevices/pushDevices.db.js b/server/src/model/pushDevices/pushDevices.db.js new file mode 100644 index 0000000..04b8492 --- /dev/null +++ b/server/src/model/pushDevices/pushDevices.db.js @@ -0,0 +1,51 @@ +const { query } = require('../../utils/db') + +const COLS = 'id, user_id, transport, endpoint, platform, created_at, last_seen_at' + +// Register (idempotent upsert on the UNIQUE user_id+endpoint). Re-registering the +// same endpoint refreshes transport/platform and bumps last_seen_at. +async function upsert({ userId, transport, endpoint, platform }) { + await query( + `INSERT INTO push_devices (user_id, transport, endpoint, platform) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE transport = VALUES(transport), platform = VALUES(platform), last_seen_at = CURRENT_TIMESTAMP`, + [userId, transport, endpoint, platform || null], + ) + return getByUserEndpoint(userId, endpoint) +} + +async function getByUserEndpoint(userId, endpoint) { + const rows = await query(`SELECT ${COLS} FROM push_devices WHERE user_id = ? AND endpoint = ? LIMIT 1`, [userId, endpoint]) + return rows[0] || null +} + +const listByUser = (userId) => + query(`SELECT ${COLS} FROM push_devices WHERE user_id = ? ORDER BY created_at DESC`, [userId]) + +async function remove(id, userId) { + const res = await query('DELETE FROM push_devices WHERE id = ? AND user_id = ?', [id, userId]) + return Number(res.affectedRows || 0) +} + +// Endpoints of every device whose user subscribes to `streamId` (public fan-out). +const endpointsForStream = (streamId) => + query( + `SELECT d.endpoint, d.transport + FROM push_devices d + JOIN notification_subscriptions s ON s.user_id = d.user_id + WHERE s.stream_id = ?`, + [streamId], + ) + +// Endpoints of ONE user's devices, only if that user subscribes to `streamId` +// (personal / owner-keyed fan-out). +const endpointsForUserStream = (userId, streamId) => + query( + `SELECT d.endpoint, d.transport + FROM push_devices d + JOIN notification_subscriptions s ON s.user_id = d.user_id + WHERE d.user_id = ? AND s.stream_id = ?`, + [userId, streamId], + ) + +module.exports = { upsert, getByUserEndpoint, listByUser, remove, endpointsForStream, endpointsForUserStream } diff --git a/server/src/model/pushDevices/pushDevices.model.js b/server/src/model/pushDevices/pushDevices.model.js new file mode 100644 index 0000000..f42125a --- /dev/null +++ b/server/src/model/pushDevices/pushDevices.model.js @@ -0,0 +1,32 @@ +// Registered push endpoints (Android/UnifiedPush v1). A device belongs to one +// user; the endpoint is the distributor URL the app's ntfy topic was handed. The +// endpoint is validated (HTTPS + allowed origin) by utils/pushDispatch before it +// is ever stored or published to — see that module's isAllowedEndpoint. + +const db = require('./pushDevices.db') + +function toSafe(row) { + if (!row) return null + return { + id: row.id, + transport: row.transport, + endpoint: row.endpoint, + platform: row.platform || null, + createdAt: row.created_at, + lastSeenAt: row.last_seen_at, + } +} + +const register = async ({ userId, transport, endpoint, platform }) => + toSafe(await db.upsert({ userId, transport, endpoint, platform })) + +const listForUser = async (userId) => (await db.listByUser(userId)).map(toSafe) + +// Returns true if a row was deleted (the device existed and belonged to userId). +const remove = async (id, userId) => (await db.remove(id, userId)) > 0 + +// Fan-out helpers: raw { endpoint, transport } rows (not toSafe-shaped). +const endpointsForStream = (streamId) => db.endpointsForStream(streamId) +const endpointsForUserStream = (userId, streamId) => db.endpointsForUserStream(userId, streamId) + +module.exports = { register, listForUser, remove, endpointsForStream, endpointsForUserStream } diff --git a/server/src/router/v1/admin/admin.controller.js b/server/src/router/v1/admin/admin.controller.js index 9cd18ed..b6d4bff 100644 --- a/server/src/router/v1/admin/admin.controller.js +++ b/server/src/router/v1/admin/admin.controller.js @@ -5,6 +5,7 @@ const users = require('../../../model/users/users.model') const activity = require('../../../model/activity/activity.model') const announceJobs = require('../../../model/announceJobs/announceJobs.model') const newsGump = require('../../../utils/newsGump') +const pushDispatch = require('../../../utils/pushDispatch') const { cleanBody } = require('../../../utils/sanitizeHtml') const log = require('../../../utils/logger')('admin') @@ -22,12 +23,23 @@ const log = require('../../../utils/logger')('admin') // (post.announce_job_id) reliable against rapid double-publishes. Still guarded: // enqueueIfNeeded swallows its own errors, so a pipeline hiccup can't break save. async function announceIfNewlyPublished(post, transition) { - await announceJobs.enqueueIfNeeded(post, transition) + // enqueueIfNeeded returns a truthy job id EXACTLY on a real transition into + // published news (and null on an edit/re-publish or a hiccup) — reuse that as + // the single "newly published news" signal for the push too, so we never + // double-fire on edits or replicate the transition logic. + const jobId = await announceJobs.enqueueIfNeeded(post, transition) // Keep the in-game Town Cryer News gump in sync with the same transition: push // the article when it becomes published news, refresh it silently on an edit, // and pull it when it leaves published-news. Best-effort (never throws), so a // sidecar hiccup never breaks saving a post — same guarantee as the enqueue. await newsGump.syncPost(post, transition) + // Opt-in push tickle to news.post subscribers, on the same transition. + // Fire-and-forget + self-guarding, so a dead ntfy relay never breaks saving. + if (jobId) { + Promise.resolve(pushDispatch.publish('news.post', { ref: String(post.id) })).catch((err) => + log.warn('news push failed', { postId: post.id, message: err.message }), + ) + } } // ── Dashboard & site mode ───────────────────────────────────────────── diff --git a/server/src/router/v1/auth/auth.routes.js b/server/src/router/v1/auth/auth.routes.js index e3cb027..3775e66 100644 --- a/server/src/router/v1/auth/auth.routes.js +++ b/server/src/router/v1/auth/auth.routes.js @@ -17,6 +17,7 @@ const validate = require('../../../middleware/validate') const mobileRouter = require('./mobile.routes') const ssoRouter = require('./sso.routes') const meRouter = require('./me.routes') +const notifRouter = require('./notifications.routes') const authRouter = express.Router() @@ -34,6 +35,11 @@ authRouter.use(ssoRouter) // free route, so /me falls through to its own handler). authRouter.use('/me', meRouter) +// Push-notification self-service — /auth/me/devices*, /auth/me/notifications/*. +// A second sub-router at /me (Express allows multiple), same requireAuth gate, +// keeping the notification surface separate from the account/identity handlers. +authRouter.use('/me', notifRouter) + // Login protection order (cheapest rejection first): // backoffGuard → per-IP exponential lockout on repeated failures // slowLogin → progressive per-request delay within the window diff --git a/server/src/router/v1/auth/notifications.controller.js b/server/src/router/v1/auth/notifications.controller.js new file mode 100644 index 0000000..67a3eee --- /dev/null +++ b/server/src/router/v1/auth/notifications.controller.js @@ -0,0 +1,86 @@ +// Self-service push-notification management for the logged-in user (any role). +// Mounted under /auth/me behind requireAuth, so req.user is the fresh DB row. +// Devices (endpoints) and stream subscriptions live here; the fan-out that +// actually delivers is utils/pushDispatch. See docs/android/PLAN.md §11. + +const pushDevices = require('../../../model/pushDevices/pushDevices.model') +const notificationSubs = require('../../../model/notificationSubs/notificationSubs.model') +const { STREAMS } = require('../../../config/notificationStreams') +const { isAllowedEndpoint } = require('../../../utils/pushDispatch') + +const log = require('../../../utils/logger')('notifications') + +// POST /auth/me/devices — register (or refresh) a push endpoint for this user. +async function registerDevice(req, res) { + const { transport = 'unifiedpush', endpoint, platform } = req.body + // SSRF guard: the endpoint is a URL the server will later POST to. Reject + // anything that isn't an allowed HTTPS relay origin before storing it. + if (!isAllowedEndpoint(endpoint)) { + return res.status(400).json({ message: 'Endpoint is not an allowed push URL' }) + } + try { + const device = await pushDevices.register({ userId: req.user.id, transport, endpoint, platform }) + return res.status(201).json(device) + } catch (err) { + log.error('registerDevice', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /auth/me/devices — this user's registered devices. +async function listDevices(req, res) { + try { + return res.json(await pushDevices.listForUser(req.user.id)) + } catch (err) { + log.error('listDevices', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// DELETE /auth/me/devices/:id — unregister a device (must belong to the caller). +async function removeDevice(req, res) { + try { + const ok = await pushDevices.remove(Number(req.params.id), req.user.id) + if (!ok) return res.status(404).json({ message: 'Not found' }) + return res.json({ ok: true }) + } catch (err) { + log.error('removeDevice', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /auth/me/notifications/streams — the subscribable catalog (static). +function getStreams(req, res) { + return res.json({ streams: STREAMS }) +} + +// GET /auth/me/notifications/subscriptions — the caller's opted-in stream ids. +async function getSubscriptions(req, res) { + try { + return res.json({ streams: await notificationSubs.getForUser(req.user.id) }) + } catch (err) { + log.error('getSubscriptions', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// PUT /auth/me/notifications/subscriptions — replace the caller's stream set. +// Unknown ids are dropped; the stored (cleaned) set is echoed back. +async function putSubscriptions(req, res) { + try { + const streams = await notificationSubs.setForUser(req.user.id, req.body.streams) + return res.json({ streams }) + } catch (err) { + log.error('putSubscriptions', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +module.exports = { + registerDevice, + listDevices, + removeDevice, + getStreams, + getSubscriptions, + putSubscriptions, +} diff --git a/server/src/router/v1/auth/notifications.routes.js b/server/src/router/v1/auth/notifications.routes.js new file mode 100644 index 0000000..91e44c6 --- /dev/null +++ b/server/src/router/v1/auth/notifications.routes.js @@ -0,0 +1,100 @@ +// ── Push-notification self-service under /auth/me ────────────────────────── +// +// Device registration + per-user stream subscriptions for the app's opt-in push +// (docs/android/PLAN.md §11). Mounted at /me by auth.routes.js alongside +// me.routes.js, behind requireAuth ONLY (role-agnostic — every authenticated +// role manages its own devices/subscriptions), and noindex. The app calls these +// and never touches /admin. + +const express = require('express') +const { body, param } = require('express-validator') + +const notif = require('./notifications.controller') +const { requireAuth } = require('../../../auth/session.middleware') +const noindex = require('../../../middleware/noindex') +const validate = require('../../../middleware/validate') + +const notifRouter = express.Router() + +notifRouter.use(noindex, requireAuth) + +// ── Devices ──────────────────────────────────────────────────────────────── +notifRouter.post( + '/devices', + // #swagger.tags = ['Auth · Me'] + // #swagger.summary = 'Register a push device (endpoint) for the current user' + // #swagger.description = 'Registers a UnifiedPush/ntfy endpoint (or an FCM token) so the backend can deliver opt-in push tickles. The endpoint must be an allowed HTTPS relay URL — private/loopback hosts and non-allowed origins are rejected 400. Idempotent per (user, endpoint).' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RegisterDeviceRequest" } } } } */ + /* #swagger.responses[201] = { description: 'Device registered', content: { "application/json": { schema: { $ref: "#/components/schemas/PushDevice" } } } } */ + /* #swagger.responses[400] = { description: 'Validation error or disallowed endpoint', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + body('endpoint').isString().trim().isURL({ protocols: ['https'], require_protocol: true }).isLength({ max: 512 }), + body('transport').optional().isIn(['unifiedpush', 'fcm']), + body('platform').optional({ values: 'falsy' }).isString().isLength({ max: 40 }), + validate, + notif.registerDevice, +) + +notifRouter.get( + '/devices', + // #swagger.tags = ['Auth · Me'] + // #swagger.summary = 'List the current user’s registered push devices' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Registered devices', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/PushDevice" } } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + notif.listDevices, +) + +notifRouter.delete( + '/devices/:id', + // #swagger.tags = ['Auth · Me'] + // #swagger.summary = 'Unregister a push device' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Device id (must belong to the caller).' } + /* #swagger.responses[200] = { description: 'Unregistered', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */ + /* #swagger.responses[404] = { description: 'No such device for this user', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt({ min: 1 }), + validate, + notif.removeDevice, +) + +// ── Streams catalog + subscriptions ───────────────────────────────────────── +notifRouter.get( + '/notifications/streams', + // #swagger.tags = ['Auth · Me'] + // #swagger.summary = 'List subscribable notification streams (catalog)' + // #swagger.description = 'The catalog of push streams. `personal`/`requiresLinkedAccount` streams are delivered only to the owning user and need a linked game account.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Stream catalog', content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationStreams" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + notif.getStreams, +) + +notifRouter.get( + '/notifications/subscriptions', + // #swagger.tags = ['Auth · Me'] + // #swagger.summary = 'Get the current user’s notification subscriptions' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Subscribed stream ids', content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationSubscriptions" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + notif.getSubscriptions, +) + +notifRouter.put( + '/notifications/subscriptions', + // #swagger.tags = ['Auth · Me'] + // #swagger.summary = 'Replace the current user’s notification subscriptions' + // #swagger.description = 'Sets the full opted-in stream set (applied to all the user’s devices). Unknown stream ids are ignored; the stored set is echoed back.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationSubscriptions" } } } } */ + /* #swagger.responses[200] = { description: 'Updated subscriptions', content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationSubscriptions" } } } } */ + /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + body('streams').isArray(), + body('streams.*').isString().isLength({ max: 64 }), + validate, + notif.putSubscriptions, +) + +module.exports = notifRouter diff --git a/server/src/utils/pushDispatch.js b/server/src/utils/pushDispatch.js new file mode 100644 index 0000000..9a4a47b --- /dev/null +++ b/server/src/utils/pushDispatch.js @@ -0,0 +1,135 @@ +// ── Push-notification fan-out (content-free tickles) ─────────────────────── +// +// The transport-agnostic publisher that turns an event into opt-in push +// notifications. Two producers call in: +// • utils/shardIngest.js → fromShardEvent(event) for shard-derived streams +// (beside the existing SSE broadcast — same event source, same allowlist). +// • the admin create/publish-post path → publish('news.post', …). +// +// What actually leaves the server is a CONTENT-FREE tickle — `{ stream, ref }`, +// no sensitive data — POSTed to each subscribed device's UnifiedPush/ntfy +// endpoint. The app wakes and PULLS the real content over the authenticated, +// ownership-checked API. So ntfy is treated as an untrusted relay: a leaked topic +// reveals nothing, which is what lets it run with no per-user accounts +// (docs/android/PLAN.md §11). +// +// SECURITY: a device `endpoint` is a client-supplied URL the server makes +// server-side POSTs to — a classic SSRF vector. isAllowedEndpoint() gates every +// registration AND every publish: HTTPS only, never a private/loopback host, and +// (when configured) the origin must be in the shard's ntfy allow-set. + +const shardLinks = require('../model/shardLinks/shardLinks.model') +const pushDevicesModel = require('../model/pushDevices/pushDevices.model') +const { mapShardEvent } = require('../config/notificationStreams') +const log = require('./logger')('push-dispatch') + +const TIMEOUT_MS = 5000 + +// Hosts that must never be POSTed to, even if the allow-set is empty (dev). This +// is a coarse literal check (no DNS resolution) — the real protection in prod is +// the configured allow-set below, which pins the single ntfy origin. +const PRIVATE_HOST = + /^(localhost|127\.|0\.0\.0\.0|10\.|192\.168\.|169\.254\.|::1|fc00:|fd00:|172\.(1[6-9]|2\d|3[01])\.)/i + +function toOrigin(u) { + try { + return new URL(u).origin + } catch { + return null + } +} + +// Allowed publish origins, from NTFY_ALLOWED_ORIGINS (comma-separated) or, failing +// that, NTFY_BASE_URL's origin. Empty when neither is set (dev fallback). +function allowedOrigins() { + const raw = process.env.NTFY_ALLOWED_ORIGINS || process.env.NTFY_BASE_URL || '' + return raw + .split(',') + .map((s) => toOrigin(s.trim())) + .filter(Boolean) +} + +// Is this endpoint safe to POST to? HTTPS + non-private host + (if an allow-set is +// configured) an allowed origin. With no allow-set (dev), any public HTTPS host is +// permitted; the private-host check still blocks the obvious SSRF targets. +function isAllowedEndpoint(endpoint) { + let url + try { + url = new URL(String(endpoint)) + } catch { + return false + } + if (url.protocol !== 'https:') return false + if (PRIVATE_HOST.test(url.hostname)) return false + const allow = allowedOrigins() + if (allow.length === 0) return true + return allow.includes(url.origin) +} + +async function postTickle(endpoint, bodyStr, deps) { + const doFetch = deps.fetchImpl || fetch + if (!isAllowedEndpoint(endpoint)) { + log.warn('skipping push to disallowed endpoint', { endpoint }) + return + } + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS) + try { + const headers = { 'Content-Type': 'application/json' } + const token = process.env.NTFY_PUBLISH_TOKEN + if (token) headers.Authorization = `Bearer ${token}` + await doFetch(endpoint, { method: 'POST', headers, body: bodyStr, signal: controller.signal }) + } catch (err) { + log.warn('push tickle failed', { message: err.message }) + } finally { + clearTimeout(timeout) + } +} + +// Publish one content-free tickle. Public (ownerUserId absent) → every device +// whose user subscribes to the stream. Personal (ownerUserId set) → only that +// user's devices, and only if subscribed. Never throws. +async function publish(streamId, { ref, ownerUserId } = {}, deps = {}) { + const devices = deps.pushDevices || pushDevicesModel + let rows + try { + rows = + ownerUserId != null + ? await devices.endpointsForUserStream(ownerUserId, streamId) + : await devices.endpointsForStream(streamId) + } catch (err) { + log.warn('push endpoint lookup failed', { streamId, message: err.message }) + return + } + if (!rows || rows.length === 0) return + const bodyStr = JSON.stringify({ stream: streamId, ref: ref ?? null }) + await Promise.all(rows.map((r) => postTickle(r.endpoint, bodyStr, deps))) +} + +// Fan a shard event out to push. Resolves personal (owner-keyed) targets to the +// owning website user via shardLinks (an unlinked account → nobody to notify). +// Never throws — a dead relay must never affect ingest. +async function fromShardEvent(event, deps = {}) { + const links = deps.shardLinks || shardLinks + const targets = mapShardEvent(event, deps.tracker) + for (const t of targets) { + try { + if (t.ownerAccount) { + let owner = null + try { + owner = await links.getByAccount(t.ownerAccount) + } catch { + owner = null + } + if (!owner || owner.userId == null) continue + await publish(t.streamId, { ref: t.ref, ownerUserId: owner.userId }, deps) + } else { + await publish(t.streamId, { ref: t.ref }, deps) + } + } catch (err) { + log.warn('push dispatch target failed', { streamId: t.streamId, message: err.message }) + } + } +} + +module.exports = { publish, fromShardEvent, isAllowedEndpoint } diff --git a/server/src/utils/shardIngest.js b/server/src/utils/shardIngest.js index f27b6a1..7c06425 100644 --- a/server/src/utils/shardIngest.js +++ b/server/src/utils/shardIngest.js @@ -17,6 +17,7 @@ const shardStateModel = require('../model/shardState/shardState.model') const shardLinksModel = require('../model/shardLinks/shardLinks.model') const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model') const broadcaster = require('./shardBroadcast') +const pushDispatch = require('./pushDispatch') const defaultLog = require('./logger')('shard-ingest') // Notable kinds appended to the shard_events log. High-frequency/session kinds @@ -194,6 +195,7 @@ async function ingest(event, deps = {}) { shardLinks: deps.shardLinks || shardLinksModel, uoLinkConfig: deps.uoLinkConfig || uoLinkConfigModel, broadcast: deps.broadcast || broadcaster.broadcast, + pushDispatch: deps.pushDispatch || pushDispatch.fromShardEvent, log: deps.log || defaultLog, } @@ -226,6 +228,12 @@ async function ingest(event, deps = {}) { } catch (err) { d.log.warn('broadcast failed', { kind: event.kind, message: err.message }) } + // Opt-in push fan-out, off the same event source as the SSE broadcast. + // Fire-and-forget (a slow/dead ntfy relay must never delay or fail ingest); + // fromShardEvent is self-guarding, but .catch() covers any lookup rejection. + Promise.resolve(d.pushDispatch(event, { shardLinks: d.shardLinks })).catch((err) => + d.log.warn('push dispatch failed', { kind: event.kind, message: err.message }), + ) } return { logged, stored } diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 529a50b..dc63df9 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -1631,6 +1631,332 @@ ] } }, + "/api/v1/auth/me/devices": { + "post": { + "tags": [ + "Auth · Me" + ], + "summary": "Register a push device (endpoint) for the current user", + "description": "Registers a UnifiedPush/ntfy endpoint (or an FCM token) so the backend can deliver opt-in push tickles. The endpoint must be an allowed HTTPS relay URL — private/loopback hosts and non-allowed origins are rejected 400. Idempotent per (user, endpoint).", + "responses": { + "201": { + "description": "Device registered", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PushDevice" + } + } + } + }, + "400": { + "description": "Validation error or disallowed endpoint", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterDeviceRequest" + } + } + } + } + }, + "get": { + "tags": [ + "Auth · Me" + ], + "summary": "List the current user’s registered push devices", + "description": "", + "responses": { + "200": { + "description": "Registered devices", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PushDevice" + } + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/auth/me/devices/{id}": { + "delete": { + "tags": [ + "Auth · Me" + ], + "summary": "Unregister a push device", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Device id (must belong to the caller)." + } + ], + "responses": { + "200": { + "description": "Unregistered", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkFlag" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "No such device for this user", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/auth/me/notifications/streams": { + "get": { + "tags": [ + "Auth · Me" + ], + "summary": "List subscribable notification streams (catalog)", + "description": "The catalog of push streams. `personal`/`requiresLinkedAccount` streams are delivered only to the owning user and need a linked game account.", + "responses": { + "200": { + "description": "Stream catalog", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationStreams" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/auth/me/notifications/subscriptions": { + "get": { + "tags": [ + "Auth · Me" + ], + "summary": "Get the current user’s notification subscriptions", + "description": "", + "responses": { + "200": { + "description": "Subscribed stream ids", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationSubscriptions" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "tags": [ + "Auth · Me" + ], + "summary": "Replace the current user’s notification subscriptions", + "description": "Sets the full opted-in stream set (applied to all the user’s devices). Unknown stream ids are ignored; the stored set is echoed back.", + "responses": { + "200": { + "description": "Updated subscriptions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationSubscriptions" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationSubscriptions" + } + } + } + } + } + }, "/api/v1/public/settings": { "get": { "tags": [ @@ -12756,6 +13082,368 @@ } } }, + "RegisterDeviceRequest": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "required": { + "type": "array", + "example": [ + "endpoint" + ], + "items": { + "type": "string" + } + }, + "properties": { + "type": "object", + "properties": { + "endpoint": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "uri" + }, + "description": { + "type": "string", + "example": "The UnifiedPush/ntfy endpoint URL the distributor handed the app (or an FCM token). Must be an allowed HTTPS relay origin — private/loopback hosts are rejected." + }, + "example": { + "type": "string", + "example": "https://ntfy.example.com/UP0a1b2c3d4e5f" + } + } + }, + "transport": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "unifiedpush", + "fcm" + ], + "items": { + "type": "string" + } + }, + "default": { + "type": "string", + "example": "unifiedpush" + }, + "example": { + "type": "string", + "example": "unifiedpush" + } + } + }, + "platform": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "maxLength": { + "type": "number", + "example": 40 + }, + "example": { + "type": "string", + "example": "android" + } + } + } + } + } + } + }, + "PushDevice": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 7 + } + } + }, + "transport": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "unifiedpush", + "fcm" + ], + "items": { + "type": "string" + } + }, + "example": { + "type": "string", + "example": "unifiedpush" + } + } + }, + "endpoint": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "https://ntfy.example.com/UP0a1b2c3d4e5f" + } + } + }, + "platform": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "android" + } + } + }, + "createdAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + }, + "lastSeenAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + } + } + } + } + }, + "NotificationStream": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "One subscribable push stream from the catalog." + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "idoc.warning" + } + } + }, + "label": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "IDOC warnings" + } + } + }, + "description": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "A house falls into its final (IDOC) decay stage." + } + } + }, + "personal": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "Owner-keyed — delivered only to the owning user, never fanned out publicly." + }, + "example": { + "type": "boolean", + "example": false + } + } + }, + "requiresLinkedAccount": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "The stream needs a linked game account (personal streams)." + }, + "example": { + "type": "boolean", + "example": false + } + } + } + } + } + } + }, + "NotificationStreams": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "streams": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/NotificationStream" + } + } + } + } + } + } + }, + "NotificationSubscriptions": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "The set of stream ids the user has opted into (used for both GET and PUT)." + }, + "properties": { + "type": "object", + "properties": { + "streams": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "example": { + "type": "array", + "example": [ + "news.post", + "idoc.warning", + "vendor.sale" + ], + "items": { + "type": "string" + } + } + } + } + } + } + } + }, "Appeal": { "type": "object", "properties": { diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index b25f659..879d0ad 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -422,6 +422,68 @@ const doc = { type: 'object', properties: { ok: { type: 'boolean', example: true } }, }, + // ── Push notifications (M7) ───────────────────────────────────────────── + RegisterDeviceRequest: { + type: 'object', + required: ['endpoint'], + properties: { + endpoint: { + type: 'string', + format: 'uri', + description: 'The UnifiedPush/ntfy endpoint URL the distributor handed the app (or an FCM token). Must be an allowed HTTPS relay origin — private/loopback hosts are rejected.', + example: 'https://ntfy.example.com/UP0a1b2c3d4e5f', + }, + transport: { type: 'string', enum: ['unifiedpush', 'fcm'], default: 'unifiedpush', example: 'unifiedpush' }, + platform: { type: 'string', nullable: true, maxLength: 40, example: 'android' }, + }, + }, + PushDevice: { + type: 'object', + properties: { + id: { type: 'integer', example: 7 }, + transport: { type: 'string', enum: ['unifiedpush', 'fcm'], example: 'unifiedpush' }, + endpoint: { type: 'string', example: 'https://ntfy.example.com/UP0a1b2c3d4e5f' }, + platform: { type: 'string', nullable: true, example: 'android' }, + createdAt: { type: 'string', format: 'date-time' }, + lastSeenAt: { type: 'string', format: 'date-time' }, + }, + }, + NotificationStream: { + type: 'object', + description: 'One subscribable push stream from the catalog.', + properties: { + id: { type: 'string', example: 'idoc.warning' }, + label: { type: 'string', example: 'IDOC warnings' }, + description: { type: 'string', example: 'A house falls into its final (IDOC) decay stage.' }, + personal: { + type: 'boolean', + description: 'Owner-keyed — delivered only to the owning user, never fanned out publicly.', + example: false, + }, + requiresLinkedAccount: { + type: 'boolean', + description: 'The stream needs a linked game account (personal streams).', + example: false, + }, + }, + }, + NotificationStreams: { + type: 'object', + properties: { + streams: { type: 'array', items: { $ref: '#/components/schemas/NotificationStream' } }, + }, + }, + NotificationSubscriptions: { + type: 'object', + description: 'The set of stream ids the user has opted into (used for both GET and PUT).', + properties: { + streams: { + type: 'array', + items: { type: 'string' }, + example: ['news.post', 'idoc.warning', 'vendor.sale'], + }, + }, + }, // ── Moderation appeals (Phase 6c/6d) ──────────────────────────────────── Appeal: { type: 'object', diff --git a/server/test/notificationsRoutes.test.js b/server/test/notificationsRoutes.test.js new file mode 100644 index 0000000..a1b2b34 --- /dev/null +++ b/server/test/notificationsRoutes.test.js @@ -0,0 +1,40 @@ +// Point the DB at a closed port BEFORE requiring anything that builds the pool — +// these cases reject at requireAuth (no session token) before any query runs. +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, after } = require('node:test') +const assert = require('node:assert/strict') + +const { startApp } = require('./_helper') +const authRouter = require('../src/router/v1/auth/auth.routes') +const db = require('../src/utils/db') + +after(() => db.close()) + +// The push-notification self surface (/auth/me/devices*, /auth/me/notifications/*) +// must be mounted AND gated: an unauthenticated caller gets 401 on every route — +// never 404 (route missing) and never 200 (gate bypassed). +test('/auth/me push routes reject unauthenticated callers with 401', async () => { + const app = await startApp((a) => a.use('/api/v1/auth', authRouter)) + try { + const calls = [ + ['GET', '/api/v1/auth/me/devices'], + ['POST', '/api/v1/auth/me/devices', { endpoint: 'https://ntfy.example.com/UPabc' }], + ['DELETE', '/api/v1/auth/me/devices/1'], + ['GET', '/api/v1/auth/me/notifications/streams'], + ['GET', '/api/v1/auth/me/notifications/subscriptions'], + ['PUT', '/api/v1/auth/me/notifications/subscriptions', { streams: ['news.post'] }], + ] + for (const [method, path, body] of calls) { + const res = await fetch(app.url + path, { + method, + headers: body ? { 'Content-Type': 'application/json' } : {}, + body: body ? JSON.stringify(body) : undefined, + }) + assert.equal(res.status, 401, `${method} ${path} should be 401, got ${res.status}`) + } + } finally { + await app.close() + } +}) diff --git a/server/test/pushDispatch.test.js b/server/test/pushDispatch.test.js new file mode 100644 index 0000000..e0ac441 --- /dev/null +++ b/server/test/pushDispatch.test.js @@ -0,0 +1,235 @@ +// Point the DB at a closed port before requiring anything that builds the pool — +// these tests inject fake models, so no real query should ever run. +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, after } = require('node:test') +const assert = require('node:assert/strict') + +const { mapShardEvent, createTracker } = require('../src/config/notificationStreams') +const pushDispatch = require('../src/utils/pushDispatch') +const db = require('../src/utils/db') + +after(() => db.close()) + +// Run body with env keys set, then restore prior values. +function withEnv(vars, fn) { + const prior = {} + for (const [k, v] of Object.entries(vars)) { + prior[k] = process.env[k] + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + try { + return fn() + } finally { + for (const [k, v] of Object.entries(prior)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + } +} + +// ── mapShardEvent ─────────────────────────────────────────────────────────── + +test('server.hello / shutdown / crashed map to the public server.status stream', () => { + const t = createTracker() + assert.deepEqual(mapShardEvent({ kind: 'server.hello', bootId: 'b1' }, t), [ + { streamId: 'server.status', ref: 'up:b1' }, + ]) + assert.deepEqual(mapShardEvent({ kind: 'server.shutdown' }, t), [{ streamId: 'server.status', ref: 'down' }]) + assert.deepEqual(mapShardEvent({ kind: 'server.crashed' }, t), [{ streamId: 'server.status', ref: 'down' }]) +}) + +test('house.decay INTO idoc yields the public idoc.warning AND the owner-keyed house.idoc', () => { + const t = createTracker() + const out = mapShardEvent({ kind: 'house.decay', to: 'IDOC', serial: '0x40', ownerAcct: 'bob' }, t) + assert.deepEqual(out, [ + { streamId: 'idoc.warning', ref: '0x40' }, + { streamId: 'house.idoc', ref: '0x40', ownerAccount: 'bob' }, + ]) + // A non-IDOC decay stage produces nothing. + assert.deepEqual(mapShardEvent({ kind: 'house.decay', to: 'Fairly', serial: '0x41' }, t), []) +}) + +test('champ.update fires champ.start only on the inactive→active transition', () => { + const t = createTracker() + // First sight active → start. + assert.deepEqual(mapShardEvent({ kind: 'champ.update', serial: 'c1', active: true }, t), [ + { streamId: 'champ.start', ref: 'c1' }, + ]) + // Still active → no re-fire. + assert.deepEqual(mapShardEvent({ kind: 'champ.update', serial: 'c1', active: true }, t), []) + // Goes inactive, then active again → fires again. + assert.deepEqual(mapShardEvent({ kind: 'champ.update', serial: 'c1', active: false }, t), []) + assert.deepEqual(mapShardEvent({ kind: 'champ.update', serial: 'c1', active: true }, t), [ + { streamId: 'champ.start', ref: 'c1' }, + ]) +}) + +test('city.update fires governor.election only on a real governor change, never on first sight', () => { + const t = createTracker() + // First sight of the city → no election (could be a reconnect snapshot). + assert.deepEqual(mapShardEvent({ kind: 'city.update', city: 'Britain', governor: { serial: '0x1' } }, t), []) + // Same governor → nothing. + assert.deepEqual(mapShardEvent({ kind: 'city.update', city: 'Britain', governor: { serial: '0x1' } }, t), []) + // New governor → election. + assert.deepEqual(mapShardEvent({ kind: 'city.update', city: 'Britain', governor: { serial: '0x2' } }, t), [ + { streamId: 'governor.election', ref: 'Britain' }, + ]) +}) + +test('personal streams are owner-keyed and sensitive kinds never yield a public target', () => { + const t = createTracker() + const sale = mapShardEvent({ kind: 'vendor.sale', ownerAcct: 'bob', t: 7 }, t) + assert.deepEqual(sale, [{ streamId: 'vendor.sale', ref: '7', ownerAccount: 'bob' }]) + + const login = mapShardEvent({ kind: 'account.login.attempt', acct: 'bob', ip: '1.2.3.4', t: 9 }, t) + assert.deepEqual(login, [{ streamId: 'account.login', ref: '9', ownerAccount: 'bob' }]) + + // Every personal target carries an ownerAccount (never a bare public push). + for (const target of [...sale, ...login]) assert.ok(target.ownerAccount, 'personal target must be owner-keyed') + + // A truly sensitive, unmapped kind produces nothing at all. + assert.deepEqual(mapShardEvent({ kind: 'cheat.fastwalk', acct: 'bob' }, t), []) + assert.deepEqual(mapShardEvent({ kind: 'admin.audit', actor: 'staff' }, t), []) +}) + +// ── isAllowedEndpoint (SSRF guard) ────────────────────────────────────────── + +test('isAllowedEndpoint pins the configured ntfy origin and rejects everything else', () => { + withEnv({ NTFY_BASE_URL: 'https://ntfy.example.com', NTFY_ALLOWED_ORIGINS: undefined }, () => { + assert.equal(pushDispatch.isAllowedEndpoint('https://ntfy.example.com/UPabc'), true) + assert.equal(pushDispatch.isAllowedEndpoint('https://evil.example.com/x'), false) // wrong origin + assert.equal(pushDispatch.isAllowedEndpoint('http://ntfy.example.com/x'), false) // not https + assert.equal(pushDispatch.isAllowedEndpoint('https://127.0.0.1/x'), false) // loopback + assert.equal(pushDispatch.isAllowedEndpoint('not a url'), false) + }) +}) + +test('isAllowedEndpoint (no allow-set) permits any public https host but blocks private/loopback/http', () => { + withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, () => { + assert.equal(pushDispatch.isAllowedEndpoint('https://relay.somehost.net/UPabc'), true) + assert.equal(pushDispatch.isAllowedEndpoint('http://relay.somehost.net/x'), false) + assert.equal(pushDispatch.isAllowedEndpoint('https://10.0.0.5/x'), false) + assert.equal(pushDispatch.isAllowedEndpoint('https://192.168.1.10/x'), false) + assert.equal(pushDispatch.isAllowedEndpoint('https://localhost/x'), false) + }) +}) + +// ── publish ───────────────────────────────────────────────────────────────── + +function captureFetch() { + const calls = [] + return { + calls, + fetchImpl: async (url, opts) => { + calls.push({ url, opts }) + return { ok: true, status: 200 } + }, + } +} + +test('publish sends a content-free tickle to public subscribers', async () => { + await withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, async () => { + const { calls, fetchImpl } = captureFetch() + const streamCalls = [] + const pushDevices = { + endpointsForStream: async (s) => { + streamCalls.push(s) + return [{ endpoint: 'https://relay.test/UPa', transport: 'unifiedpush' }] + }, + endpointsForUserStream: async () => [], + } + await pushDispatch.publish('news.post', { ref: '5' }, { pushDevices, fetchImpl }) + assert.deepEqual(streamCalls, ['news.post']) + assert.equal(calls.length, 1) + assert.equal(calls[0].url, 'https://relay.test/UPa') + assert.equal(calls[0].opts.method, 'POST') + assert.deepEqual(JSON.parse(calls[0].opts.body), { stream: 'news.post', ref: '5' }) + }) +}) + +test('publish (personal) targets only the owner’s subscribed devices', async () => { + await withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, async () => { + const { calls, fetchImpl } = captureFetch() + const userStreamCalls = [] + const pushDevices = { + endpointsForStream: async () => { + throw new Error('public path must not be used for a personal publish') + }, + endpointsForUserStream: async (userId, s) => { + userStreamCalls.push([userId, s]) + return [{ endpoint: 'https://relay.test/UPb' }] + }, + } + await pushDispatch.publish('vendor.sale', { ref: '9', ownerUserId: 42 }, { pushDevices, fetchImpl }) + assert.deepEqual(userStreamCalls, [[42, 'vendor.sale']]) + assert.equal(calls.length, 1) + }) +}) + +test('publish skips endpoints that fail the SSRF guard', async () => { + await withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, async () => { + const { calls, fetchImpl } = captureFetch() + const pushDevices = { + endpointsForStream: async () => [ + { endpoint: 'http://relay.test/insecure' }, // not https → skipped + { endpoint: 'https://10.0.0.9/private' }, // private → skipped + { endpoint: 'https://relay.test/ok' }, // delivered + ], + endpointsForUserStream: async () => [], + } + await pushDispatch.publish('server.status', { ref: 'down' }, { pushDevices, fetchImpl }) + assert.equal(calls.length, 1) + assert.equal(calls[0].url, 'https://relay.test/ok') + }) +}) + +// ── fromShardEvent (owner resolution) ─────────────────────────────────────── + +test('fromShardEvent resolves a personal event to the owning user, or drops it if unlinked', async () => { + await withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, async () => { + const { calls, fetchImpl } = captureFetch() + const userStreamCalls = [] + const shardLinks = { getByAccount: async (acct) => (acct === 'mine' ? { userId: 42 } : null) } + const pushDevices = { + endpointsForStream: async () => [], + endpointsForUserStream: async (userId, s) => { + userStreamCalls.push([userId, s]) + return [{ endpoint: 'https://relay.test/UPc' }] + }, + } + const deps = { shardLinks, pushDevices, fetchImpl, tracker: createTracker() } + + await pushDispatch.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'mine', t: 1 }, deps) + assert.deepEqual(userStreamCalls, [[42, 'vendor.sale']]) + assert.equal(calls.length, 1) + + // Unlinked account → nobody to notify → no publish. + userStreamCalls.length = 0 + calls.length = 0 + await pushDispatch.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'stranger', t: 2 }, deps) + assert.equal(userStreamCalls.length, 0) + assert.equal(calls.length, 0) + }) +}) + +test('fromShardEvent fans a public shard event to the stream’s subscribers', async () => { + await withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, async () => { + const { fetchImpl } = captureFetch() + const publicCalls = [] + const pushDevices = { + endpointsForStream: async (s) => { + publicCalls.push(s) + return [] + }, + endpointsForUserStream: async () => [], + } + await pushDispatch.fromShardEvent( + { kind: 'server.hello', bootId: 'b1' }, + { pushDevices, fetchImpl, tracker: createTracker() }, + ) + assert.deepEqual(publicCalls, ['server.status']) + }) +}) diff --git a/server/test/shardIngest.champsPages.test.js b/server/test/shardIngest.champsPages.test.js index 875ded8..b07d9b5 100644 --- a/server/test/shardIngest.champsPages.test.js +++ b/server/test/shardIngest.champsPages.test.js @@ -22,6 +22,8 @@ function makeDeps() { }, uoLinkConfig: { recordStatus: noop }, broadcast: (ev) => { calls.broadcast.push(ev) }, + // No-op push fan-out so ingest() stays hermetic (no real relay/DB). + pushDispatch: async () => {}, log: { warn() {}, info() {}, error() {} }, } } diff --git a/server/test/shardIngest.protocol2.test.js b/server/test/shardIngest.protocol2.test.js index 27617a8..9990ede 100644 --- a/server/test/shardIngest.protocol2.test.js +++ b/server/test/shardIngest.protocol2.test.js @@ -33,6 +33,8 @@ function makeDeps() { shardLinks: { removeByAccount: async (account) => { calls.linkRemove.push(account) } }, uoLinkConfig: { recordStatus: noop }, broadcast: (ev) => { calls.broadcast.push(ev) }, + // No-op push fan-out so ingest() stays hermetic (no real relay/DB). + pushDispatch: async () => {}, log: { warn() {}, info() {}, error() {} }, } }