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
+3 -8
View File
@@ -1,5 +1,6 @@
import { getCachedBlob } from '../db/offlineDb'
import { isEffectivelyOffline } from '../sync/networkMode'
import { isIosStandalone } from './iosDevice'
// MIME types safe to open inline (will not execute script in any browser).
// Everything else (text/html, image/svg+xml, text/javascript, …) is forced to
@@ -35,13 +36,6 @@ function triggerAnchorDownload(blobUrl: string, filename?: string): void {
setTimeout(() => { URL.revokeObjectURL(blobUrl); a.remove() }, 100)
}
// navigator.standalone is true only on iOS when running as an
// add-to-home-screen PWA. In that context, target="_blank" hands off to
// Safari, which cannot access blob URLs sandboxed to the WebView.
function isIosStandalone(): boolean {
return (navigator as any).standalone === true
}
/**
* Resolves a protected file to a Blob, preferring the live server but falling
* back to the offline cache (pre-downloaded by the trip sync manager). This is
@@ -124,7 +118,8 @@ export async function openFile(url: string, filename?: string): Promise<void> {
return
}
// iOS PWA: target="_blank" would open Safari, which can't access the blob
// iOS PWA: target="_blank" hands off to Safari, which cannot reach a blob URL
// sandboxed to the installed app's WebView.
if (isIosStandalone()) {
triggerAnchorDownload(blobUrl, filename)
return
+51
View File
@@ -0,0 +1,51 @@
/**
* FE-IOS-001 to FE-IOS-004: the two iPhone and iPad facts shared by file
* opening and Web Push.
*/
import { describe, it, expect, afterEach } from 'vitest'
import { isIosDevice, isIosStandalone } from './iosDevice'
function define(name: 'userAgent' | 'maxTouchPoints' | 'standalone', value: unknown) {
Object.defineProperty(navigator, name, { configurable: true, get: () => value })
}
afterEach(() => {
for (const name of ['userAgent', 'maxTouchPoints', 'standalone']) {
delete (navigator as unknown as Record<string, unknown>)[name]
}
})
describe('isIosStandalone', () => {
it("FE-IOS-001: is true only for Apple's own home-screen flag", () => {
expect(isIosStandalone()).toBe(false)
define('standalone', false)
expect(isIosStandalone()).toBe(false)
define('standalone', true)
expect(isIosStandalone()).toBe(true)
})
})
describe('isIosDevice', () => {
it('FE-IOS-002: recognises iPhone, iPod and iPad by their user agent', () => {
for (const device of ['iPhone', 'iPod touch', 'iPad']) {
define('userAgent', `Mozilla/5.0 (${device}; CPU OS 18_0 like Mac OS X) AppleWebKit/605.1.15`)
expect(isIosDevice()).toBe(true)
}
})
it('FE-IOS-003: an iPad asking for the desktop site is a Mac with a touch screen', () => {
define('userAgent', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/605.1.15')
define('maxTouchPoints', 5)
expect(isIosDevice()).toBe(true)
define('maxTouchPoints', 0)
expect(isIosDevice()).toBe(false)
})
it('FE-IOS-004: Android and Windows are not iOS', () => {
define('maxTouchPoints', 5)
define('userAgent', 'Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 Chrome/140.0 Mobile Safari/537.36')
expect(isIosDevice()).toBe(false)
define('userAgent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/140.0 Safari/537.36')
expect(isIosDevice()).toBe(false)
})
})
+28
View File
@@ -0,0 +1,28 @@
/**
* iPhone and iPad facts that more than one feature has to know about: file
* opening needs to know whether a new tab would leave the installed app, and
* Web Push needs to know whether it can work here at all.
*/
type NavigatorWithStandalone = Navigator & { standalone?: boolean }
/**
* True only on iOS and iPadOS while TREK runs as an app added to the Home
* Screen. `navigator.standalone` is Apple's own flag and exists nowhere else,
* so every other platform answers false.
*/
export function isIosStandalone(): boolean {
return typeof navigator !== 'undefined' && (navigator as NavigatorWithStandalone).standalone === true
}
/**
* True on iPhone, iPod touch and iPad. iPadOS 13 and later asks for the desktop
* site by default and then introduces itself as a Mac, so a Mac with a touch
* screen counts too: no real Mac has one.
*/
export function isIosDevice(): boolean {
if (typeof navigator === 'undefined') return false
const ua = navigator.userAgent || ''
if (/iPad|iPhone|iPod/.test(ua)) return true
return /Macintosh/.test(ua) && (navigator.maxTouchPoints ?? 0) > 1
}