From c8e5829a774b61178664aa276b6783748f5e40ba Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Fri, 9 Oct 2026 11:14:20 -0400 Subject: [PATCH] Service worker push handlers and client subscribe (Feature 19) (#156) Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> --- web/src/push/notification.test.ts | 231 ++++++++++++++++++++++ web/src/push/notification.ts | 174 +++++++++++++++++ web/src/push/subscribe.test.ts | 308 ++++++++++++++++++++++++++++++ web/src/push/subscribe.ts | 209 ++++++++++++++++++++ web/src/push/usePush.ts | 29 +++ web/src/sw.test.ts | 54 ++++++ web/src/sw.ts | 16 ++ web/src/sw/worker.test.ts | 10 + 8 files changed, 1031 insertions(+) create mode 100644 web/src/push/notification.test.ts create mode 100644 web/src/push/notification.ts create mode 100644 web/src/push/subscribe.test.ts create mode 100644 web/src/push/subscribe.ts create mode 100644 web/src/push/usePush.ts diff --git a/web/src/push/notification.test.ts b/web/src/push/notification.test.ts new file mode 100644 index 0000000..2c432ae --- /dev/null +++ b/web/src/push/notification.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it, vi } from 'vitest' +import { + APP_BADGE, + APP_ICON, + GENERIC_NOTICE, + appPath, + clickPath, + openNotification, + parsePushPayload, + planClick, + showPush, + type ClickWindow, +} from './notification.ts' + +const origin = 'https://mealquest.example' + +describe('appPath', () => { + it('keeps a same-origin path with its query and fragment', () => { + expect(appPath('/plan/shopping?week=2026-10-05#tuesday', origin)).toBe('/plan/shopping?week=2026-10-05#tuesday') + }) + + it.each([ + 'https://evil.example/plan', + '//evil.example/plan', + '/\\evil.example/plan', + 'javascript:alert(1)', + 'plan', + '', + ])('refuses %s', (raw) => { + expect(appPath(raw, origin)).toBeNull() + }) + + it('resolves dot segments and keeps the result on origin', () => { + expect(appPath('/plan/../discover', origin)).toBe('/discover') + }) + + // Each of these starts with a single / but resolves to a path starting with + // //, which a browser reads as another host. + it.each([ + '/..//evil.example/path', + '/plan/..//evil.example/path', + '/..\\\\evil.example/path', + '/./\\evil.example', + '/.\\/evil.example', + '/%2e%2e//evil.example/path', + '/.\t.//evil.example/path', + '/..\n//evil.example', + ])('refuses %j, which resolves to a protocol-relative path', (raw) => { + expect(appPath(raw, origin)).toBeNull() + }) +}) + +describe('parsePushPayload', () => { + it('reads title, body, tag and url', () => { + const text = JSON.stringify({ title: 'Plan ready', body: 'Your Draft board is ready.', tag: 'plan_ready', url: '/plan' }) + expect(parsePushPayload(text, origin)).toEqual({ + notice: { title: 'Plan ready', body: 'Your Draft board is ready.', tag: 'plan_ready', url: '/plan' }, + problem: null, + }) + }) + + it('opens home with an empty body and no tag when only a title is sent', () => { + expect(parsePushPayload(JSON.stringify({ title: 'Hi' }), origin)).toEqual({ + notice: { title: 'Hi', body: '', url: '/' }, + problem: null, + }) + }) + + it('ignores fields it does not know', () => { + const parsed = parsePushPayload(JSON.stringify({ title: 'Hi', event: 'plan_ready' }), origin) + expect(parsed.problem).toBeNull() + expect(parsed.notice).toEqual({ title: 'Hi', body: '', url: '/' }) + }) + + it.each([ + [null, 'push message has no payload'], + ['', 'push message has no payload'], + ['\u0000ÿ not json', 'push payload is not JSON'], + ['[1,2]', 'push payload is not a JSON object'], + ['null', 'push payload is not a JSON object'], + ['{"body":"no title"}', 'push payload has no title'], + ['{"title":" "}', 'push payload has no title'], + ['{"title":42}', 'push payload has no title'], + ['{"title":"Hi","body":7}', 'push payload body is not a string'], + ['{"title":"Hi","tag":""}', 'push payload tag is not a non-empty string'], + ['{"title":"Hi","url":"https://evil.example/"}', 'push payload url is not a same-origin path'], + ['{"title":"Hi","url":5}', 'push payload url is not a same-origin path'], + ])('shows the generic notice for %j', (text, problem) => { + expect(parsePushPayload(text, origin)).toEqual({ notice: GENERIC_NOTICE, problem }) + }) +}) + +describe('showPush', () => { + it('shows the payload with the app icon and badge, its tag and its url as data', async () => { + const registration = { showNotification: vi.fn(async () => {}) } + const warn = vi.fn() + await showPush( + JSON.stringify({ title: 'Supply run ready', body: 'Time to shop.', tag: 'list_ready', url: '/plan/shopping' }), + registration, + origin, + warn, + ) + expect(registration.showNotification).toHaveBeenCalledWith('Supply run ready', { + body: 'Time to shop.', + tag: 'list_ready', + icon: APP_ICON, + badge: APP_BADGE, + data: { url: '/plan/shopping' }, + }) + expect(warn).not.toHaveBeenCalled() + }) + + it('shows the generic notification for a malformed payload and logs why, without throwing', async () => { + const registration = { showNotification: vi.fn(async () => {}) } + const warn = vi.fn() + await expect(showPush('{not json', registration, origin, warn)).resolves.toBeUndefined() + expect(registration.showNotification).toHaveBeenCalledWith('MealQuest', { + body: GENERIC_NOTICE.body, + icon: APP_ICON, + badge: APP_BADGE, + data: { url: '/' }, + }) + expect(warn).toHaveBeenCalledWith('MealQuest: push payload is not JSON; showing the generic notification') + }) +}) + +describe('clickPath', () => { + it('opens the url the notification carries', () => { + expect(clickPath({ url: '/discover' }, origin)).toBe('/discover') + }) + + it.each([undefined, null, 'x', {}, { url: 'https://evil.example/' }])('opens home for data %j', (data) => { + expect(clickPath(data, origin)).toBe('/') + }) +}) + +describe('planClick', () => { + it('refuses a path that resolves off origin', () => { + expect(() => planClick([], '//evil.example/path', origin)).toThrow(/resolves off origin/) + }) + + const at = (path: string, focused = false) => ({ url: origin + path, focused }) + + it('opens a new window when no MealQuest window is open', () => { + expect(planClick([], '/plan', origin)).toEqual({ action: 'open', url: `${origin}/plan` }) + }) + + it('ignores windows on another origin', () => { + const other = { url: 'https://other.example/plan', focused: true } + expect(planClick([other], '/plan', origin)).toEqual({ action: 'open', url: `${origin}/plan` }) + }) + + it('focuses the focused MealQuest window and navigates it', () => { + const first = at('/') + const focused = at('/discover', true) + expect(planClick([first, focused], '/plan', origin)).toEqual({ + action: 'focus', + window: focused, + url: `${origin}/plan`, + navigate: true, + }) + }) + + it('focuses the first MealQuest window when none is focused', () => { + const first = at('/') + expect(planClick([first, at('/discover')], '/plan', origin)).toMatchObject({ action: 'focus', window: first }) + }) + + it('does not reload a window already at the url', () => { + expect(planClick([at('/plan')], '/plan', origin)).toMatchObject({ action: 'focus', navigate: false }) + }) +}) + +describe('openNotification', () => { + function window(path: string, focused = false) { + return { + url: origin + path, + focused, + focus: vi.fn(async () => {}), + navigate: vi.fn(async () => {}), + } satisfies ClickWindow + } + + function clients(windows: ClickWindow[]) { + return { matchAll: vi.fn(async () => windows), openWindow: vi.fn(async () => null) } + } + + it('focuses an open window, then navigates it to the url', async () => { + const win = window('/', true) + const all = clients([win]) + await openNotification({ url: '/plan/shopping' }, all, origin) + expect(all.matchAll).toHaveBeenCalledWith({ type: 'window' }) + expect(win.focus).toHaveBeenCalled() + expect(win.navigate).toHaveBeenCalledWith(`${origin}/plan/shopping`) + expect(win.focus.mock.invocationCallOrder[0]).toBeLessThan(win.navigate.mock.invocationCallOrder[0]!) + expect(all.openWindow).not.toHaveBeenCalled() + }) + + it('opens a new window at the url when none is open', async () => { + const all = clients([]) + await openNotification({ url: '/discover' }, all, origin) + expect(all.openWindow).toHaveBeenCalledWith(`${origin}/discover`) + }) + + it('never opens a url off origin, even from tampered data', async () => { + const all = clients([]) + await openNotification({ url: 'https://evil.example/' }, all, origin) + expect(all.openWindow).toHaveBeenCalledWith(`${origin}/`) + }) + + it.each(['/..//evil.example/path', '/..\\\\evil.example/path', '/%2e%2e//evil.example/path', '/.\t.//evil.example/path'])( + 'never leaves origin for %j, which resolves to a protocol-relative path', + async (url) => { + const win = window('/plan', true) + const all = clients([win]) + await openNotification({ url }, all, origin) + expect(win.navigate).toHaveBeenCalledWith(`${origin}/`) + expect(all.openWindow).not.toHaveBeenCalled() + + const none = clients([]) + await openNotification({ url }, none, origin) + expect(none.openWindow).toHaveBeenCalledWith(`${origin}/`) + }, + ) + + it('still opens an internal path that uses dot segments', async () => { + const all = clients([]) + await openNotification({ url: '/plan/../discover' }, all, origin) + expect(all.openWindow).toHaveBeenCalledWith(`${origin}/discover`) + }) +}) diff --git a/web/src/push/notification.ts b/web/src/push/notification.ts new file mode 100644 index 0000000..b86c9c4 --- /dev/null +++ b/web/src/push/notification.ts @@ -0,0 +1,174 @@ +// Web Push notifications as the service worker shows them, and where a tap on +// one takes the household. The worker's push and notificationclick listeners +// (src/sw.ts) only adapt these to the worker's globals, so every decision runs +// under test without a real ServiceWorkerGlobalScope. This file is checked +// with both the DOM and the WebWorker libraries, so it names only the narrow +// shapes it uses. + +// APP_ICON and APP_BADGE are the app icon from the web manifest. +export const APP_ICON = '/icons/icon-192.png' +export const APP_BADGE = '/icons/icon-192.png' + +// PushNotice is a notification to show: its title, body and tag, and the +// same-origin path a tap opens. +export type PushNotice = { title: string; body: string; tag?: string; url: string } + +// GENERIC_NOTICE is shown for a push whose payload is not a notice: the +// household still learns that MealQuest has something for them. +export const GENERIC_NOTICE: PushNotice = { + title: 'MealQuest', + body: "There's something new in MealQuest.", + url: '/', +} + +// ParsedPush is the notice to show for a push, and why its payload was not +// used, when it was not. +export type ParsedPush = { notice: PushNotice; problem: string | null } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +// appPath returns raw as a path on origin (path, query and fragment), or null +// when raw is not a path-absolute URL that stays on origin. A payload never +// sends the household to another site. The path is checked after the parser +// has resolved it: dot segments (also as %2e), backslashes and stripped tabs +// and newlines can all turn an innocent-looking path into one starting with +// //, which a browser would read as another host. +export function appPath(raw: string, origin: string): string | null { + if (!raw.startsWith('/') || raw.startsWith('//')) return null + let url: URL + try { + url = new URL(raw, origin) + } catch { + return null + } + if (url.origin !== origin || url.pathname.startsWith('//')) return null + return url.pathname + url.search + url.hash +} + +// parsePushPayload reads a push message's payload, which may be any bytes: +// a JSON object with a non-empty string title, and optionally a string body, +// a non-empty string tag and a url that is a same-origin path. Other fields +// are ignored. Anything else — no payload, not JSON, a missing title, a +// field of the wrong type, a url off origin — is shown as GENERIC_NOTICE, +// with the problem for the worker to log. It never throws. +export function parsePushPayload(text: string | null, origin: string): ParsedPush { + const generic = (problem: string): ParsedPush => ({ notice: GENERIC_NOTICE, problem }) + if (text === null || text === '') return generic('push message has no payload') + let payload: unknown + try { + payload = JSON.parse(text) + } catch { + return generic('push payload is not JSON') + } + if (!isRecord(payload)) return generic('push payload is not a JSON object') + const { title, body, tag, url } = payload + if (typeof title !== 'string' || title.trim() === '') return generic('push payload has no title') + if (body !== undefined && typeof body !== 'string') return generic('push payload body is not a string') + if (tag !== undefined && (typeof tag !== 'string' || tag === '')) { + return generic('push payload tag is not a non-empty string') + } + let path = '/' + if (url !== undefined) { + const safe = typeof url === 'string' ? appPath(url, origin) : null + if (safe === null) return generic('push payload url is not a same-origin path') + path = safe + } + const notice: PushNotice = { title, body: body ?? '', url: path } + if (tag !== undefined) notice.tag = tag + return { notice, problem: null } +} + +// NoticeData is the data a shown notification carries for its click. +export type NoticeData = { url: string } + +// notificationOptions is what showNotification is given for notice: its body +// and tag, the app icon and badge, and the path to open as its data. +export function notificationOptions(notice: PushNotice): NotificationOptions & { data: NoticeData } { + const options: NotificationOptions & { data: NoticeData } = { + body: notice.body, + icon: APP_ICON, + badge: APP_BADGE, + data: { url: notice.url }, + } + if (notice.tag !== undefined) options.tag = notice.tag + return options +} + +export type NotificationShower = { + showNotification(title: string, options: NotificationOptions): Promise +} + +// showPush shows the notification for a push message's payload text, +// reporting through warn why a payload was not used. +export function showPush( + text: string | null, + registration: NotificationShower, + origin: string, + warn: (message: string) => void, +): Promise { + const { notice, problem } = parsePushPayload(text, origin) + if (problem !== null) warn(`MealQuest: ${problem}; showing the generic notification`) + return registration.showNotification(notice.title, notificationOptions(notice)) +} + +// clickPath is the same-origin path a tapped notification opens: the url in +// its data, which showPush put there, or the app's home when the data is not +// ours (a notification shown by an older worker). +export function clickPath(data: unknown, origin: string): string { + if (isRecord(data) && typeof data.url === 'string') { + const path = appPath(data.url, origin) + if (path !== null) return path + } + return '/' +} + +// AppWindow is the part of a window client a click reads. +export type AppWindow = { url: string; focused: boolean } + +// ClickPlan is what a notification tap does: focus an open MealQuest window +// and navigate it to url, or open a new window at url. url is absolute. +export type ClickPlan = { action: 'focus'; window: W; url: string; navigate: boolean } | { action: 'open'; url: string } + +// planClick decides a tap on a notification that opens path: the focused +// MealQuest window if there is one, else the first open one, navigated to +// path unless it is already there; with none open, a new window. A path that +// resolves off origin is an error: a tap never leaves MealQuest. +export function planClick(windows: readonly W[], path: string, origin: string): ClickPlan { + const resolved = new URL(path, origin) + if (resolved.origin !== origin) { + throw new Error(`MealQuest: notification path ${JSON.stringify(path)} resolves off origin to ${resolved.origin}`) + } + const url = resolved.href + const ours = windows.filter((w) => URL.canParse(w.url) && new URL(w.url).origin === origin) + const target = ours.find((w) => w.focused) ?? ours[0] + if (target === undefined) return { action: 'open', url } + return { action: 'focus', window: target, url, navigate: target.url !== url } +} + +export type ClickWindow = AppWindow & { + focus(): Promise + navigate(url: string): Promise +} + +export type ClickClients = { + matchAll(options: { type: 'window' }): Promise + openWindow(url: string): Promise +} + +// openNotification carries out a tap on a notification with data: focus +// first, while the tap still allows it, then navigate. +export async function openNotification( + data: unknown, + clients: ClickClients, + origin: string, +): Promise { + const plan = planClick(await clients.matchAll({ type: 'window' }), clickPath(data, origin), origin) + if (plan.action === 'open') { + await clients.openWindow(plan.url) + return + } + await plan.window.focus() + if (plan.navigate) await plan.window.navigate(plan.url) +} diff --git a/web/src/push/subscribe.test.ts b/web/src/push/subscribe.test.ts new file mode 100644 index 0000000..5d26de3 --- /dev/null +++ b/web/src/push/subscribe.test.ts @@ -0,0 +1,308 @@ +import { describe, expect, it, vi } from 'vitest' +import { ApiError, type ApiRequest } from '../api/index.ts' +import { + SUBSCRIPTIONS_PATH, + VAPID_PUBLIC_KEY_PATH, + decodeVapidKey, + encodeBase64url, + PermissionGestureError, + subscribePush, + subscriptionBody, + unsubscribePush, + type BrowserPushSubscription, + type PushEnvironment, +} from './subscribe.ts' + +function bytes(length: number, first: number): Uint8Array { + const out = new Uint8Array(new ArrayBuffer(length)) + for (let i = 0; i < length; i++) out[i] = (first + i) % 256 + return out +} + +const vapidKey = bytes(65, 4) +const vapidKeyText = encodeBase64url(vapidKey.buffer) +const p256dh = bytes(65, 4).buffer +const auth = bytes(16, 200).buffer +const endpoint = 'https://push.example/send/abc' + +function subscription(key: ArrayBuffer | null = vapidKey.buffer, at = endpoint) { + return { + endpoint: at, + options: { applicationServerKey: key }, + getKey: (name: 'p256dh' | 'auth') => (name === 'p256dh' ? p256dh : auth), + unsubscribe: vi.fn(async () => true), + } satisfies BrowserPushSubscription +} + +type Setup = { + permission?: NotificationPermission + answer?: NotificationPermission + active?: boolean + current?: BrowserPushSubscription | null + registered?: boolean + subscribeError?: unknown +} + +function setup({ + permission = 'default', + answer = 'granted', + active = true, + current = null, + registered = true, + subscribeError, +}: Setup = {}) { + const created = subscription() + const pushManager = { + getSubscription: vi.fn(async () => current), + subscribe: vi.fn(async () => { + if (subscribeError !== undefined) throw subscribeError + return created + }), + } + const notification = { permission, requestPermission: vi.fn(async () => answer) } + const env: PushEnvironment = { + serviceWorker: { getRegistration: vi.fn(async () => (registered ? { pushManager } : undefined)) }, + pushManager: true, + notification, + userActivation: { isActive: active }, + userAgent: 'Mozilla/5.0 Test', + } + const calls: { path: string; request?: ApiRequest }[] = [] + const api = { + apiFetch: vi.fn(async (path: string, request?: ApiRequest) => { + calls.push({ path, request }) + if (path === VAPID_PUBLIC_KEY_PATH) return { public_key: vapidKeyText } + return undefined + }) as (path: string, request?: ApiRequest) => Promise, + } + return { env, api, calls, pushManager, notification, created } +} + +describe('decodeVapidKey', () => { + it('decodes the unpadded base64url point', () => { + expect(decodeVapidKey(vapidKeyText)).toEqual(vapidKey) + }) + + it.each(['', 'abc=', 'not base64!', encodeBase64url(bytes(64, 4).buffer), encodeBase64url(bytes(65, 2).buffer)])( + 'refuses %j', + (key) => { + expect(() => decodeVapidKey(key)).toThrow(/65-byte uncompressed P-256 point/) + }, + ) +}) + +describe('subscriptionBody', () => { + it('sends exactly the endpoint, the keys in unpadded base64url and the user agent', () => { + const body = subscriptionBody(subscription(), 'Mozilla/5.0 Test') + expect(body).toEqual({ + endpoint, + keys: { p256dh: encodeBase64url(p256dh), auth: encodeBase64url(auth) }, + user_agent: 'Mozilla/5.0 Test', + }) + expect(body.keys.auth).toMatch(/^[A-Za-z0-9_-]{22}$/) + expect(JSON.stringify(body)).not.toContain('expirationTime') + }) + + it.each(['', ' ', 'x'.repeat(513)])('leaves out a blank or too-long user agent', (userAgent) => { + expect(subscriptionBody(subscription(), userAgent)).not.toHaveProperty('user_agent') + }) + + it('refuses a subscription without keys', () => { + const keyless = { ...subscription(), getKey: () => null } + expect(() => subscriptionBody(keyless, 'ua')).toThrow(/without its p256dh and auth keys/) + }) +}) + +describe('subscribePush', () => { + it.each([ + ['serviceWorker', { serviceWorker: undefined }], + ['PushManager', { pushManager: false }], + ['Notification', { notification: undefined }], + ])('answers unsupported without %s, asking nothing', async (_, missing) => { + const { env, api, notification } = setup() + expect(await subscribePush(api, { ...env, ...missing })).toBe('unsupported') + expect(notification.requestPermission).not.toHaveBeenCalled() + expect(api.apiFetch).not.toHaveBeenCalled() + }) + + it('asks for permission during a tap, subscribes with the server key and stores the subscription', async () => { + const { env, api, calls, pushManager, notification } = setup() + expect(await subscribePush(api, env)).toBe('subscribed') + expect(notification.requestPermission).toHaveBeenCalledTimes(1) + expect(pushManager.subscribe).toHaveBeenCalledWith({ userVisibleOnly: true, applicationServerKey: vapidKey }) + expect(calls).toEqual([ + { path: VAPID_PUBLIC_KEY_PATH, request: undefined }, + { + path: SUBSCRIPTIONS_PATH, + request: { + method: 'POST', + body: { + endpoint, + keys: { p256dh: encodeBase64url(p256dh), auth: encodeBase64url(auth) }, + user_agent: 'Mozilla/5.0 Test', + }, + }, + }, + ]) + }) + + it('asks for permission before awaiting anything, so the tap still counts', async () => { + const { env, api, notification } = setup() + const pending = subscribePush(api, env) + expect(notification.requestPermission).toHaveBeenCalledTimes(1) + expect(api.apiFetch).not.toHaveBeenCalled() + await pending + }) + + it('refuses to ask for permission outside a tap', async () => { + const { env, api, notification } = setup({ active: false }) + const error = await subscribePush(api, env).catch((e: unknown) => e) + expect(error).toBeInstanceOf(PermissionGestureError) + expect(error).toMatchObject({ reason: 'inactive', message: expect.stringMatching(/only be asked for from a tap/) }) + expect(notification.requestPermission).not.toHaveBeenCalled() + expect(api.apiFetch).not.toHaveBeenCalled() + }) + + it('refuses to ask for permission where the browser cannot say whether a tap is active', async () => { + const { env, api, notification } = setup() + const error = await subscribePush(api, { ...env, userActivation: undefined }).catch((e: unknown) => e) + expect(error).toBeInstanceOf(PermissionGestureError) + expect(error).toMatchObject({ reason: 'unobservable', message: expect.stringMatching(/no navigator.userActivation/) }) + expect(notification.requestPermission).not.toHaveBeenCalled() + expect(api.apiFetch).not.toHaveBeenCalled() + }) + + it('does not need to observe a tap once permission is granted', async () => { + const { env, api, notification } = setup({ permission: 'granted' }) + expect(await subscribePush(api, { ...env, userActivation: undefined })).toBe('subscribed') + expect(notification.requestPermission).not.toHaveBeenCalled() + }) + + it('does not ask again once permission is granted, even outside a tap', async () => { + const { env, api, notification } = setup({ permission: 'granted', active: false }) + expect(await subscribePush(api, env)).toBe('subscribed') + expect(notification.requestPermission).not.toHaveBeenCalled() + }) + + it('reports a permission denied earlier without asking or subscribing', async () => { + const { env, api, notification, pushManager } = setup({ permission: 'denied' }) + expect(await subscribePush(api, env)).toBe('denied') + expect(notification.requestPermission).not.toHaveBeenCalled() + expect(pushManager.subscribe).not.toHaveBeenCalled() + expect(api.apiFetch).not.toHaveBeenCalled() + }) + + it('reports a permission denied when asked, without subscribing', async () => { + const { env, api, pushManager } = setup({ answer: 'denied' }) + expect(await subscribePush(api, env)).toBe('denied') + expect(pushManager.subscribe).not.toHaveBeenCalled() + expect(api.apiFetch).not.toHaveBeenCalled() + }) + + it('reports a dismissed prompt', async () => { + const { env, api, pushManager } = setup({ answer: 'default' }) + expect(await subscribePush(api, env)).toBe('dismissed') + expect(pushManager.subscribe).not.toHaveBeenCalled() + }) + + it('reports denied when the browser refuses the subscription for permission', async () => { + const { env, api, calls } = setup({ subscribeError: new DOMException('Registration failed - permission denied', 'NotAllowedError') }) + expect(await subscribePush(api, env)).toBe('denied') + expect(calls.map((c) => c.path)).toEqual([VAPID_PUBLIC_KEY_PATH]) + }) + + it('rejects with an Error, not a raw DOMException, when the browser cannot subscribe', async () => { + const cause = new DOMException('push service not available', 'AbortError') + const { env, api } = setup({ subscribeError: cause }) + const error = await subscribePush(api, env).catch((e: unknown) => e) + expect(error).not.toBeInstanceOf(DOMException) + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toBe('Web Push: the browser could not subscribe: push service not available') + expect((error as Error).cause).toBe(cause) + }) + + it('rejects when no service worker is registered', async () => { + const { env, api } = setup({ registered: false }) + await expect(subscribePush(api, env)).rejects.toThrow(/no service worker is registered/) + }) + + it('stores an existing subscription made with the same key again, without resubscribing', async () => { + const current = subscription() + const { env, api, calls, pushManager } = setup({ permission: 'granted', current }) + expect(await subscribePush(api, env)).toBe('subscribed') + expect(pushManager.subscribe).not.toHaveBeenCalled() + expect(current.unsubscribe).not.toHaveBeenCalled() + expect(calls.map((c) => c.request?.method ?? 'GET')).toEqual(['GET', 'POST']) + }) + + it('replaces a subscription made with an older key', async () => { + const old = subscription(bytes(65, 9).buffer, 'https://push.example/send/old') + const { env, api, calls, pushManager } = setup({ permission: 'granted', current: old }) + expect(await subscribePush(api, env)).toBe('subscribed') + expect(old.unsubscribe).toHaveBeenCalled() + expect(pushManager.subscribe).toHaveBeenCalled() + expect(calls.map((c) => [c.request?.method ?? 'GET', c.request?.body])).toEqual([ + ['GET', undefined], + ['DELETE', { endpoint: 'https://push.example/send/old' }], + ['POST', expect.objectContaining({ endpoint })], + ]) + }) + + it('passes on an API failure storing the subscription', async () => { + const { env, api } = setup() + const refused = new ApiError(400, 'invalid_subscription', 'endpoint: want an absolute https:// URL', false) + vi.mocked(api.apiFetch).mockImplementation(async (path: string) => { + if (path === VAPID_PUBLIC_KEY_PATH) return { public_key: vapidKeyText } as never + throw refused + }) + await expect(subscribePush(api, env)).rejects.toBe(refused) + }) + + it('rejects a VAPID key response without a public_key', async () => { + const { env, api } = setup() + vi.mocked(api.apiFetch).mockResolvedValue({} as never) + await expect(subscribePush(api, env)).rejects.toThrow(`GET ${VAPID_PUBLIC_KEY_PATH}: response has no public_key`) + }) +}) + +describe('unsubscribePush', () => { + it('deletes the subscription on the server, then in the browser', async () => { + const current = subscription() + const { env, api, calls } = setup({ current }) + expect(await unsubscribePush(api, env)).toBe('unsubscribed') + expect(calls).toEqual([{ path: SUBSCRIPTIONS_PATH, request: { method: 'DELETE', body: { endpoint } } }]) + expect(current.unsubscribe).toHaveBeenCalled() + }) + + it('answers not-subscribed without calling the server when there is no subscription', async () => { + const { env, api } = setup({ current: null }) + expect(await unsubscribePush(api, env)).toBe('not-subscribed') + expect(api.apiFetch).not.toHaveBeenCalled() + }) + + it('answers not-subscribed when no service worker is registered', async () => { + const { env, api } = setup({ registered: false }) + expect(await unsubscribePush(api, env)).toBe('not-subscribed') + expect(api.apiFetch).not.toHaveBeenCalled() + }) + + it('answers unsupported without Web Push', async () => { + const { env, api } = setup() + expect(await unsubscribePush(api, { ...env, pushManager: false })).toBe('unsupported') + }) + + it('keeps the browser subscribed when the server delete fails, so trying again works', async () => { + const current = subscription() + const { env, api } = setup({ current }) + vi.mocked(api.apiFetch).mockRejectedValue(new TypeError('Failed to fetch')) + await expect(unsubscribePush(api, env)).rejects.toThrow('Failed to fetch') + expect(current.unsubscribe).not.toHaveBeenCalled() + }) + + it('rejects when the browser does not remove the subscription', async () => { + const current = subscription() + current.unsubscribe.mockResolvedValue(false) + const { env, api } = setup({ current }) + await expect(unsubscribePush(api, env)).rejects.toThrow(/did not remove its push subscription/) + }) +}) diff --git a/web/src/push/subscribe.ts b/web/src/push/subscribe.ts new file mode 100644 index 0000000..7bd182e --- /dev/null +++ b/web/src/push/subscribe.ts @@ -0,0 +1,209 @@ +import type { ApiClient } from '../api/client.ts' +import { isRecord } from '../api/errors.ts' + +export const VAPID_PUBLIC_KEY_PATH = '/api/v1/push/vapid-public-key' +export const SUBSCRIPTIONS_PATH = '/api/v1/push/subscriptions' + +// MAX_USER_AGENT_BYTES is the server's limit on a subscription's user_agent +// (internal/push MaxUserAgentBytes). +export const MAX_USER_AGENT_BYTES = 512 + +// SubscribePushResult is how turning on Web Push on this device ended: +// subscribed, the browser's permission denied (now or earlier), the +// permission prompt dismissed without an answer, or a browser without Web +// Push. Any other failure rejects with an Error (an ApiError from the API). +export type SubscribePushResult = 'subscribed' | 'denied' | 'dismissed' | 'unsupported' + +// UnsubscribePushResult is how turning off Web Push on this device ended: +// its subscription removed from the browser and the server, no subscription +// to remove, or a browser without Web Push. Any other failure rejects. +export type UnsubscribePushResult = 'unsubscribed' | 'not-subscribed' | 'unsupported' + +// The parts of the browser's Push API this module uses. +export type BrowserPushSubscription = { + readonly endpoint: string + readonly options: { readonly applicationServerKey: ArrayBuffer | null } + getKey(name: 'p256dh' | 'auth'): ArrayBuffer | null + unsubscribe(): Promise +} + +export type BrowserPushManager = { + getSubscription(): Promise + subscribe(options: { userVisibleOnly: boolean; applicationServerKey: Uint8Array }): Promise +} + +// PushEnvironment is what the browser offers for Web Push. A field is +// undefined (or pushManager false) where the browser lacks it. +export type PushEnvironment = { + serviceWorker: { getRegistration(): Promise<{ readonly pushManager: BrowserPushManager } | undefined> } | undefined + pushManager: boolean + notification: + | { readonly permission: NotificationPermission; requestPermission(): Promise } + | undefined + // Whether the page is handling a user gesture right now. + userActivation: { readonly isActive: boolean } | undefined + userAgent: string +} + +// browserPushEnvironment reads the PushEnvironment from this window. +export function browserPushEnvironment(): PushEnvironment { + return { + serviceWorker: 'serviceWorker' in navigator ? navigator.serviceWorker : undefined, + pushManager: 'PushManager' in window, + notification: 'Notification' in window ? Notification : undefined, + userActivation: 'userActivation' in navigator ? navigator.userActivation : undefined, + userAgent: navigator.userAgent, + } +} + +type PushApi = Pick + +// PermissionGestureError is why subscribePush refused to ask for notification +// permission: no tap is active right now ('inactive'), or the browser cannot +// say whether one is ('unobservable', no navigator.userActivation). +export class PermissionGestureError extends Error { + readonly reason: 'inactive' | 'unobservable' + + constructor(reason: 'inactive' | 'unobservable') { + super( + reason === 'inactive' + ? 'subscribePush: notification permission can only be asked for from a tap on a button' + : 'subscribePush: this browser cannot report whether a tap is active (no navigator.userActivation), so notification permission is not asked for', + ) + this.name = 'PermissionGestureError' + this.reason = reason + } +} + +// encodeBase64url encodes bytes as unpadded base64url, as the server wants +// subscription keys. +export function encodeBase64url(bytes: ArrayBuffer): string { + let binary = '' + for (const byte of new Uint8Array(bytes)) binary += String.fromCharCode(byte) + return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '') +} + +// decodeVapidKey decodes the server's VAPID public key, unpadded base64url +// of the 65-byte uncompressed P-256 point, into an applicationServerKey. +export function decodeVapidKey(key: string): Uint8Array { + const problem = `GET ${VAPID_PUBLIC_KEY_PATH}: public_key is not the unpadded base64url of a 65-byte uncompressed P-256 point` + if (!/^[A-Za-z0-9_-]+$/.test(key) || key.length % 4 === 1) throw new Error(problem) + const binary = atob(key.replaceAll('-', '+').replaceAll('_', '/')) + const bytes = new Uint8Array(new ArrayBuffer(binary.length)) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + if (bytes.length !== 65 || bytes[0] !== 0x04) throw new Error(problem) + return bytes +} + +function sameKey(current: ArrayBuffer | null, key: Uint8Array): boolean { + if (current === null || current.byteLength !== key.length) return false + const bytes = new Uint8Array(current) + return bytes.every((byte, i) => byte === key[i]) +} + +// SubscriptionBody is the POST /api/v1/push/subscriptions body. The server +// refuses unknown fields and nulls, so it is built field by field rather +// than from PushSubscription.toJSON(), whose expirationTime may be null. +export type SubscriptionBody = { endpoint: string; keys: { p256dh: string; auth: string }; user_agent?: string } + +// subscriptionBody is the body that stores subscription. The user agent only +// labels the device, so one that is blank or over the server's limit is left +// out rather than sent to be refused. +export function subscriptionBody(subscription: BrowserPushSubscription, userAgent: string): SubscriptionBody { + const p256dh = subscription.getKey('p256dh') + const auth = subscription.getKey('auth') + if (p256dh === null || auth === null) { + throw new Error('Web Push: the browser gave a subscription without its p256dh and auth keys') + } + const body: SubscriptionBody = { + endpoint: subscription.endpoint, + keys: { p256dh: encodeBase64url(p256dh), auth: encodeBase64url(auth) }, + } + if (userAgent.trim() !== '' && new TextEncoder().encode(userAgent).length <= MAX_USER_AGENT_BYTES) { + body.user_agent = userAgent + } + return body +} + +async function fetchVapidKey(api: PushApi): Promise> { + const body = await api.apiFetch(VAPID_PUBLIC_KEY_PATH) + if (!isRecord(body) || typeof body.public_key !== 'string') { + throw new Error(`GET ${VAPID_PUBLIC_KEY_PATH}: response has no public_key`) + } + return decodeVapidKey(body.public_key) +} + +// removeSubscription deletes subscription on the server, then in the +// browser. Deleting on the server first means a failure leaves both still +// subscribed, so trying again finds the subscription and finishes the job. +async function removeSubscription(api: PushApi, subscription: BrowserPushSubscription): Promise { + await api.apiFetch(SUBSCRIPTIONS_PATH, { method: 'DELETE', body: { endpoint: subscription.endpoint } }) + if (!(await subscription.unsubscribe())) { + throw new Error('Web Push: the browser did not remove its push subscription') + } +} + +function errorName(error: unknown): string | undefined { + return isRecord(error) && typeof error.name === 'string' ? error.name : undefined +} + +// subscribePush turns on Web Push on this device. Call it only from the +// household's tap on a button: when the browser has not yet been asked, it +// asks for notification permission, which browsers allow only during a user +// gesture, and it refuses to ask outside one — or where the browser cannot +// say whether a gesture is active — with a PermissionGestureError. Permission +// is asked before +// anything is awaited so the gesture still counts. With permission, it +// subscribes the browser with the server's VAPID public key — replacing a +// subscription made with an older key — and stores the subscription on the +// server. Subscribing again when already subscribed stores it again. +export async function subscribePush(api: PushApi, env: PushEnvironment): Promise { + const { serviceWorker, notification } = env + if (serviceWorker === undefined || !env.pushManager || notification === undefined) return 'unsupported' + + let permission = notification.permission + if (permission === 'default') { + if (env.userActivation === undefined) throw new PermissionGestureError('unobservable') + if (!env.userActivation.isActive) throw new PermissionGestureError('inactive') + permission = await notification.requestPermission() + } + if (permission === 'denied') return 'denied' + if (permission === 'default') return 'dismissed' + + const found = await serviceWorker.getRegistration() + if (found === undefined) { + throw new Error('Web Push: no service worker is registered for this page, so it cannot receive notifications') + } + const { pushManager } = found + const key = await fetchVapidKey(api) + let subscription = await pushManager.getSubscription() + if (subscription !== null && !sameKey(subscription.options.applicationServerKey, key)) { + await removeSubscription(api, subscription) + subscription = null + } + if (subscription === null) { + try { + subscription = await pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key }) + } catch (error) { + if (errorName(error) === 'NotAllowedError') return 'denied' + throw new Error(`Web Push: the browser could not subscribe: ${(error as Error).message}`, { cause: error }) + } + } + await api.apiFetch(SUBSCRIPTIONS_PATH, { method: 'POST', body: subscriptionBody(subscription, env.userAgent) }) + return 'subscribed' +} + +// unsubscribePush turns off Web Push on this device: it removes the +// browser's subscription on the server and in the browser. With no +// subscription there is nothing to remove, and it answers 'not-subscribed' +// without calling the server. +export async function unsubscribePush(api: PushApi, env: PushEnvironment): Promise { + const { serviceWorker } = env + if (serviceWorker === undefined || !env.pushManager) return 'unsupported' + const found = await serviceWorker.getRegistration() + if (found === undefined) return 'not-subscribed' + const subscription = await found.pushManager.getSubscription() + if (subscription === null) return 'not-subscribed' + await removeSubscription(api, subscription) + return 'unsubscribed' +} diff --git a/web/src/push/usePush.ts b/web/src/push/usePush.ts new file mode 100644 index 0000000..65078fd --- /dev/null +++ b/web/src/push/usePush.ts @@ -0,0 +1,29 @@ +import { useMemo } from 'react' +import { useApi } from '../api/ApiContext.tsx' +import { + browserPushEnvironment, + subscribePush, + unsubscribePush, + type SubscribePushResult, + type UnsubscribePushResult, +} from './subscribe.ts' + +export type Push = { + // subscribePush turns on Web Push on this device; call it only from a tap. + subscribePush(): Promise + // unsubscribePush turns off Web Push on this device. + unsubscribePush(): Promise +} + +// usePush binds subscribePush and unsubscribePush to the app's API client +// and this browser. +export function usePush(): Push { + const api = useApi() + return useMemo( + () => ({ + subscribePush: () => subscribePush(api, browserPushEnvironment()), + unsubscribePush: () => unsubscribePush(api, browserPushEnvironment()), + }), + [api], + ) +} diff --git a/web/src/sw.test.ts b/web/src/sw.test.ts index d2f7b01..1367d50 100644 --- a/web/src/sw.test.ts +++ b/web/src/sw.test.ts @@ -50,3 +50,57 @@ describe('service worker activation', () => { expect(cacheStorage.delete).toHaveBeenCalledTimes(2) }) }) + +// The push listeners only hand src/push/notification.ts the worker's globals; +// jsdom has no ServiceWorkerGlobalScope, so registration and clients are +// stubbed and the events are plain Events with the worker event's fields. +describe('service worker push listeners', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + async function dispatch(type: string, fields: object): Promise { + vi.stubGlobal('caches', { keys: vi.fn(async () => []), delete: vi.fn() }) + vi.stubGlobal('__WB_MANIFEST', []) + await import('./sw') + const pending: Promise[] = [] + self.dispatchEvent( + Object.assign(new Event(type), { waitUntil: (promise: Promise) => pending.push(promise) }, fields), + ) + expect(pending).toHaveLength(1) + await Promise.all(pending) + } + + it('shows a push message as a notification', async () => { + const registration = { showNotification: vi.fn(async () => {}) } + vi.stubGlobal('registration', registration) + const payload = JSON.stringify({ title: 'Plan ready', body: 'Adjust the Draft board.', tag: 'plan_ready', url: '/plan' }) + await dispatch('push', { data: { text: () => payload } }) + expect(registration.showNotification).toHaveBeenCalledWith('Plan ready', { + body: 'Adjust the Draft board.', + tag: 'plan_ready', + icon: '/icons/icon-192.png', + badge: '/icons/icon-192.png', + data: { url: '/plan' }, + }) + }) + + it('shows the generic notification for a push without a payload', async () => { + const registration = { showNotification: vi.fn(async () => {}) } + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.stubGlobal('registration', registration) + await dispatch('push', { data: null }) + expect(registration.showNotification).toHaveBeenCalledWith('MealQuest', expect.objectContaining({ data: { url: '/' } })) + expect(warn).toHaveBeenCalledWith('MealQuest: push message has no payload; showing the generic notification') + warn.mockRestore() + }) + + it('closes a tapped notification and opens its url', async () => { + const clients = { matchAll: vi.fn(async () => []), openWindow: vi.fn(async () => null) } + vi.stubGlobal('clients', clients) + const notification = { data: { url: '/discover' }, close: vi.fn() } + await dispatch('notificationclick', { notification }) + expect(notification.close).toHaveBeenCalled() + expect(clients.openWindow).toHaveBeenCalledWith(`${self.location.origin}/discover`) + }) +}) diff --git a/web/src/sw.ts b/web/src/sw.ts index 3983585..d069d0a 100644 --- a/web/src/sw.ts +++ b/web/src/sw.ts @@ -15,6 +15,7 @@ import { ExpirationPlugin } from 'workbox-expiration' import { PrecacheFallbackPlugin, matchPrecache, precacheAndRoute } from 'workbox-precaching' import { registerRoute } from 'workbox-routing' import { CacheFirst, NetworkOnly, type Strategy } from 'workbox-strategies' +import { openNotification, showPush } from './push/notification' import { createApiStrategy } from './sw/apiStrategy' import { cacheNames, @@ -113,3 +114,18 @@ self.addEventListener('activate', (event) => { self.addEventListener('message', (event) => { if (event.data?.type === 'SKIP_WAITING') void self.skipWaiting() }) + +// Web Push: src/push/notification.ts decides what to show and where a tap +// goes; these listeners only hand it the worker's globals. A payload that is +// not a MealQuest notice still shows a generic notification, and the reason +// is logged. +self.addEventListener('push', (event) => { + event.waitUntil( + showPush(event.data?.text() ?? null, self.registration, self.location.origin, (message) => console.warn(message)), + ) +}) + +self.addEventListener('notificationclick', (event) => { + event.notification.close() + event.waitUntil(openNotification(event.notification.data, self.clients, self.location.origin)) +}) diff --git a/web/src/sw/worker.test.ts b/web/src/sw/worker.test.ts index 9c858c5..4bd01cc 100644 --- a/web/src/sw/worker.test.ts +++ b/web/src/sw/worker.test.ts @@ -44,3 +44,13 @@ describe('service worker source', () => { ) }) }) + +describe('service worker push listeners source', () => { + it('delegates push and notificationclick to src/push/notification.ts', () => { + expect(source).toContain("import { openNotification, showPush } from './push/notification'") + expect(source).toMatch(/self\.addEventListener\('push', \(event\) => \{\s*event\.waitUntil\(\s*showPush\(event\.data\?\.text\(\) \?\? null, self\.registration, self\.location\.origin,/) + expect(source).toMatch( + /self\.addEventListener\('notificationclick', \(event\) => \{\s*event\.notification\.close\(\)\s*event\.waitUntil\(openNotification\(event\.notification\.data, self\.clients, self\.location\.origin\)\)/, + ) + }) +})