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

@@ -118,3 +118,19 @@ BOT_INTERNAL_KEY=change-me-to-a-long-random-string
UOLINK_BASE_URL=http://127.0.0.1:8080
UOLINK_WS_URL=ws://127.0.0.1:8080/ws
UOLINK_PROTOCOL=1
# ─── Push notifications (M7) — self-hosted ntfy UnifiedPush relay ───
# The `ntfy` compose service and the backend's push fan-out (opt-in notifications
# for the Android app; docs/android/PLAN.md §11).
# NTFY_BASE_URL Public URL devices reach the relay at (behind the
# reverse proxy). Used BOTH to configure the ntfy service
# AND as the backend's SSRF allow-set — a device may only
# register an endpoint whose origin matches this.
# NTFY_ALLOWED_ORIGINS Optional, comma-separated extra allowed endpoint origins
# (defaults to NTFY_BASE_URL's origin). Set only if devices
# register endpoints on a different host than NTFY_BASE_URL.
# NTFY_PUBLISH_TOKEN Optional. The content-free-tickle design needs NO token;
# set one only to require auth on backend→ntfy publishes.
NTFY_BASE_URL=https://ntfy.example.com
# NTFY_ALLOWED_ORIGINS=https://ntfy.example.com
# NTFY_PUBLISH_TOKEN=

View File

@@ -55,6 +55,29 @@ services:
ports:
- "3000:3000"
ntfy:
# Self-hosted UnifiedPush relay for the app's opt-in push notifications
# (docs/android/PLAN.md §11). Pinned upstream image — fits this file's
# pull-only, never-build model. All config is declarative (./ntfy/server.yml
# + the NTFY_BASE_URL override below), so bringing the stack up provisions a
# working relay with NO interactive steps (no `ntfy user add`, no accounts).
# The backend treats ntfy as an untrusted relay and publishes only
# content-free tickles, so anonymous read-write to unguessable topics is safe.
image: binwiederhier/ntfy:v2.11.0
restart: unless-stopped
command: ["serve"]
environment:
# Public URL devices reach it at (behind the reverse proxy). MUST match the
# origin of the endpoints the app registers — the backend's SSRF allow-set
# (NTFY_BASE_URL / NTFY_ALLOWED_ORIGINS on the app) is derived from it.
NTFY_BASE_URL: ${NTFY_BASE_URL:-https://ntfy.localhost}
volumes:
- ntfydata:/var/lib/ntfy
- ./ntfy/server.yml:/etc/ntfy/server.yml:ro
# No published host port — devices reach ntfy through the public reverse proxy
# on its own hostname; the backend publisher reaches it over the private
# compose network. Never publish this directly.
bot:
# Same as app: prebuilt bot image, pulled in production. Build locally via
# docker-compose.dev.yml.
@@ -90,3 +113,4 @@ services:
volumes:
dbdata:
uploads:
ntfydata:

31
ntfy/server.yml Normal file
View File

@@ -0,0 +1,31 @@
# ── ntfy self-hosted server config (UnifiedPush relay) ─────────────────────
#
# Backs the Android app's opt-in push notifications (docs/android/PLAN.md §11).
# Declarative + committed: `docker compose up` provisions a working relay with
# NO interactive setup — no `ntfy user add`, no per-user accounts, no post-deploy
# steps. The website backend treats ntfy as an UNTRUSTED relay and only ever
# publishes content-free tickles ({ stream, ref }); the real, ownership-checked
# content is pulled by the app over the authenticated website API. That is why
# anonymous access to unguessable topics is intentional and safe here.
#
# The public base URL is provided per-deploy via the NTFY_BASE_URL env var in
# docker-compose.yml (ntfy env vars override this file), so this default is only
# a placeholder for a bare `ntfy serve`.
base-url: "https://ntfy.localhost"
# Served on the private compose network; the public reverse proxy terminates TLS
# and forwards to this port. docker-compose.yml publishes NO host port for ntfy.
listen-http: ":80"
behind-proxy: true
# Persist the message cache + (empty) auth db on the named volume.
cache-file: "/var/lib/ntfy/cache.db"
auth-file: "/var/lib/ntfy/auth.db"
# No accounts to administer — anonymous read+write to unguessable topics. Safe
# because payloads are content-free; the security boundary is the authenticated
# website API, not ntfy (see the header note).
auth-default-access: "read-write"
# Pure relay: no attachments.
attachment-cache-dir: ""

View File

@@ -97,3 +97,16 @@ BOT_INTERNAL_KEY=dev-only-change-me-bot-key
# TOWNCRIER_DURATION_SEC how long the in-game town-crier message stays up (<= 86400)
ANNOUNCE_POLL_MS=15000
TOWNCRIER_DURATION_SEC=3600
# Push notifications (M7) — opt-in fan-out to the Android app via a self-hosted
# ntfy UnifiedPush relay (docs/android/PLAN.md §11). The publisher POSTs
# content-free tickles to each device's endpoint, so no publish token is required.
# NTFY_BASE_URL Public relay URL; also the backend's SSRF allow-set — a
# device may only register an endpoint on this origin.
# NTFY_ALLOWED_ORIGINS Optional comma-separated extra allowed origins.
# NTFY_PUBLISH_TOKEN Optional bearer token for backend->ntfy publishes (off by default).
# Leave NTFY_BASE_URL unset in local dev to allow any public HTTPS endpoint
# (private/loopback hosts are always rejected).
# NTFY_BASE_URL=https://ntfy.example.com
# NTFY_ALLOWED_ORIGINS=
# NTFY_PUBLISH_TOKEN=

View File

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

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 }

View File

@@ -1631,6 +1631,332 @@
]
}
},
"/api/v1/auth/me/devices": {
"post": {
"tags": [
"Auth · Me"
],
"summary": "Register a push device (endpoint) for the current user",
"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).",
"responses": {
"201": {
"description": "Device registered",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PushDevice"
}
}
}
},
"400": {
"description": "Validation error or disallowed endpoint",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Forbidden"
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RegisterDeviceRequest"
}
}
}
}
},
"get": {
"tags": [
"Auth · Me"
],
"summary": "List the current users registered push devices",
"description": "",
"responses": {
"200": {
"description": "Registered devices",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PushDevice"
}
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Forbidden"
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/auth/me/devices/{id}": {
"delete": {
"tags": [
"Auth · Me"
],
"summary": "Unregister a push device",
"description": "",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
},
"description": "Device id (must belong to the caller)."
}
],
"responses": {
"200": {
"description": "Unregistered",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OkFlag"
}
}
}
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "No such device for this user",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/auth/me/notifications/streams": {
"get": {
"tags": [
"Auth · Me"
],
"summary": "List subscribable notification streams (catalog)",
"description": "The catalog of push streams. `personal`/`requiresLinkedAccount` streams are delivered only to the owning user and need a linked game account.",
"responses": {
"200": {
"description": "Stream catalog",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotificationStreams"
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Forbidden"
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/auth/me/notifications/subscriptions": {
"get": {
"tags": [
"Auth · Me"
],
"summary": "Get the current users notification subscriptions",
"description": "",
"responses": {
"200": {
"description": "Subscribed stream ids",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotificationSubscriptions"
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Forbidden"
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
},
"put": {
"tags": [
"Auth · Me"
],
"summary": "Replace the current users notification subscriptions",
"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.",
"responses": {
"200": {
"description": "Updated subscriptions",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotificationSubscriptions"
}
}
}
},
"400": {
"description": "Validation error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationError"
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Forbidden"
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotificationSubscriptions"
}
}
}
}
}
},
"/api/v1/public/settings": {
"get": {
"tags": [
@@ -12756,6 +13082,368 @@
}
}
},
"RegisterDeviceRequest": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"required": {
"type": "array",
"example": [
"endpoint"
],
"items": {
"type": "string"
}
},
"properties": {
"type": "object",
"properties": {
"endpoint": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"format": {
"type": "string",
"example": "uri"
},
"description": {
"type": "string",
"example": "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": {
"type": "string",
"example": "https://ntfy.example.com/UP0a1b2c3d4e5f"
}
}
},
"transport": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"enum": {
"type": "array",
"example": [
"unifiedpush",
"fcm"
],
"items": {
"type": "string"
}
},
"default": {
"type": "string",
"example": "unifiedpush"
},
"example": {
"type": "string",
"example": "unifiedpush"
}
}
},
"platform": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"maxLength": {
"type": "number",
"example": 40
},
"example": {
"type": "string",
"example": "android"
}
}
}
}
}
}
},
"PushDevice": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"id": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 7
}
}
},
"transport": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"enum": {
"type": "array",
"example": [
"unifiedpush",
"fcm"
],
"items": {
"type": "string"
}
},
"example": {
"type": "string",
"example": "unifiedpush"
}
}
},
"endpoint": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "https://ntfy.example.com/UP0a1b2c3d4e5f"
}
}
},
"platform": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"example": {
"type": "string",
"example": "android"
}
}
},
"createdAt": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"format": {
"type": "string",
"example": "date-time"
}
}
},
"lastSeenAt": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"format": {
"type": "string",
"example": "date-time"
}
}
}
}
}
}
},
"NotificationStream": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "One subscribable push stream from the catalog."
},
"properties": {
"type": "object",
"properties": {
"id": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "idoc.warning"
}
}
},
"label": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "IDOC warnings"
}
}
},
"description": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "A house falls into its final (IDOC) decay stage."
}
}
},
"personal": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"description": {
"type": "string",
"example": "Owner-keyed — delivered only to the owning user, never fanned out publicly."
},
"example": {
"type": "boolean",
"example": false
}
}
},
"requiresLinkedAccount": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"description": {
"type": "string",
"example": "The stream needs a linked game account (personal streams)."
},
"example": {
"type": "boolean",
"example": false
}
}
}
}
}
}
},
"NotificationStreams": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"streams": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"$ref": "#/components/schemas/NotificationStream"
}
}
}
}
}
}
},
"NotificationSubscriptions": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "The set of stream ids the user has opted into (used for both GET and PUT)."
},
"properties": {
"type": "object",
"properties": {
"streams": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
}
}
},
"example": {
"type": "array",
"example": [
"news.post",
"idoc.warning",
"vendor.sale"
],
"items": {
"type": "string"
}
}
}
}
}
}
}
},
"Appeal": {
"type": "object",
"properties": {

View File

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

View File

@@ -0,0 +1,40 @@
// Point the DB at a closed port BEFORE requiring anything that builds the pool —
// these cases reject at requireAuth (no session token) before any query runs.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, after } = require('node:test')
const assert = require('node:assert/strict')
const { startApp } = require('./_helper')
const authRouter = require('../src/router/v1/auth/auth.routes')
const db = require('../src/utils/db')
after(() => db.close())
// The push-notification self surface (/auth/me/devices*, /auth/me/notifications/*)
// must be mounted AND gated: an unauthenticated caller gets 401 on every route —
// never 404 (route missing) and never 200 (gate bypassed).
test('/auth/me push routes reject unauthenticated callers with 401', async () => {
const app = await startApp((a) => a.use('/api/v1/auth', authRouter))
try {
const calls = [
['GET', '/api/v1/auth/me/devices'],
['POST', '/api/v1/auth/me/devices', { endpoint: 'https://ntfy.example.com/UPabc' }],
['DELETE', '/api/v1/auth/me/devices/1'],
['GET', '/api/v1/auth/me/notifications/streams'],
['GET', '/api/v1/auth/me/notifications/subscriptions'],
['PUT', '/api/v1/auth/me/notifications/subscriptions', { streams: ['news.post'] }],
]
for (const [method, path, body] of calls) {
const res = await fetch(app.url + path, {
method,
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
})
assert.equal(res.status, 401, `${method} ${path} should be 401, got ${res.status}`)
}
} finally {
await app.close()
}
})

View File

@@ -0,0 +1,235 @@
// Point the DB at a closed port before requiring anything that builds the pool —
// these tests inject fake models, so no real query should ever run.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, after } = require('node:test')
const assert = require('node:assert/strict')
const { mapShardEvent, createTracker } = require('../src/config/notificationStreams')
const pushDispatch = require('../src/utils/pushDispatch')
const db = require('../src/utils/db')
after(() => db.close())
// Run body with env keys set, then restore prior values.
function withEnv(vars, fn) {
const prior = {}
for (const [k, v] of Object.entries(vars)) {
prior[k] = process.env[k]
if (v === undefined) delete process.env[k]
else process.env[k] = v
}
try {
return fn()
} finally {
for (const [k, v] of Object.entries(prior)) {
if (v === undefined) delete process.env[k]
else process.env[k] = v
}
}
}
// ── mapShardEvent ───────────────────────────────────────────────────────────
test('server.hello / shutdown / crashed map to the public server.status stream', () => {
const t = createTracker()
assert.deepEqual(mapShardEvent({ kind: 'server.hello', bootId: 'b1' }, t), [
{ streamId: 'server.status', ref: 'up:b1' },
])
assert.deepEqual(mapShardEvent({ kind: 'server.shutdown' }, t), [{ streamId: 'server.status', ref: 'down' }])
assert.deepEqual(mapShardEvent({ kind: 'server.crashed' }, t), [{ streamId: 'server.status', ref: 'down' }])
})
test('house.decay INTO idoc yields the public idoc.warning AND the owner-keyed house.idoc', () => {
const t = createTracker()
const out = mapShardEvent({ kind: 'house.decay', to: 'IDOC', serial: '0x40', ownerAcct: 'bob' }, t)
assert.deepEqual(out, [
{ streamId: 'idoc.warning', ref: '0x40' },
{ streamId: 'house.idoc', ref: '0x40', ownerAccount: 'bob' },
])
// A non-IDOC decay stage produces nothing.
assert.deepEqual(mapShardEvent({ kind: 'house.decay', to: 'Fairly', serial: '0x41' }, t), [])
})
test('champ.update fires champ.start only on the inactive→active transition', () => {
const t = createTracker()
// First sight active → start.
assert.deepEqual(mapShardEvent({ kind: 'champ.update', serial: 'c1', active: true }, t), [
{ streamId: 'champ.start', ref: 'c1' },
])
// Still active → no re-fire.
assert.deepEqual(mapShardEvent({ kind: 'champ.update', serial: 'c1', active: true }, t), [])
// Goes inactive, then active again → fires again.
assert.deepEqual(mapShardEvent({ kind: 'champ.update', serial: 'c1', active: false }, t), [])
assert.deepEqual(mapShardEvent({ kind: 'champ.update', serial: 'c1', active: true }, t), [
{ streamId: 'champ.start', ref: 'c1' },
])
})
test('city.update fires governor.election only on a real governor change, never on first sight', () => {
const t = createTracker()
// First sight of the city → no election (could be a reconnect snapshot).
assert.deepEqual(mapShardEvent({ kind: 'city.update', city: 'Britain', governor: { serial: '0x1' } }, t), [])
// Same governor → nothing.
assert.deepEqual(mapShardEvent({ kind: 'city.update', city: 'Britain', governor: { serial: '0x1' } }, t), [])
// New governor → election.
assert.deepEqual(mapShardEvent({ kind: 'city.update', city: 'Britain', governor: { serial: '0x2' } }, t), [
{ streamId: 'governor.election', ref: 'Britain' },
])
})
test('personal streams are owner-keyed and sensitive kinds never yield a public target', () => {
const t = createTracker()
const sale = mapShardEvent({ kind: 'vendor.sale', ownerAcct: 'bob', t: 7 }, t)
assert.deepEqual(sale, [{ streamId: 'vendor.sale', ref: '7', ownerAccount: 'bob' }])
const login = mapShardEvent({ kind: 'account.login.attempt', acct: 'bob', ip: '1.2.3.4', t: 9 }, t)
assert.deepEqual(login, [{ streamId: 'account.login', ref: '9', ownerAccount: 'bob' }])
// Every personal target carries an ownerAccount (never a bare public push).
for (const target of [...sale, ...login]) assert.ok(target.ownerAccount, 'personal target must be owner-keyed')
// A truly sensitive, unmapped kind produces nothing at all.
assert.deepEqual(mapShardEvent({ kind: 'cheat.fastwalk', acct: 'bob' }, t), [])
assert.deepEqual(mapShardEvent({ kind: 'admin.audit', actor: 'staff' }, t), [])
})
// ── isAllowedEndpoint (SSRF guard) ──────────────────────────────────────────
test('isAllowedEndpoint pins the configured ntfy origin and rejects everything else', () => {
withEnv({ NTFY_BASE_URL: 'https://ntfy.example.com', NTFY_ALLOWED_ORIGINS: undefined }, () => {
assert.equal(pushDispatch.isAllowedEndpoint('https://ntfy.example.com/UPabc'), true)
assert.equal(pushDispatch.isAllowedEndpoint('https://evil.example.com/x'), false) // wrong origin
assert.equal(pushDispatch.isAllowedEndpoint('http://ntfy.example.com/x'), false) // not https
assert.equal(pushDispatch.isAllowedEndpoint('https://127.0.0.1/x'), false) // loopback
assert.equal(pushDispatch.isAllowedEndpoint('not a url'), false)
})
})
test('isAllowedEndpoint (no allow-set) permits any public https host but blocks private/loopback/http', () => {
withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, () => {
assert.equal(pushDispatch.isAllowedEndpoint('https://relay.somehost.net/UPabc'), true)
assert.equal(pushDispatch.isAllowedEndpoint('http://relay.somehost.net/x'), false)
assert.equal(pushDispatch.isAllowedEndpoint('https://10.0.0.5/x'), false)
assert.equal(pushDispatch.isAllowedEndpoint('https://192.168.1.10/x'), false)
assert.equal(pushDispatch.isAllowedEndpoint('https://localhost/x'), false)
})
})
// ── publish ─────────────────────────────────────────────────────────────────
function captureFetch() {
const calls = []
return {
calls,
fetchImpl: async (url, opts) => {
calls.push({ url, opts })
return { ok: true, status: 200 }
},
}
}
test('publish sends a content-free tickle to public subscribers', async () => {
await withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, async () => {
const { calls, fetchImpl } = captureFetch()
const streamCalls = []
const pushDevices = {
endpointsForStream: async (s) => {
streamCalls.push(s)
return [{ endpoint: 'https://relay.test/UPa', transport: 'unifiedpush' }]
},
endpointsForUserStream: async () => [],
}
await pushDispatch.publish('news.post', { ref: '5' }, { pushDevices, fetchImpl })
assert.deepEqual(streamCalls, ['news.post'])
assert.equal(calls.length, 1)
assert.equal(calls[0].url, 'https://relay.test/UPa')
assert.equal(calls[0].opts.method, 'POST')
assert.deepEqual(JSON.parse(calls[0].opts.body), { stream: 'news.post', ref: '5' })
})
})
test('publish (personal) targets only the owners subscribed devices', async () => {
await withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, async () => {
const { calls, fetchImpl } = captureFetch()
const userStreamCalls = []
const pushDevices = {
endpointsForStream: async () => {
throw new Error('public path must not be used for a personal publish')
},
endpointsForUserStream: async (userId, s) => {
userStreamCalls.push([userId, s])
return [{ endpoint: 'https://relay.test/UPb' }]
},
}
await pushDispatch.publish('vendor.sale', { ref: '9', ownerUserId: 42 }, { pushDevices, fetchImpl })
assert.deepEqual(userStreamCalls, [[42, 'vendor.sale']])
assert.equal(calls.length, 1)
})
})
test('publish skips endpoints that fail the SSRF guard', async () => {
await withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, async () => {
const { calls, fetchImpl } = captureFetch()
const pushDevices = {
endpointsForStream: async () => [
{ endpoint: 'http://relay.test/insecure' }, // not https → skipped
{ endpoint: 'https://10.0.0.9/private' }, // private → skipped
{ endpoint: 'https://relay.test/ok' }, // delivered
],
endpointsForUserStream: async () => [],
}
await pushDispatch.publish('server.status', { ref: 'down' }, { pushDevices, fetchImpl })
assert.equal(calls.length, 1)
assert.equal(calls[0].url, 'https://relay.test/ok')
})
})
// ── fromShardEvent (owner resolution) ───────────────────────────────────────
test('fromShardEvent resolves a personal event to the owning user, or drops it if unlinked', async () => {
await withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, async () => {
const { calls, fetchImpl } = captureFetch()
const userStreamCalls = []
const shardLinks = { getByAccount: async (acct) => (acct === 'mine' ? { userId: 42 } : null) }
const pushDevices = {
endpointsForStream: async () => [],
endpointsForUserStream: async (userId, s) => {
userStreamCalls.push([userId, s])
return [{ endpoint: 'https://relay.test/UPc' }]
},
}
const deps = { shardLinks, pushDevices, fetchImpl, tracker: createTracker() }
await pushDispatch.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'mine', t: 1 }, deps)
assert.deepEqual(userStreamCalls, [[42, 'vendor.sale']])
assert.equal(calls.length, 1)
// Unlinked account → nobody to notify → no publish.
userStreamCalls.length = 0
calls.length = 0
await pushDispatch.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'stranger', t: 2 }, deps)
assert.equal(userStreamCalls.length, 0)
assert.equal(calls.length, 0)
})
})
test('fromShardEvent fans a public shard event to the streams subscribers', async () => {
await withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, async () => {
const { fetchImpl } = captureFetch()
const publicCalls = []
const pushDevices = {
endpointsForStream: async (s) => {
publicCalls.push(s)
return []
},
endpointsForUserStream: async () => [],
}
await pushDispatch.fromShardEvent(
{ kind: 'server.hello', bootId: 'b1' },
{ pushDevices, fetchImpl, tracker: createTracker() },
)
assert.deepEqual(publicCalls, ['server.status'])
})
})

View File

@@ -22,6 +22,8 @@ function makeDeps() {
},
uoLinkConfig: { recordStatus: noop },
broadcast: (ev) => { calls.broadcast.push(ev) },
// No-op push fan-out so ingest() stays hermetic (no real relay/DB).
pushDispatch: async () => {},
log: { warn() {}, info() {}, error() {} },
}
}

View File

@@ -33,6 +33,8 @@ function makeDeps() {
shardLinks: { removeByAccount: async (account) => { calls.linkRemove.push(account) } },
uoLinkConfig: { recordStatus: noop },
broadcast: (ev) => { calls.broadcast.push(ev) },
// No-op push fan-out so ingest() stays hermetic (no real relay/DB).
pushDispatch: async () => {},
log: { warn() {}, info() {}, error() {} },
}
}