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