feat(server): deliver push without a Firebase project
Every notification depended on Firebase Cloud Messaging, so a missing Firebase project, which is the current state, meant no notification could be delivered on any platform. Web Push and token-based Apple Push are now first class transports alongside FCM, chosen per registered device, and a scheduled Cloudflare Worker drain retries an outbox so a provider outage delays rather than drops a message.
This commit is contained in:
parent
5aa268970a
commit
bb0e54dcee
13 changed files with 1412 additions and 41 deletions
251
server/supabase/functions/_shared/webpush.test.ts
Normal file
251
server/supabase/functions/_shared/webpush.test.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
// server/supabase/functions/_shared/webpush.test.ts
|
||||
// RFC 8291 (aes128gcm) + VAPID transport tests. Firebase-free web push.
|
||||
|
||||
import {
|
||||
encryptWebPushPayload,
|
||||
parseWebPushSubscription,
|
||||
readWebPushConfig,
|
||||
sendWebPushMessage,
|
||||
type WebPushConfig,
|
||||
type WebPushSubscription,
|
||||
} from './webpush.ts'
|
||||
import { PushContractError, type PushNotification } from './push-contract.ts'
|
||||
|
||||
function assert(condition: boolean, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message)
|
||||
}
|
||||
|
||||
async function assertPushError(
|
||||
action: () => unknown | Promise<unknown>,
|
||||
code: string,
|
||||
status: number,
|
||||
stale?: boolean,
|
||||
): Promise<void> {
|
||||
let actual: unknown
|
||||
try {
|
||||
await action()
|
||||
} catch (error) {
|
||||
actual = error
|
||||
}
|
||||
assert(actual instanceof PushContractError, `expected PushContractError for ${code}`)
|
||||
assert(actual.code === code, `expected ${code}, received ${actual.code}`)
|
||||
assert(actual.status === status, `expected status ${status}, received ${actual.status}`)
|
||||
if (stale !== undefined) {
|
||||
assert(actual.staleRegistration === stale, `expected stale=${stale}`)
|
||||
}
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
function b64url(bytes: Uint8Array): string {
|
||||
let binary = ''
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte)
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
function unb64url(value: string): Uint8Array {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
|
||||
const binary = atob(padded)
|
||||
return Uint8Array.from(binary, (character) => character.charCodeAt(0))
|
||||
}
|
||||
|
||||
function concat(...parts: Uint8Array[]): Uint8Array {
|
||||
const output = new Uint8Array(parts.reduce((sum, part) => sum + part.byteLength, 0))
|
||||
let offset = 0
|
||||
for (const part of parts) {
|
||||
output.set(part, offset)
|
||||
offset += part.byteLength
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
const buffer = new ArrayBuffer(bytes.byteLength)
|
||||
new Uint8Array(buffer).set(bytes)
|
||||
return buffer
|
||||
}
|
||||
|
||||
async function hmacSha256(key: Uint8Array, data: Uint8Array): Promise<Uint8Array> {
|
||||
const imported = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
toArrayBuffer(key),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign'],
|
||||
)
|
||||
return new Uint8Array(await crypto.subtle.sign('HMAC', imported, toArrayBuffer(data)))
|
||||
}
|
||||
|
||||
async function hkdfExpand(prk: Uint8Array, info: Uint8Array, length: number): Promise<Uint8Array> {
|
||||
const blocks = Math.ceil(length / 32)
|
||||
const output = new Uint8Array(blocks * 32)
|
||||
let previous = new Uint8Array(0)
|
||||
for (let index = 0; index < blocks; index += 1) {
|
||||
const block = await hmacSha256(prk, concat(previous, info, Uint8Array.of(index + 1)))
|
||||
output.set(block, index * 32)
|
||||
previous = new Uint8Array(block)
|
||||
}
|
||||
return output.slice(0, length)
|
||||
}
|
||||
|
||||
async function makeVapidConfig(): Promise<WebPushConfig> {
|
||||
const keys = await crypto.subtle.generateKey(
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
true,
|
||||
['sign', 'verify'],
|
||||
)
|
||||
const publicRaw = new Uint8Array(await crypto.subtle.exportKey('raw', keys.publicKey))
|
||||
const jwk = await crypto.subtle.exportKey('jwk', keys.privateKey)
|
||||
assert(typeof jwk.d === 'string', 'private scalar must be exportable')
|
||||
return {
|
||||
publicKey: b64url(publicRaw),
|
||||
privateKey: jwk.d,
|
||||
subject: 'mailto:push@d3ro.test',
|
||||
}
|
||||
}
|
||||
|
||||
async function makeSubscription(): Promise<{ registrationId: string; subscription: WebPushSubscription; privateKey: CryptoKey; publicRaw: Uint8Array }> {
|
||||
const uaKeys = await crypto.subtle.generateKey(
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
true,
|
||||
['deriveBits'],
|
||||
)
|
||||
const publicRaw = new Uint8Array(await crypto.subtle.exportKey('raw', uaKeys.publicKey))
|
||||
const auth = crypto.getRandomValues(new Uint8Array(16))
|
||||
const subscription: WebPushSubscription = {
|
||||
endpoint: 'https://push.example.com/subscriptions/abc123',
|
||||
p256dh: b64url(publicRaw),
|
||||
auth: b64url(auth),
|
||||
}
|
||||
return {
|
||||
registrationId: JSON.stringify({ endpoint: subscription.endpoint, keys: { p256dh: subscription.p256dh, auth: subscription.auth } }),
|
||||
subscription,
|
||||
privateKey: uaKeys.privateKey,
|
||||
publicRaw,
|
||||
}
|
||||
}
|
||||
|
||||
const notification: PushNotification = {
|
||||
title: 'Transcription complete',
|
||||
body: 'Open D3RO Voice',
|
||||
data: { schema_version: '1', event_type: 'transcription.completed', resource_id: '11111111-2222-4333-8444-555555555555', route: 'HistoryDetail', history_id: '11111111-2222-4333-8444-555555555555' },
|
||||
}
|
||||
|
||||
Deno.test('web push config requires valid VAPID credentials', () => {
|
||||
const empty = () => undefined
|
||||
assertPushError(() => readWebPushConfig(empty), 'webpush_not_configured', 503)
|
||||
const malformed = (name: string) => ({
|
||||
WEBPUSH_VAPID_PUBLIC_KEY: 'not-a-key',
|
||||
WEBPUSH_VAPID_PRIVATE_KEY: 'also-bad',
|
||||
WEBPUSH_SUBJECT: 'mailto:push@d3ro.test',
|
||||
})[name]
|
||||
assertPushError(() => readWebPushConfig(malformed), 'webpush_credentials_invalid', 503)
|
||||
})
|
||||
|
||||
Deno.test('web push subscription parsing is strict', async () => {
|
||||
const { registrationId, subscription } = await makeSubscription()
|
||||
const parsed = parseWebPushSubscription(registrationId)
|
||||
assert(parsed.endpoint === subscription.endpoint, 'endpoint preserved')
|
||||
assert(parsed.p256dh === subscription.p256dh, 'p256dh preserved')
|
||||
|
||||
assertPushError(() => parseWebPushSubscription('short'), 'webpush_registration_invalid', 400, true)
|
||||
assertPushError(
|
||||
() => parseWebPushSubscription(JSON.stringify({ endpoint: 'http://insecure.example.com', keys: { p256dh: subscription.p256dh, auth: subscription.auth } })),
|
||||
'webpush_registration_invalid',
|
||||
400,
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
Deno.test('aes128gcm encryption round-trips per RFC 8291', async () => {
|
||||
const { registrationId, subscription, privateKey, publicRaw } = await makeSubscription()
|
||||
const fixedSalt = crypto.getRandomValues(new Uint8Array(16))
|
||||
const payload = encoder.encode('hello web push')
|
||||
const body = await encryptWebPushPayload(
|
||||
parseWebPushSubscription(registrationId),
|
||||
payload,
|
||||
() => fixedSalt,
|
||||
)
|
||||
|
||||
// Header: salt(16) | rs(4) | idlen(1) | keyid(65)
|
||||
assert(body.byteLength === 16 + 4 + 1 + 65 + payload.byteLength + 1 + 16, 'body length matches aes128gcm')
|
||||
const salt = body.slice(0, 16)
|
||||
const recordSize = new DataView(body.buffer, body.byteOffset + 16, 4).getUint32(0, false)
|
||||
assert(recordSize === 4096, 'record size is 4096')
|
||||
assert(body[20] === 65, 'key id length is 65')
|
||||
const asPublic = body.slice(21, 86)
|
||||
const ciphertext = body.slice(86)
|
||||
|
||||
assert(salt.every((byte, index) => byte === fixedSalt[index]), 'salt preserved')
|
||||
|
||||
// Decrypt as the user agent would.
|
||||
const asKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
toArrayBuffer(asPublic),
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
false,
|
||||
[],
|
||||
)
|
||||
const shared = new Uint8Array(
|
||||
await crypto.subtle.deriveBits({ name: 'ECDH', public: asKey }, privateKey, 256),
|
||||
)
|
||||
const prkKey = await hmacSha256(unb64url(subscription.auth), shared)
|
||||
const keyInfo = concat(encoder.encode('WebPush: info\x00'), publicRaw, asPublic)
|
||||
const ikm = await hkdfExpand(prkKey, keyInfo, 32)
|
||||
const prk = await hmacSha256(salt, ikm)
|
||||
const cek = await hkdfExpand(prk, encoder.encode('Content-Encoding: aes128gcm\x00'), 16)
|
||||
const nonce = await hkdfExpand(prk, encoder.encode('Content-Encoding: nonce\x00'), 12)
|
||||
const aesKey = await crypto.subtle.importKey('raw', toArrayBuffer(cek), 'AES-GCM', false, ['decrypt'])
|
||||
const plaintext = new Uint8Array(
|
||||
await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: toArrayBuffer(nonce), tagLength: 128 },
|
||||
aesKey,
|
||||
toArrayBuffer(ciphertext),
|
||||
),
|
||||
)
|
||||
const decoded = plaintext.slice(0, plaintext.byteLength - 1)
|
||||
assert(plaintext[plaintext.byteLength - 1] === 0x02, 'last-record delimiter is 0x02')
|
||||
assert(new TextDecoder().decode(decoded) === 'hello web push', 'payload round-trips')
|
||||
})
|
||||
|
||||
Deno.test('web push send maps provider responses', async () => {
|
||||
const { registrationId } = await makeSubscription()
|
||||
const config = await makeVapidConfig()
|
||||
|
||||
const okFetch = (() => Promise.resolve(new Response(null, { status: 201 }))) as unknown as typeof fetch
|
||||
const result = await sendWebPushMessage(registrationId, notification, { config, fetchImpl: okFetch })
|
||||
assert(result.status === 201, 'created status returned')
|
||||
|
||||
const staleFetch = (() => Promise.resolve(new Response(null, { status: 410 }))) as unknown as typeof fetch
|
||||
await assertPushError(
|
||||
() => sendWebPushMessage(registrationId, notification, { config, fetchImpl: staleFetch }),
|
||||
'webpush_registration_stale',
|
||||
410,
|
||||
true,
|
||||
)
|
||||
|
||||
const tooLarge = (() => Promise.resolve(new Response(null, { status: 413 }))) as unknown as typeof fetch
|
||||
await assertPushError(
|
||||
() => sendWebPushMessage(registrationId, notification, { config, fetchImpl: tooLarge }),
|
||||
'webpush_payload_too_large',
|
||||
500,
|
||||
)
|
||||
|
||||
const failed = (() => Promise.resolve(new Response(null, { status: 500 }))) as unknown as typeof fetch
|
||||
await assertPushError(
|
||||
() => sendWebPushMessage(registrationId, notification, { config, fetchImpl: failed }),
|
||||
'webpush_send_failed',
|
||||
502,
|
||||
)
|
||||
})
|
||||
|
||||
Deno.test('web push send rejects a malformed registration id', async () => {
|
||||
const config = await makeVapidConfig()
|
||||
await assertPushError(
|
||||
() => sendWebPushMessage('not-json-registration-id', notification, { config }),
|
||||
'webpush_registration_invalid',
|
||||
400,
|
||||
true,
|
||||
)
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue