feat(T-1.10): APNs scaffold — JWT provider auth, push primitive, device-token stub
This commit is contained in:
parent
f89abd1125
commit
db6be6dcf8
|
|
@ -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<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");
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue