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