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:
190
package-lock.json
generated
190
package-lock.json
generated
@@ -14,6 +14,7 @@
|
|||||||
"@xterm/addon-web-links": "^0.12.0",
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"google-auth-library": "^10.9.0",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"web-push": "3.6.7",
|
"web-push": "3.6.7",
|
||||||
@@ -1537,6 +1538,26 @@
|
|||||||
"js-tokens": "^10.0.0"
|
"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": {
|
"node_modules/bidi-js": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||||
@@ -1547,6 +1568,15 @@
|
|||||||
"require-from-string": "^2.0.2"
|
"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": {
|
"node_modules/bn.js": {
|
||||||
"version": "4.12.4",
|
"version": "4.12.4",
|
||||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.4.tgz",
|
"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": "^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": {
|
"node_modules/data-urls": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||||
@@ -2029,6 +2068,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"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": {
|
"node_modules/finalhandler": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
||||||
@@ -2081,6 +2149,18 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/forwarded": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
@@ -2123,6 +2203,34 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/get-caller-file": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
@@ -2169,6 +2277,32 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/gopd": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"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": {
|
"node_modules/jwa": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
@@ -2876,6 +3019,44 @@
|
|||||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/node-pty": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
|
||||||
@@ -3902,6 +4083,15 @@
|
|||||||
"node": ">= 16"
|
"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": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"@xterm/addon-web-links": "^0.12.0",
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"google-auth-library": "^10.9.0",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"web-push": "3.6.7",
|
"web-push": "3.6.7",
|
||||||
|
|||||||
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 { loadPrefsStore } from './http/prefs-store.js'
|
||||||
import { createPushService } from './push/push-service.js'
|
import { createPushService } from './push/push-service.js'
|
||||||
import { combineNotifyServices, initApns, normalizeApnsToken } from './push/apns.js'
|
import { combineNotifyServices, initApns, normalizeApnsToken } from './push/apns.js'
|
||||||
|
import { initFcm, normalizeFcmToken } from './push/fcm.js'
|
||||||
import type {
|
import type {
|
||||||
Config,
|
Config,
|
||||||
NotifyService,
|
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
|
// web-push through the SAME NotifyService seam, so every existing hook-event
|
||||||
// call site below stays untouched: `pushService` is the combined fan-out.
|
// call site below stays untouched: `pushService` is the combined fan-out.
|
||||||
const apns = initApns(cfg, process.env as Record<string, string | undefined>)
|
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 =
|
const pushService: NotifyService =
|
||||||
apns === null ? webPushService : combineNotifyServices(webPushService, apns.service)
|
extraNotifyServices.length === 0
|
||||||
|
? webPushService
|
||||||
|
: combineNotifyServices(webPushService, ...extraNotifyServices)
|
||||||
const manager = createSessionManager(cfg, pushService)
|
const manager = createSessionManager(cfg, pushService)
|
||||||
|
|
||||||
// v0.6 Projects: cross-device UI prefs (favourites + group collapse-state).
|
// 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).
|
// reachable "walked-away" device (T-iOS-20).
|
||||||
const hasPushTarget =
|
const hasPushTarget =
|
||||||
(webPushService.isEnabled() && subStore.list().length > 0) ||
|
(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) {
|
if (!hasWatcher && !hasPushTarget) {
|
||||||
res.json({})
|
res.json({})
|
||||||
return
|
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
|
// 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/
|
// loopback) → guard with Origin + a per-decision capability token (stale/
|
||||||
// mismatch → 403).
|
// mismatch → 403).
|
||||||
|
|||||||
1025
test/push-fcm.test.ts
Normal file
1025
test/push-fcm.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user