// 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 } 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 { 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 { 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 { 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 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) }