Skip to content

Commit f04fcf2

Browse files
NateIsernclaude
andcommitted
feat(notifications): FCM + APNS silent-push dispatch
createDispatcher builds the content-free payload ({txid, event, subscriptionId}) and routes by platform to injected FCM/APNS senders, pruning on a dead-token result and swallowing transient errors. Real senders: FCM HTTP v1 via google-auth-library, APNS background push over HTTP/2 with an ES256 provider JWT signed by node:crypto (no jsonwebtoken dep). Senders are injected so the module needs no credentials to build or test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 38330af commit f04fcf2

5 files changed

Lines changed: 411 additions & 0 deletions

File tree

server/lib/push/apns.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// APNS token-based sender over HTTP/2. Signs a provider-authentication JWT
2+
// (ES256, from the `.p8` key) with Node's built-in crypto — no third-party JWT
3+
// dependency — and sends a silent `content-available` background push. Only wire
4+
// this when the APNS_* env is configured — see the notifications config.
5+
6+
import http2 from 'node:http2'
7+
import { createPrivateKey, sign as cryptoSign, type KeyObject } from 'node:crypto'
8+
import { logger } from '../logger'
9+
import type { ApnsSender } from './types'
10+
11+
/**
12+
* How long a provider JWT is reused before re-signing. Apple requires refreshing
13+
* no more than once every 20 min and at least once every 60 min; 30 min sits
14+
* safely inside that window.
15+
*/
16+
const APNS_JWT_TTL_SECONDS = 30 * 60
17+
18+
const HOST_PRODUCTION = 'https://api.push.apple.com'
19+
const HOST_SANDBOX = 'https://api.sandbox.push.apple.com'
20+
21+
export interface ApnsConfig {
22+
/** Contents of the AuthKey `.p8` (PKCS#8 PEM). */
23+
p8: string
24+
/** The key's 10-character Key ID. */
25+
keyId: string
26+
/** The Apple Developer Team ID. */
27+
teamId: string
28+
/** App bundle id, sent as `apns-topic`. */
29+
bundleId: string
30+
/** true → api.push.apple.com; false → the sandbox host. */
31+
production: boolean
32+
}
33+
34+
function base64url(input: string | Buffer): string {
35+
return Buffer.from(input).toString('base64url')
36+
}
37+
38+
/** Sign the APNS provider-authentication JWT (ES256) for the given issue time. */
39+
function signProviderJwt(privateKey: KeyObject, keyId: string, teamId: string, iat: number): string {
40+
const header = base64url(JSON.stringify({ alg: 'ES256', kid: keyId }))
41+
const claims = base64url(JSON.stringify({ iss: teamId, iat }))
42+
const signingInput = `${header}.${claims}`
43+
// ieee-p1363 yields the raw R||S signature JWS ES256 requires (not DER).
44+
const signature = cryptoSign('sha256', Buffer.from(signingInput), {
45+
key: privateKey,
46+
dsaEncoding: 'ieee-p1363',
47+
})
48+
return `${signingInput}.${base64url(signature)}`
49+
}
50+
51+
/**
52+
* Build an {@link ApnsSender}. Maintains one persistent HTTP/2 session (lazily
53+
* reconnected) and a cached provider JWT. A push resolves `{ deadToken: true }`
54+
* on 410 Unregistered / 400 BadDeviceToken; any other non-200 throws (transient).
55+
*/
56+
export function createApnsSender(config: ApnsConfig): ApnsSender {
57+
const host = config.production ? HOST_PRODUCTION : HOST_SANDBOX
58+
const privateKey = createPrivateKey(config.p8)
59+
60+
let session: http2.ClientHttp2Session | null = null
61+
let cachedJwt: { token: string; issuedAt: number } | null = null
62+
63+
function getSession(): http2.ClientHttp2Session {
64+
if (session && !session.closed && !session.destroyed) {
65+
return session
66+
}
67+
const next = http2.connect(host)
68+
next.on('error', (error) => {
69+
logger.error('[push] APNS HTTP/2 session error', error)
70+
if (session === next) {
71+
session = null
72+
}
73+
})
74+
session = next
75+
return next
76+
}
77+
78+
function getJwt(): string {
79+
const now = Math.floor(Date.now() / 1000)
80+
if (cachedJwt && now - cachedJwt.issuedAt < APNS_JWT_TTL_SECONDS) {
81+
return cachedJwt.token
82+
}
83+
const token = signProviderJwt(privateKey, config.keyId, config.teamId, now)
84+
cachedJwt = { token, issuedAt: now }
85+
return token
86+
}
87+
88+
return (deviceToken, payload) =>
89+
new Promise((resolve, reject) => {
90+
const request = getSession().request({
91+
':method': 'POST',
92+
':path': `/3/device/${deviceToken}`,
93+
authorization: `bearer ${getJwt()}`,
94+
'apns-topic': config.bundleId,
95+
'apns-push-type': 'background',
96+
'apns-priority': '5',
97+
'content-type': 'application/json',
98+
})
99+
100+
let status = 0
101+
let body = ''
102+
request.setEncoding('utf8')
103+
request.on('response', (headers) => {
104+
status = Number(headers[':status'] ?? 0)
105+
})
106+
request.on('data', (chunk: string) => {
107+
body += chunk
108+
})
109+
request.on('end', () => {
110+
if (status === 200) {
111+
resolve({ deadToken: false })
112+
return
113+
}
114+
if (status === 410 || (status === 400 && body.includes('BadDeviceToken'))) {
115+
logger.debug(`[push] APNS reports dead token (${status})`)
116+
resolve({ deadToken: true })
117+
return
118+
}
119+
reject(new Error(`APNS send failed: ${status} ${body}`))
120+
})
121+
request.on('error', reject)
122+
request.end(JSON.stringify(payload))
123+
})
124+
}

server/lib/push/fcm.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// FCM HTTP v1 sender. Builds a data-only message and authenticates with an OAuth
2+
// access token minted from the Firebase service account (google-auth-library
3+
// caches/refreshes the token). Only wire this when FCM_SERVICE_ACCOUNT_JSON is
4+
// configured — see the notifications config.
5+
6+
import { JWT } from 'google-auth-library'
7+
import { logger } from '../logger'
8+
import type { FcmSender } from './types'
9+
10+
/** OAuth scope required to send FCM messages via HTTP v1. */
11+
const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging'
12+
13+
/** The service-account fields we read (the JSON has many more). */
14+
interface FcmServiceAccount {
15+
client_email: string
16+
private_key: string
17+
project_id: string
18+
}
19+
20+
function parseServiceAccount(json: string): FcmServiceAccount {
21+
const parsed: unknown = JSON.parse(json)
22+
if (parsed === null || typeof parsed !== 'object') {
23+
throw new Error('FCM_SERVICE_ACCOUNT_JSON is not a JSON object')
24+
}
25+
const record = parsed as Record<string, unknown>
26+
const clientEmail = record.client_email
27+
const privateKey = record.private_key
28+
const projectId = record.project_id
29+
if (typeof clientEmail !== 'string' || typeof privateKey !== 'string' || typeof projectId !== 'string') {
30+
throw new Error('FCM_SERVICE_ACCOUNT_JSON missing client_email/private_key/project_id')
31+
}
32+
return { client_email: clientEmail, private_key: privateKey, project_id: projectId }
33+
}
34+
35+
/**
36+
* Build an {@link FcmSender} from a Firebase service-account JSON string. The
37+
* sender posts a data-only, high-priority message; a permanently invalid token
38+
* resolves as `{ deadToken: true }` and any other non-2xx throws (transient).
39+
*/
40+
export function createFcmSender(serviceAccountJson: string): FcmSender {
41+
const account = parseServiceAccount(serviceAccountJson)
42+
const jwtClient = new JWT({
43+
email: account.client_email,
44+
key: account.private_key,
45+
scopes: [FCM_SCOPE],
46+
})
47+
const endpoint = `https://fcm.googleapis.com/v1/projects/${account.project_id}/messages:send`
48+
49+
return async (deviceToken, data) => {
50+
const { token } = await jwtClient.getAccessToken()
51+
if (!token) {
52+
throw new Error('FCM: failed to obtain an access token')
53+
}
54+
55+
const response = await fetch(endpoint, {
56+
method: 'POST',
57+
headers: {
58+
authorization: `Bearer ${token}`,
59+
'content-type': 'application/json',
60+
},
61+
body: JSON.stringify({
62+
message: {
63+
token: deviceToken,
64+
data,
65+
android: { priority: 'high' },
66+
},
67+
}),
68+
})
69+
70+
if (response.ok) {
71+
return { deadToken: false }
72+
}
73+
74+
const body = await response.text()
75+
// A token FCM no longer recognizes (uninstalled app / rotated token): prune.
76+
if (
77+
response.status === 404 ||
78+
body.includes('UNREGISTERED') ||
79+
(response.status === 400 && body.includes('INVALID_ARGUMENT'))
80+
) {
81+
logger.debug(`[push] FCM reports dead token (${response.status})`)
82+
return { deadToken: true }
83+
}
84+
85+
throw new Error(`FCM send failed: ${response.status} ${body}`)
86+
}
87+
}

server/lib/push/index.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { describe, it, expect, vi } from 'vitest'
2+
import { createDispatcher } from './index'
3+
import type { ApnsSender, FcmSender, PushSendResult } from './types'
4+
5+
const androidTarget = { subscriptionId: 'sub-1', deviceToken: 'fcm-token', platform: 'android' as const }
6+
const iosTarget = { subscriptionId: 'sub-2', deviceToken: 'apns-token', platform: 'ios' as const }
7+
const message = { txid: 'e'.repeat(64), event: 'incoming_pending' as const }
8+
9+
const delivered: PushSendResult = { deadToken: false }
10+
const dead: PushSendResult = { deadToken: true }
11+
12+
describe('createDispatcher', () => {
13+
it('sends android pushes via FCM with a content-free data payload', async () => {
14+
const fcm = vi.fn<FcmSender>(async () => delivered)
15+
const apns = vi.fn<ApnsSender>(async () => delivered)
16+
const dispatch = createDispatcher({ fcm, apns })
17+
18+
await dispatch(androidTarget, message)
19+
20+
expect(fcm).toHaveBeenCalledTimes(1)
21+
expect(apns).not.toHaveBeenCalled()
22+
const [token, data] = fcm.mock.calls[0]
23+
expect(token).toBe('fcm-token')
24+
// The payload keys are EXACTLY the content-free wake signal — nothing else.
25+
expect(Object.keys(data).sort()).toEqual(['event', 'subscriptionId', 'txid'])
26+
expect(data).toEqual({ txid: 'e'.repeat(64), event: 'incoming_pending', subscriptionId: 'sub-1' })
27+
// Defense-in-depth: no amount/address/balance leaked into the payload.
28+
const serialized = JSON.stringify(data).toLowerCase()
29+
expect(serialized).not.toMatch(/amount|address|balance|value|fair/)
30+
})
31+
32+
it('sends ios pushes via APNS as a silent content-available push with no alert', async () => {
33+
const fcm = vi.fn<FcmSender>(async () => delivered)
34+
const apns = vi.fn<ApnsSender>(async () => delivered)
35+
const dispatch = createDispatcher({ fcm, apns })
36+
37+
await dispatch(iosTarget, message)
38+
39+
expect(apns).toHaveBeenCalledTimes(1)
40+
expect(fcm).not.toHaveBeenCalled()
41+
const [token, payload] = apns.mock.calls[0]
42+
expect(token).toBe('apns-token')
43+
expect(payload.aps['content-available']).toBe(1)
44+
// Silent push: no alert dictionary, so the OS shows nothing on its own.
45+
expect('alert' in payload.aps).toBe(false)
46+
expect(payload).toMatchObject({ txid: 'e'.repeat(64), event: 'incoming_pending', subscriptionId: 'sub-2' })
47+
const serialized = JSON.stringify(payload).toLowerCase()
48+
expect(serialized).not.toMatch(/amount|address|balance|"value"|fair/)
49+
})
50+
51+
it('prunes the subscription when FCM reports a dead token', async () => {
52+
const fcm = vi.fn(async (): Promise<PushSendResult> => dead)
53+
const onDeadToken = vi.fn(async () => {})
54+
const dispatch = createDispatcher({ fcm, onDeadToken })
55+
56+
await dispatch(androidTarget, message)
57+
58+
expect(onDeadToken).toHaveBeenCalledWith('sub-1')
59+
})
60+
61+
it('prunes the subscription when APNS reports a dead token', async () => {
62+
const apns = vi.fn(async (): Promise<PushSendResult> => dead)
63+
const onDeadToken = vi.fn(async () => {})
64+
const dispatch = createDispatcher({ apns, onDeadToken })
65+
66+
await dispatch(iosTarget, message)
67+
68+
expect(onDeadToken).toHaveBeenCalledWith('sub-2')
69+
})
70+
71+
it('swallows a transient send error and does not prune the token', async () => {
72+
const fcm = vi.fn(async (): Promise<PushSendResult> => {
73+
throw new Error('503 upstream')
74+
})
75+
const onDeadToken = vi.fn(async () => {})
76+
const dispatch = createDispatcher({ fcm, onDeadToken })
77+
78+
await expect(dispatch(androidTarget, message)).resolves.toBeUndefined()
79+
expect(onDeadToken).not.toHaveBeenCalled()
80+
})
81+
82+
it('is a no-op when no sender is configured for the platform', async () => {
83+
const apns = vi.fn(async (): Promise<PushSendResult> => delivered)
84+
const dispatch = createDispatcher({ apns }) // android target, no fcm sender
85+
86+
await expect(dispatch(androidTarget, message)).resolves.toBeUndefined()
87+
expect(apns).not.toHaveBeenCalled()
88+
})
89+
})

server/lib/push/index.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Push dispatch: turn a matched (target, message) into one silent provider push.
2+
//
3+
// The dispatcher owns the payload contract — the ONLY thing that transits
4+
// FCM/APNS is `{ txid, event, subscriptionId }` (FCM data message / APNS
5+
// content-available background push). It never sends an amount, address,
6+
// balance, or user identity; the visible notification is composed on-device.
7+
//
8+
// The concrete FCM/APNS senders are injected (built from env in server startup,
9+
// mocked in tests), so this module has no network/credential dependency and is a
10+
// pure, inert no-op when no sender is configured.
11+
12+
import { logger } from '../logger'
13+
import type {
14+
ApnsSender,
15+
FcmSender,
16+
PushData,
17+
PushDispatcher,
18+
PushSendResult,
19+
} from './types'
20+
21+
export type { PushDispatcher } from './types'
22+
23+
export interface DispatcherConfig {
24+
/** Android transport. Absent → android pushes are a no-op. */
25+
fcm?: FcmSender
26+
/** iOS transport. Absent → ios pushes are a no-op. */
27+
apns?: ApnsSender
28+
/** Invoked with the subscriptionId when a provider reports a permanently dead token. */
29+
onDeadToken?: (subscriptionId: string) => Promise<void>
30+
}
31+
32+
/**
33+
* Build a {@link PushDispatcher} from the configured senders. The returned
34+
* function never throws: transient provider errors are logged (pushes are
35+
* best-effort — the wallet reconciles true state on next open), and a dead-token
36+
* result triggers `onDeadToken` so the stale subscription is pruned.
37+
*/
38+
export function createDispatcher(config: DispatcherConfig): PushDispatcher {
39+
return async (target, message) => {
40+
// The complete, content-free application payload.
41+
const data: PushData = {
42+
txid: message.txid,
43+
event: message.event,
44+
subscriptionId: target.subscriptionId,
45+
}
46+
47+
let result: PushSendResult
48+
try {
49+
if (target.platform === 'android') {
50+
if (!config.fcm) {
51+
logger.debug('[push] no FCM sender configured; skipping android push')
52+
return
53+
}
54+
result = await config.fcm(target.deviceToken, data)
55+
} else {
56+
if (!config.apns) {
57+
logger.debug('[push] no APNS sender configured; skipping ios push')
58+
return
59+
}
60+
result = await config.apns(target.deviceToken, {
61+
aps: { 'content-available': 1 },
62+
txid: data.txid,
63+
event: data.event,
64+
subscriptionId: data.subscriptionId,
65+
})
66+
}
67+
} catch (error) {
68+
logger.error(`[push] ${target.platform} send failed for ${target.subscriptionId}`, error)
69+
return
70+
}
71+
72+
if (result.deadToken && config.onDeadToken) {
73+
await config.onDeadToken(target.subscriptionId)
74+
}
75+
}
76+
}

0 commit comments

Comments
 (0)