feat(push): M7 backend — opt-in push notifications via self-hosted ntfy
Additive, v1-only backend contract for the Android app's opt-in push (Part 1 of
M7; docs/android/PLAN.md §11). The app is a pure consumer — this lands the
endpoints, fan-out, and relay it needs.
- Schema: push_devices (per-device endpoint) + notification_subscriptions
(per-user opted-in streams), FK→users ON DELETE CASCADE.
- Stream catalog + event→stream mapping (config/notificationStreams.js): public
streams (news.post, server.status, idoc.warning, champ.start, governor.election)
drawn ONLY from the SSE PUBLIC_KINDS allowlist; personal owner-keyed streams
(vendor.sale, house.idoc, account.login). Full-state upserts (champ/city) fire
only on a real transition via an injectable tracker.
- Fan-out (utils/pushDispatch.js): content-free tickles ({ stream, ref }) POSTed
to each subscribed device; never throws. Two producers — shardIngest.ingest
(beside the SSE broadcast) and the create/publish-post path (news.post).
Personal events resolve to the owner via shardLinks. SSRF guard: endpoints must
be HTTPS, non-private, and on the NTFY_BASE_URL/NTFY_ALLOWED_ORIGINS allow-set —
enforced at registration and every publish.
- Routes under the role-agnostic self surface (never /admin): POST|GET
/auth/me/devices, DELETE /auth/me/devices/:id, GET
/auth/me/notifications/streams, GET|PUT /auth/me/notifications/subscriptions.
Swagger regenerated (4 paths, PushDevice/NotificationStreams/etc. schemas).
- ntfy service in docker-compose.yml: pinned image, declarative ./ntfy/server.yml,
no published host port, anonymous unguessable topics (no accounts) — zero
interactive setup. No publish token required (content-free design); optional
NTFY_PUBLISH_TOKEN honored.
- Tests: pushDispatch (mapping, PUBLIC_KINDS gate, owner-keying, SSRF guard,
content-free payload) + notifications route auth gate. Full suite green (247).
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
16
.env.example
16
.env.example
@@ -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=
|
||||
|
||||
@@ -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
31
ntfy/server.yml
Normal 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: ""
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
167
server/src/config/notificationStreams.js
Normal file
167
server/src/config/notificationStreams.js
Normal 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 }
|
||||
16
server/src/model/notificationSubs/notificationSubs.db.js
Normal file
16
server/src/model/notificationSubs/notificationSubs.db.js
Normal 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 }
|
||||
18
server/src/model/notificationSubs/notificationSubs.model.js
Normal file
18
server/src/model/notificationSubs/notificationSubs.model.js
Normal 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 }
|
||||
51
server/src/model/pushDevices/pushDevices.db.js
Normal file
51
server/src/model/pushDevices/pushDevices.db.js
Normal 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 }
|
||||
32
server/src/model/pushDevices/pushDevices.model.js
Normal file
32
server/src/model/pushDevices/pushDevices.model.js
Normal 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 }
|
||||
@@ -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 ─────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
86
server/src/router/v1/auth/notifications.controller.js
Normal file
86
server/src/router/v1/auth/notifications.controller.js
Normal 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,
|
||||
}
|
||||
100
server/src/router/v1/auth/notifications.routes.js
Normal file
100
server/src/router/v1/auth/notifications.routes.js
Normal 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 user’s 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 user’s 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 user’s notification subscriptions'
|
||||
// #swagger.description = 'Sets the full opted-in stream set (applied to all the user’s 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
|
||||
135
server/src/utils/pushDispatch.js
Normal file
135
server/src/utils/pushDispatch.js
Normal 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 }
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 user’s 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 user’s 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 user’s notification subscriptions",
|
||||
"description": "Sets the full opted-in stream set (applied to all the user’s 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": {
|
||||
|
||||
@@ -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',
|
||||
|
||||
40
server/test/notificationsRoutes.test.js
Normal file
40
server/test/notificationsRoutes.test.js
Normal 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()
|
||||
}
|
||||
})
|
||||
235
server/test/pushDispatch.test.js
Normal file
235
server/test/pushDispatch.test.js
Normal 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 owner’s 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 stream’s 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'])
|
||||
})
|
||||
})
|
||||
@@ -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() {} },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {} },
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user