feat(notifications): Web Push delivers notifications to every device, even with TREK closed

A fourth built-in channel next to email, webhook and ntfy. Browsers
register per device, the server encrypts per RFC 8291 and signs per RFC
8292 with node:crypto and sends only to known push services through the
SSRF guard. The event matrix decides per category what arrives by push;
admins switch the channel on under Notifications.

VAPID keys come from the environment or are generated once, only while
neither stored row exists, and kept encrypted. A pair that does not
decrypt or match is never overwritten and never swapped for another one:
push stays off with an error in the log and every device stays
registered until the key is fixed. The subject is VAPID_SUBJECT, then an
https APP_URL, then the project URL.

Only chat messages collapse into one notification per trip, and a
replacement still alerts. A 403 removes a device only after three
failures in a row; 404 and 410 remove it at once. Password change, reset
and an admin setting a password drop the user's devices with the other
credentials. The request log redacts subscriptions and endpoints, boot
errors print *** for every secret variable, and logout forgets the
device with a plain fetch so an expired session cannot redirect midway.

Addresses discussion #894.
This commit is contained in:
Maurice
2026-09-24 23:17:03 +00:00
parent 7ce31756a3
commit 650451bdf7
177 changed files with 7809 additions and 271 deletions
+5
View File
@@ -283,6 +283,10 @@ async function main() {
// rotation leaves it encrypted under the old key, which reads back as "no
// key" and silently drops the install to OpenStreetMap, so the copy is
// pinned by tests/unit/db/migrate-encryption-parity.test.ts.
//
// web_push_vapid_private_key is the Web Push signing key VapidKeysService
// generates. Missed here it would no longer decrypt under the new key, and
// push would stay off on every device until the old key came back.
for (const key of [
'oidc_client_secret',
'smtp_pass',
@@ -291,6 +295,7 @@ async function main() {
'maps_api_key',
'unsplash_api_key',
'amap_api_key',
'web_push_vapid_private_key',
]) {
const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined;
if (!row?.value) continue;