pi-remote-control/extensions/remote-control/apns/push.ts

157 lines
4.4 KiB
TypeScript

/**
* 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<string, unknown>;
}
/** 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<string, JwtCache>();
/**
* 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<string> {
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<PushResult> {
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<string, string> = {
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");
}