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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user