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
+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');
});
});