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:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user