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
+1 -1
View File
@@ -168,7 +168,7 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
- **Admin panel**: users and invites, the permission matrix, packing templates, categories, addons, plugins, API keys, MCP tokens and OAuth sessions, backups, storage, audit log, and GitHub releases
- **Backups**: manual or scheduled hourly, daily, weekly or monthly, with retention in days. The zip carries the at-rest key, so a restore can decrypt its own secrets
- **Storage**: pluggable storage backends per content category — keep everything on local disk, or add S3-compatible backends and replicate any category to them, configured entirely from the admin panel
- **Notifications**: a per-user matrix of events against in-app, email (SMTP), webhook and ntfy, plus any channel a plugin registers
- **Notifications**: a per-user matrix of events against in-app, email (SMTP), webhook, ntfy and Web Push (on every device you turn it on for, even with TREK closed), plus any channel a plugin registers
- **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, compact density, reduce motion, and text size per tier
- **27 languages**: en, de, es, fr, it, nl, hu, ru, zh, zh-TW, pl, cs, ar (right to left), br, id, tr, ja, ko, uk, gr, sv, vi, ca, az, et, sk, th
- **In-app help**: the wiki ships inside the image and is served from disk at `/help`, so the docs match the version you are running
+1
View File
@@ -53,4 +53,5 @@ See `values.yaml` for more options.
- `FORCE_HTTPS` is optional. Set `env.FORCE_HTTPS: "true"` only when ingress (or another proxy) terminates TLS. It enables HTTPS redirects, HSTS, CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Requires `TRUST_PROXY` to be set.
- Set `env.TRUST_PROXY: "1"` (or the number of proxy hops) when running behind ingress or a load balancer. Required for `FORCE_HTTPS` to detect the forwarded protocol correctly. In production it defaults to `1` automatically.
- `COOKIE_SECURE` is auto-derived (on when `NODE_ENV=production` or `FORCE_HTTPS=true`). Set `env.COOKIE_SECURE: "false"` only during local testing without TLS. **Not recommended for production.**
- Web Push needs no chart settings: TREK generates its key pair on first start and keeps it in the database on the data PVC. To bring your own pair, set `env.VAPID_PUBLIC_KEY` and `secretEnv.VAPID_PRIVATE_KEY` together (and optionally `env.VAPID_SUBJECT`); changing the pair later means every device has to turn push on again.
- Set `env.OIDC_DISCOVERY_URL` to override the auto-constructed OIDC discovery endpoint. Required for providers (e.g. Authentik) that expose it at a non-standard path.
+6
View File
@@ -97,3 +97,9 @@ data:
{{- if .Values.env.AMAP_API_BASE }}
AMAP_API_BASE: {{ .Values.env.AMAP_API_BASE | quote }}
{{- end }}
{{- if .Values.env.VAPID_PUBLIC_KEY }}
VAPID_PUBLIC_KEY: {{ .Values.env.VAPID_PUBLIC_KEY | quote }}
{{- end }}
{{- if .Values.env.VAPID_SUBJECT }}
VAPID_SUBJECT: {{ .Values.env.VAPID_SUBJECT | quote }}
{{- end }}
+6
View File
@@ -93,6 +93,12 @@ spec:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: AMAP_API_SECRET
optional: true
- name: VAPID_PRIVATE_KEY
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: VAPID_PRIVATE_KEY
optional: true
volumeMounts:
- name: data
mountPath: /app/data
+6
View File
@@ -29,6 +29,9 @@ data:
{{- if .Values.secretEnv.AMAP_API_SECRET }}
AMAP_API_SECRET: {{ .Values.secretEnv.AMAP_API_SECRET | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.VAPID_PRIVATE_KEY }}
VAPID_PRIVATE_KEY: {{ .Values.secretEnv.VAPID_PRIVATE_KEY | b64enc | quote }}
{{- end }}
{{- end }}
{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }}
@@ -68,4 +71,7 @@ stringData:
{{- if .Values.secretEnv.AMAP_API_SECRET }}
AMAP_API_SECRET: {{ .Values.secretEnv.AMAP_API_SECRET }}
{{- end }}
{{- if .Values.secretEnv.VAPID_PRIVATE_KEY }}
VAPID_PRIVATE_KEY: {{ .Values.secretEnv.VAPID_PRIVATE_KEY }}
{{- end }}
{{- end }}
+14
View File
@@ -108,6 +108,15 @@ env:
# Send the Amap (高德地图) calls through an egress proxy, a cache or a gateway that speaks the same API instead of
# restapi.amap.com directly. The key itself is a credential and goes under secretEnv.AMAP_API_KEY, or into
# Admin > Settings > API Keys. Defaults to https://restapi.amap.com.
# VAPID_PUBLIC_KEY: ""
# Web Push key pair, public half (base64url, 65 bytes). Leave both halves empty and TREK generates a pair on
# first start and keeps it in the database, so it travels with backups. To use your own pair, set this and
# secretEnv.VAPID_PRIVATE_KEY together, e.g. from `npx web-push generate-vapid-keys`. Changing the pair later
# means every device has to turn push on again.
# VAPID_SUBJECT: ""
# The contact push services see with every Web Push message: a mailto: address or an https:// URL. Defaults to
# APP_URL when it is an https:// address other than localhost, else https://github.com/liketrek/TREK. No email
# address is sent unless you set one here.
# Secret environment variables stored in a Kubernetes Secret.
@@ -147,6 +156,11 @@ secretEnv:
# with signing enabled, because such a key rejects every unsigned request. Leave empty
# otherwise. The endpoint itself is not a secret and is set under env.AMAP_API_BASE.
AMAP_API_SECRET: ""
# Optional Web Push key pair, private half (base64url, 32 bytes). Only used together with
# env.VAPID_PUBLIC_KEY; a pair whose halves do not belong together, or only one half, turns
# push off with an error in the log until it is fixed, and every device stays registered.
# Leave both empty to let TREK keep a generated pair in its database.
VAPID_PRIVATE_KEY: ""
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
generateEncryptionKey: false
+148
View File
@@ -0,0 +1,148 @@
/*
* Web Push handlers for the generated service worker.
*
* vite-plugin-pwa builds sw.js with Workbox (generateSW), and Workbox pulls
* this file in through `workbox.importScripts` in vite.config.js, ahead of its
* own precaching. A CLASSIC, dependency-free script on purpose, in the style of
* theme-boot.js: importScripts cannot load a module, and switching the whole
* worker to injectManifest to get TypeScript here would mean rewriting every
* runtime caching rule by hand. The behaviour is pinned by
* tests/unit/pwa/swPush.test.ts, which runs this file against a fake worker.
*
* What arrives is the shared pushPayloadSchema: { title, body, url?, tag? },
* rendered on the server in the recipient's language, because a worker has no
* translations of its own. Only chat messages carry a tag, so one trip's chat
* collapses into one notification; everything else stands on its own.
*/
(function () {
'use strict';
var FALLBACK_TITLE = 'TREK';
var ICON = '/icons/icon-192x192.png';
var SUBSCRIPTIONS_URL = '/api/notifications/push/subscriptions';
/** The payload as an object; a push that is not JSON still shows, with its text as the body. */
function readPayload(data) {
if (!data) return {};
try {
var parsed = data.json();
return parsed && typeof parsed === 'object' ? parsed : {};
} catch (e) {
try {
return { body: data.text() };
} catch (e2) {
return {};
}
}
}
function text(value) {
return typeof value === 'string' ? value : '';
}
/**
* A path on this origin, or '/'. The url comes from the server, but a click
* must never be able to open another site in the installed app's window.
*/
function sameOriginPath(value) {
if (typeof value !== 'string' || !value) return '/';
try {
var resolved = new URL(value, self.location.origin);
if (resolved.origin !== self.location.origin) return '/';
return resolved.pathname + resolved.search + resolved.hash;
} catch (e) {
return '/';
}
}
self.addEventListener('push', function (event) {
var payload = readPayload(event.data);
var options = {
body: text(payload.body),
icon: ICON,
data: { url: sameOriginPath(payload.url) },
};
// A tag makes this notification replace an earlier one with the same tag
// (the server only sets one for chat, per trip). renotify goes with it, or
// the replacement would arrive without sound or vibration.
if (text(payload.tag)) {
options.tag = payload.tag;
options.renotify = true;
}
// Always show something. Chrome requires a visible notification for every
// push (userVisibleOnly), and WebKit revokes the subscription after a few
// silent ones, even while a TREK window has the focus.
event.waitUntil(self.registration.showNotification(text(payload.title) || FALLBACK_TITLE, options));
});
/** Focus a TREK window and take it to `path`, or open one there when none is left. */
function openPath(path) {
var target = self.location.origin + path;
return self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function (windows) {
var client = windows[0];
for (var i = 0; i < windows.length; i++) {
if (windows[i].focused) client = windows[i];
}
if (!client) return self.clients.openWindow(target);
return client
.focus()
.then(function (focused) {
// navigate() is only allowed on a window this worker controls.
var win = focused || client;
return typeof win.navigate === 'function' ? win.navigate(target) : win;
})
.catch(function () {
return self.clients.openWindow(target);
});
});
}
self.addEventListener('notificationclick', function (event) {
event.notification.close();
var data = event.notification.data || {};
event.waitUntil(openPath(sameOriginPath(data.url)));
});
/**
* The browser replaced or dropped the subscription (Firefox does this now and
* then). Subscribe again with the same server key and tell the server. The
* same-origin fetch carries the session cookie; when the session is gone this
* fails, and the re-sync after the next sign-in is the backstop.
*/
function resubscribe(event) {
var old = event.oldSubscription;
var key = old && old.options ? old.options.applicationServerKey : null;
var next = event.newSubscription
? Promise.resolve(event.newSubscription)
: key
? self.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key })
: Promise.resolve(null);
return next
.then(function (subscription) {
if (!subscription) return undefined;
return fetch(SUBSCRIPTIONS_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subscription: subscription.toJSON() }),
}).then(function (response) {
// The old endpoint is dead either way; forgetting it only tidies the
// server's device list, and only once the new one is safely stored.
if (!response.ok || !old || old.endpoint === subscription.endpoint) return undefined;
return fetch(SUBSCRIPTIONS_URL, {
method: 'DELETE',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: old.endpoint }),
});
});
})
.catch(function (err) {
console.warn('[push] could not renew the subscription', err);
});
}
self.addEventListener('pushsubscriptionchange', function (event) {
event.waitUntil(resubscribe(event));
});
})();
+76
View File
@@ -0,0 +1,76 @@
/**
* FE-PUSH-API-001 to FE-PUSH-API-005: the Web Push device endpoints.
*
* One line per endpoint and no logic, which is why a slip here is quiet: a
* wrong path or a body under the wrong key does not throw, the server answers
* 400 or 404 and push simply never arrives. So each call is driven through MSW
* with its verb, path and body pinned, and a failure has to reach the caller
* intact, because `webPush.ts` decides from it whether to roll a subscription
* back.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http, HttpResponse, type JsonBodyType } from 'msw'
import type { PushSubscriptionInput } from '@trek/shared'
import { server } from '../../tests/helpers/msw/server'
import { pushApi } from './push'
const BASE = '/api/notifications/push'
let seen: { method: string; url: string; body: unknown } | null = null
function record<T extends JsonBodyType>(data: T) {
return async ({ request }: { request: Request }) => {
const text = await request.text()
seen = { method: request.method, url: new URL(request.url).pathname, body: text ? JSON.parse(text) : undefined }
return HttpResponse.json(data)
}
}
const subscription: PushSubscriptionInput = {
endpoint: 'https://fcm.googleapis.com/fcm/send/abc',
expirationTime: null,
keys: { p256dh: 'BPk', auth: 'aGk' },
}
beforeEach(() => {
seen = null
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('pushApi', () => {
it('FE-PUSH-API-001: reads the public key', async () => {
server.use(http.get(`${BASE}/public-key`, record({ publicKey: 'BAECAwQ' })))
await expect(pushApi.getPublicKey()).resolves.toEqual({ publicKey: 'BAECAwQ' })
expect(seen).toMatchObject({ method: 'GET', url: `${BASE}/public-key` })
})
it('FE-PUSH-API-002: registers the subscription under `subscription`', async () => {
server.use(http.post(`${BASE}/subscriptions`, record({ success: true, devices: 2 })))
await expect(pushApi.subscribe(subscription)).resolves.toEqual({ success: true, devices: 2 })
expect(seen).toEqual({ method: 'POST', url: `${BASE}/subscriptions`, body: { subscription } })
})
it('FE-PUSH-API-003: forgets a device by its endpoint, in a DELETE body', async () => {
server.use(http.delete(`${BASE}/subscriptions`, record({ success: true })))
await expect(pushApi.unsubscribe(subscription.endpoint)).resolves.toEqual({ success: true })
expect(seen).toEqual({ method: 'DELETE', url: `${BASE}/subscriptions`, body: { endpoint: subscription.endpoint } })
})
it('FE-PUSH-API-004: a refusal reaches the caller instead of passing as success', async () => {
server.use(http.post(`${BASE}/subscriptions`, () => HttpResponse.json({ error: 'Unsupported push service' }, { status: 400 })))
await expect(pushApi.subscribe(subscription)).rejects.toMatchObject({ response: { status: 400 } })
})
it('FE-PUSH-API-005: the DELETE answer is checked against the shared contract, like the subscribe answer', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
server.use(http.delete(`${BASE}/subscriptions`, record({ success: false })))
await expect(pushApi.unsubscribe(subscription.endpoint)).resolves.toEqual({ success: false })
expect(warn).toHaveBeenCalledWith(
'[api] push.unsubscribe: response did not match the @trek/shared schema',
expect.anything(),
)
})
})
+49
View File
@@ -0,0 +1,49 @@
import apiClient, { parseInDev } from './client'
import {
pushPublicKeyResultSchema,
pushSubscribeResultSchema,
pushUnsubscribeResultSchema,
type PushPublicKeyResult,
type PushSubscribeRequest,
type PushSubscribeResult,
type PushSubscriptionInput,
type PushUnsubscribeRequest,
type PushUnsubscribeResult,
} from '@trek/shared'
/**
* The Web Push device endpoints, in their own file rather than in the
* `client.ts` god module. Types come from the shared contracts, so a server
* change the client has not caught up with is a typecheck failure rather than
* a runtime surprise.
*
* These calls are online only by nature: a subscription is a device talking to
* the server and to the browser's push service at the same moment, so there is
* nothing to queue or to cache. `push/webPush.ts` is the only caller. The one
* DELETE that does not come through here is the logout teardown's: it is a
* plain fetch in webPush.ts, so a session that already ran out cannot set off
* apiClient's 401 redirect in the middle of a logout.
*/
export const pushApi = {
/** The VAPID public key a browser subscribes with. */
getPublicKey: (): Promise<PushPublicKeyResult> =>
apiClient
.get('/notifications/push/public-key')
.then(r => parseInDev(pushPublicKeyResultSchema, r.data, 'push.publicKey')),
/** Registers this device. Repeating it for the same endpoint only refreshes the row. */
subscribe: (subscription: PushSubscriptionInput): Promise<PushSubscribeResult> => {
const body: PushSubscribeRequest = { subscription }
return apiClient
.post('/notifications/push/subscriptions', body)
.then(r => parseInDev(pushSubscribeResultSchema, r.data, 'push.subscribe'))
},
/** Forgets one device, by the endpoint its subscription delivers to. */
unsubscribe: (endpoint: string): Promise<PushUnsubscribeResult> => {
const body: PushUnsubscribeRequest = { endpoint }
return apiClient
.delete('/notifications/push/subscriptions', { data: body })
.then(r => parseInDev(pushUnsubscribeResultSchema, r.data, 'push.unsubscribe'))
},
}
@@ -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 }
}
@@ -1185,3 +1185,47 @@ describe('NotificationsTab — webhook and channel-test failures', () => {
expect(await screen.findByText('Test failed.')).toBeInTheDocument();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Web Push (#894)
// ─────────────────────────────────────────────────────────────────────────────
//
// The Push column comes from the server like every other; what this tab adds is
// the per-device card, shown only while the admin has the channel switched on.
// jsdom has no push APIs, so the real hook reads this "browser" as unable to
// receive push, which is exactly what the card must then say.
const pushMatrix = (active: boolean) => ({
preferences: { trip_invite: { inapp: true, push: true } },
channels: [
{ id: 'inapp', source: 'builtin', labelKey: 'settings.notificationPreferences.inapp', active: true, configured: true },
{ id: 'push', source: 'builtin', labelKey: 'settings.notificationPreferences.push', active, configured: false },
],
event_types: ['trip_invite'],
implemented_combos: { trip_invite: ['inapp', 'push'] },
});
describe('NotificationsTab: Web Push', () => {
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
});
it('FE-COMP-NOTIFICATIONS-PUSH-001: an active push channel gets its column and the device card', async () => {
mockMatrix(pushMatrix(true));
render(<NotificationsTab />);
expect(await screen.findByText('Push notifications on this device')).toBeInTheDocument();
expect(screen.getByText('Push')).toBeInTheDocument();
expect(await screen.findByRole('status')).toHaveTextContent(/push/i);
});
it('FE-COMP-NOTIFICATIONS-PUSH-002: no card and no column while the admin has push off', async () => {
mockMatrix(pushMatrix(false));
render(<NotificationsTab />);
await screen.findByText(/in-app/i);
expect(screen.queryByText('Push notifications on this device')).not.toBeInTheDocument();
expect(screen.queryByText('Push')).not.toBeInTheDocument();
});
});
@@ -1,11 +1,13 @@
import React, { useState, useEffect } from 'react'
import { Link } from 'react-router'
import { Lock } from 'lucide-react'
import { WEB_PUSH_CHANNEL_ID } from '@trek/shared'
import { useTranslation } from '../../i18n'
import { notificationsApi, settingsApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import ToggleSwitch from './ToggleSwitch'
import Section from './Section'
import WebPushCard from './WebPushCard'
interface ChannelDescriptor {
id: string
@@ -323,6 +325,9 @@ export default function NotificationsTab(): React.ReactElement {
</div>
</div>
)}
{/* Per device rather than per account: the column below picks the events,
this card decides whether this browser is one of the places they go. */}
{hasChannel(WEB_PUSH_CHANNEL_ID) && <WebPushCard />}
{pluginChannels.map(ch => (
<div key={ch.id} style={{ marginBottom: 16, padding: '12px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}>
<label style={{ display: 'block', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
@@ -0,0 +1,72 @@
/**
* FE-COMP-WEBPUSHCARD-001 to FE-COMP-WEBPUSHCARD-004: the desktop device card.
*
* Markup only: `useWebPush` is mocked and has its own spec, so these cases pin
* what each hook state puts on screen and that the buttons reach the hook.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import userEvent from '@testing-library/user-event'
import { render, screen } from '../../../tests/helpers/render'
import type { WebPushCardState } from '../../hooks/useWebPush'
import WebPushCard from './WebPushCard'
const hookState = vi.hoisted(() => ({ current: null as unknown as WebPushCardState }))
vi.mock('../../hooks/useWebPush', () => ({ useWebPush: () => hookState.current }))
function state(over: Partial<WebPushCardState> = {}): WebPushCardState {
return {
state: 'off',
stateText: null,
canSwitch: true,
on: false,
switchLabel: 'Turn on for this device',
busy: false,
canTest: false,
toggle: vi.fn(),
sendTest: vi.fn(),
...over,
}
}
beforeEach(() => {
hookState.current = state()
})
describe('WebPushCard', () => {
it('FE-COMP-WEBPUSHCARD-001: explains itself and offers to switch on', async () => {
render(<WebPushCard />)
expect(screen.getByText('Push notifications on this device')).toBeInTheDocument()
expect(screen.getByText(/Shows TREK notifications on this device/)).toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
const turnOn = screen.getByRole('button', { name: 'Turn on for this device' })
expect(turnOn.className).toContain('bg-accent')
expect(screen.getByRole('button', { name: 'Send test' })).toBeDisabled()
await userEvent.click(turnOn)
expect(hookState.current.toggle).toHaveBeenCalledTimes(1)
})
it('FE-COMP-WEBPUSHCARD-002: when on, says so, offers to switch off and to test', async () => {
hookState.current = state({ state: 'on', on: true, stateText: 'On for this device', switchLabel: 'Turn off for this device', canTest: true })
render(<WebPushCard />)
expect(screen.getByRole('status')).toHaveTextContent('On for this device')
expect(screen.getByRole('button', { name: 'Turn off for this device' }).className).not.toContain('bg-accent')
await userEvent.click(screen.getByRole('button', { name: 'Send test' }))
expect(hookState.current.sendTest).toHaveBeenCalledTimes(1)
})
it('FE-COMP-WEBPUSHCARD-003: a state that cannot be switched shows its sentence and no buttons', () => {
hookState.current = state({ state: 'ios-install', canSwitch: false, stateText: 'Add TREK to the Home Screen first.' })
render(<WebPushCard />)
expect(screen.getByRole('status')).toHaveTextContent('Add TREK to the Home Screen first.')
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('FE-COMP-WEBPUSHCARD-004: the switch is disabled while busy', () => {
hookState.current = state({ busy: true })
render(<WebPushCard />)
expect(screen.getByRole('button', { name: 'Turn on for this device' })).toBeDisabled()
})
})
@@ -0,0 +1,47 @@
import React from 'react'
import { useTranslation } from '../../i18n'
import { useWebPush } from '../../hooks/useWebPush'
/**
* The "Push notifications on this device" card of the Notifications tab. Only
* markup: the logic is `useWebPush`, shared with the phone's `MWebPushCard`.
* The tab renders it while the admin has the push channel switched on.
*/
export default function WebPushCard(): React.ReactElement {
const { t } = useTranslation()
const push = useWebPush()
return (
<div className="mb-4 rounded-lg border border-edge bg-surface-secondary p-3">
<p className="mb-1 text-caption font-semibold text-content-secondary">{t('settings.webPush.title')}</p>
<p className="mb-2 text-caption text-content-faint">{t('settings.webPush.hint')}</p>
{push.stateText && (
<p role="status" className="mb-2 text-caption text-content-secondary">
{push.stateText}
</p>
)}
{push.canSwitch && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={push.toggle}
disabled={push.busy}
className={`rounded-md px-3 py-1.5 text-caption disabled:cursor-not-allowed disabled:opacity-60 ${
push.on ? 'border border-edge bg-transparent text-content-secondary' : 'bg-accent text-accent-text'
}`}
>
{push.switchLabel}
</button>
<button
type="button"
onClick={push.sendTest}
disabled={!push.canTest}
className="rounded-md border border-edge bg-transparent px-3 py-1.5 text-caption text-content-secondary disabled:cursor-not-allowed disabled:opacity-50"
>
{t('settings.notificationPreferences.sendTest')}
</button>
</div>
)}
</div>
)
}
+1 -1
View File
@@ -68,7 +68,7 @@ export const settingsTabContexts: HelpContext[] = [
tab('settings-display', 'sliders', 3, [{ slug: GENERAL }]),
tab('settings-appearance', 'palette', 4, [{ slug: APPEARANCE }]),
tab('settings-map', 'map', 3, [{ slug: MAP }]),
tab('settings-notifications', 'bell', 3, [{ slug: NOTIFICATIONS }]),
tab('settings-notifications', 'bell', 4, [{ slug: NOTIFICATIONS }]),
tab('settings-integrations', 'plug', 4, [{ slug: SETTINGS, anchor: 'integrations-tab' }, { slug: 'MCP-Setup' }, { slug: 'Photo-Providers' }]),
tab('settings-offline', 'cloudOff', 4, [{ slug: OFFLINE }]),
tab('settings-account', 'user', 4, [{ slug: SETTINGS, anchor: 'account-tab-summary' }, { slug: 'Two-Factor-Authentication' }, { slug: 'Passkeys' }]),
+284
View File
@@ -0,0 +1,284 @@
/**
* FE-WEBPUSH-HOOK-001 to FE-WEBPUSH-HOOK-015: the one hook behind the desktop
* and phone "Push notifications on this device" cards.
*
* The browser glue (`push/webPush.ts`) has its own spec against a fake browser
* and is mocked here, so these cases are about what the card does with it:
*
* 1. **Every state has its sentence.** Unsupported, insecure, "install first",
* blocked and on each read differently, and "off" adds nothing to the hint.
* 2. **The gesture.** The key and the registration are fetched on mount, so
* the click can hand them straight to `enableWebPush` in the same tick.
* 3. **The browser is re-read** after every switch and when the page becomes
* visible again, so the card never shows a state of its own invention.
* 4. **Toasts only for real failures.** A block or a dismissed prompt is the
* user's answer, not an error.
*
* Translation is the real provider, so the sentences asserted are the shipped
* English.
*/
import { createElement, type ReactNode } from 'react'
import { act, renderHook, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { TranslationProvider } from '../i18n/TranslationContext'
import { WebPushError, type WebPushDeviceState } from '../push/webPush'
import { useWebPush } from './useWebPush'
const toast = { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }
vi.mock('../components/shared/Toast', () => ({ useToast: () => toast, default: () => toast }))
const push = vi.hoisted(() => ({
readWebPushDeviceState: vi.fn(),
getWebPushSupport: vi.fn(),
loadPushPublicKey: vi.fn(),
waitForPushRegistration: vi.fn(),
enableWebPush: vi.fn(),
disableWebPush: vi.fn(),
}))
vi.mock('../push/webPush', async importOriginal => {
const actual = await importOriginal<typeof import('../push/webPush')>()
return { ...actual, ...push }
})
const testChannel = vi.hoisted(() => vi.fn())
vi.mock('../api/client', () => ({ notificationsApi: { testChannel } }))
const REGISTRATION = { scope: '/' } as unknown as ServiceWorkerRegistration
const EN = {
on: 'On for this device',
enable: 'Turn on for this device',
disable: 'Turn off for this device',
unsupported: 'This browser cannot receive push notifications.',
insecure: 'Push needs TREK to be opened over HTTPS.',
iosInstall: 'On iPhone and iPad, add TREK to the Home Screen first and open it from there.',
denied: 'Notifications are blocked for TREK in this browser. Allow them in the browser settings, then try again.',
failed: 'Push could not be turned on for this device.',
testSuccess: 'Test notification sent.',
testFailed: 'Test failed.',
}
const wrapper = ({ children }: { children: ReactNode }) => createElement(TranslationProvider, null, children)
async function mounted(state: WebPushDeviceState) {
push.readWebPushDeviceState.mockResolvedValue(state)
const hook = renderHook(() => useWebPush(), { wrapper })
await waitFor(() => expect(hook.result.current.state).toBe(state))
return hook
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
beforeEach(() => {
vi.clearAllMocks()
push.getWebPushSupport.mockReturnValue({ supported: true, reason: null })
push.loadPushPublicKey.mockResolvedValue('BAECAwQ')
push.waitForPushRegistration.mockResolvedValue(REGISTRATION)
push.enableWebPush.mockResolvedValue(undefined)
push.disableWebPush.mockResolvedValue(undefined)
testChannel.mockResolvedValue({ success: true })
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('useWebPush: what the card says', () => {
it('FE-WEBPUSH-HOOK-001: nothing is claimed before the device has been read', () => {
push.readWebPushDeviceState.mockReturnValue(new Promise(() => {}))
const { result } = renderHook(() => useWebPush(), { wrapper })
expect(result.current.state).toBeNull()
expect(result.current.stateText).toBeNull()
expect(result.current.canSwitch).toBe(false)
expect(result.current.canTest).toBe(false)
})
it.each([
['unsupported', EN.unsupported],
['insecure', EN.insecure],
['ios-install', EN.iosInstall],
['denied', EN.denied],
] as const)('FE-WEBPUSH-HOOK-002: %s has its own sentence and no switch', async (state, text) => {
const { result } = await mounted(state)
expect(result.current.stateText).toBe(text)
expect(result.current.canSwitch).toBe(false)
expect(result.current.canTest).toBe(false)
})
it('FE-WEBPUSH-HOOK-003: off offers to switch on and adds no sentence', async () => {
const { result } = await mounted('off')
expect(result.current.stateText).toBeNull()
expect(result.current.canSwitch).toBe(true)
expect(result.current.on).toBe(false)
expect(result.current.switchLabel).toBe(EN.enable)
expect(result.current.canTest).toBe(false)
})
it('FE-WEBPUSH-HOOK-004: on says so, offers to switch off and allows a test', async () => {
const { result } = await mounted('on')
expect(result.current.stateText).toBe(EN.on)
expect(result.current.on).toBe(true)
expect(result.current.switchLabel).toBe(EN.disable)
expect(result.current.canTest).toBe(true)
})
})
describe('useWebPush: ahead of the click', () => {
it('FE-WEBPUSH-HOOK-005: fetches the key and the registration on mount where push is supported', async () => {
await mounted('off')
expect(push.loadPushPublicKey).toHaveBeenCalledTimes(1)
expect(push.waitForPushRegistration).toHaveBeenCalledTimes(1)
})
it('FE-WEBPUSH-HOOK-006: fetches neither where push cannot work', async () => {
push.getWebPushSupport.mockReturnValue({ supported: false, reason: 'insecure' })
await mounted('insecure')
expect(push.loadPushPublicKey).not.toHaveBeenCalled()
expect(push.waitForPushRegistration).not.toHaveBeenCalled()
})
})
describe('useWebPush: switching', () => {
it('FE-WEBPUSH-HOOK-007: switching on hands the prefetched key and registration over in the same tick', async () => {
const hook = await mounted('off')
await waitFor(() => expect(push.waitForPushRegistration).toHaveBeenCalled())
await act(async () => {})
const pending = deferred<void>()
push.enableWebPush.mockReturnValueOnce(pending.promise)
act(() => hook.result.current.toggle())
expect(push.enableWebPush).toHaveBeenCalledWith('BAECAwQ', REGISTRATION)
expect(hook.result.current.busy).toBe(true)
push.readWebPushDeviceState.mockResolvedValue('on')
await act(async () => pending.resolve())
await waitFor(() => expect(hook.result.current.state).toBe('on'))
expect(hook.result.current.busy).toBe(false)
expect(toast.error).not.toHaveBeenCalled()
})
it('FE-WEBPUSH-HOOK-008: without a prefetched key it fetches one first, and fails cleanly without', async () => {
push.loadPushPublicKey.mockResolvedValueOnce(null).mockResolvedValueOnce('LATEKEY')
const hook = await mounted('off')
await act(async () => hook.result.current.toggle())
await waitFor(() => expect(push.enableWebPush).toHaveBeenCalledWith('LATEKEY', REGISTRATION))
push.loadPushPublicKey.mockResolvedValueOnce(null)
vi.spyOn(console, 'warn').mockImplementation(() => {})
await waitFor(() => expect(hook.result.current.busy).toBe(false))
await act(async () => hook.result.current.toggle())
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(EN.failed))
})
it('FE-WEBPUSH-HOOK-009: a block or a dismissed prompt is an answer, not an error', async () => {
const hook = await mounted('off')
push.enableWebPush.mockRejectedValueOnce(new WebPushError('dismissed'))
await act(async () => hook.result.current.toggle())
await waitFor(() => expect(hook.result.current.busy).toBe(false))
push.enableWebPush.mockRejectedValueOnce(new WebPushError('denied'))
push.readWebPushDeviceState.mockResolvedValue('denied')
await act(async () => hook.result.current.toggle())
await waitFor(() => expect(hook.result.current.state).toBe('denied'))
expect(hook.result.current.stateText).toBe(EN.denied)
expect(toast.error).not.toHaveBeenCalled()
})
it('FE-WEBPUSH-HOOK-010: a failure to switch on is toasted and the device read again', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
const hook = await mounted('off')
push.enableWebPush.mockRejectedValueOnce(new WebPushError('failed'))
await act(async () => hook.result.current.toggle())
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(EN.failed))
expect(push.readWebPushDeviceState).toHaveBeenCalledTimes(2)
})
it('FE-WEBPUSH-HOOK-011: switching off goes through disableWebPush', async () => {
const hook = await mounted('on')
push.readWebPushDeviceState.mockResolvedValue('off')
await act(async () => hook.result.current.toggle())
await waitFor(() => expect(hook.result.current.state).toBe('off'))
expect(push.disableWebPush).toHaveBeenCalledTimes(1)
expect(push.enableWebPush).not.toHaveBeenCalled()
})
it('FE-WEBPUSH-HOOK-012: a browser that refuses to unsubscribe is toasted and stays on', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
const hook = await mounted('on')
push.disableWebPush.mockRejectedValueOnce(new Error('refused'))
await act(async () => hook.result.current.toggle())
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Error'))
await waitFor(() => expect(hook.result.current.busy).toBe(false))
expect(hook.result.current.state).toBe('on')
})
it('FE-WEBPUSH-HOOK-013: a second click while busy, or with nothing to switch, does nothing', async () => {
const hook = await mounted('off')
push.enableWebPush.mockReturnValueOnce(new Promise(() => {}))
act(() => hook.result.current.toggle())
act(() => hook.result.current.toggle())
expect(push.enableWebPush).toHaveBeenCalledTimes(1)
const denied = await mounted('denied')
act(() => denied.result.current.toggle())
expect(push.enableWebPush).toHaveBeenCalledTimes(1)
expect(push.disableWebPush).not.toHaveBeenCalled()
})
})
describe('useWebPush: the rest', () => {
it('FE-WEBPUSH-HOOK-014: the test goes through the generic channel route', async () => {
const hook = await mounted('on')
const pending = deferred<{ success: boolean; error?: string }>()
testChannel.mockReturnValueOnce(pending.promise)
act(() => hook.result.current.sendTest())
expect(testChannel).toHaveBeenCalledWith('push')
expect(hook.result.current.canTest).toBe(false)
act(() => hook.result.current.sendTest())
expect(testChannel).toHaveBeenCalledTimes(1)
await act(async () => pending.resolve({ success: true }))
expect(toast.success).toHaveBeenCalledWith(EN.testSuccess)
expect(hook.result.current.canTest).toBe(true)
testChannel.mockResolvedValueOnce({ success: false, error: 'No devices' })
await act(async () => hook.result.current.sendTest())
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('No devices'))
testChannel.mockResolvedValueOnce({ success: false })
await act(async () => hook.result.current.sendTest())
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(EN.testFailed))
testChannel.mockRejectedValueOnce(new Error('timeout'))
toast.error.mockClear()
await act(async () => hook.result.current.sendTest())
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(EN.testFailed))
})
it('FE-WEBPUSH-HOOK-015: coming back to the page reads the device again, until unmount', async () => {
const hook = await mounted('denied')
push.readWebPushDeviceState.mockResolvedValue('off')
const visibility = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden')
act(() => {
document.dispatchEvent(new Event('visibilitychange'))
})
expect(push.readWebPushDeviceState).toHaveBeenCalledTimes(1)
visibility.mockReturnValue('visible')
act(() => {
document.dispatchEvent(new Event('visibilitychange'))
})
await waitFor(() => expect(hook.result.current.state).toBe('off'))
hook.unmount()
document.dispatchEvent(new Event('visibilitychange'))
expect(push.readWebPushDeviceState).toHaveBeenCalledTimes(2)
})
})
+156
View File
@@ -0,0 +1,156 @@
import { useEffect, useRef, useState } from 'react'
import { WEB_PUSH_CHANNEL_ID } from '@trek/shared'
import { notificationsApi } from '../api/client'
import { useTranslation } from '../i18n'
import { useToast } from '../components/shared/Toast'
import {
disableWebPush,
enableWebPush,
getWebPushSupport,
loadPushPublicKey,
readWebPushDeviceState,
waitForPushRegistration,
WebPushError,
type WebPushDeviceState,
} from '../push/webPush'
/**
* Everything the "Push notifications on this device" card does, in one hook.
*
* Shared by the desktop Settings tab and its phone twin on purpose: the two
* shells render different markup around identical logic, and SonarCloud's 3%
* duplication budget on new code has no room for a second copy
* (`useDawarichConnection` is the same pattern for the same reason).
*
* The browser is the only record of whether this device is subscribed, so the
* hook keeps no copy of its own beyond the last reading, and reads again after
* every switch and whenever the page comes back into view (which is how a user
* returns from unblocking notifications in the browser's site settings).
*/
export interface WebPushCardState {
/** Null until the first look at this device has finished. */
state: WebPushDeviceState | null
/** The sentence for that state, already translated; null when the hint says it all. */
stateText: string | null
/** Push is possible here and not blocked, so the card offers the switch. */
canSwitch: boolean
on: boolean
switchLabel: string
busy: boolean
canTest: boolean
toggle: () => void
sendTest: () => void
}
const STATE_TEXT_KEYS: Partial<Record<WebPushDeviceState, string>> = {
unsupported: 'settings.webPush.unsupported',
insecure: 'settings.webPush.insecure',
'ios-install': 'settings.webPush.iosInstall',
denied: 'settings.webPush.denied',
on: 'settings.webPush.enabled',
}
export function useWebPush(): WebPushCardState {
const { t } = useTranslation()
const toast = useToast()
const [state, setState] = useState<WebPushDeviceState | null>(null)
const [busy, setBusy] = useState(false)
const [testing, setTesting] = useState(false)
const publicKey = useRef<string | null>(null)
const registration = useRef<ServiceWorkerRegistration | null>(null)
const mounted = useRef(true)
useEffect(() => {
mounted.current = true
let cancelled = false
const look = () => {
void readWebPushDeviceState().then(next => {
if (!cancelled) setState(next)
})
}
look()
if (getWebPushSupport().supported) {
// Fetched before any click. WebKit honours the permission prompt and
// subscribe() only inside the user's gesture, and awaiting either of
// these in the click handler would spend it.
void loadPushPublicKey().then(key => {
if (!cancelled) publicKey.current = key
})
void waitForPushRegistration().then(reg => {
if (!cancelled) registration.current = reg
})
}
const onVisible = () => {
if (document.visibilityState === 'visible') look()
}
document.addEventListener('visibilitychange', onVisible)
return () => {
cancelled = true
mounted.current = false
document.removeEventListener('visibilitychange', onVisible)
}
}, [])
const settle = async () => {
const next = await readWebPushDeviceState()
if (!mounted.current) return
setState(next)
setBusy(false)
}
const switchOn = (): Promise<void> => {
const key = publicKey.current
if (key) return enableWebPush(key, registration.current)
// The key could not be fetched ahead of time. Fetching it now may cost the
// gesture on WebKit, but it is the only way left to try.
return loadPushPublicKey().then(late => {
if (!late) throw new WebPushError('failed', 'no public key')
return enableWebPush(late, registration.current)
})
}
const toggle = () => {
if (busy || (state !== 'on' && state !== 'off')) return
const turningOn = state === 'off'
setBusy(true)
const action = turningOn ? switchOn() : disableWebPush()
action
.catch(err => {
if (!mounted.current) return
// A block or a dismissed prompt is the user's answer, and the card
// shows the first on its own; only a real failure deserves a toast.
if (err instanceof WebPushError && (err.code === 'denied' || err.code === 'dismissed')) return
console.warn('[push] switching this device failed', err)
toast.error(turningOn ? t('settings.webPush.failed') : t('common.error'))
})
.finally(() => void settle())
}
const sendTest = () => {
if (testing) return
setTesting(true)
notificationsApi
.testChannel(WEB_PUSH_CHANNEL_ID)
.then(result => {
if (result.success) toast.success(t('settings.notificationPreferences.testSuccess'))
else toast.error(result.error || t('settings.notificationPreferences.testFailed'))
})
.catch(() => toast.error(t('settings.notificationPreferences.testFailed')))
.finally(() => {
if (mounted.current) setTesting(false)
})
}
const textKey = state ? STATE_TEXT_KEYS[state] : undefined
return {
state,
stateText: textKey ? t(textKey) : null,
canSwitch: state === 'on' || state === 'off',
on: state === 'on',
switchLabel: state === 'on' ? t('settings.webPush.disable') : t('settings.webPush.enable'),
busy,
canTest: state === 'on' && !testing && !busy,
toggle,
sendTest,
}
}
@@ -1,6 +1,7 @@
import { authApi, notificationsApi } from '../../../api/client'
import type { TranslationFn } from '../../../types'
import type { useAdmin } from '../../../pages/admin/useAdmin'
import { SWITCH_ONLY_CHANNELS, useNotificationChannels } from '../../../components/Admin/useNotificationChannels'
import MToggle from '../../components/MToggle'
import MAdminNotifyMatrix from './MAdminNotifyMatrix'
import { MAdminButton, MAdminCard, MAdminCardHead, MAdminField, MAdminInput, MAdminRow } from './MAdminUi'
@@ -18,39 +19,18 @@ const SMTP_FIELDS = [
{ key: 'smtp_from', label: 'From Address', placeholder: 'trek@example.com' },
]
// Notifications section: channel toggles (email/webhook/ntfy/in-app), SMTP
// credentials, trip reminders, admin webhook + ntfy targets and the per-event
// preference matrix — the desktop notifications tab in mobile cards.
// Notifications section: channel toggles (email/webhook/ntfy/web push/in-app),
// SMTP credentials, trip reminders, admin webhook + ntfy targets and the
// per-event preference matrix: the desktop notifications tab in mobile cards.
// The channel switches come from useNotificationChannels, shared with desktop.
export default function MAdminNotificationsSection({ admin, t }: MAdminNotificationsSectionProps) {
const { toast, smtpValues, setSmtpValues, smtpLoaded, setTripRemindersEnabled } = admin
// Derive active channels from notification_channels (plural) with fallback
// to notification_channel (singular) for existing installs.
const rawChannels = smtpValues.notification_channels ?? smtpValues.notification_channel ?? 'none'
const activeChans = rawChannels === 'none' ? [] : rawChannels.split(',').map((c: string) => c.trim())
const emailActive = activeChans.includes('email')
const webhookActive = activeChans.includes('webhook')
const ntfyActive = activeChans.includes('ntfy')
const channels = useNotificationChannels(admin, t)
const emailActive = channels.isActive('email')
const tripRemindersActive = smtpValues.notify_trip_reminder !== 'false'
const smtpConfigured = !!smtpValues.smtp_host?.trim()
const setChannels = async (email: boolean, webhook: boolean, ntfy: boolean) => {
// Preserve channel ids this toggle doesn't know about instead of
// rebuilding the CSV from just these three booleans.
const others = activeChans.filter((c: string) => c !== 'email' && c !== 'webhook' && c !== 'ntfy')
const chans = [email && 'email', webhook && 'webhook', ntfy && 'ntfy', ...others].filter(Boolean).join(',') || 'none'
setSmtpValues((prev) => ({ ...prev, notification_channels: chans }))
try {
await authApi.updateAppSettings({ notification_channels: chans })
} catch {
const reverted =
[emailActive && 'email', webhookActive && 'webhook', ntfyActive && 'ntfy', ...others].filter(Boolean).join(',') ||
'none'
setSmtpValues((prev) => ({ ...prev, notification_channels: reverted }))
toast.error(t('common.error'))
}
}
const saveSmtp = async () => {
// Saves credentials only — channel activation is auto-saved by the toggle.
const notifKeys = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify']
@@ -179,7 +159,7 @@ export default function MAdminNotificationsSection({ admin, t }: MAdminNotificat
<MToggle
checked={emailActive}
ariaLabel={t('admin.notifications.emailPanel.title')}
onChange={() => setChannels(!emailActive, webhookActive, ntfyActive)}
onChange={() => channels.toggle('email')}
/>
}
/>
@@ -219,35 +199,22 @@ export default function MAdminNotificationsSection({ admin, t }: MAdminNotificat
</div>
</MAdminCard>
{/* Webhook */}
<MAdminCard>
<MAdminCardHead
title={t('admin.notifications.webhookPanel.title')}
hint={t('admin.webhook.hint')}
trailing={
<MToggle
checked={webhookActive}
ariaLabel={t('admin.notifications.webhookPanel.title')}
onChange={() => setChannels(emailActive, !webhookActive, ntfyActive)}
/>
}
/>
</MAdminCard>
{/* Ntfy */}
<MAdminCard>
<MAdminCardHead
title={t('admin.notifications.ntfy')}
hint={t('admin.ntfy.hint')}
trailing={
<MToggle
checked={ntfyActive}
ariaLabel={t('admin.notifications.ntfy')}
onChange={() => setChannels(emailActive, webhookActive, !ntfyActive)}
/>
}
/>
</MAdminCard>
{/* Webhook, Ntfy and Web Push: a title, a hint and the switch each */}
{SWITCH_ONLY_CHANNELS.map((ch) => (
<MAdminCard key={ch.id}>
<MAdminCardHead
title={t(ch.titleKey)}
hint={t(ch.hintKey)}
trailing={
<MToggle
checked={channels.isActive(ch.id)}
ariaLabel={t(ch.titleKey)}
onChange={() => channels.toggle(ch.id)}
/>
}
/>
</MAdminCard>
))}
{/* In-App (always on) */}
<MAdminCard>
@@ -1,11 +1,13 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router'
import { Bell, Link2, Send } from 'lucide-react'
import { WEB_PUSH_CHANNEL_ID } from '@trek/shared'
import { useTranslation } from '../../../i18n'
import { notificationsApi, settingsApi } from '../../../api/client'
import { useToast } from '../../../components/shared/Toast'
import { MSetCard, MSetEyebrow, MSetInput, MSetButton, MSetHint } from './MSettingsUi'
import MChip from '../../components/MChip'
import MWebPushCard from './MWebPushCard'
interface ChannelDescriptor {
id: string
@@ -52,8 +54,8 @@ const MASKED = '••••••••'
/**
* "Notifications" section — NotificationsTab parity: webhook + ntfy channel
* credentials, plugin channels and the event/channel preference matrix,
* rendered as chip rows instead of the desktop grid.
* credentials, push on this device, plugin channels and the event/channel
* preference matrix, rendered as chip rows instead of the desktop grid.
*/
export default function MSettingsNotifications() {
const { t } = useTranslation()
@@ -282,6 +284,8 @@ export default function MSettingsNotifications() {
</div>
)}
{hasChannel(WEB_PUSH_CHANNEL_ID) && <MWebPushCard />}
{pluginChannels.map((ch) => (
<div key={ch.id} className="mb-3 rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] p-3">
<MSetEyebrow className="mb-1">{channelLabel(ch, t)}</MSetEyebrow>
@@ -0,0 +1,38 @@
import { BellRing, BellOff, Send } from 'lucide-react'
import { useTranslation } from '../../../i18n'
import { useWebPush } from '../../../hooks/useWebPush'
import { MSetButton, MSetEyebrow, MSetHint } from './MSettingsUi'
/**
* Phone twin of the desktop `WebPushCard`: markup only, over the same
* `useWebPush` hook. MSettingsNotifications renders it while the admin has the
* push channel switched on.
*/
export default function MWebPushCard() {
const { t } = useTranslation()
const push = useWebPush()
return (
<div className="mb-3 rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] p-3">
<MSetEyebrow className="mb-1">{t('settings.webPush.title')}</MSetEyebrow>
<MSetHint className="mb-2 mt-0">{t('settings.webPush.hint')}</MSetHint>
{push.stateText && (
<p role="status" className="mb-2 text-[0.75rem] font-semibold text-m-ink">
{push.stateText}
</p>
)}
{push.canSwitch && (
<div className="flex flex-wrap gap-2">
<MSetButton variant={push.on ? 'ghost' : 'primary'} onClick={push.toggle} disabled={push.busy}>
{push.on ? <BellOff size={13} /> : <BellRing size={13} />}
{push.switchLabel}
</MSetButton>
<MSetButton variant="ghost" onClick={push.sendTest} disabled={!push.canTest}>
<Send size={13} />
{t('settings.notificationPreferences.sendTest')}
</MSetButton>
</div>
)}
</div>
)
}
+2 -1
View File
@@ -1441,7 +1441,8 @@ describe('AdminPage', () => {
// Find the webhook panel heading ('Webhook') — exact match to avoid 'Admin Webhook'
const webhookHeading = screen.getByRole('heading', { name: /^webhook$/i });
const webhookCard = webhookHeading.closest<HTMLElement>('.bg-white');
// The channel switch cards are drawn with theme tokens, so the card is found by its shape.
const webhookCard = webhookHeading.closest<HTMLElement>('.rounded-xl');
// Find the toggle button in webhook card
const webhookToggle = within(webhookCard!).getByRole('button');
fireEvent.click(webhookToggle);
@@ -1,4 +1,4 @@
// FE-ADMNOT-001 to FE-ADMNOT-043
// FE-ADMNOT-001 to FE-ADMNOT-046
import { http, HttpResponse } from 'msw';
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -571,4 +571,47 @@ describe('AdminNotificationsTab', () => {
expect(screen.queryByRole('heading', { name: 'Admin Webhook' })).not.toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'Admin Ntfy' })).not.toBeInTheDocument();
});
it('FE-ADMNOT-044: Web Push has its own card after Ntfy, before In-App', () => {
renderTab({ smtpValues: { notification_channels: 'email' } });
const headings = screen.getAllByRole('heading').map(h => h.textContent);
expect(headings.indexOf('Web Push')).toBe(headings.indexOf('Ntfy') + 1);
expect(headings.indexOf('In-App')).toBe(headings.indexOf('Web Push') + 1);
expect(within(card('Web Push')).getByText(/receive notifications on their phones/)).toBeInTheDocument();
expect(within(card('Web Push')).getByRole('button', { name: 'Web Push' })).toHaveAttribute('aria-pressed', 'false');
});
it('FE-ADMNOT-045: switching Web Push on adds push to the list and keeps the rest', async () => {
let body: Record<string, unknown> | null = null;
server.use(
http.put('/api/auth/app-settings', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({});
})
);
const admin = renderTab({ smtpValues: { notification_channels: 'email,plugin-gotify' } });
fireEvent.click(within(card('Web Push')).getByRole('button', { name: 'Web Push' }));
await waitFor(() => expect(body).toEqual({ notification_channels: 'email,push,plugin-gotify' }));
expect(replaySmtp(admin.setSmtpValues, 0).notification_channels).toBe('email,push,plugin-gotify');
});
it('FE-ADMNOT-046: an active push channel shows its switch on, and switching it off keeps email', async () => {
let body: Record<string, unknown> | null = null;
server.use(
http.put('/api/auth/app-settings', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({});
})
);
renderTab({ smtpValues: { notification_channels: 'email,push' } });
const toggle = within(card('Web Push')).getByRole('button', { name: 'Web Push' });
expect(toggle).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(toggle);
await waitFor(() => expect(body).toEqual({ notification_channels: 'email' }));
});
});
@@ -4,43 +4,39 @@ import { Save } from 'lucide-react'
import type { TranslationFn } from '../../types'
import type { useAdmin } from './useAdmin'
import AdminNotificationsPanel from './AdminNotificationsPanel'
import { SWITCH_ONLY_CHANNELS, useNotificationChannels } from '../../components/Admin/useNotificationChannels'
interface AdminNotificationsTabProps {
admin: ReturnType<typeof useAdmin>
t: TranslationFn
}
// "Notifications" admin tab: email/webhook/ntfy channel toggles, SMTP credentials,
// trip reminders, admin webhook + ntfy targets, and the per-event preference matrix.
// Derives channel state from smtpValues exactly as the original inline IIFE did.
/** The on/off switch at the right of a channel card. */
function ChannelSwitch({ on, label, onToggle }: { on: boolean; label: string; onToggle: () => void }): React.ReactElement {
return (
<button type="button"
onClick={onToggle}
aria-pressed={on}
aria-label={label}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ${on ? 'bg-content' : 'bg-edge'}`}
>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-surface-card transition-transform duration-200"
style={{ transform: on ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
)
}
// "Notifications" admin tab: email/webhook/ntfy/web push channel toggles, SMTP
// credentials, trip reminders, admin webhook + ntfy targets, and the per-event
// preference matrix. The channel switches come from useNotificationChannels,
// which the phone section shares.
export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTabProps): React.ReactElement {
const { toast, smtpValues, setSmtpValues, smtpLoaded, setTripRemindersEnabled, managed } = admin
// Derive active channels from smtpValues.notification_channels (plural)
// with fallback to notification_channel (singular) for existing installs
const rawChannels = smtpValues.notification_channels ?? smtpValues.notification_channel ?? 'none'
const activeChans = rawChannels === 'none' ? [] : rawChannels.split(',').map((c: string) => c.trim())
const emailActive = activeChans.includes('email')
const webhookActive = activeChans.includes('webhook')
const ntfyActive = activeChans.includes('ntfy')
const channels = useNotificationChannels(admin, t)
const emailActive = channels.isActive('email')
const tripRemindersActive = smtpValues.notify_trip_reminder !== 'false'
const setChannels = async (email: boolean, webhook: boolean, ntfy: boolean) => {
// Preserve any id this toggle doesn't know about instead of rebuilding the CSV from
// just these three booleans — that used to silently DROP anything else stored here.
const others = activeChans.filter((c: string) => c !== 'email' && c !== 'webhook' && c !== 'ntfy')
const chans = [email && 'email', webhook && 'webhook', ntfy && 'ntfy', ...others].filter(Boolean).join(',') || 'none'
setSmtpValues(prev => ({ ...prev, notification_channels: chans }))
try {
await authApi.updateAppSettings({ notification_channels: chans })
} catch {
// Revert state on failure
const reverted = [emailActive && 'email', webhookActive && 'webhook', ntfyActive && 'ntfy', ...others].filter(Boolean).join(',') || 'none'
setSmtpValues(prev => ({ ...prev, notification_channels: reverted }))
toast.error(t('common.error'))
}
}
const smtpConfigured = !!(smtpValues.smtp_host?.trim())
const saveNotifications = async () => {
// Saves credentials only — channel activation is auto-saved by the toggle
@@ -64,7 +60,7 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
pairs cards row by row and leaves a hole under the shorter one, and the SMTP
card is taller than all the toggle rows together. Grouped by audience, not by
height — the channels a user can receive on the left, everything the operator
sends or receives themselves on the right. The three channel switches stay
sends or receives themselves on the right. The channel switches stay
together because they all write the same notification_channels list.
On a managed install the two admin-target cards are gone and the right column
is the matrix alone — still a column, so the grid keeps working. */}
@@ -81,13 +77,7 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
<h2 className="font-semibold text-slate-900">{t('admin.notifications.emailPanel.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.smtp.hint')}</p>
</div>
<button type="button"
onClick={() => setChannels(!emailActive, webhookActive, ntfyActive)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ${emailActive ? 'bg-content' : 'bg-edge'}`}
>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: emailActive ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
<ChannelSwitch on={emailActive} label={t('admin.notifications.emailPanel.title')} onToggle={() => channels.toggle('email')} />
</div>
<div className={`p-6 space-y-3 ${!emailActive ? 'opacity-50 pointer-events-none' : ''}`}>
{smtpLoaded && [
@@ -152,39 +142,18 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
</div>
</>)}
{/* Webhook Panel */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 flex items-center justify-between">
<div>
<h2 className="font-semibold text-slate-900">{t('admin.notifications.webhookPanel.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.webhook.hint')}</p>
{/* Webhook, Ntfy and Web Push: a title, a hint and the switch each */}
{SWITCH_ONLY_CHANNELS.map(ch => (
<div key={ch.id} className="bg-surface-card rounded-xl border border-edge overflow-hidden">
<div className="px-6 py-4 flex items-center justify-between">
<div>
<h2 className="font-semibold text-content">{t(ch.titleKey)}</h2>
<p className="text-caption text-content-faint mt-1">{t(ch.hintKey)}</p>
</div>
<ChannelSwitch on={channels.isActive(ch.id)} label={t(ch.titleKey)} onToggle={() => channels.toggle(ch.id)} />
</div>
<button type="button"
onClick={() => setChannels(emailActive, !webhookActive, ntfyActive)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ${webhookActive ? 'bg-content' : 'bg-edge'}`}
>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: webhookActive ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
</div>
{/* Ntfy Panel */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 flex items-center justify-between">
<div>
<h2 className="font-semibold text-slate-900">{t('admin.notifications.ntfy')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.ntfy.hint') || 'Allow users to configure their own ntfy topics for push notifications.'}</p>
</div>
<button type="button"
onClick={() => setChannels(emailActive, webhookActive, !ntfyActive)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ${ntfyActive ? 'bg-content' : 'bg-edge'}`}
>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: ntfyActive ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
</div>
))}
{/* In-App Panel */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
+535
View File
@@ -0,0 +1,535 @@
/**
* FE-PUSH-001 to FE-PUSH-044: this device's Web Push, against a fake browser.
*
* jsdom has no service worker, no PushManager and no Notification, so every
* case installs the three by hand. What is worth pinning here:
*
* 1. **Support is a reason, not a yes/no.** An insecure origin, an iPhone in
* a Safari tab and a browser without push each need a different sentence,
* because each has a different fix.
* 2. **The gesture.** WebKit honours the permission prompt only inside the
* click, so `enableWebPush` must ask before its first await.
* 3. **The browser is the record.** A subscription the server refused is
* rolled back, switching off unsubscribes even when the server cannot be
* told, and logout forgets the device before the cookie goes, bounded so it
* can never hang, and with a plain fetch so a 401 cannot redirect.
* 4. **The re-sync after sign-in** repeats what the device holds, replaces a
* subscription made with an old server key, and never calls update().
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
const api = vi.hoisted(() => ({
getPublicKey: vi.fn(),
subscribe: vi.fn(),
unsubscribe: vi.fn(),
}))
vi.mock('../api/push', () => ({ pushApi: api }))
const network = vi.hoisted(() => ({ offline: false }))
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: () => network.offline }))
import {
base64UrlToUint8Array,
currentPushSubscription,
disableWebPush,
enableWebPush,
forgetPushDeviceOnLogout,
getPushPermission,
getWebPushSupport,
loadPushPublicKey,
readWebPushDeviceState,
resyncPushSubscription,
toSubscriptionInput,
waitForPushRegistration,
WebPushError,
} from './webPush'
/** A P-256 point is 65 bytes; these tests only need two keys that differ. */
const SERVER_KEY = 'BAECAwQ'
const OTHER_KEY = 'BAUGBwg'
class FakeSubscription {
readonly options: { applicationServerKey: ArrayBuffer | null; userVisibleOnly: boolean }
unsubscribe = vi.fn(async () => true)
constructor(
readonly endpoint: string,
key: Uint8Array | null,
) {
this.options = { applicationServerKey: key ? new Uint8Array(key).buffer : null, userVisibleOnly: true }
}
toJSON() {
return { endpoint: this.endpoint, expirationTime: null, keys: { p256dh: 'p256-' + this.endpoint, auth: 'auth' } }
}
}
class FakePushManager {
current: FakeSubscription | null = null
created = 0
subscribe = vi.fn(async (options: { userVisibleOnly?: boolean; applicationServerKey: Uint8Array }) => {
this.created++
this.current = new FakeSubscription(`https://push.example/sub-${this.created}`, options.applicationServerKey)
return this.current
})
getSubscription = vi.fn(async () => this.current)
}
interface Browser {
pushManager: FakePushManager
registration: { pushManager: FakePushManager; update: ReturnType<typeof vi.fn> }
container: { getRegistration: ReturnType<typeof vi.fn>; ready: Promise<unknown> }
notification: { permission: NotificationPermission; requestPermission: ReturnType<typeof vi.fn> }
}
function installBrowser(opts: { permission?: NotificationPermission; answer?: NotificationPermission } = {}): Browser {
const pushManager = new FakePushManager()
const registration = { pushManager, update: vi.fn() }
const container = { getRegistration: vi.fn(async () => registration), ready: Promise.resolve(registration) }
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container })
const notification = {
permission: opts.permission ?? 'default',
requestPermission: vi.fn(async () => {
notification.permission = opts.answer ?? 'granted'
return notification.permission
}),
}
vi.stubGlobal('Notification', notification)
vi.stubGlobal('PushManager', function PushManager() {})
vi.stubGlobal('isSecureContext', true)
return { pushManager, registration, container, notification }
}
function subscribed(browser: Browser, key = SERVER_KEY, endpoint = 'https://push.example/existing') {
browser.pushManager.current = new FakeSubscription(endpoint, base64UrlToUint8Array(key))
return browser.pushManager.current
}
/** Own properties shadow jsdom's prototype getters; afterEach deletes them again. */
function setUserAgent(ua: string, touchPoints = 0) {
Object.defineProperty(navigator, 'userAgent', { configurable: true, get: () => ua })
Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, get: () => touchPoints })
}
const IPHONE = 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile Safari/604.1'
const IPAD_DESKTOP = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.0 Safari/605.1.15'
const CHROME = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/140.0 Safari/537.36'
beforeEach(() => {
vi.clearAllMocks()
network.offline = false
api.getPublicKey.mockResolvedValue({ publicKey: SERVER_KEY })
api.subscribe.mockResolvedValue({ success: true, devices: 1 })
api.unsubscribe.mockResolvedValue({ success: true })
setUserAgent(CHROME)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
vi.useRealTimers()
delete (navigator as unknown as { serviceWorker?: unknown }).serviceWorker
delete (navigator as unknown as { standalone?: unknown }).standalone
delete (navigator as unknown as { userAgent?: unknown }).userAgent
delete (navigator as unknown as { maxTouchPoints?: unknown }).maxTouchPoints
})
describe('getWebPushSupport', () => {
it('FE-PUSH-001: a secure browser with a worker, PushManager and Notification is supported', () => {
installBrowser()
expect(getWebPushSupport()).toEqual({ supported: true, reason: null })
})
it('FE-PUSH-002: an insecure origin says so before anything else', () => {
installBrowser()
vi.stubGlobal('isSecureContext', false)
setUserAgent(IPHONE)
expect(getWebPushSupport()).toEqual({ supported: false, reason: 'insecure' })
})
it('FE-PUSH-003: an iPhone in a Safari tab is told to add TREK to the Home Screen', () => {
vi.stubGlobal('isSecureContext', true)
setUserAgent(IPHONE, 5)
expect(getWebPushSupport()).toEqual({ supported: false, reason: 'ios-install' })
})
it('FE-PUSH-004: an iPad asking for the desktop site counts as iOS too', () => {
installBrowser()
setUserAgent(IPAD_DESKTOP, 5)
expect(getWebPushSupport().reason).toBe('ios-install')
})
it('FE-PUSH-005: a real Mac (no touch screen) is not mistaken for an iPad', () => {
installBrowser()
setUserAgent(IPAD_DESKTOP, 0)
expect(getWebPushSupport().supported).toBe(true)
})
it('FE-PUSH-006: the installed iOS app is supported', () => {
installBrowser()
setUserAgent(IPHONE, 5)
Object.defineProperty(navigator, 'standalone', { configurable: true, value: true })
expect(getWebPushSupport().supported).toBe(true)
})
it('FE-PUSH-007: the installed app on an iOS without push still reads as unsupported', () => {
vi.stubGlobal('isSecureContext', true)
setUserAgent(IPHONE, 5)
Object.defineProperty(navigator, 'standalone', { configurable: true, value: true })
expect(getWebPushSupport()).toEqual({ supported: false, reason: 'unsupported' })
})
it('FE-PUSH-008: a desktop browser without the APIs is unsupported', () => {
vi.stubGlobal('isSecureContext', true)
expect(getWebPushSupport()).toEqual({ supported: false, reason: 'unsupported' })
})
})
describe('small helpers', () => {
it('FE-PUSH-009: the permission falls back to default where Notification is missing', () => {
expect(getPushPermission()).toBe('default')
installBrowser({ permission: 'denied' })
expect(getPushPermission()).toBe('denied')
})
it('FE-PUSH-010: base64url keys decode to their raw bytes, padding or not', () => {
expect(Array.from(base64UrlToUint8Array('AQID'))).toEqual([1, 2, 3])
expect(Array.from(base64UrlToUint8Array('-_8'))).toEqual([251, 255])
expect(Array.from(base64UrlToUint8Array('-_8='))).toEqual([251, 255])
})
it('FE-PUSH-011: a subscription becomes the shared contract shape', () => {
const sub = new FakeSubscription('https://push.example/a', null)
expect(toSubscriptionInput(sub as unknown as PushSubscription)).toEqual({
endpoint: 'https://push.example/a',
expirationTime: null,
keys: { p256dh: 'p256-https://push.example/a', auth: 'auth' },
})
})
it('FE-PUSH-012: a subscription without keys is refused rather than sent half', () => {
const broken = { toJSON: () => ({ endpoint: 'https://push.example/a', keys: {} }) }
expect(() => toSubscriptionInput(broken as unknown as PushSubscription)).toThrow(WebPushError)
})
it('FE-PUSH-013: the public key loads, and a failure is null rather than a rejection', async () => {
await expect(loadPushPublicKey()).resolves.toBe(SERVER_KEY)
vi.spyOn(console, 'warn').mockImplementation(() => {})
api.getPublicKey.mockRejectedValueOnce(new Error('offline'))
await expect(loadPushPublicKey()).resolves.toBeNull()
})
it('FE-PUSH-014: there is no subscription to read without the APIs or a registration', async () => {
await expect(currentPushSubscription()).resolves.toBeNull()
const browser = installBrowser()
browser.container.getRegistration.mockResolvedValueOnce(undefined)
await expect(currentPushSubscription()).resolves.toBeNull()
})
})
describe('waitForPushRegistration', () => {
it('FE-PUSH-015: hands over the active registration', async () => {
const browser = installBrowser()
await expect(waitForPushRegistration()).resolves.toBe(browser.registration)
})
it('FE-PUSH-016: gives up after the timeout on a page that never gets a worker', async () => {
const browser = installBrowser()
browser.container.ready = new Promise(() => {})
vi.useFakeTimers()
const waiting = waitForPushRegistration(1000)
await vi.advanceTimersByTimeAsync(1000)
await expect(waiting).resolves.toBeNull()
})
it('FE-PUSH-017: is null straight away where the APIs are missing', async () => {
await expect(waitForPushRegistration()).resolves.toBeNull()
})
})
describe('readWebPushDeviceState', () => {
it('FE-PUSH-018: an unsupported browser reports its reason', async () => {
vi.stubGlobal('isSecureContext', false)
await expect(readWebPushDeviceState()).resolves.toBe('insecure')
})
it('FE-PUSH-019: a blocked permission wins over an old subscription', async () => {
const browser = installBrowser({ permission: 'denied' })
subscribed(browser)
await expect(readWebPushDeviceState()).resolves.toBe('denied')
})
it('FE-PUSH-020: on with a subscription, off without', async () => {
const browser = installBrowser({ permission: 'granted' })
await expect(readWebPushDeviceState()).resolves.toBe('off')
subscribed(browser)
await expect(readWebPushDeviceState()).resolves.toBe('on')
})
it('FE-PUSH-021: a registration that cannot be read counts as off, not as an error', async () => {
const browser = installBrowser({ permission: 'granted' })
vi.spyOn(console, 'warn').mockImplementation(() => {})
browser.container.getRegistration.mockRejectedValueOnce(new Error('site data blocked'))
await expect(readWebPushDeviceState()).resolves.toBe('off')
})
})
describe('enableWebPush', () => {
it('FE-PUSH-022: asks for permission before its first await, so the gesture still counts', async () => {
const browser = installBrowser()
const done = enableWebPush(SERVER_KEY, browser.registration as unknown as ServiceWorkerRegistration)
expect(browser.notification.requestPermission).toHaveBeenCalledTimes(1)
await done
})
it('FE-PUSH-023: subscribes visibly with the server key and registers the device', async () => {
const browser = installBrowser({ permission: 'granted' })
await enableWebPush(SERVER_KEY, browser.registration as unknown as ServiceWorkerRegistration)
expect(browser.notification.requestPermission).not.toHaveBeenCalled()
const options = browser.pushManager.subscribe.mock.calls[0][0]
expect(options.userVisibleOnly).toBe(true)
expect(Array.from(options.applicationServerKey)).toEqual(Array.from(base64UrlToUint8Array(SERVER_KEY)))
expect(api.subscribe).toHaveBeenCalledWith(toSubscriptionInput(browser.pushManager.current as unknown as PushSubscription))
})
it('FE-PUSH-024: waits for the worker when no registration was handed over', async () => {
const browser = installBrowser({ permission: 'granted' })
await enableWebPush(SERVER_KEY)
expect(browser.pushManager.subscribe).toHaveBeenCalledTimes(1)
})
it('FE-PUSH-025: a block is reported as denied and nothing is subscribed', async () => {
const browser = installBrowser({ answer: 'denied' })
await expect(enableWebPush(SERVER_KEY, browser.registration as unknown as ServiceWorkerRegistration)).rejects.toMatchObject({ code: 'denied' })
expect(browser.pushManager.subscribe).not.toHaveBeenCalled()
})
it('FE-PUSH-026: a prompt closed without an answer is dismissed, not a failure', async () => {
const browser = installBrowser({ answer: 'default' })
await expect(enableWebPush(SERVER_KEY, browser.registration as unknown as ServiceWorkerRegistration)).rejects.toMatchObject({ code: 'dismissed' })
})
it('FE-PUSH-027: without a worker it fails instead of spinning', async () => {
const browser = installBrowser({ permission: 'granted' })
browser.container.ready = new Promise(() => {})
vi.useFakeTimers()
const done = enableWebPush(SERVER_KEY).catch(err => err)
await vi.advanceTimersByTimeAsync(10_000)
expect(await done).toMatchObject({ code: 'failed' })
})
it('FE-PUSH-028: a subscription left over from an older server key is replaced', async () => {
const browser = installBrowser({ permission: 'granted' })
const stale = subscribed(browser, OTHER_KEY, 'https://push.example/stale')
browser.pushManager.subscribe.mockRejectedValueOnce(new DOMException('key mismatch', 'InvalidStateError'))
await enableWebPush(SERVER_KEY, browser.registration as unknown as ServiceWorkerRegistration)
expect(api.unsubscribe).toHaveBeenCalledWith('https://push.example/stale')
expect(stale.unsubscribe).toHaveBeenCalled()
expect(browser.pushManager.subscribe).toHaveBeenCalledTimes(2)
expect(api.subscribe).toHaveBeenCalledTimes(1)
})
it('FE-PUSH-029: any other subscribe failure is final', async () => {
const browser = installBrowser({ permission: 'granted' })
browser.pushManager.subscribe.mockRejectedValueOnce(new DOMException('push service down', 'AbortError'))
await expect(enableWebPush(SERVER_KEY, browser.registration as unknown as ServiceWorkerRegistration)).rejects.toMatchObject({ code: 'failed' })
expect(browser.pushManager.subscribe).toHaveBeenCalledTimes(1)
expect(api.subscribe).not.toHaveBeenCalled()
})
it('FE-PUSH-030: a subscription the server refused is rolled back in the browser', async () => {
const browser = installBrowser({ permission: 'granted' })
api.subscribe.mockRejectedValueOnce(new Error('400'))
await expect(enableWebPush(SERVER_KEY, browser.registration as unknown as ServiceWorkerRegistration)).rejects.toMatchObject({ code: 'failed' })
expect(browser.pushManager.current?.unsubscribe).toHaveBeenCalled()
})
})
describe('disableWebPush', () => {
it('FE-PUSH-031: forgets the device on the server, then unsubscribes the browser', async () => {
const browser = installBrowser({ permission: 'granted' })
const sub = subscribed(browser)
const order: string[] = []
api.unsubscribe.mockImplementationOnce(async () => {
order.push('server')
return { success: true }
})
sub.unsubscribe.mockImplementationOnce(async () => {
order.push('browser')
return true
})
await disableWebPush()
expect(api.unsubscribe).toHaveBeenCalledWith('https://push.example/existing')
expect(order).toEqual(['server', 'browser'])
})
it('FE-PUSH-032: still unsubscribes when the server cannot be told', async () => {
const browser = installBrowser({ permission: 'granted' })
const sub = subscribed(browser)
vi.spyOn(console, 'warn').mockImplementation(() => {})
api.unsubscribe.mockRejectedValueOnce(new Error('offline'))
await disableWebPush()
expect(sub.unsubscribe).toHaveBeenCalled()
})
it('FE-PUSH-033: does nothing without a subscription, and reports a browser that refuses', async () => {
const browser = installBrowser({ permission: 'granted' })
await disableWebPush()
expect(api.unsubscribe).not.toHaveBeenCalled()
const sub = subscribed(browser)
sub.unsubscribe.mockRejectedValueOnce(new Error('refused'))
await expect(disableWebPush()).rejects.toThrow('refused')
})
})
describe('resyncPushSubscription', () => {
it('FE-PUSH-034: stays quiet when push is unsupported, not granted or the app is offline', async () => {
await resyncPushSubscription()
const browser = installBrowser({ permission: 'default' })
subscribed(browser)
await resyncPushSubscription()
browser.notification.permission = 'granted'
network.offline = true
await resyncPushSubscription()
expect(browser.container.getRegistration).not.toHaveBeenCalled()
expect(api.subscribe).not.toHaveBeenCalled()
})
it('FE-PUSH-035: registers the subscription the device holds again, without update()', async () => {
const browser = installBrowser({ permission: 'granted' })
const sub = subscribed(browser)
await resyncPushSubscription()
expect(api.subscribe).toHaveBeenCalledWith(toSubscriptionInput(sub as unknown as PushSubscription))
expect(browser.pushManager.subscribe).not.toHaveBeenCalled()
expect(browser.registration.update).not.toHaveBeenCalled()
})
it('FE-PUSH-036: does nothing for a device that never switched push on', async () => {
installBrowser({ permission: 'granted' })
await resyncPushSubscription()
expect(api.getPublicKey).not.toHaveBeenCalled()
expect(api.subscribe).not.toHaveBeenCalled()
})
it('FE-PUSH-037: replaces a subscription made with an old server key', async () => {
const browser = installBrowser({ permission: 'granted' })
const stale = subscribed(browser, OTHER_KEY, 'https://push.example/stale')
await resyncPushSubscription()
expect(api.unsubscribe).toHaveBeenCalledWith('https://push.example/stale')
expect(stale.unsubscribe).toHaveBeenCalled()
expect(browser.pushManager.subscribe).toHaveBeenCalledTimes(1)
expect(api.subscribe).toHaveBeenCalledWith(expect.objectContaining({ endpoint: 'https://push.example/sub-1' }))
})
it('FE-PUSH-038: a browser that does not report its key is taken at its word', async () => {
const browser = installBrowser({ permission: 'granted' })
browser.pushManager.current = new FakeSubscription('https://push.example/unknown-key', null)
await resyncPushSubscription()
expect(browser.pushManager.subscribe).not.toHaveBeenCalled()
expect(api.subscribe).toHaveBeenCalledWith(expect.objectContaining({ endpoint: 'https://push.example/unknown-key' }))
})
it('FE-PUSH-039: never rejects, even when the server does', async () => {
const browser = installBrowser({ permission: 'granted' })
subscribed(browser)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
api.subscribe.mockRejectedValueOnce(new Error('500'))
await expect(resyncPushSubscription()).resolves.toBeUndefined()
expect(warn).toHaveBeenCalled()
})
})
describe('forgetPushDeviceOnLogout', () => {
/** The logout DELETE is a plain fetch, so a 401 can never reach apiClient's login redirect. */
function stubFetch(answer: (init?: RequestInit) => Promise<Response>) {
const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => answer(init))
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
it('FE-PUSH-040: forgets the device on the server with a plain fetch, and in the browser', async () => {
const browser = installBrowser({ permission: 'granted' })
const sub = subscribed(browser)
const fetchMock = stubFetch(async () => Response.json({ success: true }))
await forgetPushDeviceOnLogout()
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('/api/notifications/push/subscriptions')
expect(init).toMatchObject({
method: 'DELETE',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
})
expect(JSON.parse(String(init?.body))).toEqual({ endpoint: 'https://push.example/existing' })
expect(init?.signal).toBeInstanceOf(AbortSignal)
expect(api.unsubscribe).not.toHaveBeenCalled()
expect(sub.unsubscribe).toHaveBeenCalled()
})
it('FE-PUSH-041: returns at once where there is no push at all', async () => {
const fetchMock = stubFetch(async () => Response.json({ success: true }))
await forgetPushDeviceOnLogout()
expect(fetchMock).not.toHaveBeenCalled()
expect(api.unsubscribe).not.toHaveBeenCalled()
})
it('FE-PUSH-042: a server that never answers cannot hold the logout up or keep the browser subscribed', async () => {
const browser = installBrowser({ permission: 'granted' })
const sub = subscribed(browser)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
// Like a real fetch to a server that never answers: it settles only once its signal aborts.
const fetchMock = stubFetch(
init =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')))
}),
)
vi.useFakeTimers()
let finished = false
const done = forgetPushDeviceOnLogout(3000).then(() => {
finished = true
})
await vi.advanceTimersByTimeAsync(2999)
expect(finished).toBe(false)
expect(fetchMock.mock.calls[0][1]?.signal?.aborted).toBe(false)
// The browser half never waited for the server half.
expect(sub.unsubscribe).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
await done
expect(finished).toBe(true)
expect(fetchMock.mock.calls[0][1]?.signal?.aborted).toBe(true)
expect(warn).toHaveBeenCalledWith('[push] forgetting the device on the server failed', expect.any(DOMException))
})
it('FE-PUSH-043: a 401 from a session that already ran out neither redirects nor stops the browser half', async () => {
const browser = installBrowser({ permission: 'granted' })
const sub = subscribed(browser)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const before = window.location.href
stubFetch(async () => Response.json({ error: 'Authentication required', code: 'AUTH_REQUIRED' }, { status: 401 }))
await expect(forgetPushDeviceOnLogout()).resolves.toBeUndefined()
expect(window.location.href).toBe(before)
expect(api.unsubscribe).not.toHaveBeenCalled()
expect(sub.unsubscribe).toHaveBeenCalled()
expect(warn).toHaveBeenCalledWith('[push] forgetting the device on the server failed', expect.any(Error))
})
it('FE-PUSH-044: a network failure on the server half still unsubscribes the browser', async () => {
const browser = installBrowser({ permission: 'granted' })
const sub = subscribed(browser)
vi.spyOn(console, 'warn').mockImplementation(() => {})
stubFetch(() => Promise.reject(new TypeError('Failed to fetch')))
await forgetPushDeviceOnLogout()
expect(sub.unsubscribe).toHaveBeenCalled()
})
})
+338
View File
@@ -0,0 +1,338 @@
import type { PushSubscriptionInput, PushUnsubscribeRequest } from '@trek/shared'
import { pushApi } from '../api/push'
import { isEffectivelyOffline } from '../sync/networkMode'
import { isIosDevice, isIosStandalone } from '../utils/iosDevice'
/**
* Web Push on this device: whether it can work here, and switching it on and
* off.
*
* Deliberately online only, and deliberately without a mirror. The browser's
* push subscription is the one record of "this device receives push"; the
* server keeps its copy keyed by the subscription's endpoint, and nothing in
* Zustand or Dexie repeats either. Making a subscription needs the browser's
* push service and the server at the same moment, so an offline queue would
* have nothing to replay, and a cached copy could only ever disagree with the
* browser.
*
* React-free on purpose: the settings cards (through `useWebPush`), the auth
* store (logout and the re-sync after sign-in) and the tests all drive these
* same functions.
*/
export type WebPushUnsupportedReason = 'insecure' | 'unsupported' | 'ios-install'
/** `reason` is null exactly when `supported` is true. */
export interface WebPushSupport {
supported: boolean
reason: WebPushUnsupportedReason | null
}
/** What the settings card has to say about this device. */
export type WebPushDeviceState = WebPushUnsupportedReason | 'denied' | 'off' | 'on'
export type WebPushErrorCode = 'denied' | 'dismissed' | 'failed'
/** Why switching push on did not work: blocked, the prompt closed without an answer, or anything else. */
export class WebPushError extends Error {
readonly code: WebPushErrorCode
readonly reason: unknown
constructor(code: WebPushErrorCode, reason?: unknown) {
super(`Web Push: ${code}`)
this.name = 'WebPushError'
this.code = code
this.reason = reason
}
}
/**
* How long switching push on waits for the service worker. `registerSW.js`
* installs it on page load, so this only runs out on a page that never got
* one, and there the card should say so rather than spin.
*/
export const REGISTRATION_TIMEOUT_MS = 10_000
/** Logout waits at most this long for this device to be forgotten. It must never hang on it. */
export const LOGOUT_PUSH_TIMEOUT_MS = 3_000
function hasPushApis(): boolean {
return (
typeof window !== 'undefined' &&
typeof navigator !== 'undefined' &&
'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window
)
}
/**
* Whether push can work in this browser, and if not, the reason the user can
* do something about.
*/
export function getWebPushSupport(): WebPushSupport {
if (typeof window === 'undefined') return unsupported('unsupported')
// A service worker, and push with it, exists only in a secure context: HTTPS,
// or localhost while developing. A plain http LAN address never gets one.
if (window.isSecureContext === false) return unsupported('insecure')
// iOS and iPadOS deliver push only to TREK added to the Home Screen. In a
// Safari tab the APIs are simply missing, which would read as "this browser
// cannot" when the fix is one tap in the share menu.
if (isIosDevice() && !isIosStandalone()) return unsupported('ios-install')
if (!hasPushApis()) return unsupported('unsupported')
return { supported: true, reason: null }
}
function unsupported(reason: WebPushUnsupportedReason): WebPushSupport {
return { supported: false, reason }
}
/** The notification permission as the browser holds it right now. */
export function getPushPermission(): NotificationPermission {
return typeof window !== 'undefined' && 'Notification' in window ? Notification.permission : 'default'
}
/** The applicationServerKey the browser wants: the raw bytes behind the server's base64url string. */
export function base64UrlToUint8Array(value: string): Uint8Array<ArrayBuffer> {
const base64 = value.replace(/-/g, '+').replace(/_/g, '/')
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=')
return Uint8Array.from(atob(padded), ch => ch.codePointAt(0) ?? 0)
}
/** A key the browser reports it subscribed with, compared with the server's. Unknown counts as the same. */
function keyMatches(subscribedWith: ArrayBuffer | null | undefined, serverKey: Uint8Array): boolean {
if (!subscribedWith) return true
const bytes = new Uint8Array(subscribedWith)
return bytes.length === serverKey.length && bytes.every((byte, i) => byte === serverKey[i])
}
/** What `PushSubscription.toJSON()` hands over, in the shape of the shared contract. */
export function toSubscriptionInput(subscription: PushSubscription): PushSubscriptionInput {
const json = subscription.toJSON()
const p256dh = json.keys?.p256dh
const auth = json.keys?.auth
if (!json.endpoint || !p256dh || !auth) throw new WebPushError('failed', 'subscription without keys')
return { endpoint: json.endpoint, expirationTime: json.expirationTime ?? null, keys: { p256dh, auth } }
}
/** Settles with `fallback` once `ms` have passed, unless `promise` settled first. */
function withTimeout<T, F>(promise: Promise<T>, ms: number, fallback: F): Promise<T | F> {
let timer: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<F>(resolve => {
timer = setTimeout(() => resolve(fallback), ms)
})
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
}
/**
* A step whose failure changes nothing for the caller. It still gets a line in
* the console, because "push quietly stopped" is otherwise impossible to trace.
*/
async function bestEffort<T>(what: string, step: () => Promise<T>): Promise<T | undefined> {
try {
return await step()
} catch (err) {
console.warn(`[push] ${what} failed`, err)
return undefined
}
}
/**
* The registration already in charge of this origin, or null. It neither waits
* for one to appear nor calls update(): the version handover owns updates, and
* its tests count every call.
*/
async function existingRegistration(): Promise<ServiceWorkerRegistration | null> {
const container = navigator.serviceWorker
if (!container || typeof container.getRegistration !== 'function') return null
return (await container.getRegistration()) ?? null
}
/** This device's subscription, or null when it has none or cannot have one. */
export async function currentPushSubscription(): Promise<PushSubscription | null> {
if (!hasPushApis()) return null
const registration = await existingRegistration()
if (!registration?.pushManager) return null
return registration.pushManager.getSubscription()
}
/**
* The active registration, for subscribing. `ready` never settles on a page
* without a service worker, hence the timeout.
*/
export async function waitForPushRegistration(
timeoutMs = REGISTRATION_TIMEOUT_MS,
): Promise<ServiceWorkerRegistration | null> {
if (!hasPushApis()) return null
return withTimeout(navigator.serviceWorker.ready, timeoutMs, null)
}
/** The server's public key, or null when it could not be fetched now. Never rejects. */
export async function loadPushPublicKey(): Promise<string | null> {
const result = await bestEffort('loading the public key', () => pushApi.getPublicKey())
return result?.publicKey || null
}
/** What the settings card shows for this device. Never rejects. */
export async function readWebPushDeviceState(): Promise<WebPushDeviceState> {
const { reason } = getWebPushSupport()
if (reason) return reason
if (getPushPermission() === 'denied') return 'denied'
const subscription = await bestEffort('reading the subscription', () => currentPushSubscription())
return subscription ? 'on' : 'off'
}
/** Both halves of dropping a subscription, each best effort: the server's row and the browser's own. */
async function forgetSubscription(subscription: PushSubscription): Promise<void> {
await bestEffort('forgetting the device on the server', () => pushApi.unsubscribe(subscription.endpoint))
await bestEffort('unsubscribing in the browser', () => subscription.unsubscribe())
}
async function subscribeWithKey(
registration: ServiceWorkerRegistration,
key: Uint8Array<ArrayBuffer>,
): Promise<PushSubscription> {
const options: PushSubscriptionOptionsInit = { userVisibleOnly: true, applicationServerKey: key }
try {
return await registration.pushManager.subscribe(options)
} catch (err) {
// A subscription made with an earlier server key blocks a new one. Drop it
// and try once more; any other failure is final.
const stale = await registration.pushManager.getSubscription()
if (!stale || keyMatches(stale.options?.applicationServerKey, key)) throw err
await forgetSubscription(stale)
return registration.pushManager.subscribe(options)
}
}
/**
* Switches push on for this device: the permission, then the browser's
* subscription, then the server.
*
* Call it straight from the click handler, with the key and the registration
* fetched beforehand. WebKit honours the permission prompt and subscribe() only
* inside the user's gesture, and an awaited network call in front of them would
* spend it. The permission request is therefore the first thing that runs.
*/
export async function enableWebPush(publicKey: string, registration?: ServiceWorkerRegistration | null): Promise<void> {
const permission =
Notification.permission === 'granted' ? 'granted' : await Notification.requestPermission()
if (permission === 'denied') throw new WebPushError('denied')
if (permission !== 'granted') throw new WebPushError('dismissed')
let subscription: PushSubscription
try {
const active = registration?.pushManager ? registration : await waitForPushRegistration()
if (!active?.pushManager) throw new WebPushError('failed', 'no service worker')
subscription = await subscribeWithKey(active, base64UrlToUint8Array(publicKey))
} catch (err) {
throw err instanceof WebPushError ? err : new WebPushError('failed', err)
}
try {
await pushApi.subscribe(toSubscriptionInput(subscription))
} catch (err) {
// The server never took this subscription, so it must not outlive the
// failure: the card reads the browser's record, and would claim push works
// on a device the server will never send to.
await bestEffort('rolling back the browser subscription', () => subscription.unsubscribe())
throw err instanceof WebPushError ? err : new WebPushError('failed', err)
}
}
/**
* Switches push off for this device. The server's row goes first, while this
* session can still name the endpoint. If that request fails the browser still
* unsubscribes: that alone stops delivery here, and the server drops a row the
* push service answers 404 or 410 for on its next send.
*/
export async function disableWebPush(): Promise<void> {
const subscription = await currentPushSubscription()
if (!subscription) return
await bestEffort('forgetting the device on the server', () => pushApi.unsubscribe(subscription.endpoint))
await subscription.unsubscribe()
}
/**
* Tells the server about this device's subscription again after sign-in. The
* server upserts by endpoint, so repeating it is harmless, and it heals a row
* that was lost or still belongs to the account that used this browser before.
*
* When the server's key changed since the device subscribed, every push to the
* old subscription fails its signature check, so it is replaced. Permission is
* already granted then, which lets the browser subscribe again without a
* gesture; where it refuses, the device simply reads as off and the card offers
* to switch it on. Never rejects.
*/
export async function resyncPushSubscription(): Promise<void> {
if (!getWebPushSupport().supported || getPushPermission() !== 'granted' || isEffectivelyOffline()) return
await bestEffort('re-registering this device', async () => {
const registration = await existingRegistration()
const subscription = await registration?.pushManager?.getSubscription()
if (!registration || !subscription) return
const { publicKey } = await pushApi.getPublicKey()
const key = base64UrlToUint8Array(publicKey)
let current = subscription
if (!keyMatches(subscription.options?.applicationServerKey, key)) {
await forgetSubscription(subscription)
current = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key })
}
await pushApi.subscribe(toSubscriptionInput(current))
})
}
/** Where the logout step tells the server to forget this device. */
const PUSH_SUBSCRIPTIONS_URL = '/api/notifications/push/subscriptions'
/**
* The server half of the logout teardown, as a plain fetch rather than through
* apiClient: when the session has already run out the answer is a 401, and
* apiClient meets that with its redirect to the login page, in the middle of
* the logout. It carries its own timeout, so the request itself is cut off
* rather than left running behind a logout that has moved on.
*/
async function forgetOnServerAtLogout(endpoint: string, timeoutMs: number): Promise<void> {
const body: PushUnsubscribeRequest = { endpoint }
const abort = new AbortController()
const timer = setTimeout(() => abort.abort(), timeoutMs)
try {
const response = await fetch(PUSH_SUBSCRIPTIONS_URL, {
method: 'DELETE',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: abort.signal,
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
} finally {
clearTimeout(timer)
}
}
/**
* The logout step: forget this device on the server and in the browser, so the
* next account on a shared device does not receive this one's notifications.
* It has to run while the session cookie still exists, and it waits at most
* `timeoutMs`. Never rejects, and never redirects: a server that already
* dropped the session, never answers or cannot be reached only costs the
* server half, the browser still unsubscribes.
*
* The two halves run side by side rather than one after the other. The server
* half may use up the whole timeout, and the browser half is the one that
* stops delivery here, so it must not queue behind it. The server needs
* nothing the browser still has to hold: the endpoint is already in hand, and
* a row left behind goes on the next send, when the push service answers 404
* or 410.
*/
export async function forgetPushDeviceOnLogout(timeoutMs = LOGOUT_PUSH_TIMEOUT_MS): Promise<void> {
if (!hasPushApis()) return
const teardown = bestEffort('forgetting this device at logout', async () => {
const subscription = await currentPushSubscription()
if (!subscription) return
await Promise.all([
bestEffort('forgetting the device on the server', () => forgetOnServerAtLogout(subscription.endpoint, timeoutMs)),
bestEffort('unsubscribing in the browser', () => subscription.unsubscribe()),
])
})
await withTimeout(teardown, timeoutMs, undefined)
}
+9
View File
@@ -15,6 +15,7 @@ import { clearAllPluginSessions } from './pluginStore'
import { forgetStartDestination } from '../utils/startDestination'
import { forgetServerLanguage } from './settingsStore'
import { markSignedOut, clearSignedOut } from '../utils/signedOut'
import { forgetPushDeviceOnLogout, resyncPushSubscription } from '../push/webPush'
interface AuthResponse {
user: User
@@ -119,6 +120,9 @@ async function onAuthSuccess(userId: number): Promise<void> {
// an SPA session, so a second login in the same tab would leave the mutation
// queue without a flush trigger. Re-registering is a no-op while they are up.
registerSyncTriggers()
// Tell the server again which push subscription this device holds, in the
// background: sign-in must not wait on it, and it never rejects.
void resyncPushSubscription()
}
export const useAuthStore = create<AuthState>()(
@@ -264,6 +268,11 @@ export const useAuthStore = create<AuthState>()(
// and the next account would come up believing it is offline over a working
// connection, with nothing cached to answer from.
setForcedOffline(false)
// Forget this device's push subscription, on the server and in the browser,
// or the next account on a shared device keeps receiving this one's
// notifications. It has to happen here: the DELETE needs the session cookie
// that step 4 clears. Best effort and bounded, so logout never hangs on it.
await forgetPushDeviceOnLogout()
// 4. Tell server to clear the httpOnly cookie (best-effort).
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
// 5. Clear service worker caches containing sensitive data.
+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
}
@@ -1,4 +1,4 @@
// FE-MOB-ANOTIF-001 to FE-MOB-ANOTIF-031
// FE-MOB-ANOTIF-001 to FE-MOB-ANOTIF-035
import { useState } from 'react';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { http, HttpResponse } from 'msw';
@@ -558,4 +558,38 @@ describe('MAdminNotificationsSection', () => {
await waitFor(() => expect(bodies).toHaveLength(1));
expect(bodies[0].smtp_pass).toBe('hunter2');
});
it('FE-MOB-ANOTIF-033: Web Push has its own card between Ntfy and In-App', () => {
render(<Harness initial={{ notification_channels: 'email' }} />);
const switches = screen.getAllByRole('switch').map((el) => el.getAttribute('aria-label'));
expect(switches.indexOf('Web Push')).toBe(switches.indexOf('Ntfy') + 1);
expect(switches.indexOf('In-App')).toBe(switches.indexOf('Web Push') + 1);
expect(screen.getByText(/receive notifications on their phones/)).toBeInTheDocument();
expect(screen.getByRole('switch', { name: 'Web Push' })).toHaveAttribute('aria-checked', 'false');
});
it('FE-MOB-ANOTIF-034: switching Web Push on saves the list and keeps unknown ids', async () => {
const bodies = captureAppSettings();
const user = userEvent.setup();
render(<Harness initial={{ notification_channels: 'webhook,pushover' }} />);
await user.click(screen.getByRole('switch', { name: 'Web Push' }));
await waitFor(() => expect(bodies).toHaveLength(1));
expect(bodies[0]).toEqual({ notification_channels: 'webhook,push,pushover' });
expect(screen.getByRole('switch', { name: 'Web Push' })).toHaveAttribute('aria-checked', 'true');
});
it('FE-MOB-ANOTIF-035: a failed Web Push save rolls the switch back', async () => {
failAppSettings();
const toast = buildToast();
const user = userEvent.setup();
render(<Harness initial={{ notification_channels: 'push' }} toast={toast} />);
await user.click(screen.getByRole('switch', { name: 'Web Push' }));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Error'));
expect(screen.getByRole('switch', { name: 'Web Push' })).toHaveAttribute('aria-checked', 'true');
});
});
@@ -594,3 +594,37 @@ describe('MSettingsNotifications', () => {
});
});
});
// Web Push (#894): the per-device card joins the other channel cards while the
// admin has push switched on. jsdom has no push APIs, so the real hook reads
// this "browser" as unable to receive push, and the card has to say so.
describe('MSettingsNotifications: Web Push', () => {
it('FE-MOB-SETNOTIF-033: an active push channel gets its chip and the device card', async () => {
usePrefs(
matrix({
preferences: { trip_invite: { inapp: true, push: true } },
channels: [builtin('inapp'), builtin('push', { configured: false })],
implemented_combos: { trip_invite: ['inapp', 'push'] },
}),
);
render(<MSettingsNotifications />);
expect(await screen.findByText('Push notifications on this device')).toBeInTheDocument();
const row = (await screen.findByText('Trip invitations')).parentElement as HTMLElement;
expect(within(row).getByRole('button', { name: 'Push' })).toBeInTheDocument();
expect(await screen.findByRole('status')).toHaveTextContent(/push/i);
});
it('FE-MOB-SETNOTIF-034: no card while the admin has push off', async () => {
usePrefs(
matrix({
channels: [builtin('inapp'), builtin('push', { active: false })],
implemented_combos: { trip_invite: ['inapp', 'push'] },
}),
);
render(<MSettingsNotifications />);
await screen.findByText('Trip invitations');
expect(screen.queryByText('Push notifications on this device')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,77 @@
// FE-MOB-WEBPUSH-001 to FE-MOB-WEBPUSH-004: the phone device card.
//
// Markup only, like its desktop twin: useWebPush is mocked and has its own
// spec, so these cases pin what each hook state renders and that the buttons
// reach the hook.
import { describe, it, expect, vi, beforeEach } from 'vitest';
import userEvent from '@testing-library/user-event';
import { render, screen } from '../../../helpers/render';
import type { WebPushCardState } from '../../../../src/hooks/useWebPush';
import MWebPushCard from '../../../../src/mobile/screens/settings/MWebPushCard';
const hookState = vi.hoisted(() => ({ current: null as unknown as WebPushCardState }));
vi.mock('../../../../src/hooks/useWebPush', () => ({ useWebPush: () => hookState.current }));
function state(over: Partial<WebPushCardState> = {}): WebPushCardState {
return {
state: 'off',
stateText: null,
canSwitch: true,
on: false,
switchLabel: 'Turn on for this device',
busy: false,
canTest: false,
toggle: vi.fn(),
sendTest: vi.fn(),
...over,
};
}
beforeEach(() => {
hookState.current = state();
});
describe('MWebPushCard', () => {
it('FE-MOB-WEBPUSH-001: explains itself and offers to switch on', async () => {
render(<MWebPushCard />);
expect(screen.getByText('Push notifications on this device')).toBeInTheDocument();
expect(screen.getByText(/Shows TREK notifications on this device/)).toBeInTheDocument();
expect(screen.queryByRole('status')).not.toBeInTheDocument();
const turnOn = screen.getByRole('button', { name: 'Turn on for this device' });
expect(turnOn.className).toContain('bg-m-act');
expect(screen.getByRole('button', { name: 'Send test' })).toBeDisabled();
await userEvent.click(turnOn);
expect(hookState.current.toggle).toHaveBeenCalledTimes(1);
});
it('FE-MOB-WEBPUSH-002: when on, says so, offers to switch off and to test', async () => {
hookState.current = state({
state: 'on',
on: true,
stateText: 'On for this device',
switchLabel: 'Turn off for this device',
canTest: true,
});
render(<MWebPushCard />);
expect(screen.getByRole('status')).toHaveTextContent('On for this device');
expect(screen.getByRole('button', { name: 'Turn off for this device' }).className).not.toContain('bg-m-act');
await userEvent.click(screen.getByRole('button', { name: 'Send test' }));
expect(hookState.current.sendTest).toHaveBeenCalledTimes(1);
});
it('FE-MOB-WEBPUSH-003: a state that cannot be switched shows its sentence and no buttons', () => {
hookState.current = state({ state: 'denied', canSwitch: false, stateText: 'Notifications are blocked.' });
render(<MWebPushCard />);
expect(screen.getByRole('status')).toHaveTextContent('Notifications are blocked.');
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('FE-MOB-WEBPUSH-004: the switch is disabled while busy', () => {
hookState.current = state({ busy: true });
render(<MWebPushCard />);
expect(screen.getByRole('button', { name: 'Turn on for this device' })).toBeDisabled();
});
});
+297
View File
@@ -0,0 +1,297 @@
// FE-SW-PUSH-001 to FE-SW-PUSH-016: the Web Push handlers in public/sw-push.js.
//
// The file is a classic script that Workbox imports into the generated service
// worker, so it sits outside src/, ESLint and coverage. This spec is its only
// guard: it evaluates the real file against a fake worker global and drives
// the three events the way a browser would.
//
// What matters most:
// - every push shows a notification inside waitUntil, whatever arrives, since
// Chrome demands one per push and WebKit revokes a subscription that stays
// silent;
// - a click only ever opens a path on this origin;
// - a renewed subscription reaches the server, and the old one is forgotten
// only once the new one is stored.
// Plus the drift guard: vite.config.js must import the file, and it must exist,
// or the SPA fallback answers importScripts with index.html and the new worker
// never installs.
import { describe, it, expect, vi, afterEach } from 'vitest';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const ORIGIN = 'https://trek.example';
const SOURCE_PATH = resolve(process.cwd(), 'public/sw-push.js');
const SOURCE = readFileSync(SOURCE_PATH, 'utf8');
type Listener = (event: Record<string, unknown>) => void;
interface FakeWindow {
url: string;
focused: boolean;
focus: ReturnType<typeof vi.fn>;
navigate: ReturnType<typeof vi.fn>;
}
function fakeWindow(over: Partial<FakeWindow> = {}): FakeWindow {
const win: FakeWindow = {
url: `${ORIGIN}/dashboard`,
focused: false,
focus: vi.fn(async () => win),
navigate: vi.fn(async () => win),
...over,
};
return win;
}
function boot(windows: FakeWindow[] = []) {
const listeners: Record<string, Listener> = {};
const registration = {
showNotification: vi.fn(async (_title: string, _options: NotificationOptions) => undefined),
pushManager: { subscribe: vi.fn() },
};
const clients = {
matchAll: vi.fn(async () => windows),
openWindow: vi.fn(async (_url: string) => null),
};
const self = {
addEventListener: (type: string, fn: Listener) => {
listeners[type] = fn;
},
registration,
clients,
location: { origin: ORIGIN },
};
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => new Response(null, { status: 200 }));
new Function('self', 'fetch', SOURCE)(self, fetchMock);
return { listeners, registration, clients, fetch: fetchMock };
}
/** Dispatches one event and hands back what it passed to waitUntil. */
function fire(listener: Listener, event: Record<string, unknown>): Promise<unknown> {
let waited: Promise<unknown> | undefined;
listener({ ...event, waitUntil: (p: Promise<unknown>) => { waited = p; } });
expect(waited).toBeInstanceOf(Promise);
return waited as Promise<unknown>;
}
function pushData(value: unknown) {
return {
json: () => (typeof value === 'string' ? JSON.parse(value) : value),
text: () => (typeof value === 'string' ? value : JSON.stringify(value)),
};
}
afterEach(() => {
vi.restoreAllMocks();
});
describe('sw-push.js: push', () => {
it('FE-SW-PUSH-001: shows the payload as sent, with the icon and the path to open', async () => {
const sw = boot();
await fire(sw.listeners.push, {
data: pushData({ title: 'Trip invite', body: 'Anna invited you to Rome', url: '/trips/5' }),
});
expect(sw.registration.showNotification).toHaveBeenCalledWith('Trip invite', {
body: 'Anna invited you to Rome',
icon: '/icons/icon-192x192.png',
data: { url: '/trips/5' },
});
});
it('FE-SW-PUSH-001b: a tagged push replaces the last one with that tag and still alerts', async () => {
const sw = boot();
const message = { title: 'New message in "Rome"', body: 'anna: see you there', url: '/trips/5' };
await fire(sw.listeners.push, { data: pushData({ ...message, tag: 'collab_message:/trips/5' }) });
expect(sw.registration.showNotification).toHaveBeenCalledWith(message.title, {
body: message.body,
icon: '/icons/icon-192x192.png',
tag: 'collab_message:/trips/5',
renotify: true,
data: { url: '/trips/5' },
});
// Without a tag there is nothing to replace, and renotify would be refused.
await fire(sw.listeners.push, { data: pushData({ ...message, tag: '' }) });
expect(sw.registration.showNotification.mock.calls[1][1]).not.toHaveProperty('tag');
expect(sw.registration.showNotification.mock.calls[1][1]).not.toHaveProperty('renotify');
});
it('FE-SW-PUSH-001c: renotify follows the tag alone, never the payload', async () => {
const sw = boot();
// showNotification throws on renotify without a tag, so a payload asking for it must not get it.
await fire(sw.listeners.push, { data: pushData({ title: 'Booking', body: 'changed', renotify: true }) });
expect(sw.registration.showNotification.mock.calls[0][1]).not.toHaveProperty('renotify');
// Any tag at all brings it, not only the chat one.
await fire(sw.listeners.push, { data: pushData({ title: 'Booking', body: 'changed', tag: 'any' }) });
expect(sw.registration.showNotification.mock.calls[1][1]).toMatchObject({ tag: 'any', renotify: true });
});
it('FE-SW-PUSH-002: a payload that is not JSON still shows, with its text as the body', async () => {
const sw = boot();
await fire(sw.listeners.push, { data: pushData('plain words, no JSON') });
expect(sw.registration.showNotification).toHaveBeenCalledWith('TREK', {
body: 'plain words, no JSON',
icon: '/icons/icon-192x192.png',
data: { url: '/' },
});
});
it('FE-SW-PUSH-003: a push without data, or with the wrong types, still shows something', async () => {
const sw = boot();
await fire(sw.listeners.push, { data: null });
await fire(sw.listeners.push, { data: pushData({ title: 42, body: ['x'], tag: 7 }) });
await fire(sw.listeners.push, { data: pushData('"just a string"') });
for (const call of sw.registration.showNotification.mock.calls) {
expect(call[0]).toBe('TREK');
expect(call[1]).toEqual({ body: '', icon: '/icons/icon-192x192.png', data: { url: '/' } });
}
expect(sw.registration.showNotification).toHaveBeenCalledTimes(3);
});
it('FE-SW-PUSH-004: a url pointing anywhere else is replaced by the start page', async () => {
const sw = boot();
for (const url of ['https://evil.example/phish', '//evil.example/x', 'javascript:alert(1)']) {
await fire(sw.listeners.push, { data: pushData({ title: 'x', body: 'y', url }) });
}
for (const call of sw.registration.showNotification.mock.calls) {
expect(call[1].data.url).toBe('/');
}
});
it('FE-SW-PUSH-005: a same-origin absolute url is reduced to its path, query and hash', async () => {
const sw = boot();
await fire(sw.listeners.push, { data: pushData({ title: 'x', body: 'y', url: `${ORIGIN}/trips/5?tab=plan#day-2` }) });
expect(sw.registration.showNotification.mock.calls[0][1]).toMatchObject({ data: { url: '/trips/5?tab=plan#day-2' } });
});
});
describe('sw-push.js: notificationclick', () => {
function click(sw: ReturnType<typeof boot>, url: unknown) {
const notification = { close: vi.fn(), data: { url } };
return { notification, done: fire(sw.listeners.notificationclick, { notification }) };
}
it('FE-SW-PUSH-006: closes the notification, focuses a TREK window and takes it there', async () => {
const win = fakeWindow();
const sw = boot([win]);
const { notification, done } = click(sw, '/trips/5');
await done;
expect(notification.close).toHaveBeenCalled();
expect(sw.clients.matchAll).toHaveBeenCalledWith({ type: 'window', includeUncontrolled: true });
expect(win.focus).toHaveBeenCalled();
expect(win.navigate).toHaveBeenCalledWith(`${ORIGIN}/trips/5`);
expect(sw.clients.openWindow).not.toHaveBeenCalled();
});
it('FE-SW-PUSH-007: prefers the window that already has the focus', async () => {
const background = fakeWindow();
const focused = fakeWindow({ focused: true });
const sw = boot([background, focused]);
await click(sw, '/notifications').done;
expect(focused.navigate).toHaveBeenCalledWith(`${ORIGIN}/notifications`);
expect(background.focus).not.toHaveBeenCalled();
});
it('FE-SW-PUSH-008: opens a window when none is left', async () => {
const sw = boot([]);
await click(sw, '/trips/5').done;
expect(sw.clients.openWindow).toHaveBeenCalledWith(`${ORIGIN}/trips/5`);
});
it('FE-SW-PUSH-009: a window the worker may not navigate gets a new one instead', async () => {
const uncontrolled = fakeWindow();
uncontrolled.navigate.mockRejectedValueOnce(new TypeError('not controlled'));
const sw = boot([uncontrolled]);
await click(sw, '/trips/5').done;
expect(sw.clients.openWindow).toHaveBeenCalledWith(`${ORIGIN}/trips/5`);
});
it('FE-SW-PUSH-010: a url from elsewhere, or none at all, opens the start page', async () => {
const sw = boot([]);
await click(sw, 'https://evil.example/').done;
const notification = { close: vi.fn(), data: null };
await fire(sw.listeners.notificationclick, { notification });
expect(sw.clients.openWindow.mock.calls).toEqual([[`${ORIGIN}/`], [`${ORIGIN}/`]]);
});
});
describe('sw-push.js: pushsubscriptionchange', () => {
const OLD_KEY = new Uint8Array([4, 1, 2, 3]).buffer;
function renewed(endpoint: string) {
return { endpoint, toJSON: () => ({ endpoint, keys: { p256dh: 'p', auth: 'a' } }) };
}
it('FE-SW-PUSH-011: subscribes again with the old key, stores the new one, then forgets the old', async () => {
const sw = boot();
sw.registration.pushManager.subscribe.mockResolvedValue(renewed('https://push.example/new'));
await fire(sw.listeners.pushsubscriptionchange, {
oldSubscription: { endpoint: 'https://push.example/old', options: { applicationServerKey: OLD_KEY } },
newSubscription: null,
});
expect(sw.registration.pushManager.subscribe).toHaveBeenCalledWith({ userVisibleOnly: true, applicationServerKey: OLD_KEY });
expect(sw.fetch).toHaveBeenCalledTimes(2);
const [postUrl, post] = sw.fetch.mock.calls[0];
expect(postUrl).toBe('/api/notifications/push/subscriptions');
expect(post).toMatchObject({ method: 'POST', credentials: 'same-origin' });
expect(JSON.parse(String(post?.body))).toEqual({
subscription: { endpoint: 'https://push.example/new', keys: { p256dh: 'p', auth: 'a' } },
});
const [, del] = sw.fetch.mock.calls[1];
expect(del).toMatchObject({ method: 'DELETE', credentials: 'same-origin' });
expect(JSON.parse(String(del?.body))).toEqual({ endpoint: 'https://push.example/old' });
});
it('FE-SW-PUSH-012: uses a subscription the browser already made', async () => {
const sw = boot();
await fire(sw.listeners.pushsubscriptionchange, {
oldSubscription: null,
newSubscription: renewed('https://push.example/fresh'),
});
expect(sw.registration.pushManager.subscribe).not.toHaveBeenCalled();
expect(sw.fetch).toHaveBeenCalledTimes(1);
});
it('FE-SW-PUSH-013: keeps the row when the endpoint did not change', async () => {
const sw = boot();
await fire(sw.listeners.pushsubscriptionchange, {
oldSubscription: { endpoint: 'https://push.example/same', options: { applicationServerKey: OLD_KEY } },
newSubscription: renewed('https://push.example/same'),
});
expect(sw.fetch).toHaveBeenCalledTimes(1);
});
it('FE-SW-PUSH-014: forgets nothing when the server did not take the new subscription', async () => {
const sw = boot();
sw.fetch.mockResolvedValueOnce(new Response(null, { status: 401 }));
await fire(sw.listeners.pushsubscriptionchange, {
oldSubscription: { endpoint: 'https://push.example/old', options: { applicationServerKey: OLD_KEY } },
newSubscription: renewed('https://push.example/new'),
});
expect(sw.fetch).toHaveBeenCalledTimes(1);
});
it('FE-SW-PUSH-015: gives up quietly, never with a rejected waitUntil', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const sw = boot();
sw.registration.pushManager.subscribe.mockRejectedValueOnce(new Error('push service down'));
await expect(
fire(sw.listeners.pushsubscriptionchange, {
oldSubscription: { endpoint: 'https://push.example/old', options: { applicationServerKey: OLD_KEY } },
newSubscription: null,
}),
).resolves.toBeUndefined();
expect(warn).toHaveBeenCalled();
await fire(sw.listeners.pushsubscriptionchange, { oldSubscription: null, newSubscription: null });
expect(sw.fetch).not.toHaveBeenCalled();
});
});
describe('sw-push.js is wired into the service worker (drift guard)', () => {
it('FE-SW-PUSH-016: vite.config.js imports it and public/ ships it', () => {
const config = readFileSync(resolve(process.cwd(), 'vite.config.js'), 'utf8');
expect(config).toMatch(/importScripts:\s*\[\s*'sw-push\.js'\s*\]/);
expect(existsSync(SOURCE_PATH)).toBe(true);
});
});
@@ -0,0 +1,132 @@
// FE-STORE-AUTH-PUSH-001 to FE-STORE-AUTH-PUSH-005: where Web Push sits in the
// sign-in and sign-out sequences.
//
// Logout has to forget this device's push subscription while the session cookie
// still exists, because the DELETE that tells the server needs it and the next
// step clears it. Sign-in re-registers the subscription the device holds, in
// the background, so a slow push service can never hold a login up. The push
// module has its own spec; here it is a pair of spies, except in the one case
// that runs the real logout teardown against a session that already ran out.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from '../../helpers/msw/server';
import { resetAllStores } from '../../helpers/store';
import { buildUser } from '../../helpers/factories';
import { useAuthStore } from '../../../src/store/authStore';
const push = vi.hoisted(() => ({
forgetPushDeviceOnLogout: vi.fn(),
resyncPushSubscription: vi.fn(),
}));
vi.mock('../../../src/push/webPush', () => push);
vi.mock('../../../src/sync/syncTriggers', () => ({
registerSyncTriggers: vi.fn(),
unregisterSyncTriggers: vi.fn(),
}));
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
push.forgetPushDeviceOnLogout.mockResolvedValue(undefined);
push.resyncPushSubscription.mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('authStore and Web Push', () => {
it('FE-STORE-AUTH-PUSH-001: logout forgets the push device before the logout request clears the cookie', async () => {
useAuthStore.setState({ user: buildUser(), isAuthenticated: true });
const order: string[] = [];
push.forgetPushDeviceOnLogout.mockImplementation(async () => {
order.push('forget push device');
});
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
order.push(`fetch ${String(input)}`);
return new Response(null, { status: 200 });
});
await useAuthStore.getState().logout();
expect(fetchSpy).toHaveBeenCalled();
expect(order).toEqual(['forget push device', 'fetch /api/auth/logout']);
});
it('FE-STORE-AUTH-PUSH-002: the logout request waits until the push step has finished', async () => {
useAuthStore.setState({ user: buildUser(), isAuthenticated: true });
let finishPush!: () => void;
push.forgetPushDeviceOnLogout.mockReturnValue(new Promise<void>((resolve) => { finishPush = resolve; }));
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 200 }));
const loggingOut = useAuthStore.getState().logout();
await new Promise((r) => setTimeout(r, 10));
expect(fetchSpy).not.toHaveBeenCalled();
finishPush();
await loggingOut;
expect(fetchSpy).toHaveBeenCalledWith('/api/auth/logout', expect.objectContaining({ method: 'POST' }));
expect(useAuthStore.getState().user).toBeNull();
});
it('FE-STORE-AUTH-PUSH-003: an expired session answers the push DELETE with 401, and logout still runs to the end', async () => {
// The real teardown this time: its DELETE must be a plain fetch, because
// apiClient would meet the 401 with its redirect to the login page.
const real = await vi.importActual<typeof import('../../../src/push/webPush')>('../../../src/push/webPush');
push.forgetPushDeviceOnLogout.mockImplementation(real.forgetPushDeviceOnLogout);
const unsubscribe = vi.fn(async () => true);
const subscription = { endpoint: 'https://fcm.googleapis.com/fcm/send/this-device', unsubscribe };
const registration = { pushManager: { getSubscription: vi.fn(async () => subscription) } };
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: { getRegistration: vi.fn(async () => registration) },
});
vi.stubGlobal('PushManager', function PushManager() {});
vi.stubGlobal('Notification', { permission: 'granted' });
vi.spyOn(console, 'warn').mockImplementation(() => {});
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) =>
String(input) === '/api/notifications/push/subscriptions'
? Response.json({ error: 'Authentication required', code: 'AUTH_REQUIRED' }, { status: 401 })
: new Response(null, { status: 200 }),
);
useAuthStore.setState({ user: buildUser(), isAuthenticated: true });
const before = window.location.href;
try {
await useAuthStore.getState().logout();
} finally {
delete (navigator as unknown as { serviceWorker?: unknown }).serviceWorker;
vi.unstubAllGlobals();
}
expect(fetchSpy.mock.calls.map(([input]) => String(input))).toEqual([
'/api/notifications/push/subscriptions',
'/api/auth/logout',
]);
expect(fetchSpy.mock.calls[0][1]).toMatchObject({ method: 'DELETE', credentials: 'include' });
expect(unsubscribe).toHaveBeenCalled();
expect(window.location.href).toBe(before);
expect(useAuthStore.getState().user).toBeNull();
});
it('FE-STORE-AUTH-PUSH-004: a successful login re-registers this device in the background', async () => {
const user = buildUser();
server.use(http.post('/api/auth/login', () => HttpResponse.json({ user, token: 'tok' })));
// Never settles: the login must not be waiting on it.
push.resyncPushSubscription.mockReturnValue(new Promise(() => {}));
await useAuthStore.getState().login(user.email, 'password');
expect(useAuthStore.getState().isAuthenticated).toBe(true);
expect(push.resyncPushSubscription).toHaveBeenCalledTimes(1);
});
it('FE-STORE-AUTH-PUSH-005: a failed login does not touch push', async () => {
server.use(http.post('/api/auth/login', () => HttpResponse.json({ error: 'Bad credentials' }, { status: 401 })));
await expect(useAuthStore.getState().login('bad@example.com', 'wrong')).rejects.toThrow();
expect(push.resyncPushSubscription).not.toHaveBeenCalled();
});
});
+6
View File
@@ -51,6 +51,12 @@ export default defineConfig(({ mode }) => ({
navigateFallback: undefined,
},
workbox: {
// The Web Push handlers (push, notificationclick, pushsubscriptionchange).
// Workbox writes importScripts('sw-push.js') at the top of the generated
// worker, in dev too. The file lives in public/: were it missing, the SPA
// fallback would answer with index.html and the new worker would fail to
// install, which tests/unit/pwa/swPush.test.ts guards against.
importScripts: ['sw-push.js'],
// Anything above this is dropped from the precache manifest. The build does
// not fail over it, it only prints "won't be precached", so the ceiling has
// to sit close to the real bundle or an accidental heavyweight goes
+3
View File
@@ -59,6 +59,9 @@ services:
# - AMAP_API_KEY= # Optional. Amap (高德地图) Web 服务 key for place search and geocoding inside mainland China. Overrides the key set in Admin > Settings > API Keys. A Web 端 (JS API) key is a different credential and is rejected with INVALID_USER_KEY.
# - AMAP_API_SECRET= # Optional. The private secret (数字签名) of that key. Required exactly when the key was created with signing enabled, because such a key rejects every unsigned request. Leave unset otherwise.
# - AMAP_API_BASE=https://restapi.amap.com # Optional. Send the Amap calls through an egress proxy, a cache or a gateway that speaks the same API instead of restapi.amap.com directly. (default: https://restapi.amap.com)
# - VAPID_PUBLIC_KEY= # Optional. Web Push key pair, public half (base64url). Unset, TREK generates a pair on first start and keeps it in the database, so it travels with backups. Set it together with VAPID_PRIVATE_KEY to use your own, e.g. from `npx web-push generate-vapid-keys`. Changing the pair means every device has to turn push on again.
# - VAPID_PRIVATE_KEY= # Optional. Web Push key pair, private half (base64url). Only used together with VAPID_PUBLIC_KEY; a mismatched pair, or only one half, turns push off with an error in the log until it is fixed, and every device stays registered.
# - VAPID_SUBJECT=mailto:admin@example.com # Optional. The contact push services see with every message: a mailto: address or an https:// URL. Defaults to APP_URL when it is https, else the TREK project page.
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
+4
View File
@@ -68,6 +68,10 @@ DEMO_MODE=false # Demo mode - resets data hourly
# LLM_TIMEOUT_MS=900000 # Ceiling (ms) for one AI-parsing call before it is abandoned. One value for every provider, and the underlying HTTP client is held to it too. Generous by default so heavier parsing work fits without a code change; lower it on a cloud provider to fail fast. (default: 900000)
# VAPID_PUBLIC_KEY= # Web Push: the public half of the key pair browsers subscribe with (base64url, 65 bytes). Leave both halves unset and TREK generates a pair on first start and keeps it in the database, so it travels with every backup. Set both to use a pair of your own, e.g. printed by `npx web-push generate-vapid-keys`. Changing the pair later means every device has to turn push on again.
# VAPID_PRIVATE_KEY= # Web Push: the private half (base64url, 32 bytes). Only used together with VAPID_PUBLIC_KEY; a pair whose halves do not belong together, or only one half, turns push off with an error in the log until the pair is fixed. A pair stored in the database is not used in its place, and every device stays registered.
# VAPID_SUBJECT=mailto:admin@example.com # Web Push: the contact push services see with every message, a mailto: address or an https:// URL. Unset, TREK uses APP_URL when it is https, else the TREK project page.
# UNSPLASH_ACCESS_KEY= # Optional Unsplash Access Key for trip-cover and place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs get blocked from (#1449). Get a free key at unsplash.com/developers. Overrides any key set per-admin in Admin > Settings. Can also be configured there instead of here.
# Initial admin account — ONLY applied on the first boot, when the database has no
+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;
+6 -1
View File
@@ -31,6 +31,10 @@ tokens, `RuntimeEnvService`) and consumes the SAME derive functions.
together wrong. It is empty without the switch, so a self-hoster never meets
it. Put a rule there only if booting anyway would be a security or data
problem — not to enforce a preference.
The report prints each malformed value, because that is what makes a typo
findable, except for the credentials in `SECRET_ENV_KEYS` (`env.ts`), which
print as `***`. A new password, token, key or secret variable goes into that
set; `validate.test.ts` fails on a credential-sounding name missing from it.
3. **Parity is law — with one deliberate exception.** Every derived field pins
the exact coercion of the call site(s) it replaced (`Number(x) || d`
treating `"0"` as unset, per-site defaults for the same variable, …). Do not
@@ -82,7 +86,8 @@ TREK_PLUGINS_ENABLED / _DEV_LINK / _IGNORE_TREK_RANGE / _DIR / _DATA_DIR / TREK_
OIDC_*, SMTP_*, FORCE_HTTPS, COOKIE_SECURE, HSTS_INCLUDE_SUBDOMAINS,
ALLOWED_ORIGINS, UNSPLASH_ACCESS_KEY, WEBAUTHN_*, TZ, ADMIN_EMAIL,
ADMIN_PASSWORD, IDEMPOTENCY_TTL_SECONDS, MCP_RATE_LIMIT (request-path check),
LLM_TIMEOUT_MS, NOMINATIM_URL.
LLM_TIMEOUT_MS, NOMINATIM_URL, VAPID_* (resolved per use by the Web Push key
service, which the notification suites build without the container).
## Exemptions — raw `process.env` stays
+11
View File
@@ -334,6 +334,16 @@ export function deriveNet(raw: RawEnv) {
};
}
export function derivePush(raw: RawEnv) {
return {
// Trimmed, and blank counts as unset: the schema validated the trimmed value,
// so a padded key must not reach the crypto as a different string.
vapidPublicKey: raw.VAPID_PUBLIC_KEY?.trim() || undefined,
vapidPrivateKey: raw.VAPID_PRIVATE_KEY?.trim() || undefined,
vapidSubject: raw.VAPID_SUBJECT?.trim() || undefined,
};
}
export function deriveAll(raw: RawEnv) {
return {
app: deriveApp(raw),
@@ -353,6 +363,7 @@ export function deriveAll(raw: RawEnv) {
db: deriveDb(raw),
paths: derivePaths(raw),
net: deriveNet(raw),
push: derivePush(raw),
};
}
+19 -1
View File
@@ -11,7 +11,13 @@
*/
import { z } from 'zod';
import { SUPPORTED_LANGUAGE_CODES } from '@trek/shared';
import { parseDurationMs, parseLinkLocalAllowList } from './parsers';
import {
isP256PrivateKey,
isUncompressedP256Key,
isVapidSubject,
parseDurationMs,
parseLinkLocalAllowList,
} from './parsers';
/** Present-but-malformed fails; unset/blank always passes (defaults apply). */
function optionalWith(test: (v: string) => boolean, message: string) {
@@ -104,6 +110,18 @@ export const envSchema = z.object({
WEBAUTHN_RP_ID: anyString,
WEBAUTHN_ORIGINS: anyString,
// Web Push (VAPID). All optional: without them the server keeps a pair of its
// own in the database. Only the shape is checked here; whether the two keys
// belong together is checked where they are used, which turns push off while
// they do not (never signing with a stored pair in their place) and says so
// in the log instead of refusing to boot.
VAPID_PUBLIC_KEY: optionalWith(
isUncompressedP256Key,
'must be a base64url-encoded P-256 public key (65 bytes, uncompressed, starting with 0x04)',
),
VAPID_PRIVATE_KEY: optionalWith(isP256PrivateKey, 'must be a base64url-encoded P-256 private key (32 bytes)'),
VAPID_SUBJECT: optionalWith(isVapidSubject, 'must be a mailto: address or an https:// URL'),
// MCP
MCP_SESSION_TTL: positiveNumber,
MCP_MAX_SESSION_PER_USER: positiveNumber,
+26 -1
View File
@@ -14,6 +14,31 @@ export function readEnv(): AppEnv {
return deriveAll(process.env as RawEnv);
}
/**
* Variables whose value never goes into the boot report, even when it is the
* malformed one: the report lands in container logs, and a key that is merely
* mistyped is still a real key. The name and the problem are enough to fix it.
*/
export const SECRET_ENV_KEYS: ReadonlySet<string> = new Set([
'ENCRYPTION_KEY',
'OIDC_CLIENT_SECRET',
'SMTP_PASS',
'VAPID_PRIVATE_KEY',
'UNSPLASH_ACCESS_KEY',
'ADMIN_PASSWORD',
'PLACES_API_KEY',
'AMAP_API_KEY',
'AMAP_API_SECRET',
'MAPBOX_ACCESS_TOKEN',
'CARTO_API_KEY',
'DEMO_ADMIN_PASS',
]);
/** How a variable's value appears in the boot report. */
function reportedValue(key: string, value: string | undefined): string {
return SECRET_ENV_KEYS.has(key) ? '***' : JSON.stringify(value);
}
/**
* Fail-fast startup validation: a variable that is PRESENT but malformed aborts
* boot with an aggregated report; unset/blank variables always pass (their
@@ -28,7 +53,7 @@ export function validateEnvAtBoot(raw: RawEnv = process.env as RawEnv): void {
? []
: result.error.issues.map((issue) => {
const key = String(issue.path[0]);
return ` - ${key}=${JSON.stringify(raw[key])}: ${issue.message}`;
return ` - ${key}=${reportedValue(key, raw[key])}: ${issue.message}`;
});
lines.push(...managedPreconditions(raw));
if (lines.length === 0) return;
+1
View File
@@ -20,6 +20,7 @@ export {
deriveDb,
derivePaths,
deriveNet,
derivePush,
} from './derive';
export * from './parsers';
export { envSchema } from './env.schema';
+43
View File
@@ -209,3 +209,46 @@ export function parseLinkLocalAllowList(raw: string | undefined): { ips: string[
}
return { ips, invalid };
}
// ── Web Push key material ──────────────────────────────────────────────────
const BASE64URL = /^[A-Za-z0-9_-]+={0,2}$/;
/**
* Strict base64url decode, null for anything that is not base64url. Web Push
* keys arrive in this alphabet from the browser and from the VAPID_* variables.
* Buffer.from(value, 'base64url') alone skips characters it does not know, so a
* mangled key would decode to fewer bytes instead of being refused, and the size
* checks below would then report the wrong problem.
*/
export function decodeBase64Url(value: string): Buffer | null {
const trimmed = value.trim();
if (!BASE64URL.test(trimmed)) return null;
return Buffer.from(trimmed, 'base64url');
}
/** An uncompressed P-256 point: 65 bytes, the first one 0x04 (VAPID public key, subscription p256dh). */
export function isUncompressedP256Key(value: string): boolean {
const bytes = decodeBase64Url(value);
return bytes?.length === 65 && bytes[0] === 0x04;
}
/** A P-256 private scalar as the Web Push tools print it: 32 bytes. */
export function isP256PrivateKey(value: string): boolean {
return decodeBase64Url(value)?.length === 32;
}
/**
* The VAPID `sub` claim (RFC 8292 section 2.1): a mailto: address or an https:
* URL. Apple refuses anything else, and so does this check, so a subject that
* boots is one every push service accepts.
*/
export function isVapidSubject(value: string): boolean {
const trimmed = value.trim();
if (/^mailto:[^\s@]+@[^\s@]+$/i.test(trimmed)) return true;
try {
return new URL(trimmed).protocol === 'https:';
} catch {
return false;
}
}
+30
View File
@@ -5257,6 +5257,36 @@ function runMigrations(db: Database.Database): void {
`);
}
},
/*
* Web Push (#894): one row per browser a user switched push on in.
*
* The endpoint is the push service URL that browser handed out, so it is
* unique: a browser shared by two accounts belongs to whoever subscribed on
* it last. p256dh and auth are the browser's keys for the RFC 8291 message
* encryption. vapid_public_key is the server key the subscription was made
* against, so a changed key pair shows up as a mismatch the sender can clean
* up, rather than as a push service refusing every message. failure_count
* and last_success_at are bookkeeping for the sender; the rows go with the
* user.
*/
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
vapid_public_key TEXT NOT NULL,
user_agent TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_success_at TEXT,
failure_count INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user ON push_subscriptions(user_id);
`);
},
];
if (currentVersion < migrations.length) {
+27 -2
View File
@@ -28,6 +28,11 @@ export const SENSITIVE_KEYS = new Set([
'code',
'smtp_pass',
'secretaccesskey',
// A Web Push subscription's keys: whoever holds them together with the
// endpoint can encrypt messages for that browser. No other request body has
// either field, and an `auth` anywhere else would be a credential as well.
'p256dh',
'auth',
]);
/**
@@ -43,7 +48,26 @@ function isSensitiveName(name: string): boolean {
return SENSITIVE_KEYS.has(lower) || SENSITIVE_SUFFIXES.some((suffix) => lower.endsWith(suffix));
}
/** Deep-redacts every key in `SENSITIVE_KEYS` (case-insensitive) from a request-log value. */
/**
* The fields PushSubscription.toJSON() puts next to `endpoint`. Either one is
* enough to know the object is a subscription, so a body that arrives without
* its keys (which the route then refuses) still keeps its endpoint out of the log.
*/
const PUSH_SUBSCRIPTION_FIELDS = new Set(['keys', 'expirationtime']);
/**
* A Web Push endpoint is a capability URL: anyone holding it can post to that
* browser's push service. It arrives inside a subscription, or on its own in
* the body that forgets a device. Anywhere else `endpoint` is an ordinary
* setting (the S3 backend's URL, for one) and stays readable.
*/
function isPushEndpoint(entries: Record<string, unknown>, name: string): boolean {
if (name.toLowerCase() !== 'endpoint') return false;
const siblings = Object.keys(entries).filter((k) => k !== name);
return siblings.length === 0 || siblings.some((k) => PUSH_SUBSCRIPTION_FIELDS.has(k.toLowerCase()));
}
/** Deep-redacts every key in `SENSITIVE_KEYS` (case-insensitive) and every Web Push endpoint from a request-log value. */
export function redact(value: unknown): unknown {
if (!value || typeof value !== 'object') return value;
if (Array.isArray(value)) return (value as unknown[]).map(redact);
@@ -54,7 +78,8 @@ export function redact(value: unknown): unknown {
const namedSecret = typeof entries.key === 'string' && isSensitiveName(entries.key);
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(entries)) {
out[k] = isSensitiveName(k) || (namedSecret && k === 'value') ? '[REDACTED]' : redact(v);
const hidden = isSensitiveName(k) || (namedSecret && k === 'value') || isPushEndpoint(entries, k);
out[k] = hidden ? '[REDACTED]' : redact(v);
}
return out;
}
+3
View File
@@ -226,6 +226,9 @@ export class AdminService {
id,
);
} catch { /* very old installs predate oauth_tokens */ }
// Push devices outlive every session, so the intruder's browser would
// keep receiving this account's notifications. They go with the rest.
this.db.run('DELETE FROM push_subscriptions WHERE user_id = ?', id);
}
});
+11 -1
View File
@@ -7,6 +7,7 @@ import { authenticator } from 'otplib';
import QRCode from 'qrcode';
import { randomBytes, createHash } from 'crypto';
import type { Request, Response } from 'express';
import { WEB_PUSH_CHANNEL_ID } from '@trek/shared';
import { readEnv } from '../../app-config';
import { JWT_SECRET, SESSION_DURATION_SECONDS, SESSION_DURATION_REMEMBER_SECONDS } from '../../config';
import { DatabaseService } from '../database/database.service';
@@ -285,6 +286,7 @@ export class AuthService {
const notifChannelsRaw = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'notification_channels'")?.value || notifChannel;
const activeChannels = notifChannelsRaw === 'none' ? [] : notifChannelsRaw.split(',').map((c: string) => c.trim()).filter(Boolean);
const hasWebhookEnabled = activeChannels.includes('webhook');
const hasPushEnabled = activeChannels.includes(WEB_PUSH_CHANNEL_ID);
const tripRemindersEnabled = tripReminderSetting !== 'false';
const placesPhotosSetting = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'")?.value;
const placesPhotosEnabled = placesPhotosSetting !== 'false';
@@ -343,7 +345,7 @@ export class AuthService {
timezone: readEnv().app.tz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
notification_channel: notifChannel,
notification_channels: activeChannels,
available_channels: { email: hasSmtpHost, webhook: hasWebhookEnabled, inapp: true },
available_channels: { email: hasSmtpHost, webhook: hasWebhookEnabled, push: hasPushEnabled, inapp: true },
trip_reminders_enabled: tripRemindersEnabled,
places_photos_enabled: placesPhotosEnabled,
places_autocomplete_enabled: placesAutocompleteEnabled,
@@ -596,6 +598,11 @@ export class AuthService {
try {
this.db.run("UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ? AND revoked_at IS NULL", userId);
} catch { /* oauth_tokens table may not exist in very old installs */ }
// Push devices keep receiving notifications without any session, so they
// go too. The device the change was made on registers again right away:
// the client reloads the user after the change, and that re-syncs its
// subscription. Other devices do so after their next sign-in.
this.db.run('DELETE FROM push_subscriptions WHERE user_id = ?', userId);
});
try { revokeUserSessions?.(userId); } catch { /* best-effort */ }
@@ -1056,6 +1063,9 @@ export class AuthService {
user.id
);
} catch { /* oauth_tokens table may not exist in very old installs */ }
// Push devices are a delivery channel that outlives every session, so an
// intruder's browser would keep reading this account's notifications.
this.db.run('DELETE FROM push_subscriptions WHERE user_id = ?', user.id);
});
// Kick off any MCP/WS session cleanup — same hook the account-delete path uses.
@@ -1,13 +1,16 @@
import { WEB_PUSH_CHANNEL_ID } from '@trek/shared';
import { registerChannel } from '../channel-registry';
import type { ChannelMessage, ExternalChannel } from '../notification-events';
import { ADMIN_SCOPED_EVENTS, type ChannelMessage, type ExternalChannel } from '../notification-events';
import type { MailerService } from '../mailer/mailer.service';
import { resolveAdminNtfyUrl, resolveNtfyToken, resolveNtfyUrl, type NtfyService } from '../transports/ntfy.service';
import type { WebhookService } from '../transports/webhook.service';
import type { WebPushService } from '../transports/web-push.service';
// The three built-in external channels, wrapping the transports that were free
// functions in services/notifications.ts before the fold. No delivery logic is
// rewritten here - it is only relocated behind the ExternalChannel interface so
// NotificationsService.send() can iterate instead of branching.
// The built-in external channels. Email, webhook and ntfy wrap the transports
// that were free functions in services/notifications.ts before the fold; no
// delivery logic is rewritten here, it is only relocated behind the
// ExternalChannel interface so NotificationsService.send() can iterate instead
// of branching. Web Push (#894) joined later on the same terms.
function supportsAllButSynology(event: string): boolean {
return event !== 'synology_session_cleared';
@@ -17,10 +20,11 @@ export interface BuiltinChannelDeps {
mailer: MailerService;
webhook: WebhookService;
ntfy: NtfyService;
push: WebPushService;
}
/**
* Build the three built-in channels over the given transports.
* Build the built-in channels over the given transports.
*
* They take their dependencies as an argument rather than importing them,
* because the registry they land in is a module singleton while the transports
@@ -32,7 +36,7 @@ export interface BuiltinChannelDeps {
* preferences; dropping that import would have silenced email, webhook and ntfy
* without a single error.
*/
export function buildBuiltinChannels({ mailer, webhook, ntfy }: BuiltinChannelDeps): ExternalChannel[] {
export function buildBuiltinChannels({ mailer, webhook, ntfy, push }: BuiltinChannelDeps): ExternalChannel[] {
const emailChannel: ExternalChannel = {
id: 'email',
source: 'builtin',
@@ -114,7 +118,26 @@ export function buildBuiltinChannels({ mailer, webhook, ntfy }: BuiltinChannelDe
},
};
return [emailChannel, webhookChannel, ntfyChannel];
// Per user and per browser. It carries no admin-scoped events: those have
// their own admin-global copy on the channels above, and a push column in
// the admin matrix could never be switched on. There is no sendGlobal for the
// same reason, and no credentials to check beyond the user having turned it
// on in at least one browser. Subscribing is a browser-only act, so there is
// no MCP twin for the routes behind it. While the server's key pair cannot be
// used, push is not sent and not offered, and every device stays subscribed.
const pushChannel: ExternalChannel = {
id: WEB_PUSH_CHANNEL_ID,
source: 'builtin',
labelKey: 'settings.notificationPreferences.push',
hiddenWhileInstanceUnconfigured: true,
supportsEvent: (event) => supportsAllButSynology(event) && !ADMIN_SCOPED_EVENTS.has(event),
isInstanceConfigured: () => push.isAvailable(),
isConfiguredFor: (userId) => push.hasDevices(userId),
sendToUser: (userId, msg) => push.sendToUser(userId, msg),
test: (userId) => push.sendTest(userId),
};
return [emailChannel, webhookChannel, ntfyChannel, pushChannel];
}
/** Idempotent - safe to call from every entry point that needs the registry populated. */
@@ -127,6 +127,13 @@ export interface ExternalChannel {
readonly bypassesActiveToggleForAdminEvents?: boolean;
/** Delivers the one admin-scoped global copy (not per-recipient). */
readonly supportsAdminGlobal?: boolean;
/**
* The user's matrix marks this channel inactive (no column, no settings card)
* while isInstanceConfigured() says no. Web Push sets it: without a usable key
* pair a browser could not even subscribe. Email does not, and keeps its
* column while SMTP is not set up, as it always has.
*/
readonly hiddenWhileInstanceUnconfigured?: boolean;
supportsEvent(event: NotifEventType): boolean;
/**
@@ -80,6 +80,16 @@ export class NotificationPreferencesService {
return channel.source === 'plugin' || this.getActiveChannels().includes(channel.id);
}
/**
* Is the channel offered to users at all right now? Switched on, and for a
* channel that says so, ready on this instance too: Web Push without a usable
* key pair is off for everyone, whatever the admin switch says.
*/
private isChannelOffered(channel: ExternalChannel): boolean {
if (!this.isChannelActive(channel)) return false;
return !channel.hiddenWhileInstanceUnconfigured || channel.isInstanceConfigured?.() !== false;
}
// ── Per-user preference checks ─────────────────────────────────────────────
/**
@@ -123,8 +133,10 @@ export class NotificationPreferencesService {
const hasAdminNtfy = !!this.getAppSetting('admin_ntfy_topic');
const adminActive: Record<string, boolean> = { email: hasSmtp, webhook: hasAdminWebhook, ntfy: hasAdminNtfy };
for (const channel of listChannels()) {
// Plugin channels are user-scoped only — they never carry admin-global events.
if (channel.source !== 'builtin') continue;
// Only the channels with an admin-global copy. Plugin channels and push are
// user-scoped: they never carry an admin-scoped event, so a column for them
// here could never be switched on.
if (channel.source !== 'builtin' || !isAdminGlobalChannel(channel.id)) continue;
const active = adminActive[channel.id] ?? false;
out.push({
id: channel.id,
@@ -147,7 +159,7 @@ export class NotificationPreferencesService {
settingsPath: channel.settingsPath,
// A live plugin channel is always a column. `configured` tells the user whether
// they still need to enter credentials — it does not hide the channel from them.
active: this.isChannelActive(channel),
active: this.isChannelOffered(channel),
configured: channel.isConfiguredFor(userId),
});
}
@@ -5,6 +5,8 @@ import {
testWebhookRequestSchema,
testNtfyRequestSchema,
notificationRespondRequestSchema,
pushSubscribeRequestSchema,
pushUnsubscribeRequestSchema,
} from '@trek/shared';
/**
@@ -19,3 +21,5 @@ export class TestSmtpDto extends createZodDto(testSmtpRequestSchema) {}
export class TestWebhookDto extends createZodDto(testWebhookRequestSchema) {}
export class TestNtfyDto extends createZodDto(testNtfyRequestSchema) {}
export class NotificationRespondDto extends createZodDto(notificationRespondRequestSchema) {}
export class PushSubscribeDto extends createZodDto(pushSubscribeRequestSchema) {}
export class PushUnsubscribeDto extends createZodDto(pushUnsubscribeRequestSchema) {}
@@ -7,6 +7,10 @@ import { ReminderJobsService } from './reminder-jobs.service';
import { StorageHealthNotifierService } from './storage-health-notifier.service';
import { NtfyService } from './transports/ntfy.service';
import { WebhookService } from './transports/webhook.service';
import { WebPushService } from './transports/web-push.service';
import { PushController } from './push/push.controller';
import { PushSubscriptionsService } from './push/push-subscriptions.service';
import { VapidKeysService } from './push/vapid-keys.service';
import { MailerModule } from './mailer/mailer.module';
import { AuthModule } from '../auth/auth.module';
import { SchedulingModule } from '../scheduling/scheduling.module';
@@ -19,17 +23,23 @@ import { StorageModule } from '../storage/storage.module';
* StorageModule feeds StorageHealthNotifierService, which bridges replica
* failures into admin notifications — this direction has no cycle (Storage
* imports only AppConfig+Audit+Scheduling).
* Web Push (#894) lives in push/: VapidKeysService holds the server's key
* pair, PushSubscriptionsService the browsers, PushController the routes
* a browser signs up through; the transport is transports/web-push.service.ts.
* NotificationsService and NotificationPreferencesService are exported for
* in-container consumers (AdminController's dev test send and preferences tab,
* the plugin RPC surface, HostSurfaceRpc). */
@Module({
imports: [AuthModule, MailerModule, SchedulingModule, StorageModule],
controllers: [NotificationsController, AdminNotificationPreferencesController],
controllers: [NotificationsController, AdminNotificationPreferencesController, PushController],
providers: [
NotificationsService,
NotificationPreferencesService,
WebhookService,
NtfyService,
WebPushService,
VapidKeysService,
PushSubscriptionsService,
NotificationsMcp,
ReminderJobsService,
StorageHealthNotifierService,
@@ -6,6 +6,7 @@ import { getEventText } from './mailer/email-html';
import { MailerService } from './mailer/mailer.service';
import { NtfyService, type NtfyConfig } from './transports/ntfy.service';
import { WebhookService } from './transports/webhook.service';
import { WebPushService } from './transports/web-push.service';
import { NotificationPreferencesService, type PreferencesMatrix } from './notification-preferences.service';
import { registerBuiltinChannels } from './channels/builtins';
import {
@@ -272,7 +273,6 @@ function shouldSendToUser(
prefs: NotificationPreferencesService,
): boolean {
if (!channel.supportsEvent(event)) return false;
if (channel.isInstanceConfigured && !channel.isInstanceConfigured()) return false;
if (ADMIN_SCOPED_EVENTS.has(event)) {
if (!channel.bypassesActiveToggleForAdminEvents) return false;
@@ -285,7 +285,10 @@ function shouldSendToUser(
if (!prefs.isEnabledForEvent(recipientId, event, channel.id)) return false;
}
return channel.isConfiguredFor(recipientId);
if (!channel.isConfiguredFor(recipientId)) return false;
// Last, since it is the costly check: Web Push reads and verifies the server's
// key pair. Every check here is a plain AND, so the order changes no answer.
return !channel.isInstanceConfigured || channel.isInstanceConfigured();
}
/**
@@ -316,12 +319,13 @@ export class NotificationsService {
private readonly webhook: WebhookService,
private readonly ntfy: NtfyService,
private readonly prefs: NotificationPreferencesService,
push: WebPushService,
) {
// The registry is a module singleton shared with the plugin runtime and any
// separately-constructed instance (which never runs onModuleInit), so
// registering from here means every path that can dispatch has the
// built-ins. registerChannel is an idempotent Map.set.
registerBuiltinChannels({ mailer, webhook, ntfy });
registerBuiltinChannels({ mailer, webhook, ntfy, push });
}
getPreferences(userId: number, role: string): PreferencesMatrix {
@@ -779,7 +783,7 @@ export class NotificationsService {
);
}
// ── External channels (email, webhook, ntfy, plugin:*) ───────────────
// ── External channels (email, webhook, ntfy, push, plugin:*) ─────────
// One loop over the registry. The message is rendered once per recipient, in
// their language, and handed to every channel that wants it — so a plugin
// channel never touches i18n.
@@ -0,0 +1,97 @@
import { MAX_PUSH_ENDPOINT_LENGTH, type PushSubscriptionInput } from '@trek/shared';
import { decodeBase64Url } from '../../../app-config';
import { isP256Point } from './web-push-crypto';
/**
* The browser hands us a URL and the server POSTs to it for as long as the row
* lives, so the URL is checked like any other user-supplied target, and more
* narrowly: it has to be one of the push services browsers actually use. The
* SSRF guard still runs on every send; this list is what keeps a subscription
* from turning the server into a POST relay towards some other public host.
*/
export const PUSH_SERVICE_HOSTS: readonly string[] = [
// Chrome, Edge on Android, and every Chromium browser that uses Google's push.
'fcm.googleapis.com',
'android.googleapis.com',
// Firefox (autopush).
'updates.push.services.mozilla.com',
'push.services.mozilla.com',
// Safari on macOS, and Home Screen apps on iOS and iPadOS 16.4+.
'web.push.apple.com',
];
/** Services that hand out per-region or per-cluster hosts under one domain. */
export const PUSH_SERVICE_HOST_SUFFIXES: readonly string[] = [
'.push.apple.com',
// Edge on Windows (WNS).
'.notify.windows.com',
];
/** The bespoke 400 bodies for a subscription the browser should not have sent. */
export const PUSH_SUBSCRIPTION_ERRORS = {
endpoint: 'Invalid push endpoint',
service: 'Push endpoint is not a known push service',
p256dh: 'Invalid push subscription key',
auth: 'Invalid push subscription auth secret',
} as const;
export interface CheckedPushSubscription {
endpoint: string;
/** Canonical base64url, no padding. */
p256dh: string;
/** Canonical base64url, no padding. */
auth: string;
}
export type PushSubscriptionCheck = { ok: true; value: CheckedPushSubscription } | { ok: false; error: string };
function isKnownPushServiceHost(host: string): boolean {
return (
PUSH_SERVICE_HOSTS.includes(host) ||
PUSH_SERVICE_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix) && host.length > suffix.length)
);
}
/**
* The endpoint half of the check, shared with the sender so a row that no
* longer passes (a host dropped from the list) is not posted to again.
* Answers 'ok', or which of the errors applies: 'endpoint' for anything that is
* not a plain https URL on the default port without credentials, 'service' for
* a host that is not on the list.
*/
export function checkPushEndpoint(endpoint: string): 'ok' | 'endpoint' | 'service' {
if (endpoint.length > MAX_PUSH_ENDPOINT_LENGTH) return 'endpoint';
let url: URL;
try {
url = new URL(endpoint);
} catch {
return 'endpoint';
}
if (url.protocol !== 'https:' || url.username || url.password || url.port) return 'endpoint';
return isKnownPushServiceHost(url.hostname) ? 'ok' : 'service';
}
export function isPushServiceEndpoint(endpoint: string): boolean {
return checkPushEndpoint(endpoint) === 'ok';
}
/**
* Everything the Zod contract cannot say about a subscription: where it points,
* and that its keys have the sizes RFC 8291 needs (and the p256dh a point on
* the curve), so the sender never meets a row it cannot encrypt for.
*/
export function checkPushSubscription(input: PushSubscriptionInput): PushSubscriptionCheck {
const endpointVerdict = checkPushEndpoint(input.endpoint);
if (endpointVerdict !== 'ok') return { ok: false, error: PUSH_SUBSCRIPTION_ERRORS[endpointVerdict] };
const p256dh = decodeBase64Url(input.keys.p256dh);
if (!p256dh || !isP256Point(p256dh)) return { ok: false, error: PUSH_SUBSCRIPTION_ERRORS.p256dh };
const auth = decodeBase64Url(input.keys.auth);
if (auth?.length !== 16) return { ok: false, error: PUSH_SUBSCRIPTION_ERRORS.auth };
return {
ok: true,
value: { endpoint: input.endpoint, p256dh: p256dh.toString('base64url'), auth: auth.toString('base64url') },
};
}
@@ -0,0 +1,131 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../../database/database.service';
import type { CheckedPushSubscription } from './push-subscription.helpers';
export interface PushSubscriptionRow {
id: number;
user_id: number;
endpoint: string;
p256dh: string;
auth: string;
vapid_public_key: string;
user_agent: string | null;
created_at: string;
last_success_at: string | null;
failure_count: number;
}
/**
* How many browsers one account can receive push on. Generous for phones,
* tablets and a few desktop profiles; the cap only exists so a client that
* subscribes in a loop cannot grow the table or the fan-out without bound.
*/
export const MAX_PUSH_DEVICES_PER_USER = 20;
/** Only there to tell one user's devices apart in the table, never parsed, so a cut-off one does no harm. */
const USER_AGENT_MAX_LENGTH = 256;
/**
* The push_subscriptions table: one row per browser a user switched push on
* in. Only rows the checks in push-subscription.helpers.ts accepted get here.
*/
@Injectable()
export class PushSubscriptionsService {
constructor(private readonly db: DatabaseService) {}
/**
* Register a browser, or refresh it when it subscribes again. The endpoint
* is unique, so a browser shared by two accounts moves to whoever subscribed
* on it last instead of notifying both. A refresh renews created_at, which
* makes the cap drop the device that registered longest ago rather than one
* that is in daily use. Answers how many devices the user now has.
*/
upsert(
userId: number,
subscription: CheckedPushSubscription,
vapidPublicKey: string,
userAgent?: string | null,
): number {
const agent = userAgent ? userAgent.slice(0, USER_AGENT_MAX_LENGTH) : null;
return this.db.transaction(() => {
this.db.run(
`INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, vapid_public_key, user_agent)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(endpoint) DO UPDATE SET
user_id = excluded.user_id,
p256dh = excluded.p256dh,
auth = excluded.auth,
vapid_public_key = excluded.vapid_public_key,
user_agent = excluded.user_agent,
created_at = CURRENT_TIMESTAMP,
failure_count = 0`,
userId,
subscription.endpoint,
subscription.p256dh,
subscription.auth,
vapidPublicKey,
agent,
);
const overflow = this.countForUser(userId) - MAX_PUSH_DEVICES_PER_USER;
if (overflow > 0) {
this.db.run(
`DELETE FROM push_subscriptions WHERE id IN (
SELECT id FROM push_subscriptions
WHERE user_id = ? AND endpoint <> ?
ORDER BY created_at ASC, id ASC
LIMIT ?
)`,
userId,
subscription.endpoint,
overflow,
);
}
return this.countForUser(userId);
});
}
/** Forget one of the caller's own browsers. Someone else's endpoint is never touched. */
removeForUser(userId: number, endpoint: string): boolean {
return (
this.db.run('DELETE FROM push_subscriptions WHERE user_id = ? AND endpoint = ?', userId, endpoint).changes > 0
);
}
listForUser(userId: number): PushSubscriptionRow[] {
return this.db.all<PushSubscriptionRow>('SELECT * FROM push_subscriptions WHERE user_id = ? ORDER BY id', userId);
}
countForUser(userId: number): number {
return this.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM push_subscriptions WHERE user_id = ?', userId)?.n ?? 0;
}
hasAny(userId: number): boolean {
return !!this.db.get('SELECT 1 FROM push_subscriptions WHERE user_id = ? LIMIT 1', userId);
}
/** The sender's clean-up: the push service said the browser is gone, or the row can no longer be sent to. */
deleteById(id: number): void {
this.db.run('DELETE FROM push_subscriptions WHERE id = ?', id);
}
recordSuccess(id: number): void {
this.db.run(
'UPDATE push_subscriptions SET last_success_at = CURRENT_TIMESTAMP, failure_count = 0 WHERE id = ?',
id,
);
}
/**
* Counts one more failed send since the last success and answers the new
* count, read in the same statement so two sends at once cannot both see
* the old value. 0 when the row is gone already.
*/
recordFailure(id: number): number {
return (
this.db.get<{ failure_count: number }>(
'UPDATE push_subscriptions SET failure_count = failure_count + 1 WHERE id = ? RETURNING failure_count',
id,
)?.failure_count ?? 0
);
}
}
@@ -0,0 +1,77 @@
import { Body, Controller, Delete, Get, Headers, HttpCode, HttpException, Post, UseGuards } from '@nestjs/common';
import type { PushPublicKeyResult, PushSubscribeResult, PushUnsubscribeResult } from '@trek/shared';
import type { User } from '../../../types';
import { CurrentUser } from '../../auth/current-user.decorator';
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
import { PushSubscribeDto, PushUnsubscribeDto } from '../notifications.dto';
import { checkPushSubscription } from './push-subscription.helpers';
import { PushSubscriptionsService } from './push-subscriptions.service';
import { PUSH_UNAVAILABLE_ERROR, PushUnavailableError, VapidKeysService } from './vapid-keys.service';
/**
* /api/notifications/push: how a browser signs up for Web Push (#894).
*
* The browser fetches the server's public key, subscribes with its push
* service, and hands the subscription over here; logging out or switching push
* off in that browser deletes it again. Which events arrive is the ordinary
* preference matrix ('push' column), and the test send is the generic
* POST /api/notifications/test/push.
*
* No MCP tool mirrors these routes on purpose: a subscription is created by a
* browser's push manager for that one browser, and there is nothing an MCP
* client could subscribe with.
*/
@Controller('api/notifications/push')
@UseGuards(JwtAuthGuard)
export class PushController {
constructor(
private readonly keys: VapidKeysService,
private readonly subscriptions: PushSubscriptionsService,
) {}
@Get('public-key')
publicKey(): PushPublicKeyResult {
return { publicKey: this.currentPublicKey() };
}
@Post('subscriptions')
@HttpCode(200)
subscribe(
@CurrentUser() user: User,
@Body() body: PushSubscribeDto,
@Headers('user-agent') userAgent?: string,
): PushSubscribeResult {
const checked = checkPushSubscription(body.subscription);
if ('error' in checked) throw new HttpException({ error: checked.error }, 400);
const devices = this.subscriptions.upsert(user.id, checked.value, this.currentPublicKey(), userAgent);
return { success: true, devices };
}
/**
* Idempotent: the push service may already have expired the row, and a
* browser logging out must not get an error for that. Only the caller's own
* endpoint can go; the same URL on someone else's account is left alone.
* Works while push is unavailable too, so a device can always be forgotten.
*/
@Delete('subscriptions')
@HttpCode(200)
unsubscribe(@CurrentUser() user: User, @Body() body: PushUnsubscribeDto): PushUnsubscribeResult {
this.subscriptions.removeForUser(user.id, body.endpoint);
return { success: true };
}
/**
* The key browsers subscribe with. While the key pair cannot be used, stored
* or from VAPID_* (the log says why), the answer is a 503: nothing could be
* signed for a new subscription, and the devices already subscribed stay as
* they are.
*/
private currentPublicKey(): string {
try {
return this.keys.getPublicKey();
} catch (err) {
if (err instanceof PushUnavailableError) throw new HttpException({ error: PUSH_UNAVAILABLE_ERROR }, 503);
throw err;
}
}
}
@@ -0,0 +1,301 @@
import { Injectable, type OnApplicationBootstrap } from '@nestjs/common';
import { decodeBase64Url, isVapidSubject, readEnv, stripTrailingSlashes } from '../../../app-config';
import { logError, logInfo } from '../../audit/audit-log.logger';
import { decrypt_api_key, encrypt_api_key } from '../../common/crypto/apiKeyCrypto';
import { DatabaseService } from '../../database/database.service';
import { generateVapidKeyPair, isVapidKeyPair, type VapidKeyPair } from './web-push-crypto';
/** app_settings rows holding the generated pair. The private half is stored encrypted. */
export const VAPID_PUBLIC_KEY_SETTING = 'web_push_vapid_public_key';
export const VAPID_PRIVATE_KEY_SETTING = 'web_push_vapid_private_key';
/** The VAPID contact when nothing better is configured: a real https URL every push service accepts. */
export const FALLBACK_VAPID_SUBJECT = 'https://github.com/liketrek/TREK';
/** What the push routes answer while the key pair cannot be used. */
export const PUSH_UNAVAILABLE_ERROR = 'Web Push is currently unavailable on this server';
export interface ResolvedVapidKeys extends VapidKeyPair {
source: 'env' | 'database';
}
/**
* Thrown by getKeys() while push is off because the pair it would sign with
* cannot be used: the VAPID_* variables name one that cannot, or, with them
* unset, the stored one cannot. The reason is in the server log (once, without
* key material); this error only says that nothing can be signed right now.
*/
export class PushUnavailableError extends Error {
constructor() {
super('there is no usable VAPID key pair; see the earlier Web Push error in the log');
this.name = 'PushUnavailableError';
}
}
type UnusableReason = 'incomplete' | 'undecryptable' | 'mismatched';
type StoredPair =
| { state: 'absent' }
| { state: 'usable'; keys: VapidKeyPair }
| { state: UnusableReason; fingerprint: string };
type PresentPair = Exclude<StoredPair, { state: 'absent' }>;
type EnvPair =
| { state: 'unset' }
| { state: 'usable'; keys: VapidKeyPair }
| { state: 'unusable'; fingerprint: string; problem: string };
const DELETE_BOTH_ROWS =
`delete both app_settings rows ${VAPID_PUBLIC_KEY_SETTING} and ${VAPID_PRIVATE_KEY_SETTING} ` +
'to generate a new pair (every device then has to turn push on again)';
/** The admin-facing explanation for each unusable state. Never contains key material. */
const UNUSABLE_MESSAGES: Record<UnusableReason, string> = {
incomplete: `only one half of the stored VAPID key pair exists. To start over, ${DELETE_BOTH_ROWS}.`,
undecryptable:
'the stored VAPID private key cannot be decrypted with the current ENCRYPTION_KEY (for example after a ' +
'restore under a different key). Start the server with the ENCRYPTION_KEY it was encrypted with to bring ' +
`push back as it was, or ${DELETE_BOTH_ROWS}.`,
mismatched: `the stored VAPID private key does not belong to the stored public key. To start over, ${DELETE_BOTH_ROWS}.`,
};
/** Re-encoded without padding, so the key the browser gets and the one rows are compared with is one string. */
function canonical(value: string): string {
return decodeBase64Url(value)?.toString('base64url') ?? value;
}
/** An https URL on a host other than localhost: what APP_URL has to be to serve as the VAPID contact. */
function isPublicHttpsUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === 'https:' && url.hostname !== 'localhost';
} catch {
return false;
}
}
/**
* The server's VAPID identity: the key pair every subscription is bound to, and
* the contact (`sub`) sent with each request.
*
* VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY win when both are set and belong
* together. Otherwise the pair lives in app_settings, created once and then
* kept: it travels with the database into every backup, where a key file in
* data/ would not, and a restore that minted a new pair would silently cut off
* every device.
*
* When the pair push should sign with cannot be used, push is off: it never
* falls back on another pair. A pair in the environment that does not fit
* together (or only one half of it) is logged rather than refusing to boot,
* and push stays off until the variables are fixed, even while a usable pair
* is stored: the devices subscribed with the environment key, so the first
* send under any other pair would drop every one of them. A stored pair that
* is there but cannot be used (a private key the current ENCRYPTION_KEY cannot
* open, halves that do not match, one half missing) is never replaced either.
* Either way the log says why, every device stays subscribed, and fixing the
* variables or starting with the right ENCRYPTION_KEY again brings everything
* back unchanged. A new pair is only ever generated while neither row exists
* and neither VAPID_* variable is set.
*/
@Injectable()
export class VapidKeysService implements OnApplicationBootstrap {
/** The last environment problem logged, so a bad pair is reported once and not on every send. */
private reportedEnvProblem: string | null = null;
/** The last stored-pair problem logged, for the same reason. */
private reportedStoredProblem: string | null = null;
/** The decrypted stored pair, keyed by the ciphertext it came from. */
private stored: { ciphertext: string; keys: VapidKeyPair } | null = null;
constructor(private readonly db: DatabaseService) {}
/**
* Make sure the pair exists before the first request. The public-key GET is
* the first thing a browser asks for, and a GET must not write; creating the
* pair here keeps that route a read. The lazy path in getKeys() covers
* instances built without the Nest lifecycle and a database restored under a
* running server.
*/
onApplicationBootstrap(): void {
this.succeeds(() => this.getKeys());
}
/**
* Whether there is a pair to sign with right now. Only reads, so the
* dispatcher and the preference matrix GET can ask on every call: while
* neither row exists the answer is yes and nothing is written, because the
* next send or subscribe creates the pair through getKeys(). Never throws;
* the reason for a no is in the log.
*/
isAvailable(): boolean {
return this.succeeds(() => this.checkUsable());
}
/**
* The pair to sign with, creating the stored one first while neither row
* exists. Throws PushUnavailableError while the pair it would use cannot be.
*/
getKeys(): ResolvedVapidKeys {
const fromEnv = this.envKeys();
if (fromEnv) return { ...fromEnv, source: 'env' };
return { ...this.usableStored(this.readOrCreateStored()), source: 'database' };
}
getPublicKey(): string {
return this.getKeys().publicKey;
}
/**
* The `sub` claim: VAPID_SUBJECT, else APP_URL when it is public https, else
* the project URL. Push services use it to reach whoever runs the sender, and
* Apple refuses a token without a valid one. Only APP_URL itself counts, not
* getAppUrl()'s stand-ins: the first ALLOWED_ORIGINS entry can be another
* site that is merely allowed to call this API, and http://localhost is no
* valid subject.
*/
getSubject(): string {
const env = readEnv();
const configured = env.push.vapidSubject;
if (configured && isVapidSubject(configured)) return configured;
const appUrl = env.app.appUrl?.trim();
return appUrl && isPublicHttpsUrl(appUrl) ? stripTrailingSlashes(appUrl) : FALLBACK_VAPID_SUBJECT;
}
/** getKeys() without the create: the same refusals, and a missing stored pair counts as usable. */
private checkUsable(): void {
if (this.envKeys()) return;
const stored = this.readStored();
if (stored.state !== 'absent') this.usableStored(stored);
}
private succeeds(attempt: () => unknown): boolean {
try {
attempt();
return true;
} catch (err) {
// An unusable pair has already been explained where it was found.
if (!(err instanceof PushUnavailableError)) {
logError(`Web Push: could not prepare the VAPID key pair: ${err instanceof Error ? err.message : err}`);
}
return false;
}
}
/**
* The pair from VAPID_*, or null while neither variable is set. A pair that
* is set but cannot be used turns push off rather than handing out the
* stored pair (or a new one) in its place: the devices subscribed with the
* environment key, and the first send under another pair would drop them
* all. Push comes back unchanged once the variables are fixed.
*/
private envKeys(): VapidKeyPair | null {
const env = this.readEnvPair();
if (env.state !== 'unusable') {
this.reportedEnvProblem = null;
return env.state === 'usable' ? env.keys : null;
}
if (this.reportedEnvProblem !== env.fingerprint) {
this.reportedEnvProblem = env.fingerprint;
logError(
`Web Push is off: ${env.problem}. Set both variables to the two halves of one pair to turn it back on. ` +
'Until then nothing is sent, every device stays subscribed, and no pair stored in the database is ' +
'used in its place.',
);
}
throw new PushUnavailableError();
}
/** What the VAPID_* variables hold. The fingerprint only tells one broken state from the next. */
private readEnvPair(): EnvPair {
const { vapidPublicKey, vapidPrivateKey } = readEnv().push;
if (!vapidPublicKey && !vapidPrivateKey) return { state: 'unset' };
if (!vapidPublicKey || !vapidPrivateKey) {
return {
state: 'unusable',
fingerprint: `half:${vapidPublicKey ?? ''}`,
problem: `${vapidPublicKey ? 'VAPID_PUBLIC_KEY' : 'VAPID_PRIVATE_KEY'} is set without its other half`,
};
}
if (!isVapidKeyPair(vapidPublicKey, vapidPrivateKey)) {
return {
state: 'unusable',
fingerprint: `pair:${vapidPublicKey}`,
problem: 'VAPID_PUBLIC_KEY does not belong to VAPID_PRIVATE_KEY',
};
}
return { state: 'usable', keys: { publicKey: canonical(vapidPublicKey), privateKey: canonical(vapidPrivateKey) } };
}
private setting(key: string): string | null {
return this.db.get<{ value: string | null }>('SELECT value FROM app_settings WHERE key = ?', key)?.value || null;
}
private writeSetting(key: string, value: string): void {
this.db.run(
`INSERT INTO app_settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
key,
value,
);
}
/** What app_settings holds right now. The fingerprint only tells one broken state from the next. */
private readStored(): StoredPair {
const publicKey = this.setting(VAPID_PUBLIC_KEY_SETTING);
const ciphertext = this.setting(VAPID_PRIVATE_KEY_SETTING);
if (!publicKey && !ciphertext) return { state: 'absent' };
const fingerprint = `${publicKey ?? ''}\n${ciphertext ?? ''}`;
if (!publicKey || !ciphertext) return { state: 'incomplete', fingerprint };
if (this.stored?.ciphertext === ciphertext && this.stored.keys.publicKey === publicKey) {
return { state: 'usable', keys: this.stored.keys };
}
const privateKey = decrypt_api_key(ciphertext);
if (!privateKey) return { state: 'undecryptable', fingerprint };
if (!isVapidKeyPair(publicKey, privateKey)) return { state: 'mismatched', fingerprint };
const keys = { publicKey, privateKey };
this.stored = { ciphertext, keys };
return { state: 'usable', keys };
}
/**
* The stored pair, created first only when neither row exists: checked again
* inside the transaction and written in it, both rows or neither, so the
* pair is created exactly once.
*/
private readOrCreateStored(): PresentPair {
const first = this.readStored();
if (first.state !== 'absent') return first;
return this.db.transaction((): PresentPair => {
const again = this.readStored();
if (again.state !== 'absent') return again;
return { state: 'usable', keys: this.generateAndStore() };
});
}
/** The keys of a stored pair, or PushUnavailableError (logged once per state) when it cannot be used. */
private usableStored(found: PresentPair): VapidKeyPair {
if (found.state === 'usable') {
this.reportedStoredProblem = null;
return found.keys;
}
this.reportStoredProblem(found.state, found.fingerprint);
throw new PushUnavailableError();
}
private reportStoredProblem(reason: UnusableReason, fingerprint: string): void {
const key = `${reason}:${fingerprint}`;
if (this.reportedStoredProblem === key) return;
this.reportedStoredProblem = key;
logError(`Web Push is off: ${UNUSABLE_MESSAGES[reason]}`);
}
private generateAndStore(): VapidKeyPair {
const keys = generateVapidKeyPair();
const ciphertext = encrypt_api_key(keys.privateKey);
this.writeSetting(VAPID_PUBLIC_KEY_SETTING, keys.publicKey);
this.writeSetting(VAPID_PRIVATE_KEY_SETTING, ciphertext);
this.stored = { ciphertext, keys };
logInfo('Web Push: generated the VAPID key pair for this instance.');
return keys;
}
}
@@ -0,0 +1,214 @@
import {
createCipheriv,
createECDH,
createPrivateKey,
ECDH,
generateKeyPairSync,
hkdfSync,
randomBytes,
type KeyObject,
} from 'node:crypto';
import jwt from 'jsonwebtoken';
import { decodeBase64Url } from '../../../app-config';
/**
* Web Push message encryption (RFC 8291, `aes128gcm` from RFC 8188) and the
* VAPID Authorization header (RFC 8292), on node:crypto and the jsonwebtoken
* the server already signs its sessions with.
*
* Pure: no database, no network, no DI. The `web-push` package does the same
* arithmetic, but it posts through its own https.request and so past the SSRF
* guard; the request is the part this domain has to own, and what is left of
* the package is this file.
*/
/** Record size announced in the header. One record carries the whole message. */
export const PUSH_RECORD_SIZE = 4096;
const P256_POINT_BYTES = 65;
const AUTH_SECRET_BYTES = 16;
const SALT_BYTES = 16;
const GCM_TAG_BYTES = 16;
/** salt (16) + rs (4) + idlen (1) + keyid (the 65-byte sender key). */
const HEADER_BYTES = SALT_BYTES + 4 + 1 + P256_POINT_BYTES;
const KEY_INFO_PREFIX = Buffer.from('WebPush: info\0', 'latin1');
const CEK_INFO = Buffer.from('Content-Encoding: aes128gcm\0', 'latin1');
const NONCE_INFO = Buffer.from('Content-Encoding: nonce\0', 'latin1');
/** Padding delimiter of the last (here: only) record, RFC 8188 section 2. */
const LAST_RECORD_DELIMITER = Buffer.from([0x02]);
/** Largest plaintext that still fits one record next to its delimiter and tag. */
export const MAX_PUSH_PLAINTEXT_BYTES = PUSH_RECORD_SIZE - HEADER_BYTES - 1 - GCM_TAG_BYTES;
/**
* How long a VAPID token is good for. RFC 8292 allows up to 24 hours and Apple
* refuses anything longer, so this stays well inside it.
*/
export const VAPID_TOKEN_TTL_SECONDS = 12 * 60 * 60;
/** A VAPID key pair in the form browsers and the Web Push tools use: base64url, raw. */
export interface VapidKeyPair {
/** Uncompressed P-256 point, 65 bytes. What the browser subscribes with. */
publicKey: string;
/** The 32-byte private scalar. */
privateKey: string;
}
/** The two secrets a browser hands over with its subscription. */
export interface PushEncryptionKeys {
/** The browser's P-256 public key, uncompressed. */
p256dh: Buffer;
/** The 16-byte authentication secret. */
auth: Buffer;
}
/**
* Fixed inputs for the RFC 8291 test vector. Leave both unset for real
* messages: every message needs a fresh salt and a fresh sender key, or the
* same key and nonce would encrypt two plaintexts.
*/
export interface PushEncryptionOptions {
salt?: Buffer;
senderPrivateKey?: Buffer;
}
function hkdf(ikm: Buffer, salt: Buffer, info: Buffer, length: number): Buffer {
return Buffer.from(hkdfSync('sha256', ikm, salt, info, length));
}
/** True for a point that is actually on P-256, not just 65 bytes that start with 0x04. */
export function isP256Point(bytes: Buffer): boolean {
if (bytes.length !== P256_POINT_BYTES || bytes[0] !== 0x04) return false;
try {
ECDH.convertKey(bytes, 'prime256v1');
return true;
} catch {
return false;
}
}
/** A fresh P-256 pair. Exported as JWK so both halves come out at full length. */
export function generateVapidKeyPair(): VapidKeyPair {
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
const { x, y, d } = privateKey.export({ format: 'jwk' });
if (!x || !y || !d) throw new Error('P-256 key export did not produce x, y and d');
return {
publicKey: Buffer.concat([Buffer.from([0x04]), Buffer.from(x, 'base64url'), Buffer.from(y, 'base64url')]).toString(
'base64url',
),
privateKey: d,
};
}
/** The public key a private scalar belongs to, or null when the scalar is not a usable P-256 key. */
export function derivePublicKey(privateKey: string): Buffer | null {
const scalar = decodeBase64Url(privateKey);
if (scalar?.length !== 32) return null;
try {
const ecdh = createECDH('prime256v1');
ecdh.setPrivateKey(scalar);
return ecdh.getPublicKey();
} catch {
return null;
}
}
/** Whether the two halves belong together. Compared as bytes, so padding and the like do not matter. */
export function isVapidKeyPair(publicKey: string, privateKey: string): boolean {
const expected = derivePublicKey(privateKey);
const given = decodeBase64Url(publicKey);
return !!expected && !!given && given.equals(expected);
}
/**
* Encrypt one push message for one browser (RFC 8291 section 3.4): ECDH with
* the browser key, HKDF over the shared secret and the auth secret, then a
* single AES-128-GCM record behind the aes128gcm header, whose key id is the
* sender's public key.
*/
export function encryptPushMessage(
plaintext: Buffer,
keys: PushEncryptionKeys,
options: PushEncryptionOptions = {},
): Buffer {
if (!isP256Point(keys.p256dh)) throw new Error('p256dh is not an uncompressed P-256 public key');
if (keys.auth.length !== AUTH_SECRET_BYTES) throw new Error('auth secret must be 16 bytes');
if (plaintext.length > MAX_PUSH_PLAINTEXT_BYTES) {
throw new Error(`push payload of ${plaintext.length} bytes does not fit one record`);
}
const salt = options.salt ?? randomBytes(SALT_BYTES);
if (salt.length !== SALT_BYTES) throw new Error('salt must be 16 bytes');
const sender = createECDH('prime256v1');
if (options.senderPrivateKey) sender.setPrivateKey(options.senderPrivateKey);
else sender.generateKeys();
const senderPublicKey = sender.getPublicKey();
const ecdhSecret = sender.computeSecret(keys.p256dh);
const keyInfo = Buffer.concat([KEY_INFO_PREFIX, keys.p256dh, senderPublicKey]);
const ikm = hkdf(ecdhSecret, keys.auth, keyInfo, 32);
const contentKey = hkdf(ikm, salt, CEK_INFO, 16);
const nonce = hkdf(ikm, salt, NONCE_INFO, 12);
const cipher = createCipheriv('aes-128-gcm', contentKey, nonce);
const record = Buffer.concat([
cipher.update(plaintext),
cipher.update(LAST_RECORD_DELIMITER),
cipher.final(),
cipher.getAuthTag(),
]);
const header = Buffer.alloc(SALT_BYTES + 4 + 1);
salt.copy(header, 0);
header.writeUInt32BE(PUSH_RECORD_SIZE, SALT_BYTES);
header.writeUInt8(senderPublicKey.length, SALT_BYTES + 4);
return Buffer.concat([header, senderPublicKey, record]);
}
/** The signing key, and the public key re-encoded the way the `k=` parameter wants it (no padding). */
function vapidSigningKey(keys: VapidKeyPair): { signingKey: KeyObject; publicKey: string } {
const point = decodeBase64Url(keys.publicKey);
const scalar = decodeBase64Url(keys.privateKey);
if (point?.length !== P256_POINT_BYTES || scalar?.length !== 32) throw new Error('VAPID key pair is malformed');
const signingKey = createPrivateKey({
key: {
kty: 'EC',
crv: 'P-256',
x: point.subarray(1, 33).toString('base64url'),
y: point.subarray(33).toString('base64url'),
d: scalar.toString('base64url'),
},
format: 'jwk',
});
return { signingKey, publicKey: point.toString('base64url') };
}
/**
* Returns a function that builds the Authorization header for an endpoint
* (RFC 8292): `vapid t=<ES256 JWT>, k=<public key>`, the token's audience being
* the endpoint's origin. Tokens are reused per origin for the life of the
* signer, which is one delivery, so a user with three Chrome devices signs once.
*/
export function createVapidSigner(keys: VapidKeyPair, subject: string, now: () => number = Date.now) {
const { signingKey, publicKey } = vapidSigningKey(keys);
const byOrigin = new Map<string, string>();
return (endpoint: string): string => {
const audience = new URL(endpoint).origin;
let header = byOrigin.get(audience);
if (!header) {
const token = jwt.sign(
{ aud: audience, exp: Math.floor(now() / 1000) + VAPID_TOKEN_TTL_SECONDS, sub: subject },
signingKey,
// The three claims RFC 8292 names and nothing else: an extra iat buys
// nothing and is one more thing a strict push service could object to.
{ algorithm: 'ES256', noTimestamp: true },
);
header = `vapid t=${token}, k=${publicKey}`;
byOrigin.set(audience, header);
}
return header;
};
}
export type VapidSigner = ReturnType<typeof createVapidSigner>;
@@ -0,0 +1,292 @@
import { Injectable } from '@nestjs/common';
import type { ChannelTestResult, PushPayload } from '@trek/shared';
import { decodeBase64Url } from '../../../app-config';
import { discardBody, readCappedText } from '../../../utils/cappedFetch';
import { safeFetchFollow, SsrfBlockedError } from '../../../utils/ssrfGuard';
import { logError, logInfo } from '../../audit/audit-log.logger';
import type { ChannelMessage } from '../notification-events';
import { isPushServiceEndpoint } from '../push/push-subscription.helpers';
import { PushSubscriptionsService, type PushSubscriptionRow } from '../push/push-subscriptions.service';
import { PUSH_UNAVAILABLE_ERROR, VapidKeysService } from '../push/vapid-keys.service';
import { createVapidSigner, encryptPushMessage, type VapidSigner } from '../push/web-push-crypto';
/**
* Ceiling for the JSON the service worker receives. A single aes128gcm record
* can carry just under 4 KB and some push services refuse anything larger, so
* the payload stays well clear of that and the body gives way first.
*/
export const MAX_PUSH_PAYLOAD_BYTES = 3000;
const TITLE_MAX_CHARS = 120;
/** A navigate target is an app path like /trips/12; anything longer is not one. */
const URL_MAX_LENGTH = 256;
const ELLIPSIS = '…';
const PUSH_TIMEOUT_MS = 10_000;
/** A day: a reminder that reaches a phone after a longer flight is still worth showing, a week-old one is not. */
const PUSH_TTL_SECONDS = 24 * 60 * 60;
const ERROR_BODY_MAX_BYTES = 1024;
/**
* How many failed sends in a row (with no success between them) a device gets
* before a 403 removes it. A 403 is not proof the subscription is dead: Apple
* also answers it for a token it rejects (BadJwtToken, a bad subject, clock
* skew), which is the server's problem and must not cost the user their device
* on the first try.
*/
export const FORBIDDEN_FAILURES_BEFORE_REMOVAL = 3;
/**
* The one event whose notifications replace each other: a burst of chat
* messages in one trip shows as one notification that keeps updating. Every
* other event stands on its own, so two todos, two bookings or two plugin
* notices never hide one another.
*/
const COLLAPSING_EVENT = 'collab_message';
type Urgency = 'normal' | 'high';
/** The events that are about a date, where arriving late defeats the point (RFC 8030 section 5.3). */
const HIGH_URGENCY_EVENTS: ReadonlySet<string> = new Set(['trip_reminder', 'todo_due']);
/** No tag either: two presses of Send test are two notifications, like two of anything but chat. */
const TEST_PAYLOAD: PushPayload = {
title: 'Test Notification',
body: 'This is a test notification from TREK. If you received this, push notifications are working on this device.',
url: '/settings?tab=notifications',
};
/**
* The target the worker may open: a path on this origin only. Absolute URLs
* and protocol-relative ones (`//host`, `/\host`) would take the click to
* another site, so they are dropped rather than passed on.
*/
export function samePagePath(target: string | undefined): string | undefined {
if (!target || target.length > URL_MAX_LENGTH) return undefined;
if (!/^\/(?![/\\])[\x21-\x7e]*$/.test(target)) return undefined;
return target;
}
function truncateChars(value: string, max: number): string {
const chars = Array.from(value);
return chars.length <= max ? value : `${chars.slice(0, max - 1).join('')}${ELLIPSIS}`;
}
function payloadBytes(payload: PushPayload): number {
return Buffer.byteLength(JSON.stringify(payload), 'utf8');
}
/**
* The rendered notification as the service worker gets it. Title and body are
* already in the recipient's language; the worker has no translations. Only
* chat messages carry a tag, `collab_message:<trip path>`, so a burst of them in
* one trip shows as one notification that keeps updating instead of a pile of
* them. Everything else has no tag and never replaces an earlier notification.
*
* The body is cut, at a character boundary and with an ellipsis, until the
* JSON fits MAX_PUSH_PAYLOAD_BYTES. The title and the path are capped first, so
* whatever they are, the body always keeps room of its own.
*/
export function buildPushPayload(
msg: Pick<ChannelMessage, 'event' | 'title' | 'body' | 'navigateTarget'>,
): PushPayload {
const url = samePagePath(msg.navigateTarget);
const base: PushPayload = {
title: truncateChars(msg.title, TITLE_MAX_CHARS),
body: '',
...(url ? { url } : {}),
...(url && msg.event === COLLAPSING_EVENT ? { tag: `${COLLAPSING_EVENT}:${url}` } : {}),
};
const full = { ...base, body: msg.body };
if (payloadBytes(full) <= MAX_PUSH_PAYLOAD_BYTES) return full;
const chars = Array.from(msg.body);
let fits = 0;
let tooLong = chars.length;
// Largest prefix that still fits with the ellipsis behind it.
while (tooLong - fits > 1) {
const mid = Math.floor((fits + tooLong) / 2);
if (payloadBytes({ ...base, body: `${chars.slice(0, mid).join('')}${ELLIPSIS}` }) <= MAX_PUSH_PAYLOAD_BYTES)
fits = mid;
else tooLong = mid;
}
return { ...base, body: `${chars.slice(0, fits).join('')}${ELLIPSIS}` };
}
function hostOf(endpoint: string): string {
try {
return new URL(endpoint).hostname;
} catch {
return 'invalid-endpoint';
}
}
/**
* Web Push delivery: encrypt the payload for each of a user's browsers and
* POST it to that browser's push service, through the SSRF guard.
*
* Mirrors the ntfy and webhook transports: every failure is logged and turned
* into `false`, nothing throws into the dispatcher. It also tends the table as
* it goes, because the push services are the only ones who know a browser is
* gone: 404 and 410 mean the subscription expired or was revoked, and the row
* is deleted at once. A 403 is counted like any other failure and only removes
* the row once it has failed FORBIDDEN_FAILURES_BEFORE_REMOVAL times in a row,
* because the same status also means a rejected token. While the server's key
* pair is unusable (VapidKeysService logs why) nothing is sent, every row
* stays and the test send says so. Endpoints are capability URLs, so the log
* names the push service host, never the URL.
*/
@Injectable()
export class WebPushService {
constructor(
private readonly keys: VapidKeysService,
private readonly subscriptions: PushSubscriptionsService,
) {}
hasDevices(userId: number): boolean {
return this.subscriptions.hasAny(userId);
}
/**
* False while the server has no key pair it can sign with. The channel
* reports it as its instance readiness, so the dispatcher skips push for
* everyone (every device stays subscribed) and the user's settings drop the
* Push card and column instead of offering a switch that could only fail.
*/
isAvailable(): boolean {
return this.keys.isAvailable();
}
/** True when at least one of the user's browsers accepted the message. */
sendToUser(userId: number, msg: ChannelMessage): Promise<boolean> {
const urgency: Urgency = HIGH_URGENCY_EVENTS.has(msg.event) ? 'high' : 'normal';
return this.deliver(userId, buildPushPayload(msg), urgency, msg.event);
}
/** The Send test button: a short fixed message to every browser the user turned push on in. */
async sendTest(userId: number): Promise<ChannelTestResult> {
if (!this.isAvailable()) return { success: false, error: PUSH_UNAVAILABLE_ERROR };
const sent = await this.deliver(userId, TEST_PAYLOAD, 'normal', 'test');
return sent ? { success: true } : { success: false, error: 'Failed to send push notification' };
}
private async deliver(userId: number, payload: PushPayload, urgency: Urgency, event: string): Promise<boolean> {
try {
const rows = this.subscriptions.listForUser(userId);
if (rows.length === 0) return false;
const keys = this.keys.getKeys();
const sign = createVapidSigner(keys, this.keys.getSubject());
const plaintext = Buffer.from(JSON.stringify(payload), 'utf8');
const results = await Promise.all(
rows.map((row) => this.deliverTo(row, plaintext, keys.publicKey, sign, urgency, event)),
);
return results.includes(true);
} catch (err) {
logError(`Web Push failed event=${event} user=${userId}: ${err instanceof Error ? err.message : err}`);
return false;
}
}
private async deliverTo(
row: PushSubscriptionRow,
plaintext: Buffer,
publicKey: string,
sign: VapidSigner,
urgency: Urgency,
event: string,
): Promise<boolean> {
const host = hostOf(row.endpoint);
if (row.vapid_public_key !== publicKey) {
// Subscribed against a key this server no longer signs with, so the push
// service would refuse every message. Until the browser subscribes again
// with the current key there is nothing to send to.
this.subscriptions.deleteById(row.id);
logInfo(`Web Push dropped a device subscribed with a previous server key user=${row.user_id} host=${host}`);
return false;
}
if (!isPushServiceEndpoint(row.endpoint)) {
this.subscriptions.deleteById(row.id);
logError(`Web Push dropped a device whose endpoint is not a known push service user=${row.user_id} host=${host}`);
return false;
}
try {
const p256dh = decodeBase64Url(row.p256dh);
const auth = decodeBase64Url(row.auth);
if (!p256dh || !auth) throw new Error('stored subscription keys are not base64url');
const body = encryptPushMessage(plaintext, { p256dh, auth });
// maxRedirects 0: a push service answers, it does not redirect, and a
// redirect would carry the VAPID token somewhere it was not minted for.
// bypassInternalIpAllowed keeps private addresses closed even on an
// install that opened ALLOW_INTERNAL_NETWORK for its own services.
const res = await safeFetchFollow(
row.endpoint,
{
method: 'POST',
headers: {
TTL: String(PUSH_TTL_SECONDS),
Urgency: urgency,
'Content-Encoding': 'aes128gcm',
'Content-Type': 'application/octet-stream',
Authorization: sign(row.endpoint),
},
body: new Uint8Array(body),
signal: AbortSignal.timeout(PUSH_TIMEOUT_MS),
},
{ maxRedirects: 0, bypassInternalIpAllowed: true },
);
return await this.settle(row, res, host, event);
} catch (err) {
this.subscriptions.recordFailure(row.id);
if (err instanceof SsrfBlockedError) {
logError(
`Web Push blocked by SSRF guard event=${event} user=${row.user_id} host=${host} reason=${err.message}`,
);
} else {
logError(
`Web Push failed event=${event} user=${row.user_id} host=${host}: ${err instanceof Error ? err.message : err}`,
);
}
return false;
}
}
private async settle(row: PushSubscriptionRow, res: Response, host: string, event: string): Promise<boolean> {
if (res.ok) {
discardBody(res);
this.subscriptions.recordSuccess(row.id);
logInfo(`Web Push sent event=${event} user=${row.user_id} host=${host}`);
return true;
}
const { text } = await readCappedText(res, ERROR_BODY_MAX_BYTES).catch(() => ({ text: '' }));
if (res.status === 404 || res.status === 410) {
this.subscriptions.deleteById(row.id);
logInfo(`Web Push removed an expired device (HTTP ${res.status}) user=${row.user_id} host=${host}`);
return false;
}
if (res.status === 403) {
// Either the subscription was made for a different key, or the push
// service rejects the token itself (Apple's BadJwtToken), which is a
// configuration problem and not the device's. Only a run of refusals
// with no success between them removes the row; the reason is logged
// every time so the second case can be told apart.
const failures = this.subscriptions.recordFailure(row.id);
if (failures >= FORBIDDEN_FAILURES_BEFORE_REMOVAL) {
this.subscriptions.deleteById(row.id);
logError(
`Web Push removed a device after ${failures} failed sends in a row ending in HTTP 403 ` +
`user=${row.user_id} host=${host}: ${text}`,
);
} else {
logError(
`Web Push HTTP 403 event=${event} user=${row.user_id} host=${host} ` +
`(failure ${failures} of ${FORBIDDEN_FAILURES_BEFORE_REMOVAL} before removal): ${text}`,
);
}
return false;
}
this.subscriptions.recordFailure(row.id);
logError(`Web Push HTTP ${res.status} event=${event} user=${row.user_id} host=${host}: ${text}`);
return false;
}
}
+268
View File
@@ -0,0 +1,268 @@
/**
* Web Push routes e2e: /api/notifications/push/* and the push test send
* through the real JwtAuthGuard, the Zod pipe and the real providers on a temp
* SQLite db. Only the outbound POST (safeFetchFollow) is replaced.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { createECDH } from 'node:crypto';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
// The harness db (users for the guard) plus the two tables push reads, built
// inside the factory; the test reaches it through the mocked module.
vi.mock('../../src/db/database', async () => {
const { createTempDb } = await import('./harness');
const db = createTempDb();
db.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);');
// Same DDL as migration 244.
db.exec(`CREATE TABLE push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
vapid_public_key TEXT NOT NULL,
user_agent TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_success_at TEXT,
failure_count INTEGER NOT NULL DEFAULT 0
);`);
return { db, closeDb: () => {}, reinitialize: () => {} };
});
const { safeFetchFollow } = vi.hoisted(() => ({ safeFetchFollow: vi.fn() }));
vi.mock('../../src/utils/ssrfGuard', async (importOriginal) => ({
...(await importOriginal<typeof import('../../src/utils/ssrfGuard')>()),
safeFetchFollow,
}));
import { db } from '../../src/db/database';
import { DatabaseModule } from '../../src/nest/database/database.module';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { NotificationsModule } from '../../src/nest/notifications/notifications.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
const ENDPOINT = 'https://fcm.googleapis.com/fcm/send/e2e-device';
function subscription(endpoint = ENDPOINT) {
const ua = createECDH('prime256v1');
ua.generateKeys();
return {
endpoint,
expirationTime: null,
keys: { p256dh: ua.getPublicKey('base64url'), auth: Buffer.alloc(16, 3).toString('base64url') },
};
}
function rows() {
return db
.prepare('SELECT user_id, endpoint, vapid_public_key, user_agent FROM push_subscriptions ORDER BY id')
.all() as {
user_id: number;
endpoint: string;
vapid_public_key: string;
user_agent: string | null;
}[];
}
describe('Web Push e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({
imports: [DatabaseModule, RealtimeModule, NotificationsModule],
}).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalPipes(new ZodValidationPipe());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
seedUser(db, { id: 1, email: 'one@example.test' });
seedUser(db, { id: 2, email: 'two@example.test' });
app = await build();
server = app.getHttpServer();
});
beforeEach(() => {
db.prepare('DELETE FROM push_subscriptions').run();
safeFetchFollow.mockReset();
});
afterAll(async () => {
await app.close();
});
it('401 on every push route without a session cookie', async () => {
expect((await request(server).get('/api/notifications/push/public-key')).status).toBe(401);
expect(
(await request(server).post('/api/notifications/push/subscriptions').send({ subscription: subscription() }))
.status,
).toBe(401);
expect(
(await request(server).delete('/api/notifications/push/subscriptions').send({ endpoint: ENDPOINT })).status,
).toBe(401);
});
it('GET public-key: the pair exists from bootstrap on, so the route only reads it', async () => {
const stored = db.prepare("SELECT value FROM app_settings WHERE key = 'web_push_vapid_public_key'").get() as {
value: string;
};
const res = await request(server).get('/api/notifications/push/public-key').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ publicKey: stored.value });
const raw = Buffer.from(res.body.publicKey, 'base64url');
expect(raw.length).toBe(65);
expect(raw[0]).toBe(0x04);
});
it('POST subscriptions: 200 with the device count, stored against the served key', async () => {
const { body: key } = await request(server)
.get('/api/notifications/push/public-key')
.set('Cookie', sessionCookie(1));
const res = await request(server)
.post('/api/notifications/push/subscriptions')
.set('Cookie', sessionCookie(1))
.set('User-Agent', 'e2e-browser/1.0')
.send({ subscription: subscription() });
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true, devices: 1 });
expect(rows()).toEqual([
{ user_id: 1, endpoint: ENDPOINT, vapid_public_key: key.publicKey, user_agent: 'e2e-browser/1.0' },
]);
});
it('POST subscriptions: the same browser subscribing for another account moves to it', async () => {
await request(server)
.post('/api/notifications/push/subscriptions')
.set('Cookie', sessionCookie(1))
.send({ subscription: subscription() });
const res = await request(server)
.post('/api/notifications/push/subscriptions')
.set('Cookie', sessionCookie(2))
.send({ subscription: subscription() });
expect(res.body).toEqual({ success: true, devices: 1 });
expect(rows().map((r) => r.user_id)).toEqual([2]);
});
it('POST subscriptions: 400 with the bespoke message for a host that is not a push service', async () => {
const res = await request(server)
.post('/api/notifications/push/subscriptions')
.set('Cookie', sessionCookie(1))
.send({ subscription: subscription('https://example.com/collect') });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'Push endpoint is not a known push service' });
expect(rows()).toEqual([]);
});
it('POST subscriptions: 400 from the contract when the keys are missing', async () => {
const res = await request(server)
.post('/api/notifications/push/subscriptions')
.set('Cookie', sessionCookie(1))
.send({ subscription: { endpoint: ENDPOINT } });
expect(res.status).toBe(400);
expect(res.body.error).toEqual(expect.stringContaining('keys'));
});
it('DELETE subscriptions: only the owner removes the endpoint, and a repeat is still 200', async () => {
await request(server)
.post('/api/notifications/push/subscriptions')
.set('Cookie', sessionCookie(1))
.send({ subscription: subscription() });
const stranger = await request(server)
.delete('/api/notifications/push/subscriptions')
.set('Cookie', sessionCookie(2))
.send({ endpoint: ENDPOINT });
expect(stranger.status).toBe(200);
expect(rows()).toHaveLength(1);
const owner = await request(server)
.delete('/api/notifications/push/subscriptions')
.set('Cookie', sessionCookie(1))
.send({ endpoint: ENDPOINT });
expect(owner.status).toBe(200);
expect(owner.body).toEqual({ success: true });
expect(rows()).toEqual([]);
const again = await request(server)
.delete('/api/notifications/push/subscriptions')
.set('Cookie', sessionCookie(1))
.send({ endpoint: ENDPOINT });
expect(again.status).toBe(200);
});
it('DELETE subscriptions: 400 without an endpoint', async () => {
const res = await request(server)
.delete('/api/notifications/push/subscriptions')
.set('Cookie', sessionCookie(1))
.send({});
expect(res.status).toBe(400);
});
it('POST test/push: sends the test message to the caller’s devices through the SSRF-guarded fetch', async () => {
const none = await request(server).post('/api/notifications/test/push').set('Cookie', sessionCookie(1));
expect(none.status).toBe(200);
expect(none.body).toEqual({ success: false, error: 'Channel is not configured for this user' });
await request(server)
.post('/api/notifications/push/subscriptions')
.set('Cookie', sessionCookie(1))
.send({ subscription: subscription() });
safeFetchFollow.mockResolvedValue({ ok: true, status: 201, body: null, headers: { get: () => null } });
const res = await request(server).post('/api/notifications/test/push').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
expect(safeFetchFollow).toHaveBeenCalledWith(
ENDPOINT,
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ 'Content-Encoding': 'aes128gcm' }),
}),
{ maxRedirects: 0, bypassInternalIpAllowed: true },
);
});
it('a stored private key that no longer decrypts turns push off with a 503 and is never replaced', async () => {
const readKey = (key: string) =>
(db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string }).value;
const unreadable = 'enc:v1:bm90IGEgY2lwaGVydGV4dA';
const publicKey = readKey('web_push_vapid_public_key');
const ciphertext = readKey('web_push_vapid_private_key');
await request(server)
.post('/api/notifications/push/subscriptions')
.set('Cookie', sessionCookie(1))
.send({ subscription: subscription() });
db.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(unreadable, 'web_push_vapid_private_key');
try {
const key = await request(server).get('/api/notifications/push/public-key').set('Cookie', sessionCookie(1));
expect(key.status).toBe(503);
expect(key.body).toEqual({ error: 'Web Push is currently unavailable on this server' });
const sub = await request(server)
.post('/api/notifications/push/subscriptions')
.set('Cookie', sessionCookie(1))
.send({ subscription: subscription(`${ENDPOINT}-second`) });
expect(sub.status).toBe(503);
const test = await request(server).post('/api/notifications/test/push').set('Cookie', sessionCookie(1));
expect(test.status).toBe(200);
expect(test.body).toEqual({ success: false, error: 'Web Push is currently unavailable on this server' });
expect(safeFetchFollow).not.toHaveBeenCalled();
// The device registered before stays, and neither stored row was touched.
expect(rows().map((r) => r.endpoint)).toEqual([ENDPOINT]);
expect(readKey('web_push_vapid_public_key')).toBe(publicKey);
expect(readKey('web_push_vapid_private_key')).toBe(unreadable);
} finally {
db.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(ciphertext, 'web_push_vapid_private_key');
}
const back = await request(server).get('/api/notifications/push/public-key').set('Cookie', sessionCookie(1));
expect(back.body).toEqual({ publicKey });
});
});
+12 -3
View File
@@ -5,13 +5,16 @@ import { NotificationPreferencesService } from '../../src/nest/notifications/not
import { NotificationsService } from '../../src/nest/notifications/notifications.service';
import { NtfyService } from '../../src/nest/notifications/transports/ntfy.service';
import { WebhookService } from '../../src/nest/notifications/transports/webhook.service';
import { WebPushService } from '../../src/nest/notifications/transports/web-push.service';
import { PushSubscriptionsService } from '../../src/nest/notifications/push/push-subscriptions.service';
import { VapidKeysService } from '../../src/nest/notifications/push/vapid-keys.service';
/**
* A NotificationsService wired the way Nest wires it.
*
* The domain takes six providers since the fold, and eight places used to build
* it by hand — every added constructor parameter was an eight-file diff. One
* helper keeps that at one.
* The domain takes seven providers since Web Push joined, and eight places used
* to build it by hand: every added constructor parameter was an eight-file
* diff. One helper keeps that at one.
*/
export function makeNotificationsService(dbs: DatabaseService, realtime = new RealtimeService()): NotificationsService {
const mailer = new MailerService(dbs);
@@ -22,9 +25,15 @@ export function makeNotificationsService(dbs: DatabaseService, realtime = new Re
new WebhookService(dbs),
new NtfyService(dbs),
new NotificationPreferencesService(dbs, mailer),
makeWebPushService(dbs),
);
}
/** The Web Push transport over its two providers, on the same connection. */
export function makeWebPushService(dbs: DatabaseService): WebPushService {
return new WebPushService(new VapidKeysService(dbs), new PushSubscriptionsService(dbs));
}
/** The preferences half on its own, over the same connection. */
export function makeNotificationPreferencesService(dbs: DatabaseService): NotificationPreferencesService {
return new NotificationPreferencesService(dbs, new MailerService(dbs));
+1
View File
@@ -108,6 +108,7 @@ const RESET_TABLES = [
'visited_countries',
'bucket_list',
// Notifications & audit
'push_subscriptions',
'notification_channel_preferences',
'notifications',
'audit_log',
@@ -81,3 +81,62 @@ describe('the Places and Amap switches in the templates', () => {
}
});
});
describe('the Web Push keys in the templates', () => {
// All optional, since TREK keeps a generated pair in its database; an
// operator who wants their own pair has to find the names somewhere.
const documented = ['VAPID_PUBLIC_KEY', 'VAPID_PRIVATE_KEY', 'VAPID_SUBJECT'];
it('.env.example and docker-compose.yml document every one of them', () => {
for (const name of documented) {
expect(envExample).toContain(`# ${name}=`);
expect(compose).toContain(`# - ${name}=`);
}
});
it('the Helm ConfigMap passes through the public key and the subject', () => {
for (const name of ['VAPID_PUBLIC_KEY', 'VAPID_SUBJECT']) {
expect(helmValues).toContain(`# ${name}:`);
expect(helmConfigMap).toContain(`${name}: {{ .Values.env.${name} | quote }}`);
}
});
it('no template promises an admin address as the push contact, since TREK never sends one on its own', () => {
const subjectDocs = [
envExample.split(/\r?\n/).find((l) => l.includes('# VAPID_SUBJECT=')),
compose.split(/\r?\n/).find((l) => l.includes('- VAPID_SUBJECT=')),
helmValues.slice(helmValues.indexOf('# VAPID_SUBJECT:')).split(/\r?\n\r?\n/)[0],
];
for (const doc of subjectDocs) {
expect(doc).toContain('APP_URL');
expect(doc).not.toContain('ADMIN_EMAIL');
}
});
it('no template says a broken pair is ignored, since push is off until it is fixed and no stored pair stands in', () => {
// The Helm comment wraps, so its lines are joined back into one sentence first.
const helmPrivateKey = helmValues
.slice(helmValues.indexOf('# Optional Web Push key pair, private half'), helmValues.indexOf(' VAPID_PRIVATE_KEY: ""'))
.replace(/\s*\n\s*#\s*/g, ' ');
const privateKeyDocs = [
envExample.split(/\r?\n/).find((l) => l.includes('# VAPID_PRIVATE_KEY=')),
compose.split(/\r?\n/).find((l) => l.includes('- VAPID_PRIVATE_KEY=')),
helmPrivateKey,
];
for (const doc of privateKeyDocs) {
expect(doc).toContain('turns push off');
expect(doc).not.toContain('ignored');
expect(doc).not.toContain('keeps working');
}
});
it('the Helm chart carries the private key in the Secret, never in the ConfigMap', () => {
const name = 'VAPID_PRIVATE_KEY';
expect(helmValues).toContain(` ${name}: ""`);
expect(helmSecret).toContain(`${name}: {{ .Values.secretEnv.${name} | b64enc | quote }}`);
expect(helmSecret).toContain(`${name}: {{ .Values.secretEnv.${name} }}`);
expect(helmDeployment).toContain(`- name: ${name}\n valueFrom:\n secretKeyRef:`);
expect(helmDeployment).toContain(`key: ${name}\n optional: true`);
expect(helmConfigMap).not.toContain(name);
});
});
+16 -1
View File
@@ -16,6 +16,7 @@ import {
deriveBackup,
deriveNet,
derivePaths,
derivePush,
deriveAll,
} from '../../../src/app-config/derive';
@@ -277,7 +278,7 @@ describe('deriveAll', () => {
expect(env.demo.enabled).toBe(true);
for (const ns of [
'app', 'http', 'session', 'demo', 'adminBootstrap', 'oidc', 'smtp', 'mcp',
'plugins', 'webauthn', 'integrations', 'backup', 'db', 'paths', 'net',
'plugins', 'webauthn', 'integrations', 'backup', 'db', 'paths', 'net', 'push',
] as const) {
expect(env[ns]).toBeDefined();
}
@@ -309,3 +310,17 @@ describe('deriveMaps', () => {
}
});
});
describe('derivePush', () => {
it('passes the VAPID_* values through trimmed, blank counting as unset', () => {
expect(derivePush({})).toEqual({ vapidPublicKey: undefined, vapidPrivateKey: undefined, vapidSubject: undefined });
expect(
derivePush({ VAPID_PUBLIC_KEY: ' BPub ', VAPID_PRIVATE_KEY: 'priv', VAPID_SUBJECT: ' mailto:ops@example.com ' }),
).toEqual({ vapidPublicKey: 'BPub', vapidPrivateKey: 'priv', vapidSubject: 'mailto:ops@example.com' });
expect(derivePush({ VAPID_PUBLIC_KEY: ' ', VAPID_SUBJECT: '' })).toEqual({
vapidPublicKey: undefined,
vapidPrivateKey: undefined,
vapidSubject: undefined,
});
});
});
@@ -3,6 +3,10 @@ import { describe, it, expect } from 'vitest';
import {
csvList,
csvListFiltered,
decodeBase64Url,
isP256PrivateKey,
isUncompressedP256Key,
isVapidSubject,
nonNegativeIntOr,
numberOr,
parseBool,
@@ -176,3 +180,37 @@ describe('parseLinkLocalAllowList (ALLOW_LINK_LOCAL_IPS)', () => {
}
});
});
describe('Web Push key material', () => {
it('decodeBase64Url refuses anything outside the base64url alphabet instead of skipping it', () => {
expect(decodeBase64Url('AQID')).toEqual(Buffer.from([1, 2, 3]));
expect(decodeBase64Url(' AQID ')).toEqual(Buffer.from([1, 2, 3]));
expect(decodeBase64Url('AQI=')).toEqual(Buffer.from([1, 2]));
expect(decodeBase64Url('-_8')).toEqual(Buffer.from([0xfb, 0xff]));
for (const value of ['', 'AQ+D', 'AQ/D', 'AQ ID', 'AQ=D', 'AQ===']) {
expect(decodeBase64Url(value), value).toBeNull();
}
});
it('isUncompressedP256Key and isP256PrivateKey check the sizes the Web Push tools print', () => {
const point = Buffer.concat([Buffer.from([0x04]), Buffer.alloc(64, 1)]).toString('base64url');
expect(isUncompressedP256Key(point)).toBe(true);
const compressed = Buffer.concat([Buffer.from([0x02]), Buffer.alloc(64, 1)]).toString('base64url');
expect(isUncompressedP256Key(compressed)).toBe(false);
expect(isUncompressedP256Key(Buffer.alloc(33, 4).toString('base64url'))).toBe(false);
expect(isP256PrivateKey(Buffer.alloc(32, 9).toString('base64url'))).toBe(true);
expect(isP256PrivateKey(Buffer.alloc(31, 9).toString('base64url'))).toBe(false);
expect(isP256PrivateKey('not base64url!')).toBe(false);
});
it('isVapidSubject accepts a mailto: address or an https: URL and nothing else', () => {
const accepted = ['mailto:ops@example.com', 'MAILTO:ops@example.com', 'https://trek.example.com', ' https://x.test/c '];
for (const ok of accepted) {
expect(isVapidSubject(ok), ok).toBe(true);
}
const refused = ['ops@example.com', 'mailto:', 'mailto:no-at-sign', 'mailto:a b@example.com', 'http://x.test', 'not a url'];
for (const bad of refused) {
expect(isVapidSubject(bad), bad).toBe(false);
}
});
});
+118 -1
View File
@@ -1,6 +1,31 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import type { z } from 'zod';
import { validateEnvAtBoot, readEnv } from '../../../src/app-config/env';
import { validateEnvAtBoot, readEnv, SECRET_ENV_KEYS } from '../../../src/app-config/env';
import { envSchema } from '../../../src/app-config/env.schema';
const schemaShape: Record<string, z.ZodType> = envSchema.shape;
/**
* A value no check of the schema accepts (not a number, URL, boolean, duration,
* language or base64url key), and one that names its variable, so a leak is
* traceable to the key it came from.
*/
const probe = (key: string): string => `probe+${key}/not valid=`;
/** Everything one boot check printed and threw. */
function bootReport(raw: Record<string, string>): { printed: string; thrown: string } {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
let thrown = '';
try {
validateEnvAtBoot(raw);
} catch (err) {
thrown = err instanceof Error ? err.message : String(err);
}
const printed = error.mock.calls.map((c) => c.join(' ')).join('\n');
error.mockRestore();
return { printed, thrown };
}
describe('validateEnvAtBoot', () => {
afterEach(() => {
@@ -58,6 +83,98 @@ describe('validateEnvAtBoot', () => {
expect(() => validateEnvAtBoot({ ALLOW_LINK_LOCAL_IPS: '' })).not.toThrow();
});
it('checks the VAPID_* shapes and lets a well-formed set through', () => {
// The application server pair from RFC 8291 Appendix A, in the form the Web Push tools print.
const publicKey = 'BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8';
const privateKey = 'yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw';
expect(() =>
validateEnvAtBoot({
VAPID_PUBLIC_KEY: publicKey,
VAPID_PRIVATE_KEY: privateKey,
VAPID_SUBJECT: 'mailto:ops@example.com',
}),
).not.toThrow();
expect(() => validateEnvAtBoot({ VAPID_SUBJECT: 'https://trek.example.com' })).not.toThrow();
// Whether the halves belong together is checked where they are used, not here.
expect(() => validateEnvAtBoot({ VAPID_PRIVATE_KEY: privateKey })).not.toThrow();
vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => validateEnvAtBoot({ VAPID_PUBLIC_KEY: privateKey })).toThrow();
expect(() => validateEnvAtBoot({ VAPID_PUBLIC_KEY: `A${publicKey.slice(1)}` })).toThrow();
expect(() => validateEnvAtBoot({ VAPID_PRIVATE_KEY: publicKey })).toThrow();
expect(() => validateEnvAtBoot({ VAPID_PRIVATE_KEY: 'yfWPiYE+n46HLnH0KqZOF1fJJU3MYrct3AELtAQ/oRw' })).toThrow();
expect(() => validateEnvAtBoot({ VAPID_SUBJECT: 'ops@example.com' })).toThrow();
expect(() => validateEnvAtBoot({ VAPID_SUBJECT: 'http://trek.example.com' })).toThrow();
});
it('never prints the value of a secret variable, only its name and the problem', () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
// 43 characters like a real key, with one character base64url does not have.
const leakedPrivateKey = 'yfWPiYE+n46HLnH0KqZOF1fJJU3MYrct3AELtAQ/oRw';
expect(() => validateEnvAtBoot({ VAPID_PRIVATE_KEY: leakedPrivateKey, PORT: 'not-a-port' })).toThrow(
/2 problems/,
);
const report = error.mock.calls.map((c) => c.join(' ')).join('\n');
expect(report).toContain('VAPID_PRIVATE_KEY=***: must be a base64url-encoded P-256 private key');
expect(report).not.toContain(leakedPrivateKey);
expect(report).not.toContain('yfWPiYE');
// Everything else keeps its value, which is what makes a typo findable.
expect(report).toContain('PORT="not-a-port"');
});
it('never prints any secret variable, whether or not its own check refuses the value', () => {
// A broken PORT guarantees a report, so every secret gets its chance to leak into it.
const { printed, thrown } = bootReport({
...Object.fromEntries([...SECRET_ENV_KEYS].map((key) => [key, probe(key)])),
PORT: 'not-a-port',
});
expect(thrown).toMatch(/Invalid environment configuration/);
expect(printed).toContain('PORT="not-a-port"');
let refused = 0;
for (const key of SECRET_ENV_KEYS) {
expect(printed, key).not.toContain(probe(key));
expect(thrown, key).not.toContain(probe(key));
if (!schemaShape[key].safeParse(probe(key)).success) {
refused += 1;
expect(printed, key).toContain(` - ${key}=***: `);
}
}
// The masking only proves itself on a variable the schema refuses.
expect(refused).toBeGreaterThan(0);
});
it('still prints the value of every variable that is not a secret', () => {
const open = Object.keys(schemaShape).filter((key) => !SECRET_ENV_KEYS.has(key));
const { printed } = bootReport(Object.fromEntries(open.map((key) => [key, probe(key)])));
// Free-form variables accept anything, so only the checked ones show up.
const refused = open.filter((key) => !schemaShape[key].safeParse(probe(key)).success);
expect(refused).toContain('VAPID_PUBLIC_KEY');
for (const key of refused) expect(printed, key).toContain(` - ${key}=${JSON.stringify(probe(key))}: `);
expect(printed.split('\n').filter((line) => line.startsWith(' - '))).toHaveLength(refused.length);
});
it('knows every secret-looking variable of the schema as a secret, and nothing the schema does not know', () => {
// A new variable with a word in its name that suggests a credential lands
// here and has to be decided on. Whole words, so OVERPASS_URL and
// WEBAUTHN_RP_ID do not count.
const looksSecret = (key: string): boolean =>
/(^|_)(SECRET|PASS(WORD|PHRASE)?|TOKEN|KEY|APIKEY|CREDENTIALS?|PRIVATE|LICEN[CS]E|WEBHOOK|AUTH|DSN|SALT)(_|$)/.test(key);
const notSecret = new Set([
'VAPID_PUBLIC_KEY', // the public half, which every subscribing browser receives
'TREK_PLUGIN_ALLOW_PRIVATE_EGRESS', // a switch about private networks, not a private value
]);
const schemaKeys = Object.keys(schemaShape);
const flagged = schemaKeys.filter((key) => looksSecret(key) && !notSecret.has(key));
expect(flagged.length).toBeGreaterThan(5);
for (const key of flagged) expect(SECRET_ENV_KEYS.has(key), key).toBe(true);
for (const key of SECRET_ENV_KEYS) expect(schemaKeys, key).toContain(key);
for (const key of notSecret) {
expect(schemaKeys, key).toContain(key);
expect(looksSecret(key), key).toBe(true);
expect(SECRET_ENV_KEYS.has(key), key).toBe(false);
}
});
it('treats blank values as unset (defaults apply, no error)', () => {
expect(() => validateEnvAtBoot({ DEMO_MODE: '', PORT: ' ', TZ: '' })).not.toThrow();
});
@@ -95,6 +95,19 @@ describe('migrate-encryption.ts app_settings parity', () => {
}
});
// VapidKeysService stores the Web Push signing key with encrypt_api_key.
// Missed by a rotation it no longer decrypts, and push stays off on every
// device until the old key is back.
it('ROTPAR-012: rotates the stored Web Push private key', () => {
const vapid = fs.readFileSync(
path.join(SERVER_ROOT, 'src', 'nest', 'notifications', 'push', 'vapid-keys.service.ts'),
'utf8',
);
expect(vapid).toContain("VAPID_PRIVATE_KEY_SETTING = 'web_push_vapid_private_key'");
expect(vapid).toContain('encrypt_api_key(keys.privateKey)');
expect(appSettingsLoop).toContain("'web_push_vapid_private_key'");
});
it('ROTPAR-003: the canonical list is not empty, so ROTPAR-001 cannot pass vacuously', () => {
expect(INSTANCE_API_KEY_NAMES.length).toBeGreaterThan(0);
});
@@ -80,6 +80,86 @@ describe('globalMiddleware request-log redaction', () => {
expect(redact({ accessKeyId: 'ak-1', tokenCount: 5 })).toEqual({ accessKeyId: 'ak-1', tokenCount: 5 });
});
it('hides the Web Push subscription body: the endpoint capability URL and both keys', () => {
// POST /api/notifications/push/subscriptions, as PushSubscription.toJSON() hands it over.
expect(
redact({
subscription: {
endpoint: 'https://fcm.googleapis.com/fcm/send/device-capability',
expirationTime: null,
keys: { p256dh: 'BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA', auth: 'tBHItJI5svbpez7KI4CCXg' },
},
}),
).toEqual({
subscription: {
endpoint: '[REDACTED]',
expirationTime: null,
keys: { p256dh: '[REDACTED]', auth: '[REDACTED]' },
},
});
});
it('hides the subscription the service worker posts again after the browser renewed it', () => {
// sw-push.js on pushsubscriptionchange: the same route and shape, and a renewed
// subscription may carry a real expiry instead of null.
expect(
redact({
subscription: {
endpoint: 'https://updates.push.services.mozilla.com/wpush/v2/renewed-capability',
expirationTime: 1767225600000,
keys: { p256dh: 'BOr1renewedPublicKey', auth: 'renewedAuthSecret' },
},
}),
).toEqual({
subscription: {
endpoint: '[REDACTED]',
expirationTime: 1767225600000,
keys: { p256dh: '[REDACTED]', auth: '[REDACTED]' },
},
});
});
it('hides the endpoint of a subscription that arrives without its keys', () => {
// The route refuses this body with a 400, but the request is logged all the same.
expect(
redact({ subscription: { endpoint: 'https://fcm.googleapis.com/fcm/send/keyless', expirationTime: null } }),
).toEqual({ subscription: { endpoint: '[REDACTED]', expirationTime: null } });
expect(redact({ subscription: { endpoint: 'https://fcm.googleapis.com/fcm/send/lone' } })).toEqual({
subscription: { endpoint: '[REDACTED]' },
});
expect(
redact({ subscription: { endpoint: 'https://fcm.googleapis.com/fcm/send/odd-keys', keys: 'not-an-object' } }),
).toEqual({ subscription: { endpoint: '[REDACTED]', keys: 'not-an-object' } });
});
it('hides the endpoint of the body that forgets a push device', () => {
// DELETE /api/notifications/push/subscriptions (logout, switching push off, the worker's renewal).
expect(redact({ endpoint: 'https://web.push.apple.com/QGx-device' })).toEqual({ endpoint: '[REDACTED]' });
expect(redact({ Endpoint: 'https://web.push.apple.com/QGx-device' })).toEqual({ Endpoint: '[REDACTED]' });
});
it('keeps an endpoint that is an ordinary setting readable, like the S3 backend URL', () => {
expect(
redact({ endpoint: 'http://127.0.0.1:9000', bucket: 'trek', region: 'us-east-1', secretAccessKey: 'sk' }),
).toEqual({ endpoint: 'http://127.0.0.1:9000', bucket: 'trek', region: 'us-east-1', secretAccessKey: '[REDACTED]' });
expect(redact({ endpoint: 'https://s3.example.com', forcePathStyle: true })).toEqual({
endpoint: 'https://s3.example.com',
forcePathStyle: true,
});
});
it('decides p256dh and auth by name wherever they appear, and nothing near them by accident', () => {
// Only the push subscription sends either field today; an `auth` anywhere
// else would carry credentials too. The fields around them stay readable.
expect(redact({ p256dh: 'BPub', Auth: 'secret', auth_method: 'basic', author: 'Ada' })).toEqual({
p256dh: '[REDACTED]',
Auth: '[REDACTED]',
auth_method: 'basic',
author: 'Ada',
});
expect(redact({ auth: { user: 'mailer', pass: 'hunter2' } })).toEqual({ auth: '[REDACTED]' });
});
it('passes non-object values through untouched', () => {
expect(redact('plain string')).toBe('plain string');
expect(redact(42)).toBe(42);
@@ -607,6 +607,20 @@ const pv = (id: number): number =>
const mcpTokenCount = (id: number): number =>
(testDb.prepare('SELECT COUNT(*) AS n FROM mcp_tokens WHERE user_id = ?').get(id) as { n: number }).n;
const addPushDevice = (userId: number, endpoint: string): void => {
testDb
.prepare(
"INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, vapid_public_key) VALUES (?, ?, 'p', 'a', 'k')",
)
.run(userId, endpoint);
};
const pushDeviceCount = (id: number): number =>
(testDb.prepare('SELECT COUNT(*) AS n FROM push_subscriptions WHERE user_id = ?').get(id) as { n: number }).n;
const passwordHash = (id: number): string =>
(testDb.prepare('SELECT password_hash FROM users WHERE id = ?').get(id) as { password_hash: string }).password_hash;
describe('admin password reset revokes what an intruder already holds', () => {
it('ADMIN-SVC-080 — setting a password bumps password_version, so existing cookies stop working', () => {
// An admin sets somebody else's password for one reason: the account is
@@ -630,15 +644,49 @@ describe('admin password reset revokes what an intruder already holds', () => {
expect(mcpTokenCount(user.id)).toBe(0);
});
it('ADMIN-SVC-081b: and forgets the push devices, which outlive every session, of that user only', () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/intruder');
addPushDevice(user.id, 'https://web.push.apple.com/owner');
addPushDevice(other.id, 'https://fcm.googleapis.com/fcm/send/bystander');
updateUser(String(user.id), { password: 'ANewStrongPass123!' });
expect(pushDeviceCount(user.id)).toBe(0);
expect(pushDeviceCount(other.id)).toBe(1);
});
it('ADMIN-SVC-081c: drops them in the same transaction as the password, so a failure leaves the account as it was', () => {
const { user } = createUser(testDb);
const before = pv(user.id);
const hashBefore = passwordHash(user.id);
testDb.prepare("INSERT INTO mcp_tokens (user_id, token_hash, token_prefix, name) VALUES (?, 'hash', 'trek_ab', 'cli')").run(user.id);
addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/intruder');
testDb.exec("CREATE TRIGGER boom BEFORE DELETE ON push_subscriptions BEGIN SELECT RAISE(ABORT, 'boom'); END");
try {
expect(() => updateUser(String(user.id), { password: 'ANewStrongPass123!' })).toThrow('boom');
} finally {
testDb.exec('DROP TRIGGER boom');
}
expect(pv(user.id)).toBe(before);
expect(passwordHash(user.id)).toBe(hashBefore);
expect(mcpTokenCount(user.id)).toBe(1);
expect(pushDeviceCount(user.id)).toBe(1);
});
it('ADMIN-SVC-082 — renaming a user touches neither, so an ordinary edit stays ordinary', () => {
const { user } = createUser(testDb);
const before = pv(user.id);
testDb.prepare("INSERT INTO mcp_tokens (user_id, token_hash, token_prefix, name) VALUES (?, 'hash', 'trek_ab', 'cli')").run(user.id);
addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/renamed');
updateUser(String(user.id), { username: 'renamed' });
expect(pv(user.id)).toBe(before);
expect(mcpTokenCount(user.id)).toBe(1);
expect(pushDeviceCount(user.id)).toBe(1);
});
});
@@ -108,6 +108,19 @@ const svc = new AuthService(
new AllowedFileTypesService(new DatabaseService(testDb)),
);
/** A Web Push device row; the password paths only care whose it is. */
function addPushDevice(userId: number, endpoint: string): void {
testDb
.prepare(
"INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, vapid_public_key) VALUES (?, ?, 'p', 'a', 'k')",
)
.run(userId, endpoint);
}
function pushDeviceCount(userId: number): number {
return (testDb.prepare('SELECT COUNT(*) c FROM push_subscriptions WHERE user_id = ?').get(userId) as { c: number }).c;
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
@@ -495,6 +508,51 @@ describe('changePassword — session invalidation', () => {
expect(decoded.remember).toBe(true);
expect(decoded.exp - decoded.iat).toBe(2592000); // remember window survives the change
});
it('AUTH-DB-036e: forgets every push device of the user, and only of that user', () => {
const { user, password } = createUser(testDb);
const { user: other } = createUser(testDb);
addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine-1');
addPushDevice(user.id, 'https://web.push.apple.com/mine-2');
addPushDevice(other.id, 'https://fcm.googleapis.com/fcm/send/theirs');
const result = svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' });
expect(result.success).toBe(true);
expect(pushDeviceCount(user.id)).toBe(0);
expect(pushDeviceCount(other.id)).toBe(1);
});
it('AUTH-DB-036f: a refused change keeps the push devices', () => {
const { user } = createUser(testDb);
addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine');
const result = svc.changePassword(user.id, user.email, { current_password: 'wrong', new_password: 'New1234!' });
expect(result.status).toBe(401);
expect(pushDeviceCount(user.id)).toBe(1);
});
it('AUTH-DB-036g: drops the push devices in the same transaction as the password change, so a failure changes nothing', () => {
const { user, password } = createUser(testDb);
tokens.createMcpToken(user.id, 'cli');
addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine');
testDb.exec("CREATE TRIGGER boom BEFORE DELETE ON push_subscriptions BEGIN SELECT RAISE(ABORT, 'boom'); END");
try {
expect(() =>
svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }),
).toThrow('boom');
} finally {
testDb.exec('DROP TRIGGER boom');
}
expect(pvOf(user.id)).toBe(0);
expect(mcpCount(user.id)).toBe(1);
expect(pushDeviceCount(user.id)).toBe(1);
// The new hash rolled back as well, so the old password is still the one that counts.
expect(svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }).success)
.toBe(true);
});
});
// ---------------------------------------------------------------------------
@@ -918,6 +976,42 @@ describe('resetPassword', () => {
.toEqual({ error: 'This reset link has already been used', status: 400 });
});
it('AUTH-DB-082b: forgets every push device of the user along with the MCP tokens', () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
tokens.createMcpToken(user.id, 'cli');
addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine');
addPushDevice(other.id, 'https://fcm.googleapis.com/fcm/send/theirs');
const issued = svc.requestPasswordReset(user.email, null);
expect(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' }))
.toEqual({ success: true, userId: user.id });
expect((testDb.prepare('SELECT COUNT(*) c FROM mcp_tokens WHERE user_id = ?').get(user.id) as { c: number }).c).toBe(0);
expect(pushDeviceCount(user.id)).toBe(0);
expect(pushDeviceCount(other.id)).toBe(1);
});
it('AUTH-DB-082c: drops the push devices in the same transaction that burns the link, so a failure keeps the link', () => {
const { user } = createUser(testDb);
addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine');
const issued = svc.requestPasswordReset(user.email, null);
testDb.exec("CREATE TRIGGER boom BEFORE DELETE ON push_subscriptions BEGIN SELECT RAISE(ABORT, 'boom'); END");
try {
expect(() => svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' })).toThrow('boom');
} finally {
testDb.exec('DROP TRIGGER boom');
}
expect(pushDeviceCount(user.id)).toBe(1);
const row = testDb.prepare('SELECT password_version FROM users WHERE id = ?').get(user.id) as { password_version: number };
expect(row.password_version).toBe(0);
// Nothing was burned, so the same link still completes the reset.
expect(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' }))
.toEqual({ success: true, userId: user.id });
expect(pushDeviceCount(user.id)).toBe(0);
});
it('AUTH-DB-083: bespoke 400s for missing/unknown/expired tokens', () => {
expect(svc.resetPassword({ new_password: 'Fresh123!' })).toEqual({ error: 'Reset token is required', status: 400 });
expect(svc.resetPassword({ token: 't' })).toEqual({ error: 'New password is required', status: 400 });
@@ -2,7 +2,7 @@
* Unit tests for the external notification channel registry.
* Covers CHREG-001 to CHREG-008.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
listChannels,
getChannel,
@@ -18,14 +18,22 @@ import { registerBuiltinChannels } from '../../../src/nest/notifications/channel
import type { MailerService } from '../../../src/nest/notifications/mailer/mailer.service';
import type { NtfyService } from '../../../src/nest/notifications/transports/ntfy.service';
import type { WebhookService } from '../../../src/nest/notifications/transports/webhook.service';
import type { WebPushService } from '../../../src/nest/notifications/transports/web-push.service';
// The built-ins take their transports as an argument now; these cases only care
// that the three ids land in the registry with the right privileges, so the
// that the four ids land in the registry with the right privileges, so the
// transports are stubs.
const pushStub = {
isAvailable: vi.fn(() => true),
hasDevices: vi.fn((userId: number) => userId === 7),
sendToUser: vi.fn(async () => true),
sendTest: vi.fn(async () => ({ success: true })),
};
const stubTransports = {
mailer: { isSmtpConfigured: () => true, getUserEmail: () => null } as unknown as MailerService,
webhook: { getUserWebhookUrl: () => null, getAdminWebhookUrl: () => null } as unknown as WebhookService,
ntfy: { getUserNtfyConfig: () => null, getAdminNtfyConfig: () => ({ server: null, topic: null, token: null }) } as unknown as NtfyService,
push: pushStub as unknown as WebPushService,
};
function fakeChannel(id: string, over: Partial<ExternalChannel> = {}): ExternalChannel {
@@ -52,8 +60,8 @@ afterEach(() => {
});
describe('channelRegistry', () => {
it('CHREG-001 — the three built-in external channels are registered; in-app is not', () => {
expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy']);
it('CHREG-001: the four built-in external channels are registered; in-app is not', () => {
expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy', 'push']);
expect(getChannel('inapp')).toBeUndefined();
});
@@ -71,14 +79,14 @@ describe('channelRegistry', () => {
expect(getChannel('plugin:gotify')).toBeDefined();
live = false;
expect(getChannel('plugin:gotify')).toBeUndefined();
expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy']);
expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy', 'push']);
});
it('CHREG-004 — a throwing plugin source cannot take notifications down', () => {
setPluginChannelSource(() => {
throw new Error('runtime exploded');
});
expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy']);
expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy', 'push']);
});
it('CHREG-005 — a plugin can never claim a built-in id', () => {
@@ -92,12 +100,50 @@ describe('channelRegistry', () => {
expect(getChannel('email')?.bypassesActiveToggleForAdminEvents).toBe(true);
expect(getChannel('webhook')?.bypassesActiveToggleForAdminEvents).toBeUndefined();
expect(getChannel('ntfy')?.bypassesActiveToggleForAdminEvents).toBeUndefined();
expect(getChannel('push')?.bypassesActiveToggleForAdminEvents).toBeUndefined();
});
it('CHREG-007 — webhook and ntfy deliver the admin-global copy; email does not', () => {
expect(getChannel('webhook')?.supportsAdminGlobal).toBe(true);
expect(getChannel('ntfy')?.supportsAdminGlobal).toBe(true);
expect(getChannel('email')?.supportsAdminGlobal).toBeUndefined();
expect(getChannel('push')?.supportsAdminGlobal).toBeUndefined();
expect(getChannel('push')?.sendGlobal).toBeUndefined();
});
it('CHREG-008b: push carries every user event, but neither admin-scoped ones nor synology_session_cleared', () => {
const push = getChannel('push')!;
expect(push.source).toBe('builtin');
expect(push.labelKey).toBe('settings.notificationPreferences.push');
for (const event of ['trip_invite', 'collab_message', 'trip_reminder', 'plugin_notification'] as const) {
expect(push.supportsEvent(event)).toBe(true);
}
expect(push.supportsEvent('version_available')).toBe(false);
expect(push.supportsEvent('replica_failure')).toBe(false);
expect(push.supportsEvent('synology_session_cleared')).toBe(false);
});
it('CHREG-008c: push is configured for a user with a device and hands sends and tests to the transport', async () => {
const push = getChannel('push')!;
expect(push.isConfiguredFor(7)).toBe(true);
expect(push.isConfiguredFor(8)).toBe(false);
await push.sendToUser(7, MSG);
expect(pushStub.sendToUser).toHaveBeenCalledWith(7, MSG);
await expect(push.test!(7)).resolves.toEqual({ success: true });
expect(pushStub.sendTest).toHaveBeenCalledWith(7);
});
it('CHREG-008d: push is ready only while it can sign, and hides meanwhile; a plugin cannot claim that', () => {
const push = getChannel('push')!;
expect(push.hiddenWhileInstanceUnconfigured).toBe(true);
expect(push.isInstanceConfigured!()).toBe(true);
pushStub.isAvailable.mockReturnValueOnce(false);
expect(push.isInstanceConfigured!()).toBe(false);
// Email keeps its column without SMTP, as before.
expect(getChannel('email')?.hiddenWhileInstanceUnconfigured).toBeUndefined();
setPluginChannelSource(() => [fakeChannel('plugin:gotify', { hiddenWhileInstanceUnconfigured: true })]);
expect(getChannel('plugin:gotify')?.hiddenWhileInstanceUnconfigured).toBeUndefined();
});
it('CHREG-008 — built-ins carry every event except synology_session_cleared', () => {
@@ -122,6 +168,6 @@ describe('channelRegistry', () => {
setPluginChannelSource(() => [boom]);
await expect(getChannel('plugin:boom')!.sendToUser(1, MSG)).rejects.toThrow('nope');
// and the registry is unharmed
expect(listChannels()).toHaveLength(4);
expect(listChannels()).toHaveLength(5);
});
});
@@ -43,10 +43,16 @@ import { registerBuiltinChannels } from '../../../src/nest/notifications/channel
import { NtfyService } from '../../../src/nest/notifications/transports/ntfy.service';
import { WebhookService } from '../../../src/nest/notifications/transports/webhook.service';
import { __resetChannelsForTest } from '../../../src/nest/notifications/channel-registry';
import { makeWebPushService } from '../../helpers/notifications';
const dbs = new DatabaseService(testDb);
const mailer = new MailerService(dbs);
registerBuiltinChannels({ mailer, webhook: new WebhookService(dbs), ntfy: new NtfyService(dbs) });
registerBuiltinChannels({
mailer,
webhook: new WebhookService(dbs),
ntfy: new NtfyService(dbs),
push: makeWebPushService(dbs),
});
const svc = new NotificationPreferencesService(dbs, mailer);
// Legacy free-function names bound to the service, so the moved cases read as before.
@@ -0,0 +1,276 @@
/**
* Web Push subscriptions: what the browser may hand over (PUSHSUB-CHK-*) and
* the table behind it (PUSHSUB-*), on the real schema including migration 244.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import { createECDH } from 'node:crypto';
import { MAX_PUSH_ENDPOINT_LENGTH, pushUnsubscribeRequestSchema } from '@trek/shared';
// One :memory: connection per file, created inside the factory so nothing has
// to be hoisted above the imports; the tests reach it through the mocked module.
vi.mock('../../../../src/db/database', async () => {
const { default: Database } = await import('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
return {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => null,
isOwner: () => false,
};
});
vi.mock('../../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { db as testDb } from '../../../../src/db/database';
import { createTables } from '../../../../src/db/schema';
import { runMigrations } from '../../../../src/db/migrations';
import { resetTestDb } from '../../../helpers/test-db';
import { createUser } from '../../../helpers/factories';
import { DatabaseService } from '../../../../src/nest/database/database.service';
import {
MAX_PUSH_DEVICES_PER_USER,
PushSubscriptionsService,
} from '../../../../src/nest/notifications/push/push-subscriptions.service';
import {
PUSH_SUBSCRIPTION_ERRORS,
checkPushEndpoint,
checkPushSubscription,
isPushServiceEndpoint,
type CheckedPushSubscription,
} from '../../../../src/nest/notifications/push/push-subscription.helpers';
const subs = new PushSubscriptionsService(new DatabaseService(testDb));
function browserKeys() {
const ua = createECDH('prime256v1');
ua.generateKeys();
return { p256dh: ua.getPublicKey('base64url'), auth: Buffer.from('0123456789abcdef').toString('base64url') };
}
function checked(endpoint: string): CheckedPushSubscription {
const result = checkPushSubscription({ endpoint, keys: browserKeys() });
if ('error' in result) throw new Error(result.error);
return result.value;
}
const FCM = 'https://fcm.googleapis.com/fcm/send/device-1';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
describe('checkPushEndpoint / checkPushSubscription', () => {
it('PUSHSUB-CHK-001: accepts the push services browsers use', () => {
for (const endpoint of [
FCM,
'https://android.googleapis.com/gcm/send/x',
'https://updates.push.services.mozilla.com/wpush/v2/gAAAA',
'https://push.services.mozilla.com/wpush/v1/x',
'https://web.push.apple.com/QGx9',
'https://api.push.apple.com/3/device/x',
'https://wns2-par02p.notify.windows.com/w/?token=BQYAAA',
]) {
expect(checkPushEndpoint(endpoint), endpoint).toBe('ok');
expect(isPushServiceEndpoint(endpoint)).toBe(true);
}
});
it('PUSHSUB-CHK-002: refuses anything that is not a plain https URL', () => {
for (const endpoint of [
'not a url',
'http://fcm.googleapis.com/fcm/send/x',
'https://user:pw@fcm.googleapis.com/fcm/send/x',
'https://fcm.googleapis.com:8443/fcm/send/x',
`https://fcm.googleapis.com/${'a'.repeat(MAX_PUSH_ENDPOINT_LENGTH)}`,
]) {
expect(checkPushEndpoint(endpoint), endpoint).toBe('endpoint');
}
});
it('PUSHSUB-CHK-002b: takes an endpoint exactly as long as the contract allows, and not one character more', () => {
const base = 'https://fcm.googleapis.com/fcm/send/';
const longest = `${base}${'a'.repeat(MAX_PUSH_ENDPOINT_LENGTH - base.length)}`;
// The contract and the check share one limit, so a device that could register can also be sent to and removed.
expect(pushUnsubscribeRequestSchema.safeParse({ endpoint: longest }).success).toBe(true);
expect(checkPushEndpoint(longest)).toBe('ok');
expect(checkPushEndpoint(`${longest}a`)).toBe('endpoint');
});
it('PUSHSUB-CHK-003: refuses hosts that are not push services, including lookalikes', () => {
for (const endpoint of [
'https://example.com/push',
'https://127.0.0.1/push',
'https://fcm.googleapis.com.evil.test/x',
'https://evilnotify.windows.com/x',
'https://.notify.windows.com/x',
'https://push.apple.com/x',
]) {
expect(checkPushEndpoint(endpoint), endpoint).not.toBe('ok');
}
expect(checkPushSubscription({ endpoint: 'https://example.com/push', keys: browserKeys() })).toEqual({
ok: false,
error: PUSH_SUBSCRIPTION_ERRORS.service,
});
expect(checkPushSubscription({ endpoint: 'http://fcm.googleapis.com/x', keys: browserKeys() })).toEqual({
ok: false,
error: PUSH_SUBSCRIPTION_ERRORS.endpoint,
});
});
it('PUSHSUB-CHK-004: wants a p256dh on the curve and a 16-byte auth secret', () => {
const keys = browserKeys();
const offCurve = Buffer.from(keys.p256dh, 'base64url');
offCurve[64] ^= 0x01;
for (const p256dh of [
'!!!',
Buffer.alloc(65, 4).toString('base64url'),
offCurve.toString('base64url'),
keys.auth,
]) {
expect(checkPushSubscription({ endpoint: FCM, keys: { ...keys, p256dh } })).toEqual({
ok: false,
error: PUSH_SUBSCRIPTION_ERRORS.p256dh,
});
}
for (const auth of ['!!!', Buffer.alloc(8).toString('base64url')]) {
expect(checkPushSubscription({ endpoint: FCM, keys: { ...keys, auth } })).toEqual({
ok: false,
error: PUSH_SUBSCRIPTION_ERRORS.auth,
});
}
});
it('PUSHSUB-CHK-005: hands the keys back canonical, without padding', () => {
const keys = browserKeys();
const result = checkPushSubscription({
endpoint: FCM,
keys: { p256dh: `${keys.p256dh}=`, auth: `${keys.auth}==` },
});
expect(result).toEqual({ ok: true, value: { endpoint: FCM, p256dh: keys.p256dh, auth: keys.auth } });
});
});
describe('PushSubscriptionsService', () => {
it('PUSHSUB-001: stores a subscription with the key it was made for and counts devices', () => {
const { user } = createUser(testDb);
const sub = checked(FCM);
expect(subs.hasAny(user.id)).toBe(false);
expect(subs.upsert(user.id, sub, 'server-key', 'Mozilla/5.0')).toBe(1);
const [row] = subs.listForUser(user.id);
expect(row).toMatchObject({
user_id: user.id,
endpoint: FCM,
p256dh: sub.p256dh,
auth: sub.auth,
vapid_public_key: 'server-key',
user_agent: 'Mozilla/5.0',
failure_count: 0,
last_success_at: null,
});
expect(subs.hasAny(user.id)).toBe(true);
expect(subs.countForUser(user.id)).toBe(1);
});
it('PUSHSUB-002: subscribing again refreshes the same row instead of adding one', () => {
const { user } = createUser(testDb);
subs.upsert(user.id, checked(FCM), 'old-key');
const [before] = subs.listForUser(user.id);
subs.recordFailure(before.id);
const fresh = checked(FCM);
expect(subs.upsert(user.id, fresh, 'new-key', null)).toBe(1);
const [after] = subs.listForUser(user.id);
expect(after.id).toBe(before.id);
expect(after).toMatchObject({
p256dh: fresh.p256dh,
vapid_public_key: 'new-key',
failure_count: 0,
user_agent: null,
});
});
it('PUSHSUB-003: a browser shared by two accounts belongs to whoever subscribed on it last', () => {
const { user: first } = createUser(testDb);
const { user: second } = createUser(testDb);
subs.upsert(first.id, checked(FCM), 'k');
expect(subs.upsert(second.id, checked(FCM), 'k')).toBe(1);
expect(subs.countForUser(first.id)).toBe(0);
expect(subs.listForUser(second.id).map((r) => r.endpoint)).toEqual([FCM]);
});
it('PUSHSUB-004: caps the devices per user and drops the one registered longest ago', () => {
const { user } = createUser(testDb);
for (let i = 0; i < MAX_PUSH_DEVICES_PER_USER; i++) {
subs.upsert(user.id, checked(`https://fcm.googleapis.com/fcm/send/d${i}`), 'k');
}
// Same second for every row; the id breaks the tie, so d0 is the oldest.
// d0 subscribing again renews it, which leaves d1 as the oldest.
testDb.prepare("UPDATE push_subscriptions SET created_at = '2026-01-01 00:00:00'").run();
subs.upsert(user.id, checked('https://fcm.googleapis.com/fcm/send/d0'), 'k');
expect(subs.upsert(user.id, checked('https://fcm.googleapis.com/fcm/send/new'), 'k')).toBe(
MAX_PUSH_DEVICES_PER_USER,
);
const endpoints = subs.listForUser(user.id).map((r) => r.endpoint);
expect(endpoints).toContain('https://fcm.googleapis.com/fcm/send/d0');
expect(endpoints).toContain('https://fcm.googleapis.com/fcm/send/new');
expect(endpoints).not.toContain('https://fcm.googleapis.com/fcm/send/d1');
});
it('PUSHSUB-005: a user can only remove their own endpoint', () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
subs.upsert(owner.id, checked(FCM), 'k');
expect(subs.removeForUser(other.id, FCM)).toBe(false);
expect(subs.countForUser(owner.id)).toBe(1);
expect(subs.removeForUser(owner.id, FCM)).toBe(true);
expect(subs.countForUser(owner.id)).toBe(0);
});
it('PUSHSUB-006: success resets the failure count, failures add up, deleteById removes the row', () => {
const { user } = createUser(testDb);
subs.upsert(user.id, checked(FCM), 'k');
const [row] = subs.listForUser(user.id);
expect(subs.recordFailure(row.id)).toBe(1);
expect(subs.recordFailure(row.id)).toBe(2);
expect(subs.listForUser(user.id)[0].failure_count).toBe(2);
subs.recordSuccess(row.id);
const [after] = subs.listForUser(user.id);
expect(after.failure_count).toBe(0);
expect(after.last_success_at).not.toBeNull();
expect(subs.recordFailure(row.id)).toBe(1);
subs.deleteById(row.id);
expect(subs.hasAny(user.id)).toBe(false);
// A failure recorded for a row another send already removed changes nothing.
expect(subs.recordFailure(row.id)).toBe(0);
});
it('PUSHSUB-007: cuts a long user agent and the rows go with the user', () => {
const { user } = createUser(testDb);
subs.upsert(user.id, checked(FCM), 'k', 'x'.repeat(1000));
expect(subs.listForUser(user.id)[0].user_agent).toHaveLength(256);
testDb.prepare('DELETE FROM users WHERE id = ?').run(user.id);
expect(testDb.prepare('SELECT COUNT(*) AS n FROM push_subscriptions').get()).toEqual({ n: 0 });
});
it('PUSHSUB-008: migration 244 created the table with its user index', () => {
const index = testDb
.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'push_subscriptions'")
.all() as { name: string }[];
expect(index.map((i) => i.name)).toContain('idx_push_subscriptions_user');
});
});
@@ -0,0 +1,116 @@
/**
* PushController parity (PUSHCTL-*): status codes, bodies and the bespoke 400
* strings of /api/notifications/push, over stubbed providers.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { HttpException } from '@nestjs/common';
import { HTTP_CODE_METADATA } from '@nestjs/common/constants';
import { createECDH } from 'node:crypto';
import { PushController } from '../../../../src/nest/notifications/push/push.controller';
import { PUSH_SUBSCRIPTION_ERRORS } from '../../../../src/nest/notifications/push/push-subscription.helpers';
import type { PushSubscriptionsService } from '../../../../src/nest/notifications/push/push-subscriptions.service';
import {
PUSH_UNAVAILABLE_ERROR,
PushUnavailableError,
type VapidKeysService,
} from '../../../../src/nest/notifications/push/vapid-keys.service';
import type { User } from '../../../../src/types';
const keys = { getPublicKey: vi.fn((): string => 'server-public-key') };
const subscriptions = { upsert: vi.fn(() => 2), removeForUser: vi.fn(() => true) };
const controller = new PushController(
keys as unknown as VapidKeysService,
subscriptions as unknown as PushSubscriptionsService,
);
const user = { id: 5 } as User;
function subscription(endpoint = 'https://fcm.googleapis.com/fcm/send/abc') {
const ua = createECDH('prime256v1');
ua.generateKeys();
return {
endpoint,
expirationTime: null,
keys: { p256dh: ua.getPublicKey('base64url'), auth: Buffer.alloc(16, 1).toString('base64url') },
};
}
function thrown(fn: () => unknown): HttpException {
try {
fn();
} catch (err) {
return err as HttpException;
}
throw new Error('expected a throw');
}
beforeEach(() => {
vi.clearAllMocks();
});
describe('PushController', () => {
it('PUSHCTL-001: GET public-key answers the key browsers subscribe with', () => {
expect(controller.publicKey()).toEqual({ publicKey: 'server-public-key' });
});
it('PUSHCTL-002: POST subscriptions stores the checked subscription against the current key and answers 200', () => {
const sub = subscription();
expect(controller.subscribe(user, { subscription: sub }, 'Firefox/130')).toEqual({ success: true, devices: 2 });
expect(subscriptions.upsert).toHaveBeenCalledWith(
5,
{ endpoint: sub.endpoint, p256dh: sub.keys.p256dh, auth: sub.keys.auth },
'server-public-key',
'Firefox/130',
);
expect(Reflect.getMetadata(HTTP_CODE_METADATA, PushController.prototype.subscribe)).toBe(200);
});
it('PUSHCTL-003: POST subscriptions answers the bespoke 400 and stores nothing', () => {
const err = thrown(() => controller.subscribe(user, { subscription: subscription('https://example.com/collect') }));
expect(err).toBeInstanceOf(HttpException);
expect(err.getStatus()).toBe(400);
expect(err.getResponse()).toEqual({ error: PUSH_SUBSCRIPTION_ERRORS.service });
expect(subscriptions.upsert).not.toHaveBeenCalled();
});
it('PUSHCTL-004: DELETE subscriptions removes only the caller’s endpoint and is idempotent', () => {
expect(controller.unsubscribe(user, { endpoint: 'https://fcm.googleapis.com/fcm/send/abc' })).toEqual({
success: true,
});
expect(subscriptions.removeForUser).toHaveBeenCalledWith(5, 'https://fcm.googleapis.com/fcm/send/abc');
subscriptions.removeForUser.mockReturnValueOnce(false);
expect(controller.unsubscribe(user, { endpoint: 'https://fcm.googleapis.com/fcm/send/gone' })).toEqual({
success: true,
});
expect(Reflect.getMetadata(HTTP_CODE_METADATA, PushController.prototype.unsubscribe)).toBe(200);
});
it('PUSHCTL-005: while the stored key pair is unusable, public-key and subscribe answer 503 and store nothing', () => {
keys.getPublicKey.mockImplementation(() => {
throw new PushUnavailableError();
});
try {
const fromKey = thrown(() => controller.publicKey());
expect(fromKey.getStatus()).toBe(503);
expect(fromKey.getResponse()).toEqual({ error: PUSH_UNAVAILABLE_ERROR });
const fromSubscribe = thrown(() => controller.subscribe(user, { subscription: subscription() }));
expect(fromSubscribe.getStatus()).toBe(503);
expect(fromSubscribe.getResponse()).toEqual({ error: PUSH_UNAVAILABLE_ERROR });
expect(subscriptions.upsert).not.toHaveBeenCalled();
// A device can still be forgotten.
expect(controller.unsubscribe(user, { endpoint: 'https://fcm.googleapis.com/fcm/send/abc' })).toEqual({
success: true,
});
} finally {
keys.getPublicKey.mockImplementation(() => 'server-public-key');
}
});
it('PUSHCTL-006: any other key failure is not dressed up as a 503', () => {
keys.getPublicKey.mockImplementationOnce(() => {
throw new Error('database is locked');
});
expect(() => controller.publicKey()).toThrow('database is locked');
});
});
@@ -0,0 +1,438 @@
/**
* Where the server's VAPID identity comes from (VKEY-*): the environment pair
* when it is complete and consistent, else a pair generated once into
* app_settings with the private half encrypted. Push is off, with every row
* left as it was, while the pair it would sign with cannot be used: a stored
* pair is never regenerated, and a broken environment pair falls back on
* neither a stored nor a new one. And the `sub` contact.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
// One :memory: connection per file, created inside the factory so nothing has
// to be hoisted above the imports; the tests reach it through the mocked module.
vi.mock('../../../../src/db/database', async () => {
const { default: Database } = await import('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
return {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => null,
isOwner: () => false,
};
});
vi.mock('../../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { logInfo, logError } = vi.hoisted(() => ({ logInfo: vi.fn(), logError: vi.fn() }));
vi.mock('../../../../src/nest/audit/audit-log.logger', () => ({
LOG_LEVEL: 'error',
logInfo,
logDebug: vi.fn(),
logError,
logWarn: vi.fn(),
}));
import { db as testDb } from '../../../../src/db/database';
import { createTables } from '../../../../src/db/schema';
import { runMigrations } from '../../../../src/db/migrations';
import { resetTestDb } from '../../../helpers/test-db';
import { DatabaseService } from '../../../../src/nest/database/database.service';
import { decrypt_api_key, encrypt_api_key } from '../../../../src/nest/common/crypto/apiKeyCrypto';
import {
FALLBACK_VAPID_SUBJECT,
PushUnavailableError,
VAPID_PRIVATE_KEY_SETTING,
VAPID_PUBLIC_KEY_SETTING,
VapidKeysService,
} from '../../../../src/nest/notifications/push/vapid-keys.service';
import { generateVapidKeyPair, isVapidKeyPair } from '../../../../src/nest/notifications/push/web-push-crypto';
const dbs = new DatabaseService(testDb);
/** An enc:v1: value the current ENCRYPTION_KEY cannot open. */
const UNREADABLE = 'enc:v1:bm90IGEgY2lwaGVydGV4dA';
function setting(key: string): string | undefined {
return (testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)
?.value;
}
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
logInfo.mockClear();
logError.mockClear();
for (const name of [
'VAPID_PUBLIC_KEY',
'VAPID_PRIVATE_KEY',
'VAPID_SUBJECT',
'APP_URL',
'ALLOWED_ORIGINS',
'ADMIN_EMAIL',
]) {
vi.stubEnv(name, '');
}
});
afterEach(() => {
vi.unstubAllEnvs();
});
afterAll(() => {
testDb.close();
});
describe('VapidKeysService key pair', () => {
it('VKEY-001: generates the pair once, the private half encrypted, and keeps serving it', () => {
const svc = new VapidKeysService(dbs);
const keys = svc.getKeys();
expect(keys.source).toBe('database');
expect(isVapidKeyPair(keys.publicKey, keys.privateKey)).toBe(true);
expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBe(keys.publicKey);
const stored = setting(VAPID_PRIVATE_KEY_SETTING)!;
expect(stored.startsWith('enc:v1:')).toBe(true);
expect(decrypt_api_key(stored)).toBe(keys.privateKey);
expect(logInfo).toHaveBeenCalledTimes(1);
// Another instance over the same database (a restart) reads the same pair.
const again = new VapidKeysService(dbs);
expect(again.getKeys()).toEqual(keys);
expect(svc.getPublicKey()).toBe(keys.publicKey);
expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBe(stored);
expect(logInfo).toHaveBeenCalledTimes(1);
});
it('VKEY-001b: a pair another instance stored between the first look and the write is the one used', () => {
const theirs = generateVapidKeyPair();
const svc = new VapidKeysService(dbs);
const transaction = dbs.transaction.bind(dbs);
const spy = vi.spyOn(dbs, 'transaction').mockImplementationOnce((fn) => {
testDb
.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?), (?, ?)')
.run(VAPID_PUBLIC_KEY_SETTING, theirs.publicKey, VAPID_PRIVATE_KEY_SETTING, encrypt_api_key(theirs.privateKey));
return transaction(fn);
});
try {
expect(svc.getKeys()).toEqual({ ...theirs, source: 'database' });
expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBe(theirs.publicKey);
expect(logInfo).not.toHaveBeenCalled();
} finally {
spy.mockRestore();
}
});
it('VKEY-002: bootstrap creates the pair, so the public-key GET only reads', () => {
new VapidKeysService(dbs).onApplicationBootstrap();
expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBeTruthy();
expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBeTruthy();
});
it('VKEY-003: a bootstrap failure is logged, not thrown', () => {
const broken = {
get: () => {
throw new Error('no such table: app_settings');
},
} as unknown as DatabaseService;
expect(() => new VapidKeysService(broken).onApplicationBootstrap()).not.toThrow();
expect(logError).toHaveBeenCalledWith(expect.stringContaining('no such table'));
});
it('VKEY-004: a consistent pair in the environment wins and the database is left alone', () => {
const pair = generateVapidKeyPair();
// Padding on the way in is fine; the key handed to browsers has none.
vi.stubEnv('VAPID_PUBLIC_KEY', `${pair.publicKey}=`);
vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey);
const keys = new VapidKeysService(dbs).getKeys();
expect(keys).toEqual({ ...pair, source: 'env' });
expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBeUndefined();
expect(logError).not.toHaveBeenCalled();
});
it('VKEY-005: a mismatched environment pair turns push off, reported once, and the stored pair is not used', () => {
const stored = new VapidKeysService(dbs).getKeys();
const ciphertext = setting(VAPID_PRIVATE_KEY_SETTING);
vi.stubEnv('VAPID_PUBLIC_KEY', generateVapidKeyPair().publicKey);
vi.stubEnv('VAPID_PRIVATE_KEY', generateVapidKeyPair().privateKey);
const svc = new VapidKeysService(dbs);
expect(() => svc.getKeys()).toThrow(PushUnavailableError);
expect(() => svc.getPublicKey()).toThrow(PushUnavailableError);
expect(svc.isAvailable()).toBe(false);
expect(logError).toHaveBeenCalledTimes(1);
expect(logError).toHaveBeenCalledWith(
expect.stringContaining('Web Push is off: VAPID_PUBLIC_KEY does not belong to VAPID_PRIVATE_KEY'),
);
// Both stored rows are exactly as they were, for when the variables go again.
expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBe(stored.publicKey);
expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBe(ciphertext);
expect(logInfo).toHaveBeenCalledTimes(1);
});
it('VKEY-006: half a pair in the environment turns push off over a stored pair, and is reported again after a fix and relapse', () => {
const stored = new VapidKeysService(dbs).getKeys();
const ciphertext = setting(VAPID_PRIVATE_KEY_SETTING);
const pair = generateVapidKeyPair();
const svc = new VapidKeysService(dbs);
vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey);
expect(() => svc.getKeys()).toThrow(PushUnavailableError);
expect(svc.isAvailable()).toBe(false);
expect(logError).toHaveBeenCalledTimes(1);
expect(logError).toHaveBeenCalledWith(expect.stringContaining('VAPID_PRIVATE_KEY is set without its other half'));
vi.stubEnv('VAPID_PUBLIC_KEY', pair.publicKey);
expect(svc.getKeys()).toEqual({ ...pair, source: 'env' });
// An existingSecret rotated without the private half: the devices hold the
// environment key, so signing with the stored pair would drop every one.
vi.stubEnv('VAPID_PRIVATE_KEY', '');
expect(() => svc.getKeys()).toThrow(PushUnavailableError);
expect(svc.isAvailable()).toBe(false);
expect(logError).toHaveBeenCalledTimes(2);
expect(logError).toHaveBeenLastCalledWith(
expect.stringContaining('VAPID_PUBLIC_KEY is set without its other half'),
);
expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBe(stored.publicKey);
expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBe(ciphertext);
// Both variables removed on purpose: the stored pair, as on any install without them.
vi.stubEnv('VAPID_PUBLIC_KEY', '');
expect(svc.getKeys()).toEqual(stored);
});
it('VKEY-006b: an install that only ran on the environment pair generates nothing when that pair breaks', () => {
const pair = generateVapidKeyPair();
vi.stubEnv('VAPID_PUBLIC_KEY', pair.publicKey);
vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey);
const svc = new VapidKeysService(dbs);
svc.onApplicationBootstrap();
expect(svc.isAvailable()).toBe(true);
// An existingSecret without the private half: the variable is simply gone.
vi.stubEnv('VAPID_PRIVATE_KEY', '');
new VapidKeysService(dbs).onApplicationBootstrap();
expect(svc.isAvailable()).toBe(false);
expect(() => svc.getKeys()).toThrow(PushUnavailableError);
expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBeUndefined();
expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBeUndefined();
expect(logInfo).not.toHaveBeenCalled();
// Once per instance, with what to do and without the key.
expect(logError).toHaveBeenCalledTimes(2);
const line = logError.mock.calls[0][0] as string;
expect(line).toContain('Web Push is off: VAPID_PUBLIC_KEY is set without its other half');
expect(line).toContain('Set both variables');
expect(line).not.toContain(pair.publicKey);
// Halves that do not belong together are refused the same way.
vi.stubEnv('VAPID_PRIVATE_KEY', generateVapidKeyPair().privateKey);
expect(svc.isAvailable()).toBe(false);
expect(logError).toHaveBeenLastCalledWith(
expect.stringContaining('Web Push is off: VAPID_PUBLIC_KEY does not belong to VAPID_PRIVATE_KEY'),
);
expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBeUndefined();
// The pair fixed: the same key as before, so every device receives again as it was.
vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey);
expect(svc.getKeys()).toEqual({ ...pair, source: 'env' });
});
it('VKEY-006c: a broken environment pair over an unusable stored pair names the variables and touches no row', () => {
new VapidKeysService(dbs).getKeys();
testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING);
vi.stubEnv('VAPID_PUBLIC_KEY', generateVapidKeyPair().publicKey);
const svc = new VapidKeysService(dbs);
expect(() => svc.getKeys()).toThrow(PushUnavailableError);
expect(() => svc.getKeys()).toThrow(PushUnavailableError);
expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBe(UNREADABLE);
// The variables are what push would sign with, so they are what the log names.
expect(logError).toHaveBeenCalledTimes(1);
expect(logError).toHaveBeenCalledWith(
expect.stringContaining('Web Push is off: VAPID_PUBLIC_KEY is set without its other half'),
);
});
it('VKEY-007: a stored private key that no longer decrypts is kept, and push is off until the key is back', () => {
const svc = new VapidKeysService(dbs);
const original = svc.getKeys();
const ciphertext = setting(VAPID_PRIVATE_KEY_SETTING)!;
// What a restore under a different ENCRYPTION_KEY looks like from here.
testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING);
logInfo.mockClear();
const broken = new VapidKeysService(dbs);
expect(() => broken.getKeys()).toThrow(PushUnavailableError);
expect(() => broken.getPublicKey()).toThrow(PushUnavailableError);
// Nothing was replaced: both rows are exactly as they were.
expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBe(original.publicKey);
expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBe(UNREADABLE);
expect(logInfo).not.toHaveBeenCalled();
// Said once, with the way out, and without any key material.
expect(logError).toHaveBeenCalledTimes(1);
const line = logError.mock.calls[0][0] as string;
expect(line).toContain('cannot be decrypted with the current ENCRYPTION_KEY');
expect(line).not.toContain(original.publicKey);
expect(line).not.toContain(UNREADABLE);
expect(line).not.toContain(ciphertext);
// The right key again (here: the original ciphertext back) brings the same pair back.
testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(ciphertext, VAPID_PRIVATE_KEY_SETTING);
expect(broken.getKeys()).toEqual(original);
});
it('VKEY-008: half a stored pair is not completed with a new pair either', () => {
const publicKey = generateVapidKeyPair().publicKey;
testDb.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)').run(VAPID_PUBLIC_KEY_SETTING, publicKey);
expect(() => new VapidKeysService(dbs).getKeys()).toThrow(PushUnavailableError);
expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBe(publicKey);
expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBeUndefined();
expect(logError).toHaveBeenCalledWith(expect.stringContaining('only one half of the stored VAPID key pair'));
expect(logError).toHaveBeenCalledWith(expect.not.stringContaining(publicKey));
});
it('VKEY-008b: only the private half stored counts as incomplete too', () => {
const { privateKey } = generateVapidKeyPair();
testDb
.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)')
.run(VAPID_PRIVATE_KEY_SETTING, encrypt_api_key(privateKey));
expect(() => new VapidKeysService(dbs).getKeys()).toThrow(PushUnavailableError);
expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBeUndefined();
});
it('VKEY-008c: halves that decrypt but do not belong together are refused, not replaced', () => {
const stored = generateVapidKeyPair();
testDb
.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?), (?, ?)')
.run(
VAPID_PUBLIC_KEY_SETTING,
stored.publicKey,
VAPID_PRIVATE_KEY_SETTING,
encrypt_api_key(generateVapidKeyPair().privateKey),
);
expect(() => new VapidKeysService(dbs).getKeys()).toThrow(PushUnavailableError);
expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBe(stored.publicKey);
expect(logError).toHaveBeenCalledWith(expect.stringContaining('does not belong to the stored public key'));
});
it('VKEY-008d: an unusable pair is reported once per state, and again after it was fixed and broke again', () => {
const svc = new VapidKeysService(dbs);
svc.getKeys();
const ciphertext = setting(VAPID_PRIVATE_KEY_SETTING)!;
testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING);
for (let i = 0; i < 3; i++) expect(() => svc.getKeys()).toThrow(PushUnavailableError);
expect(logError).toHaveBeenCalledTimes(1);
testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(ciphertext, VAPID_PRIVATE_KEY_SETTING);
svc.getKeys();
testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING);
expect(() => svc.getKeys()).toThrow(PushUnavailableError);
expect(logError).toHaveBeenCalledTimes(2);
});
it('VKEY-008e: bootstrap over an unusable pair logs the reason once and does not throw', () => {
new VapidKeysService(dbs).getKeys();
testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING);
logError.mockClear();
expect(() => new VapidKeysService(dbs).onApplicationBootstrap()).not.toThrow();
expect(logError).toHaveBeenCalledTimes(1);
expect(logError).toHaveBeenCalledWith(expect.stringContaining('Web Push is off'));
});
it('VKEY-008f: a consistent environment pair still works while the stored one is unusable', () => {
new VapidKeysService(dbs).getKeys();
testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING);
const pair = generateVapidKeyPair();
vi.stubEnv('VAPID_PUBLIC_KEY', pair.publicKey);
vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey);
const svc = new VapidKeysService(dbs);
expect(svc.getKeys()).toEqual({ ...pair, source: 'env' });
expect(svc.isAvailable()).toBe(true);
// The stored rows are still exactly what was there.
expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBe(UNREADABLE);
});
it('VKEY-008g: isAvailable only reads: it creates nothing, and never touches an unusable pair', () => {
const svc = new VapidKeysService(dbs);
// Neither row yet: available, since the next send or subscribe creates the
// pair, but nothing is written now (the preference matrix GET asks this).
expect(svc.isAvailable()).toBe(true);
expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBeUndefined();
expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBeUndefined();
expect(logInfo).not.toHaveBeenCalled();
const { publicKey } = svc.getKeys();
expect(svc.isAvailable()).toBe(true);
testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING);
expect(svc.isAvailable()).toBe(false);
expect(svc.isAvailable()).toBe(false);
expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBe(publicKey);
expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBe(UNREADABLE);
expect(logError).toHaveBeenCalledTimes(1);
});
it('VKEY-008h: isAvailable turns any other failure into a logged false', () => {
const svc = new VapidKeysService(dbs);
const spy = vi.spyOn(dbs, 'get').mockImplementationOnce(() => {
throw new Error('database is locked');
});
expect(svc.isAvailable()).toBe(false);
expect(logError).toHaveBeenCalledWith(expect.stringContaining('database is locked'));
spy.mockRestore();
expect(svc.isAvailable()).toBe(true);
});
});
describe('VapidKeysService subject', () => {
const svc = new VapidKeysService(dbs);
it('VKEY-009: VAPID_SUBJECT comes first', () => {
vi.stubEnv('VAPID_SUBJECT', 'mailto:push@example.test');
vi.stubEnv('APP_URL', 'https://trek.example.test');
expect(svc.getSubject()).toBe('mailto:push@example.test');
});
it('VKEY-010: then APP_URL, when it is public https', () => {
vi.stubEnv('APP_URL', ' https://trek.example.test/ ');
expect(svc.getSubject()).toBe('https://trek.example.test');
});
it('VKEY-010b: never an ALLOWED_ORIGINS entry, which can be another site allowed to call the API', () => {
vi.stubEnv('ALLOWED_ORIGINS', 'https://companion.example.test,https://trek.example.test');
expect(svc.getSubject()).toBe(FALLBACK_VAPID_SUBJECT);
vi.stubEnv('APP_URL', 'https://trek.example.test');
expect(svc.getSubject()).toBe('https://trek.example.test');
});
it('VKEY-010c: an APP_URL that does not parse is no candidate', () => {
vi.stubEnv('APP_URL', 'not a url');
expect(svc.getSubject()).toBe(FALLBACK_VAPID_SUBJECT);
});
it('VKEY-011: never the bootstrap admin address, which would travel to every push service', () => {
vi.stubEnv('ADMIN_EMAIL', 'admin@example.test');
vi.stubEnv('APP_URL', 'https://trek.example.test');
expect(svc.getSubject()).toBe('https://trek.example.test');
vi.stubEnv('APP_URL', 'http://trek.lan');
expect(svc.getSubject()).toBe(FALLBACK_VAPID_SUBJECT);
});
it('VKEY-012: then the project URL when the instance URL is plain http or localhost', () => {
vi.stubEnv('APP_URL', 'http://trek.lan');
expect(svc.getSubject()).toBe(FALLBACK_VAPID_SUBJECT);
vi.stubEnv('APP_URL', 'https://localhost:3000');
expect(svc.getSubject()).toBe(FALLBACK_VAPID_SUBJECT);
});
it('VKEY-013: and never a subject Apple would refuse, even when one is configured', () => {
vi.stubEnv('VAPID_SUBJECT', 'push@example.test'); // not mailto:, not https: (boot would have refused it)
expect(svc.getSubject()).toBe(FALLBACK_VAPID_SUBJECT);
vi.stubEnv('APP_URL', 'https://trek.example.test');
expect(svc.getSubject()).toBe('https://trek.example.test');
});
});
@@ -0,0 +1,211 @@
/**
* RFC 8291 message encryption and RFC 8292 VAPID headers (WPCRYPTO-*).
*
* The encryption is pinned twice: byte for byte against the worked example in
* RFC 8291 Appendix A, and by a decrypt written here from the receiving side,
* the way a browser unwraps the message, for fresh keys and salts.
*/
import { describe, it, expect } from 'vitest';
import { createDecipheriv, createECDH, createPublicKey, hkdfSync, type ECDH } from 'node:crypto';
import jwt from 'jsonwebtoken';
import {
MAX_PUSH_PLAINTEXT_BYTES,
PUSH_RECORD_SIZE,
VAPID_TOKEN_TTL_SECONDS,
createVapidSigner,
derivePublicKey,
encryptPushMessage,
generateVapidKeyPair,
isP256Point,
isVapidKeyPair,
} from '../../../../src/nest/notifications/push/web-push-crypto';
const b64 = (value: string) => Buffer.from(value, 'base64url');
// RFC 8291 Appendix A.
const RFC = {
plaintext: b64('V2hlbiBJIGdyb3cgdXAsIEkgd2FudCB0byBiZSBhIHdhdGVybWVsb24'),
asPublic: 'BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8',
asPrivate: b64('yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw'),
uaPublic: b64('BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4'),
uaPrivate: b64('q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94'),
salt: b64('DGv6ra1nlYgDCS1FRnbzlw'),
auth: b64('BTBZMqHH6r4Tts7J_aSIgg'),
// Section 5, the message body as sent (the three lines of the RFC joined).
body:
'DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27ml' +
'mlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPT' +
'pK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN',
};
/** The receiving side of RFC 8291, independent of the module under test. */
function decrypt(body: Buffer, ua: ECDH, auth: Buffer): { plaintext: Buffer; rs: number; keyId: Buffer } {
const salt = body.subarray(0, 16);
const rs = body.readUInt32BE(16);
const idlen = body[20];
const keyId = body.subarray(21, 21 + idlen);
const record = body.subarray(21 + idlen);
const secret = ua.computeSecret(keyId);
const keyInfo = Buffer.concat([Buffer.from('WebPush: info\0'), ua.getPublicKey(), keyId]);
const ikm = Buffer.from(hkdfSync('sha256', secret, auth, keyInfo, 32));
const cek = Buffer.from(hkdfSync('sha256', ikm, salt, Buffer.from('Content-Encoding: aes128gcm\0'), 16));
const nonce = Buffer.from(hkdfSync('sha256', ikm, salt, Buffer.from('Content-Encoding: nonce\0'), 12));
const decipher = createDecipheriv('aes-128-gcm', cek, nonce);
decipher.setAuthTag(record.subarray(record.length - 16));
const padded = Buffer.concat([decipher.update(record.subarray(0, record.length - 16)), decipher.final()]);
let end = padded.length - 1;
while (end >= 0 && padded[end] === 0) end--;
expect(padded[end]).toBe(0x02); // the last-record delimiter
return { plaintext: padded.subarray(0, end), rs, keyId };
}
function browser() {
const ua = createECDH('prime256v1');
ua.generateKeys();
const auth = Buffer.from('0123456789abcdef');
return { ua, keys: { p256dh: ua.getPublicKey(), auth } };
}
describe('encryptPushMessage', () => {
it('WPCRYPTO-001: reproduces the RFC 8291 Appendix A message byte for byte', () => {
const body = encryptPushMessage(
RFC.plaintext,
{ p256dh: RFC.uaPublic, auth: RFC.auth },
{ salt: RFC.salt, senderPrivateKey: RFC.asPrivate },
);
expect(body.toString('base64url')).toBe(RFC.body);
});
it('WPCRYPTO-002: the RFC message decrypts with the user agent key, which checks the reference decrypt too', () => {
const ua = createECDH('prime256v1');
ua.setPrivateKey(RFC.uaPrivate);
const { plaintext, rs, keyId } = decrypt(b64(RFC.body), ua, RFC.auth);
expect(plaintext.toString('utf8')).toBe('When I grow up, I want to be a watermelon');
expect(rs).toBe(4096);
expect(keyId.toString('base64url')).toBe(RFC.asPublic);
});
it('WPCRYPTO-003: a real message round-trips, with a fresh salt and sender key every time', () => {
const { ua, keys } = browser();
const message = Buffer.from(JSON.stringify({ title: 'Trip reminder: Kyōto', body: 'Tomorrow 🚄' }));
const first = encryptPushMessage(message, keys);
const second = encryptPushMessage(message, keys);
expect(decrypt(first, ua, keys.auth).plaintext.equals(message)).toBe(true);
expect(decrypt(second, ua, keys.auth).plaintext.equals(message)).toBe(true);
expect(first.subarray(0, 16).equals(second.subarray(0, 16))).toBe(false);
expect(first.subarray(21, 86).equals(second.subarray(21, 86))).toBe(false);
});
it('WPCRYPTO-004: the largest allowed plaintext still fits one 4096-byte record', () => {
const { ua, keys } = browser();
const message = Buffer.alloc(MAX_PUSH_PLAINTEXT_BYTES, 'a');
const body = encryptPushMessage(message, keys);
expect(body.length).toBe(PUSH_RECORD_SIZE);
expect(decrypt(body, ua, keys.auth).plaintext.length).toBe(MAX_PUSH_PLAINTEXT_BYTES);
expect(() => encryptPushMessage(Buffer.alloc(MAX_PUSH_PLAINTEXT_BYTES + 1), keys)).toThrow(
/does not fit one record/,
);
});
it('WPCRYPTO-005: refuses keys it cannot encrypt for', () => {
const { keys } = browser();
const offCurve = Buffer.from(keys.p256dh);
offCurve[64] ^= 0x01;
expect(() => encryptPushMessage(Buffer.from('x'), { ...keys, p256dh: offCurve })).toThrow(/p256dh/);
expect(() => encryptPushMessage(Buffer.from('x'), { ...keys, auth: Buffer.alloc(8) })).toThrow(/auth secret/);
expect(() => encryptPushMessage(Buffer.from('x'), keys, { salt: Buffer.alloc(4) })).toThrow(/salt/);
});
});
describe('key helpers', () => {
it('WPCRYPTO-006: generates a full-length pair whose halves belong together', () => {
for (let i = 0; i < 20; i++) {
const pair = generateVapidKeyPair();
const pub = b64(pair.publicKey);
expect(pub.length).toBe(65);
expect(pub[0]).toBe(0x04);
expect(b64(pair.privateKey).length).toBe(32);
expect(isVapidKeyPair(pair.publicKey, pair.privateKey)).toBe(true);
}
});
it('WPCRYPTO-007: tells a pair from two unrelated halves, and rejects what is not a key', () => {
const a = generateVapidKeyPair();
const b = generateVapidKeyPair();
expect(isVapidKeyPair(a.publicKey, b.privateKey)).toBe(false);
expect(isVapidKeyPair('not base64url!', a.privateKey)).toBe(false);
expect(derivePublicKey('short')).toBeNull();
expect(derivePublicKey(Buffer.alloc(32).toString('base64url'))).toBeNull(); // zero is no scalar
expect(derivePublicKey(a.privateKey)?.toString('base64url')).toBe(a.publicKey);
});
it('WPCRYPTO-008: isP256Point wants an uncompressed point that is on the curve', () => {
const { keys } = browser();
expect(isP256Point(keys.p256dh)).toBe(true);
const offCurve = Buffer.from(keys.p256dh);
offCurve[40] ^= 0xff;
expect(isP256Point(offCurve)).toBe(false);
expect(isP256Point(Buffer.concat([Buffer.from([0x03]), keys.p256dh.subarray(1)]))).toBe(false);
expect(isP256Point(keys.p256dh.subarray(0, 33))).toBe(false);
});
});
describe('createVapidSigner', () => {
const pair = generateVapidKeyPair();
const pub = b64(pair.publicKey);
const verifyKey = createPublicKey({
key: {
kty: 'EC',
crv: 'P-256',
x: pub.subarray(1, 33).toString('base64url'),
y: pub.subarray(33).toString('base64url'),
},
format: 'jwk',
});
function parse(header: string) {
const m = /^vapid t=([^,]+), k=(.+)$/.exec(header);
expect(m).not.toBeNull();
return { token: m![1], k: m![2] };
}
it('WPCRYPTO-009: builds `vapid t=<ES256 JWT>, k=<public key>` for the endpoint origin', () => {
const now = Date.UTC(2026, 8, 24, 12, 0, 0);
const sign = createVapidSigner(pair, 'mailto:ops@example.test', () => now);
const { token, k } = parse(sign('https://fcm.googleapis.com/fcm/send/abc:def'));
expect(k).toBe(pair.publicKey);
const claims = jwt.verify(token, verifyKey, {
algorithms: ['ES256'],
clockTimestamp: now / 1000,
}) as jwt.JwtPayload;
expect(claims).toEqual({
aud: 'https://fcm.googleapis.com',
exp: now / 1000 + VAPID_TOKEN_TTL_SECONDS,
sub: 'mailto:ops@example.test',
});
expect(VAPID_TOKEN_TTL_SECONDS).toBeLessThanOrEqual(24 * 60 * 60);
const [header, , signature] = token.split('.');
expect(JSON.parse(b64(header).toString('utf8'))).toEqual({ alg: 'ES256', typ: 'JWT' });
// JOSE r||s, not DER: the form push services verify.
expect(b64(signature).length).toBe(64);
});
it('WPCRYPTO-010: signs once per origin and separately for another push service', () => {
const sign = createVapidSigner(pair, 'https://trek.example.test');
const a = sign('https://updates.push.services.mozilla.com/wpush/v2/one');
const b = sign('https://updates.push.services.mozilla.com/wpush/v2/two');
const c = sign('https://web.push.apple.com/QGx');
expect(a).toBe(b);
expect(c).not.toBe(a);
const claims = jwt.decode(parse(c).token) as jwt.JwtPayload;
expect(claims.aud).toBe('https://web.push.apple.com');
});
it('WPCRYPTO-011: refuses a malformed key pair up front', () => {
expect(() => createVapidSigner({ publicKey: 'abc', privateKey: pair.privateKey }, 'mailto:a@b.test')).toThrow(
/malformed/,
);
});
});
@@ -0,0 +1,302 @@
/**
* Web Push inside the dispatcher (WPDISP-*): the admin switch, the per-user
* Push column, admin-scoped events, the preference matrix, the generic test
* route, and all of them while the key pair cannot be used, stored or from
* VAPID_*. Real NotificationsService and real SQL; safeFetchFollow is the edge.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
import { createECDH } from 'node:crypto';
// One :memory: connection per file, created inside the factory so nothing has
// to be hoisted above the imports; the tests reach it through the mocked module.
vi.mock('../../../../src/db/database', async () => {
const { default: Database } = await import('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
return {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => null,
isOwner: () => false,
};
});
vi.mock('../../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { safeFetchFollow, logError } = vi.hoisted(() => ({ safeFetchFollow: vi.fn(), logError: vi.fn() }));
vi.mock('../../../../src/nest/audit/audit-log.logger', () => ({
LOG_LEVEL: 'error',
logInfo: vi.fn(),
logDebug: vi.fn(),
logError,
logWarn: vi.fn(),
}));
vi.mock('../../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
vi.mock('../../../../src/utils/ssrfGuard', () => {
class SsrfBlockedError extends Error {}
return { SsrfBlockedError, safeFetchFollow };
});
import { db as testDb } from '../../../../src/db/database';
import { createTables } from '../../../../src/db/schema';
import { runMigrations } from '../../../../src/db/migrations';
import { resetTestDb } from '../../../helpers/test-db';
import { createAdmin, createUser, disableNotificationPref, setNotificationChannels } from '../../../helpers/factories';
import { makeNotificationPreferencesService, makeNotificationsService } from '../../../helpers/notifications';
import { DatabaseService } from '../../../../src/nest/database/database.service';
import { PushSubscriptionsService } from '../../../../src/nest/notifications/push/push-subscriptions.service';
import {
PUSH_UNAVAILABLE_ERROR,
VAPID_PRIVATE_KEY_SETTING,
VapidKeysService,
} from '../../../../src/nest/notifications/push/vapid-keys.service';
import { checkPushSubscription } from '../../../../src/nest/notifications/push/push-subscription.helpers';
import { generateVapidKeyPair } from '../../../../src/nest/notifications/push/web-push-crypto';
const dbs = new DatabaseService(testDb);
const notifications = makeNotificationsService(dbs);
const prefs = makeNotificationPreferencesService(dbs);
const subscriptions = new PushSubscriptionsService(dbs);
const keys = new VapidKeysService(dbs);
function addDevice(userId: number, endpoint = `https://fcm.googleapis.com/fcm/send/u${userId}`): string {
const ua = createECDH('prime256v1');
ua.generateKeys();
const checked = checkPushSubscription({
endpoint,
keys: { p256dh: ua.getPublicKey('base64url'), auth: Buffer.alloc(16, 7).toString('base64url') },
});
if ('error' in checked) throw new Error(checked.error);
subscriptions.upsert(userId, checked.value, keys.getPublicKey());
return endpoint;
}
function invite(targetId: number, actorId: number) {
return notifications.send({
event: 'trip_invite',
actorId,
params: { trip: 'Lisbon', actor: 'bob@example.test', invitee: 'alice', tripId: '12' },
scope: 'user',
targetId,
});
}
const pushedTo = () => safeFetchFollow.mock.calls.map((c) => c[0] as string);
/** What a restore under a different ENCRYPTION_KEY leaves behind: a private key that no longer opens. */
const UNREADABLE = 'enc:v1:bm90IGEgY2lwaGVydGV4dA';
function breakStoredPrivateKey(): void {
testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING);
}
const pushColumn = (userId: number) =>
prefs.getPreferencesMatrix(userId, 'user').channels.find((c) => c.id === 'push');
const storedKeyRows = () =>
testDb.prepare("SELECT key, value FROM app_settings WHERE key LIKE 'web_push_vapid_%' ORDER BY key").all();
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
safeFetchFollow.mockReset();
safeFetchFollow.mockResolvedValue({ ok: true, status: 201, body: null, headers: { get: () => null } });
logError.mockClear();
});
afterEach(() => {
vi.unstubAllEnvs();
});
afterAll(() => {
testDb.close();
});
describe('Web Push in NotificationsService.send()', () => {
it('WPDISP-001: goes out once the admin enabled push and the user has a device', async () => {
const { user } = createUser(testDb);
const { user: actor } = createUser(testDb);
const endpoint = addDevice(user.id);
setNotificationChannels(testDb, 'push');
await invite(user.id, actor.id);
expect(pushedTo()).toEqual([endpoint]);
});
it('WPDISP-002: stays quiet while the admin has not enabled push', async () => {
const { user } = createUser(testDb);
const { user: actor } = createUser(testDb);
addDevice(user.id);
setNotificationChannels(testDb, 'email');
await invite(user.id, actor.id);
expect(safeFetchFollow).not.toHaveBeenCalled();
});
it('WPDISP-003: respects the user switching the event off in the Push column', async () => {
const { user } = createUser(testDb);
const { user: actor } = createUser(testDb);
addDevice(user.id);
setNotificationChannels(testDb, 'push');
disableNotificationPref(testDb, user.id, 'trip_invite', 'push');
await invite(user.id, actor.id);
expect(safeFetchFollow).not.toHaveBeenCalled();
});
it('WPDISP-004: never carries an admin-scoped event', async () => {
const { user: admin } = createAdmin(testDb);
addDevice(admin.id);
setNotificationChannels(testDb, 'push');
await notifications.send({
event: 'version_available',
actorId: null,
params: { version: '9.9.9' },
scope: 'admin',
targetId: 0,
});
expect(safeFetchFollow).not.toHaveBeenCalled();
});
});
describe('Web Push in the preference matrix', () => {
it('WPDISP-005: the user matrix gets a Push column, active by the admin switch, configured by a device', () => {
const { user } = createUser(testDb);
let push = prefs.getPreferencesMatrix(user.id, 'user').channels.find((c) => c.id === 'push');
expect(push).toMatchObject({
source: 'builtin',
labelKey: 'settings.notificationPreferences.push',
active: false,
configured: false,
});
setNotificationChannels(testDb, 'email,push');
addDevice(user.id);
push = prefs.getPreferencesMatrix(user.id, 'user').channels.find((c) => c.id === 'push');
expect(push).toMatchObject({ active: true, configured: true });
expect(prefs.getActiveChannels()).toEqual(['email', 'push']);
expect(prefs.getPreferencesMatrix(user.id, 'user').implemented_combos['trip_invite']).toContain('push');
});
it('WPDISP-006: the admin matrix has no Push column, since push never carries admin events', () => {
const { user: admin } = createAdmin(testDb);
const matrix = prefs.getPreferencesMatrix(admin.id, 'admin', 'admin');
expect(matrix.channels.map((c) => c.id)).toEqual(['inapp', 'email', 'webhook', 'ntfy']);
expect(matrix.implemented_combos['version_available']).not.toContain('push');
expect(matrix.preferences['version_available']).not.toHaveProperty('push');
});
});
describe('POST /api/notifications/test/push, through the registry', () => {
it('WPDISP-007: refuses a user without a device and sends to one with', async () => {
const { user } = createUser(testDb);
await expect(notifications.testChannel(user.id, 'push')).resolves.toEqual({
success: false,
error: 'Channel is not configured for this user',
});
const endpoint = addDevice(user.id);
await expect(notifications.testChannel(user.id, 'push')).resolves.toEqual({ success: true });
expect(pushedTo()).toEqual([endpoint]);
});
});
describe('Web Push while the stored key pair cannot be used', () => {
it('WPDISP-008: a send skips push without an error while in-app and webhook still go out', async () => {
const { user } = createUser(testDb);
const { user: actor } = createUser(testDb);
const endpoint = addDevice(user.id);
testDb
.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'webhook_url', ?)")
.run(user.id, 'https://hooks.example.test/trek');
setNotificationChannels(testDb, 'webhook,push');
breakStoredPrivateKey();
await expect(invite(user.id, actor.id)).resolves.toBeUndefined();
expect(pushedTo()).toEqual(['https://hooks.example.test/trek']);
const inApp = testDb.prepare('SELECT COUNT(*) AS n FROM notifications WHERE recipient_id = ?').get(user.id) as {
n: number;
};
expect(inApp.n).toBe(1);
// The device is kept for when the key is back, and nothing failed on the way.
expect(subscriptions.listForUser(user.id).map((r) => r.endpoint)).toEqual([endpoint]);
expect(logError).not.toHaveBeenCalledWith(expect.stringContaining('dispatch failed'));
expect(logError).not.toHaveBeenCalledWith(expect.stringContaining('Web Push failed'));
});
it('WPDISP-009: the matrix stops offering push, so the card and the column go, and both come back with the key', () => {
const { user } = createUser(testDb);
setNotificationChannels(testDb, 'email,push');
addDevice(user.id);
const ciphertext = (
testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get(VAPID_PRIVATE_KEY_SETTING) as { value: string }
).value;
breakStoredPrivateKey();
expect(pushColumn(user.id)).toMatchObject({ active: false, configured: true });
// Only push: email keeps its column, SMTP or not, as it always has.
expect(prefs.getPreferencesMatrix(user.id, 'user').channels.find((c) => c.id === 'email')?.active).toBe(true);
testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(ciphertext, VAPID_PRIVATE_KEY_SETTING);
expect(pushColumn(user.id)).toMatchObject({ active: true, configured: true });
});
it('WPDISP-009b: reading the matrix while neither key row exists writes nothing, and push is still offered', () => {
const { user } = createUser(testDb);
setNotificationChannels(testDb, 'email,push');
addDevice(user.id);
// What deleting both rows to start over leaves under a running server.
testDb.prepare("DELETE FROM app_settings WHERE key LIKE 'web_push_vapid_%'").run();
expect(pushColumn(user.id)).toMatchObject({ active: true, configured: true });
expect(storedKeyRows()).toEqual([]);
});
it('WPDISP-010: the test send says push is unavailable and sends nothing', async () => {
const { user } = createUser(testDb);
addDevice(user.id);
breakStoredPrivateKey();
await expect(notifications.testChannel(user.id, 'push')).resolves.toEqual({
success: false,
error: PUSH_UNAVAILABLE_ERROR,
});
expect(safeFetchFollow).not.toHaveBeenCalled();
});
});
describe('Web Push while the VAPID_* pair is broken and an older pair is stored', () => {
it('WPDISP-011: nothing is sent, the device and both rows stay, the column goes, and all of it returns with the variables', async () => {
const { user } = createUser(testDb);
const { user: actor } = createUser(testDb);
setNotificationChannels(testDb, 'push');
// The first start without VAPID_* stored a pair; the operator then brought their own.
keys.getPublicKey();
const stored = storedKeyRows();
const pair = generateVapidKeyPair();
vi.stubEnv('VAPID_PUBLIC_KEY', pair.publicKey);
vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey);
const endpoint = addDevice(user.id);
// A typo or a rotated Secret: still a valid 32-byte key, so boot is fine.
vi.stubEnv('VAPID_PRIVATE_KEY', generateVapidKeyPair().privateKey);
await expect(invite(user.id, actor.id)).resolves.toBeUndefined();
expect(safeFetchFollow).not.toHaveBeenCalled();
expect(subscriptions.listForUser(user.id).map((r) => [r.endpoint, r.vapid_public_key])).toEqual([
[endpoint, pair.publicKey],
]);
expect(storedKeyRows()).toEqual(stored);
expect(pushColumn(user.id)).toMatchObject({ active: false, configured: true });
vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey);
expect(pushColumn(user.id)).toMatchObject({ active: true, configured: true });
await invite(user.id, actor.id);
expect(pushedTo()).toEqual([endpoint]);
});
});
@@ -0,0 +1,528 @@
/**
* The Web Push transport (WPUSH-*): what goes over the wire, what each push
* service answer does to the table, and the payload the worker receives
* (WPUSH-PAY-*). safeFetchFollow is the boundary; the database is real.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import { HttpException } from '@nestjs/common';
import { createDecipheriv, createECDH, hkdfSync, type ECDH } from 'node:crypto';
// One :memory: connection per file, created inside the factory so nothing has
// to be hoisted above the imports; the tests reach it through the mocked module.
vi.mock('../../../../src/db/database', async () => {
const { default: Database } = await import('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
return {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => null,
isOwner: () => false,
};
});
vi.mock('../../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { logInfo, logError, safeFetchFollow } = vi.hoisted(() => ({
logInfo: vi.fn(),
logError: vi.fn(),
safeFetchFollow: vi.fn(),
}));
vi.mock('../../../../src/nest/audit/audit-log.logger', () => ({
LOG_LEVEL: 'error',
logInfo,
logDebug: vi.fn(),
logError,
logWarn: vi.fn(),
}));
vi.mock('../../../../src/utils/ssrfGuard', () => {
class SsrfBlockedError extends Error {
constructor(message: string) {
super(message);
this.name = 'SsrfBlockedError';
}
}
return { SsrfBlockedError, safeFetchFollow };
});
import { db as testDb } from '../../../../src/db/database';
import { createTables } from '../../../../src/db/schema';
import { runMigrations } from '../../../../src/db/migrations';
import { resetTestDb } from '../../../helpers/test-db';
import { createUser } from '../../../helpers/factories';
import { DatabaseService } from '../../../../src/nest/database/database.service';
import { SsrfBlockedError } from '../../../../src/utils/ssrfGuard';
import { PushSubscriptionsService } from '../../../../src/nest/notifications/push/push-subscriptions.service';
import {
PUSH_UNAVAILABLE_ERROR,
VAPID_PRIVATE_KEY_SETTING,
VAPID_PUBLIC_KEY_SETTING,
VapidKeysService,
} from '../../../../src/nest/notifications/push/vapid-keys.service';
import { PushController } from '../../../../src/nest/notifications/push/push.controller';
import { checkPushSubscription } from '../../../../src/nest/notifications/push/push-subscription.helpers';
import { generateVapidKeyPair } from '../../../../src/nest/notifications/push/web-push-crypto';
import {
FORBIDDEN_FAILURES_BEFORE_REMOVAL,
MAX_PUSH_PAYLOAD_BYTES,
WebPushService,
buildPushPayload,
samePagePath,
} from '../../../../src/nest/notifications/transports/web-push.service';
import { ALL_EVENT_TYPES, type ChannelMessage } from '../../../../src/nest/notifications/notification-events';
const dbs = new DatabaseService(testDb);
const keys = new VapidKeysService(dbs);
const subscriptions = new PushSubscriptionsService(dbs);
const push = new WebPushService(keys, subscriptions);
const MSG: ChannelMessage = {
event: 'collab_message',
title: 'New message in "Lisbon"',
body: 'alice: see you at the station',
navigateTarget: '/trips/12',
url: 'https://trek.example.test/trips/12',
};
interface Device {
endpoint: string;
ua: ECDH;
auth: Buffer;
}
function subscribe(userId: number, endpoint: string): Device {
const ua = createECDH('prime256v1');
ua.generateKeys();
const auth = Buffer.from('fedcba9876543210');
const checked = checkPushSubscription({
endpoint,
keys: { p256dh: ua.getPublicKey('base64url'), auth: auth.toString('base64url') },
});
if ('error' in checked) throw new Error(checked.error);
subscriptions.upsert(userId, checked.value, keys.getPublicKey());
return { endpoint, ua, auth };
}
/** The browser's side of RFC 8291, to read what was actually sent. */
function open(body: Uint8Array, device: Device): unknown {
const buf = Buffer.from(body);
const salt = buf.subarray(0, 16);
const keyId = buf.subarray(21, 21 + buf[20]);
const record = buf.subarray(21 + buf[20]);
const secret = device.ua.computeSecret(keyId);
const info = Buffer.concat([Buffer.from('WebPush: info\0'), device.ua.getPublicKey(), keyId]);
const ikm = Buffer.from(hkdfSync('sha256', secret, device.auth, info, 32));
const cek = Buffer.from(hkdfSync('sha256', ikm, salt, Buffer.from('Content-Encoding: aes128gcm\0'), 16));
const nonce = Buffer.from(hkdfSync('sha256', ikm, salt, Buffer.from('Content-Encoding: nonce\0'), 12));
const decipher = createDecipheriv('aes-128-gcm', cek, nonce);
decipher.setAuthTag(record.subarray(record.length - 16));
const padded = Buffer.concat([decipher.update(record.subarray(0, record.length - 16)), decipher.final()]);
return JSON.parse(padded.subarray(0, padded.lastIndexOf(0x02)).toString('utf8'));
}
function reply(status: number, text = '') {
return {
ok: status >= 200 && status < 300,
status,
body: null,
headers: { get: () => null },
text: async () => text,
};
}
function rows(userId: number) {
return subscriptions.listForUser(userId);
}
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
safeFetchFollow.mockReset();
logInfo.mockClear();
logError.mockClear();
});
afterAll(() => {
testDb.close();
});
describe('WebPushService delivery', () => {
it('WPUSH-001: posts one encrypted, VAPID-signed message through the SSRF guard', async () => {
const { user } = createUser(testDb);
const device = subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/abc');
safeFetchFollow.mockResolvedValue(reply(201));
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(true);
expect(safeFetchFollow).toHaveBeenCalledTimes(1);
const [url, init, options] = safeFetchFollow.mock.calls[0];
expect(url).toBe(device.endpoint);
expect(options).toEqual({ maxRedirects: 0, bypassInternalIpAllowed: true });
expect(init.method).toBe('POST');
expect(init.signal).toBeInstanceOf(AbortSignal);
expect(init.headers).toMatchObject({
TTL: '86400',
Urgency: 'normal',
'Content-Encoding': 'aes128gcm',
'Content-Type': 'application/octet-stream',
});
expect(init.headers.Authorization).toMatch(
new RegExp(`^vapid t=[\\w-]+\\.[\\w-]+\\.[\\w-]+, k=${keys.getPublicKey()}$`),
);
expect(open(init.body, device)).toEqual({
title: MSG.title,
body: MSG.body,
url: '/trips/12',
tag: 'collab_message:/trips/12',
});
const [row] = rows(user.id);
expect(row.last_success_at).not.toBeNull();
expect(row.failure_count).toBe(0);
});
it('WPUSH-002: date-bound events go out with high urgency', async () => {
const { user } = createUser(testDb);
subscribe(user.id, 'https://web.push.apple.com/QGx');
safeFetchFollow.mockResolvedValue(reply(201));
await push.sendToUser(user.id, { ...MSG, event: 'trip_reminder' });
expect(safeFetchFollow.mock.calls[0][1].headers.Urgency).toBe('high');
});
it('WPUSH-003: 404 and 410 mean the browser is gone, and the row goes with it', async () => {
const { user } = createUser(testDb);
subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/expired');
subscribe(user.id, 'https://updates.push.services.mozilla.com/wpush/v2/revoked');
safeFetchFollow.mockImplementation(async (url: string) => reply(url.includes('expired') ? 404 : 410));
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
expect(rows(user.id)).toEqual([]);
expect(logError).not.toHaveBeenCalled();
});
it('WPUSH-004: a first 403 with Apple’s BadJwtToken keeps the device, counts it and logs the reason', async () => {
// Apple answers 403 for a token it rejects, which is the server's problem
// (subject, clock, key), not a sign the device is gone.
const { user } = createUser(testDb);
subscribe(user.id, 'https://web.push.apple.com/QGx');
safeFetchFollow.mockResolvedValue(reply(403, '{"reason":"BadJwtToken"}'));
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
expect(rows(user.id)).toHaveLength(1);
expect(rows(user.id)[0].failure_count).toBe(1);
const line = logError.mock.calls[0][0] as string;
expect(line).toContain('HTTP 403');
expect(line).toContain('BadJwtToken');
expect(line).toContain('host=web.push.apple.com');
// The endpoint is a capability URL: the log names the host only.
expect(line).not.toContain('/QGx');
});
it(`WPUSH-004b: the device goes after ${FORBIDDEN_FAILURES_BEFORE_REMOVAL} failed sends in a row ending in 403`, async () => {
const { user } = createUser(testDb);
subscribe(user.id, 'https://web.push.apple.com/QGx');
safeFetchFollow.mockResolvedValue(reply(403, '{"reason":"BadJwtToken"}'));
for (let i = 1; i < FORBIDDEN_FAILURES_BEFORE_REMOVAL; i++) {
await push.sendToUser(user.id, MSG);
expect(rows(user.id)[0].failure_count).toBe(i);
}
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
expect(rows(user.id)).toEqual([]);
expect(logError).toHaveBeenLastCalledWith(expect.stringContaining('removed a device'));
expect(logError).toHaveBeenLastCalledWith(expect.stringContaining('BadJwtToken'));
});
it('WPUSH-004c: a success between refusals starts the count over', async () => {
const { user } = createUser(testDb);
subscribe(user.id, 'https://web.push.apple.com/QGx');
safeFetchFollow.mockResolvedValue(reply(403, '{"reason":"BadJwtToken"}'));
for (let i = 1; i < FORBIDDEN_FAILURES_BEFORE_REMOVAL; i++) await push.sendToUser(user.id, MSG);
safeFetchFollow.mockResolvedValueOnce(reply(201));
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(true);
expect(rows(user.id)[0].failure_count).toBe(0);
await push.sendToUser(user.id, MSG);
expect(rows(user.id)).toHaveLength(1);
expect(rows(user.id)[0].failure_count).toBe(1);
});
it('WPUSH-005: any other failure keeps the row, counts it and logs the capped body', async () => {
const { user } = createUser(testDb);
subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/busy');
safeFetchFollow.mockResolvedValue(reply(503, `overloaded ${'x'.repeat(5000)}`));
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
expect(rows(user.id)[0].failure_count).toBe(1);
const line = logError.mock.calls[0][0] as string;
expect(line).toContain('HTTP 503');
expect(line.length).toBeLessThan(1300);
// A body that cannot be read changes nothing but the log text.
safeFetchFollow.mockResolvedValueOnce({
...reply(500),
text: async () => {
throw new Error('stream reset');
},
});
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
expect(rows(user.id)[0].failure_count).toBe(2);
expect(logError).toHaveBeenLastCalledWith(expect.stringMatching(/HTTP 500 .*host=fcm\.googleapis\.com: $/));
});
it('WPUSH-006: a refused or failed request is a failure, never a throw', async () => {
const { user } = createUser(testDb);
subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/a');
safeFetchFollow.mockRejectedValueOnce(new SsrfBlockedError('Requests to private addresses are not allowed'));
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
expect(logError).toHaveBeenLastCalledWith(expect.stringContaining('blocked by SSRF guard'));
safeFetchFollow.mockRejectedValueOnce(new Error('The operation was aborted due to timeout'));
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
expect(logError).toHaveBeenLastCalledWith(expect.stringContaining('timeout'));
expect(rows(user.id)[0].failure_count).toBe(2);
});
it('WPUSH-007: one device getting it is enough', async () => {
const { user } = createUser(testDb);
subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/ok');
subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/down');
safeFetchFollow.mockImplementation(async (url: string) => reply(url.endsWith('/ok') ? 201 : 500));
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(true);
// Both hosts are the same origin, so one token served both requests.
const [a, b] = safeFetchFollow.mock.calls.map((c) => c[1].headers.Authorization);
expect(a).toBe(b);
});
it('WPUSH-008: a row made for a previous server key is dropped without a request', async () => {
const { user } = createUser(testDb);
subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/old');
testDb.prepare("UPDATE push_subscriptions SET vapid_public_key = 'previous-key'").run();
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
expect(safeFetchFollow).not.toHaveBeenCalled();
expect(rows(user.id)).toEqual([]);
});
it('WPUSH-009: a row whose host is not a push service any more is dropped without a request', async () => {
const { user } = createUser(testDb);
subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/x');
subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/y');
testDb
.prepare("UPDATE push_subscriptions SET endpoint = 'https://attacker.example.test/collect' WHERE endpoint LIKE '%/x'")
.run();
testDb.prepare("UPDATE push_subscriptions SET endpoint = 'not a url' WHERE endpoint LIKE '%/y'").run();
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
expect(safeFetchFollow).not.toHaveBeenCalled();
expect(rows(user.id)).toEqual([]);
expect(logError).toHaveBeenCalledWith(expect.stringContaining('host=attacker.example.test'));
// Not even a URL: the log still has something to name instead of the value.
expect(logError).toHaveBeenCalledWith(expect.stringContaining('not a known push service user'));
expect(logError).toHaveBeenCalledWith(expect.stringContaining('host=invalid-endpoint'));
});
it('WPUSH-010: broken stored keys count as a failure for that row', async () => {
const { user } = createUser(testDb);
subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/x');
testDb.prepare("UPDATE push_subscriptions SET auth = '***'").run();
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
expect(safeFetchFollow).not.toHaveBeenCalled();
expect(rows(user.id)[0].failure_count).toBe(1);
});
it('WPUSH-011: nothing to send to is simply false', async () => {
const { user } = createUser(testDb);
expect(push.hasDevices(user.id)).toBe(false);
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
expect(safeFetchFollow).not.toHaveBeenCalled();
});
it('WPUSH-012: a failure before any request (no key pair) is logged and false', async () => {
const { user } = createUser(testDb);
subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/x');
const spy = vi.spyOn(keys, 'getKeys').mockImplementationOnce(() => {
throw new Error('database is locked');
});
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
expect(logError).toHaveBeenCalledWith(expect.stringContaining('database is locked'));
spy.mockRestore();
});
it('WPUSH-012b: while the stored key pair cannot be used nothing is sent and every device stays', async () => {
const { user } = createUser(testDb);
subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/x');
const publicKey = keys.getPublicKey();
// A restore under a different ENCRYPTION_KEY: the ciphertext no longer opens.
testDb
.prepare("UPDATE app_settings SET value = 'enc:v1:bm90IGEgY2lwaGVydGV4dA' WHERE key = ?")
.run(VAPID_PRIVATE_KEY_SETTING);
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
expect(safeFetchFollow).not.toHaveBeenCalled();
expect(rows(user.id)).toHaveLength(1);
expect(rows(user.id)[0].vapid_public_key).toBe(publicKey);
expect(rows(user.id)[0].failure_count).toBe(0);
expect(push.isAvailable()).toBe(false);
// The test send says why instead of a bare failure, and sends nothing either.
await expect(push.sendTest(user.id)).resolves.toEqual({ success: false, error: PUSH_UNAVAILABLE_ERROR });
expect(safeFetchFollow).not.toHaveBeenCalled();
});
it('WPUSH-012c: an install that only ran on VAPID_* generates nothing when that pair breaks, and keeps every device', async () => {
const pair = generateVapidKeyPair();
vi.stubEnv('VAPID_PUBLIC_KEY', pair.publicKey);
vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey);
try {
const { user } = createUser(testDb);
subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/x');
subscribe(user.id, 'https://web.push.apple.com/y');
// An existingSecret without the private half: the variable is simply gone.
vi.stubEnv('VAPID_PRIVATE_KEY', '');
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
await expect(push.sendTest(user.id)).resolves.toEqual({ success: false, error: PUSH_UNAVAILABLE_ERROR });
expect(push.isAvailable()).toBe(false);
expect(safeFetchFollow).not.toHaveBeenCalled();
expect(testDb.prepare("SELECT key FROM app_settings WHERE key LIKE 'web_push_vapid_%'").all()).toEqual([]);
expect(rows(user.id).map((row) => row.vapid_public_key)).toEqual([pair.publicKey, pair.publicKey]);
// The Secret fixed: the same devices receive again, nobody turns push on again.
vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey);
safeFetchFollow.mockResolvedValue(reply(201));
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(true);
expect(safeFetchFollow).toHaveBeenCalledTimes(2);
expect(rows(user.id)).toHaveLength(2);
} finally {
vi.unstubAllEnvs();
}
});
it('WPUSH-012d: a broken VAPID_* pair over a pair stored from before sends nothing, keeps every device and serves no key', async () => {
// The first start without VAPID_* stored a pair; the operator then brought their own.
const storedKey = keys.getPublicKey();
const pair = generateVapidKeyPair();
vi.stubEnv('VAPID_PUBLIC_KEY', pair.publicKey);
vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey);
const controller = new PushController(keys, subscriptions);
try {
const { user } = createUser(testDb);
subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/x');
subscribe(user.id, 'https://web.push.apple.com/y');
// An existingSecret rotated without the private half.
vi.stubEnv('VAPID_PRIVATE_KEY', '');
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
await expect(push.sendTest(user.id)).resolves.toEqual({ success: false, error: PUSH_UNAVAILABLE_ERROR });
expect(push.isAvailable()).toBe(false);
expect(safeFetchFollow).not.toHaveBeenCalled();
// Signing with the stored pair would have dropped both rows; they stay on the environment key.
expect(rows(user.id).map((row) => row.vapid_public_key)).toEqual([pair.publicKey, pair.publicKey]);
expect(testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get(VAPID_PUBLIC_KEY_SETTING)).toEqual({
value: storedKey,
});
// Browsers are not handed the stored key to subscribe again with either.
let refusal: unknown = null;
try {
controller.publicKey();
} catch (err) {
refusal = err;
}
expect(refusal).toBeInstanceOf(HttpException);
expect((refusal as HttpException).getStatus()).toBe(503);
// The Secret fixed: the same rows receive again, and the key served is the one they hold.
vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey);
safeFetchFollow.mockResolvedValue(reply(201));
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(true);
expect(safeFetchFollow).toHaveBeenCalledTimes(2);
expect(rows(user.id)).toHaveLength(2);
expect(controller.publicKey()).toEqual({ publicKey: pair.publicKey });
} finally {
vi.unstubAllEnvs();
}
});
it('WPUSH-013: the test send reports success and failure like the other channels', async () => {
const { user } = createUser(testDb);
const device = subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/x');
safeFetchFollow.mockResolvedValueOnce(reply(201));
expect(push.isAvailable()).toBe(true);
await expect(push.sendTest(user.id)).resolves.toEqual({ success: true });
const sent = open(safeFetchFollow.mock.calls[0][1].body, device);
expect(sent).toMatchObject({ title: 'Test Notification', url: '/settings?tab=notifications' });
// Only chat collapses; a second test is a second notification.
expect(sent).not.toHaveProperty('tag');
safeFetchFollow.mockResolvedValueOnce(reply(500));
await expect(push.sendTest(user.id)).resolves.toEqual({
success: false,
error: 'Failed to send push notification',
});
});
});
describe('buildPushPayload', () => {
const size = (p: unknown) => Buffer.byteLength(JSON.stringify(p), 'utf8');
it('WPUSH-PAY-001: passes a short message through; chat collapses per trip', () => {
expect(buildPushPayload(MSG)).toEqual({
title: MSG.title,
body: MSG.body,
url: '/trips/12',
tag: 'collab_message:/trips/12',
});
// Without a trip to collapse on there is no tag at all, never one for every chat everywhere.
expect(buildPushPayload({ ...MSG, navigateTarget: undefined })).toEqual({
title: MSG.title,
body: MSG.body,
});
});
it('WPUSH-PAY-001b: every other event has no tag, so two of them never replace each other', () => {
const others = ALL_EVENT_TYPES.filter((event) => event !== 'collab_message');
expect(others.length).toBeGreaterThan(5);
for (const event of others) {
const payload = buildPushPayload({ ...MSG, event });
expect(payload, event).toEqual({ title: MSG.title, body: MSG.body, url: '/trips/12' });
expect(payload, event).not.toHaveProperty('tag');
}
});
it('WPUSH-PAY-002: cuts a long body at a character boundary until the payload fits', () => {
const payload = buildPushPayload({ ...MSG, body: '🚄'.repeat(5000) });
expect(size(payload)).toBeLessThanOrEqual(MAX_PUSH_PAYLOAD_BYTES);
expect(size(payload)).toBeGreaterThan(MAX_PUSH_PAYLOAD_BYTES - 8);
expect(payload.body.endsWith('…')).toBe(true);
expect(payload.body.slice(0, -1)).toMatch(/^(🚄)+$/u);
});
it('WPUSH-PAY-003: characters JSON has to escape are counted as what they cost', () => {
const payload = buildPushPayload({ ...MSG, title: '\u0001'.repeat(500), body: '"\u0002'.repeat(3000) });
expect(size(payload)).toBeLessThanOrEqual(MAX_PUSH_PAYLOAD_BYTES);
expect(Array.from(payload.title)).toHaveLength(120);
expect(payload.body.length).toBeGreaterThan(0);
});
it('WPUSH-PAY-004: only same-origin paths reach the worker', () => {
expect(samePagePath('/trips/5')).toBe('/trips/5');
expect(samePagePath('/admin?tab=storage')).toBe('/admin?tab=storage');
for (const target of [
'https://evil.test/x',
'//evil.test/x',
'/\\evil.test',
'trips/5',
'/trips/5 x',
`/${'a'.repeat(300)}`,
'',
undefined,
]) {
expect(samePagePath(target), String(target)).toBeUndefined();
}
expect(buildPushPayload({ ...MSG, navigateTarget: 'https://evil.test/' })).not.toHaveProperty('url');
});
});
+3
View File
@@ -720,6 +720,9 @@ const admin: TranslationStrings = {
'admin.notifications.ntfy': 'Ntfy', // en-fallback
'admin.notifications.emailPanel.title': 'Email (SMTP)', // en-fallback
'admin.notifications.webhookPanel.title': 'Webhook', // en-fallback
'admin.notifications.webPushPanel.title': 'إشعارات الويب الفورية',
'admin.notifications.webPushPanel.hint':
'تتيح للمستخدمين تلقي الإشعارات على هواتفهم وحواسيبهم عبر المتصفح، حتى عندما يكون TREK مغلقًا. يتطلب HTTPS؛ وعلى iPhone وiPad يجب إضافة TREK إلى الشاشة الرئيسية.',
'admin.notifications.inappPanel.title': 'In-App', // en-fallback
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh', // en-fallback
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts', // en-fallback
+5 -3
View File
@@ -960,11 +960,13 @@ const help: TranslationStrings = {
'«وضع الجودة العالية» لتنعيم الحواف وإسقاط الكرة الأرضية؛ و«حفظ الخريطة» يثبّت الاختيار.',
'help.ctx.settings-notifications.title': 'الإشعارات',
'help.ctx.settings-notifications.summary':
'أين يصلك TREK خارج التطبيق: موضوع ntfy، أو webhook، أو قناة توفرها إضافة. تحت القنوات، صف لكل حدث يحدد ما يذهب إلى أين.',
'أين يصلك TREK خارج التطبيق: إشعارات فورية على هذا الجهاز، أو موضوع ntfy، أو webhook، أو قناة توفرها إضافة. تحت القنوات، صف لكل حدث يحدد ما يذهب إلى أين.',
'help.ctx.settings-notifications.bullet.1':
'ntfy: الموضوع، وخادم خاص بك اختياري، ورمز وصول اختياري، مع «اختبار» لإرسال رسالة على الفور.',
'help.ctx.settings-notifications.bullet.2': 'Webhook: عنوان URL واحد يستقبل كل حدث بصيغة JSON، مع «اختبار».',
'help.ctx.settings-notifications.bullet.3':
'الإشعارات الفورية على هذا الجهاز: «تشغيل على هذا الجهاز» يشمل المتصفح الذي تستخدمه فقط، لذا كرّره على كل هاتف أو حاسوب. «إرسال اختبار» يصل إليها جميعًا.',
'help.ctx.settings-notifications.bullet.4':
'صفوف التفضيلات: لكل حدث، أي قناة مفعّلة. تعرض قنوات الإضافات «تهيئة» حتى يتم إعدادها.',
'help.ctx.settings-integrations.title': 'التكاملات',
'help.ctx.settings-integrations.summary':
@@ -1266,7 +1268,7 @@ const help: TranslationStrings = {
'help.ctx.admin-notifications.summary':
'القنوات التي تقدمها النسخة لمستخدميها، والقنوات التي تصلك أنت بصفتك المسؤول. يختار المستخدمون مواضيعهم وعناوين URL الخاصة بهم في «الإعدادات»؛ وأنت تقرر ما يوجد وتهيّئ البريد الإلكتروني.',
'help.ctx.admin-notifications.bullet.1':
'«In-App» و«Email (SMTP)» و«Ntfy» و«Webhook»: لوحة لكل منها، مع مفتاح يقدّم القناة للمستخدمين، والتهيئة التي تحتاجها من جهة الخادم.',
'«In-App» و«Email (SMTP)» و«Ntfy» و«Webhook» و«إشعارات الويب الفورية»: لوحة لكل منها، مع مفتاح يقدّم القناة للمستخدمين، والتهيئة التي تحتاجها من جهة الخادم.',
'help.ctx.admin-notifications.bullet.2': '«تذكيرات الرحلات»: هل يرسل الخادم التذكير قبل بدء الرحلة.',
'help.ctx.admin-notifications.bullet.3':
'«Ntfy المسؤول» و«Webhook المسؤول»: وجهة أحداث المسؤول مثل فشل نسخة احتياطية أو صدور إصدار جديد، مع «اختبار».',
@@ -1482,7 +1484,7 @@ const help: TranslationStrings = {
'help.guide.channels-instance.step.1':
'تحت «Email (SMTP)»، أدخل SMTP Host وSMTP Port وSMTP User وSMTP Password وFrom Address؛ و«إرسال بريد تجريبي» يرسل رسالة إليك.',
'help.guide.channels-instance.step.2':
'شغّل «Ntfy» و«Webhook» لتقديمهما؛ ثم يدخل المستخدمون موضوعهم أو عنوان URL الخاص بهم في «الإعدادات»، «الإشعارات».',
'شغّل «إشعارات الويب الفورية» و«Ntfy» و«Webhook» لتقديمها؛ ثم يشغّل المستخدمون الإشعارات الفورية لكل جهاز، أو يدخلون موضوعهم أو عنوان URL الخاص بهم، في «الإعدادات»، «الإشعارات».',
'help.guide.channels-instance.step.3':
'«تذكيرات الرحلات» تشغّل التذكير قبل بدء الرحلة؛ و«In-App» مفعّل دائمًا ويُشرح هنا فحسب.',
'help.guide.channels-instance.result': 'يعرض تبويب «الإشعارات» لدى كل مستخدم القنوات التي شغّلتها.',
+12
View File
@@ -300,6 +300,18 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.testFailed': 'فشل الاختبار.',
'settings.notificationPreferences.pluginConfigured': 'تم الإعداد. أدر بيانات الاعتماد من صفحة إعدادات الإضافة.',
'settings.notificationPreferences.ntfy': 'Ntfy', // en-fallback
'settings.notificationPreferences.push': 'إشعارات فورية',
'settings.webPush.title': 'الإشعارات الفورية على هذا الجهاز',
'settings.webPush.hint':
'يعرض إشعارات TREK على هذا الجهاز حتى عندما يكون TREK مغلقًا. الأحداث التي تصل تتبع عمود «إشعارات فورية».',
'settings.webPush.enable': 'تشغيل على هذا الجهاز',
'settings.webPush.disable': 'إيقاف على هذا الجهاز',
'settings.webPush.enabled': 'مُفعّل على هذا الجهاز',
'settings.webPush.unsupported': 'لا يمكن لهذا المتصفح تلقي الإشعارات الفورية.',
'settings.webPush.insecure': 'تتطلب الإشعارات الفورية فتح TREK عبر HTTPS.',
'settings.webPush.iosInstall': 'على iPhone وiPad، أضف TREK أولًا إلى الشاشة الرئيسية ثم افتحه من هناك.',
'settings.webPush.denied': 'الإشعارات محظورة لـ TREK في هذا المتصفح. اسمح بها في إعدادات المتصفح، ثم حاول مرة أخرى.',
'settings.webPush.failed': 'تعذّر تشغيل الإشعارات الفورية على هذا الجهاز.',
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', // en-fallback
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts', // en-fallback
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh', // en-fallback
+3
View File
@@ -19,6 +19,9 @@ const admin: TranslationStrings = {
'admin.notifications.testNtfyFailed': 'Sınaq ntfy bildirişi uğursuz oldu',
'admin.notifications.emailPanel.title': 'E-poçt (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.webPushPanel.title': 'Web Push',
'admin.notifications.webPushPanel.hint':
'İstifadəçilərə TREK bağlı olanda belə brauzer vasitəsilə telefon və kompüterlərində bildiriş almağa imkan verir. HTTPS tələb olunur; iPhone və iPad-də TREK Əsas ekrana əlavə edilməlidir.',
'admin.notifications.inappPanel.title': 'Tətbiqdaxili',
'admin.notifications.inappPanel.hint':
'Tətbiqdaxili bildirişlər həmişə aktivdir və ümumi şəkildə deaktiv edilə bilməz.',
+5 -3
View File
@@ -1085,11 +1085,13 @@ const help: TranslationStrings = {
'Hamarlaşdırma və qlobus proyeksiyası üçün Yüksək keyfiyyət rejimi; Xəritəni yadda saxla seçimi saxlayır.',
'help.ctx.settings-notifications.title': 'Bildirişlər',
'help.ctx.settings-notifications.summary':
'TREK-in tətbiqdən kənarda sizə harada çatdığı: ntfy mövzusu, webhook və ya plaginin təqdim etdiyi kanal. Kanalların altında hər hadisə üçün bir sətir nəyin hara getdiyini müəyyən edir.',
'TREK-in tətbiqdən kənarda sizə harada çatdığı: bu cihazda push bildirişləri, ntfy mövzusu, webhook və ya plaginin təqdim etdiyi kanal. Kanalların altında hər hadisə üçün bir sətir nəyin hara getdiyini müəyyən edir.',
'help.ctx.settings-notifications.bullet.1':
'ntfy: mövzu, istəyə bağlı öz serveriniz və istəyə bağlı giriş tokeni, dərhal test göndərmək üçün Sına düyməsi ilə.',
'help.ctx.settings-notifications.bullet.2': 'Webhook: hər hadisəni JSON kimi qəbul edən bir URL, Sına düyməsi ilə.',
'help.ctx.settings-notifications.bullet.3':
'Bu cihazda push bildirişləri: Bu cihaz üçün aktiv et yalnız istifadə etdiyiniz brauzerə aiddir, ona görə də bunu hər telefonda və ya kompüterdə təkrarlayın. Test göndər hamısına çatır.',
'help.ctx.settings-notifications.bullet.4':
'Tənzimləmə sətirləri: hər hadisə üçün hansı kanalın aktiv olduğu. Plagin kanalları qurulana qədər Konfiqurasiya et göstərir.',
'help.ctx.settings-integrations.title': 'İnteqrasiyalar',
'help.ctx.settings-integrations.summary':
@@ -1418,7 +1420,7 @@ const help: TranslationStrings = {
'help.ctx.admin-notifications.summary':
'İnstansiyanın istifadəçilərə təklif etdiyi kanallar və sizə administrator kimi çatan kanallar. İstifadəçilər öz mövzularını və URL-lərini Tənzimləmələr bölməsində seçir; nəyin mövcud olacağını siz müəyyən edirsiniz və e-poçtu siz konfiqurasiya edirsiniz.',
'help.ctx.admin-notifications.bullet.1':
'Tətbiqdaxili, E-poçt (SMTP), Ntfy və Webhook: hər biri üçün kanalı istifadəçilərə təklif edən açarı və tələb etdiyi server tərəfi konfiqurasiyası olan ayrıca panel.',
'Tətbiqdaxili, E-poçt (SMTP), Ntfy, Webhook və Web Push: hər biri üçün kanalı istifadəçilərə təklif edən açarı və tələb etdiyi server tərəfi konfiqurasiyası olan ayrıca panel.',
'help.ctx.admin-notifications.bullet.2':
'Səyahət xatırlatmaları: serverin səyahət başlamazdan əvvəl xatırlatma göndərib-göndərməməsi.',
'help.ctx.admin-notifications.bullet.3':
@@ -1687,7 +1689,7 @@ const help: TranslationStrings = {
'help.guide.channels-instance.step.1':
'E-poçt (SMTP) bölməsində SMTP hostu, SMTP portu, SMTP istifadəçisi, SMTP parolu və Göndərən ünvanı daxil edin; Sınaq e-poçtu göndər sizə məktub göndərir.',
'help.guide.channels-instance.step.2':
'Ntfy və Webhook-u təklif etmək üçün onları aktiv edin; sonra istifadəçilər Tənzimləmələr, Bildirişlər bölməsində öz mövzularını və ya URL-lərini daxil edirlər.',
'Web Push, Ntfy və Webhook-u təklif etmək üçün onları aktiv edin; sonra istifadəçilər Tənzimləmələr, Bildirişlər bölməsində push-u hər cihaz üçün aktiv edir və ya öz mövzularını və ya URL-lərini daxil edirlər.',
'help.guide.channels-instance.step.3':
'Səyahət xatırlatmaları səyahət başlamazdan əvvəlki xatırlatmanı aktiv və ya deaktiv edir; Tətbiqdaxili həmişə aktivdir və burada yalnız izah olunur.',
'help.guide.channels-instance.result':
+13
View File
@@ -163,6 +163,19 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.pluginConfigured':
'Konfiqurasiya edilib. Giriş məlumatlarını plaginin tənzimləmələr səhifəsindən idarə edin.',
'settings.notificationPreferences.ntfy': 'Ntfy',
'settings.notificationPreferences.push': 'Push',
'settings.webPush.title': 'Bu cihazda push bildirişləri',
'settings.webPush.hint':
'TREK bağlı olanda belə TREK bildirişlərini bu cihazda göstərir. Hansı hadisələrin gəldiyini Push sütunu müəyyən edir.',
'settings.webPush.enable': 'Bu cihaz üçün aktiv et',
'settings.webPush.disable': 'Bu cihaz üçün deaktiv et',
'settings.webPush.enabled': 'Bu cihaz üçün aktivdir',
'settings.webPush.unsupported': 'Bu brauzer push bildirişlərini qəbul edə bilmir.',
'settings.webPush.insecure': 'Push üçün TREK HTTPS vasitəsilə açılmalıdır.',
'settings.webPush.iosInstall': 'iPhone və iPad-də əvvəlcə TREK-i Əsas ekrana əlavə edin və onu oradan açın.',
'settings.webPush.denied':
'Bu brauzerdə TREK üçün bildirişlər bloklanıb. Onlara brauzer tənzimləmələrində icazə verin, sonra yenidən cəhd edin.',
'settings.webPush.failed': 'Bu cihaz üçün push aktiv edilə bilmədi.',
'settings.notificationPreferences.noChannels':
'Heç bir bildiriş kanalı konfiqurasiya edilməyib. Administratordan e-poçt və ya webhook bildirişlərini quraşdırmasını istəyin.',
+3
View File
@@ -714,6 +714,9 @@ const admin: TranslationStrings = {
'admin.oauthSessions.loadError': 'Falha ao carregar sessões OAuth',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.webPushPanel.title': 'Web Push',
'admin.notifications.webPushPanel.hint':
'Permite que os usuários recebam notificações no celular e no computador pelo navegador, mesmo com o TREK fechado. Requer HTTPS; no iPhone e no iPad, o TREK precisa ser adicionado à Tela de Início.',
'admin.notifications.inappPanel.title': 'In-App',
'admin.notifications.inappPanel.hint':
'As notificações no aplicativo estão sempre ativas e não podem ser desativadas globalmente.',
+5 -3
View File
@@ -1051,11 +1051,13 @@ const help: TranslationStrings = {
'Modo alta qualidade para antialiasing e a projeção de globo; Salvar mapa grava a escolha.',
'help.ctx.settings-notifications.title': 'Notificações',
'help.ctx.settings-notifications.summary':
'Onde o TREK alcança você fora do app: um tópico ntfy, um webhook ou um canal que um plugin fornece. Abaixo dos canais, uma linha por evento decide o que vai para onde.',
'Onde o TREK alcança você fora do app: notificações push neste dispositivo, um tópico ntfy, um webhook ou um canal que um plugin fornece. Abaixo dos canais, uma linha por evento decide o que vai para onde.',
'help.ctx.settings-notifications.bullet.1':
'ntfy: o tópico, um servidor próprio opcional e um token de acesso opcional, com Testar para enviar um na hora.',
'help.ctx.settings-notifications.bullet.2': 'Webhook: uma URL que recebe cada evento como JSON, com Testar.',
'help.ctx.settings-notifications.bullet.3':
'Notificações push neste dispositivo: Ativar neste dispositivo vale só para o navegador que você está usando, então repita em cada celular ou computador. Enviar teste chega a todos eles.',
'help.ctx.settings-notifications.bullet.4':
'As linhas de preferências: por evento, qual canal está ligado. Canais de plugin mostram Configurar até serem configurados.',
'help.ctx.settings-integrations.title': 'Integrações',
'help.ctx.settings-integrations.summary':
@@ -1377,7 +1379,7 @@ const help: TranslationStrings = {
'help.ctx.admin-notifications.summary':
'Os canais que a instância oferece aos usuários, e os que chegam até você como admin. Os usuários escolhem seus próprios tópicos e URLs em Configurações; você decide o que existe e configura o e-mail.',
'help.ctx.admin-notifications.bullet.1':
'In-App, Email (SMTP), Ntfy e Webhook: um painel para cada, com uma chave que oferece o canal aos usuários e a configuração do lado do servidor de que ele precisa.',
'In-App, Email (SMTP), Ntfy, Webhook e Web Push: um painel para cada, com uma chave que oferece o canal aos usuários e a configuração do lado do servidor de que ele precisa.',
'help.ctx.admin-notifications.bullet.2':
'Lembretes de viagem: se o servidor envia o lembrete antes de uma viagem começar.',
'help.ctx.admin-notifications.bullet.3':
@@ -1620,7 +1622,7 @@ const help: TranslationStrings = {
'help.guide.channels-instance.step.1':
'Em Email (SMTP), informe SMTP Host, SMTP Port, SMTP User, SMTP Password e a From Address; Enviar e-mail de teste manda um e-mail para você.',
'help.guide.channels-instance.step.2':
'Ligue Ntfy e Webhook para oferecê-los; os usuários então informam o próprio tópico ou URL em Configurações, Notificações.',
'Ligue Web Push, Ntfy e Webhook para oferecê-los; os usuários então ativam o push em cada dispositivo, ou informam o próprio tópico ou URL, em Configurações, Notificações.',
'help.guide.channels-instance.step.3':
'Lembretes de viagem controla o lembrete antes de uma viagem começar; In-App está sempre ligado e aqui só é explicado.',
'help.guide.channels-instance.result': 'A aba Notificações de cada usuário mostra os canais que você ligou.',
+13
View File
@@ -322,6 +322,19 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'settings.notificationPreferences.ntfy': 'Ntfy',
'settings.notificationPreferences.push': 'Push',
'settings.webPush.title': 'Notificações push neste dispositivo',
'settings.webPush.hint':
'Mostra as notificações do TREK neste dispositivo mesmo com o TREK fechado. Os eventos que chegam seguem a coluna Push.',
'settings.webPush.enable': 'Ativar neste dispositivo',
'settings.webPush.disable': 'Desativar neste dispositivo',
'settings.webPush.enabled': 'Ativado neste dispositivo',
'settings.webPush.unsupported': 'Este navegador não pode receber notificações push.',
'settings.webPush.insecure': 'O push exige que o TREK seja aberto via HTTPS.',
'settings.webPush.iosInstall': 'No iPhone e no iPad, adicione primeiro o TREK à Tela de Início e abra-o por lá.',
'settings.webPush.denied':
'As notificações do TREK estão bloqueadas neste navegador. Permita-as nas configurações do navegador e tente novamente.',
'settings.webPush.failed': 'Não foi possível ativar o push neste dispositivo.',
'settings.currency': 'Moeda de exibição',
'settings.currencyHint':
'Os valores em Custos são exibidos convertidos para esta moeda apenas para visualização — os valores originais permanecem inalterados.',
+3
View File
@@ -361,6 +361,9 @@ const admin: TranslationStrings = {
'admin.group.maintenance': 'Manteniment',
'admin.notifications.emailPanel.title': 'Correu (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.webPushPanel.title': 'Web Push',
'admin.notifications.webPushPanel.hint':
"Permet als usuaris rebre notificacions als telèfons i ordinadors a través del navegador, fins i tot amb TREK tancat. Cal HTTPS; a l'iPhone i l'iPad, TREK s'ha d'afegir a la pantalla d'inici.",
'admin.notifications.inappPanel.title': 'In-App',
'admin.notifications.inappPanel.hint':
'Les notificacions in-app sempre estan actives i no es poden desactivar globalment.',
+5 -3
View File
@@ -1068,11 +1068,13 @@ const help: TranslationStrings = {
"Mode d'alta qualitat per a l’antialiàsing i la projecció de globus; Desa el mapa escriu la tria.",
'help.ctx.settings-notifications.title': 'Notificacions',
'help.ctx.settings-notifications.summary':
'On et troba TREK fora de l’app: un tema de ntfy, un webhook o un canal que aporta un connector. Sota els canals, una fila per esdeveniment decideix què va on.',
'On et troba TREK fora de l’app: notificacions push en aquest dispositiu, un tema de ntfy, un webhook o un canal que aporta un connector. Sota els canals, una fila per esdeveniment decideix què va on.',
'help.ctx.settings-notifications.bullet.1':
'ntfy: el tema, un servidor propi opcional i un token d’accés opcional, amb Prova per enviar-ne un a l’instant.',
'help.ctx.settings-notifications.bullet.2': 'Webhook: una URL que rep cada esdeveniment com a JSON, amb Prova.',
'help.ctx.settings-notifications.bullet.3':
'Notificacions push en aquest dispositiu: Activa en aquest dispositiu només afecta el navegador que fas servir, així que repeteix-ho a cada mòbil o ordinador. Envia una prova arriba a tots.',
'help.ctx.settings-notifications.bullet.4':
'Les files de preferències: per esdeveniment, quin canal està actiu. Els canals de connectors mostren Configura fins que estan configurats.',
'help.ctx.settings-integrations.title': 'Integracions',
'help.ctx.settings-integrations.summary':
@@ -1401,7 +1403,7 @@ const help: TranslationStrings = {
'help.ctx.admin-notifications.summary':
'Els canals que la instància ofereix als seus usuaris, i els que t’arriben a tu com a admin. Els usuaris trien els seus propis temes i URL a Configuració; tu decideixes què existeix i configures el correu.',
'help.ctx.admin-notifications.bullet.1':
'In-App, Correu (SMTP), Ntfy i Webhook: un panell cadascun, amb un interruptor que ofereix el canal als usuaris i la configuració del costat del servidor que necessita.',
'In-App, Correu (SMTP), Ntfy, Webhook i Web Push: un panell cadascun, amb un interruptor que ofereix el canal als usuaris i la configuració del costat del servidor que necessita.',
'help.ctx.admin-notifications.bullet.2':
'Recordatoris de viatge: si el servidor envia el recordatori abans que comenci un viatge.',
'help.ctx.admin-notifications.bullet.3':
@@ -1653,7 +1655,7 @@ const help: TranslationStrings = {
'help.guide.channels-instance.step.1':
'A Correu (SMTP), introdueix SMTP Host, SMTP Port, SMTP User, SMTP Password i la From Address; Enviar correu de prova t’envia un correu a tu.',
'help.guide.channels-instance.step.2':
'Activa Ntfy i Webhook per oferir-los; els usuaris introdueixen llavors el seu propi tema o URL a Configuració, Notificacions.',
'Activa Web Push, Ntfy i Webhook per oferir-los; els usuaris activen llavors el push per a cada dispositiu, o introdueixen el seu propi tema o URL, a Configuració, Notificacions.',
'help.guide.channels-instance.step.3':
'Recordatoris de viatge controla el recordatori abans que comenci un viatge; In-App sempre està activat i aquí només s’explica.',
'help.guide.channels-instance.result': 'La pestanya Notificacions de cada usuari mostra els canals que has activat.',
+13
View File
@@ -296,6 +296,19 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Correu electrònic',
'settings.notificationPreferences.ntfy': 'Ntfy',
'settings.notificationPreferences.push': 'Push',
'settings.webPush.title': 'Notificacions push en aquest dispositiu',
'settings.webPush.hint':
'Mostra les notificacions de TREK en aquest dispositiu fins i tot quan TREK està tancat. Els esdeveniments que arriben segueixen la columna Push.',
'settings.webPush.enable': 'Activa en aquest dispositiu',
'settings.webPush.disable': 'Desactiva en aquest dispositiu',
'settings.webPush.enabled': 'Activat en aquest dispositiu',
'settings.webPush.unsupported': 'Aquest navegador no pot rebre notificacions push.',
'settings.webPush.insecure': "Push necessita que TREK s'obri per HTTPS.",
'settings.webPush.iosInstall': "A l'iPhone i l'iPad, afegeix primer TREK a la pantalla d'inici i obre'l des d'allà.",
'settings.webPush.denied':
'Les notificacions de TREK estan bloquejades en aquest navegador. Permet-les a la configuració del navegador i torna-ho a provar.',
'settings.webPush.failed': "No s'ha pogut activar push en aquest dispositiu.",
'settings.passkey.title': 'Passkeys',
'settings.passkey.description':
'Inicia la sessió més ràpidament i amb protecció contra el phishing utilitzant una passkey: la teva empremta, la teva cara, el teu PIN o una clau de seguretat física. La teva contrasenya continua disponible com a alternativa.',
+3
View File
@@ -709,6 +709,9 @@ const admin: TranslationStrings = {
'admin.tabs.permissions': 'Oprávnění',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.webPushPanel.title': 'Web Push',
'admin.notifications.webPushPanel.hint':
'Umožňuje uživatelům přijímat oznámení na telefonech a počítačích přes prohlížeč, i když je TREK zavřený. Vyžaduje HTTPS; na iPhonu a iPadu musí být TREK přidán na plochu.',
'admin.notifications.inappPanel.title': 'In-App',
'admin.notifications.inappPanel.hint': 'In-app oznámení jsou vždy aktivní a nelze je globálně vypnout.',
'admin.notifications.adminWebhookPanel.title': 'Admin webhook',
+5 -3
View File
@@ -1021,12 +1021,14 @@ const help: TranslationStrings = {
'Režim vysoké kvality pro vyhlazování a projekci glóbu; Uložit nastavení mapy volbu zapíše.',
'help.ctx.settings-notifications.title': 'Oznámení',
'help.ctx.settings-notifications.summary':
'Kde vás TREK zastihne mimo aplikaci: téma ntfy, webhook nebo kanál, který poskytuje doplněk. Pod kanály rozhoduje jeden řádek na událost, co kam půjde.',
'Kde vás TREK zastihne mimo aplikaci: push notifikace na tomto zařízení, téma ntfy, webhook nebo kanál, který poskytuje doplněk. Pod kanály rozhoduje jeden řádek na událost, co kam půjde.',
'help.ctx.settings-notifications.bullet.1':
'ntfy: téma, volitelně vlastní server a volitelný přístupový token, s tlačítkem Otestovat, které hned jedno odešle.',
'help.ctx.settings-notifications.bullet.2':
'Webhook: jedna URL, která dostává každou událost jako JSON, s tlačítkem Otestovat.',
'help.ctx.settings-notifications.bullet.3':
'Push notifikace na tomto zařízení: Zapnout pro toto zařízení platí jen pro prohlížeč, který právě používáte, takže to zopakujte na každém telefonu nebo počítači. Odeslat test dorazí na všechny.',
'help.ctx.settings-notifications.bullet.4':
'Řádky předvoleb: pro každou událost, který kanál je zapnutý. Kanály doplňků ukazují Nastavit, dokud nejsou nastavené.',
'help.ctx.settings-integrations.title': 'Integrace',
'help.ctx.settings-integrations.summary':
@@ -1345,7 +1347,7 @@ const help: TranslationStrings = {
'help.ctx.admin-notifications.summary':
'Kanály, které instance nabízí svým uživatelům, a ty, které zastihnou vás jako administrátora. Uživatelé si vybírají vlastní témata a URL pod Nastavení; vy rozhodujete, co existuje, a nastavujete e-mail.',
'help.ctx.admin-notifications.bullet.1':
'In-App, Email (SMTP), Ntfy a Webhook: jeden panel pro každý, s přepínačem, který kanál nabídne uživatelům, a konfigurací na straně serveru, kterou potřebuje.',
'In-App, Email (SMTP), Ntfy, Webhook a Web Push: jeden panel pro každý, s přepínačem, který kanál nabídne uživatelům, a konfigurací na straně serveru, kterou potřebuje.',
'help.ctx.admin-notifications.bullet.2': 'Připomínky výletů: zda server posílá připomínku před začátkem cesty.',
'help.ctx.admin-notifications.bullet.3':
'Admin Ntfy a Admin webhook: kam jdou události pro administrátory, jako neúspěšná záloha nebo nové vydání, s tlačítkem pro test.',
@@ -1580,7 +1582,7 @@ const help: TranslationStrings = {
'help.guide.channels-instance.step.1':
'Pod Email (SMTP) zadejte SMTP Host, SMTP Port, SMTP User, SMTP Password a From Address; Odeslat testovací e-mail vám pošle zprávu.',
'help.guide.channels-instance.step.2':
'Zapněte Ntfy a Webhook, abyste je nabídli; uživatelé pak zadají vlastní téma nebo URL pod Nastavení, Oznámení.',
'Zapněte Web Push, Ntfy a Webhook, abyste je nabídli; uživatelé pak pod Nastavení, Oznámení zapnou push pro každé zařízení, nebo zadají vlastní téma či URL.',
'help.guide.channels-instance.step.3':
'Připomínky výletů přepínají připomínku před začátkem cesty; In-App je vždy zapnuté a tady je jen vysvětlené.',
'help.guide.channels-instance.result': 'Karta Oznámení každého uživatele ukazuje kanály, které jste zapnuli.',
+13
View File
@@ -315,6 +315,19 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'settings.notificationPreferences.ntfy': 'Ntfy',
'settings.notificationPreferences.push': 'Push',
'settings.webPush.title': 'Push notifikace na tomto zařízení',
'settings.webPush.hint':
'Zobrazuje oznámení TREK na tomto zařízení, i když je TREK zavřený. Které události dorazí, určuje sloupec Push.',
'settings.webPush.enable': 'Zapnout pro toto zařízení',
'settings.webPush.disable': 'Vypnout pro toto zařízení',
'settings.webPush.enabled': 'Zapnuto pro toto zařízení',
'settings.webPush.unsupported': 'Tento prohlížeč nemůže přijímat push notifikace.',
'settings.webPush.insecure': 'Push vyžaduje, aby byl TREK otevřen přes HTTPS.',
'settings.webPush.iosInstall': 'Na iPhonu a iPadu nejprve přidejte TREK na plochu a otevřete ho odtud.',
'settings.webPush.denied':
'Oznámení pro TREK jsou v tomto prohlížeči zablokovaná. Povolte je v nastavení prohlížeče a zkuste to znovu.',
'settings.webPush.failed': 'Push se pro toto zařízení nepodařilo zapnout.',
'settings.currency': 'Zobrazovaná měna',
'settings.currencyHint':
'Částky v Nákladech se do této měny přepočítávají pouze pro zobrazení – původní částky zůstávají beze změny.',
+3
View File
@@ -721,6 +721,9 @@ const admin: TranslationStrings = {
'admin.tabs.permissions': 'Berechtigungen',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.webPushPanel.title': 'Web Push',
'admin.notifications.webPushPanel.hint':
'Ermöglicht Benutzern, über den Browser Benachrichtigungen auf ihren Smartphones und Computern zu empfangen, auch wenn TREK geschlossen ist. Erfordert HTTPS; auf iPhone und iPad muss TREK zum Home-Bildschirm hinzugefügt werden.',
'admin.notifications.inappPanel.title': 'In-App',
'admin.notifications.inappPanel.hint':
'In-App-Benachrichtigungen sind immer aktiv und können nicht global deaktiviert werden.',
+5 -3
View File
@@ -1075,11 +1075,13 @@ const help: TranslationStrings = {
'Hochqualitäts-Modus für Antialiasing und die Globus-Projektion; Karte speichern schreibt die Wahl.',
'help.ctx.settings-notifications.title': 'Mitteilungen',
'help.ctx.settings-notifications.summary':
'Wo TREK dich außerhalb der App erreicht: ein ntfy-Thema, ein Webhook oder ein Kanal, den ein Plugin bereitstellt. Unter den Kanälen entscheidet eine Zeile pro Ereignis, was wohin geht.',
'Wo TREK dich außerhalb der App erreicht: Push-Benachrichtigungen auf diesem Gerät, ein ntfy-Thema, ein Webhook oder ein Kanal, den ein Plugin bereitstellt. Unter den Kanälen entscheidet eine Zeile pro Ereignis, was wohin geht.',
'help.ctx.settings-notifications.bullet.1':
'ntfy: das Thema, optional ein eigener Server und ein optionaler Zugriffstoken, mit Testen, um sofort eine Nachricht zu schicken.',
'help.ctx.settings-notifications.bullet.2': 'Webhook: eine URL, die jedes Ereignis als JSON empfängt, mit Testen.',
'help.ctx.settings-notifications.bullet.3':
'Push-Benachrichtigungen auf diesem Gerät: Für dieses Gerät einschalten gilt nur für den Browser, den du gerade nutzt, also wiederhole es auf jedem Handy oder Computer. Test senden erreicht alle.',
'help.ctx.settings-notifications.bullet.4':
'Die Zeilen mit den Präferenzen: pro Ereignis, welcher Kanal an ist. Plugin-Kanäle zeigen Einrichten, bis sie eingerichtet sind.',
'help.ctx.settings-integrations.title': 'Integrationen',
'help.ctx.settings-integrations.summary':
@@ -1407,7 +1409,7 @@ const help: TranslationStrings = {
'help.ctx.admin-notifications.summary':
'Die Kanäle, die die Instanz ihren Benutzern anbietet, und die, die dich als Admin erreichen. Benutzer wählen ihre eigenen Themen und URLs unter Einstellungen; du entscheidest, was es gibt, und richtest E-Mail ein.',
'help.ctx.admin-notifications.bullet.1':
'In-App, Email (SMTP), Ntfy und Webhook: je ein Panel, mit einem Schalter, der den Kanal den Benutzern anbietet, und der serverseitigen Konfiguration, die er braucht.',
'In-App, Email (SMTP), Ntfy, Webhook und Web Push: je ein Panel, mit einem Schalter, der den Kanal den Benutzern anbietet, und der serverseitigen Konfiguration, die er braucht.',
'help.ctx.admin-notifications.bullet.2': 'Reiseerinnerungen: ob der Server die Erinnerung vor Reisebeginn schickt.',
'help.ctx.admin-notifications.bullet.3':
'Admin-Ntfy und Admin-Webhook: wohin Admin-Ereignisse wie ein fehlgeschlagenes Backup oder ein neues Release gehen, mit Test.',
@@ -1662,7 +1664,7 @@ const help: TranslationStrings = {
'help.guide.channels-instance.step.1':
'Trag unter Email (SMTP) SMTP Host, SMTP Port, SMTP User, SMTP Password und die From Address ein; Test-E-Mail senden schickt eine Mail an dich.',
'help.guide.channels-instance.step.2':
'Schalte Ntfy und Webhook an, um sie anzubieten; Benutzer tragen dann ihr eigenes Thema oder ihre URL unter Einstellungen, Mitteilungen ein.',
'Schalte Web Push, Ntfy und Webhook an, um sie anzubieten; Benutzer schalten dann unter Einstellungen, Mitteilungen Push pro Gerät ein oder tragen ihr eigenes Thema oder ihre URL ein.',
'help.guide.channels-instance.step.3':
'Reiseerinnerungen schaltet die Erinnerung vor Reisebeginn; In-App ist immer an und wird hier nur erklärt.',
'help.guide.channels-instance.result':
+14
View File
@@ -323,6 +323,20 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'settings.notificationPreferences.ntfy': 'Ntfy',
'settings.notificationPreferences.push': 'Push',
'settings.webPush.title': 'Push-Benachrichtigungen auf diesem Gerät',
'settings.webPush.hint':
'Zeigt TREK-Benachrichtigungen auf diesem Gerät an, auch wenn TREK geschlossen ist. Welche Ereignisse ankommen, richtet sich nach der Spalte Push.',
'settings.webPush.enable': 'Für dieses Gerät einschalten',
'settings.webPush.disable': 'Für dieses Gerät ausschalten',
'settings.webPush.enabled': 'Für dieses Gerät eingeschaltet',
'settings.webPush.unsupported': 'Dieser Browser kann keine Push-Benachrichtigungen empfangen.',
'settings.webPush.insecure': 'Für Push muss TREK über HTTPS geöffnet werden.',
'settings.webPush.iosInstall':
'Füge TREK auf iPhone und iPad zuerst zum Home-Bildschirm hinzu und öffne es von dort.',
'settings.webPush.denied':
'Benachrichtigungen für TREK sind in diesem Browser blockiert. Erlaube sie in den Browsereinstellungen und versuche es dann erneut.',
'settings.webPush.failed': 'Push konnte für dieses Gerät nicht eingeschaltet werden.',
'settings.currency': 'Anzeigewährung',
'settings.currencyHint':
'Beträge unter Kosten werden nur zur Anzeige in diese Währung umgerechnet – die ursprünglichen Beträge bleiben unverändert.',
+3
View File
@@ -19,6 +19,9 @@ const admin: TranslationStrings = {
'admin.notifications.testNtfyFailed': 'Test ntfy failed',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.webPushPanel.title': 'Web Push',
'admin.notifications.webPushPanel.hint':
'Lets users receive notifications on their phones and computers through the browser, even while TREK is closed. Needs HTTPS; on iPhone and iPad TREK has to be added to the Home Screen.',
'admin.notifications.inappPanel.title': 'In-App',
'admin.notifications.inappPanel.hint': 'In-app notifications are always active and cannot be disabled globally.',
'admin.notifications.adminWebhookPanel.title': 'Admin Webhook',
+5 -3
View File
@@ -1034,11 +1034,13 @@ const help: TranslationStrings = {
'High Quality Mode for antialiasing and the globe projection; Save Map writes the choice.',
'help.ctx.settings-notifications.title': 'Notifications',
'help.ctx.settings-notifications.summary':
'Where TREK reaches you outside the app: an ntfy topic, a webhook, or a channel a plugin provides. Below the channels, one row per event decides what goes where.',
'Where TREK reaches you outside the app: push notifications on this device, an ntfy topic, a webhook, or a channel a plugin provides. Below the channels, one row per event decides what goes where.',
'help.ctx.settings-notifications.bullet.1':
'ntfy: the topic, an optional server of your own and an optional access token, with Test to send one right away.',
'help.ctx.settings-notifications.bullet.2': 'Webhook: one URL that receives every event as JSON, with Test.',
'help.ctx.settings-notifications.bullet.3':
'Push notifications on this device: Turn on for this device covers only the browser you are using, so repeat it on each phone or computer. Send test reaches all of them.',
'help.ctx.settings-notifications.bullet.4':
'The preference rows: per event, which channel is on. Plugin channels show Configure until they are set up.',
'help.ctx.settings-integrations.title': 'Integrations',
'help.ctx.settings-integrations.summary':
@@ -1350,7 +1352,7 @@ const help: TranslationStrings = {
'help.ctx.admin-notifications.summary':
'The channels the instance offers its users, and the ones that reach you as the admin. Users pick their own topics and URLs under Settings; you decide what exists and configure email.',
'help.ctx.admin-notifications.bullet.1':
'In-App, Email (SMTP), Ntfy and Webhook: one panel each, with a switch that offers the channel to users and the server-side configuration it needs.',
'In-App, Email (SMTP), Ntfy, Webhook and Web Push: one panel each, with a switch that offers the channel to users and the server-side configuration it needs.',
'help.ctx.admin-notifications.bullet.2':
'Trip Reminders: whether the server sends the reminder before a trip starts.',
'help.ctx.admin-notifications.bullet.3':
@@ -1587,7 +1589,7 @@ const help: TranslationStrings = {
'help.guide.channels-instance.step.1':
'Under Email (SMTP), enter SMTP Host, SMTP Port, SMTP User, SMTP Password and the From Address; Send test email sends a mail to you.',
'help.guide.channels-instance.step.2':
'Switch Ntfy and Webhook on to offer them; users then enter their own topic or URL under Settings, Notifications.',
'Switch Web Push, Ntfy and Webhook on to offer them; users then turn push on per device, or enter their own topic or URL, under Settings, Notifications.',
'help.guide.channels-instance.step.3':
'Trip Reminders switches the reminder before a trip starts; In-App is always on and only explained here.',
'help.guide.channels-instance.result': 'The Notifications tab of every user shows the channels you switched on.',

Some files were not shown because too many files have changed in this diff Show More