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
308
server/supabase/functions/_shared/webpush.ts
Normal file
308
server/supabase/functions/_shared/webpush.ts
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
// server/supabase/functions/_shared/webpush.ts
|
||||
// Standard Web Push (VAPID + RFC 8291 aes128gcm) transport.
|
||||
//
|
||||
// Firebase-free: the browser Push API delivers the message; we only sign with
|
||||
// VAPID and encrypt per RFC 8291. No Google account or Firebase project needed.
|
||||
|
||||
import { PushContractError, type PushNotification } from './push-contract.ts'
|
||||
|
||||
export interface WebPushConfig {
|
||||
/** base64url encoded uncompressed P-256 public key (65 bytes, 0x04 prefix). */
|
||||
publicKey: string
|
||||
/** base64url encoded P-256 private scalar (32 bytes). */
|
||||
privateKey: string
|
||||
/** VAPID subject: `mailto:` or `https:` URL. */
|
||||
subject: string
|
||||
}
|
||||
|
||||
export interface WebPushSubscription {
|
||||
endpoint: string
|
||||
p256dh: string
|
||||
auth: string
|
||||
}
|
||||
|
||||
export interface WebPushSendOptions {
|
||||
fetchImpl?: typeof fetch
|
||||
config?: WebPushConfig
|
||||
now?: number
|
||||
randomBytes?: (length: number) => Uint8Array
|
||||
}
|
||||
|
||||
export interface WebPushSendResult {
|
||||
status: number
|
||||
}
|
||||
|
||||
const SUBJECT_PATTERN = /^(mailto:[^@\s]+@[^@\s]+|https:\/\/[^\s]+)$/
|
||||
const RECORD_SIZE = 4096
|
||||
const encoder = new TextEncoder()
|
||||
const REQUEST_TIMEOUT_MS = 10_000
|
||||
|
||||
function decodeBase64Url(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 encodeBase64Url(bytes: Uint8Array): string {
|
||||
let binary = ''
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte)
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
function concat(...parts: Uint8Array[]): Uint8Array {
|
||||
const total = parts.reduce((sum, part) => sum + part.byteLength, 0)
|
||||
const output = new Uint8Array(total)
|
||||
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'],
|
||||
)
|
||||
const signature = await crypto.subtle.sign('HMAC', imported, toArrayBuffer(data))
|
||||
return new Uint8Array(signature)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
export function readWebPushConfig(
|
||||
readEnv: (name: string) => string | undefined = (name) => Deno.env.get(name),
|
||||
): WebPushConfig {
|
||||
const publicKey = readEnv('WEBPUSH_VAPID_PUBLIC_KEY')?.trim() ?? ''
|
||||
const privateKey = readEnv('WEBPUSH_VAPID_PRIVATE_KEY')?.trim() ?? ''
|
||||
const subject = readEnv('WEBPUSH_SUBJECT')?.trim() ?? ''
|
||||
if (!publicKey || !privateKey || !subject) {
|
||||
throw new PushContractError('webpush_not_configured', 503)
|
||||
}
|
||||
try {
|
||||
const publicBytes = decodeBase64Url(publicKey)
|
||||
const privateBytes = decodeBase64Url(privateKey)
|
||||
if (
|
||||
publicBytes.byteLength !== 65
|
||||
|| publicBytes[0] !== 0x04
|
||||
|| privateBytes.byteLength !== 32
|
||||
|| !SUBJECT_PATTERN.test(subject)
|
||||
) {
|
||||
throw new Error('invalid web push credentials')
|
||||
}
|
||||
return { publicKey, privateKey, subject }
|
||||
} catch {
|
||||
throw new PushContractError('webpush_credentials_invalid', 503)
|
||||
}
|
||||
}
|
||||
|
||||
export function parseWebPushSubscription(registrationId: string): WebPushSubscription {
|
||||
if (typeof registrationId !== 'string' || registrationId.length < 20 || registrationId.length > 8192) {
|
||||
throw new PushContractError('webpush_registration_invalid', 400, true)
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(registrationId) as Record<string, unknown>
|
||||
const endpoint = typeof parsed.endpoint === 'string' ? parsed.endpoint : ''
|
||||
const keys = parsed.keys
|
||||
const p256dh = keys && typeof keys === 'object' && typeof (keys as Record<string, unknown>).p256dh === 'string'
|
||||
? (keys as Record<string, string>).p256dh
|
||||
: ''
|
||||
const auth = keys && typeof keys === 'object' && typeof (keys as Record<string, unknown>).auth === 'string'
|
||||
? (keys as Record<string, string>).auth
|
||||
: ''
|
||||
const endpointUrl = new URL(endpoint)
|
||||
const p256dhBytes = decodeBase64Url(p256dh)
|
||||
const authBytes = decodeBase64Url(auth)
|
||||
if (
|
||||
endpointUrl.protocol !== 'https:'
|
||||
|| p256dhBytes.byteLength !== 65
|
||||
|| p256dhBytes[0] !== 0x04
|
||||
|| authBytes.byteLength < 16
|
||||
|| authBytes.byteLength > 32
|
||||
) {
|
||||
throw new Error('invalid subscription')
|
||||
}
|
||||
return { endpoint: endpointUrl.toString(), p256dh, auth }
|
||||
} catch (error) {
|
||||
if (error instanceof PushContractError) throw error
|
||||
throw new PushContractError('webpush_registration_invalid', 400, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function buildVapidAuthorization(
|
||||
config: WebPushConfig,
|
||||
endpoint: string,
|
||||
nowSeconds: number,
|
||||
): Promise<string> {
|
||||
const publicBytes = decodeBase64Url(config.publicKey)
|
||||
const jwk: JsonWebKey = {
|
||||
kty: 'EC',
|
||||
crv: 'P-256',
|
||||
d: config.privateKey,
|
||||
x: encodeBase64Url(publicBytes.slice(1, 33)),
|
||||
y: encodeBase64Url(publicBytes.slice(33, 65)),
|
||||
}
|
||||
const key = await crypto.subtle.importKey(
|
||||
'jwk',
|
||||
jwk,
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
false,
|
||||
['sign'],
|
||||
)
|
||||
const header = encodeBase64Url(encoder.encode(JSON.stringify({ typ: 'JWT', alg: 'ES256' })))
|
||||
const payload = encodeBase64Url(encoder.encode(JSON.stringify({
|
||||
aud: new URL(endpoint).origin,
|
||||
exp: nowSeconds + 12 * 3600,
|
||||
sub: config.subject,
|
||||
})))
|
||||
const signingInput = `${header}.${payload}`
|
||||
const signature = await crypto.subtle.sign(
|
||||
{ name: 'ECDSA', hash: 'SHA-256' },
|
||||
key,
|
||||
encoder.encode(signingInput),
|
||||
)
|
||||
const jwt = `${signingInput}.${encodeBase64Url(new Uint8Array(signature))}`
|
||||
return `vapid t=${jwt}, k=${config.publicKey}`
|
||||
}
|
||||
|
||||
export async function encryptWebPushPayload(
|
||||
subscription: WebPushSubscription,
|
||||
payload: Uint8Array,
|
||||
randomBytes: (length: number) => Uint8Array,
|
||||
): Promise<Uint8Array> {
|
||||
const uaPublic = decodeBase64Url(subscription.p256dh)
|
||||
const authSecret = decodeBase64Url(subscription.auth)
|
||||
const salt = randomBytes(16)
|
||||
|
||||
const applicationKeys = await crypto.subtle.generateKey(
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
true,
|
||||
['deriveBits'],
|
||||
)
|
||||
const applicationPublic = new Uint8Array(
|
||||
await crypto.subtle.exportKey('raw', applicationKeys.publicKey),
|
||||
)
|
||||
const userAgentKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
toArrayBuffer(uaPublic),
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
false,
|
||||
[],
|
||||
)
|
||||
const sharedSecret = new Uint8Array(
|
||||
await crypto.subtle.deriveBits(
|
||||
{ name: 'ECDH', public: userAgentKey },
|
||||
applicationKeys.privateKey,
|
||||
256,
|
||||
),
|
||||
)
|
||||
|
||||
const prkKey = await hmacSha256(authSecret, sharedSecret)
|
||||
const keyInfo = concat(
|
||||
encoder.encode('WebPush: info\x00'),
|
||||
uaPublic,
|
||||
applicationPublic,
|
||||
)
|
||||
const ikm = await hkdfExpand(prkKey, keyInfo, 32)
|
||||
const prk = await hmacSha256(salt, ikm)
|
||||
const contentEncryptionKey = await hkdfExpand(
|
||||
prk,
|
||||
encoder.encode('Content-Encoding: aes128gcm\x00'),
|
||||
16,
|
||||
)
|
||||
const nonce = await hkdfExpand(prk, encoder.encode('Content-Encoding: nonce\x00'), 12)
|
||||
|
||||
const plaintext = concat(payload, Uint8Array.of(0x02))
|
||||
const aesKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
toArrayBuffer(contentEncryptionKey),
|
||||
'AES-GCM',
|
||||
false,
|
||||
['encrypt'],
|
||||
)
|
||||
const ciphertext = new Uint8Array(
|
||||
await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: toArrayBuffer(nonce), tagLength: 128 },
|
||||
aesKey,
|
||||
toArrayBuffer(plaintext),
|
||||
),
|
||||
)
|
||||
|
||||
const header = new Uint8Array(16 + 4 + 1 + 65)
|
||||
header.set(salt, 0)
|
||||
new DataView(header.buffer).setUint32(16, RECORD_SIZE, false)
|
||||
header[20] = 65
|
||||
header.set(applicationPublic, 21)
|
||||
|
||||
return concat(header, ciphertext)
|
||||
}
|
||||
|
||||
export async function sendWebPushMessage(
|
||||
registrationId: string,
|
||||
notification: PushNotification,
|
||||
options: WebPushSendOptions = {},
|
||||
): Promise<WebPushSendResult> {
|
||||
const subscription = parseWebPushSubscription(registrationId)
|
||||
const config = options.config ?? readWebPushConfig()
|
||||
const fetchImpl = options.fetchImpl ?? fetch
|
||||
const nowSeconds = Math.floor((options.now ?? Date.now()) / 1000)
|
||||
const randomBytes = options.randomBytes
|
||||
?? ((length: number) => crypto.getRandomValues(new Uint8Array(length)))
|
||||
|
||||
const payload = encoder.encode(JSON.stringify({
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
data: notification.data,
|
||||
}))
|
||||
const body = await encryptWebPushPayload(subscription, payload, randomBytes)
|
||||
const authorization = await buildVapidAuthorization(config, subscription.endpoint, nowSeconds)
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetchImpl(subscription.endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: authorization,
|
||||
'Content-Encoding': 'aes128gcm',
|
||||
'Content-Type': 'application/octet-stream',
|
||||
TTL: '86400',
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
})
|
||||
} catch {
|
||||
throw new PushContractError('webpush_send_timeout', 504)
|
||||
}
|
||||
|
||||
if (response.status === 200 || response.status === 201 || response.status === 202) {
|
||||
return { status: response.status }
|
||||
}
|
||||
if (response.status === 404 || response.status === 410) {
|
||||
throw new PushContractError('webpush_registration_stale', 410, true)
|
||||
}
|
||||
if (response.status === 413) {
|
||||
throw new PushContractError('webpush_payload_too_large', 500)
|
||||
}
|
||||
throw new PushContractError('webpush_send_failed', 502)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue