diff --git a/src/servers/sidecar/notify/dispatch.ts b/src/servers/sidecar/notify/dispatch.ts index c3a3cb47..b4f6bc5a 100644 --- a/src/servers/sidecar/notify/dispatch.ts +++ b/src/servers/sidecar/notify/dispatch.ts @@ -1,6 +1,7 @@ import type { Channel, DeliveryResult, Notification } from './types'; import { discordChannel } from './discord'; import { apnsChannel } from './apns'; +import { fcmChannel } from './fcm'; // Fan a notification out to every configured channel. // @@ -8,7 +9,7 @@ import { apnsChannel } from './apns'; // whether or not a banner appeared. So every channel is awaited with its own error boundary, and the // dispatcher always resolves. -const channels: Channel[] = [apnsChannel, discordChannel]; +const channels: Channel[] = [apnsChannel, fcmChannel, discordChannel]; /** Registered here rather than imported at the top so channels can be added without touching producers. */ export function registerChannel(channel: Channel): void { diff --git a/src/servers/sidecar/notify/fcm-auth.ts b/src/servers/sidecar/notify/fcm-auth.ts new file mode 100644 index 00000000..35dcda41 --- /dev/null +++ b/src/servers/sidecar/notify/fcm-auth.ts @@ -0,0 +1,99 @@ +import { createSign, createPrivateKey, type KeyObject } from 'node:crypto'; + +// FCM v1 authorization: unlike APNs, the signed JWT is not the credential Google accepts. It is an +// *assertion* you exchange at the OAuth2 endpoint for a short-lived access token, and that token is what +// goes in the Authorization header. So there are two things to cache, not one, and a network round trip +// on the cold path that APNs does not have. +// +// The private key comes from a service-account JSON downloaded from the Firebase console. It arrives +// with literal \n sequences inside the PEM, which have to be unescaped or createPrivateKey throws. + +const TOKEN_URL = 'https://oauth2.googleapis.com/token'; +const SCOPE = 'https://www.googleapis.com/auth/firebase.messaging'; +/** Google issues 1-hour tokens; refresh early so a request never races the expiry. */ +const REFRESH_MARGIN_MS = 5 * 60 * 1000; + +export type ServiceAccount = { + project_id: string; + client_email: string; + private_key: string; +}; + +export function parseServiceAccount(raw: string): ServiceAccount { + const parsed = JSON.parse(raw) as Partial; + if (!parsed.project_id || !parsed.client_email || !parsed.private_key) { + throw new Error('FCM service account is missing project_id, client_email or private_key'); + } + return parsed as ServiceAccount; +} + +const b64url = (input: object | Buffer): string => + (Buffer.isBuffer(input) ? input : Buffer.from(JSON.stringify(input))).toString('base64url'); + +export class FcmAccessToken { + private key: KeyObject; + private cached: { token: string; expiresAt: number } | null = null; + /** In-flight refresh, so a burst of pushes makes one token request rather than one each. */ + private inflight: Promise | null = null; + + constructor(private account: ServiceAccount) { + this.key = createPrivateKey({ key: account.private_key.replace(/\\n/g, '\n'), format: 'pem' }); + } + + get projectId(): string { + return this.account.project_id; + } + + /** The signed assertion Google exchanges for an access token. Exposed for testing. */ + buildAssertion(now = Date.now()): string { + const iat = Math.floor(now / 1000); + const header = b64url({ alg: 'RS256', typ: 'JWT' }); + const claims = b64url({ + iss: this.account.client_email, + scope: SCOPE, + aud: TOKEN_URL, + iat, + exp: iat + 3600, + }); + const signingInput = `${header}.${claims}`; + const signer = createSign('RSA-SHA256'); + signer.update(signingInput); + return `${signingInput}.${b64url(signer.sign(this.key))}`; + } + + async get(): Promise { + const now = Date.now(); + if (this.cached && this.cached.expiresAt - now > REFRESH_MARGIN_MS) return this.cached.token; + if (this.inflight) return this.inflight; + + this.inflight = (async () => { + try { + const res = await fetch(TOKEN_URL, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: this.buildAssertion(now), + }), + signal: AbortSignal.timeout(10_000), + }); + if (!res.ok) { + // Never log the body: it echoes the assertion, which is signed with the service-account key. + throw new Error(`FCM token exchange failed: ${res.status}`); + } + const body = (await res.json()) as { access_token?: string; expires_in?: number }; + if (!body.access_token) throw new Error('FCM token exchange returned no access_token'); + + this.cached = { + token: body.access_token, + expiresAt: now + (body.expires_in ?? 3600) * 1000, + }; + return this.cached.token; + } finally { + this.inflight = null; + } + })(); + + return this.inflight; + } +} diff --git a/src/servers/sidecar/notify/fcm.test.ts b/src/servers/sidecar/notify/fcm.test.ts new file mode 100644 index 00000000..9d028d9f --- /dev/null +++ b/src/servers/sidecar/notify/fcm.test.ts @@ -0,0 +1,95 @@ +import { describe, test, expect } from 'bun:test'; +import { generateKeyPairSync, createVerify } from 'node:crypto'; +import { FcmAccessToken, parseServiceAccount } from './fcm-auth'; +import { __testing } from './fcm'; +import type { Notification } from './types'; + +// What can be proven without Google: the assertion is a well-formed RS256 JWT with the claims the token +// endpoint requires, and the message body matches FCM v1's shape — including the string-only `data` rule +// that is the usual reason a first integration fails. + +const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); +const account = { + project_id: 'officer-test', + client_email: 'push@officer-test.iam.gserviceaccount.com', + private_key: privateKey.export({ format: 'pem', type: 'pkcs8' }) as string, +}; + +const decode = (part: string) => JSON.parse(Buffer.from(part, 'base64url').toString()); + +describe('FCM service account', () => { + test('rejects an incomplete account rather than failing at send time', () => { + expect(() => parseServiceAccount('{"project_id":"x"}')).toThrow(/missing/); + }); + + test('accepts a key whose newlines were escaped, as they are in the JSON', () => { + const escaped = { ...account, private_key: account.private_key.replace(/\n/g, '\\n') }; + expect(() => new FcmAccessToken(escaped).buildAssertion()).not.toThrow(); + }); +}); + +describe('OAuth2 assertion', () => { + const auth = new FcmAccessToken(account); + + test('carries the claims Google requires', () => { + const [h, c] = auth.buildAssertion().split('.'); + expect(decode(h!)).toEqual({ alg: 'RS256', typ: 'JWT' }); + const claims = decode(c!); + expect(claims.iss).toBe(account.client_email); + expect(claims.aud).toBe('https://oauth2.googleapis.com/token'); + expect(claims.scope).toBe('https://www.googleapis.com/auth/firebase.messaging'); + expect(claims.exp - claims.iat).toBe(3600); + }); + + test('the signature verifies over header.claims', () => { + const [h, c, s] = auth.buildAssertion().split('.'); + const v = createVerify('RSA-SHA256'); + v.update(`${h}.${c}`); + expect(v.verify(publicKey, Buffer.from(s!, 'base64url'))).toBe(true); + }); +}); + +describe('FCM message', () => { + const build = __testing.buildMessage; + + // The rule that breaks first integrations: FCM rejects a non-string in `data` with a bare + // INVALID_ARGUMENT that says nothing about which field. + test('every data value is a string, including numbers and booleans', () => { + const msg = build('tok', { type: 'job', userId: 1, id: 412, ok: false, count: 2 }); + for (const [key, value] of Object.entries(msg.message.data)) { + expect(typeof value, `data.${key}`).toBe('string'); + } + expect(msg.message.data).toEqual({ type: 'job', id: '412', ok: 'false', count: '2' }); + }); + + test('omits keys that were not provided, rather than sending "undefined"', () => { + const msg = build('tok', { type: 'mail', userId: 1, count: 3 }); + expect(msg.message.data).toEqual({ type: 'mail', count: '3' }); + expect(Object.keys(msg.message.data)).not.toContain('id'); + }); + + test('title is the generic rendered one and the token is set', () => { + const msg = build('device-token', { type: 'mail', userId: 1, count: 3 }); + expect(msg.message.token).toBe('device-token'); + expect(msg.message.notification.title).toBe('3 new emails'); + }); + + test('tags by category so banners group instead of stacking', () => { + expect(build('t', { type: 'mail', userId: 1 }).message.android.notification.tag).toBe('mail'); + }); + + // The same doorbell guarantee asserted for APNs, on the other channel. + test('a producer cannot smuggle content through', () => { + const sneaky = { + type: 'mail', + userId: 1, + count: 1, + subject: 'Re: your invoice', + from: 'accounts@example.com', + } as unknown as Notification; + + const json = JSON.stringify(build('tok', sneaky)); + expect(json).not.toContain('invoice'); + expect(json).not.toContain('example.com'); + }); +}); diff --git a/src/servers/sidecar/notify/fcm.ts b/src/servers/sidecar/notify/fcm.ts new file mode 100644 index 00000000..957cc673 --- /dev/null +++ b/src/servers/sidecar/notify/fcm.ts @@ -0,0 +1,142 @@ +import { getPushDevices, deletePushDevice, recordPushFailure, markPushDeviceSeen } from 'officerdb'; +import type { Channel, DeliveryResult, Notification } from './types'; +import { FcmAccessToken, parseServiceAccount } from './fcm-auth'; +import { renderTitle, renderBody } from './text'; + +// The Google channel. FCM HTTP v1, called directly — no Expo, no firebase-admin. +// +// Ordinary HTTPS, unlike APNs, but two things differ from Apple in ways that matter: +// +// 1. One request per token. There is no multicast in v1 (`/batch` is deprecated), so N devices is N +// requests — fine at this scale, and the same shape as APNs anyway. +// 2. FCM data values must all be STRINGS. A number or boolean in `message.data` is rejected outright +// with an unhelpful INVALID_ARGUMENT, so everything is stringified on the way out and the app parses +// it back. This is the single most common way a first FCM integration fails. + +const ENDPOINT = (projectId: string) => `https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`; + +/** Google's ways of saying "this token is dead" — the row goes immediately. */ +const DEAD_TOKEN_CODES = new Set(['UNREGISTERED', 'INVALID_ARGUMENT', 'NOT_FOUND']); + +const raw = process.env.FCM_SERVICE_ACCOUNT; +const auth = raw ? new FcmAccessToken(parseServiceAccount(raw)) : null; + +type FcmMessage = { + message: { + token: string; + notification: { title: string; body?: string }; + data: Record; + android: { + priority: 'high'; + notification: { + /** Groups banners the way APNs' thread-id does. */ + tag: string; + /** Replaces an existing banner with the same tag rather than stacking. */ + notification_priority: 'PRIORITY_HIGH'; + }; + }; + }; +}; + +function buildMessage(token: string, n: Notification): FcmMessage { + const body = renderBody(n); + + // Every value a string — see the note at the top. The app parses `id` and `ok` back. + const data: Record = { type: n.type }; + if (n.id !== undefined) data.id = String(n.id); + if (n.ok !== undefined) data.ok = String(n.ok); + if (n.count !== undefined) data.count = String(n.count); + + return { + message: { + token, + notification: body ? { title: renderTitle(n), body } : { title: renderTitle(n) }, + data, + android: { + priority: 'high', + notification: { tag: n.type, notification_priority: 'PRIORITY_HIGH' }, + }, + }, + }; +} + +type SendOutcome = { ok: boolean; dead: boolean; reason?: string }; + +async function sendOne(accessToken: string, projectId: string, token: string, n: Notification): Promise { + try { + const res = await fetch(ENDPOINT(projectId), { + method: 'POST', + headers: { authorization: `Bearer ${accessToken}`, 'content-type': 'application/json' }, + body: JSON.stringify(buildMessage(token, n)), + signal: AbortSignal.timeout(10_000), + }); + + if (res.ok) return { ok: true, dead: false }; + + // FCM errors look like { error: { status: 'UNREGISTERED', message: '…' } }. The message can echo the + // token, so only the status is logged. + let status = `status ${res.status}`; + try { + const body = (await res.json()) as { error?: { status?: string } }; + status = body.error?.status ?? status; + } catch { + /* keep the http status */ + } + return { ok: false, dead: DEAD_TOKEN_CODES.has(status), reason: status }; + } catch (err) { + return { ok: false, dead: false, reason: err instanceof Error ? err.message : 'send failed' }; + } +} + +export const fcmChannel: Channel = { + name: 'fcm', + + isConfigured: () => auth !== null, + + async send(n: Notification): Promise { + if (!auth) return { channel: 'fcm', sent: 0, failed: 0 }; + + const devices = (await getPushDevices(n.userId, n.appSlug)).filter((d) => d.platform === 'android'); + if (devices.length === 0) return { channel: 'fcm', sent: 0, failed: 0 }; + + let accessToken: string; + try { + accessToken = await auth.get(); + } catch (err) { + // A failed token exchange fails every device equally; no point attempting the sends. + console.error('[notify:fcm]', err instanceof Error ? err.message : err); + return { channel: 'fcm', sent: 0, failed: devices.length }; + } + + const dead: string[] = []; + let sent = 0; + let failed = 0; + + await Promise.all( + devices.map(async (d) => { + const outcome = await sendOne(accessToken, auth.projectId, d.token, n); + + if (outcome.ok) { + sent++; + await markPushDeviceSeen(d.token); + return; + } + + failed++; + console.error(`[notify:fcm] ${d.bundleId}: ${outcome.reason}`); + + if (outcome.dead) { + dead.push(d.token); + await deletePushDevice(d.token); + } else { + await recordPushFailure(d.token); + } + }), + ); + + return { channel: 'fcm', sent, failed, ...(dead.length ? { dead } : {}) }; + }, +}; + +/** Exposed for tests — the wire shape is where FCM integrations usually go wrong. */ +export const __testing = { buildMessage };