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,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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }