notify: the APNs channel

Step 3. Apple directly over HTTP/2 — no Expo, no library, just node:http2 and node:crypto.

Three things here are the difference between working and a silent failure:

- The provider JWT must be signed with dsaEncoding 'ieee-p1363'. Node's default is DER, which
  is a perfectly valid ECDSA signature that Apple rejects, and the rejection says nothing
  about why. A test asserts the signature is 64 bytes rather than trusting the flag.
- Apple rejects a token minted more than once per 20 minutes and any token older than 60, so
  it is cached and refreshed at 40 — between the two walls, not per request.
- APNs expects ONE long-lived HTTP/2 session carrying many requests. A session per push gets
  throttled, so sessions are kept per host and re-created only when they die, with an error
  handler so a transport failure cannot take the sidecar down as an unhandled rejection.

Sandbox and production are separate hosts and separate token namespaces, so devices are sent
per their stored `environment` — a debug-build token against production fails with
BadDeviceToken and no other symptom.

Dead tokens (BadDeviceToken, Unregistered, DeviceTokenNotForTopic) delete their row
immediately; everything else counts a strike. Going direct means Apple answers inline, so
none of Expo's deferred receipt-polling is needed.

The doorbell rule is now enforced by a test, not just by convention: a producer that passes
subject/from/body — which the type forbids but JavaScript permits — cannot get any of it into
the serialized payload. `aps.alert` carries a generic title composed from the category, and
the custom `officer` key carries ids the app fetches by.

12 tests, 100% of the JWT and payload paths. Verified against the real endpoint too: a
throwaway key gets 403 InvalidProviderToken from api.sandbox.push.apple.com, which means the
connection, path, apns-topic, push-type and JWT structure are all accepted and only the
credential is missing.

Still needed for a real send: APNS_KEY_P8, APNS_KEY_ID, APNS_TEAM_ID, and a device token
from the app.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 21:45:48 +00:00
co-authored by Claude Opus 5
parent 3f22a808d6
commit 707a2a5ba3
6 changed files with 378 additions and 1 deletions
+2 -1
View File
@@ -1,5 +1,6 @@
import type { Channel, DeliveryResult, Notification } from './types';
import { discordChannel } from './discord';
import { apnsChannel } from './apns';
// Fan a notification out to every configured channel.
//
@@ -7,7 +8,7 @@ import { discordChannel } from './discord';
// whether or not a banner appeared. So every channel is awaited with its own error boundary, and the
// dispatcher always resolves.
const channels: Channel[] = [discordChannel];
const channels: Channel[] = [apnsChannel, discordChannel];
/** Registered here rather than imported at the top so channels can be added without touching producers. */
export function registerChannel(channel: Channel): void {