From 578e60c0bcc732bcd429e248e80ea4e719162315 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:06:30 +0200 Subject: [PATCH] feat: nevermind, migrate push notification handling from UnifiedPush to FCM --- app/api/push/jmap/[id]/route.ts | 15 ++- app/api/push/register/route.ts | 34 ++----- app/api/push/vapid/route.ts | 22 ----- lib/push/base64url.ts | 14 --- lib/push/fcm.ts | 158 ++++++++++++++++++++++++++++++++ lib/push/types.ts | 8 +- lib/push/unified-push.ts | 57 ------------ lib/push/validation.ts | 31 +------ lib/push/vapid.ts | 90 ------------------ lib/push/web-push.ts | 149 ------------------------------ 10 files changed, 183 insertions(+), 395 deletions(-) delete mode 100644 app/api/push/vapid/route.ts delete mode 100644 lib/push/base64url.ts create mode 100644 lib/push/fcm.ts delete mode 100644 lib/push/unified-push.ts delete mode 100644 lib/push/vapid.ts delete mode 100644 lib/push/web-push.ts diff --git a/app/api/push/jmap/[id]/route.ts b/app/api/push/jmap/[id]/route.ts index 641e7a1f..1d7850df 100644 --- a/app/api/push/jmap/[id]/route.ts +++ b/app/api/push/jmap/[id]/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { logger } from '@/lib/logger'; import { subscriptionStore } from '@/lib/push/store'; -import { sendUnifiedPush } from '@/lib/push/unified-push'; +import { sendFcmPush } from '@/lib/push/fcm'; import { isValidSubscriptionId } from '@/lib/push/validation'; import type { JmapPushBody } from '@/lib/push/types'; @@ -15,8 +15,8 @@ import type { JmapPushBody } from '@/lib/push/types'; * { "@type": "StateChange", changed: { [accountId]: { [type]: state } } } * * PushVerification is terminated here — cached against the subscription so - * the client can poll it. StateChange is fanned out as an Expo push so the - * mobile app wakes and re-fetches with its own credentials. + * the client can poll it. StateChange is fanned out via FCM so the mobile + * app wakes and re-fetches with its own credentials. */ function getAllowedOrigin(): string | null { @@ -75,10 +75,15 @@ export async function POST( } if (body['@type'] === 'StateChange') { - const ok = await sendUnifiedPush(record, body); + const result = await sendFcmPush(record, body); record.lastPushAt = Date.now(); await subscriptionStore.put(id, record); - return NextResponse.json({ ok }); + if (result.unregistered) { + // FCM told us the token is no longer valid — drop the subscription + // so the device re-registers on next launch. + await subscriptionStore.delete(id); + } + return NextResponse.json({ ok: result.ok }); } return NextResponse.json({ error: 'Unsupported JMAP push type' }, { status: 400 }); diff --git a/app/api/push/register/route.ts b/app/api/push/register/route.ts index 415a14ad..1f586f9e 100644 --- a/app/api/push/register/route.ts +++ b/app/api/push/register/route.ts @@ -1,45 +1,31 @@ import { NextRequest, NextResponse } from 'next/server'; import { logger } from '@/lib/logger'; import { subscriptionStore } from '@/lib/push/store'; -import { - isValidAuthSecret, - isValidP256dh, - isValidPushEndpoint, - isValidSubscriptionId, -} from '@/lib/push/validation'; +import { isValidFcmToken, isValidSubscriptionId } from '@/lib/push/validation'; import type { SubscriptionRecord } from '@/lib/push/types'; /** * POST /api/push/register - * Body: { subscriptionId, endpoint, p256dh, auth, accountLabel? } + * Body: { subscriptionId, fcmToken, accountLabel? } * - * Called by the mobile app once its UnifiedPush distributor has produced a - * push endpoint. We hold only the Web Push keys needed to encrypt payloads - * per RFC 8291 — no user credentials pass through the relay. + * Called by the mobile app once Firebase has issued an FCM registration + * token. The relay stores only the opaque token — no user credentials. */ export async function POST(request: NextRequest) { try { const body = (await request.json().catch(() => null)) as { subscriptionId?: unknown; - endpoint?: unknown; - p256dh?: unknown; - auth?: unknown; + fcmToken?: unknown; accountLabel?: unknown; } | null; if (!body) return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - const { subscriptionId, endpoint, p256dh, auth, accountLabel } = body; + const { subscriptionId, fcmToken, accountLabel } = body; if (!isValidSubscriptionId(subscriptionId)) { return NextResponse.json({ error: 'Invalid subscriptionId' }, { status: 400 }); } - if (!isValidPushEndpoint(endpoint)) { - return NextResponse.json({ error: 'Invalid endpoint' }, { status: 400 }); - } - if (!isValidP256dh(p256dh)) { - return NextResponse.json({ error: 'Invalid p256dh' }, { status: 400 }); - } - if (!isValidAuthSecret(auth)) { - return NextResponse.json({ error: 'Invalid auth' }, { status: 400 }); + if (!isValidFcmToken(fcmToken)) { + return NextResponse.json({ error: 'Invalid fcmToken' }, { status: 400 }); } // Preserve verificationCode if a previous record exists — the JMAP server @@ -47,9 +33,7 @@ export async function POST(request: NextRequest) { const existing = await subscriptionStore.get(subscriptionId); const record: SubscriptionRecord = { - endpoint, - p256dh, - auth, + fcmToken, verificationCode: existing?.verificationCode ?? null, createdAt: existing?.createdAt ?? Date.now(), lastPushAt: existing?.lastPushAt ?? null, diff --git a/app/api/push/vapid/route.ts b/app/api/push/vapid/route.ts deleted file mode 100644 index 7e7650f1..00000000 --- a/app/api/push/vapid/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NextResponse } from 'next/server'; -import { logger } from '@/lib/logger'; -import { getVapidKeys } from '@/lib/push/vapid'; - -/** - * GET /api/push/vapid - * - * Returns the relay's VAPID public key. The mobile app passes this to the - * UnifiedPush distributor so the distributor can lock the endpoint to pushes - * signed by us. Generated on first call; persisted under `data/push/vapid.json`. - */ -export async function GET() { - try { - const keys = await getVapidKeys(); - return NextResponse.json({ publicKey: keys.publicKey }); - } catch (error) { - logger.error('push: vapid key fetch failed', { - error: error instanceof Error ? error.message : 'Unknown error', - }); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} diff --git a/lib/push/base64url.ts b/lib/push/base64url.ts deleted file mode 100644 index ac168166..00000000 --- a/lib/push/base64url.ts +++ /dev/null @@ -1,14 +0,0 @@ -export function b64uEncode(buf: Buffer | Uint8Array): string { - const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf); - return b - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); -} - -export function b64uDecode(value: string): Buffer { - const padded = value.replace(/-/g, '+').replace(/_/g, '/'); - const padLen = (4 - (padded.length % 4)) % 4; - return Buffer.from(padded + '='.repeat(padLen), 'base64'); -} diff --git a/lib/push/fcm.ts b/lib/push/fcm.ts new file mode 100644 index 00000000..310837b3 --- /dev/null +++ b/lib/push/fcm.ts @@ -0,0 +1,158 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { StateChange } from './types'; +import type { SubscriptionRecord } from './types'; + +interface ServiceAccount { + type: string; + project_id: string; + private_key_id: string; + private_key: string; + client_email: string; + token_uri: string; +} + +let cachedAccount: ServiceAccount | null = null; +let cachedAccessToken: { token: string; expiresAt: number } | null = null; + +async function loadServiceAccount(): Promise { + if (cachedAccount) return cachedAccount; + + const inline = process.env.FCM_SERVICE_ACCOUNT_JSON; + if (inline && inline.trim().startsWith('{')) { + cachedAccount = JSON.parse(inline) as ServiceAccount; + return cachedAccount; + } + + const filePath = inline && inline.trim().length > 0 + ? inline + : path.join(process.env.PUSH_DATA_DIR ?? './data/push', 'fcm-service-account.json'); + const raw = await fs.readFile(filePath, 'utf8'); + cachedAccount = JSON.parse(raw) as ServiceAccount; + return cachedAccount; +} + +function base64url(buf: Buffer | string): string { + const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf); + return b.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +async function mintAccessToken(): Promise { + const now = Date.now(); + if (cachedAccessToken && cachedAccessToken.expiresAt - 60_000 > now) { + return cachedAccessToken.token; + } + + const account = await loadServiceAccount(); + const iat = Math.floor(now / 1000); + const exp = iat + 3600; + const header = { alg: 'RS256', typ: 'JWT', kid: account.private_key_id }; + const claim = { + iss: account.client_email, + scope: 'https://www.googleapis.com/auth/firebase.messaging', + aud: account.token_uri ?? 'https://oauth2.googleapis.com/token', + iat, + exp, + }; + const signingInput = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(claim))}`; + const signature = crypto + .createSign('RSA-SHA256') + .update(signingInput) + .sign(account.private_key); + const jwt = `${signingInput}.${base64url(signature)}`; + + const res = await fetch(account.token_uri ?? 'https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: jwt, + }), + }); + if (!res.ok) { + throw new Error(`FCM oauth2 token failed: ${res.status} ${await res.text()}`); + } + const body = (await res.json()) as { access_token: string; expires_in: number }; + cachedAccessToken = { + token: body.access_token, + expiresAt: now + body.expires_in * 1000, + }; + return body.access_token; +} + +export interface FcmSendResult { + ok: boolean; + status: number; + unregistered: boolean; + body?: unknown; +} + +/** + * Send a data message via FCM HTTP v1. Returns `unregistered: true` if the + * token was rejected with UNREGISTERED / NOT_FOUND — callers should delete + * the subscription in that case. + */ +export async function sendFcmPush( + record: SubscriptionRecord, + change: StateChange, +): Promise { + const account = await loadServiceAccount(); + const accessToken = await mintAccessToken(); + const hasEmail = Boolean(change.changed && Object.values(change.changed).some((types) => 'Email' in types)); + + const title = hasEmail ? 'New mail' : 'Mailbox updated'; + const body = record.accountLabel ?? 'Tap to open Bulwark'; + + const message = { + message: { + token: record.fcmToken, + android: { + priority: 'HIGH', + notification: { + title, + body, + channel_id: 'bulwark_mail', + default_sound: true, + }, + }, + data: { + kind: 'jmap-state-change', + changed: JSON.stringify(change.changed ?? {}), + }, + }, + }; + + const res = await fetch( + `https://fcm.googleapis.com/v1/projects/${encodeURIComponent(account.project_id)}/messages:send`, + { + method: 'POST', + headers: { + authorization: `Bearer ${accessToken}`, + 'content-type': 'application/json', + }, + body: JSON.stringify(message), + }, + ); + + const rawBody = await res.text(); + let parsed: unknown = rawBody; + try { + parsed = rawBody.length > 0 ? JSON.parse(rawBody) : null; + } catch { + // keep raw text + } + + const errStatus = + typeof parsed === 'object' && parsed && 'error' in parsed + ? ((parsed as { error?: { status?: string } }).error?.status ?? '') + : ''; + const unregistered = res.status === 404 || errStatus === 'UNREGISTERED' || errStatus === 'NOT_FOUND'; + + return { + ok: res.ok, + status: res.status, + unregistered, + body: parsed, + }; +} diff --git a/lib/push/types.ts b/lib/push/types.ts index 442f1a48..531942af 100644 --- a/lib/push/types.ts +++ b/lib/push/types.ts @@ -2,13 +2,7 @@ // so both server code and admin UIs can import without pulling `node:fs`. export interface SubscriptionRecord { - // UnifiedPush / Web Push endpoint the distributor gave the app. The relay - // POSTs RFC 8291 aes128gcm-encrypted bodies here. - endpoint: string; - // P-256 public key (UA), uncompressed 65-byte point, base64url without padding. - p256dh: string; - // Auth secret, 16 bytes, base64url without padding. - auth: string; + fcmToken: string; verificationCode: string | null; createdAt: number; lastPushAt: number | null; diff --git a/lib/push/unified-push.ts b/lib/push/unified-push.ts deleted file mode 100644 index 83712f03..00000000 --- a/lib/push/unified-push.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { logger } from '@/lib/logger'; -import type { StateChange, SubscriptionRecord } from './types'; -import { sendWebPush } from './web-push'; -import { getVapidKeys } from './vapid'; - -interface UnifiedPushPayload { - kind: 'jmap-state-change'; - title: string; - body: string; - changed: Record>; -} - -function buildPayload(record: SubscriptionRecord, change: StateChange): UnifiedPushPayload { - const types = new Set(); - for (const perAccount of Object.values(change.changed)) { - for (const type of Object.keys(perAccount)) types.add(type); - } - const hasEmail = types.has('Email') || types.has('EmailDelivery'); - return { - kind: 'jmap-state-change', - title: hasEmail ? 'New mail' : 'Mailbox updated', - body: record.accountLabel ?? '', - changed: change.changed, - }; -} - -export async function sendUnifiedPush( - record: SubscriptionRecord, - change: StateChange, -): Promise { - try { - const vapid = await getVapidKeys(); - const payload = Buffer.from(JSON.stringify(buildPayload(record, change)), 'utf-8'); - const result = await sendWebPush({ - endpoint: record.endpoint, - p256dh: record.p256dh, - auth: record.auth, - payload, - vapid, - ttlSeconds: 60, - urgency: 'high', - }); - if (!result.ok) { - logger.warn('push: unified-push delivery failed', { - status: result.status, - body: result.body, - }); - return false; - } - return true; - } catch (error) { - logger.warn('push: unified-push delivery error', { - error: error instanceof Error ? error.message : 'Unknown error', - }); - return false; - } -} diff --git a/lib/push/validation.ts b/lib/push/validation.ts index 3e7dc3e8..ac5a8b4a 100644 --- a/lib/push/validation.ts +++ b/lib/push/validation.ts @@ -2,30 +2,9 @@ export function isValidSubscriptionId(id: unknown): id is string { return typeof id === 'string' && /^[A-Za-z0-9_-]{8,128}$/.test(id); } -export function isValidPushEndpoint(url: unknown): url is string { - if (typeof url !== 'string' || url.length > 2048) return false; - try { - const parsed = new URL(url); - return parsed.protocol === 'https:' || parsed.protocol === 'http:'; - } catch { - return false; - } -} - -export function isBase64Url(value: unknown, minBytes: number, maxBytes: number): value is string { - if (typeof value !== 'string') return false; - if (!/^[A-Za-z0-9_-]+$/.test(value)) return false; - // base64url decoded length: floor(n * 3 / 4) where n is input length (no padding). - const decodedLen = Math.floor((value.length * 3) / 4); - return decodedLen >= minBytes && decodedLen <= maxBytes; -} - -export function isValidP256dh(value: unknown): value is string { - // Uncompressed P-256 point is 65 bytes (0x04 || X || Y). base64url no padding. - return isBase64Url(value, 64, 66); -} - -export function isValidAuthSecret(value: unknown): value is string { - // RFC 8291 requires 16 bytes. - return isBase64Url(value, 16, 16); +export function isValidFcmToken(value: unknown): value is string { + // FCM registration tokens are opaque. In practice they're ~140–250 chars of + // [A-Za-z0-9:_-]. Be permissive on length; strict on charset to block + // obvious garbage without rejecting future token formats. + return typeof value === 'string' && value.length >= 64 && value.length <= 4096 && /^[A-Za-z0-9:_\-]+$/.test(value); } diff --git a/lib/push/vapid.ts b/lib/push/vapid.ts deleted file mode 100644 index 0909d557..00000000 --- a/lib/push/vapid.ts +++ /dev/null @@ -1,90 +0,0 @@ -import crypto from 'node:crypto'; -import { readFile, writeFile, mkdir } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import path from 'node:path'; -import { b64uDecode, b64uEncode } from './base64url'; - -interface StoredVapid { - publicKey: string; - privateKeyPem: string; - subject: string; -} - -export interface VapidKeys { - publicKey: string; - publicKeyBuf: Buffer; - privateKeyPem: string; - subject: string; -} - -function getVapidPath(): string { - const dir = process.env.PUSH_DATA_DIR || path.join(process.cwd(), 'data', 'push'); - return path.join(dir, 'vapid.json'); -} - -function getSubject(): string { - return process.env.VAPID_SUBJECT?.trim() || 'mailto:admin@localhost'; -} - -// P-256 SPKI DER wraps a 26-byte algorithm identifier followed by a BIT STRING -// containing the uncompressed point (0x04 || X || Y). Strip the header to get -// the raw 65-byte public key that VAPID requires. -function extractUncompressedPoint(spkiDer: Buffer): Buffer { - if (spkiDer.length < 65) throw new Error('SPKI DER too short for P-256'); - return spkiDer.subarray(spkiDer.length - 65); -} - -let cached: VapidKeys | null = null; -let loading: Promise | null = null; - -async function loadOrCreate(): Promise { - const file = getVapidPath(); - try { - const raw = await readFile(file, 'utf-8'); - const parsed = JSON.parse(raw) as StoredVapid; - return { - publicKey: parsed.publicKey, - publicKeyBuf: b64uDecode(parsed.publicKey), - privateKeyPem: parsed.privateKeyPem, - subject: parsed.subject, - }; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; - } - - const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', { - namedCurve: 'prime256v1', - }); - const spki = publicKey.export({ format: 'der', type: 'spki' }); - const uncompressed = extractUncompressedPoint(spki); - const privateKeyPem = privateKey.export({ format: 'pem', type: 'pkcs8' }) as string; - const stored: StoredVapid = { - publicKey: b64uEncode(uncompressed), - privateKeyPem, - subject: getSubject(), - }; - - const dir = path.dirname(file); - if (!existsSync(dir)) await mkdir(dir, { recursive: true }); - const tmp = file + '.tmp'; - await writeFile(tmp, JSON.stringify(stored, null, 2), 'utf-8'); - await import('node:fs/promises').then((fs) => fs.rename(tmp, file)); - - return { - publicKey: stored.publicKey, - publicKeyBuf: uncompressed, - privateKeyPem: stored.privateKeyPem, - subject: stored.subject, - }; -} - -export async function getVapidKeys(): Promise { - if (cached) return cached; - if (!loading) { - loading = loadOrCreate().then((keys) => { - cached = keys; - return keys; - }); - } - return loading; -} diff --git a/lib/push/web-push.ts b/lib/push/web-push.ts deleted file mode 100644 index 2bc32bf4..00000000 --- a/lib/push/web-push.ts +++ /dev/null @@ -1,149 +0,0 @@ -import crypto from 'node:crypto'; -import { b64uDecode, b64uEncode } from './base64url'; -import type { VapidKeys } from './vapid'; - -// RFC 5869 HKDF-Extract + HKDF-Expand using SHA-256. -function hkdf(salt: Buffer, ikm: Buffer, info: Buffer, length: number): Buffer { - const prk = crypto.createHmac('sha256', salt).update(ikm).digest(); - const out = Buffer.alloc(length); - let previous = Buffer.alloc(0); - let offset = 0; - let counter = 1; - while (offset < length) { - const hmac = crypto.createHmac('sha256', prk); - hmac.update(previous); - hmac.update(info); - hmac.update(Buffer.from([counter])); - previous = hmac.digest(); - const copyLen = Math.min(previous.length, length - offset); - previous.subarray(0, copyLen).copy(out, offset); - offset += copyLen; - counter++; - } - return out; -} - -// RFC 8291 aes128gcm Web Push content encoding (single record). -export function encryptAes128gcm(params: { - p256dh: Buffer; - auth: Buffer; - payload: Buffer; -}): Buffer { - const ecdh = crypto.createECDH('prime256v1'); - const serverPublic = ecdh.generateKeys(); - const sharedSecret = ecdh.computeSecret(params.p256dh); - const salt = crypto.randomBytes(16); - - // RFC 8291 §3.3: PRK_key = HMAC-SHA-256(auth_secret, IKM) - // IKM = ECDH shared secret - // info = "WebPush: info\0" || ua_public || as_public - const keyInfo = Buffer.concat([ - Buffer.from('WebPush: info\0', 'utf-8'), - params.p256dh, - serverPublic, - ]); - const prkKey = hkdf(params.auth, sharedSecret, keyInfo, 32); - - // RFC 8188 §2.2: derive CEK and nonce from PRK_key with salt. - const cek = hkdf(salt, prkKey, Buffer.from('Content-Encoding: aes128gcm\0', 'utf-8'), 16); - const nonce = hkdf(salt, prkKey, Buffer.from('Content-Encoding: nonce\0', 'utf-8'), 12); - - // Single-record padding: plaintext || 0x02 (end-of-last-record delimiter). - const padded = Buffer.concat([params.payload, Buffer.from([0x02])]); - - const cipher = crypto.createCipheriv('aes-128-gcm', cek, nonce); - const ciphertext = Buffer.concat([cipher.update(padded), cipher.final()]); - const tag = cipher.getAuthTag(); - - // Per RFC 8188 §2.1: salt (16) || rs (4 BE) || idlen (1) || keyid || ciphertext+tag. - const rsBuf = Buffer.alloc(4); - rsBuf.writeUInt32BE(4096, 0); - const idlenBuf = Buffer.from([serverPublic.length]); - - return Buffer.concat([salt, rsBuf, idlenBuf, serverPublic, ciphertext, tag]); -} - -// ECDSA DER → raw r||s (64 bytes) for ES256 signature encoding used by JWT. -function derEs256ToRaw(der: Buffer): Buffer { - if (der[0] !== 0x30) throw new Error('Invalid DER signature'); - let offset = 2; - if (der[1] & 0x80) offset += der[1] & 0x7f; - if (der[offset] !== 0x02) throw new Error('Invalid DER r marker'); - const rLen = der[offset + 1]; - let r = der.subarray(offset + 2, offset + 2 + rLen); - offset += 2 + rLen; - if (der[offset] !== 0x02) throw new Error('Invalid DER s marker'); - const sLen = der[offset + 1]; - let s = der.subarray(offset + 2, offset + 2 + sLen); - const pad = (buf: Buffer) => { - if (buf.length === 32) return buf; - if (buf.length === 33 && buf[0] === 0x00) return buf.subarray(1); - if (buf.length > 32) throw new Error('ECDSA component > 32 bytes'); - const out = Buffer.alloc(32); - buf.copy(out, 32 - buf.length); - return out; - }; - return Buffer.concat([pad(r), pad(s)]); -} - -function buildVapidJwt(endpoint: string, vapid: VapidKeys): string { - const audience = new URL(endpoint).origin; - const header = b64uEncode(Buffer.from(JSON.stringify({ typ: 'JWT', alg: 'ES256' }))); - const claims = b64uEncode( - Buffer.from( - JSON.stringify({ - aud: audience, - exp: Math.floor(Date.now() / 1000) + 12 * 3600, - sub: vapid.subject, - }), - ), - ); - const signingInput = `${header}.${claims}`; - const der = crypto.createSign('SHA256').update(signingInput).sign(vapid.privateKeyPem); - return `${signingInput}.${b64uEncode(derEs256ToRaw(der))}`; -} - -export interface WebPushResult { - ok: boolean; - status: number; - body?: string; -} - -export async function sendWebPush(params: { - endpoint: string; - p256dh: string; - auth: string; - payload: Buffer; - vapid: VapidKeys; - ttlSeconds?: number; - urgency?: 'very-low' | 'low' | 'normal' | 'high'; - topic?: string; -}): Promise { - const body = encryptAes128gcm({ - p256dh: b64uDecode(params.p256dh), - auth: b64uDecode(params.auth), - payload: params.payload, - }); - const jwt = buildVapidJwt(params.endpoint, params.vapid); - const headers: Record = { - 'content-type': 'application/octet-stream', - 'content-encoding': 'aes128gcm', - 'content-length': String(body.length), - ttl: String(params.ttlSeconds ?? 60), - urgency: params.urgency ?? 'high', - authorization: `vapid t=${jwt}, k=${params.vapid.publicKey}`, - }; - if (params.topic) headers.topic = params.topic; - - const res = await fetch(params.endpoint, { - method: 'POST', - headers, - body: new Uint8Array(body), - }); - const ok = res.status >= 200 && res.status < 300; - return { - ok, - status: res.status, - body: ok ? undefined : await res.text().catch(() => undefined), - }; -}