Files
TREK/client/src/api/push.ts
T
Maurice 650451bdf7 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.
2026-09-24 23:17:03 +00:00

50 lines
2.1 KiB
TypeScript

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'))
},
}