notify: the FCM channel

Step 4, and the last transport. Google directly via FCM HTTP v1 — no Expo, no firebase-admin.

Unlike APNs the signed JWT is not the credential: it is an assertion exchanged at
oauth2.googleapis.com for a 1-hour access token, so there are two things to cache and a
network round trip on the cold path. Concurrent pushes share one in-flight exchange rather
than each starting their own, and the token refreshes five minutes early so a request cannot
race the expiry.

The detail that breaks most first FCM integrations: `message.data` values must all be
STRINGS. A number or boolean is rejected with a bare INVALID_ARGUMENT that does not say which
field. Everything is stringified on the way out — id, ok and count included — and a test
walks every value asserting its type rather than trusting the code that wrote it.

Also v1-specific: there is no multicast (the /batch endpoint is deprecated), so N devices is
N requests, which happens to match the APNs shape anyway.

Errors are read for `error.status` only. FCM's `message` field can echo the device token, so
logging the whole body would put device addresses in the logs. UNREGISTERED / INVALID_ARGUMENT
/ NOT_FOUND delete the row; anything else counts a strike.

21 tests across both channels, and verified against the real endpoint: a throwaway key gets
400 invalid_grant "account not found" from Google, meaning the endpoint, form encoding,
grant_type, RS256 signature and claim structure were all accepted and only the account is
missing. A malformed assertion would have failed earlier, with a different error.

The doorbell rule is asserted on this channel too: a producer passing subject/from cannot get
either into the serialized message.

Still needed for a real send: FCM_SERVICE_ACCOUNT, a Firebase project, and google-services.json
in the Android build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 21:57:29 +00:00
co-authored by Claude Opus 5
parent 707a2a5ba3
commit 93796d882a
4 changed files with 338 additions and 1 deletions
+2 -1
View File
@@ -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 {
+99
View File
@@ -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<ServiceAccount>;
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<string> | 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<string> {
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;
}
}
+95
View File
@@ -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');
});
});
+142
View File
@@ -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<string, string>;
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<string, string> = { 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<SendOutcome> {
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<DeliveryResult> {
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 };