diff --git a/extensions/remote-control/apns/push.ts b/extensions/remote-control/apns/push.ts new file mode 100644 index 0000000..463fa73 --- /dev/null +++ b/extensions/remote-control/apns/push.ts @@ -0,0 +1,156 @@ +/** + * 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"); +}