feat(push): M7 backend — opt-in push notifications via self-hosted ntfy
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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user