feat(push): FCM sender + /push/fcm-token route for the Android client (A33)

Mirror src/push/apns.ts behind the same NotifyService seam (combineNotifyServices),
so held-gate/done hook events fan out to Android via data-only high-priority FCM with
zero new event paths. OAuth2 bearer via google-auth-library (R7); payload minimized to
sessionId/cls/token (never cwd/command/bytes); intentionally-loose FCM-token validator;
Origin-guarded + rate-limited POST/DELETE /push/fcm-token; FCM_* env all-or-disabled.

Cross-review fix: dropped validateStatus:()=>true so google-auth-library's built-in
401 refresh-and-retry actually fires. 60 new tests; full server suite green; tsc clean.
This commit is contained in:
Yaojia Wang
2026-07-10 16:40:43 +02:00
parent e7f3bd05f0
commit 542fde9580
5 changed files with 1798 additions and 2 deletions

190
package-lock.json generated
View File

@@ -14,6 +14,7 @@
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"express": "^5.2.1",
"google-auth-library": "^10.9.0",
"node-pty": "^1.1.0",
"qrcode": "^1.5.4",
"web-push": "3.6.7",
@@ -1537,6 +1538,26 @@
"js-tokens": "^10.0.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
@@ -1547,6 +1568,15 @@
"require-from-string": "^2.0.2"
}
},
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/bn.js": {
"version": "4.12.4",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.4.tgz",
@@ -1743,6 +1773,15 @@
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
@@ -2029,6 +2068,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -2047,6 +2092,29 @@
}
}
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
@@ -2081,6 +2149,18 @@
"node": ">=8"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"license": "MIT",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -2123,6 +2203,34 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gaxios": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.2.0.tgz",
"integrity": "sha512-CUVb4wcYe+771XevyH6HtGmXFAGGKkIC3kswAP8Z1JCe0j80JMaTPZH930DWFrvo0atjh18Arc0pEyUCWa5bfg==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"https-proxy-agent": "^7.0.1",
"node-fetch": "^3.3.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/gcp-metadata": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^7.0.0",
"google-logging-utils": "^1.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -2169,6 +2277,32 @@
"node": ">= 0.4"
}
},
"node_modules/google-auth-library": {
"version": "10.9.0",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.0.tgz",
"integrity": "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11",
"gaxios": "^7.1.4",
"gcp-metadata": "8.1.2",
"google-logging-utils": "1.1.3",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/google-logging-utils": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -2417,6 +2551,15 @@
}
}
},
"node_modules/json-bigint": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
"license": "MIT",
"dependencies": {
"bignumber.js": "^9.0.0"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
@@ -2876,6 +3019,44 @@
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/node-pty": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
@@ -3902,6 +4083,15 @@
"node": ">= 16"
}
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",

View File

@@ -25,6 +25,7 @@
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"express": "^5.2.1",
"google-auth-library": "^10.9.0",
"node-pty": "^1.1.0",
"qrcode": "^1.5.4",
"web-push": "3.6.7",

521
src/push/fcm.ts Normal file
View File

@@ -0,0 +1,521 @@
/**
* src/push/fcm.ts (A33) — FCM v1 sender + device-token registry for the Android
* client (ANDROID_CLIENT_PLAN §4.5 — the ONE additive server touch-point).
*
* Sits BESIDE web-push and APNs behind the same `NotifyService` seam: the server
* combines all three via `combineNotifyServices` (exported by apns.ts) so every
* existing hook event point (held gate → needs-input, Stop/SessionEnd → done)
* fans out to browsers, iPhones AND Android phones with zero new event paths.
*
* Config is env-only and all-or-disabled: FCM_PROJECT_ID + FCM_KEY_PATH are the
* critical pair; any piece missing (or an unreadable / malformed service-account
* key) cleanly disables the feature — one log line, never a crash, never any key
* material in logs (§8 "No secrets in code": the service-account JSON is
* server-side only).
*
* PAYLOAD MINIMIZATION (security invariant, mirrors apns.ts / push-service SP1):
* the message is DATA-ONLY (no `notification` block — the Android client builds
* the lock-screen notification + Allow/Deny actions locally, §4.5/R1). `data`
* carries ONLY what the web-push payload already carries: sessionId, cls, and —
* needs-input only — the single-use capability token for /hook/decision. Never
* cwd, never command content, never terminal bytes.
*
* Transport: OAuth2 bearer via `google-auth-library` (R7 — a deliberate exception
* to the zero-new-dep rule: less crypto to own/test than a hand-rolled RS256 JWT).
* The library mints + refreshes the ~1h access token and auto-refreshes on 401.
* The auth + HTTP POST hide behind the `FcmTransport` seam so tests inject a fake
* and NEVER touch the network or Google.
*/
import { readFileSync } from 'node:fs';
import { chmod, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import path from 'node:path';
import { GoogleAuth } from 'google-auth-library';
import type { Config, NotifyClass, NotifyService, Session } from '../types.js';
// ── constants ─────────────────────────────────────────────────────────────────
/** 0600 — owner read/write only; the file holds device push tokens. */
const FILE_MODE = 0o600;
/** Default token-store filename (under the home dir), mirroring the apns store. */
const DEFAULT_STORE_FILENAME = '.web-terminal-fcm-tokens.json';
/** Production FCM host; FCM_HOST overrides (tests / staging). */
const DEFAULT_FCM_HOST = 'https://fcm.googleapis.com';
/** OAuth2 scope the FCM v1 send endpoint requires. */
const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging';
/** ttl (seconds) for low-priority DONE pushes (mirrors web-push / apns). */
const DONE_EXPIRATION_SECONDS = 600;
/**
* Loose FCM-token validator (§4.5, deliberate): FCM registration-token
* format/length is undocumented and drifts, so a strict `{140,170}` regex risks
* rejecting valid tokens. Accept non-empty base64url charset plus ':' up to a
* bounded max. Case is preserved (FCM tokens are case-sensitive).
*/
const FCM_TOKEN_PATTERN = /^[A-Za-z0-9_:-]+$/;
/** Upper bound on a stored token (bounded, but generous — real tokens are ~160). */
const MAX_FCM_TOKEN_LENGTH = 4096;
/** AndroidConfig.priority for a held gate (immediate wake) vs. a done signal.
* NOTE: the FCM v1 `AndroidConfig.priority` enum is UPPERCASE HIGH/NORMAL — a
* lowercase value is rejected with INVALID_ARGUMENT (which would then prune the
* token). See the deviation note in the A33 log entry. */
const PRIORITY_HIGH = 'HIGH';
const PRIORITY_NORMAL = 'NORMAL';
/** FCM v1 error codes that mean "this token is dead" → prune it. */
const PRUNE_ERROR_CODES = new Set(['UNREGISTERED', 'INVALID_ARGUMENT']);
// ── types ─────────────────────────────────────────────────────────────────────
/** Frozen, validated FCM runtime config (loaded from the FCM_* env group). */
export interface FcmConfig {
readonly projectId: string;
readonly keyPath: string;
readonly storePath: string;
readonly host: string;
}
export type FcmConfigResult =
| { readonly ok: true; readonly config: FcmConfig }
| { readonly ok: false; readonly reason: string };
/** One registered Android device (FCM registration token, verbatim). */
export interface FcmTokenRecord {
readonly token: string;
readonly createdAt: number;
}
export interface FcmTokenStore {
/** Frozen snapshot of current records (immutable to callers). */
list(): readonly FcmTokenRecord[];
/** Idempotent upsert by token (FIFO-capped). Throws on invalid input. */
add(record: FcmTokenRecord): void;
/** Remove by token; no-op when absent (idempotent). */
remove(token: string): void;
/** Remove a batch of dead tokens (UNREGISTERED / INVALID_ARGUMENT evictions). */
prune(deadTokens: readonly string[]): void;
/** Persist to disk (0600). Best-effort: logs and resolves on failure. */
persist(): Promise<void>;
}
/** One FCM v1 send exchange (the transport adds the OAuth2 bearer internally). */
export interface FcmRequest {
readonly url: string;
readonly body: string;
}
export interface FcmResponse {
readonly status: number;
readonly body: string;
}
/**
* Transport seam: mints/refreshes the OAuth2 bearer AND performs the POST.
* Injectable for tests — the fake records requests and never touches Google.
*/
export interface FcmTransport {
send(req: FcmRequest): Promise<FcmResponse>;
}
export interface FcmServiceDeps {
transport?: FcmTransport;
}
/** What the server wires up when the feature is enabled. */
export interface FcmRuntime {
readonly service: NotifyService;
readonly store: FcmTokenStore;
}
// ── config loading ────────────────────────────────────────────────────────────
/** Read a required env var; empty string counts as missing. */
function envOrNull(env: Record<string, string | undefined>, key: string): string | null {
const value = env[key];
return typeof value === 'string' && value.length > 0 ? value : null;
}
/**
* Load the FCM_* env group. All-or-disabled: any missing critical piece
* (FCM_PROJECT_ID / FCM_KEY_PATH) or a malformed FCM_HOST yields `{ok:false,
* reason}` — the caller disables the feature, never crashes.
*/
export function loadFcmConfig(env: Record<string, string | undefined>): FcmConfigResult {
const missing = ['FCM_PROJECT_ID', 'FCM_KEY_PATH'].filter((key) => envOrNull(env, key) === null);
if (missing.length > 0) {
return { ok: false, reason: `${missing.join('/')} not set` };
}
const host = envOrNull(env, 'FCM_HOST') ?? DEFAULT_FCM_HOST;
try {
const parsed = new URL(host);
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
return { ok: false, reason: 'FCM_HOST must be an http(s) URL' };
}
} catch {
return { ok: false, reason: 'FCM_HOST is not a valid URL' };
}
return {
ok: true,
config: {
projectId: envOrNull(env, 'FCM_PROJECT_ID') as string,
keyPath: envOrNull(env, 'FCM_KEY_PATH') as string,
storePath: envOrNull(env, 'FCM_STORE_PATH') ?? path.join(homedir(), DEFAULT_STORE_FILENAME),
host,
},
};
}
/**
* Sanity-check the service-account JSON key without ever echoing its contents.
* Returns false (logged, no key material) on any failure: missing file,
* unparsable JSON, or a shape missing client_email / private_key.
*/
export function validateFcmKeyFile(keyPath: string): boolean {
let raw: string;
try {
raw = readFileSync(keyPath, 'utf8');
} catch (err) {
console.error(
`fcm: cannot read service-account key at ${keyPath} (${(err as NodeJS.ErrnoException)?.code ?? 'error'})`,
);
return false;
}
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
if (typeof parsed['client_email'] !== 'string' || typeof parsed['private_key'] !== 'string') {
console.error(`fcm: service-account key at ${keyPath} is missing client_email/private_key`);
return false;
}
return true;
} catch {
// Never echo parse errors — they can quote fragments of the private_key.
console.error(`fcm: service-account key at ${keyPath} is not valid JSON`);
return false;
}
}
// ── token validation + store ──────────────────────────────────────────────────
/**
* Boundary validation for the (loose, deliberate) FCM-token wire shape: a
* non-empty base64url string (plus ':') within a bounded length. Returns the
* token verbatim (case-preserved) or null (the route answers 400).
*/
export function normalizeFcmToken(raw: unknown): string | null {
if (typeof raw !== 'string') return null;
if (raw.length === 0 || raw.length > MAX_FCM_TOKEN_LENGTH) return null;
return FCM_TOKEN_PATTERN.test(raw) ? raw : null;
}
/** Type guard: a structurally valid, persistable token record. */
function isValidFcmTokenRecord(x: unknown): x is FcmTokenRecord {
if (x === null || typeof x !== 'object') return false;
const r = x as Record<string, unknown>;
if (normalizeFcmToken(r['token']) === null) return false;
if (typeof r['createdAt'] !== 'number' || !Number.isFinite(r['createdAt'])) return false;
return true;
}
/** Best-effort load (mirrors the apns store): missing/malformed → empty. */
function loadTokenRecords(filePath: string): FcmTokenRecord[] {
let raw: string;
try {
raw = readFileSync(filePath, 'utf8');
} catch (err) {
if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') {
console.error('fcm: token store load failed, starting empty', err);
}
return [];
}
try {
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(isValidFcmTokenRecord);
} catch {
console.error('fcm: token store is malformed JSON, starting empty');
return [];
}
}
/**
* Load (or initialise empty) the device-token store backed by `filePath`.
* Same conventions as the apns store: immutable replacement on every mutation,
* FIFO cap, 0600 persistence, tolerant load.
* @param filePath absolute path to the JSON store (FCM_STORE_PATH)
* @param maxTokens FIFO cap (pass cfg.pushMaxSubs)
*/
export function loadFcmTokenStore(filePath: string, maxTokens: number): FcmTokenStore {
let records: FcmTokenRecord[] = loadTokenRecords(filePath);
return {
list(): readonly FcmTokenRecord[] {
return Object.freeze(records.slice());
},
add(record: FcmTokenRecord): void {
if (!isValidFcmTokenRecord(record)) {
throw new TypeError('fcm: invalid token record');
}
const deduped = records.filter((r) => r.token !== record.token);
const next = [...deduped, record];
records = next.length > maxTokens ? next.slice(next.length - maxTokens) : next;
},
remove(token: string): void {
records = records.filter((r) => r.token !== token);
},
prune(deadTokens: readonly string[]): void {
if (deadTokens.length === 0) return;
const dead = new Set(deadTokens);
records = records.filter((r) => !dead.has(r.token));
},
async persist(): Promise<void> {
try {
await writeFile(filePath, JSON.stringify(records, null, 2), { mode: FILE_MODE });
// writeFile's mode only applies on creation; enforce 0600 if pre-existing.
await chmod(filePath, FILE_MODE);
} catch (err) {
console.error('fcm: token store persist failed', err);
}
},
};
}
// ── message construction (payload minimization lives HERE) ───────────────────
interface FcmMessage {
readonly data: Readonly<Record<string, string>>;
readonly priority: string;
readonly ttl: string;
}
/**
* Build the DATA-ONLY message fields for one signal. Returns null for classes
* outside the P1 FCM signal list (needs-input / done). No `notification` block:
* the Android client renders the lock-screen UI locally (§4.5/R1). `data` mirrors
* the web-push payload minus the cwd-derived label — sessionId, cls, and (gate
* only) the single-use capability token. Never cwd, command, or terminal bytes.
*/
function buildFcmMessage(
cfg: Config,
session: Session,
cls: NotifyClass,
token?: string,
): FcmMessage | null {
if (cls !== 'needs-input' && cls !== 'done') return null;
const isGate = cls === 'needs-input';
const ttlSeconds = isGate ? Math.ceil(cfg.decisionTokenTtlMs / 1000) : DONE_EXPIRATION_SECONDS;
const data: Record<string, string> = {
sessionId: session.meta.id,
cls,
...(isGate && token !== undefined ? { token } : {}),
};
return {
data,
priority: isGate ? PRIORITY_HIGH : PRIORITY_NORMAL,
ttl: `${ttlSeconds}s`,
};
}
/** Serialize one per-token FCM v1 request body (data-only — no notification). */
function serializeMessage(recordToken: string, message: FcmMessage): string {
return JSON.stringify({
message: {
token: recordToken,
data: message.data,
android: { priority: message.priority, ttl: message.ttl },
},
});
}
// ── error classification ──────────────────────────────────────────────────────
/** Collect every FCM error identifier (error.status + details[].errorCode). */
function fcmErrorCodes(body: string): Set<string> {
const codes = new Set<string>();
try {
const parsed: unknown = JSON.parse(body);
if (parsed === null || typeof parsed !== 'object') return codes;
const error = (parsed as Record<string, unknown>)['error'];
if (error === null || typeof error !== 'object') return codes;
const e = error as Record<string, unknown>;
if (typeof e['status'] === 'string') codes.add(e['status']);
const details = e['details'];
if (Array.isArray(details)) {
for (const d of details) {
if (d !== null && typeof d === 'object') {
const errorCode = (d as Record<string, unknown>)['errorCode'];
if (typeof errorCode === 'string') codes.add(errorCode);
}
}
}
} catch {
/* non-JSON body — no codes */
}
return codes;
}
/** True when the response says this token is permanently dead → evict it. */
function isDeadToken(body: string): boolean {
for (const code of fcmErrorCodes(body)) {
if (PRUNE_ERROR_CODES.has(code)) return true;
}
return false;
}
// ── default transport (google-auth-library) ───────────────────────────────────
/** Safely read a numeric `status` off an unknown Gaxios response/error. */
function statusOf(value: unknown): number {
if (value !== null && typeof value === 'object' && 'status' in value) {
const status = (value as { status: unknown }).status;
if (typeof status === 'number') return status;
}
return 0;
}
/** Coerce an unknown Gaxios `data` (string with responseType 'text') to string. */
function bodyOf(data: unknown): string {
if (typeof data === 'string') return data;
if (data === undefined || data === null) return '';
try {
return JSON.stringify(data);
} catch {
return '';
}
}
/**
* Default transport: `google-auth-library` mints + refreshes the OAuth2 access
* token (scope firebase.messaging) and issues the POST.
*
* We deliberately do NOT pass `validateStatus: () => true`. Gaxios throws on any
* non-2xx status, and that throw is exactly what triggers OAuth2Client's internal
* "re-auth on 401/403 → refresh access token → retry once" (see
* oauth2client.requestAsync's catch block — R7's whole reason for the dep).
* Swallowing 4xx/5xx into a resolved response would silently defeat that refresh.
*
* A thrown GaxiosError carries the HTTP result on `err.response` — we convert it
* back to an FcmResponse so sendOne's existing classification (200 / dead-token
* UNREGISTERED|INVALID_ARGUMENT → prune / other) still runs. A thrown error with
* NO response is a real transport failure (DNS, connection refused, token mint) —
* we re-throw so sendOne treats it as a send failure and KEEPS the token, never a
* false dead-token prune.
*/
function createGoogleFcmTransport(keyPath: string): FcmTransport {
const auth = new GoogleAuth({ keyFile: keyPath, scopes: [FCM_SCOPE] });
return {
async send(req: FcmRequest): Promise<FcmResponse> {
const client = await auth.getClient();
try {
const res = await client.request({
url: req.url,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: req.body,
responseType: 'text',
});
return { status: statusOf(res), body: bodyOf(res.data) };
} catch (err) {
const response = (err as { response?: unknown }).response;
if (response !== null && typeof response === 'object') {
return { status: statusOf(response), body: bodyOf((response as { data?: unknown }).data) };
}
throw err; // no HTTP response ⇒ real transport error, surface it to sendOne
}
},
};
}
// ── sender (NotifyService implementation) ─────────────────────────────────────
/**
* Create the FCM NotifyService. Same seam as the web-push / apns services: the
* server fans hook events out through `notify(session, cls, token?)`.
* @param cfg frozen runtime config (DND/done toggles, decision-token TTL)
* @param fcmCfg validated FCM_* env group
* @param store device-token store (read for fan-out, pruned on dead-token errors)
* @param deps injectable transport (tests never touch the network / Google)
*/
export function createFcmService(
cfg: Config,
fcmCfg: FcmConfig,
store: FcmTokenStore,
deps: FcmServiceDeps = {},
): NotifyService {
const transport = deps.transport ?? createGoogleFcmTransport(fcmCfg.keyPath);
const sendUrl = `${fcmCfg.host}/v1/projects/${fcmCfg.projectId}/messages:send`;
/** Mirrors web-push/apns shouldSend; additionally scopes FCM to its P1 signals. */
function shouldSend(cls: NotifyClass): boolean {
if (cls !== 'needs-input' && cls !== 'done') return false;
if (cfg.notifyDnd) return false;
if (cls === 'done' && !cfg.notifyDone) return false;
return true;
}
async function sendOne(record: FcmTokenRecord, message: FcmMessage, dead: string[]): Promise<void> {
let res: FcmResponse;
try {
res = await transport.send({ url: sendUrl, body: serializeMessage(record.token, message) });
} catch (err) {
// Connection-level / auth-mint failure: keep the token, log without secrets.
console.error(`fcm: send failed (${err instanceof Error ? err.message : 'connection error'})`);
return;
}
if (res.status === 200) return;
if (isDeadToken(res.body)) {
dead.push(record.token); // UNREGISTERED / INVALID_ARGUMENT → evict
return;
}
// 401 (lib auto-refreshes internally), 429 and 5xx: keep the token, log
// without any token or key material.
const codes = [...fcmErrorCodes(res.body)];
console.error(`fcm: send failed (status ${res.status}${codes.length > 0 ? ` ${codes.join('/')}` : ''})`);
}
return {
isEnabled: () => true,
async notify(session: Session, cls: NotifyClass, token?: string): Promise<void> {
if (!shouldSend(cls)) return;
const records = store.list();
if (records.length === 0) return;
const message = buildFcmMessage(cfg, session, cls, token);
if (message === null) return;
const dead: string[] = [];
await Promise.all(records.map((record) => sendOne(record, message, dead)));
if (dead.length > 0) {
store.prune(dead);
await store.persist();
}
},
};
}
// ── server-facing init ────────────────────────────────────────────────────────
/**
* Server entry point: read the FCM_* env group and build the runtime, or return
* null when the feature is disabled. Exactly one log line either way; startup
* NEVER crashes on FCM problems; key material is never logged.
*/
export function initFcm(cfg: Config, env: Record<string, string | undefined>): FcmRuntime | null {
const result = loadFcmConfig(env);
if (!result.ok) {
console.error(`fcm: disabled (${result.reason})`);
return null;
}
if (!validateFcmKeyFile(result.config.keyPath)) {
// validateFcmKeyFile already logged the specific (key-material-free) cause.
return null;
}
const store = loadFcmTokenStore(result.config.storePath, cfg.pushMaxSubs);
const service = createFcmService(cfg, result.config, store);
console.error(`fcm: enabled (project ${result.config.projectId}, store ${result.config.storePath})`);
return { service, store };
}

View File

@@ -47,6 +47,7 @@ import { loadSubscriptionStore } from './push/subscription-store.js'
import { loadPrefsStore } from './http/prefs-store.js'
import { createPushService } from './push/push-service.js'
import { combineNotifyServices, initApns, normalizeApnsToken } from './push/apns.js'
import { initFcm, normalizeFcmToken } from './push/fcm.js'
import type {
Config,
NotifyService,
@@ -196,8 +197,18 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
// web-push through the SAME NotifyService seam, so every existing hook-event
// call site below stays untouched: `pushService` is the combined fan-out.
const apns = initApns(cfg, process.env as Record<string, string | undefined>)
// A33: optional FCM sender (Android client) — enabled only when the FCM_* env
// group is complete (else null + one log line, never a crash). It fans out
// beside web-push and APNs through the SAME NotifyService seam.
const fcm = initFcm(cfg, process.env as Record<string, string | undefined>)
const extraNotifyServices: NotifyService[] = [
...(apns !== null ? [apns.service] : []),
...(fcm !== null ? [fcm.service] : []),
]
const pushService: NotifyService =
apns === null ? webPushService : combineNotifyServices(webPushService, apns.service)
extraNotifyServices.length === 0
? webPushService
: combineNotifyServices(webPushService, ...extraNotifyServices)
const manager = createSessionManager(cfg, pushService)
// v0.6 Projects: cross-device UI prefs (favourites + group collapse-state).
@@ -432,7 +443,8 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
// reachable "walked-away" device (T-iOS-20).
const hasPushTarget =
(webPushService.isEnabled() && subStore.list().length > 0) ||
(apns !== null && apns.store.list().length > 0)
(apns !== null && apns.store.list().length > 0) ||
(fcm !== null && fcm.store.list().length > 0)
if (!hasWatcher && !hasPushTarget) {
res.json({})
return
@@ -557,6 +569,53 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
})
}
// ── A33: FCM device-token registry (Android client) ───────────────────────
// Mounted only when the FCM_* env group is complete (disabled ⇒ 404). Frozen
// wire shape: POST/DELETE /push/fcm-token {token: loose base64url+':' string,
// ≤ bound} → 204 idempotent upsert/remove; 400 invalid; Origin guard 403;
// 5/min/IP 429; body ≤ 8kb — mirrors /push/apns-token above.
if (fcm !== null) {
const fcmStore = fcm.store
const fcmTokenLimiter = createRateLimiter(SUBSCRIBE_RATE_MAX, RATE_LIMIT_WINDOW_MS)
app.post('/push/fcm-token', express.json({ limit: '8kb' }), async (req, res) => {
if (!requireAllowedOrigin(req, res)) return
if (!fcmTokenLimiter(req.socket.remoteAddress ?? '', Date.now())) {
res.status(429).end()
return
}
const token = normalizeFcmToken(((req.body ?? {}) as Record<string, unknown>)['token'])
if (token === null) {
res.status(400).end()
return
}
try {
fcmStore.add({ token, createdAt: Date.now() })
await fcmStore.persist()
} catch {
res.status(400).end()
return
}
res.status(204).end()
})
app.delete('/push/fcm-token', express.json({ limit: '8kb' }), async (req, res) => {
if (!requireAllowedOrigin(req, res)) return
if (!fcmTokenLimiter(req.socket.remoteAddress ?? '', Date.now())) {
res.status(429).end()
return
}
const token = normalizeFcmToken(((req.body ?? {}) as Record<string, unknown>)['token'])
if (token === null) {
res.status(400).end()
return
}
fcmStore.remove(token) // idempotent: unknown token still 204
await fcmStore.persist()
res.status(204).end()
})
}
// Remote lock-screen Allow/Deny (SEC-C1): called by a REMOTE device's SW (not
// loopback) → guard with Origin + a per-decision capability token (stale/
// mismatch → 403).

1025
test/push-fcm.test.ts Normal file

File diff suppressed because it is too large Load Diff