From 542fde95806cafb0086cb7ed8d4481468f4e7028 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Fri, 10 Jul 2026 16:40:43 +0200 Subject: [PATCH] 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. --- package-lock.json | 190 ++++++++ package.json | 1 + src/push/fcm.ts | 521 +++++++++++++++++++++ src/server.ts | 63 ++- test/push-fcm.test.ts | 1025 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1798 insertions(+), 2 deletions(-) create mode 100644 src/push/fcm.ts create mode 100644 test/push-fcm.test.ts diff --git a/package-lock.json b/package-lock.json index 85f4e0c..b4f3d96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index b9a952e..43f5a8a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/push/fcm.ts b/src/push/fcm.ts new file mode 100644 index 0000000..a589d2f --- /dev/null +++ b/src/push/fcm.ts @@ -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; +} + +/** 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; +} + +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, 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): 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; + 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; + 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 { + 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>; + 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 = { + 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 { + const codes = new Set(); + try { + const parsed: unknown = JSON.parse(body); + if (parsed === null || typeof parsed !== 'object') return codes; + const error = (parsed as Record)['error']; + if (error === null || typeof error !== 'object') return codes; + const e = error as Record; + 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)['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 { + 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 { + 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 { + 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): 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 }; +} diff --git a/src/server.ts b/src/server.ts index d93227d..8bfc84c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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 } { // 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) + // 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) + 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 } { // 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 } { }) } + // ── 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)['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)['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). diff --git a/test/push-fcm.test.ts b/test/push-fcm.test.ts new file mode 100644 index 0000000..50d4142 --- /dev/null +++ b/test/push-fcm.test.ts @@ -0,0 +1,1025 @@ +/** + * test/push-fcm.test.ts (A33) — FCM v1 sender + device-token registry (Android). + * + * Unit layer (no network, fake FcmTransport): + * - loadFcmConfig: env group all-or-disabled, defaults, host validation + * - normalizeFcmToken: loose base64url+':' accept, empty/oversized/bad-charset reject + * - FcmTokenStore: idempotent upsert/remove, FIFO cap, 0600 persist, tolerant load + * - validateFcmKeyFile: shape check, never logs key material + * - createFcmService: DATA-ONLY minimized payload NEEDS-INPUT vs DONE (priority/ + * ttl — no `notification` block, cwd/command NEVER in the payload), DND/ + * NOTIFY_DONE gates, prune on UNREGISTERED / INVALID_ARGUMENT, connection + * errors never throw, token/key never logged + * + * Integration layer (real startServer on an ephemeral port; google-auth-library + * mocked so the default transport never touches Google): + * - disabled mode: no FCM_* env → routes absent (404), startup never crashes + * - POST/DELETE /push/fcm-token: Origin guard 403, invalid 400, idempotent + * 204s (case-preserved), rate limit 429 (frozen wire shape) + * - coexistence (PTY): held gate → web-push AND FCM both fire, carrying the + * SAME capability token; the data-only payload has no notification block + */ + +import net from 'node:net' +import os from 'node:os' +import path from 'node:path' +import fs from 'node:fs/promises' + +import { afterEach, describe, expect, it, vi } from 'vitest' +import WebSocket from 'ws' +import * as nodePty from 'node-pty' + +import { loadConfig } from '../src/config.js' +import { startServer } from '../src/server.js' +import type { Config, NotifyService, Session } from '../src/types.js' +import { + createFcmService, + initFcm, + loadFcmConfig, + loadFcmTokenStore, + normalizeFcmToken, + validateFcmKeyFile, + type FcmConfig, + type FcmRequest, + type FcmResponse, + type FcmTransport, +} from '../src/push/fcm.js' + +// ── mocks: web-push (coexistence capture) + google-auth-library (no network) ── +// `fcmClientRef.current`, when set by a test, replaces the OAuth2Client returned by +// GoogleAuth.getClient() so a test can exercise the REAL createGoogleFcmTransport +// (401 refresh-and-retry / thrown-error classification) via createFcmService. +type FakeAuthClient = { + request(opts: Record): Promise<{ status: number; data: string }> +} +const { sentWebPushPayloads, fcmSentRequests, fcmClientRef } = vi.hoisted(() => ({ + sentWebPushPayloads: [] as string[], + fcmSentRequests: [] as { url: string; body: string }[], + fcmClientRef: { current: null as null | { request(opts: Record): Promise<{ status: number; data: string }> } }, +})) +vi.mock('web-push', () => ({ + default: { + setVapidDetails: (): void => {}, + sendNotification: async (_sub: unknown, payload: string): Promise => { + sentWebPushPayloads.push(payload) + }, + }, +})) +vi.mock('google-auth-library', () => ({ + GoogleAuth: class { + constructor(_opts: unknown) {} + async getClient(): Promise<{ request(opts: Record): Promise<{ status: number; data: string }> }> { + if (fcmClientRef.current !== null) return fcmClientRef.current + return { + async request(opts: Record): Promise<{ status: number; data: string }> { + fcmSentRequests.push({ url: opts['url'] as string, body: opts['data'] as string }) + return { status: 200, data: '' } + }, + } + } + }, +})) + +const PTY_AVAILABLE = (() => { + try { + const p = nodePty.spawn(process.env['SHELL'] ?? '/bin/sh', [], { cols: 80, rows: 24 }) + p.kill() + return true + } catch { + return false + } +})() +const itPty = PTY_AVAILABLE ? it : it.skip + +// ── fixtures ─────────────────────────────────────────────────────────────────── + +const BASE_CFG: Config = { + port: 3000, + bindHost: '0.0.0.0', + shellPath: '/bin/zsh', + homeDir: '/home/tester', + idleTtlMs: 10_000, + scrollbackBytes: 2 * 1024 * 1024, + maxPayloadBytes: 1024 * 1024, + wsPath: '/term', + maxSessions: 50, + maxMsgsPerSec: 2000, + permTimeoutMs: 300_000, + reapIntervalMs: 60_000, + previewBytes: 24 * 1024, + useTmux: false, + allowedOrigins: [], + projectRoots: ['/home/tester'], + projectScanDepth: 4, + projectScanTtlMs: 10_000, + projectDirtyCheck: true, + editorCmd: 'code', + vapidPublicKey: 'PUBKEY', + vapidPrivateKey: 'PRIVKEY', + vapidSubject: 'mailto:admin@example.com', + pushStorePath: '/tmp/push-subs.json', + pushMaxSubs: 50, + notifyDone: true, + notifyDnd: false, + decisionTokenTtlMs: 300_000, + timelineMax: 200, + timelineEnabled: true, + stuckTtlMs: 600_000, + stuckAlert: true, + diffTimeoutMs: 2000, + diffMaxBytes: 2 * 1024 * 1024, + diffMaxFiles: 300, + statuslineTtlMs: 30_000, + worktreeEnabled: true, + worktreeRoot: undefined, + worktreeTimeoutMs: 10_000, + defaultPermissionMode: 'default', + allowAutoMode: false, +} + +function cfg(overrides: Partial = {}): Config { + return { ...BASE_CFG, ...overrides } +} + +const SID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479' +const SECRET_CWD = '/home/tester/super-secret-project' + +function fakeSession(id = SID, cwd: string | null = SECRET_CWD): Session { + return { meta: { id, createdAt: 0, shellPath: '/bin/zsh' }, cwd } as unknown as Session +} + +const TOK_A = 'fcm-token-AAA:aaaaaaaaaaaa' +const TOK_B = 'fcm-token-BBB:bbbbbbbbbbbb' + +const tmpFiles: string[] = [] + +function tmpPath(name: string): string { + const p = path.join(os.tmpdir(), `webterm-fcm-${name}-${Date.now()}-${Math.random().toString(36).slice(2)}`) + tmpFiles.push(p) + return p +} + +const FCM_ENV_OK = { + FCM_PROJECT_ID: 'demo-project', + FCM_KEY_PATH: '/tmp/sa-demo.json', +} + +/** A well-formed (fake) service-account JSON — the private_key marker must never + * appear in any log line. google-auth-library is mocked, so the key is inert. */ +function serviceAccountKey(): { json: string; privateMarker: string } { + const privateMarker = 'SUPER-SECRET-PRIVATE-KEY-MATERIAL' + const json = JSON.stringify({ + type: 'service_account', + project_id: 'demo-project', + private_key: `-----BEGIN PRIVATE KEY-----\n${privateMarker}\n-----END PRIVATE KEY-----\n`, + client_email: 'fcm@demo-project.iam.gserviceaccount.com', + }) + return { json, privateMarker } +} + +async function writeServiceAccountKey(): Promise<{ keyPath: string; privateMarker: string }> { + const { json, privateMarker } = serviceAccountKey() + const keyPath = tmpPath('sa.json') + await fs.writeFile(keyPath, json) + return { keyPath, privateMarker } +} + +function fcmCfgFixture(overrides: Partial = {}): FcmConfig { + return { + projectId: 'demo-project', + keyPath: '/unused/sa.json', + storePath: tmpPath('svc-store'), + host: 'https://fcm.googleapis.com', + ...overrides, + } +} + +interface FakeTransport extends FcmTransport { + requests: FcmRequest[] +} + +/** Fake transport seam: records requests; per-call responder decides the outcome. */ +function makeTransport( + respond: (req: FcmRequest, callIndex: number) => FcmResponse | Promise = () => ({ status: 200, body: '' }), +): FakeTransport { + const requests: FcmRequest[] = [] + return { + requests, + async send(req: FcmRequest): Promise { + requests.push(req) + return respond(req, requests.length - 1) + }, + } +} + +function makeService(options: { + respond?: (req: FcmRequest, i: number) => FcmResponse | Promise + cfgOverrides?: Partial + fcmOverrides?: Partial + tokens?: string[] +}): { service: NotifyService; transport: FakeTransport; store: ReturnType } { + const store = loadFcmTokenStore(tmpPath('unit-tokens.json'), 50) + for (const t of options.tokens ?? [TOK_A]) store.add({ token: t, createdAt: 1 }) + const transport = makeTransport(options.respond) + const service = createFcmService(cfg(options.cfgOverrides), fcmCfgFixture(options.fcmOverrides), store, { transport }) + return { service, transport, store } +} + +afterEach(async () => { + vi.restoreAllMocks() + sentWebPushPayloads.length = 0 + fcmSentRequests.length = 0 + fcmClientRef.current = null + for (const f of tmpFiles.splice(0)) await fs.rm(f, { force: true }).catch(() => undefined) +}) + +// ═══════════════════════════════════════════════════════════════════════════════ +// Unit: loadFcmConfig +// ═══════════════════════════════════════════════════════════════════════════════ + +describe('loadFcmConfig', () => { + it('returns ok with defaults when the critical pair is set', () => { + const r = loadFcmConfig(FCM_ENV_OK) + expect(r.ok).toBe(true) + if (!r.ok) return + expect(r.config.projectId).toBe('demo-project') + expect(r.config.keyPath).toBe('/tmp/sa-demo.json') + expect(r.config.storePath.endsWith('.web-terminal-fcm-tokens.json')).toBe(true) + expect(r.config.host).toBe('https://fcm.googleapis.com') + }) + + it('honors FCM_STORE_PATH / FCM_HOST overrides', () => { + const r = loadFcmConfig({ + ...FCM_ENV_OK, + FCM_STORE_PATH: '/tmp/custom-fcm.json', + FCM_HOST: 'http://127.0.0.1:9', + }) + expect(r.ok).toBe(true) + if (!r.ok) return + expect(r.config.storePath).toBe('/tmp/custom-fcm.json') + expect(r.config.host).toBe('http://127.0.0.1:9') + }) + + it.each(['FCM_PROJECT_ID', 'FCM_KEY_PATH'])( + 'disables the whole group when %s is missing (reason names the var)', + (missing) => { + const env: Record = { ...FCM_ENV_OK } + delete env[missing] + const r = loadFcmConfig(env) + expect(r.ok).toBe(false) + if (r.ok) return + expect(r.reason).toContain(missing) + }, + ) + + it('treats an empty-string critical var as missing', () => { + const r = loadFcmConfig({ ...FCM_ENV_OK, FCM_PROJECT_ID: '' }) + expect(r.ok).toBe(false) + }) + + it('rejects a malformed FCM_HOST instead of crashing', () => { + const r = loadFcmConfig({ ...FCM_ENV_OK, FCM_HOST: 'not a url' }) + expect(r.ok).toBe(false) + }) + + it('rejects a non-http(s) FCM_HOST scheme', () => { + const r = loadFcmConfig({ ...FCM_ENV_OK, FCM_HOST: 'ftp://fcm.googleapis.com' }) + expect(r.ok).toBe(false) + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════════ +// Unit: normalizeFcmToken (loose, deliberate — §4.5) +// ═══════════════════════════════════════════════════════════════════════════════ + +describe('normalizeFcmToken', () => { + it('accepts a realistic base64url+colon token verbatim (case preserved)', () => { + const raw = 'cXyZ-09_ABC:dEfGhIjK123' + expect(normalizeFcmToken(raw)).toBe(raw) + }) + + it('accepts the maximum bounded length', () => { + const raw = 'a'.repeat(4096) + expect(normalizeFcmToken(raw)).toBe(raw) + }) + + it.each([ + ['empty', ''], + ['oversized (>4096)', 'a'.repeat(4097)], + ['plus (non-base64url)', `${'a'.repeat(20)}+`], + ['slash (non-base64url)', `${'a'.repeat(20)}/`], + ['whitespace', `${'a'.repeat(20)} `], + ['dot', `${'a'.repeat(20)}.`], + ])('rejects %s', (_label, raw) => { + expect(normalizeFcmToken(raw)).toBeNull() + }) + + it.each([ + ['number', 42], + ['null', null], + ['undefined', undefined], + ['object', { token: TOK_A }], + ])('rejects non-string input (%s)', (_label, raw) => { + expect(normalizeFcmToken(raw)).toBeNull() + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════════ +// Unit: FcmTokenStore +// ═══════════════════════════════════════════════════════════════════════════════ + +describe('loadFcmTokenStore', () => { + it('starts empty when the file does not exist', () => { + const store = loadFcmTokenStore(tmpPath('missing.json'), 50) + expect(store.list()).toEqual([]) + }) + + it('upserts idempotently by token (same token twice → one record)', () => { + const store = loadFcmTokenStore(tmpPath('upsert.json'), 50) + store.add({ token: TOK_A, createdAt: 1 }) + store.add({ token: TOK_A, createdAt: 2 }) + expect(store.list().length).toBe(1) + expect(store.list()[0]?.createdAt).toBe(2) + }) + + it('remove is idempotent (unknown token is a no-op)', () => { + const store = loadFcmTokenStore(tmpPath('remove.json'), 50) + store.add({ token: TOK_A, createdAt: 1 }) + store.remove(TOK_B) + expect(store.list().length).toBe(1) + store.remove(TOK_A) + store.remove(TOK_A) + expect(store.list()).toEqual([]) + }) + + it('FIFO-caps at maxTokens (oldest evicted)', () => { + const store = loadFcmTokenStore(tmpPath('cap.json'), 2) + store.add({ token: 'aaa', createdAt: 1 }) + store.add({ token: 'bbb', createdAt: 2 }) + store.add({ token: 'ccc', createdAt: 3 }) + expect(store.list().map((r) => r.token)).toEqual(['bbb', 'ccc']) + }) + + it('throws on a structurally invalid record', () => { + const store = loadFcmTokenStore(tmpPath('invalid.json'), 50) + expect(() => store.add({ token: 'bad token!', createdAt: 1 })).toThrow(TypeError) + }) + + it('persists with 0600 permissions and round-trips through load', async () => { + const file = tmpPath('persist.json') + const store = loadFcmTokenStore(file, 50) + store.add({ token: TOK_A, createdAt: 7 }) + await store.persist() + const st = await fs.stat(file) + expect(st.mode & 0o777).toBe(0o600) + const reloaded = loadFcmTokenStore(file, 50) + expect(reloaded.list()).toEqual([{ token: TOK_A, createdAt: 7 }]) + }) + + it('tolerates a malformed store file (starts empty, no throw)', async () => { + const file = tmpPath('malformed.json') + await fs.writeFile(file, 'not json at all') + const store = loadFcmTokenStore(file, 50) + expect(store.list()).toEqual([]) + }) + + it('filters invalid entries out of a persisted file', async () => { + const file = tmpPath('mixed.json') + await fs.writeFile( + file, + JSON.stringify([{ token: TOK_A, createdAt: 1 }, { token: 'bad token!', createdAt: 1 }, 42]), + ) + const store = loadFcmTokenStore(file, 50) + expect(store.list()).toEqual([{ token: TOK_A, createdAt: 1 }]) + }) + + it('prune removes a batch of dead tokens', () => { + const store = loadFcmTokenStore(tmpPath('prune.json'), 50) + store.add({ token: TOK_A, createdAt: 1 }) + store.add({ token: TOK_B, createdAt: 2 }) + store.prune([TOK_A]) + expect(store.list().map((r) => r.token)).toEqual([TOK_B]) + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════════ +// Unit: validateFcmKeyFile (never logs key material) +// ═══════════════════════════════════════════════════════════════════════════════ + +describe('validateFcmKeyFile', () => { + it('accepts a well-formed service-account JSON', async () => { + const { keyPath } = await writeServiceAccountKey() + expect(validateFcmKeyFile(keyPath)).toBe(true) + }) + + it('returns false (never throws) for a missing file', () => { + expect(validateFcmKeyFile('/nonexistent/sa.json')).toBe(false) + }) + + it('returns false for non-JSON, never echoing the contents', async () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const file = tmpPath('garbage.json') + await fs.writeFile(file, 'MY-LEAKED-SECRET not json') + expect(validateFcmKeyFile(file)).toBe(false) + for (const call of errSpy.mock.calls) { + expect(call.join(' ')).not.toContain('MY-LEAKED-SECRET') + } + }) + + it('returns false when client_email / private_key are missing', async () => { + const file = tmpPath('shape.json') + await fs.writeFile(file, JSON.stringify({ type: 'service_account', project_id: 'x' })) + expect(validateFcmKeyFile(file)).toBe(false) + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════════ +// Unit: createFcmService — DATA-ONLY payload shapes + minimization +// ═══════════════════════════════════════════════════════════════════════════════ + +function parseMessage(req: FcmRequest): { token: unknown; data: Record; android: Record; hasNotification: boolean } { + const parsed = JSON.parse(req.body) as { message: Record } + const message = parsed.message + return { + token: message['token'], + data: message['data'] as Record, + android: message['android'] as Record, + hasNotification: 'notification' in message, + } +} + +describe('createFcmService payload shape', () => { + it('NEEDS-INPUT: HIGH priority, data-only {sessionId, cls, token}, NO notification block', async () => { + const { service, transport } = makeService({}) + await service.notify(fakeSession(), 'needs-input', 'cap-token-1') + + expect(transport.requests.length).toBe(1) + const req = transport.requests[0] as FcmRequest + expect(req.url).toBe('https://fcm.googleapis.com/v1/projects/demo-project/messages:send') + + const m = parseMessage(req) + expect(m.token).toBe(TOK_A) + expect(m.hasNotification).toBe(false) + expect(m.android['priority']).toBe('HIGH') + expect(m.android['ttl']).toBe('300s') // decisionTokenTtlMs 300_000 → 300s + expect(m.data).toEqual({ sessionId: SID, cls: 'needs-input', token: 'cap-token-1' }) + }) + + it('DONE: NORMAL priority, no decision token, still data-only', async () => { + const { service, transport } = makeService({}) + await service.notify(fakeSession(), 'done') + + const req = transport.requests[0] as FcmRequest + const m = parseMessage(req) + expect(m.hasNotification).toBe(false) + expect(m.android['priority']).toBe('NORMAL') + expect(m.android['ttl']).toBe('600s') + expect(m.data).toEqual({ sessionId: SID, cls: 'done' }) + expect('token' in m.data).toBe(false) + }) + + it('NEVER leaks cwd, command content, or terminal output into the payload', async () => { + const { service, transport } = makeService({}) + await service.notify(fakeSession(SID, SECRET_CWD), 'needs-input', 'tok') + await service.notify(fakeSession(SID, SECRET_CWD), 'done') + + for (const req of transport.requests) { + expect(req.body).not.toContain('super-secret-project') + expect(req.body).not.toContain(SECRET_CWD) + expect(req.body).not.toContain('cwd') + expect(req.body).not.toContain('command') + expect(req.body).not.toContain('notification') + } + }) + + it('P1 signal list is NEEDS-INPUT + DONE only: stuck sends nothing over FCM', async () => { + const { service, transport } = makeService({}) + await service.notify(fakeSession(), 'stuck') + expect(transport.requests.length).toBe(0) + }) + + it('fans out to every registered token', async () => { + const { service, transport } = makeService({ tokens: [TOK_A, TOK_B] }) + await service.notify(fakeSession(), 'done') + expect(transport.requests.map((r) => parseMessage(r).token).sort()).toEqual([TOK_A, TOK_B].sort()) + }) + + it('sends nothing when no tokens are registered', async () => { + const { service, transport } = makeService({ tokens: [] }) + await service.notify(fakeSession(), 'needs-input', 'tok') + expect(transport.requests.length).toBe(0) + }) + + it('honors NOTIFY_DND (mirrors web-push / apns semantics)', async () => { + const { service, transport } = makeService({ cfgOverrides: { notifyDnd: true } }) + await service.notify(fakeSession(), 'needs-input', 'tok') + expect(transport.requests.length).toBe(0) + }) + + it('honors NOTIFY_DONE=false for done but still sends needs-input', async () => { + const { service, transport } = makeService({ cfgOverrides: { notifyDone: false } }) + await service.notify(fakeSession(), 'done') + expect(transport.requests.length).toBe(0) + await service.notify(fakeSession(), 'needs-input', 'tok') + expect(transport.requests.length).toBe(1) + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════════ +// Unit: response handling — prune on dead tokens, resilience, no secret logging +// ═══════════════════════════════════════════════════════════════════════════════ + +const UNREGISTERED_BODY = JSON.stringify({ + error: { + code: 404, + status: 'NOT_FOUND', + details: [{ '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', errorCode: 'UNREGISTERED' }], + }, +}) +const INVALID_ARG_BODY = JSON.stringify({ + error: { + code: 400, + status: 'INVALID_ARGUMENT', + details: [{ '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', errorCode: 'INVALID_ARGUMENT' }], + }, +}) + +describe('createFcmService response handling', () => { + it('evicts a token on UNREGISTERED and keeps healthy ones', async () => { + const { service, store } = makeService({ + tokens: [TOK_A, TOK_B], + respond: (req) => + req.body.includes(TOK_A) ? { status: 404, body: UNREGISTERED_BODY } : { status: 200, body: '' }, + }) + await service.notify(fakeSession(), 'done') + expect(store.list().map((r) => r.token)).toEqual([TOK_B]) + }) + + it('evicts a token on INVALID_ARGUMENT', async () => { + const { service, store } = makeService({ + respond: () => ({ status: 400, body: INVALID_ARG_BODY }), + }) + await service.notify(fakeSession(), 'done') + expect(store.list()).toEqual([]) + }) + + it('keeps the token on a 401 (lib auto-refreshes) and on 429 (transient)', async () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { service, store } = makeService({ + respond: (_req, i) => + i === 0 + ? { status: 401, body: '{"error":{"status":"UNAUTHENTICATED"}}' } + : { status: 429, body: '{"error":{"status":"RESOURCE_EXHAUSTED"}}' }, + }) + await service.notify(fakeSession(), 'done') + await service.notify(fakeSession(), 'done') + expect(store.list().length).toBe(1) + expect(errSpy).toHaveBeenCalled() + for (const call of errSpy.mock.calls) { + expect(call.join(' ')).not.toContain(TOK_A) + } + }) + + it('never throws on transport errors and keeps the token', async () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { service, store } = makeService({ + respond: () => { + throw new Error('ECONNREFUSED fcm.googleapis.com:443') + }, + }) + await expect(service.notify(fakeSession(), 'needs-input', 'tok')).resolves.toBeUndefined() + expect(store.list().length).toBe(1) + expect(errSpy).toHaveBeenCalled() + for (const call of errSpy.mock.calls) { + expect(call.join(' ')).not.toContain(TOK_A) + } + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════════ +// Unit: default google-auth-library transport — 401 refresh-and-retry + thrown- +// error classification (regression: validateStatus:()=>true defeated the refresh) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** Build a service that uses the REAL createGoogleFcmTransport (no injected + * transport) so GoogleAuth.getClient() → fcmClientRef.current is exercised. */ +function makeServiceWithDefaultTransport(tokens: string[] = [TOK_A]): { + service: NotifyService + store: ReturnType +} { + const store = loadFcmTokenStore(tmpPath('default-transport.json'), 50) + for (const t of tokens) store.add({ token: t, createdAt: 1 }) + const service = createFcmService(cfg(), fcmCfgFixture(), store) // no deps → createGoogleFcmTransport + return { service, store } +} + +/** + * Models google-auth-library's OAuth2Client.request(): its internal gaxios call + * THROWS on a >=400 status, and that throw is what makes the client refresh the + * access token and retry a 401 once. A stale token 401s; after the refresh the + * token is fresh and 200s. If the caller passes validateStatus that swallows the + * 401 (the bug), gaxios never throws → the refresh path never runs. + */ +function refreshOn401Client(): { client: FakeAuthClient; state: { gaxiosCalls: number; refreshes: number } } { + const state = { gaxiosCalls: 0, refreshes: 0 } + let fresh = false + const request = async (opts: Record): Promise<{ status: number; data: string }> => { + state.gaxiosCalls++ + const res = fresh + ? { status: 200, data: '' } + : { status: 401, data: '{"error":{"status":"UNAUTHENTICATED"}}' } + if (res.status < 400) return res + const validateStatus = opts['validateStatus'] as ((s: number) => boolean) | undefined + if (validateStatus?.(res.status) === true) return res // 401 swallowed → no refresh (the bug) + state.refreshes++ + fresh = true + return request(opts) // OAuth2Client refreshes the token + retries once + } + return { client: { request }, state } +} + +/** A client whose request always throws — with `response` set it mimics a thrown + * GaxiosError (HTTP error); with null it mimics a real transport error (no HTTP). */ +function throwingClient(response: { status: number; data: string } | null): FakeAuthClient { + return { + async request(): Promise<{ status: number; data: string }> { + const err = new Error('ECONNREFUSED fcm.googleapis.com:443') as Error & { response?: unknown } + if (response !== null) err.response = response + throw err + }, + } +} + +describe('default google-auth-library transport (createGoogleFcmTransport)', () => { + it('a 401 refreshes-and-retries and the send ultimately succeeds (refresh path exercised)', async () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { client, state } = refreshOn401Client() + fcmClientRef.current = client + const { service, store } = makeServiceWithDefaultTransport() + + await service.notify(fakeSession(), 'done') + + // gaxios ran twice (initial 401 + post-refresh 200) and the client refreshed once. + expect(state.gaxiosCalls).toBe(2) + expect(state.refreshes).toBe(1) + // Success ⇒ token kept and no "send failed" line logged (buggy code returns the + // 401 verbatim → refreshes 0, gaxiosCalls 1, and a send-failure is logged). + expect(store.list().map((r) => r.token)).toEqual([TOK_A]) + for (const call of errSpy.mock.calls) { + expect(call.join(' ')).not.toContain('send failed') + } + }) + + it('classifies a thrown 404/UNREGISTERED GaxiosError as a dead token → prunes it', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + fcmClientRef.current = throwingClient({ status: 404, data: UNREGISTERED_BODY }) + const { service, store } = makeServiceWithDefaultTransport() + + await service.notify(fakeSession(), 'done') + + expect(store.list()).toEqual([]) // thrown 404 must still evict the dead token + }) + + it('treats a thrown error with NO response as a send failure → keeps the token', async () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + fcmClientRef.current = throwingClient(null) + const { service, store } = makeServiceWithDefaultTransport() + + await expect(service.notify(fakeSession(), 'needs-input', 'tok')).resolves.toBeUndefined() + + expect(store.list().map((r) => r.token)).toEqual([TOK_A]) // never a false dead-token prune + expect(errSpy).toHaveBeenCalled() + const logged = errSpy.mock.calls.map((c) => c.join(' ')).join('\n') + expect(logged).toContain('send failed') + expect(logged).not.toContain(TOK_A) // token still never logged + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════════ +// Unit: initFcm — disabled semantics (startup never crashes, one log line) +// ═══════════════════════════════════════════════════════════════════════════════ + +describe('initFcm', () => { + it('returns null with one log line when nothing is configured', () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + expect(initFcm(cfg(), {})).toBeNull() + const fcmLines = errSpy.mock.calls.filter((c) => String(c[0]).includes('fcm')) + expect(fcmLines.length).toBe(1) + }) + + it('returns null when the group is only partially configured', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + expect(initFcm(cfg(), { FCM_PROJECT_ID: 'demo-project' })).toBeNull() + }) + + it('returns null (no crash) when the key file is unreadable or garbage', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const file = tmpPath('bad.json') + await fs.writeFile(file, 'garbage') + expect(initFcm(cfg(), { ...FCM_ENV_OK, FCM_KEY_PATH: file, FCM_STORE_PATH: tmpPath('s1.json') })).toBeNull() + }) + + it('returns a runtime with a working store when fully configured — never logging key material', async () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { keyPath, privateMarker } = await writeServiceAccountKey() + const rt = initFcm(cfg(), { ...FCM_ENV_OK, FCM_KEY_PATH: keyPath, FCM_STORE_PATH: tmpPath('init-store.json') }) + expect(rt).not.toBeNull() + if (rt === null) return + expect(rt.service.isEnabled()).toBe(true) + rt.store.add({ token: TOK_A, createdAt: 1 }) + expect(rt.store.list().length).toBe(1) + for (const call of errSpy.mock.calls) { + expect(call.join(' ')).not.toContain(privateMarker) + expect(call.join(' ')).not.toContain('PRIVATE KEY') + } + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════════ +// Integration: real startServer on an ephemeral port +// ═══════════════════════════════════════════════════════════════════════════════ + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer() + srv.listen(0, '127.0.0.1', () => { + const addr = srv.address() + if (addr === null || typeof addr === 'string') { + srv.close() + reject(new Error('bad addr')) + return + } + const port = addr.port + srv.close(() => resolve(port)) + }) + srv.on('error', reject) + }) +} + +const handles: { close(): Promise }[] = [] + +const FCM_ENV_KEYS = ['FCM_PROJECT_ID', 'FCM_KEY_PATH', 'FCM_STORE_PATH', 'FCM_HOST'] as const + +/** Run startServer with FCM_* temporarily injected into process.env, restoring + * the previous values right after (the server reads the group once at startup). */ +async function spawnServer(options: { + fcmEnv?: Partial> + cfgEnv?: Record +} = {}): Promise<{ port: number; origin: string; fcmStorePath: string }> { + const port = await getFreePort() + const pushStorePath = tmpPath(`web-subs-${port}.json`) + const fcmStorePath = options.fcmEnv?.FCM_STORE_PATH ?? tmpPath(`fcm-tokens-${port}.json`) + + const saved = new Map() + for (const k of FCM_ENV_KEYS) saved.set(k, process.env[k]) + for (const k of FCM_ENV_KEYS) delete process.env[k] + if (options.fcmEnv !== undefined) { + for (const [k, v] of Object.entries(options.fcmEnv)) process.env[k] = v + if (options.fcmEnv.FCM_STORE_PATH === undefined) process.env['FCM_STORE_PATH'] = fcmStorePath + } + try { + const config = loadConfig({ + PORT: String(port), + BIND_HOST: '127.0.0.1', + SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh', + ALLOWED_ORIGINS: `http://127.0.0.1:${port}`, + USE_TMUX: '0', + IDLE_TTL: '86400', + PUSH_STORE_PATH: pushStorePath, + ...options.cfgEnv, + }) + handles.push(startServer(config)) + } finally { + for (const [k, v] of saved) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + } + await new Promise((r) => setTimeout(r, 80)) + return { port, origin: `http://127.0.0.1:${port}`, fcmStorePath } +} + +async function makeFcmEnv(): Promise> { + const { keyPath } = await writeServiceAccountKey() + return { FCM_PROJECT_ID: 'demo-project', FCM_KEY_PATH: keyPath } +} + +function postToken(port: number, token: unknown, origin?: string, method: 'POST' | 'DELETE' = 'POST'): Promise { + return fetch(`http://127.0.0.1:${port}/push/fcm-token`, { + method, + headers: { 'Content-Type': 'application/json', ...(origin !== undefined ? { Origin: origin } : {}) }, + body: JSON.stringify({ token }), + }) +} + +afterEach(async () => { + while (handles.length > 0) await handles.pop()?.close() + await new Promise((r) => setTimeout(r, 30)) +}) + +describe('disabled mode (no FCM_* env)', () => { + it('does not mount the token routes and the server still works', async () => { + const { port, origin } = await spawnServer() + expect((await postToken(port, TOK_A, origin)).status).toBe(404) + expect((await postToken(port, TOK_A, origin, 'DELETE')).status).toBe(404) + const alive = await fetch(`http://127.0.0.1:${port}/live-sessions`) + expect(alive.status).toBe(200) + }) + + it('stays disabled (no crash) when the group is partial or the key is garbage', async () => { + const badKey = tmpPath('garbage-server.json') + await fs.writeFile(badKey, 'garbage') + const { port, origin } = await spawnServer({ + fcmEnv: { FCM_PROJECT_ID: 'demo-project', FCM_KEY_PATH: badKey }, + }) + expect((await postToken(port, TOK_A, origin)).status).toBe(404) + expect((await fetch(`http://127.0.0.1:${port}/live-sessions`)).status).toBe(200) + }) +}) + +describe('POST/DELETE /push/fcm-token (frozen wire shape)', () => { + it('rejects foreign and missing Origin with 403', async () => { + const { port } = await spawnServer({ fcmEnv: await makeFcmEnv() }) + expect((await postToken(port, TOK_A, 'http://evil.example')).status).toBe(403) + expect((await postToken(port, TOK_A)).status).toBe(403) + expect((await postToken(port, TOK_A, 'http://evil.example', 'DELETE')).status).toBe(403) + }) + + it('rejects invalid tokens with 400', async () => { + const { port, origin } = await spawnServer({ fcmEnv: await makeFcmEnv() }) + expect((await postToken(port, '', origin)).status).toBe(400) + expect((await postToken(port, 'has spaces here', origin)).status).toBe(400) + expect((await postToken(port, 42, origin)).status).toBe(400) + }) + + it('registers idempotently (204 twice), preserving token case', async () => { + const { port, origin, fcmStorePath } = await spawnServer({ fcmEnv: await makeFcmEnv() }) + const token = 'AbC-123_xyz:MixedCase' + expect((await postToken(port, token, origin)).status).toBe(204) + expect((await postToken(port, token, origin)).status).toBe(204) + const stored = JSON.parse(await fs.readFile(fcmStorePath, 'utf8')) as { token: string }[] + expect(stored.length).toBe(1) + expect(stored[0]?.token).toBe(token) + }) + + it('unregisters idempotently (204 even for an unknown token)', async () => { + const { port, origin, fcmStorePath } = await spawnServer({ fcmEnv: await makeFcmEnv() }) + expect((await postToken(port, TOK_A, origin)).status).toBe(204) + expect((await postToken(port, TOK_A, origin, 'DELETE')).status).toBe(204) + expect((await postToken(port, TOK_B, origin, 'DELETE')).status).toBe(204) + const stored = JSON.parse(await fs.readFile(fcmStorePath, 'utf8')) as { token: string }[] + expect(stored.length).toBe(0) + }) + + it('rate-limits more than 5 calls/min from one IP (429)', async () => { + const { port, origin } = await spawnServer({ fcmEnv: await makeFcmEnv() }) + const codes: number[] = [] + for (let i = 0; i < 6; i++) { + codes.push((await postToken(port, `tok${i}:${'a'.repeat(40)}`, origin)).status) + } + expect(codes.slice(0, 5).every((c) => c === 204)).toBe(true) + expect(codes[5]).toBe(429) + }) +}) + +// ── coexistence: held gate → BOTH web-push and FCM fire with the same token ── + +function waitForOpen(ws: WebSocket): Promise { + return new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error('open timeout')), 3000) + ws.once('open', () => { + clearTimeout(t) + resolve() + }) + ws.once('error', (e) => { + clearTimeout(t) + reject(e) + }) + }) +} + +async function createDetachedSession(port: number, origin: string): Promise { + const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { headers: { Origin: origin } }) + await waitForOpen(ws) + ws.send(JSON.stringify({ type: 'attach', sessionId: null })) + const sessionId = await new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error('attach timeout')), 5000) + ws.on('message', (raw) => { + try { + const m = JSON.parse(String(raw)) as Record + if (m['type'] === 'attached') { + clearTimeout(t) + resolve(m['sessionId'] as string) + } + } catch { + /* ignore non-JSON frames */ + } + }) + }) + ws.close() + await new Promise((r) => setTimeout(r, 150)) + return sessionId +} + +describe('coexistence: web-push + FCM on a held gate', () => { + itPty('fires BOTH paths with the same capability token; the FCM payload is data-only', async () => { + const { port, origin } = await spawnServer({ + fcmEnv: await makeFcmEnv(), + cfgEnv: { + VAPID_PUBLIC_KEY: 'test-public-key', + VAPID_PRIVATE_KEY: 'test-private-key', + VAPID_SUBJECT: 'mailto:test@localhost', + PERM_TIMEOUT_MS: '4000', + }, + }) + + // Register a web-push subscription AND an FCM device token. + const sub = await fetch(`http://127.0.0.1:${port}/push/subscribe`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: origin }, + body: JSON.stringify({ endpoint: 'https://push.example/ep-1', keys: { p256dh: 'p', auth: 'a' } }), + }) + expect(sub.status).toBe(204) + expect((await postToken(port, TOK_A, origin)).status).toBe(204) + + const sessionId = await createDetachedSession(port, origin) + + // Fire the held permission hook (loopback); do NOT await — it must be HELD. + sentWebPushPayloads.length = 0 + fcmSentRequests.length = 0 + let resolved = false + const permPromise = fetch(`http://127.0.0.1:${port}/hook/permission`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId }, + body: JSON.stringify({ tool_name: 'Bash' }), + }).then(async (r) => { + resolved = true + return (await r.json()) as { hookSpecificOutput?: { decision?: { behavior?: string } } } + }) + + await new Promise((r) => setTimeout(r, 400)) + expect(resolved).toBe(false) + + // Web-push path: unchanged. + expect(sentWebPushPayloads.length).toBe(1) + const webToken = (JSON.parse(sentWebPushPayloads[0] as string) as { token?: string }).token + expect(typeof webToken).toBe('string') + + // FCM path: same gate, same capability token, data-only frozen shape. + expect(fcmSentRequests.length).toBe(1) + const fcmReq = fcmSentRequests[0] as { url: string; body: string } + expect(fcmReq.url).toBe('https://fcm.googleapis.com/v1/projects/demo-project/messages:send') + const message = (JSON.parse(fcmReq.body) as { message: Record }).message + expect(message['token']).toBe(TOK_A) + expect('notification' in message).toBe(false) + const data = message['data'] as Record + expect(data['sessionId']).toBe(sessionId) + expect(data['cls']).toBe('needs-input') + expect(data['token']).toBe(webToken) + expect((message['android'] as Record)['priority']).toBe('HIGH') + expect(fcmReq.body).not.toContain('"cwd"') + + // The capability token semantics are UNCHANGED: a single decision resolves it. + const dec = await fetch(`http://127.0.0.1:${port}/hook/decision`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: origin }, + body: JSON.stringify({ sessionId, decision: 'allow', token: webToken }), + }) + expect(dec.status).toBe(204) + const decision = await permPromise + expect(decision.hookSpecificOutput?.decision?.behavior).toBe('allow') + }) + + itPty('an FCM token alone (zero web subs, zero clients) is enough to HOLD the gate', async () => { + const { port, origin } = await spawnServer({ + fcmEnv: await makeFcmEnv(), + cfgEnv: { PERM_TIMEOUT_MS: '4000' }, + }) + expect((await postToken(port, TOK_A, origin)).status).toBe(204) + const sessionId = await createDetachedSession(port, origin) + + fcmSentRequests.length = 0 + let resolved = false + void fetch(`http://127.0.0.1:${port}/hook/permission`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId }, + body: JSON.stringify({ tool_name: 'Bash' }), + }).then(() => { + resolved = true + }) + + await new Promise((r) => setTimeout(r, 400)) + expect(resolved).toBe(false) // held — not fallen through to Claude's own prompt + expect(fcmSentRequests.length).toBe(1) + + // Release it so the server can shut down cleanly. + const message = (JSON.parse((fcmSentRequests[0] as { body: string }).body) as { message: Record }).message + const capToken = (message['data'] as Record)['token'] + const dec = await fetch(`http://127.0.0.1:${port}/hook/decision`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: origin }, + body: JSON.stringify({ sessionId, decision: 'deny', token: capToken }), + }) + expect(dec.status).toBe(204) + }) +})