/** * APNs scaffold — JWT generation + push primitive. * * Provides a minimal APNs HTTP/2 push implementation using the JWT * provider-auth method (no persistent connection pool — deferred to Phase 2 * when iOS delivers real device tokens and call volume is known). * * Config comes from [apns] section in config.toml (T-1.7 wires this). * Until then, an explicit ApnsConfig object is passed in. * * Owner: T-1.10 */ import { createSign } from "node:crypto"; import fs from "node:fs/promises"; export interface ApnsConfig { teamId: string; // 10-char Apple Team ID keyId: string; // 10-char APNs key ID keyPath: string; // path to .p8 private key file bundleId: string; // app bundle ID, e.g. "com.example.pi-remote" } export interface PushPayload { title?: string; body?: string; badge?: number; data?: Record; } /** APNs host per environment */ const APNS_HOST = { production: "api.push.apple.com", sandbox: "api.development.push.apple.com", } as const; // --------------------------------------------------------------------------- // JWT generation (ES256, valid 60 minutes per APNs spec) // --------------------------------------------------------------------------- interface JwtCache { token: string; issuedAt: number; } const _jwtCache = new Map(); /** * Generate (or return cached) APNs provider JWT. * APNs rejects tokens older than 60 min; we refresh at 55 min. */ export async function getProviderJwt(cfg: ApnsConfig): Promise { const cacheKey = `${cfg.teamId}:${cfg.keyId}`; const cached = _jwtCache.get(cacheKey); const now = Math.floor(Date.now() / 1000); if (cached && now - cached.issuedAt < 55 * 60) { return cached.token; } const keyPem = await fs.readFile(cfg.keyPath, "utf8"); const header = base64url(JSON.stringify({ alg: "ES256", kid: cfg.keyId })); const claims = base64url(JSON.stringify({ iss: cfg.teamId, iat: now })); const signingInput = `${header}.${claims}`; const sign = createSign("SHA256"); sign.update(signingInput); const sig = sign.sign({ key: keyPem, dsaEncoding: "ieee-p1363" }); const sigB64 = sig.toString("base64url"); const token = `${signingInput}.${sigB64}`; _jwtCache.set(cacheKey, { token, issuedAt: now }); return token; } // --------------------------------------------------------------------------- // Push primitive // --------------------------------------------------------------------------- export interface PushResult { ok: boolean; status?: number; apnsId?: string; error?: string; } /** * Send a single APNs push notification. * * Uses the Node fetch API (available since Node 18). * No connection pooling — Phase 2 can upgrade to http2 if throughput demands. */ export async function sendPush(opts: { cfg: ApnsConfig; deviceToken: string; environment: "production" | "sandbox"; payload: PushPayload; collapseId?: string; }): Promise { const { cfg, deviceToken, environment, payload, collapseId } = opts; const jwt = await getProviderJwt(cfg); const host = APNS_HOST[environment]; const url = `https://${host}/3/device/${deviceToken}`; const apsPayload = { aps: { ...(payload.title || payload.body ? { alert: { title: payload.title, body: payload.body } } : {}), ...(payload.badge !== undefined ? { badge: payload.badge } : {}), }, ...payload.data, }; const headers: Record = { authorization: `bearer ${jwt}`, "apns-topic": cfg.bundleId, "content-type": "application/json", }; if (collapseId) headers["apns-collapse-id"] = collapseId; try { const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(apsPayload), }); const apnsId = res.headers.get("apns-id") ?? undefined; if (res.ok) { return { ok: true, status: res.status, apnsId }; } const body = await res.text().catch(() => ""); let errorReason = body; try { errorReason = (JSON.parse(body) as { reason?: string }).reason ?? body; } catch { // use raw body } return { ok: false, status: res.status, apnsId, error: errorReason }; } catch (err) { return { ok: false, error: String(err) }; } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function base64url(input: string): string { return Buffer.from(input).toString("base64url"); }