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