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
189
server/supabase/functions/_shared/apns.test.ts
Normal file
189
server/supabase/functions/_shared/apns.test.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
// server/supabase/functions/_shared/apns.test.ts
|
||||
// Apple Push Notification service (token/.p8) transport tests. Firebase-free.
|
||||
|
||||
import {
|
||||
createApnsToken,
|
||||
readApnsConfig,
|
||||
sendApnsMessage,
|
||||
type ApnsConfig,
|
||||
} from './apns.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}`)
|
||||
}
|
||||
}
|
||||
|
||||
function toPem(bytes: Uint8Array): string {
|
||||
let binary = ''
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte)
|
||||
const b64 = btoa(binary)
|
||||
const lines = b64.match(/.{1,64}/g)?.join('\n') ?? b64
|
||||
return `-----BEGIN PRIVATE KEY-----\n${lines}\n-----END PRIVATE KEY-----\n`
|
||||
}
|
||||
|
||||
async function makeP8(): Promise<string> {
|
||||
const keys = await crypto.subtle.generateKey(
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
true,
|
||||
['sign'],
|
||||
)
|
||||
return toPem(new Uint8Array(await crypto.subtle.exportKey('pkcs8', keys.privateKey)))
|
||||
}
|
||||
|
||||
function b64urlDecode(value: string): string {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
|
||||
return new TextDecoder().decode(Uint8Array.from(atob(padded), (character) => character.charCodeAt(0)))
|
||||
}
|
||||
|
||||
const DEVICE_TOKEN = 'a'.repeat(64)
|
||||
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('apns config requires valid credentials', () => {
|
||||
const empty = () => undefined
|
||||
assertPushError(() => readApnsConfig(empty), 'apns_not_configured', 503)
|
||||
|
||||
const malformed = (name: string) => ({
|
||||
APNS_KEY_ID: 'too-short',
|
||||
APNS_TEAM_ID: 'ABCDEFGHIJ',
|
||||
APNS_PRIVATE_KEY: '-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----',
|
||||
APNS_TOPIC: 'com.d3ro.voice',
|
||||
})[name]
|
||||
assertPushError(() => readApnsConfig(malformed), 'apns_credentials_invalid', 503)
|
||||
})
|
||||
|
||||
Deno.test('apns provider token carries kid and ES256', async () => {
|
||||
const pem = await makeP8()
|
||||
const config: ApnsConfig = {
|
||||
keyId: 'ABCDEFGHIJ',
|
||||
teamId: 'KLMNOPQRST',
|
||||
privateKey: pem,
|
||||
topic: 'com.d3ro.voice',
|
||||
environment: 'production',
|
||||
}
|
||||
const token = await createApnsToken(config, 1_700_000_000)
|
||||
const [header, claims] = token.split('.')
|
||||
const decodedHeader = JSON.parse(b64urlDecode(header)) as Record<string, unknown>
|
||||
const decodedClaims = JSON.parse(b64urlDecode(claims)) as Record<string, unknown>
|
||||
assert(decodedHeader.alg === 'ES256', 'alg must be ES256')
|
||||
assert(decodedHeader.kid === 'ABCDEFGHIJ', 'kid must be the key id')
|
||||
assert(decodedClaims.iss === 'KLMNOPQRST', 'iss must be the team id')
|
||||
assert(typeof decodedClaims.iat === 'number', 'iat must be set')
|
||||
assert(token.split('.').length === 3, 'token is a JWT')
|
||||
})
|
||||
|
||||
Deno.test('apns send targets the right host with topic headers', async () => {
|
||||
const config: ApnsConfig = {
|
||||
keyId: 'ABCDEFGHIJ',
|
||||
teamId: 'KLMNOPQRST',
|
||||
privateKey: 'unused',
|
||||
topic: 'com.d3ro.voice',
|
||||
environment: 'production',
|
||||
}
|
||||
const calls: Array<{ url: string; init: RequestInit }> = []
|
||||
const fetchImpl = ((url: string | URL | Request, init?: RequestInit) => {
|
||||
calls.push({ url: String(url), init: init ?? {} })
|
||||
return Promise.resolve(new Response('{}', { status: 200 }))
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
const result = await sendApnsMessage(DEVICE_TOKEN, notification, {
|
||||
config,
|
||||
fetchImpl,
|
||||
getAccessToken: () => Promise.resolve('test-token'),
|
||||
})
|
||||
assert(result.status === 200, 'ok status returned')
|
||||
assert(calls.length === 1, 'one request issued')
|
||||
assert(calls[0].url === `https://api.push.apple.com/3/device/${DEVICE_TOKEN}`, 'production host used')
|
||||
const headers = calls[0].init.headers as Record<string, string>
|
||||
assert(headers['apns-topic'] === 'com.d3ro.voice', 'topic header present')
|
||||
assert(headers['apns-push-type'] === 'alert', 'push type header present')
|
||||
assert(headers.authorization === 'bearer test-token', 'bearer token present')
|
||||
})
|
||||
|
||||
Deno.test('apns send maps stale and auth responses', async () => {
|
||||
const config: ApnsConfig = {
|
||||
keyId: 'ABCDEFGHIJ',
|
||||
teamId: 'KLMNOPQRST',
|
||||
privateKey: 'unused',
|
||||
topic: 'com.d3ro.voice',
|
||||
environment: 'sandbox',
|
||||
}
|
||||
const respond = (status: number, body: unknown) => (
|
||||
(() => Promise.resolve(new Response(JSON.stringify(body), { status }))) as unknown as typeof fetch
|
||||
)
|
||||
const options = { config, getAccessToken: () => Promise.resolve('t') }
|
||||
|
||||
await assertPushError(
|
||||
() => sendApnsMessage(DEVICE_TOKEN, notification, { ...options, fetchImpl: respond(410, { reason: 'Unregistered' }) }),
|
||||
'apns_registration_stale',
|
||||
410,
|
||||
true,
|
||||
)
|
||||
await assertPushError(
|
||||
() => sendApnsMessage(DEVICE_TOKEN, notification, { ...options, fetchImpl: respond(400, { reason: 'BadDeviceToken' }) }),
|
||||
'apns_registration_stale',
|
||||
410,
|
||||
true,
|
||||
)
|
||||
await assertPushError(
|
||||
() => sendApnsMessage(DEVICE_TOKEN, notification, { ...options, fetchImpl: respond(403, { reason: 'InvalidProviderToken' }) }),
|
||||
'apns_credentials_invalid',
|
||||
503,
|
||||
)
|
||||
await assertPushError(
|
||||
() => sendApnsMessage(DEVICE_TOKEN, notification, { ...options, fetchImpl: respond(503, { reason: 'ServiceUnavailable' }) }),
|
||||
'apns_send_failed',
|
||||
502,
|
||||
)
|
||||
await assertPushError(
|
||||
() => sendApnsMessage('not-a-token', notification, { ...options, fetchImpl: respond(200, {}) }),
|
||||
'apns_registration_invalid',
|
||||
400,
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
Deno.test('apns send uses the sandbox host when configured', async () => {
|
||||
const config: ApnsConfig = {
|
||||
keyId: 'ABCDEFGHIJ',
|
||||
teamId: 'KLMNOPQRST',
|
||||
privateKey: 'unused',
|
||||
topic: 'com.d3ro.voice',
|
||||
environment: 'sandbox',
|
||||
}
|
||||
const calls: string[] = []
|
||||
const fetchImpl = ((url: string | URL | Request) => {
|
||||
calls.push(String(url))
|
||||
return Promise.resolve(new Response('{}', { status: 200 }))
|
||||
}) as unknown as typeof fetch
|
||||
await sendApnsMessage(DEVICE_TOKEN, notification, {
|
||||
config,
|
||||
fetchImpl,
|
||||
getAccessToken: () => Promise.resolve('t'),
|
||||
})
|
||||
assert(calls[0].startsWith('https://api.sandbox.push.apple.com/'), 'sandbox host used')
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue