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

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 }

View File

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

View File

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

View File

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

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

View File

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

View File

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