chainlesschain 0.162.147 → 0.162.148
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/assets/web-panel/.build-hash +1 -1
- package/src/assets/web-panel/assets/{AIOps-DywJ7xTv.js → AIOps-DwpCCp64.js} +1 -1
- package/src/assets/web-panel/assets/{ActionButton-BTlqcY-q.js → ActionButton-C9pBYocA.js} +1 -1
- package/src/assets/web-panel/assets/{Analytics-FdG1Mvwy.js → Analytics-BWAkeXxK.js} +3 -3
- package/src/assets/web-panel/assets/{AppLayout-CVsnbvN1.js → AppLayout-CivFGH6B.js} +5 -5
- package/src/assets/web-panel/assets/{Audit-ITdpJ7vR.js → Audit-Cm8hRpZm.js} +1 -1
- package/src/assets/web-panel/assets/{Backup-BfXqnXo1.js → Backup-DXdFMsE8.js} +1 -1
- package/src/assets/web-panel/assets/{BaseInput-C09ip3_E.js → BaseInput-uAwSTeql.js} +1 -1
- package/src/assets/web-panel/assets/{Chat-qdkhB42l.js → Chat-KU8eeJJE.js} +6 -6
- package/src/assets/web-panel/assets/ChatBubbleRenderer-DCNfbdlx.js +1 -0
- package/src/assets/web-panel/assets/{Checkbox-j6WZCuff.js → Checkbox-C6AMDkjd.js} +1 -1
- package/src/assets/web-panel/assets/{Codegen-CGZ-K3uK.js → Codegen-t2moDxcO.js} +1 -1
- package/src/assets/web-panel/assets/{Col-CIlPOAu6.js → Col-DCcsRRhN.js} +1 -1
- package/src/assets/web-panel/assets/{Community-sY-i-hic.js → Community-DtB-8SAC.js} +1 -1
- package/src/assets/web-panel/assets/{Compact-DcoEfyL8.js → Compact-CZm8THYA.js} +1 -1
- package/src/assets/web-panel/assets/Compliance-LiQgC7g1.js +1 -0
- package/src/assets/web-panel/assets/{Cowork-DLY_fkLv.js → Cowork-CA8SAxht.js} +3 -3
- package/src/assets/web-panel/assets/{Cron-D049pZBC.js → Cron-CtRaF1wD.js} +2 -2
- package/src/assets/web-panel/assets/{Crosschain-DCXdE8M9.js → Crosschain-6-br6Hub.js} +1 -1
- package/src/assets/web-panel/assets/{DID-JCvPZtjW.js → DID-Do2EccrL.js} +2 -2
- package/src/assets/web-panel/assets/{Dashboard-Cg2xu3iE.js → Dashboard-wrXcbzGe.js} +2 -2
- package/src/assets/web-panel/assets/{Dropdown-DjJLjbLh.js → Dropdown-p3FsT245.js} +1 -1
- package/src/assets/web-panel/assets/{EmailListRenderer-DugNcSLR.js → EmailListRenderer-D95A2L8q.js} +1 -1
- package/src/assets/web-panel/assets/{FamilyGuardDashboard-J6Nb-E30.js → FamilyGuardDashboard-cLuJW2zo.js} +1 -1
- package/src/assets/web-panel/assets/{Federation-CQ3Ttpbl.js → Federation-Brgq_cbN.js} +1 -1
- package/src/assets/web-panel/assets/{FormItemContext-Cz0NKag4.js → FormItemContext-DWLCkNgh.js} +1 -1
- package/src/assets/web-panel/assets/{GenericCardRenderer-NYcMP6iz.js → GenericCardRenderer-BBqUfQd6.js} +1 -1
- package/src/assets/web-panel/assets/{Git-B4hYN18N.js → Git-8F090w4I.js} +2 -2
- package/src/assets/web-panel/assets/{Governance-DOQPCcC0.js → Governance-CCogEbxb.js} +1 -1
- package/src/assets/web-panel/assets/{Inference-HtLF746W.js → Inference-CfLD7v7C.js} +1 -1
- package/src/assets/web-panel/assets/{KnowledgeGraph-BXrk8rMm.js → KnowledgeGraph-DDuHJewd.js} +1 -1
- package/src/assets/web-panel/assets/{Logs-DS0JnD3-.js → Logs-CE8KSEVC.js} +2 -2
- package/src/assets/web-panel/assets/{Marketplace-Cqnjihrz.js → Marketplace-DmwpZmhT.js} +1 -1
- package/src/assets/web-panel/assets/{McpTools-Cx3Psk-U.js → McpTools-Bf7AazU8.js} +5 -5
- package/src/assets/web-panel/assets/{Memory-BjyysPtj.js → Memory-D0QU_00S.js} +2 -2
- package/src/assets/web-panel/assets/MobileBridge-D3Arxfeg.css +1 -0
- package/src/assets/web-panel/assets/MobileBridge-yXS9xdhx.js +1 -0
- package/src/assets/web-panel/assets/{MobileProjects-Xwf-fdOQ.js → MobileProjects-BuDUoGUP.js} +1 -1
- package/src/assets/web-panel/assets/{Mtc-Djkql5m5.js → Mtc-BYyHy28p.js} +2 -2
- package/src/assets/web-panel/assets/{MtcAudit-D2BAZlu0.js → MtcAudit-CK0aqpiZ.js} +4 -4
- package/src/assets/web-panel/assets/{Multisig-ByqP1ZzH.js → Multisig-mNimKYeb.js} +3 -3
- package/src/assets/web-panel/assets/{NLProgramming-C_kn0nE2.js → NLProgramming-AioAJ0YA.js} +1 -1
- package/src/assets/web-panel/assets/{Notes-7l7eXHPq.js → Notes-CVVNCLHE.js} +3 -3
- package/src/assets/web-panel/assets/{NotificationSettings-BrCDoitw.js → NotificationSettings-C4ABj-TK.js} +1 -1
- package/src/assets/web-panel/assets/{OrderTableRenderer-CmtqIdwr.js → OrderTableRenderer-DIp8Xcbd.js} +1 -1
- package/src/assets/web-panel/assets/{Organization-DdoNAxsM.js → Organization-uozl_gWK.js} +4 -4
- package/src/assets/web-panel/assets/{Overflow-Bw7R6dE3.js → Overflow-BCZOM2hK.js} +1 -1
- package/src/assets/web-panel/assets/{OverrideContext-C2r4vyBb.js → OverrideContext-BlgL9440.js} +1 -1
- package/src/assets/web-panel/assets/{P2P-DJNtBugA.js → P2P-CArcaiaj.js} +2 -2
- package/src/assets/web-panel/assets/{PdhVaultBrowser-Dwhd5_ue.js → PdhVaultBrowser-Sgq2Srr8.js} +3 -3
- package/src/assets/web-panel/assets/{Permissions-BYoSfhPR.js → Permissions-BYrQrXnH.js} +3 -3
- package/src/assets/web-panel/assets/{PersonalDataHub-aYCYJbnH.js → PersonalDataHub-BzHStAbz.js} +2 -2
- package/src/assets/web-panel/assets/{Pipeline-DJp6CprF.js → Pipeline-k7KqxX1M.js} +1 -1
- package/src/assets/web-panel/assets/{Privacy-Cq9HZweR.js → Privacy-IumTUWqx.js} +1 -1
- package/src/assets/web-panel/assets/{ProjectInit-CmF0HsSt.js → ProjectInit-BRyImYTf.js} +2 -2
- package/src/assets/web-panel/assets/{ProjectSettings-P8V5Aea2.js → ProjectSettings-BFIqTBFk.js} +2 -2
- package/src/assets/web-panel/assets/{Projects-DPd5-lNS.js → Projects-Dpid9CDg.js} +1 -1
- package/src/assets/web-panel/assets/{Providers-D589v0q2.js → Providers-CyyFnUPs.js} +1 -1
- package/src/assets/web-panel/assets/QrScannerModal-C82cRx-X.js +1 -0
- package/src/assets/web-panel/assets/{MobileBridge-zJpPuf4E.css → QrScannerModal-NwAHNfiJ.css} +1 -1
- package/src/assets/web-panel/assets/{QuickAsk-lvRV2osB.js → QuickAsk-DGxDF521.js} +1 -1
- package/src/assets/web-panel/assets/{Recommend-sb84MTpY.js → Recommend-Cns-Am1y.js} +1 -1
- package/src/assets/web-panel/assets/RemoteSession-R7OqAIlg.js +4 -0
- package/src/assets/web-panel/assets/{Reputation-d9Y3vy-W.js → Reputation-D7mgPIYV.js} +1 -1
- package/src/assets/web-panel/assets/{Row-DMdhLUTp.js → Row-B3lEURyd.js} +1 -1
- package/src/assets/web-panel/assets/{RssFeed-OL-SMDkz.js → RssFeed-9_UVrpBt.js} +2 -2
- package/src/assets/web-panel/assets/{Search-BjStLKq1.js → Search-ClZeO80q.js} +1 -1
- package/src/assets/web-panel/assets/{Security-MeI411qr.js → Security-BNNJczEp.js} +3 -3
- package/src/assets/web-panel/assets/{Services-UajosuhT.js → Services-C5SjrWUj.js} +2 -2
- package/src/assets/web-panel/assets/{Skeleton-CGpG-QJ1.js → Skeleton-Ci_obQ-g.js} +1 -1
- package/src/assets/web-panel/assets/{Skills-C6P2RPY-.js → Skills-DdebN15P.js} +1 -1
- package/src/assets/web-panel/assets/{Sla-B_fPprgw.js → Sla-OinZP2vi.js} +1 -1
- package/src/assets/web-panel/assets/{SpeechSettings-CqxLjSlQ.js → SpeechSettings-CxzbNF51.js} +1 -1
- package/src/assets/web-panel/assets/{SyncSettings-DixSfFOQ.js → SyncSettings-D06N_66b.js} +2 -2
- package/src/assets/web-panel/assets/{Tasks-PavzPBxc.js → Tasks-BdEdW0AZ.js} +1 -1
- package/src/assets/web-panel/assets/{Templates-BYdGRnfX.js → Templates-F1ctKmPa.js} +1 -1
- package/src/assets/web-panel/assets/{Tenant-BQLgnarD.js → Tenant-CVz1Urot.js} +1 -1
- package/src/assets/web-panel/assets/{Terminal-BzOHpq-B.js → Terminal-BvAK_Zel.js} +2 -2
- package/src/assets/web-panel/assets/TimelineRenderer-Doitzjmf.js +1 -0
- package/src/assets/web-panel/assets/{Tokens-DxhgszRJ.js → Tokens-BpyVRpVA.js} +1 -1
- package/src/assets/web-panel/assets/{Trigger-nQYHayuc.js → Trigger-nWhWYTbU.js} +1 -1
- package/src/assets/web-panel/assets/{Trust-CvqZDuZ4.js → Trust-DK_G-wLx.js} +1 -1
- package/src/assets/web-panel/assets/{UkeySign-DaR8GV2I.js → UkeySign-B5yBUojO.js} +1 -1
- package/src/assets/web-panel/assets/{VideoEditing-C6Mpsokw.js → VideoEditing-C5D51qAe.js} +1 -1
- package/src/assets/web-panel/assets/{Wallet-B57VRrWc.js → Wallet-CLbL9K7v.js} +4 -4
- package/src/assets/web-panel/assets/{WebAuthn-BbtEEtpq.js → WebAuthn-DZUelI3V.js} +4 -4
- package/src/assets/web-panel/assets/{WorkflowEditor-C5LwvKmH.js → WorkflowEditor-8RPxt4KW.js} +1 -1
- package/src/assets/web-panel/assets/{chat-MzuPR5jh.js → chat-4N4tOA3d.js} +1 -1
- package/src/assets/web-panel/assets/{collapseMotion-mxzzcDsg.js → collapseMotion-DVfhbsdP.js} +1 -1
- package/src/assets/web-panel/assets/{colors-HyW8mi_y.js → colors-zbbGVrua.js} +1 -1
- package/src/assets/web-panel/assets/{compact-item-DvUSzWAp.js → compact-item-D7iMkbnE.js} +1 -1
- package/src/assets/web-panel/assets/{createContext-4MppZl7X.js → createContext-CBzPJv-w.js} +1 -1
- package/src/assets/web-panel/assets/devWarning-C2Z5LRgq.js +1 -0
- package/src/assets/web-panel/assets/{echarts-jxgIOFUZ.js → echarts-CL0F2SnX.js} +1 -1
- package/src/assets/web-panel/assets/{hasIn-D9q3CJ-u.js → hasIn-bHdrQSqZ.js} +1 -1
- package/src/assets/web-panel/assets/{icons-DP3uiYxy.js → icons-BsDmGpcT.js} +1 -1
- package/src/assets/web-panel/assets/{index-Caqtu6Et.js → index-308gCGh7.js} +1 -1
- package/src/assets/web-panel/assets/{index-CgIfXOD9.js → index-B1s0Bz9O.js} +1 -1
- package/src/assets/web-panel/assets/{index-BcunsBIx.js → index-B7b1hGry.js} +1 -1
- package/src/assets/web-panel/assets/{index-DDZ6fP5Z.js → index-BBNr77E1.js} +1 -1
- package/src/assets/web-panel/assets/{index-BF2JsbiX.js → index-BCr0geV9.js} +1 -1
- package/src/assets/web-panel/assets/index-BGp6Yr-Y.js +1 -0
- package/src/assets/web-panel/assets/{index-CucRo4Vz.js → index-BJ4ZGyW0.js} +1 -1
- package/src/assets/web-panel/assets/{index-Bl-E4W4q.js → index-BS4_Mwk6.js} +1 -1
- package/src/assets/web-panel/assets/{index-Dv7ffI2g.js → index-BTIjjXry.js} +1 -1
- package/src/assets/web-panel/assets/index-BXiw9dQf.js +1 -0
- package/src/assets/web-panel/assets/{index-CifKUNtV.js → index-BZb8F8YG.js} +1 -1
- package/src/assets/web-panel/assets/{index-Btz8pU68.js → index-BnBOpqv3.js} +1 -1
- package/src/assets/web-panel/assets/{index-m0b6Fj9q.js → index-Bqmx24kh.js} +1 -1
- package/src/assets/web-panel/assets/{index-Dvg5xYuP.js → index-BsKehmmS.js} +1 -1
- package/src/assets/web-panel/assets/{index-UUMCA2Sq.js → index-BtbbdLnf.js} +4 -4
- package/src/assets/web-panel/assets/{index-CvAqCAo9.js → index-CPxkoKgA.js} +1 -1
- package/src/assets/web-panel/assets/{index-B5E4DhVc.js → index-CQ6NcfWz.js} +1 -1
- package/src/assets/web-panel/assets/{index-B994UQfE.js → index-CQfu1U78.js} +1 -1
- package/src/assets/web-panel/assets/{index-CLAZs1Vd.js → index-CahryTX0.js} +1 -1
- package/src/assets/web-panel/assets/{index-BFGfC5RS.js → index-CdmMoYN1.js} +1 -1
- package/src/assets/web-panel/assets/{index-DcMkjb92.js → index-Ci009ijp.js} +1 -1
- package/src/assets/web-panel/assets/{index-DNYqw0MO.js → index-CiG1IZao.js} +1 -1
- package/src/assets/web-panel/assets/{index--Bm6OqmU.js → index-CkxK86vf.js} +1 -1
- package/src/assets/web-panel/assets/{index-zLwYpvX0.js → index-CqdPyWm5.js} +1 -1
- package/src/assets/web-panel/assets/{index-Zvtru2oE.js → index-CvRMA9uT.js} +1 -1
- package/src/assets/web-panel/assets/{index-BmCo2Xdp.js → index-D1YDh7Wr.js} +1 -1
- package/src/assets/web-panel/assets/{index-D2jrnaq3.js → index-D4XKpj6c.js} +1 -1
- package/src/assets/web-panel/assets/{index-D7TDMMfu.js → index-DE9j5YgT.js} +1 -1
- package/src/assets/web-panel/assets/{index-ryFLxB1p.js → index-DISWAYce.js} +1 -1
- package/src/assets/web-panel/assets/{index-xvUVuFnA.js → index-DUyjUkY7.js} +1 -1
- package/src/assets/web-panel/assets/{index-C9I5MuJ0.js → index-DbE5D0WL.js} +1 -1
- package/src/assets/web-panel/assets/{index-BGQtFso0.js → index-DmqlJed-.js} +1 -1
- package/src/assets/web-panel/assets/{index-SDblbKj6.js → index-DyoNgbXl.js} +1 -1
- package/src/assets/web-panel/assets/{index-Ci6adKh8.js → index-QFObYeo1.js} +1 -1
- package/src/assets/web-panel/assets/{index-Cs-RKRUM.js → index-QNAG4JtR.js} +1 -1
- package/src/assets/web-panel/assets/{index-DFqvWeGc.js → index-S74FpAIn.js} +1 -1
- package/src/assets/web-panel/assets/{index-eOQO6KnV.js → index-kmh1TxRa.js} +1 -1
- package/src/assets/web-panel/assets/{index-DmZ6-suL.js → index-oDKNgCsP.js} +1 -1
- package/src/assets/web-panel/assets/{index-C0DiL97c.js → index-xYCm6W2h.js} +1 -1
- package/src/assets/web-panel/assets/{initDefaultProps-CC--PMsC.js → initDefaultProps-BD55yNBt.js} +1 -1
- package/src/assets/web-panel/assets/{motion-DdrBjRF1.js → motion-B5Bbe77U.js} +1 -1
- package/src/assets/web-panel/assets/{move-D712Gwl2.js → move-CTvIqHEH.js} +1 -1
- package/src/assets/web-panel/assets/{mtc-parser-39y-keKO.js → mtc-parser-BkNyPQKB.js} +1 -1
- package/src/assets/web-panel/assets/{omit-6Y51gDB9.js → omit-BaXJXgHA.js} +1 -1
- package/src/assets/web-panel/assets/{pickAttrs-Cy6EHbq9.js → pickAttrs-Bw6-YTxH.js} +1 -1
- package/src/assets/web-panel/assets/{placementArrow-BYxdAm0p.js → placementArrow-BLdbkLqJ.js} +1 -1
- package/src/assets/web-panel/assets/{responsiveObserve-C9R6q_cr.js → responsiveObserve-IVYkzntU.js} +1 -1
- package/src/assets/web-panel/assets/{slide-CXUJlilB.js → slide-DFMFwwtY.js} +1 -1
- package/src/assets/web-panel/assets/{statusUtils-DOtrOeql.js → statusUtils-CURYRtZ1.js} +1 -1
- package/src/assets/web-panel/assets/{styleChecker-D5r39o92.js → styleChecker-Cfz63s1r.js} +1 -1
- package/src/assets/web-panel/assets/useFlexGapSupport-COJL0KE3.js +1 -0
- package/src/assets/web-panel/assets/{useFs-BkrT-Zbc.js → useFs-YLLojQQf.js} +1 -1
- package/src/assets/web-panel/assets/{useMergedState-Cl2q1fes.js → useMergedState-Dc9sO2d7.js} +1 -1
- package/src/assets/web-panel/assets/{usePersonalDataHub-BwnnnZjU.js → usePersonalDataHub-BF_21qUC.js} +1 -1
- package/src/assets/web-panel/assets/{useRefs-DD9q4mOT.js → useRefs-CAeFRzPf.js} +1 -1
- package/src/assets/web-panel/assets/{useState-BK4hy8Ma.js → useState-LwExUYDO.js} +1 -1
- package/src/assets/web-panel/assets/{vendor-BvqAck49.js → vendor-BMxUs6UX.js} +16 -16
- package/src/assets/web-panel/assets/{vnode-SU-N4pum.js → vnode-9ZpkA7bb.js} +1 -1
- package/src/assets/web-panel/assets/{zoom-DHScfpTP.js → zoom-DhtAfhl8.js} +1 -1
- package/src/assets/web-panel/index.html +3 -3
- package/src/commands/serve.js +10 -0
- package/src/gateways/remote-session-relay.js +160 -0
- package/src/gateways/ws/message-dispatcher.js +32 -0
- package/src/gateways/ws/remote-session-protocol.js +429 -0
- package/src/gateways/ws/ws-server.js +299 -0
- package/src/harness/golden-transcript.js +65 -0
- package/src/harness/mcp-client.js +6 -2
- package/src/harness/remote-session-audit-sink.js +132 -0
- package/src/harness/remote-session-audit.js +110 -0
- package/src/harness/remote-session-crypto.js +217 -0
- package/src/harness/remote-session-push-fcm.js +254 -0
- package/src/harness/remote-session-push.js +104 -0
- package/src/harness/remote-session-registry.js +420 -0
- package/src/lib/did-manager.js +6 -4
- package/src/lib/mcp-oauth.js +23 -6
- package/src/lib/session-manager.js +7 -3
- package/src/runtime/agent-runtime.js +8 -13
- package/src/runtime/policies/agent-policy.js +2 -0
- package/src/assets/web-panel/assets/ChatBubbleRenderer-D2KfETZD.js +0 -1
- package/src/assets/web-panel/assets/Compliance-T8EtTecW.js +0 -1
- package/src/assets/web-panel/assets/MobileBridge-CYaITZ7w.js +0 -1
- package/src/assets/web-panel/assets/TimelineRenderer-Bv7uZRM7.js +0 -1
- package/src/assets/web-panel/assets/devWarning-6YJJMjSH.js +0 -1
- package/src/assets/web-panel/assets/index-D_oeGLr6.js +0 -1
- package/src/assets/web-panel/assets/index-Qa57YG19.js +0 -1
- package/src/assets/web-panel/assets/useFlexGapSupport-DfAD5U4Z.js +0 -1
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCipheriv,
|
|
3
|
+
createDecipheriv,
|
|
4
|
+
createHash,
|
|
5
|
+
createPrivateKey,
|
|
6
|
+
createPublicKey,
|
|
7
|
+
diffieHellman,
|
|
8
|
+
generateKeyPairSync,
|
|
9
|
+
hkdfSync,
|
|
10
|
+
randomBytes,
|
|
11
|
+
} from "crypto";
|
|
12
|
+
|
|
13
|
+
const PROTOCOL = "chainlesschain.remote-session.e2ee.v1";
|
|
14
|
+
const PAIRING_SCHEME = "chainlesschain://remote-session/pair#";
|
|
15
|
+
|
|
16
|
+
function b64(value) {
|
|
17
|
+
return Buffer.from(value).toString("base64url");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function unb64(value) {
|
|
21
|
+
return Buffer.from(value, "base64url");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function exportPublicKey(key) {
|
|
25
|
+
return b64(key.export({ type: "spki", format: "der" }));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function importPublicKey(value) {
|
|
29
|
+
let der = unb64(value);
|
|
30
|
+
if (der.length === 32) {
|
|
31
|
+
// RFC 8410 SubjectPublicKeyInfo prefix for a raw X25519 public key.
|
|
32
|
+
der = Buffer.concat([Buffer.from("302a300506032b656e032100", "hex"), der]);
|
|
33
|
+
}
|
|
34
|
+
return createPublicKey({ key: der, type: "spki", format: "der" });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function aad(sessionId, senderId, sequence) {
|
|
38
|
+
return Buffer.from(
|
|
39
|
+
`${PROTOCOL}\n${sessionId}\n${senderId}\n${sequence}`,
|
|
40
|
+
"utf8",
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function deriveKey(privateKey, peerPublicKey, sessionId, pairingToken) {
|
|
45
|
+
const secret = diffieHellman({ privateKey, publicKey: peerPublicKey });
|
|
46
|
+
const salt = createHash("sha256").update(pairingToken, "utf8").digest();
|
|
47
|
+
return Buffer.from(
|
|
48
|
+
hkdfSync(
|
|
49
|
+
"sha256",
|
|
50
|
+
secret,
|
|
51
|
+
salt,
|
|
52
|
+
Buffer.from(`${PROTOCOL}:${sessionId}`),
|
|
53
|
+
32,
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createRemoteSessionKeyPair() {
|
|
59
|
+
const { publicKey, privateKey } = generateKeyPairSync("x25519");
|
|
60
|
+
return {
|
|
61
|
+
publicKey: exportPublicKey(publicKey),
|
|
62
|
+
privateKey: b64(privateKey.export({ type: "pkcs8", format: "der" })),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function createRemotePairingUri({
|
|
67
|
+
relayUrl,
|
|
68
|
+
remoteSessionId,
|
|
69
|
+
hostPeerId,
|
|
70
|
+
hostPublicKey,
|
|
71
|
+
pairingToken,
|
|
72
|
+
expiresAt,
|
|
73
|
+
}) {
|
|
74
|
+
if (
|
|
75
|
+
!relayUrl ||
|
|
76
|
+
!remoteSessionId ||
|
|
77
|
+
!hostPeerId ||
|
|
78
|
+
!hostPublicKey ||
|
|
79
|
+
!pairingToken
|
|
80
|
+
) {
|
|
81
|
+
throw new Error("Incomplete Remote Session pairing payload");
|
|
82
|
+
}
|
|
83
|
+
const payload = {
|
|
84
|
+
v: 1,
|
|
85
|
+
relayUrl,
|
|
86
|
+
remoteSessionId,
|
|
87
|
+
hostPeerId,
|
|
88
|
+
hostPublicKey,
|
|
89
|
+
pairingToken,
|
|
90
|
+
expiresAt,
|
|
91
|
+
};
|
|
92
|
+
return `${PAIRING_SCHEME}${b64(JSON.stringify(payload))}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function parseRemotePairingUri(uri, now = Date.now()) {
|
|
96
|
+
if (typeof uri !== "string" || !uri.startsWith(PAIRING_SCHEME)) {
|
|
97
|
+
throw new Error("Invalid Remote Session pairing URI");
|
|
98
|
+
}
|
|
99
|
+
let payload;
|
|
100
|
+
try {
|
|
101
|
+
payload = JSON.parse(
|
|
102
|
+
unb64(uri.slice(PAIRING_SCHEME.length)).toString("utf8"),
|
|
103
|
+
);
|
|
104
|
+
} catch {
|
|
105
|
+
throw new Error("Malformed Remote Session pairing payload");
|
|
106
|
+
}
|
|
107
|
+
if (
|
|
108
|
+
payload.v !== 1 ||
|
|
109
|
+
!payload.relayUrl ||
|
|
110
|
+
!payload.remoteSessionId ||
|
|
111
|
+
!payload.hostPeerId ||
|
|
112
|
+
!payload.hostPublicKey ||
|
|
113
|
+
!payload.pairingToken
|
|
114
|
+
) {
|
|
115
|
+
throw new Error("Incomplete Remote Session pairing payload");
|
|
116
|
+
}
|
|
117
|
+
if (payload.expiresAt && payload.expiresAt <= now) {
|
|
118
|
+
throw new Error("Remote Session pairing payload expired");
|
|
119
|
+
}
|
|
120
|
+
return payload;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export class RemoteSessionCryptoContext {
|
|
124
|
+
constructor({ sessionId, localPeerId, privateKey, publicKey } = {}) {
|
|
125
|
+
if (!sessionId || !localPeerId)
|
|
126
|
+
throw new Error("sessionId and localPeerId are required");
|
|
127
|
+
let keyPair;
|
|
128
|
+
if (!privateKey || !publicKey) keyPair = createRemoteSessionKeyPair();
|
|
129
|
+
this.sessionId = sessionId;
|
|
130
|
+
this.localPeerId = localPeerId;
|
|
131
|
+
this.privateKey = createPrivateKey({
|
|
132
|
+
key: unb64(privateKey || keyPair.privateKey),
|
|
133
|
+
type: "pkcs8",
|
|
134
|
+
format: "der",
|
|
135
|
+
});
|
|
136
|
+
this.publicKey = publicKey || keyPair.publicKey;
|
|
137
|
+
this.keys = new Map();
|
|
138
|
+
this.sendSequence = 0;
|
|
139
|
+
this.receivedSequences = new Map();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
pair(peerId, peerPublicKey, pairingToken) {
|
|
143
|
+
if (!peerId || !peerPublicKey || !pairingToken)
|
|
144
|
+
throw new Error("Incomplete peer key material");
|
|
145
|
+
this.keys.set(
|
|
146
|
+
peerId,
|
|
147
|
+
deriveKey(
|
|
148
|
+
this.privateKey,
|
|
149
|
+
importPublicKey(peerPublicKey),
|
|
150
|
+
this.sessionId,
|
|
151
|
+
pairingToken,
|
|
152
|
+
),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
encrypt(peerId, message) {
|
|
157
|
+
const key = this.keys.get(peerId);
|
|
158
|
+
if (!key) throw new Error(`No encryption key for peer: ${peerId}`);
|
|
159
|
+
const sequence = ++this.sendSequence;
|
|
160
|
+
const nonce = randomBytes(12);
|
|
161
|
+
const cipher = createCipheriv("aes-256-gcm", key, nonce);
|
|
162
|
+
cipher.setAAD(aad(this.sessionId, this.localPeerId, sequence));
|
|
163
|
+
const ciphertext = Buffer.concat([
|
|
164
|
+
cipher.update(JSON.stringify(message), "utf8"),
|
|
165
|
+
cipher.final(),
|
|
166
|
+
]);
|
|
167
|
+
return {
|
|
168
|
+
v: 1,
|
|
169
|
+
sessionId: this.sessionId,
|
|
170
|
+
senderId: this.localPeerId,
|
|
171
|
+
sequence,
|
|
172
|
+
nonce: b64(nonce),
|
|
173
|
+
ciphertext: b64(ciphertext),
|
|
174
|
+
tag: b64(cipher.getAuthTag()),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
decrypt(envelope) {
|
|
179
|
+
if (envelope?.v !== 1 || envelope.sessionId !== this.sessionId) {
|
|
180
|
+
throw new Error("Invalid encrypted Remote Session envelope");
|
|
181
|
+
}
|
|
182
|
+
const key = this.keys.get(envelope.senderId);
|
|
183
|
+
if (!key)
|
|
184
|
+
throw new Error(`No encryption key for peer: ${envelope.senderId}`);
|
|
185
|
+
const previous = this.receivedSequences.get(envelope.senderId) || 0;
|
|
186
|
+
if (
|
|
187
|
+
!Number.isSafeInteger(envelope.sequence) ||
|
|
188
|
+
envelope.sequence <= previous
|
|
189
|
+
) {
|
|
190
|
+
throw new Error(
|
|
191
|
+
"Remote Session replay or out-of-order envelope rejected",
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
const decipher = createDecipheriv(
|
|
196
|
+
"aes-256-gcm",
|
|
197
|
+
key,
|
|
198
|
+
unb64(envelope.nonce),
|
|
199
|
+
);
|
|
200
|
+
decipher.setAAD(
|
|
201
|
+
aad(this.sessionId, envelope.senderId, envelope.sequence),
|
|
202
|
+
);
|
|
203
|
+
decipher.setAuthTag(unb64(envelope.tag));
|
|
204
|
+
const plaintext = Buffer.concat([
|
|
205
|
+
decipher.update(unb64(envelope.ciphertext)),
|
|
206
|
+
decipher.final(),
|
|
207
|
+
]);
|
|
208
|
+
const message = JSON.parse(plaintext.toString("utf8"));
|
|
209
|
+
this.receivedSequences.set(envelope.senderId, envelope.sequence);
|
|
210
|
+
return message;
|
|
211
|
+
} catch (error) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`Remote Session envelope authentication failed: ${error.message}`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// Remote Session vendor push — Firebase Cloud Messaging (HTTP v1) sender.
|
|
2
|
+
//
|
|
3
|
+
// A concrete implementation of the RemoteSessionPushDispatcher `sender`
|
|
4
|
+
// contract that delivers approval wake-ups to Android devices through FCM's
|
|
5
|
+
// current HTTP v1 API (the legacy server-key API is retired). Auth uses a
|
|
6
|
+
// Google service account: a short-lived OAuth2 access token minted by signing a
|
|
7
|
+
// JWT (RS256) with the service account private key and exchanging it at
|
|
8
|
+
// oauth2.googleapis.com — no long-lived server key on disk.
|
|
9
|
+
//
|
|
10
|
+
// Everything is injectable (fetch, clock, signer, fs) so the whole chain is
|
|
11
|
+
// unit-testable with a generated keypair and a fake transport — no real
|
|
12
|
+
// credentials or network. The payload carries NO session content, only the
|
|
13
|
+
// routing shape (aligns with the audit log's privacy-first stance).
|
|
14
|
+
|
|
15
|
+
import { createSign } from "node:crypto";
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
|
|
18
|
+
const FCM_SCOPE = "https://www.googleapis.com/auth/firebase.messaging";
|
|
19
|
+
const DEFAULT_TOKEN_URI = "https://oauth2.googleapis.com/token";
|
|
20
|
+
const JWT_BEARER_GRANT = "urn:ietf:params:oauth:grant-type:jwt-bearer";
|
|
21
|
+
const ACCESS_TOKEN_SKEW_MS = 60_000; // refresh a minute before real expiry
|
|
22
|
+
const ASSERTION_TTL_SEC = 3600;
|
|
23
|
+
|
|
24
|
+
function base64url(input) {
|
|
25
|
+
return Buffer.from(input)
|
|
26
|
+
.toString("base64")
|
|
27
|
+
.replace(/\+/g, "-")
|
|
28
|
+
.replace(/\//g, "_")
|
|
29
|
+
.replace(/=+$/, "");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function safeText(res) {
|
|
33
|
+
try {
|
|
34
|
+
return await res.text();
|
|
35
|
+
} catch {
|
|
36
|
+
return "";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function dropUndefined(obj) {
|
|
41
|
+
const out = {};
|
|
42
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
43
|
+
if (value !== undefined) out[key] = value;
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A device token the vendor reports as no longer valid (uninstalled app, token
|
|
50
|
+
* rotated). The caller should prune it so it is never retried.
|
|
51
|
+
*/
|
|
52
|
+
export class PushTokenUnregisteredError extends Error {
|
|
53
|
+
constructor(message) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.name = "PushTokenUnregisteredError";
|
|
56
|
+
this.code = "PUSH_TOKEN_UNREGISTERED";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Mints + caches OAuth2 access tokens from a Google service account. */
|
|
61
|
+
export class GoogleServiceAccountTokenProvider {
|
|
62
|
+
constructor(serviceAccount = {}, options = {}) {
|
|
63
|
+
if (!serviceAccount.client_email || !serviceAccount.private_key) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
"Service account must include client_email and private_key",
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
this.clientEmail = serviceAccount.client_email;
|
|
69
|
+
this.privateKey = serviceAccount.private_key;
|
|
70
|
+
this.tokenUri = serviceAccount.token_uri || DEFAULT_TOKEN_URI;
|
|
71
|
+
this.scope = options.scope || FCM_SCOPE;
|
|
72
|
+
this.now = options.now || Date.now;
|
|
73
|
+
this.fetch = options.fetch || globalThis.fetch;
|
|
74
|
+
// RS256 = RSA PKCS#1 v1.5 over SHA-256. Injectable for tests.
|
|
75
|
+
this.sign =
|
|
76
|
+
options.sign ||
|
|
77
|
+
((signingInput, key) =>
|
|
78
|
+
createSign("RSA-SHA256").update(signingInput).sign(key));
|
|
79
|
+
this._cached = null; // { token, expiresAt }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async getAccessToken() {
|
|
83
|
+
const now = this.now();
|
|
84
|
+
if (this._cached && this._cached.expiresAt - ACCESS_TOKEN_SKEW_MS > now) {
|
|
85
|
+
return this._cached.token;
|
|
86
|
+
}
|
|
87
|
+
const assertion = this._buildAssertion(now);
|
|
88
|
+
const body = new URLSearchParams({
|
|
89
|
+
grant_type: JWT_BEARER_GRANT,
|
|
90
|
+
assertion,
|
|
91
|
+
});
|
|
92
|
+
const res = await this.fetch(this.tokenUri, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
95
|
+
body: body.toString(),
|
|
96
|
+
});
|
|
97
|
+
if (!res.ok) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`OAuth token request failed (${res.status}): ${await safeText(res)}`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
const json = await res.json();
|
|
103
|
+
if (!json.access_token) {
|
|
104
|
+
throw new Error("OAuth token response missing access_token");
|
|
105
|
+
}
|
|
106
|
+
const expiresInMs = (Number(json.expires_in) || ASSERTION_TTL_SEC) * 1000;
|
|
107
|
+
this._cached = { token: json.access_token, expiresAt: now + expiresInMs };
|
|
108
|
+
return this._cached.token;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
_buildAssertion(now) {
|
|
112
|
+
const iat = Math.floor(now / 1000);
|
|
113
|
+
const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
|
|
114
|
+
const payload = base64url(
|
|
115
|
+
JSON.stringify({
|
|
116
|
+
iss: this.clientEmail,
|
|
117
|
+
scope: this.scope,
|
|
118
|
+
aud: this.tokenUri,
|
|
119
|
+
iat,
|
|
120
|
+
exp: iat + ASSERTION_TTL_SEC,
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
const signingInput = `${header}.${payload}`;
|
|
124
|
+
const signature = base64url(this.sign(signingInput, this.privateKey));
|
|
125
|
+
return `${signingInput}.${signature}`;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Sends one high-priority data+notification message via FCM HTTP v1. */
|
|
130
|
+
export class FcmV1PushSender {
|
|
131
|
+
constructor(options = {}) {
|
|
132
|
+
if (!options.projectId) {
|
|
133
|
+
throw new Error("FcmV1PushSender requires projectId");
|
|
134
|
+
}
|
|
135
|
+
if (!options.tokenProvider) {
|
|
136
|
+
throw new Error("FcmV1PushSender requires a tokenProvider");
|
|
137
|
+
}
|
|
138
|
+
this.projectId = options.projectId;
|
|
139
|
+
this.tokenProvider = options.tokenProvider;
|
|
140
|
+
this.fetch = options.fetch || globalThis.fetch;
|
|
141
|
+
this.endpoint =
|
|
142
|
+
options.endpoint ||
|
|
143
|
+
`https://fcm.googleapis.com/v1/projects/${options.projectId}/messages:send`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Bound function matching the dispatcher's `sender` contract. */
|
|
147
|
+
get handler() {
|
|
148
|
+
return (payload) => this.send(payload);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async send({ token, notification, sessionId, clientId } = {}) {
|
|
152
|
+
if (!token) throw new Error("FCM send requires a device token");
|
|
153
|
+
const accessToken = await this.tokenProvider.getAccessToken();
|
|
154
|
+
const message = {
|
|
155
|
+
message: dropUndefined({
|
|
156
|
+
token,
|
|
157
|
+
notification: notification
|
|
158
|
+
? dropUndefined({
|
|
159
|
+
title: notification.title,
|
|
160
|
+
body: notification.body,
|
|
161
|
+
})
|
|
162
|
+
: undefined,
|
|
163
|
+
// Data-only fields let the app route the wake-up; no session content.
|
|
164
|
+
data: dropUndefined({
|
|
165
|
+
type: "remote-session.approval-request",
|
|
166
|
+
sessionId: sessionId != null ? String(sessionId) : undefined,
|
|
167
|
+
clientId: clientId != null ? String(clientId) : undefined,
|
|
168
|
+
}),
|
|
169
|
+
android: { priority: "high" },
|
|
170
|
+
apns: { headers: { "apns-priority": "10" } },
|
|
171
|
+
}),
|
|
172
|
+
};
|
|
173
|
+
const res = await this.fetch(this.endpoint, {
|
|
174
|
+
method: "POST",
|
|
175
|
+
headers: {
|
|
176
|
+
Authorization: `Bearer ${accessToken}`,
|
|
177
|
+
"Content-Type": "application/json",
|
|
178
|
+
},
|
|
179
|
+
body: JSON.stringify(message),
|
|
180
|
+
});
|
|
181
|
+
if (res.ok) {
|
|
182
|
+
const json = await res.json().catch(() => ({}));
|
|
183
|
+
return { id: json.name || null };
|
|
184
|
+
}
|
|
185
|
+
const text = await safeText(res);
|
|
186
|
+
// A retired/uninstalled token surfaces as HTTP 404 or an UNREGISTERED /
|
|
187
|
+
// NOT_FOUND error status — prune it rather than retry forever.
|
|
188
|
+
if (res.status === 404 || /UNREGISTERED|NOT_FOUND/i.test(text)) {
|
|
189
|
+
throw new PushTokenUnregisteredError(`FCM token unregistered: ${text}`);
|
|
190
|
+
}
|
|
191
|
+
throw new Error(`FCM send failed (${res.status}): ${text}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function loadServiceAccount(env, options) {
|
|
196
|
+
if (options.serviceAccount) return options.serviceAccount;
|
|
197
|
+
const inline = env.CHAINLESSCHAIN_REMOTE_SESSION_FCM_SERVICE_ACCOUNT_JSON;
|
|
198
|
+
if (inline) {
|
|
199
|
+
try {
|
|
200
|
+
return JSON.parse(inline);
|
|
201
|
+
} catch {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const filePath = env.CHAINLESSCHAIN_REMOTE_SESSION_FCM_SERVICE_ACCOUNT;
|
|
206
|
+
if (filePath) {
|
|
207
|
+
const fsMod = options.fs || fs;
|
|
208
|
+
try {
|
|
209
|
+
return JSON.parse(fsMod.readFileSync(filePath, "utf8"));
|
|
210
|
+
} catch {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Build a concrete push `sender` from env, or null when unconfigured / for a
|
|
219
|
+
* provider that is not yet implemented. Returns the bound sender function the
|
|
220
|
+
* RemoteSessionPushDispatcher expects.
|
|
221
|
+
*
|
|
222
|
+
* FCM env:
|
|
223
|
+
* CHAINLESSCHAIN_REMOTE_SESSION_PUSH_PROVIDER = "fcm" (default)
|
|
224
|
+
* CHAINLESSCHAIN_REMOTE_SESSION_FCM_SERVICE_ACCOUNT (path to JSON), or
|
|
225
|
+
* CHAINLESSCHAIN_REMOTE_SESSION_FCM_SERVICE_ACCOUNT_JSON (inline JSON)
|
|
226
|
+
* CHAINLESSCHAIN_REMOTE_SESSION_FCM_PROJECT_ID (optional; else service
|
|
227
|
+
* account project_id)
|
|
228
|
+
*/
|
|
229
|
+
export function createRemoteSessionPushSender(env = {}, options = {}) {
|
|
230
|
+
const provider = (
|
|
231
|
+
env.CHAINLESSCHAIN_REMOTE_SESSION_PUSH_PROVIDER || "fcm"
|
|
232
|
+
).toLowerCase();
|
|
233
|
+
if (provider !== "fcm") {
|
|
234
|
+
// APNs / HMS / Xiaomi / OPPO senders are not implemented yet; the injection
|
|
235
|
+
// seam means they can be added without touching the dispatcher.
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
const serviceAccount = loadServiceAccount(env, options);
|
|
239
|
+
if (!serviceAccount) return null;
|
|
240
|
+
const projectId =
|
|
241
|
+
env.CHAINLESSCHAIN_REMOTE_SESSION_FCM_PROJECT_ID ||
|
|
242
|
+
serviceAccount.project_id;
|
|
243
|
+
if (!projectId) return null;
|
|
244
|
+
let tokenProvider;
|
|
245
|
+
try {
|
|
246
|
+
tokenProvider =
|
|
247
|
+
options.tokenProvider ||
|
|
248
|
+
new GoogleServiceAccountTokenProvider(serviceAccount, options);
|
|
249
|
+
} catch {
|
|
250
|
+
return null; // Malformed service account — stay disabled rather than crash.
|
|
251
|
+
}
|
|
252
|
+
const sender = new FcmV1PushSender({ projectId, tokenProvider, ...options });
|
|
253
|
+
return sender.handler;
|
|
254
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Remote Session push notifications (vendor push).
|
|
2
|
+
//
|
|
3
|
+
// Wakes a paired device for time-sensitive events (an approval request) even
|
|
4
|
+
// when its relay WebSocket is suspended — the case a foreground-only local
|
|
5
|
+
// notification can't cover. The actual delivery (FCM / APNs / Xiaomi / Huawei /
|
|
6
|
+
// OPPO) is a pluggable `sender` injected by the host, so this layer stays
|
|
7
|
+
// credential-free and fully unit-testable; with no sender it degrades to a
|
|
8
|
+
// recorded no-op. Payloads carry NO session content, only the shape needed to
|
|
9
|
+
// route the wake-up (aligns with the audit log's privacy-first stance).
|
|
10
|
+
|
|
11
|
+
const DEFAULT_DEDUPE_WINDOW_MS = 30_000;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* True for host events that should wake a backgrounded device. Approval /
|
|
15
|
+
* permission requests qualify; their `resolved` counterparts do not.
|
|
16
|
+
*/
|
|
17
|
+
export function isApprovalRequestEvent(type) {
|
|
18
|
+
const value = String(type || "");
|
|
19
|
+
return /approval|permission/i.test(value) && !/resolv/i.test(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class RemoteSessionPushDispatcher {
|
|
23
|
+
constructor(options = {}) {
|
|
24
|
+
this.now = options.now || Date.now;
|
|
25
|
+
this.sender = typeof options.sender === "function" ? options.sender : null;
|
|
26
|
+
this.provider = options.provider || null;
|
|
27
|
+
this.dedupeWindowMs = options.dedupeWindowMs || DEFAULT_DEDUPE_WINDOW_MS;
|
|
28
|
+
this.recent = new Map();
|
|
29
|
+
this.stats = { sent: 0, skipped: 0, failed: 0 };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get enabled() {
|
|
33
|
+
return Boolean(this.sender);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Deliver one wake-up. Never throws — a push is best-effort; the relay event
|
|
38
|
+
* and in-app notification remain the primary channels. Returns a structured
|
|
39
|
+
* outcome the caller can audit.
|
|
40
|
+
*/
|
|
41
|
+
async dispatch({
|
|
42
|
+
token,
|
|
43
|
+
provider,
|
|
44
|
+
sessionId,
|
|
45
|
+
clientId,
|
|
46
|
+
notification,
|
|
47
|
+
dedupeKey,
|
|
48
|
+
} = {}) {
|
|
49
|
+
if (!token) return this._skip("no-token");
|
|
50
|
+
if (!this.sender) return this._skip("no-sender");
|
|
51
|
+
if (dedupeKey && this._isDuplicate(clientId, dedupeKey)) {
|
|
52
|
+
return this._skip("deduplicated");
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const result = await this.sender({
|
|
56
|
+
token,
|
|
57
|
+
provider: provider || this.provider,
|
|
58
|
+
sessionId,
|
|
59
|
+
clientId,
|
|
60
|
+
notification,
|
|
61
|
+
});
|
|
62
|
+
this.stats.sent += 1;
|
|
63
|
+
return { status: "sent", provider: provider || this.provider, result };
|
|
64
|
+
} catch (error) {
|
|
65
|
+
this.stats.failed += 1;
|
|
66
|
+
// Propagate a typed code (e.g. PUSH_TOKEN_UNREGISTERED) so the caller can
|
|
67
|
+
// prune a dead device token instead of retrying it forever.
|
|
68
|
+
return {
|
|
69
|
+
status: "failed",
|
|
70
|
+
error: error.message,
|
|
71
|
+
code: error.code || null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
_skip(reason) {
|
|
77
|
+
this.stats.skipped += 1;
|
|
78
|
+
return { status: "skipped", reason };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
_isDuplicate(clientId, dedupeKey) {
|
|
82
|
+
const key = `${clientId || ""}:${dedupeKey}`;
|
|
83
|
+
const now = this.now();
|
|
84
|
+
const last = this.recent.get(key);
|
|
85
|
+
if (last != null && now - last < this.dedupeWindowMs) return true;
|
|
86
|
+
this.recent.set(key, now);
|
|
87
|
+
this._gc(now);
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
_gc(now) {
|
|
92
|
+
if (this.recent.size < 500) return;
|
|
93
|
+
for (const [key, ts] of this.recent) {
|
|
94
|
+
if (now - ts >= this.dedupeWindowMs) this.recent.delete(key);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
static fromEnv(env = {}, options = {}) {
|
|
99
|
+
return new RemoteSessionPushDispatcher({
|
|
100
|
+
provider: env.CHAINLESSCHAIN_REMOTE_SESSION_PUSH_PROVIDER || null,
|
|
101
|
+
...options,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|