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
@@ -0,0 +1,103 @@
/**
* FE-ADMCHAN-001 to FE-ADMCHAN-008: the admin's channel switches, shared by
* the desktop Notifications tab and the phone section.
*
* Every switch rewrites one comma-separated app setting. The two things that
* must never happen are the ones a rebuild-from-booleans used to do: drop an id
* this build does not know (a plugin channel, or a newer built-in), and leave
* the screen claiming a state the server refused to store.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { act, renderHook } from '@testing-library/react'
import { useState } from 'react'
import {
BUILTIN_CHANNEL_ORDER,
parseChannelList,
serializeChannelList,
SWITCH_ONLY_CHANNELS,
toggleChannelList,
useNotificationChannels,
} from './useNotificationChannels'
const updateAppSettings = vi.hoisted(() => vi.fn())
vi.mock('../../api/client', () => ({ authApi: { updateAppSettings } }))
const t = (key: string) => (key === 'common.error' ? 'Error' : key)
function mount(initial: Record<string, string>) {
const toast = { error: vi.fn() }
const hook = renderHook(() => {
const [smtpValues, setSmtpValues] = useState(initial)
return { smtpValues, channels: useNotificationChannels({ smtpValues, setSmtpValues, toast }, t) }
})
return { hook, toast }
}
beforeEach(() => {
updateAppSettings.mockReset()
updateAppSettings.mockResolvedValue({})
})
describe('the stored list', () => {
it('FE-ADMCHAN-001: reads the plural setting, the legacy singular one, and none', () => {
expect(parseChannelList({ notification_channels: 'email, push ,plugin-gotify' })).toEqual(['email', 'push', 'plugin-gotify'])
expect(parseChannelList({ notification_channel: 'webhook' })).toEqual(['webhook'])
expect(parseChannelList({ notification_channels: 'none' })).toEqual([])
expect(parseChannelList({})).toEqual([])
expect(parseChannelList({ notification_channels: 'email,' })).toEqual(['email'])
})
it('FE-ADMCHAN-002: writes built-ins in their fixed order and keeps every other id', () => {
expect(serializeChannelList(['plugin-gotify', 'push', 'email'])).toBe('email,push,plugin-gotify')
expect(serializeChannelList([])).toBe('none')
})
it('FE-ADMCHAN-003: flips exactly one id', () => {
expect(toggleChannelList(['email', 'plugin-gotify'], 'push')).toBe('email,push,plugin-gotify')
expect(toggleChannelList(['email', 'push'], 'push')).toBe('email')
expect(toggleChannelList(['push'], 'push')).toBe('none')
})
it('FE-ADMCHAN-004: Web Push is a built-in with its own switch card', () => {
expect(BUILTIN_CHANNEL_ORDER).toEqual(['email', 'webhook', 'ntfy', 'push'])
expect(SWITCH_ONLY_CHANNELS.map(c => c.id)).toEqual(['webhook', 'ntfy', 'push'])
expect(SWITCH_ONLY_CHANNELS[2]).toEqual({
id: 'push',
titleKey: 'admin.notifications.webPushPanel.title',
hintKey: 'admin.notifications.webPushPanel.hint',
})
})
})
describe('useNotificationChannels', () => {
it('FE-ADMCHAN-005: answers which channels are on', () => {
const { hook } = mount({ notification_channels: 'email,push' })
expect(hook.result.current.channels.isActive('push')).toBe(true)
expect(hook.result.current.channels.isActive('ntfy')).toBe(false)
})
it('FE-ADMCHAN-006: switching one on saves the whole list and shows it at once', async () => {
const { hook, toast } = mount({ notification_channels: 'email,plugin-gotify' })
await act(() => hook.result.current.channels.toggle('push'))
expect(updateAppSettings).toHaveBeenCalledWith({ notification_channels: 'email,push,plugin-gotify' })
expect(hook.result.current.smtpValues.notification_channels).toBe('email,push,plugin-gotify')
expect(toast.error).not.toHaveBeenCalled()
})
it('FE-ADMCHAN-007: a failed save puts the previous list back and says so', async () => {
updateAppSettings.mockRejectedValueOnce(new Error('500'))
const { hook, toast } = mount({ notification_channels: 'email' })
await act(() => hook.result.current.channels.toggle('push'))
expect(updateAppSettings).toHaveBeenCalledWith({ notification_channels: 'email,push' })
expect(hook.result.current.smtpValues.notification_channels).toBe('email')
expect(hook.result.current.channels.isActive('push')).toBe(false)
expect(toast.error).toHaveBeenCalledWith('Error')
})
it('FE-ADMCHAN-008: the rollback writes the normalised form, never an id less', async () => {
updateAppSettings.mockRejectedValueOnce(new Error('500'))
const { hook } = mount({ notification_channels: 'plugin-gotify, email' })
await act(() => hook.result.current.channels.toggle('webhook'))
expect(hook.result.current.smtpValues.notification_channels).toBe('email,plugin-gotify')
})
})
@@ -0,0 +1,88 @@
import type { Dispatch, SetStateAction } from 'react'
import { WEB_PUSH_CHANNEL_ID } from '@trek/shared'
import { authApi } from '../../api/client'
import type { TranslationFn } from '../../types'
/**
* The admin's channel switches, shared by the desktop Notifications tab and its
* phone twin. Both used to carry their own copy of a three-boolean
* `setChannels`, and a fourth channel would have meant a fourth argument in
* two places; SonarCloud's duplication budget has no room for that either.
*
* All switches write one app setting, `notification_channels`, a comma
* separated list of channel ids. Ids this build does not know (a plugin
* channel, or one a newer server added) must survive a toggle untouched, so
* the list is edited, never rebuilt from the switches.
*/
/** The built-in channels, in the order they are written to the list. Other ids follow as stored. */
export const BUILTIN_CHANNEL_ORDER: readonly string[] = ['email', 'webhook', 'ntfy', WEB_PUSH_CHANNEL_ID]
/** The channels whose admin card is only a title, a hint and the switch (email carries the SMTP form). */
export const SWITCH_ONLY_CHANNELS: ReadonlyArray<{ id: string; titleKey: string; hintKey: string }> = [
{ id: 'webhook', titleKey: 'admin.notifications.webhookPanel.title', hintKey: 'admin.webhook.hint' },
{ id: 'ntfy', titleKey: 'admin.notifications.ntfy', hintKey: 'admin.ntfy.hint' },
{
id: WEB_PUSH_CHANNEL_ID,
titleKey: 'admin.notifications.webPushPanel.title',
hintKey: 'admin.notifications.webPushPanel.hint',
},
]
/**
* The active ids, from `notification_channels` or, on installs that predate
* it, the singular `notification_channel`. 'none' is the stored empty list.
*/
export function parseChannelList(values: Record<string, string>): string[] {
const raw = values.notification_channels ?? values.notification_channel ?? 'none'
if (raw === 'none') return []
return raw
.split(',')
.map(id => id.trim())
.filter(Boolean)
}
/** The stored form: built-ins in their fixed order, then every other id as it was, or 'none'. */
export function serializeChannelList(active: readonly string[]): string {
const builtins = BUILTIN_CHANNEL_ORDER.filter(id => active.includes(id))
const others = active.filter(id => !BUILTIN_CHANNEL_ORDER.includes(id))
return [...builtins, ...others].join(',') || 'none'
}
/** The stored list with `id` switched the other way. */
export function toggleChannelList(active: readonly string[], id: string): string {
return serializeChannelList(active.includes(id) ? active.filter(c => c !== id) : [...active, id])
}
interface ChannelSettingsHost {
smtpValues: Record<string, string>
setSmtpValues: Dispatch<SetStateAction<Record<string, string>>>
toast: { error: (message: string) => void }
}
export interface NotificationChannelSwitches {
isActive: (id: string) => boolean
/** Flips one channel and saves the list; puts the old list back and toasts when the save fails. */
toggle: (id: string) => Promise<void>
}
export function useNotificationChannels(
{ smtpValues, setSmtpValues, toast }: ChannelSettingsHost,
t: TranslationFn,
): NotificationChannelSwitches {
const active = parseChannelList(smtpValues)
const toggle = async (id: string) => {
const next = toggleChannelList(active, id)
setSmtpValues(prev => ({ ...prev, notification_channels: next }))
try {
await authApi.updateAppSettings({ notification_channels: next })
} catch {
const reverted = serializeChannelList(active)
setSmtpValues(prev => ({ ...prev, notification_channels: reverted }))
toast.error(t('common.error'))
}
}
return { isActive: id => active.includes(id), toggle }
}