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
199
server/supabase/functions/_shared/apns.ts
Normal file
199
server/supabase/functions/_shared/apns.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
// server/supabase/functions/_shared/apns.ts
|
||||
// Apple Push Notification service transport (token-based, .p8 / ES256 JWT).
|
||||
//
|
||||
// Firebase-free: APNs is Apple's own service. Only an Apple Developer key is
|
||||
// required; no Firebase project is involved.
|
||||
|
||||
import { PushContractError, type PushNotification } from './push-contract.ts'
|
||||
|
||||
export type ApnsEnvironment = 'production' | 'sandbox'
|
||||
|
||||
export interface ApnsConfig {
|
||||
keyId: string
|
||||
teamId: string
|
||||
privateKey: string
|
||||
topic: string
|
||||
environment: ApnsEnvironment
|
||||
}
|
||||
|
||||
export interface ApnsSendOptions {
|
||||
fetchImpl?: typeof fetch
|
||||
config?: ApnsConfig
|
||||
now?: number
|
||||
getAccessToken?: (config: ApnsConfig, nowSeconds: number) => Promise<string>
|
||||
}
|
||||
|
||||
export interface ApnsSendResult {
|
||||
status: number
|
||||
}
|
||||
|
||||
const KEY_ID_PATTERN = /^[A-Z0-9]{10}$/
|
||||
const TOPIC_PATTERN = /^[A-Za-z0-9.-]{3,155}$/
|
||||
const DEVICE_TOKEN_PATTERN = /^[0-9a-fA-F]{32,200}$/
|
||||
const encoder = new TextEncoder()
|
||||
const REQUEST_TIMEOUT_MS = 10_000
|
||||
const TOKEN_TTL_SECONDS = 50 * 60
|
||||
|
||||
let cachedToken: { identity: string; token: string; issuedAtSeconds: number } | null = null
|
||||
|
||||
export function readApnsConfig(
|
||||
readEnv: (name: string) => string | undefined = (name) => Deno.env.get(name),
|
||||
): ApnsConfig {
|
||||
const keyId = readEnv('APNS_KEY_ID')?.trim() ?? ''
|
||||
const teamId = readEnv('APNS_TEAM_ID')?.trim() ?? ''
|
||||
const privateKey = readEnv('APNS_PRIVATE_KEY') ?? ''
|
||||
const topic = readEnv('APNS_TOPIC')?.trim() ?? ''
|
||||
const environmentRaw = readEnv('APNS_ENVIRONMENT')?.trim().toLowerCase() ?? 'production'
|
||||
if (!keyId || !teamId || !privateKey || !topic) {
|
||||
throw new PushContractError('apns_not_configured', 503)
|
||||
}
|
||||
if (
|
||||
!KEY_ID_PATTERN.test(keyId)
|
||||
|| !KEY_ID_PATTERN.test(teamId)
|
||||
|| !TOPIC_PATTERN.test(topic)
|
||||
|| !privateKey.includes('BEGIN PRIVATE KEY')
|
||||
|| (environmentRaw !== 'production' && environmentRaw !== 'sandbox')
|
||||
) {
|
||||
throw new PushContractError('apns_credentials_invalid', 503)
|
||||
}
|
||||
return {
|
||||
keyId,
|
||||
teamId,
|
||||
privateKey,
|
||||
topic,
|
||||
environment: environmentRaw === 'sandbox' ? 'sandbox' : 'production',
|
||||
}
|
||||
}
|
||||
|
||||
function decodePemPrivateKey(pem: string): Uint8Array {
|
||||
const body = pem
|
||||
.replace(/-----BEGIN PRIVATE KEY-----/g, '')
|
||||
.replace(/-----END PRIVATE KEY-----/g, '')
|
||||
.replace(/\s/g, '')
|
||||
if (!body) throw new PushContractError('apns_credentials_invalid', 503)
|
||||
try {
|
||||
const binary = atob(body)
|
||||
return Uint8Array.from(binary, (character) => character.charCodeAt(0))
|
||||
} catch {
|
||||
throw new PushContractError('apns_credentials_invalid', 503)
|
||||
}
|
||||
}
|
||||
|
||||
export async function createApnsToken(config: ApnsConfig, nowSeconds: number): Promise<string> {
|
||||
const header = base64Url(encoder.encode(JSON.stringify({ alg: 'ES256', kid: config.keyId })))
|
||||
const claims = base64Url(encoder.encode(JSON.stringify({ iss: config.teamId, iat: nowSeconds })))
|
||||
const signingInput = `${header}.${claims}`
|
||||
try {
|
||||
const decoded = decodePemPrivateKey(config.privateKey)
|
||||
const keyBytes = new ArrayBuffer(decoded.byteLength)
|
||||
new Uint8Array(keyBytes).set(decoded)
|
||||
const key = await crypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
keyBytes,
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
false,
|
||||
['sign'],
|
||||
)
|
||||
const signature = await crypto.subtle.sign(
|
||||
{ name: 'ECDSA', hash: 'SHA-256' },
|
||||
key,
|
||||
encoder.encode(signingInput),
|
||||
)
|
||||
return `${signingInput}.${base64Url(new Uint8Array(signature))}`
|
||||
} catch (error) {
|
||||
if (error instanceof PushContractError) throw error
|
||||
throw new PushContractError('apns_credentials_invalid', 503)
|
||||
}
|
||||
}
|
||||
|
||||
function base64Url(bytes: Uint8Array): string {
|
||||
let binary = ''
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte)
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
export async function getApnsAccessToken(
|
||||
config: ApnsConfig,
|
||||
nowSeconds: number,
|
||||
): Promise<string> {
|
||||
const identity = `${config.keyId}\n${config.teamId}`
|
||||
if (
|
||||
cachedToken
|
||||
&& cachedToken.identity === identity
|
||||
&& cachedToken.issuedAtSeconds + TOKEN_TTL_SECONDS > nowSeconds
|
||||
) {
|
||||
return cachedToken.token
|
||||
}
|
||||
const token = await createApnsToken(config, nowSeconds)
|
||||
cachedToken = { identity, token, issuedAtSeconds: nowSeconds }
|
||||
return token
|
||||
}
|
||||
|
||||
function apnsHost(environment: ApnsEnvironment): string {
|
||||
return environment === 'sandbox' ? 'https://api.sandbox.push.apple.com' : 'https://api.push.apple.com'
|
||||
}
|
||||
|
||||
function isStaleReason(reason: string): boolean {
|
||||
return reason === 'BadDeviceToken'
|
||||
|| reason === 'DeviceTokenNotForTopic'
|
||||
|| reason === 'Unregistered'
|
||||
|| reason === 'ExpiredToken'
|
||||
}
|
||||
|
||||
export async function sendApnsMessage(
|
||||
registrationId: string,
|
||||
notification: PushNotification,
|
||||
options: ApnsSendOptions = {},
|
||||
): Promise<ApnsSendResult> {
|
||||
if (typeof registrationId !== 'string' || !DEVICE_TOKEN_PATTERN.test(registrationId)) {
|
||||
throw new PushContractError('apns_registration_invalid', 400, true)
|
||||
}
|
||||
const config = options.config ?? readApnsConfig()
|
||||
const fetchImpl = options.fetchImpl ?? fetch
|
||||
const nowSeconds = Math.floor((options.now ?? Date.now()) / 1000)
|
||||
const accessToken = await (options.getAccessToken ?? getApnsAccessToken)(config, nowSeconds)
|
||||
|
||||
const body = JSON.stringify({
|
||||
aps: {
|
||||
alert: { title: notification.title, body: notification.body },
|
||||
sound: 'default',
|
||||
},
|
||||
...notification.data,
|
||||
})
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetchImpl(`${apnsHost(config.environment)}/3/device/${registrationId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `bearer ${accessToken}`,
|
||||
'apns-topic': config.topic,
|
||||
'apns-push-type': 'alert',
|
||||
'apns-priority': '10',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
})
|
||||
} catch {
|
||||
throw new PushContractError('apns_send_timeout', 504)
|
||||
}
|
||||
|
||||
if (response.ok) return { status: response.status }
|
||||
|
||||
let reason = ''
|
||||
try {
|
||||
const parsed = await response.json() as Record<string, unknown>
|
||||
reason = typeof parsed.reason === 'string' ? parsed.reason : ''
|
||||
} catch {
|
||||
reason = ''
|
||||
}
|
||||
|
||||
if (response.status === 410 || (response.status === 400 && isStaleReason(reason))) {
|
||||
throw new PushContractError('apns_registration_stale', 410, true)
|
||||
}
|
||||
if (response.status === 403) {
|
||||
throw new PushContractError('apns_credentials_invalid', 503)
|
||||
}
|
||||
throw new PushContractError('apns_send_failed', 502)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue