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
+54
View File
@@ -0,0 +1,54 @@
import { createSign, createPrivateKey, type KeyObject } from 'node:crypto';
// The APNs authorization token: an ES256 JWT signed with the .p8 auth key.
//
// Two details here are the difference between working and a 403 with no useful message:
//
// 1. The signature must be raw r||s (64 bytes), NOT DER. Node signs ECDSA as DER by default, which is
// a valid signature that Apple rejects — `dsaEncoding: 'ieee-p1363'` is what produces the JOSE form.
// 2. Apple rejects a token minted more than once every 20 minutes (TooManyProviderTokenUpdates) and any
// token older than 60 minutes (ExpiredProviderToken). So it is cached and refreshed on a schedule that
// sits between those two walls, not per request.
const REFRESH_AFTER_MS = 40 * 60 * 1000; // comfortably inside 60, comfortably outside 20
export type ApnsAuthConfig = {
/** Contents of the .p8 file — PEM, PKCS#8, EC P-256. */
keyP8: string;
/** The 10-character Key ID shown beside the key in the Apple Developer portal. */
keyId: string;
/** The 10-character Team ID. */
teamId: string;
};
const b64url = (input: object | Buffer): string =>
(Buffer.isBuffer(input) ? input : Buffer.from(JSON.stringify(input))).toString('base64url');
export class ApnsAuthToken {
private key: KeyObject;
private cached: { token: string; mintedAt: number } | null = null;
constructor(private config: ApnsAuthConfig) {
// Parsed once, at construction, so a malformed .p8 fails at startup rather than on the first push.
// `\n` is unescaped because the key usually arrives through an env var, where real newlines do not survive.
this.key = createPrivateKey({ key: config.keyP8.replace(/\\n/g, '\n'), format: 'pem' });
}
/** A valid provider token, minted only when the cached one is old enough to warrant it. */
get(): string {
const now = Date.now();
if (this.cached && now - this.cached.mintedAt < REFRESH_AFTER_MS) return this.cached.token;
const header = b64url({ alg: 'ES256', kid: this.config.keyId });
const claims = b64url({ iss: this.config.teamId, iat: Math.floor(now / 1000) });
const signingInput = `${header}.${claims}`;
const signer = createSign('SHA256');
signer.update(signingInput);
const signature = signer.sign({ key: this.key, dsaEncoding: 'ieee-p1363' });
const token = `${signingInput}.${b64url(signature)}`;
this.cached = { token, mintedAt: now };
return token;
}
}
@@ -0,0 +1,54 @@
import type { Notification } from './types';
import { renderTitle, renderBody } from './text';
// The APNs payload.
//
// `aps` is Apple's reserved key and controls what the system does; everything beside it is ours and
// arrives in the app untouched. The doorbell rule lives in what we put in each: the alert carries only a
// generic title composed from the category, and the custom keys carry ids the app uses to FETCH the real
// content over the tailnet. Nothing here should ever be readable as the thing itself.
export type ApnsPayload = {
aps: {
alert: { title: string; body?: string };
sound: string;
/** Drives the red badge. Only meaningful where a count is, so it is set only then. */
badge?: number;
/** Lets the app group/replace banners rather than stacking one per event. */
'thread-id'?: string;
};
/** Ours. The app routes on these; they are ids, never content. */
officer: {
type: string;
id?: string | number;
ok?: boolean;
};
};
export function buildApnsPayload(n: Notification): ApnsPayload {
const body = renderBody(n);
return {
aps: {
alert: body ? { title: renderTitle(n), body } : { title: renderTitle(n) },
sound: 'default',
...(typeof n.count === 'number' ? { badge: n.count } : {}),
// One thread per category: three job notifications collapse instead of filling the shade.
'thread-id': n.type,
},
officer: {
type: n.type,
...(n.id !== undefined ? { id: n.id } : {}),
...(n.ok !== undefined ? { ok: n.ok } : {}),
},
};
}
/**
* `apns-collapse-id` — Apple replaces an undelivered notification with the same id rather than queueing
* both. Right for "3 new emails" superseding "1 new email"; wrong for jobs, where each completion is its
* own event, so those collapse per id instead of per category.
*/
export function collapseId(n: Notification): string {
return n.id !== undefined ? `${n.type}-${n.id}` : n.type;
}
+111
View File
@@ -0,0 +1,111 @@
import { describe, test, expect } from 'bun:test';
import { generateKeyPairSync, createVerify } from 'node:crypto';
import { ApnsAuthToken } from './apns-jwt';
import { buildApnsPayload, collapseId } from './apns-payload';
import { renderTitle } from './text';
import type { Notification } from './types';
// What can be proven without Apple: the token is a well-formed ES256 JWT with the encoding Apple
// actually requires, and the payload never carries content.
const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
const keyP8 = privateKey.export({ format: 'pem', type: 'pkcs8' }) as string;
const auth = () => new ApnsAuthToken({ keyP8, keyId: 'ABCDE12345', teamId: 'TEAM123456' });
const decode = (part: string) => JSON.parse(Buffer.from(part, 'base64url').toString());
describe('APNs provider token', () => {
test('is a three-part JWT with the header Apple expects', () => {
const [h, c, s] = auth().get().split('.');
expect(decode(h!)).toEqual({ alg: 'ES256', kid: 'ABCDE12345' });
expect(decode(c!).iss).toBe('TEAM123456');
expect(typeof decode(c!).iat).toBe('number');
expect(s!.length).toBeGreaterThan(0);
});
// The whole point of dsaEncoding: 'ieee-p1363'. Node's default DER encoding is a *valid* ECDSA
// signature that Apple rejects, and the failure gives no hint as to why — so assert the length.
test('signs with raw r||s (64 bytes), not DER', () => {
const sig = Buffer.from(auth().get().split('.')[2]!, 'base64url');
expect(sig.length).toBe(64);
});
test('the signature actually verifies over header.claims', () => {
const [h, c, s] = auth().get().split('.');
const v = createVerify('SHA256');
v.update(`${h}.${c}`);
expect(v.verify({ key: publicKey, dsaEncoding: 'ieee-p1363' }, Buffer.from(s!, 'base64url'))).toBe(true);
});
// Apple rejects tokens minted more than once per 20 minutes (TooManyProviderTokenUpdates), so a fresh
// JWT per push would eventually lock us out.
test('is cached rather than minted per call', () => {
const a = auth();
expect(a.get()).toBe(a.get());
});
test('accepts a key whose newlines were escaped by the environment', () => {
const escaped = keyP8.replace(/\n/g, '\\n');
expect(() => new ApnsAuthToken({ keyP8: escaped, keyId: 'K', teamId: 'T' }).get()).not.toThrow();
});
});
describe('APNs payload', () => {
const mail: Notification = { type: 'mail', userId: 1, count: 3 };
test('carries a generic title and no content', () => {
const p = buildApnsPayload(mail);
expect(p.aps.alert.title).toBe('3 new emails');
expect(p.aps.badge).toBe(3);
expect(p.officer).toEqual({ type: 'mail' });
});
// The doorbell rule, asserted rather than trusted: whatever a producer sends, nothing resembling
// content may appear in the serialized payload that leaves for Apple.
test('a producer cannot smuggle content through', () => {
const sneaky = {
type: 'mail',
userId: 1,
count: 1,
// none of these are part of Notification, but a caller in JS could still pass them
subject: 'Re: your invoice',
from: 'accounts@example.com',
body: 'please pay',
} as unknown as Notification;
const json = JSON.stringify(buildApnsPayload(sneaky));
expect(json).not.toContain('invoice');
expect(json).not.toContain('example.com');
expect(json).not.toContain('please pay');
});
test('ids travel, so the app can fetch the real thing', () => {
const p = buildApnsPayload({ type: 'job', userId: 1, id: 412, ok: false });
expect(p.officer).toEqual({ type: 'job', id: 412, ok: false });
expect(p.aps.alert.title).toBe('Job failed');
expect(p.aps.badge).toBeUndefined();
});
test('threads by category so banners group instead of stacking', () => {
expect(buildApnsPayload(mail).aps['thread-id']).toBe('mail');
});
// Collapse replaces an undelivered notification. Right for mail (one count supersedes the last),
// wrong for jobs, where each completion is its own event.
test('collapses mail by category and jobs by id', () => {
expect(collapseId(mail)).toBe('mail');
expect(collapseId({ type: 'job', userId: 1, id: 412 })).toBe('job-412');
});
});
describe('rendered text', () => {
test('singular and plural mail read correctly', () => {
expect(renderTitle({ type: 'mail', userId: 1, count: 1 })).toBe('New email');
expect(renderTitle({ type: 'mail', userId: 1, count: 5 })).toBe('5 new emails');
});
test('job wording follows ok', () => {
expect(renderTitle({ type: 'job', userId: 1, ok: true })).toBe('Job finished');
expect(renderTitle({ type: 'job', userId: 1, ok: false })).toBe('Job failed');
});
});
+155
View File
@@ -0,0 +1,155 @@
import http2 from 'node:http2';
import { getPushDevices, deletePushDevice, recordPushFailure, markPushDeviceSeen } from 'officerdb';
import type { Channel, DeliveryResult, Notification } from './types';
import { ApnsAuthToken } from './apns-jwt';
import { buildApnsPayload, collapseId } from './apns-payload';
// The Apple channel. Talks to APNs directly — no Expo, no library.
//
// APNs is HTTP/2 only and expects ONE long-lived connection carrying many requests. Opening a session
// per push is the classic mistake: Apple throttles it, and the TLS handshake dwarfs the send. So the
// session is created once, reused, and re-created only when it dies.
//
// Sandbox and production are different hosts AND different token namespaces. A token minted by a debug
// build fails against production with BadDeviceToken and no other symptom, which is why `environment` is
// a stored column rather than a guess — and why devices are grouped by it here and sent in two batches.
const HOSTS = {
production: 'https://api.push.apple.com',
sandbox: 'https://api.sandbox.push.apple.com',
} as const;
/** Apple's reasons for "this token is dead" — the row goes immediately rather than after three strikes. */
const DEAD_TOKEN_REASONS = new Set(['BadDeviceToken', 'Unregistered', 'DeviceTokenNotForTopic']);
const { APNS_KEY_P8, APNS_KEY_ID, APNS_TEAM_ID } = process.env;
const auth =
APNS_KEY_P8 && APNS_KEY_ID && APNS_TEAM_ID
? new ApnsAuthToken({ keyP8: APNS_KEY_P8, keyId: APNS_KEY_ID, teamId: APNS_TEAM_ID })
: null;
// One session per host, lazily created and reused.
const sessions = new Map<string, http2.ClientHttp2Session>();
function session(host: string): http2.ClientHttp2Session {
const existing = sessions.get(host);
if (existing && !existing.closed && !existing.destroyed) return existing;
const s = http2.connect(host);
// Without this a transport error becomes an unhandled rejection and takes the sidecar down.
s.on('error', (err) => {
console.error(`[notify:apns] session error (${host}):`, err.message);
sessions.delete(host);
});
s.on('close', () => sessions.delete(host));
sessions.set(host, s);
return s;
}
type SendOutcome = { ok: boolean; dead: boolean; reason?: string };
function sendOne(host: string, token: string, bundleId: string, n: Notification): Promise<SendOutcome> {
return new Promise((resolve) => {
const body = Buffer.from(JSON.stringify(buildApnsPayload(n)));
const req = session(host).request({
':method': 'POST',
':path': `/3/device/${token}`,
authorization: `bearer ${auth!.get()}`,
'apns-topic': bundleId,
// `alert` because these are user-visible banners. A silent/background push would be `background`
// with priority 5, which is a different feature and not what this channel is for.
'apns-push-type': 'alert',
'apns-priority': '10',
'apns-collapse-id': collapseId(n),
'content-type': 'application/json',
'content-length': body.length,
});
let status = 0;
let raw = '';
req.on('response', (headers) => {
status = Number(headers[':status'] ?? 0);
});
req.on('data', (chunk) => {
raw += chunk;
});
req.on('end', () => {
if (status === 200) return resolve({ ok: true, dead: false });
// Apple answers failures with { "reason": "BadDeviceToken" } and nothing else useful.
let reason = `status ${status}`;
try {
reason = (JSON.parse(raw) as { reason?: string }).reason ?? reason;
} catch {
/* keep the status */
}
resolve({ ok: false, dead: DEAD_TOKEN_REASONS.has(reason), reason });
});
req.on('error', (err) => resolve({ ok: false, dead: false, reason: err.message }));
req.setTimeout(10_000, () => {
req.close();
resolve({ ok: false, dead: false, reason: 'timeout' });
});
req.end(body);
});
}
export const apnsChannel: Channel = {
name: 'apns',
isConfigured: () => auth !== null,
async send(n: Notification): Promise<DeliveryResult> {
if (!auth) return { channel: 'apns', sent: 0, failed: 0 };
const devices = (await getPushDevices(n.userId, n.appSlug)).filter((d) => d.platform === 'ios');
if (devices.length === 0) return { channel: 'apns', sent: 0, failed: 0 };
const dead: string[] = [];
let sent = 0;
let failed = 0;
await Promise.all(
devices.map(async (d) => {
const host = HOSTS[d.environment as keyof typeof HOSTS] ?? HOSTS.production;
const outcome = await sendOne(host, d.token, d.bundleId, n);
if (outcome.ok) {
sent++;
await markPushDeviceSeen(d.token);
return;
}
failed++;
console.error(`[notify:apns] ${d.bundleId} ${d.environment}: ${outcome.reason}`);
if (outcome.dead) {
// An uninstalled app or a rotated token. Delete now — retrying cannot help, and a registry
// full of dead tokens is how delivery quietly degrades.
dead.push(d.token);
await deletePushDevice(d.token);
} else {
await recordPushFailure(d.token);
}
}),
);
return { channel: 'apns', sent, failed, ...(dead.length ? { dead } : {}) };
},
};
/** Close the shared sessions on shutdown so the process can exit promptly. */
export function closeApnsSessions(): void {
for (const s of sessions.values()) {
try {
s.close();
} catch {
/* already gone */
}
}
sessions.clear();
}
+2 -1
View File
@@ -1,5 +1,6 @@
import type { Channel, DeliveryResult, Notification } from './types'; import type { Channel, DeliveryResult, Notification } from './types';
import { discordChannel } from './discord'; import { discordChannel } from './discord';
import { apnsChannel } from './apns';
// Fan a notification out to every configured channel. // 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 // whether or not a banner appeared. So every channel is awaited with its own error boundary, and the
// dispatcher always resolves. // 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. */ /** Registered here rather than imported at the top so channels can be added without touching producers. */
export function registerChannel(channel: Channel): void { export function registerChannel(channel: Channel): void {
+2
View File
@@ -2,6 +2,7 @@ import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect'; import { createSidecarConnector } from '../connect';
import { dispatch, configuredChannels } from './dispatch'; import { dispatch, configuredChannels } from './dispatch';
import { handleDeviceRoute } from './devices'; import { handleDeviceRoute } from './devices';
import { closeApnsSessions } from './apns';
import type { Notification, NotifyType } from './types'; import type { Notification, NotifyType } from './types';
// The officer-notify sidecar. The one place anything leaves this machine to tell the owner something. // The officer-notify sidecar. The one place anything leaves this machine to tell the owner something.
@@ -106,6 +107,7 @@ const connection = createSidecarConnector({
function shutdown(signal: string) { function shutdown(signal: string) {
console.log(`[notify] ${signal} received, shutting down...`); console.log(`[notify] ${signal} received, shutting down...`);
closeApnsSessions();
connection.destroy(); connection.destroy();
process.exit(0); process.exit(0);
} }