mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
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:
@@ -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(),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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'))
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user