From 7f70856e2bfdc4d63a2ed5b76a9a1af08ec29d55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 11 May 2026 00:37:57 +0200 Subject: [PATCH] feat(notifications): APNs sender mirrors web-push contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `src/lib/notifications/senders/apns.ts` so the dispatcher can fan notifications out to a user's iPhone via Apple's push gateway. The sender exposes two surfaces: * `sendApnsPush({ deviceToken, environment, payload })` for one-shot delivery — the helper the iOS-targeted reminder pipeline calls directly. * `sendViaApns(userId, payload)` mirrors `sendViaWebPush(userId, payload)` so the dispatcher can route APNS through the same `SendOutcome` contract: at-least-one-success → ok, every device permanently rejected → hard channel reject + auto-disable, anything else → soft failure that feeds the existing exponential-backoff schedule. Provider lifecycle is one `apn.Provider` per gateway (`sandbox` vs `production`), lazy-initialised so the JWT-signing-key parse doesn't happen at boot. `@parse/node-apn` regenerates the bearer JWT every ~50 minutes inside the Provider, so the singleton is safe — the explicit `resetApnsForTesting()` helper exists so unit tests can re-read env vars between cases. Permanent failure detection covers `Unregistered`, `BadDeviceToken`, and `DeviceTokenNotForTopic`; the dispatcher branch deletes the dead Device row before returning, mirroring the web-push 410 cleanup. The env-var loader is all-or-none with a one-shot warning when the operator started configuring and stopped — half-finished Coolify env blocks surface in the logs the moment they matter. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/notifications/senders/apns.ts | 408 ++++++++++++++++++++++++++ 1 file changed, 408 insertions(+) create mode 100644 src/lib/notifications/senders/apns.ts diff --git a/src/lib/notifications/senders/apns.ts b/src/lib/notifications/senders/apns.ts new file mode 100644 index 000000000..66019e148 --- /dev/null +++ b/src/lib/notifications/senders/apns.ts @@ -0,0 +1,408 @@ +/** + * APNs sender — joins the dispatcher cascade alongside Telegram, ntfy, + * and Web Push (v1.4.23 Wave 3 / F4). + * + * Mirrors the `web-push.ts` shape so the dispatcher can call it through + * the same `SendOutcome`-returning contract: + * - `ok: true` if at least one device received the push. + * - `hardReject: true` when EVERY device returned a permanent + * APNs reason (`Unregistered`, `BadDeviceToken`, `DeviceTokenNotForTopic`). + * The dead device rows are deleted from the DB regardless, so a + * hard channel reject means the user has zero iOS devices left for + * the dispatcher to try. + * - `hardReject: false` for any other failure (transient APNs + * 5xx, throttling, network, certificate-invalid). The channel-state + * machine then schedules the next retry per the backoff schedule. + * + * Provider lifecycle: `@parse/node-apn` keeps the HTTP/2 connection + * pool + the JWT bearer (regenerated every ~50 minutes per Apple's spec) + * inside its `Provider` instance. We lazy-init one Provider per gateway + * (`sandbox` vs `production`) at module scope. **Do not** cache the JWT + * separately — that's what the library is for, and pinning a single + * token to the process lifetime would expire ~50 minutes after boot. + */ +import { readFileSync } from "node:fs"; + +import apn from "@parse/node-apn"; + +import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; +import type { SendOutcome } from "@/lib/notifications/retry-policy"; + +export interface ApnsPayload { + /** APNs `aps.alert.title` + `aps.alert.body`. */ + alert: { title: string; body: string }; + /** APNs `aps.badge`. Omit to leave the existing badge unchanged. */ + badge?: number; + /** APNs `aps.sound`. Omit for `default`. */ + sound?: string; + /** Custom keys; surfaces in `Notification.userInfo` on iOS. */ + data?: Record; + /** APNs `aps.thread-id` — groups related alerts in Notification Center. */ + threadId?: string; +} + +export interface ApnsSendInput { + deviceToken: string; + environment: "sandbox" | "production"; + payload: ApnsPayload; + /** Defaults to `APNS_BUNDLE_ID`. */ + topic?: string; + /** Replaces an earlier alert of the same kind in Notification Center. */ + collapseId?: string; +} + +export interface ApnsSendResult { + ok: boolean; + /** APNs HTTP status, when the call reached Apple. */ + status?: number; + /** Apple's error code, e.g. "BadDeviceToken", "Unregistered". */ + reason?: string; + /** + * True on permanent failures (`Unregistered`, `BadDeviceToken`, + * `DeviceTokenNotForTopic`). The caller should remove the device row + * — sending again will only burn quota. + */ + shouldDisable?: boolean; + /** Human-readable error fragment for logs. */ + message?: string; +} + +interface ApnsConfig { + keyId: string; + teamId: string; + bundleId: string; + /** PEM-encoded .p8 contents (multi-line OK). */ + signingKey: string; + /** + * If set, force every send through the production gateway regardless of + * the per-device `apnsEnvironment`. Useful for staging environments + * that have to talk to a TestFlight build. + */ + forceProduction: boolean; +} + +/** + * Permanent APNs error reasons. Anything in this set means "delete the + * device row, do not retry". Sourced from Apple's + * `Sending Notification Requests to APNs` reference table. + * + * `DeviceTokenNotForTopic` lands here (rather than as a transient blip) + * because it indicates a configuration mismatch between the iOS bundle + * and the server-side topic — re-sending will fail forever. + */ +const PERMANENT_APNS_REASONS = new Set([ + "Unregistered", + "BadDeviceToken", + "DeviceTokenNotForTopic", +]); + +let cachedConfig: ApnsConfig | null | undefined; +const providers = new Map<"sandbox" | "production", apn.Provider>(); + +/** + * Read the APNS_* env vars once per process. Returns null if any one of + * the required values is missing — that disables the channel cleanly + * rather than throwing on every dispatch. + * + * Validation rule: APNS_KEY_ID, APNS_TEAM_ID, APNS_BUNDLE_ID, and ONE OF + * (APNS_KEY | APNS_KEY_FILE) must all be set together. Setting some but + * not others is treated as "intentionally disabled" and emits a warning + * the first time a send is attempted, so a half-finished Coolify env + * surfaces in the logs the moment it matters. + */ +export function loadApnsConfig(): ApnsConfig | null { + if (cachedConfig !== undefined) return cachedConfig; + + const keyId = process.env.APNS_KEY_ID?.trim() || ""; + const teamId = process.env.APNS_TEAM_ID?.trim() || ""; + const bundleId = process.env.APNS_BUNDLE_ID?.trim() || ""; + const inlineKey = process.env.APNS_KEY?.trim() || ""; + const keyFile = process.env.APNS_KEY_FILE?.trim() || ""; + const forceProduction = process.env.APNS_PRODUCTION === "true"; + + // All-or-none guard: if zero of the four core values are set, APNs is + // simply disabled — no warning. If some are set, the operator started + // configuring and stopped — surface that as a warning so the + // half-finished state is visible in logs. + const anySet = Boolean(keyId || teamId || bundleId || inlineKey || keyFile); + const allSet = Boolean( + keyId && teamId && bundleId && (inlineKey || keyFile), + ); + + if (!anySet) { + cachedConfig = null; + return null; + } + + if (!allSet) { + getEvent()?.addWarning( + "APNs config incomplete — set APNS_KEY_ID, APNS_TEAM_ID, " + + "APNS_BUNDLE_ID, and one of APNS_KEY / APNS_KEY_FILE together. " + + "APNs sender is disabled until all four are present.", + ); + cachedConfig = null; + return null; + } + + let signingKey: string; + if (inlineKey) { + // Multi-line PEM with literal `\n` escapes is the 12-factor norm + // when the env block is a single line. Convert them back here so + // node-apn sees a real PEM. No-op for already-multiline values. + signingKey = inlineKey.replace(/\\n/g, "\n"); + } else { + try { + signingKey = readFileSync(keyFile, "utf8"); + } catch (err) { + const message = err instanceof Error ? err.message : "read_failed"; + getEvent()?.addWarning(`APNs key file read failed: ${message}`); + cachedConfig = null; + return null; + } + } + + cachedConfig = { keyId, teamId, bundleId, signingKey, forceProduction }; + return cachedConfig; +} + +/** + * Reset the cached config + Provider singletons. Tests use this to + * re-read env vars between cases; production never calls it. + */ +export function resetApnsForTesting(): void { + cachedConfig = undefined; + for (const provider of providers.values()) { + void provider.shutdown(); + } + providers.clear(); +} + +function getProvider( + config: ApnsConfig, + environment: "sandbox" | "production", +): apn.Provider { + const useProduction = config.forceProduction || environment === "production"; + const key = useProduction ? "production" : "sandbox"; + const existing = providers.get(key); + if (existing) return existing; + + const provider = new apn.Provider({ + token: { + key: config.signingKey, + keyId: config.keyId, + teamId: config.teamId, + }, + production: useProduction, + }); + providers.set(key, provider); + return provider; +} + +/** + * Send one APNs alert push to one device. The dispatcher fans out per + * device because APNs can return per-device failure reasons that map to + * different cleanup actions. + */ +export async function sendApnsPush( + input: ApnsSendInput, +): Promise { + const config = loadApnsConfig(); + if (!config) { + return { + ok: false, + reason: "apns_not_configured", + message: "APNs env vars are not set", + }; + } + + const provider = getProvider(config, input.environment); + + const note = new apn.Notification(); + note.topic = input.topic ?? config.bundleId; + note.alert = { + title: input.payload.alert.title, + body: input.payload.alert.body, + }; + note.sound = input.payload.sound ?? "default"; + if (typeof input.payload.badge === "number") { + note.badge = input.payload.badge; + } + if (input.payload.threadId) { + note.threadId = input.payload.threadId; + } + if (input.collapseId) { + note.collapseId = input.collapseId; + } + if (input.payload.data) { + note.payload = { ...input.payload.data }; + } + + const start = performance.now(); + let result: Awaited>; + try { + result = await provider.send(note, input.deviceToken); + } catch (err) { + const message = err instanceof Error ? err.message : "send_failed"; + getEvent()?.addExternalCall({ + service: "apns", + method: "send", + duration_ms: Math.round(performance.now() - start), + error: message, + }); + return { ok: false, reason: "apns_network_error", message }; + } + + const duration = Math.round(performance.now() - start); + + if (result.sent.length > 0) { + getEvent()?.addExternalCall({ + service: "apns", + method: "send", + duration_ms: duration, + status: 200, + }); + return { ok: true, status: 200 }; + } + + const failure = result.failed[0]; + const reason = failure?.response?.reason ?? failure?.error?.message; + const status = failure?.status ? Number(failure.status) : undefined; + const shouldDisable = reason ? PERMANENT_APNS_REASONS.has(reason) : false; + + getEvent()?.addExternalCall({ + service: "apns", + method: "send", + duration_ms: duration, + status: status ?? undefined, + error: reason ?? "all_failed", + }); + + return { + ok: false, + status, + reason: reason ?? "apns_unknown_failure", + shouldDisable, + message: failure?.error?.message, + }; +} + +/** + * Dispatcher entry point — fans a payload out to every iOS device the + * user owns and returns a single `SendOutcome` summarising the batch. + * + * Mirrors `sendViaWebPush(userId, payload)` so `dispatcher.ts` can route + * the APNS branch identically to its web-push branch: + * - At least one success ⇒ `ok: true`. + * - All devices permanently rejected ⇒ `hardReject: true` (channel + * auto-disables; user has no iOS device left to retry against). + * - Otherwise ⇒ soft failure, channel-state machine schedules backoff. + */ +export async function sendViaApns( + userId: string, + payload: { + title: string; + message: string; + eventType: string; + metadata?: Record; + }, +): Promise { + const config = loadApnsConfig(); + if (!config) { + return { + ok: false, + hardReject: false, + reason: "apns_not_configured", + }; + } + + const devices = await prisma.device.findMany({ + where: { + userId, + apnsToken: { not: null }, + }, + select: { + id: true, + apnsToken: true, + apnsEnvironment: true, + }, + }); + + if (devices.length === 0) { + // Same rationale as web-push: a user who hasn't paired an iPhone yet + // shouldn't see the channel auto-disabled. Treat as soft "no recipient". + return { + ok: false, + hardReject: false, + reason: "apns_no_devices", + }; + } + + let anySuccess = false; + const deadDeviceIds: string[] = []; + let lastFailureReason: string | undefined; + let lastFailureStatus: number | undefined; + + for (const device of devices) { + if (!device.apnsToken) continue; + const env = + device.apnsEnvironment === "production" ? "production" : "sandbox"; + + const result = await sendApnsPush({ + deviceToken: device.apnsToken, + environment: env, + payload: { + alert: { title: payload.title, body: stripHtml(payload.message) }, + data: { + eventType: payload.eventType, + ...(payload.metadata ?? {}), + }, + threadId: payload.eventType, + }, + collapseId: payload.eventType, + }); + + if (result.ok) { + anySuccess = true; + continue; + } + + if (result.shouldDisable) { + deadDeviceIds.push(device.id); + } + lastFailureReason = result.reason; + lastFailureStatus = result.status; + } + + if (deadDeviceIds.length > 0) { + await prisma.device.deleteMany({ + where: { id: { in: deadDeviceIds } }, + }); + } + + if (anySuccess) { + return { ok: true }; + } + + // Every surviving device returned a permanent reason → channel is dead + // until the user re-registers from a fresh iOS install. + if (deadDeviceIds.length === devices.length) { + return { + ok: false, + hardReject: true, + statusCode: lastFailureStatus, + reason: lastFailureReason ?? "apns_all_devices_unregistered", + }; + } + + return { + ok: false, + hardReject: false, + statusCode: lastFailureStatus, + reason: lastFailureReason ?? "apns_send_failed", + }; +} + +function stripHtml(s: string): string { + return s.replace(/<[^>]*>/g, ""); +}