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