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:
521
src/push/fcm.ts
Normal file
521
src/push/fcm.ts
Normal 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 };
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user