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

Additive, v1-only backend contract for the Android app's opt-in push (Part 1 of
M7; docs/android/PLAN.md §11). The app is a pure consumer — this lands the
endpoints, fan-out, and relay it needs.

- Schema: push_devices (per-device endpoint) + notification_subscriptions
  (per-user opted-in streams), FK→users ON DELETE CASCADE.
- Stream catalog + event→stream mapping (config/notificationStreams.js): public
  streams (news.post, server.status, idoc.warning, champ.start, governor.election)
  drawn ONLY from the SSE PUBLIC_KINDS allowlist; personal owner-keyed streams
  (vendor.sale, house.idoc, account.login). Full-state upserts (champ/city) fire
  only on a real transition via an injectable tracker.
- Fan-out (utils/pushDispatch.js): content-free tickles ({ stream, ref }) POSTed
  to each subscribed device; never throws. Two producers — shardIngest.ingest
  (beside the SSE broadcast) and the create/publish-post path (news.post).
  Personal events resolve to the owner via shardLinks. SSRF guard: endpoints must
  be HTTPS, non-private, and on the NTFY_BASE_URL/NTFY_ALLOWED_ORIGINS allow-set —
  enforced at registration and every publish.
- Routes under the role-agnostic self surface (never /admin): POST|GET
  /auth/me/devices, DELETE /auth/me/devices/:id, GET
  /auth/me/notifications/streams, GET|PUT /auth/me/notifications/subscriptions.
  Swagger regenerated (4 paths, PushDevice/NotificationStreams/etc. schemas).
- ntfy service in docker-compose.yml: pinned image, declarative ./ntfy/server.yml,
  no published host port, anonymous unguessable topics (no accounts) — zero
  interactive setup. No publish token required (content-free design); optional
  NTFY_PUBLISH_TOKEN honored.
- Tests: pushDispatch (mapping, PUBLIC_KINDS gate, owner-keying, SSRF guard,
  content-free payload) + notifications route auth gate. Full suite green (247).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-20 05:13:48 -05:00
parent 030414f13d
commit 416761f8f7
22 changed files with 1778 additions and 1 deletions

View File

@@ -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 users 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 users 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 users notification subscriptions'
// #swagger.description = 'Sets the full opted-in stream set (applied to all the users 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