chainlesschain 0.163.7 → 0.164.0
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/package.json +5 -1
- package/scripts/macos-mcp-launcher-build.mjs +317 -0
- package/scripts/macos-mcp-launcher-postinstall.sh +54 -0
- package/scripts/macos-mcp-launcher-preinstall.sh +61 -0
- package/src/assets/web-panel/assets/{AIOps-axqQfwv8.js → AIOps-D2MEn7fI.js} +1 -1
- package/src/assets/web-panel/assets/{ActionButton-DV-Ro0By.js → ActionButton-lCW_4Gf0.js} +1 -1
- package/src/assets/web-panel/assets/{Analytics-Bt72WEVu.js → Analytics-Bs7i-nsS.js} +3 -3
- package/src/assets/web-panel/assets/{AppLayout-BtepG55E.js → AppLayout-CK7B6S_j.js} +5 -5
- package/src/assets/web-panel/assets/{Artifacts-B7KaJ7S_.js → Artifacts-C8ILjpyo.js} +1 -1
- package/src/assets/web-panel/assets/{Audit-FNFaUg7A.js → Audit-Bb9cNaWc.js} +1 -1
- package/src/assets/web-panel/assets/{BackgroundAgents-DFb6J2wQ.js → BackgroundAgents-sC9ooXIf.js} +1 -1
- package/src/assets/web-panel/assets/{Backup-CC7YBhyj.js → Backup-D-DrsplT.js} +1 -1
- package/src/assets/web-panel/assets/{BaseInput-RkgTNVtj.js → BaseInput-uCNZpwSK.js} +1 -1
- package/src/assets/web-panel/assets/{Chat-hHEmGZy-.js → Chat-CsDK1uE9.js} +6 -6
- package/src/assets/web-panel/assets/{ChatBubbleRenderer-CAP9YhHv.js → ChatBubbleRenderer-OqSDt50w.js} +1 -1
- package/src/assets/web-panel/assets/{Checkbox-z5wXOQP1.js → Checkbox-JhTXM1-K.js} +1 -1
- package/src/assets/web-panel/assets/{Codegen-DVaDJnxy.js → Codegen-BbzZLnyD.js} +1 -1
- package/src/assets/web-panel/assets/{Col-CJmaRDR5.js → Col-D4VW4m4j.js} +1 -1
- package/src/assets/web-panel/assets/{Community-DF29_2bI.js → Community-BXrYawxV.js} +1 -1
- package/src/assets/web-panel/assets/{Compact-DtkQkBzn.js → Compact-DCihGy1u.js} +1 -1
- package/src/assets/web-panel/assets/{Compliance-xoHDxp7I.js → Compliance-DlyZ-lk8.js} +1 -1
- package/src/assets/web-panel/assets/{Cowork-BUm-R7ht.js → Cowork-DvGZrhbO.js} +2 -2
- package/src/assets/web-panel/assets/{Cron-Cgl14ZkB.js → Cron-DUncZi8h.js} +2 -2
- package/src/assets/web-panel/assets/{Crosschain-BNcbmoqh.js → Crosschain-C_hLuUcm.js} +1 -1
- package/src/assets/web-panel/assets/{DID-Bwja0Zo9.js → DID-b6YSUqxr.js} +2 -2
- package/src/assets/web-panel/assets/{Dashboard-Bk5XjEdJ.js → Dashboard-uvM15Cfb.js} +2 -2
- package/src/assets/web-panel/assets/{Dropdown-Ci36Oitq.js → Dropdown-D1-Nd4qY.js} +1 -1
- package/src/assets/web-panel/assets/{EmailListRenderer--U04ZN4q.js → EmailListRenderer-8ApvL6Zc.js} +1 -1
- package/src/assets/web-panel/assets/{FamilyGuardDashboard-B2gNPR2s.js → FamilyGuardDashboard-LYJt8811.js} +1 -1
- package/src/assets/web-panel/assets/{Federation-C4KghYn7.js → Federation-BbI3HGpv.js} +1 -1
- package/src/assets/web-panel/assets/{FormItemContext-QL7x_Q_0.js → FormItemContext-DSoEKuFu.js} +1 -1
- package/src/assets/web-panel/assets/{GenericCardRenderer-F3Ryltwm.js → GenericCardRenderer-B0S9OWd5.js} +1 -1
- package/src/assets/web-panel/assets/{Git-2HassK5k.js → Git-DIs9Zhf3.js} +2 -2
- package/src/assets/web-panel/assets/{Governance-DoBIp7oe.js → Governance-jzZXrxcl.js} +1 -1
- package/src/assets/web-panel/assets/{Inference--W01QhZ-.js → Inference-JUPjf2ZI.js} +1 -1
- package/src/assets/web-panel/assets/{KnowledgeGraph-DmvwqGpT.js → KnowledgeGraph-DxnfJvlP.js} +1 -1
- package/src/assets/web-panel/assets/{Logs-BBJ4yAel.js → Logs-iXiz8BEE.js} +2 -2
- package/src/assets/web-panel/assets/{MarkdownRenderer-BLCZMzhJ.js → MarkdownRenderer-GmB6DOr6.js} +1 -1
- package/src/assets/web-panel/assets/{Marketplace-D5lNngSI.js → Marketplace-Y3Xch312.js} +1 -1
- package/src/assets/web-panel/assets/{McpTools-CkEVOMKH.js → McpTools-D7kGGbO7.js} +4 -4
- package/src/assets/web-panel/assets/{Memory-B1Ow1xVE.js → Memory-Do_hVxzh.js} +2 -2
- package/src/assets/web-panel/assets/{MobileBridge-SgYbUfb6.js → MobileBridge-CtrR0Tas.js} +1 -1
- package/src/assets/web-panel/assets/MobileProjects-BL0Czcoj.js +1 -0
- package/src/assets/web-panel/assets/{Mtc-DcTXSSma.js → Mtc-qo5QCTGd.js} +2 -2
- package/src/assets/web-panel/assets/{MtcAudit-Dt3WG4oj.js → MtcAudit-cq5mnjYj.js} +2 -2
- package/src/assets/web-panel/assets/{Multisig-B9B38UZx.js → Multisig-Bq0MAQHw.js} +3 -3
- package/src/assets/web-panel/assets/{NLProgramming-B0S4GGvG.js → NLProgramming-DdWE6Vi2.js} +1 -1
- package/src/assets/web-panel/assets/{Notes-DZ5U58Ng.js → Notes-BxMBFcIp.js} +3 -3
- package/src/assets/web-panel/assets/{NotificationSettings-PjL5ZW34.js → NotificationSettings-RTrAcbUe.js} +1 -1
- package/src/assets/web-panel/assets/{OrderTableRenderer-DrAX2ESt.js → OrderTableRenderer-DJbpGqzT.js} +1 -1
- package/src/assets/web-panel/assets/{Organization-Cv1GYs-c.js → Organization-SQzIPdqC.js} +4 -4
- package/src/assets/web-panel/assets/{Overflow-Bbkp_PL3.js → Overflow-02yECh22.js} +1 -1
- package/src/assets/web-panel/assets/{P2P-BD8QUgoZ.js → P2P-BNg4pNCM.js} +2 -2
- package/src/assets/web-panel/assets/{PdhVaultBrowser-BO-2PeaB.js → PdhVaultBrowser-BxPJZsD3.js} +3 -3
- package/src/assets/web-panel/assets/{Permissions-D_OPVa02.js → Permissions-CLnDnPAz.js} +4 -4
- package/src/assets/web-panel/assets/{PersonalDataHub-mh-pEmaB.js → PersonalDataHub-C5m0AjGf.js} +4 -4
- package/src/assets/web-panel/assets/{Pipeline-6JC2EDdb.js → Pipeline-VwOf5Dhv.js} +1 -1
- package/src/assets/web-panel/assets/{Privacy-CUshzUAG.js → Privacy-CC0W2hZd.js} +1 -1
- package/src/assets/web-panel/assets/{ProjectInit-Cs2Aalri.js → ProjectInit-Dz893Bob.js} +2 -2
- package/src/assets/web-panel/assets/{ProjectSettings-1ak49tcj.js → ProjectSettings-CxQkvl0k.js} +2 -2
- package/src/assets/web-panel/assets/Projects-DDAjkUkT.js +1 -0
- package/src/assets/web-panel/assets/{Providers-CXXxvafH.js → Providers-DeB9IkxK.js} +1 -1
- package/src/assets/web-panel/assets/{QrScannerModal-PTcDsF58.js → QrScannerModal-OCeoaswn.js} +1 -1
- package/src/assets/web-panel/assets/{QuickAsk-CDwucrJw.js → QuickAsk-DenotaAj.js} +1 -1
- package/src/assets/web-panel/assets/{Recommend-CJlLVRXR.js → Recommend-Cies_jjI.js} +1 -1
- package/src/assets/web-panel/assets/{RemoteSession-DlN-ETsO.js → RemoteSession-Dxzl5n2T.js} +3 -3
- package/src/assets/web-panel/assets/{Reputation-DI23LR6m.js → Reputation-CW0mvxgZ.js} +1 -1
- package/src/assets/web-panel/assets/{Row-Cl7UQMrM.js → Row-SEd32qZU.js} +1 -1
- package/src/assets/web-panel/assets/{RssFeed-CexHNfh8.js → RssFeed-BSwnJ3Lm.js} +3 -3
- package/src/assets/web-panel/assets/{Search-BneBWvRN.js → Search-xe2m8bkC.js} +1 -1
- package/src/assets/web-panel/assets/{Security-DX7Phgrp.js → Security-Mq2F9Nro.js} +4 -4
- package/src/assets/web-panel/assets/{Services-BWXO8Mte.js → Services-BKGw2V3P.js} +2 -2
- package/src/assets/web-panel/assets/{Skeleton-DCyOjKPJ.js → Skeleton-DW95-Rxf.js} +1 -1
- package/src/assets/web-panel/assets/{Skills-CXqERsTB.js → Skills-CHgChCv_.js} +1 -1
- package/src/assets/web-panel/assets/{Sla-bhbrbz8E.js → Sla-tKh6k3R7.js} +1 -1
- package/src/assets/web-panel/assets/{SpeechSettings-3IPU0HBZ.js → SpeechSettings-D6UL3dA5.js} +1 -1
- package/src/assets/web-panel/assets/{SyncSettings-DBNTQLbM.js → SyncSettings-D0Fw8fVL.js} +2 -2
- package/src/assets/web-panel/assets/{Tasks-quP0bkN-.js → Tasks-20x1GB3D.js} +1 -1
- package/src/assets/web-panel/assets/{Templates-BouWmgho.js → Templates-DJ-lts-a.js} +1 -1
- package/src/assets/web-panel/assets/{Tenant-qv0D9s7z.js → Tenant-BFdGpbs7.js} +1 -1
- package/src/assets/web-panel/assets/{Terminal-BxvbE8BB.js → Terminal-Xpx61vHL.js} +2 -2
- package/src/assets/web-panel/assets/{TimelineRenderer-D-IeLnJ2.js → TimelineRenderer-C6UIvyZQ.js} +1 -1
- package/src/assets/web-panel/assets/{Tokens-CFMKKyVG.js → Tokens-Dellq08f.js} +1 -1
- package/src/assets/web-panel/assets/{Trigger-L8as3UuJ.js → Trigger-VKw8d0J-.js} +1 -1
- package/src/assets/web-panel/assets/{Trust-MxmA4VZG.js → Trust-DNftdKT5.js} +1 -1
- package/src/assets/web-panel/assets/{UkeySign-C0VvJYRi.js → UkeySign-DX9VAKJI.js} +1 -1
- package/src/assets/web-panel/assets/{VideoEditing-Bf5QZj8N.js → VideoEditing-B2h3jO8q.js} +1 -1
- package/src/assets/web-panel/assets/{Wallet-BIJ9riRz.js → Wallet-CjGkof7f.js} +4 -4
- package/src/assets/web-panel/assets/{WebAuthn-B8p9Mhkw.js → WebAuthn-2Zfcu55P.js} +4 -4
- package/src/assets/web-panel/assets/{WorkflowEditor-BVsuaPen.js → WorkflowEditor-B9eZ8UxZ.js} +1 -1
- package/src/assets/web-panel/assets/{chat-BfkjoKpV.js → chat-CQNfD-YO.js} +1 -1
- package/src/assets/web-panel/assets/{colors-DeAlsWK8.js → colors-D4usyFT2.js} +1 -1
- package/src/assets/web-panel/assets/{compact-item-Cs97bz5j.js → compact-item-s4TpjvjX.js} +1 -1
- package/src/assets/web-panel/assets/{createContext-BqbG60LH.js → createContext-BRCSvYxj.js} +1 -1
- package/src/assets/web-panel/assets/devWarning-DKJjSWRn.js +1 -0
- package/src/assets/web-panel/assets/{hasIn-i_RY0b1X.js → hasIn-AdkUbkV-.js} +1 -1
- package/src/assets/web-panel/assets/{index-BOVkf1K3.js → index--00U6vXZ.js} +1 -1
- package/src/assets/web-panel/assets/{index-B-3tEpTc.js → index-B2V-eKfK.js} +1 -1
- package/src/assets/web-panel/assets/{index-D2B4VPyP.js → index-B4JJCfQ2.js} +1 -1
- package/src/assets/web-panel/assets/{index-BkBi5TmC.js → index-BIoG5yYf.js} +1 -1
- package/src/assets/web-panel/assets/{index-79V_zv1s.js → index-BMcimU-2.js} +1 -1
- package/src/assets/web-panel/assets/{index-U4f5izHz.js → index-BPhkFZ1I.js} +1 -1
- package/src/assets/web-panel/assets/{index-DiSkx4UB.js → index-BQ3DXBtD.js} +3 -3
- package/src/assets/web-panel/assets/{index-dIHr4B_u.js → index-Bad4VNvz.js} +1 -1
- package/src/assets/web-panel/assets/{index-CIG8fQ2_.js → index-BgiXR_-_.js} +1 -1
- package/src/assets/web-panel/assets/{index-DqBgBpgp.js → index-BtobShro.js} +1 -1
- package/src/assets/web-panel/assets/{index-ZxO_-E8a.js → index-Bw2MEnUk.js} +1 -1
- package/src/assets/web-panel/assets/{index-DUUrv141.js → index-C1mJtE6q.js} +1 -1
- package/src/assets/web-panel/assets/{index-Bm7lLR4c.js → index-C484EJiS.js} +1 -1
- package/src/assets/web-panel/assets/{index-COdGA-8n.js → index-C5KAQFPQ.js} +1 -1
- package/src/assets/web-panel/assets/index-C61cnl3K.js +1 -0
- package/src/assets/web-panel/assets/{index-CED2PL_y.js → index-C7Y8Aluc.js} +1 -1
- package/src/assets/web-panel/assets/{index-YXgLng0K.js → index-CCsM_Ybx.js} +1 -1
- package/src/assets/web-panel/assets/{index-0-agYlWL.js → index-CJCFe2Qf.js} +1 -1
- package/src/assets/web-panel/assets/{index-B5UkyA3c.js → index-CJVEOa4i.js} +1 -1
- package/src/assets/web-panel/assets/{index-B1CbOH1i.js → index-CJdQrZth.js} +1 -1
- package/src/assets/web-panel/assets/{index-s1opAL-M.js → index-CKC1t3Ic.js} +1 -1
- package/src/assets/web-panel/assets/{index-yObj1O4n.js → index-CQhAVU5t.js} +1 -1
- package/src/assets/web-panel/assets/{index-CgsqdnRV.js → index-CYdoQsa1.js} +1 -1
- package/src/assets/web-panel/assets/{index-Dk7-v4hP.js → index-Cduu2Hhr.js} +1 -1
- package/src/assets/web-panel/assets/{index-B-JVTrpr.js → index-CkwiSLhU.js} +1 -1
- package/src/assets/web-panel/assets/{index-CeWoNHau.js → index-Cvyt-zhm.js} +1 -1
- package/src/assets/web-panel/assets/{index-CE2VtTlp.js → index-D2GjI2oe.js} +1 -1
- package/src/assets/web-panel/assets/index-D4XFHxBr.js +1 -0
- package/src/assets/web-panel/assets/{index-DmbfhJUP.js → index-DARsAyUp.js} +1 -1
- package/src/assets/web-panel/assets/{index-BESLFt4q.js → index-DVKfQ0Ux.js} +1 -1
- package/src/assets/web-panel/assets/{index-PT_5sBMA.js → index-DZT36ZYq.js} +1 -1
- package/src/assets/web-panel/assets/{index-gyFpvL7l.js → index-D_XBHopw.js} +1 -1
- package/src/assets/web-panel/assets/{index-BYUVuwKt.js → index-DdCB70SS.js} +1 -1
- package/src/assets/web-panel/assets/{index-BcUXAB2s.js → index-DzRjHxEv.js} +1 -1
- package/src/assets/web-panel/assets/{index-Dk2qouAz.js → index-MMN9V6JF.js} +1 -1
- package/src/assets/web-panel/assets/{index-D9evtvg2.js → index-RfMr79MW.js} +1 -1
- package/src/assets/web-panel/assets/{index-BuLQobzW.js → index-_IRVsV9X.js} +1 -1
- package/src/assets/web-panel/assets/{index-Cf3WCLmy.js → index-nXyk8GZ9.js} +1 -1
- package/src/assets/web-panel/assets/{index-CG3PRyAL.js → index-s_buFMEG.js} +1 -1
- package/src/assets/web-panel/assets/{initDefaultProps-C9h9yKL3.js → initDefaultProps-Blmpk7HL.js} +1 -1
- package/src/assets/web-panel/assets/{motion-BvmLsPdu.js → motion-DPBJMv1n.js} +1 -1
- package/src/assets/web-panel/assets/{move-CoL4ekU1.js → move-ButdzCM7.js} +1 -1
- package/src/assets/web-panel/assets/{mtc-parser-C4pMy6xV.js → mtc-parser-cyRC_QCe.js} +1 -1
- package/src/assets/web-panel/assets/{omit-DPuJfNxx.js → omit-1epSV7hz.js} +1 -1
- package/src/assets/web-panel/assets/{pickAttrs-D0KEeaK2.js → pickAttrs-DuPY8xJs.js} +1 -1
- package/src/assets/web-panel/assets/{placementArrow-BpkyINxt.js → placementArrow-6XZqarhe.js} +1 -1
- package/src/assets/web-panel/assets/{responsiveObserve-DQI_wOq8.js → responsiveObserve-B40pXBWM.js} +1 -1
- package/src/assets/web-panel/assets/{slide-DYfdDiRg.js → slide-jbZ9Wu4n.js} +1 -1
- package/src/assets/web-panel/assets/{statusUtils-B5dtvb0D.js → statusUtils-BKQQcVy4.js} +1 -1
- package/src/assets/web-panel/assets/{styleChecker-CF310mSj.js → styleChecker-NiKfWjUT.js} +1 -1
- package/src/assets/web-panel/assets/{useFlexGapSupport-D1Qp4BLp.js → useFlexGapSupport-bQUCvPeb.js} +1 -1
- package/src/assets/web-panel/assets/{useFs-C7_iyvJK.js → useFs-CUGji7ph.js} +1 -1
- package/src/assets/web-panel/assets/{usePersonalDataHub-DZUt_fIm.js → usePersonalDataHub-4veyN2N2.js} +1 -1
- package/src/assets/web-panel/assets/{vnode-D3dsi0lZ.js → vnode-1-nLug-Z.js} +1 -1
- package/src/assets/web-panel/assets/{zoom-WdbOlvdQ.js → zoom-dhCeRDMo.js} +1 -1
- package/src/assets/web-panel/index.html +1 -1
- package/src/command-help-index.json +6 -6
- package/src/commands/automation.js +103 -1
- package/src/commands/cowork.js +174 -19
- package/src/commands/permissions.js +240 -16
- package/src/commands/plugin.js +1293 -56
- package/src/commands/session-location.js +276 -0
- package/src/commands/session.js +2 -0
- package/src/commands/team-merge-review.js +937 -0
- package/src/commands/team.js +2 -0
- package/src/data/changelog.json +27 -1
- package/src/gateways/ws/action-protocol.js +35 -10
- package/src/gateways/ws/remote-session-protocol.js +24 -1
- package/src/gateways/ws/ws-agent-handler.js +12 -0
- package/src/gateways/ws/ws-server.js +48 -12
- package/src/harness/jsonl-session-store.js +341 -24
- package/src/harness/mcp-client.js +8 -1
- package/src/harness/remote-session-audit.js +1 -0
- package/src/harness/remote-session-crypto.js +210 -2
- package/src/harness/remote-session-registry.js +159 -6
- package/src/harness/session-list-index.js +233 -14
- package/src/lib/agent-authority.js +6 -0
- package/src/lib/agent-schedule-store.js +139 -28
- package/src/lib/agent-team/team-merge-review-store.js +765 -0
- package/src/lib/agent-team/team-merge-review-transaction.js +1223 -0
- package/src/lib/agent-team/team-merge-review.js +1847 -0
- package/src/lib/automation-center-incidents.js +314 -0
- package/src/lib/automation-center-runtime.js +448 -0
- package/src/lib/automation-center.js +182 -38
- package/src/lib/automation-engine.js +41 -0
- package/src/lib/automation-execution-authority.js +241 -12
- package/src/lib/automation-execution-incident.js +737 -0
- package/src/lib/background-agent-keeper-client.js +248 -0
- package/src/lib/background-agent-keeper-protocol.js +147 -0
- package/src/lib/background-agent-supervisor.js +520 -40
- package/src/lib/background-interaction-resolver.js +12 -0
- package/src/lib/background-turn-bootstrap-protocol.js +126 -0
- package/src/lib/cowork-cron.js +85 -14
- package/src/lib/cowork-workflow.js +336 -16
- package/src/lib/dynamic-workflow-facade.js +648 -0
- package/src/lib/execution-location-contract.js +551 -0
- package/src/lib/execution-location-runtime.js +130 -0
- package/src/lib/ide-context.js +94 -6
- package/src/lib/mcp-stdio-package-materialization.js +166 -14
- package/src/lib/packer/pkg-config-generator.js +2 -0
- package/src/lib/permission-authority.js +77 -0
- package/src/lib/permission-side-effect-center.js +495 -0
- package/src/lib/plugin-runtime/install.js +514 -13
- package/src/lib/plugin-runtime/marketplace-artifact-readback.js +900 -0
- package/src/lib/plugin-runtime/marketplace-catalog.js +1181 -0
- package/src/lib/plugin-runtime/marketplace-impact.js +408 -0
- package/src/lib/plugin-runtime/marketplace-remote-artifacts.js +1567 -0
- package/src/lib/plugin-security.js +60 -3
- package/src/lib/process-execution-broker/index.js +1095 -57
- package/src/lib/process-execution-broker/linux-bwrap-descriptor-launch.js +375 -0
- package/src/lib/process-execution-broker/linux-generic-bwrap-runtime.js +90 -16
- package/src/lib/process-execution-broker/linux-generic-bwrap.js +159 -18
- package/src/lib/process-execution-broker/macos-mcp-launcher-contract.js +278 -0
- package/src/lib/process-execution-broker/macos-mcp-launcher-protocol.json +23 -0
- package/src/lib/process-execution-broker/macos-mcp-launcher.c +1497 -0
- package/src/lib/process-execution-broker/mcp-fd-entry-bootstrap.js +37 -0
- package/src/lib/process-execution-broker/platform-sandbox.js +2330 -279
- package/src/lib/process-execution-broker/windows-sandbox-helper.dll +0 -0
- package/src/lib/process-execution-broker/windows-sandbox-helper.exe +0 -0
- package/src/lib/process-execution-broker/windows-sandbox.cs +50 -4
- package/src/lib/public-ide-capability-manifest.js +3 -0
- package/src/lib/remote-approval-bridge.js +123 -17
- package/src/lib/remote-membership-authority.js +632 -0
- package/src/lib/remote-membership-coordinator.js +2409 -0
- package/src/lib/remote-membership-host-store.js +1750 -0
- package/src/lib/scheduler-kernel/agenda-adapter.js +17 -7
- package/src/lib/scheduler-kernel/automation-adapter.js +105 -11
- package/src/lib/scheduler-kernel/automation-event-adapter.js +87 -11
- package/src/lib/scheduler-kernel/contract.js +91 -0
- package/src/lib/scheduler-kernel/cowork-cron-adapter.js +12 -1
- package/src/lib/scheduler-kernel/loop-adapter.js +7 -1
- package/src/lib/scheduler-kernel/routine-adapter.js +7 -1
- package/src/lib/scheduler-kernel/runtime-control-capabilities.js +31 -0
- package/src/lib/scheduler-kernel/runtime.js +176 -3
- package/src/lib/scheduler-kernel/store.js +1253 -45
- package/src/lib/scoped-permission-store.js +369 -0
- package/src/lib/session-anti-rollback-anchor.js +262 -5
- package/src/lib/sub-agent-context.js +4 -0
- package/src/lib/workflow-definition-contract.js +149 -0
- package/src/repl/agent-repl.js +41 -7
- package/src/repl/chat-repl.js +2 -0
- package/src/repl/clipboard-image.js +735 -16
- package/src/repl/ide-status.js +1 -1
- package/src/repl/prompt-interactions.js +59 -6
- package/src/runtime/agent-core.js +93 -3
- package/src/runtime/headless-runner.js +30 -25
- package/src/runtime/headless-stream.js +32 -25
- package/src/runtime/mcp-config.js +81 -10
- package/src/workers/background-agent-keeper.js +498 -0
- package/src/workers/background-agent-worker.js +358 -13
- package/src/workers/background-turn-bootstrap.js +143 -0
- package/src/assets/web-panel/assets/MobileProjects-BWdOvQAj.js +0 -1
- package/src/assets/web-panel/assets/Projects-BV4oApsm.js +0 -1
- package/src/assets/web-panel/assets/devWarning-DPty0mGN.js +0 -1
- package/src/assets/web-panel/assets/index-Bt7g0Vq_.js +0 -1
- package/src/assets/web-panel/assets/index-ChVT0p9u.js +0 -1
|
@@ -0,0 +1,2409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-authoritative membership and one-shot execution leases for Remote
|
|
3
|
+
* Approval across independent hosts.
|
|
4
|
+
*
|
|
5
|
+
* This is a single strongly consistent coordinator, not quorum consensus. A
|
|
6
|
+
* host must remain online to ACK and consume a lease immediately before it
|
|
7
|
+
* dispatches the approved side effect. The coordinator signs every statement
|
|
8
|
+
* with a pinned Ed25519 key; WebSocket delivery is transport only and never an
|
|
9
|
+
* authority by itself.
|
|
10
|
+
*
|
|
11
|
+
* Integration status: this module is a deployable contract, but the current
|
|
12
|
+
* remote-session registry, approval bridge, and agent dispatch paths do not
|
|
13
|
+
* call it yet. Cross-host revoke closure therefore remains false until every
|
|
14
|
+
* real side-effect entry point performs the online one-shot consume. A
|
|
15
|
+
* successful consume linearizes dispatch authority; it cannot undo an external
|
|
16
|
+
* effect after that effect has started.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import {
|
|
22
|
+
createHash,
|
|
23
|
+
createPrivateKey,
|
|
24
|
+
createPublicKey,
|
|
25
|
+
generateKeyPairSync,
|
|
26
|
+
randomBytes,
|
|
27
|
+
randomUUID,
|
|
28
|
+
sign,
|
|
29
|
+
verify,
|
|
30
|
+
} from "node:crypto";
|
|
31
|
+
import { getMachineSecurityAnchorDir } from "./paths.js";
|
|
32
|
+
import {
|
|
33
|
+
mutateSecurityStore,
|
|
34
|
+
readSecurityStore,
|
|
35
|
+
} from "./durable-security-store.js";
|
|
36
|
+
import { withFileLock } from "./with-file-lock.js";
|
|
37
|
+
import { consumeRemoteRelayPossessionCapability } from "../harness/remote-session-crypto.js";
|
|
38
|
+
|
|
39
|
+
export const REMOTE_MEMBERSHIP_COORDINATOR_VERSION =
|
|
40
|
+
"server-signed-membership-execution-lease-v1";
|
|
41
|
+
export const REMOTE_MEMBERSHIP_STATEMENT_SCHEMA =
|
|
42
|
+
"chainlesschain.remote-membership-statement/v1";
|
|
43
|
+
export const REMOTE_MEMBERSHIP_COORDINATOR_UNAVAILABLE_CODE =
|
|
44
|
+
"CC_REMOTE_MEMBERSHIP_COORDINATOR_UNAVAILABLE";
|
|
45
|
+
export const REMOTE_MEMBERSHIP_COORDINATOR_ROLLBACK_CODE =
|
|
46
|
+
"CC_REMOTE_MEMBERSHIP_COORDINATOR_ROLLBACK";
|
|
47
|
+
|
|
48
|
+
const STORE_VERSION = 1;
|
|
49
|
+
const KEY_VERSION = 1;
|
|
50
|
+
const WITNESS_VERSION = 2;
|
|
51
|
+
const EVENT_SCHEMA = "chainlesschain.remote-membership-event/v1";
|
|
52
|
+
const AUTHENTICATION_CHALLENGE_SCHEMA =
|
|
53
|
+
"chainlesschain.remote-membership-authentication-challenge/v1";
|
|
54
|
+
const AUTHENTICATION_CHALLENGE_DOMAIN =
|
|
55
|
+
"chainlesschain.remote-membership-authentication-challenge.v1\0";
|
|
56
|
+
const STORE_LABEL = "Remote membership coordinator";
|
|
57
|
+
const KEY_LABEL = "Remote membership coordinator signing key";
|
|
58
|
+
const WITNESS_LABEL = "Remote membership coordinator rollback witness";
|
|
59
|
+
const DIGEST_RE = /^[0-9a-f]{64}$/;
|
|
60
|
+
const POSITIVE_RE = /^[1-9]\d*$/;
|
|
61
|
+
const NON_NEGATIVE_RE = /^(?:0|[1-9]\d*)$/;
|
|
62
|
+
const VALID_SCOPES = new Set(["observe", "prompt", "approve", "interrupt"]);
|
|
63
|
+
const VALID_TYPES = new Set([
|
|
64
|
+
"session.created",
|
|
65
|
+
"member.joined",
|
|
66
|
+
"member.revoked",
|
|
67
|
+
"session.closed",
|
|
68
|
+
"lease.created",
|
|
69
|
+
"lease.acked",
|
|
70
|
+
"lease.consumed",
|
|
71
|
+
"lease.cancelled",
|
|
72
|
+
]);
|
|
73
|
+
const DEFAULT_LEASE_TTL_MS = 30_000;
|
|
74
|
+
const MAX_LEASE_TTL_MS = 60_000;
|
|
75
|
+
const DEFAULT_CHALLENGE_TTL_MS = 30_000;
|
|
76
|
+
const MAX_CHALLENGE_TTL_MS = 60_000;
|
|
77
|
+
const BASE64URL_RE = /^[A-Za-z0-9_-]+$/;
|
|
78
|
+
|
|
79
|
+
const COMMON_EVENT_FIELDS = Object.freeze([
|
|
80
|
+
"eventHash",
|
|
81
|
+
"eventId",
|
|
82
|
+
"generation",
|
|
83
|
+
"occurredAtMs",
|
|
84
|
+
"previousEventHash",
|
|
85
|
+
"previousGeneration",
|
|
86
|
+
"schema",
|
|
87
|
+
"sessionId",
|
|
88
|
+
"type",
|
|
89
|
+
]);
|
|
90
|
+
const EVENT_FIELDS = Object.freeze({
|
|
91
|
+
"session.created": Object.freeze([
|
|
92
|
+
...COMMON_EVENT_FIELDS,
|
|
93
|
+
"agentSessionId",
|
|
94
|
+
"expiresAt",
|
|
95
|
+
"hostCredentialDigest",
|
|
96
|
+
"hostCredentialPublicKey",
|
|
97
|
+
"hostCredentialType",
|
|
98
|
+
"hostPrincipalId",
|
|
99
|
+
"membershipEpoch",
|
|
100
|
+
"scopes",
|
|
101
|
+
"sessionEpoch",
|
|
102
|
+
]),
|
|
103
|
+
"member.joined": Object.freeze([
|
|
104
|
+
...COMMON_EVENT_FIELDS,
|
|
105
|
+
"credentialDigest",
|
|
106
|
+
"credentialPublicKey",
|
|
107
|
+
"credentialType",
|
|
108
|
+
"expectedSessionEpoch",
|
|
109
|
+
"membershipEpoch",
|
|
110
|
+
"previousMembershipEpoch",
|
|
111
|
+
"principalId",
|
|
112
|
+
"scopes",
|
|
113
|
+
]),
|
|
114
|
+
"member.revoked": Object.freeze([
|
|
115
|
+
...COMMON_EVENT_FIELDS,
|
|
116
|
+
"cancelledLeaseIds",
|
|
117
|
+
"expectedHostMembershipEpoch",
|
|
118
|
+
"expectedMembershipEpoch",
|
|
119
|
+
"expectedSessionEpoch",
|
|
120
|
+
"hostPrincipalId",
|
|
121
|
+
"membershipEpoch",
|
|
122
|
+
"principalId",
|
|
123
|
+
]),
|
|
124
|
+
"session.closed": Object.freeze([
|
|
125
|
+
...COMMON_EVENT_FIELDS,
|
|
126
|
+
"cancelledLeaseIds",
|
|
127
|
+
"expectedHostMembershipEpoch",
|
|
128
|
+
"expectedSessionEpoch",
|
|
129
|
+
"hostPrincipalId",
|
|
130
|
+
"sessionEpoch",
|
|
131
|
+
]),
|
|
132
|
+
"lease.created": Object.freeze([
|
|
133
|
+
...COMMON_EVENT_FIELDS,
|
|
134
|
+
"binding",
|
|
135
|
+
"expectedSessionEpoch",
|
|
136
|
+
"expiresAt",
|
|
137
|
+
"fingerprint",
|
|
138
|
+
"hostPrincipalId",
|
|
139
|
+
"leaseId",
|
|
140
|
+
"membershipEpoch",
|
|
141
|
+
"principalId",
|
|
142
|
+
"requestId",
|
|
143
|
+
]),
|
|
144
|
+
"lease.acked": Object.freeze([
|
|
145
|
+
...COMMON_EVENT_FIELDS,
|
|
146
|
+
"expectedCreatedGeneration",
|
|
147
|
+
"expectedHostMembershipEpoch",
|
|
148
|
+
"expectedSessionEpoch",
|
|
149
|
+
"hostPrincipalId",
|
|
150
|
+
"hostReceiptDigest",
|
|
151
|
+
"leaseId",
|
|
152
|
+
]),
|
|
153
|
+
"lease.consumed": Object.freeze([
|
|
154
|
+
...COMMON_EVENT_FIELDS,
|
|
155
|
+
"binding",
|
|
156
|
+
"expectedAckedGeneration",
|
|
157
|
+
"expectedHostMembershipEpoch",
|
|
158
|
+
"expectedMembershipEpoch",
|
|
159
|
+
"expectedSessionEpoch",
|
|
160
|
+
"fingerprint",
|
|
161
|
+
"hostPrincipalId",
|
|
162
|
+
"leaseId",
|
|
163
|
+
"requestId",
|
|
164
|
+
]),
|
|
165
|
+
"lease.cancelled": Object.freeze([
|
|
166
|
+
...COMMON_EVENT_FIELDS,
|
|
167
|
+
"expectedHostMembershipEpoch",
|
|
168
|
+
"expectedSessionEpoch",
|
|
169
|
+
"hostPrincipalId",
|
|
170
|
+
"leaseId",
|
|
171
|
+
"reason",
|
|
172
|
+
]),
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
function coordinatorError(code, message, cause = null, details = {}) {
|
|
176
|
+
const error = new Error(message, cause ? { cause } : undefined);
|
|
177
|
+
error.name = "RemoteMembershipCoordinatorError";
|
|
178
|
+
error.code = code;
|
|
179
|
+
Object.assign(error, details);
|
|
180
|
+
return error;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function unavailable(cause, filePath) {
|
|
184
|
+
if (
|
|
185
|
+
cause?.code === REMOTE_MEMBERSHIP_COORDINATOR_UNAVAILABLE_CODE ||
|
|
186
|
+
cause?.code === REMOTE_MEMBERSHIP_COORDINATOR_ROLLBACK_CODE
|
|
187
|
+
) {
|
|
188
|
+
return cause;
|
|
189
|
+
}
|
|
190
|
+
return coordinatorError(
|
|
191
|
+
REMOTE_MEMBERSHIP_COORDINATOR_UNAVAILABLE_CODE,
|
|
192
|
+
"Remote membership coordinator is unavailable; remote execution is denied",
|
|
193
|
+
cause,
|
|
194
|
+
{ filePath, commitState: cause?.commitState || null },
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function rollback(message, details = {}) {
|
|
199
|
+
return coordinatorError(
|
|
200
|
+
REMOTE_MEMBERSHIP_COORDINATOR_ROLLBACK_CODE,
|
|
201
|
+
`Remote membership coordinator rollback detected: ${message}`,
|
|
202
|
+
null,
|
|
203
|
+
details,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function requiredString(value, label, maximum = 1024) {
|
|
208
|
+
if (
|
|
209
|
+
typeof value !== "string" ||
|
|
210
|
+
value.length === 0 ||
|
|
211
|
+
value.length > maximum ||
|
|
212
|
+
value.includes("\0")
|
|
213
|
+
) {
|
|
214
|
+
throw new TypeError(`${label} must be a non-empty bounded string`);
|
|
215
|
+
}
|
|
216
|
+
return value;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function canonicalEpoch(value, label, { zero = false } = {}) {
|
|
220
|
+
const pattern = zero ? NON_NEGATIVE_RE : POSITIVE_RE;
|
|
221
|
+
if (typeof value !== "string" || !pattern.test(value)) {
|
|
222
|
+
throw new TypeError(`${label} must be a canonical integer string`);
|
|
223
|
+
}
|
|
224
|
+
return BigInt(value);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function safeTimestamp(value, label) {
|
|
228
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
229
|
+
throw new TypeError(`${label} must be a non-negative safe timestamp`);
|
|
230
|
+
}
|
|
231
|
+
return value;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function normalizeScopes(scopes) {
|
|
235
|
+
if (!Array.isArray(scopes) || scopes.length === 0) {
|
|
236
|
+
throw new TypeError("membership scopes must be a non-empty array");
|
|
237
|
+
}
|
|
238
|
+
const normalized = [...new Set(scopes.map(String))].sort();
|
|
239
|
+
for (const scope of normalized) {
|
|
240
|
+
if (!VALID_SCOPES.has(scope)) {
|
|
241
|
+
throw new TypeError(`unsupported membership scope: ${scope}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return normalized;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function canonicalJson(value) {
|
|
248
|
+
if (
|
|
249
|
+
value === null ||
|
|
250
|
+
typeof value === "boolean" ||
|
|
251
|
+
typeof value === "string"
|
|
252
|
+
) {
|
|
253
|
+
return JSON.stringify(value);
|
|
254
|
+
}
|
|
255
|
+
if (typeof value === "number") {
|
|
256
|
+
if (!Number.isSafeInteger(value) || Object.is(value, -0)) {
|
|
257
|
+
throw new TypeError("canonical JSON numbers must be safe integers");
|
|
258
|
+
}
|
|
259
|
+
return JSON.stringify(value);
|
|
260
|
+
}
|
|
261
|
+
if (typeof value !== "object") {
|
|
262
|
+
throw new TypeError(`unsupported canonical JSON value: ${typeof value}`);
|
|
263
|
+
}
|
|
264
|
+
if (Array.isArray(value)) {
|
|
265
|
+
if (Object.keys(value).length !== value.length) {
|
|
266
|
+
throw new TypeError("canonical JSON arrays must be dense and unadorned");
|
|
267
|
+
}
|
|
268
|
+
return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
|
|
269
|
+
}
|
|
270
|
+
const prototype = Object.getPrototypeOf(value);
|
|
271
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
272
|
+
throw new TypeError("canonical JSON objects must have a plain prototype");
|
|
273
|
+
}
|
|
274
|
+
return `{${Object.keys(value)
|
|
275
|
+
.sort()
|
|
276
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
|
|
277
|
+
.join(",")}}`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function hasExactKeys(value, expected) {
|
|
281
|
+
return (
|
|
282
|
+
value &&
|
|
283
|
+
typeof value === "object" &&
|
|
284
|
+
!Array.isArray(value) &&
|
|
285
|
+
canonicalJson(Object.keys(value).sort()) ===
|
|
286
|
+
canonicalJson([...expected].sort())
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function decodeBase64Url(value, label, maximum = 8192) {
|
|
291
|
+
const encoded = requiredString(value, label, maximum);
|
|
292
|
+
if (!BASE64URL_RE.test(encoded)) {
|
|
293
|
+
throw new TypeError(`${label} is not canonical base64url`);
|
|
294
|
+
}
|
|
295
|
+
const decoded = Buffer.from(encoded, "base64url");
|
|
296
|
+
if (decoded.length === 0 || decoded.toString("base64url") !== encoded) {
|
|
297
|
+
throw new TypeError(`${label} is not canonical base64url`);
|
|
298
|
+
}
|
|
299
|
+
return decoded;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function sha256(value) {
|
|
303
|
+
return createHash("sha256").update(value).digest("hex");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function eventHash(event) {
|
|
307
|
+
const core = { ...event };
|
|
308
|
+
delete core.eventHash;
|
|
309
|
+
return sha256(
|
|
310
|
+
Buffer.concat([
|
|
311
|
+
Buffer.from("chainlesschain.remote-membership-event.v1\0", "utf8"),
|
|
312
|
+
Buffer.from(canonicalJson(core), "utf8"),
|
|
313
|
+
]),
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function createRemoteMembershipPrincipalCredential() {
|
|
318
|
+
const pair = generateKeyPairSync("ed25519", {
|
|
319
|
+
publicKeyEncoding: { type: "spki", format: "der" },
|
|
320
|
+
privateKeyEncoding: { type: "pkcs8", format: "der" },
|
|
321
|
+
});
|
|
322
|
+
return Object.freeze({
|
|
323
|
+
type: "ed25519",
|
|
324
|
+
publicKey: pair.publicKey.toString("base64url"),
|
|
325
|
+
privateKeyPkcs8: pair.privateKey.toString("base64url"),
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function authenticationChallengeBytes(challenge) {
|
|
330
|
+
return Buffer.concat([
|
|
331
|
+
Buffer.from(AUTHENTICATION_CHALLENGE_DOMAIN, "utf8"),
|
|
332
|
+
Buffer.from(canonicalJson(challenge), "utf8"),
|
|
333
|
+
]);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function signRemoteMembershipAuthenticationChallenge(
|
|
337
|
+
challenge,
|
|
338
|
+
privateKeyPkcs8,
|
|
339
|
+
) {
|
|
340
|
+
const privateDer = decodeBase64Url(privateKeyPkcs8, "privateKeyPkcs8");
|
|
341
|
+
const privateKey = createPrivateKey({
|
|
342
|
+
key: privateDer,
|
|
343
|
+
type: "pkcs8",
|
|
344
|
+
format: "der",
|
|
345
|
+
});
|
|
346
|
+
if (privateKey.asymmetricKeyType !== "ed25519") {
|
|
347
|
+
throw new TypeError("privateKeyPkcs8 must contain an Ed25519 private key");
|
|
348
|
+
}
|
|
349
|
+
return sign(
|
|
350
|
+
null,
|
|
351
|
+
authenticationChallengeBytes(challenge),
|
|
352
|
+
privateKey,
|
|
353
|
+
).toString("base64url");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function normalizePrincipalCredential(
|
|
357
|
+
{ type = "ed25519", publicKey } = {},
|
|
358
|
+
label = "principalCredential",
|
|
359
|
+
) {
|
|
360
|
+
if (!new Set(["ed25519", "relay-x25519"]).has(type)) {
|
|
361
|
+
throw new TypeError(`${label}.type is unsupported`);
|
|
362
|
+
}
|
|
363
|
+
const encoded = requiredString(publicKey, `${label}.publicKey`, 4096);
|
|
364
|
+
let der;
|
|
365
|
+
try {
|
|
366
|
+
der = decodeBase64Url(encoded, `${label}.publicKey`, 4096);
|
|
367
|
+
const key = createPublicKey({ key: der, type: "spki", format: "der" });
|
|
368
|
+
if (key.asymmetricKeyType !== (type === "ed25519" ? "ed25519" : "x25519")) {
|
|
369
|
+
throw new TypeError("wrong asymmetric key type");
|
|
370
|
+
}
|
|
371
|
+
} catch (cause) {
|
|
372
|
+
throw new TypeError(`${label}.publicKey is not a ${type} SPKI key`, {
|
|
373
|
+
cause,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
const digest = sha256(der);
|
|
377
|
+
return Object.freeze({
|
|
378
|
+
type,
|
|
379
|
+
publicKey: encoded,
|
|
380
|
+
digest,
|
|
381
|
+
principalId: `${type}:${digest}`,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function publicMember(member) {
|
|
386
|
+
return Object.freeze({
|
|
387
|
+
principalId: member.principalId,
|
|
388
|
+
membershipEpoch: member.membershipEpoch,
|
|
389
|
+
status: member.status,
|
|
390
|
+
scopes: Object.freeze([...member.scopes]),
|
|
391
|
+
credentialKeySha256: member.credentialDigest,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function publicLease(lease) {
|
|
396
|
+
return Object.freeze({ ...lease });
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function publicSessionSnapshot(session, generation) {
|
|
400
|
+
return Object.freeze({
|
|
401
|
+
sessionId: session.sessionId,
|
|
402
|
+
agentSessionId: session.agentSessionId,
|
|
403
|
+
hostPrincipalId: session.hostPrincipalId,
|
|
404
|
+
sessionEpoch: session.sessionEpoch,
|
|
405
|
+
createdAt: session.createdAt,
|
|
406
|
+
expiresAt: session.expiresAt,
|
|
407
|
+
status: session.status,
|
|
408
|
+
authorityGeneration: String(generation),
|
|
409
|
+
members: Object.freeze(
|
|
410
|
+
[...session.members.values()]
|
|
411
|
+
.map(publicMember)
|
|
412
|
+
.sort((left, right) =>
|
|
413
|
+
left.principalId.localeCompare(right.principalId),
|
|
414
|
+
),
|
|
415
|
+
),
|
|
416
|
+
leases: Object.freeze(
|
|
417
|
+
[...session.leases.values()]
|
|
418
|
+
.map(publicLease)
|
|
419
|
+
.sort((left, right) => left.leaseId.localeCompare(right.leaseId)),
|
|
420
|
+
),
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function replayStore(store, coordinatorId, { missing = false } = {}) {
|
|
425
|
+
if (missing && Object.keys(store).length === 0) {
|
|
426
|
+
return {
|
|
427
|
+
generation: 0n,
|
|
428
|
+
headHash: null,
|
|
429
|
+
events: [],
|
|
430
|
+
sessions: new Map(),
|
|
431
|
+
lastOccurredAtMs: 0,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
if (
|
|
435
|
+
!hasExactKeys(store, [
|
|
436
|
+
"coordinatorId",
|
|
437
|
+
"events",
|
|
438
|
+
"generation",
|
|
439
|
+
"headHash",
|
|
440
|
+
"version",
|
|
441
|
+
]) ||
|
|
442
|
+
store.version !== STORE_VERSION ||
|
|
443
|
+
store.coordinatorId !== coordinatorId ||
|
|
444
|
+
!Array.isArray(store.events)
|
|
445
|
+
) {
|
|
446
|
+
throw new TypeError(
|
|
447
|
+
"Remote membership coordinator store identity is invalid",
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
const declaredGeneration = canonicalEpoch(store.generation, "generation", {
|
|
451
|
+
zero: true,
|
|
452
|
+
});
|
|
453
|
+
if (store.headHash !== null && !DIGEST_RE.test(String(store.headHash))) {
|
|
454
|
+
throw new TypeError("Remote membership coordinator head hash is invalid");
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const sessions = new Map();
|
|
458
|
+
let generation = 0n;
|
|
459
|
+
let previousHash = null;
|
|
460
|
+
let lastOccurredAtMs = 0;
|
|
461
|
+
const seenEventIds = new Set();
|
|
462
|
+
const seenLeaseIds = new Set();
|
|
463
|
+
|
|
464
|
+
for (const [index, event] of store.events.entries()) {
|
|
465
|
+
const label = `events[${index}]`;
|
|
466
|
+
if (!event || typeof event !== "object" || Array.isArray(event)) {
|
|
467
|
+
throw new TypeError(`${label} must be an object`);
|
|
468
|
+
}
|
|
469
|
+
if (event.schema !== EVENT_SCHEMA || !VALID_TYPES.has(event.type)) {
|
|
470
|
+
throw new TypeError(`${label} has an unsupported schema or type`);
|
|
471
|
+
}
|
|
472
|
+
if (!hasExactKeys(event, EVENT_FIELDS[event.type])) {
|
|
473
|
+
throw new TypeError(
|
|
474
|
+
`${label} has non-canonical fields for ${event.type}`,
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
const currentGeneration = canonicalEpoch(
|
|
478
|
+
event.generation,
|
|
479
|
+
`${label}.generation`,
|
|
480
|
+
);
|
|
481
|
+
const priorGeneration = canonicalEpoch(
|
|
482
|
+
event.previousGeneration,
|
|
483
|
+
`${label}.previousGeneration`,
|
|
484
|
+
{ zero: true },
|
|
485
|
+
);
|
|
486
|
+
if (
|
|
487
|
+
priorGeneration !== generation ||
|
|
488
|
+
currentGeneration !== generation + 1n ||
|
|
489
|
+
event.previousEventHash !== previousHash
|
|
490
|
+
) {
|
|
491
|
+
throw new TypeError(`${label} breaks the append-only chain`);
|
|
492
|
+
}
|
|
493
|
+
requiredString(event.eventId, `${label}.eventId`);
|
|
494
|
+
if (seenEventIds.has(event.eventId)) {
|
|
495
|
+
throw new TypeError(`${label} repeats an event id`);
|
|
496
|
+
}
|
|
497
|
+
seenEventIds.add(event.eventId);
|
|
498
|
+
safeTimestamp(event.occurredAtMs, `${label}.occurredAtMs`);
|
|
499
|
+
if (event.occurredAtMs < lastOccurredAtMs) {
|
|
500
|
+
throw new TypeError(
|
|
501
|
+
`${label}.occurredAtMs moves authority time backward`,
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
if (!DIGEST_RE.test(String(event.eventHash))) {
|
|
505
|
+
throw new TypeError(`${label}.eventHash is invalid`);
|
|
506
|
+
}
|
|
507
|
+
if (eventHash(event) !== event.eventHash) {
|
|
508
|
+
throw new TypeError(`${label}.eventHash does not cover the event`);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const sessionId = requiredString(event.sessionId, `${label}.sessionId`);
|
|
512
|
+
if (event.type === "session.created") {
|
|
513
|
+
if (sessions.has(sessionId)) {
|
|
514
|
+
throw new TypeError(`${label} recreates a session`);
|
|
515
|
+
}
|
|
516
|
+
const hostPrincipalId = requiredString(
|
|
517
|
+
event.hostPrincipalId,
|
|
518
|
+
`${label}.hostPrincipalId`,
|
|
519
|
+
);
|
|
520
|
+
const hostCredential = normalizePrincipalCredential(
|
|
521
|
+
{
|
|
522
|
+
type: event.hostCredentialType,
|
|
523
|
+
publicKey: event.hostCredentialPublicKey,
|
|
524
|
+
},
|
|
525
|
+
`${label}.hostCredential`,
|
|
526
|
+
);
|
|
527
|
+
if (
|
|
528
|
+
hostCredential.digest !== event.hostCredentialDigest ||
|
|
529
|
+
hostCredential.principalId !== hostPrincipalId
|
|
530
|
+
) {
|
|
531
|
+
throw new TypeError(`${label}.hostPrincipalId is not key-bound`);
|
|
532
|
+
}
|
|
533
|
+
const sessionEpoch = String(
|
|
534
|
+
canonicalEpoch(event.sessionEpoch, `${label}.sessionEpoch`),
|
|
535
|
+
);
|
|
536
|
+
const membershipEpoch = String(
|
|
537
|
+
canonicalEpoch(event.membershipEpoch, `${label}.membershipEpoch`),
|
|
538
|
+
);
|
|
539
|
+
if (sessionEpoch !== "1" || membershipEpoch !== "1") {
|
|
540
|
+
throw new TypeError(`${label} must start epochs at one`);
|
|
541
|
+
}
|
|
542
|
+
const expiresAt = safeTimestamp(event.expiresAt, `${label}.expiresAt`);
|
|
543
|
+
if (expiresAt <= event.occurredAtMs) {
|
|
544
|
+
throw new TypeError(`${label}.expiresAt must be in the future`);
|
|
545
|
+
}
|
|
546
|
+
sessions.set(sessionId, {
|
|
547
|
+
sessionId,
|
|
548
|
+
agentSessionId: requiredString(
|
|
549
|
+
event.agentSessionId,
|
|
550
|
+
`${label}.agentSessionId`,
|
|
551
|
+
),
|
|
552
|
+
hostPrincipalId,
|
|
553
|
+
sessionEpoch,
|
|
554
|
+
createdAt: event.occurredAtMs,
|
|
555
|
+
expiresAt,
|
|
556
|
+
status: "active",
|
|
557
|
+
members: new Map([
|
|
558
|
+
[
|
|
559
|
+
hostPrincipalId,
|
|
560
|
+
{
|
|
561
|
+
principalId: hostPrincipalId,
|
|
562
|
+
membershipEpoch,
|
|
563
|
+
status: "active",
|
|
564
|
+
scopes: normalizeScopes(event.scopes),
|
|
565
|
+
credentialType: hostCredential.type,
|
|
566
|
+
credentialPublicKey: hostCredential.publicKey,
|
|
567
|
+
credentialDigest: hostCredential.digest,
|
|
568
|
+
},
|
|
569
|
+
],
|
|
570
|
+
]),
|
|
571
|
+
leases: new Map(),
|
|
572
|
+
});
|
|
573
|
+
} else {
|
|
574
|
+
const session = sessions.get(sessionId);
|
|
575
|
+
if (!session) throw new TypeError(`${label} targets an unknown session`);
|
|
576
|
+
const expectedSessionEpoch = String(
|
|
577
|
+
canonicalEpoch(
|
|
578
|
+
event.expectedSessionEpoch,
|
|
579
|
+
`${label}.expectedSessionEpoch`,
|
|
580
|
+
),
|
|
581
|
+
);
|
|
582
|
+
if (expectedSessionEpoch !== session.sessionEpoch) {
|
|
583
|
+
throw new TypeError(`${label} has a stale session epoch`);
|
|
584
|
+
}
|
|
585
|
+
if (
|
|
586
|
+
event.type !== "session.closed" &&
|
|
587
|
+
event.occurredAtMs >= session.expiresAt
|
|
588
|
+
) {
|
|
589
|
+
throw new TypeError(`${label} occurs after session expiry`);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (event.type === "member.joined") {
|
|
593
|
+
if (session.status !== "active") {
|
|
594
|
+
throw new TypeError(`${label} joins a closed session`);
|
|
595
|
+
}
|
|
596
|
+
const principalId = requiredString(
|
|
597
|
+
event.principalId,
|
|
598
|
+
`${label}.principalId`,
|
|
599
|
+
);
|
|
600
|
+
if (principalId === session.hostPrincipalId) {
|
|
601
|
+
throw new TypeError(`${label} replaces the host principal`);
|
|
602
|
+
}
|
|
603
|
+
const credential = normalizePrincipalCredential(
|
|
604
|
+
{
|
|
605
|
+
type: event.credentialType,
|
|
606
|
+
publicKey: event.credentialPublicKey,
|
|
607
|
+
},
|
|
608
|
+
`${label}.credential`,
|
|
609
|
+
);
|
|
610
|
+
if (
|
|
611
|
+
credential.digest !== event.credentialDigest ||
|
|
612
|
+
credential.principalId !== principalId
|
|
613
|
+
) {
|
|
614
|
+
throw new TypeError(`${label}.principalId is not key-bound`);
|
|
615
|
+
}
|
|
616
|
+
const prior = session.members.get(principalId) || null;
|
|
617
|
+
if (prior?.status === "active") {
|
|
618
|
+
throw new TypeError(`${label} replaces an active principal`);
|
|
619
|
+
}
|
|
620
|
+
const previousMembershipEpoch =
|
|
621
|
+
event.previousMembershipEpoch === null
|
|
622
|
+
? null
|
|
623
|
+
: String(
|
|
624
|
+
canonicalEpoch(
|
|
625
|
+
event.previousMembershipEpoch,
|
|
626
|
+
`${label}.previousMembershipEpoch`,
|
|
627
|
+
),
|
|
628
|
+
);
|
|
629
|
+
if (previousMembershipEpoch !== (prior?.membershipEpoch || null)) {
|
|
630
|
+
throw new TypeError(`${label} has a stale previous membership epoch`);
|
|
631
|
+
}
|
|
632
|
+
const membershipEpoch = String(
|
|
633
|
+
canonicalEpoch(event.membershipEpoch, `${label}.membershipEpoch`),
|
|
634
|
+
);
|
|
635
|
+
const expected = prior
|
|
636
|
+
? String(BigInt(prior.membershipEpoch) + 1n)
|
|
637
|
+
: "1";
|
|
638
|
+
if (membershipEpoch !== expected) {
|
|
639
|
+
throw new TypeError(`${label} does not advance the principal epoch`);
|
|
640
|
+
}
|
|
641
|
+
session.members.set(principalId, {
|
|
642
|
+
principalId,
|
|
643
|
+
membershipEpoch,
|
|
644
|
+
status: "active",
|
|
645
|
+
scopes: normalizeScopes(event.scopes),
|
|
646
|
+
credentialType: credential.type,
|
|
647
|
+
credentialPublicKey: credential.publicKey,
|
|
648
|
+
credentialDigest: credential.digest,
|
|
649
|
+
});
|
|
650
|
+
} else if (event.type === "member.revoked") {
|
|
651
|
+
if (session.status !== "active") {
|
|
652
|
+
throw new TypeError(`${label} revokes within a closed session`);
|
|
653
|
+
}
|
|
654
|
+
const principalId = requiredString(
|
|
655
|
+
event.principalId,
|
|
656
|
+
`${label}.principalId`,
|
|
657
|
+
);
|
|
658
|
+
const member = session.members.get(principalId);
|
|
659
|
+
if (!member || member.status !== "active") {
|
|
660
|
+
throw new TypeError(`${label} targets an inactive member`);
|
|
661
|
+
}
|
|
662
|
+
if (principalId === session.hostPrincipalId) {
|
|
663
|
+
throw new TypeError(`${label} revokes the host principal`);
|
|
664
|
+
}
|
|
665
|
+
const host = session.members.get(session.hostPrincipalId);
|
|
666
|
+
if (
|
|
667
|
+
event.hostPrincipalId !== session.hostPrincipalId ||
|
|
668
|
+
!host ||
|
|
669
|
+
host.status !== "active" ||
|
|
670
|
+
event.expectedHostMembershipEpoch !== host.membershipEpoch
|
|
671
|
+
) {
|
|
672
|
+
throw new TypeError(
|
|
673
|
+
`${label} was not authorized by the current host`,
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
if (event.expectedMembershipEpoch !== member.membershipEpoch) {
|
|
677
|
+
throw new TypeError(`${label} has a stale membership epoch`);
|
|
678
|
+
}
|
|
679
|
+
const nextEpoch = String(
|
|
680
|
+
canonicalEpoch(event.membershipEpoch, `${label}.membershipEpoch`),
|
|
681
|
+
);
|
|
682
|
+
if (nextEpoch !== String(BigInt(member.membershipEpoch) + 1n)) {
|
|
683
|
+
throw new TypeError(`${label} does not advance the principal epoch`);
|
|
684
|
+
}
|
|
685
|
+
const cancellable = [...session.leases.values()]
|
|
686
|
+
.filter(
|
|
687
|
+
(lease) =>
|
|
688
|
+
lease.principalId === principalId &&
|
|
689
|
+
(lease.status === "active" || lease.status === "acked"),
|
|
690
|
+
)
|
|
691
|
+
.map((lease) => lease.leaseId)
|
|
692
|
+
.sort();
|
|
693
|
+
if (
|
|
694
|
+
canonicalJson(event.cancelledLeaseIds) !== canonicalJson(cancellable)
|
|
695
|
+
) {
|
|
696
|
+
throw new TypeError(`${label} does not cancel every active lease`);
|
|
697
|
+
}
|
|
698
|
+
member.status = "revoked";
|
|
699
|
+
member.membershipEpoch = nextEpoch;
|
|
700
|
+
for (const leaseId of cancellable) {
|
|
701
|
+
const lease = session.leases.get(leaseId);
|
|
702
|
+
lease.status = "cancelled";
|
|
703
|
+
lease.cancelReason = "membership-revoked";
|
|
704
|
+
lease.terminalGeneration = event.generation;
|
|
705
|
+
}
|
|
706
|
+
} else if (event.type === "session.closed") {
|
|
707
|
+
if (session.status !== "active") {
|
|
708
|
+
throw new TypeError(`${label} closes an inactive session`);
|
|
709
|
+
}
|
|
710
|
+
const host = session.members.get(session.hostPrincipalId);
|
|
711
|
+
if (
|
|
712
|
+
event.hostPrincipalId !== session.hostPrincipalId ||
|
|
713
|
+
!host ||
|
|
714
|
+
host.status !== "active" ||
|
|
715
|
+
event.expectedHostMembershipEpoch !== host.membershipEpoch
|
|
716
|
+
) {
|
|
717
|
+
throw new TypeError(`${label} was not closed by the host`);
|
|
718
|
+
}
|
|
719
|
+
const nextEpoch = String(
|
|
720
|
+
canonicalEpoch(event.sessionEpoch, `${label}.sessionEpoch`),
|
|
721
|
+
);
|
|
722
|
+
if (nextEpoch !== String(BigInt(session.sessionEpoch) + 1n)) {
|
|
723
|
+
throw new TypeError(`${label} does not advance the session epoch`);
|
|
724
|
+
}
|
|
725
|
+
const cancellable = [...session.leases.values()]
|
|
726
|
+
.filter(
|
|
727
|
+
(lease) => lease.status === "active" || lease.status === "acked",
|
|
728
|
+
)
|
|
729
|
+
.map((lease) => lease.leaseId)
|
|
730
|
+
.sort();
|
|
731
|
+
if (
|
|
732
|
+
canonicalJson(event.cancelledLeaseIds) !== canonicalJson(cancellable)
|
|
733
|
+
) {
|
|
734
|
+
throw new TypeError(`${label} does not cancel every session lease`);
|
|
735
|
+
}
|
|
736
|
+
session.status = "closed";
|
|
737
|
+
session.sessionEpoch = nextEpoch;
|
|
738
|
+
for (const leaseId of cancellable) {
|
|
739
|
+
const lease = session.leases.get(leaseId);
|
|
740
|
+
lease.status = "cancelled";
|
|
741
|
+
lease.cancelReason = "session-closed";
|
|
742
|
+
lease.terminalGeneration = event.generation;
|
|
743
|
+
}
|
|
744
|
+
} else if (event.type === "lease.created") {
|
|
745
|
+
if (session.status !== "active") {
|
|
746
|
+
throw new TypeError(`${label} creates a lease for a closed session`);
|
|
747
|
+
}
|
|
748
|
+
const leaseId = requiredString(event.leaseId, `${label}.leaseId`);
|
|
749
|
+
if (seenLeaseIds.has(leaseId) || session.leases.has(leaseId)) {
|
|
750
|
+
throw new TypeError(`${label} repeats a lease id`);
|
|
751
|
+
}
|
|
752
|
+
seenLeaseIds.add(leaseId);
|
|
753
|
+
const principalId = requiredString(
|
|
754
|
+
event.principalId,
|
|
755
|
+
`${label}.principalId`,
|
|
756
|
+
);
|
|
757
|
+
const member = session.members.get(principalId);
|
|
758
|
+
if (
|
|
759
|
+
!member ||
|
|
760
|
+
member.status !== "active" ||
|
|
761
|
+
member.membershipEpoch !== event.membershipEpoch ||
|
|
762
|
+
!member.scopes.includes("approve")
|
|
763
|
+
) {
|
|
764
|
+
throw new TypeError(`${label} is not bound to an active approver`);
|
|
765
|
+
}
|
|
766
|
+
if (event.hostPrincipalId !== session.hostPrincipalId) {
|
|
767
|
+
throw new TypeError(`${label} has the wrong host principal`);
|
|
768
|
+
}
|
|
769
|
+
const expiresAt = safeTimestamp(event.expiresAt, `${label}.expiresAt`);
|
|
770
|
+
if (expiresAt <= event.occurredAtMs || expiresAt > session.expiresAt) {
|
|
771
|
+
throw new TypeError(`${label}.expiresAt is outside the session`);
|
|
772
|
+
}
|
|
773
|
+
const requestId = requiredString(event.requestId, `${label}.requestId`);
|
|
774
|
+
const existingRequest = [...session.leases.values()].find(
|
|
775
|
+
(lease) => lease.requestId === requestId,
|
|
776
|
+
);
|
|
777
|
+
if (existingRequest) {
|
|
778
|
+
throw new TypeError(`${label} repeats an approval request`);
|
|
779
|
+
}
|
|
780
|
+
session.leases.set(leaseId, {
|
|
781
|
+
leaseId,
|
|
782
|
+
sessionId,
|
|
783
|
+
sessionEpoch: session.sessionEpoch,
|
|
784
|
+
principalId,
|
|
785
|
+
membershipEpoch: member.membershipEpoch,
|
|
786
|
+
hostPrincipalId: session.hostPrincipalId,
|
|
787
|
+
requestId,
|
|
788
|
+
fingerprint: requiredString(
|
|
789
|
+
event.fingerprint,
|
|
790
|
+
`${label}.fingerprint`,
|
|
791
|
+
),
|
|
792
|
+
binding: requiredString(event.binding, `${label}.binding`),
|
|
793
|
+
createdAt: event.occurredAtMs,
|
|
794
|
+
expiresAt,
|
|
795
|
+
createdGeneration: event.generation,
|
|
796
|
+
status: "active",
|
|
797
|
+
ackedGeneration: null,
|
|
798
|
+
consumedGeneration: null,
|
|
799
|
+
terminalGeneration: null,
|
|
800
|
+
cancelReason: null,
|
|
801
|
+
hostReceiptDigest: null,
|
|
802
|
+
});
|
|
803
|
+
} else {
|
|
804
|
+
const leaseId = requiredString(event.leaseId, `${label}.leaseId`);
|
|
805
|
+
const lease = session.leases.get(leaseId);
|
|
806
|
+
if (!lease) throw new TypeError(`${label} targets an unknown lease`);
|
|
807
|
+
if (event.type === "lease.acked") {
|
|
808
|
+
if (lease.status !== "active") {
|
|
809
|
+
throw new TypeError(`${label} targets a non-active lease`);
|
|
810
|
+
}
|
|
811
|
+
const host = session.members.get(session.hostPrincipalId);
|
|
812
|
+
if (
|
|
813
|
+
event.hostPrincipalId !== lease.hostPrincipalId ||
|
|
814
|
+
!host ||
|
|
815
|
+
host.status !== "active" ||
|
|
816
|
+
event.expectedHostMembershipEpoch !== host.membershipEpoch ||
|
|
817
|
+
event.expectedCreatedGeneration !== lease.createdGeneration ||
|
|
818
|
+
!DIGEST_RE.test(String(event.hostReceiptDigest))
|
|
819
|
+
) {
|
|
820
|
+
throw new TypeError(`${label} has an invalid host ACK binding`);
|
|
821
|
+
}
|
|
822
|
+
if (event.occurredAtMs >= lease.expiresAt) {
|
|
823
|
+
throw new TypeError(`${label} ACKs an expired lease`);
|
|
824
|
+
}
|
|
825
|
+
lease.status = "acked";
|
|
826
|
+
lease.ackedGeneration = event.generation;
|
|
827
|
+
lease.hostReceiptDigest = event.hostReceiptDigest;
|
|
828
|
+
} else if (event.type === "lease.consumed") {
|
|
829
|
+
if (lease.status !== "acked") {
|
|
830
|
+
throw new TypeError(`${label} targets a non-ACKed lease`);
|
|
831
|
+
}
|
|
832
|
+
const host = session.members.get(session.hostPrincipalId);
|
|
833
|
+
if (
|
|
834
|
+
event.hostPrincipalId !== lease.hostPrincipalId ||
|
|
835
|
+
!host ||
|
|
836
|
+
host.status !== "active" ||
|
|
837
|
+
event.expectedHostMembershipEpoch !== host.membershipEpoch ||
|
|
838
|
+
event.expectedAckedGeneration !== lease.ackedGeneration ||
|
|
839
|
+
event.expectedMembershipEpoch !== lease.membershipEpoch ||
|
|
840
|
+
event.requestId !== lease.requestId ||
|
|
841
|
+
event.fingerprint !== lease.fingerprint ||
|
|
842
|
+
event.binding !== lease.binding
|
|
843
|
+
) {
|
|
844
|
+
throw new TypeError(`${label} has a stale execution binding`);
|
|
845
|
+
}
|
|
846
|
+
if (event.occurredAtMs >= lease.expiresAt) {
|
|
847
|
+
throw new TypeError(`${label} consumes an expired lease`);
|
|
848
|
+
}
|
|
849
|
+
const member = session.members.get(lease.principalId);
|
|
850
|
+
if (
|
|
851
|
+
session.status !== "active" ||
|
|
852
|
+
!member ||
|
|
853
|
+
member.status !== "active" ||
|
|
854
|
+
member.membershipEpoch !== lease.membershipEpoch
|
|
855
|
+
) {
|
|
856
|
+
throw new TypeError(`${label} consumes after revocation`);
|
|
857
|
+
}
|
|
858
|
+
lease.status = "consumed";
|
|
859
|
+
lease.consumedGeneration = event.generation;
|
|
860
|
+
lease.terminalGeneration = event.generation;
|
|
861
|
+
} else {
|
|
862
|
+
if (lease.status !== "active" && lease.status !== "acked") {
|
|
863
|
+
throw new TypeError(`${label} targets a terminal lease`);
|
|
864
|
+
}
|
|
865
|
+
requiredString(event.reason, `${label}.reason`);
|
|
866
|
+
const host = session.members.get(session.hostPrincipalId);
|
|
867
|
+
if (
|
|
868
|
+
event.hostPrincipalId !== lease.hostPrincipalId ||
|
|
869
|
+
!host ||
|
|
870
|
+
host.status !== "active" ||
|
|
871
|
+
event.expectedHostMembershipEpoch !== host.membershipEpoch
|
|
872
|
+
) {
|
|
873
|
+
throw new TypeError(`${label} was not cancelled by the host`);
|
|
874
|
+
}
|
|
875
|
+
lease.status = "cancelled";
|
|
876
|
+
lease.cancelReason = event.reason;
|
|
877
|
+
lease.terminalGeneration = event.generation;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
generation = currentGeneration;
|
|
883
|
+
previousHash = event.eventHash;
|
|
884
|
+
lastOccurredAtMs = event.occurredAtMs;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
if (generation !== declaredGeneration || previousHash !== store.headHash) {
|
|
888
|
+
throw new TypeError(
|
|
889
|
+
"Remote membership coordinator head does not match events",
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
return {
|
|
893
|
+
generation,
|
|
894
|
+
headHash: previousHash,
|
|
895
|
+
events: [...store.events],
|
|
896
|
+
sessions,
|
|
897
|
+
lastOccurredAtMs,
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function loadOrCreateSigningKey(keyFile, lockOptions, lock) {
|
|
902
|
+
let result;
|
|
903
|
+
try {
|
|
904
|
+
result = mutateSecurityStore(
|
|
905
|
+
keyFile,
|
|
906
|
+
KEY_LABEL,
|
|
907
|
+
(draft) => {
|
|
908
|
+
if (Object.keys(draft).length === 0) {
|
|
909
|
+
const pair = generateKeyPairSync("ed25519", {
|
|
910
|
+
publicKeyEncoding: { type: "spki", format: "der" },
|
|
911
|
+
privateKeyEncoding: { type: "pkcs8", format: "der" },
|
|
912
|
+
});
|
|
913
|
+
draft.version = KEY_VERSION;
|
|
914
|
+
draft.algorithm = "ed25519";
|
|
915
|
+
draft.publicKeySpki = pair.publicKey.toString("base64url");
|
|
916
|
+
draft.privateKeyPkcs8 = pair.privateKey.toString("base64url");
|
|
917
|
+
}
|
|
918
|
+
if (
|
|
919
|
+
draft.version !== KEY_VERSION ||
|
|
920
|
+
draft.algorithm !== "ed25519" ||
|
|
921
|
+
typeof draft.publicKeySpki !== "string" ||
|
|
922
|
+
typeof draft.privateKeyPkcs8 !== "string"
|
|
923
|
+
) {
|
|
924
|
+
throw new TypeError("Remote membership signing key is invalid");
|
|
925
|
+
}
|
|
926
|
+
const publicDer = decodeBase64Url(
|
|
927
|
+
draft.publicKeySpki,
|
|
928
|
+
"signingKey.publicKeySpki",
|
|
929
|
+
4096,
|
|
930
|
+
);
|
|
931
|
+
const privateDer = decodeBase64Url(
|
|
932
|
+
draft.privateKeyPkcs8,
|
|
933
|
+
"signingKey.privateKeyPkcs8",
|
|
934
|
+
4096,
|
|
935
|
+
);
|
|
936
|
+
const publicKey = createPublicKey({
|
|
937
|
+
key: publicDer,
|
|
938
|
+
type: "spki",
|
|
939
|
+
format: "der",
|
|
940
|
+
});
|
|
941
|
+
const privateKey = createPrivateKey({
|
|
942
|
+
key: privateDer,
|
|
943
|
+
type: "pkcs8",
|
|
944
|
+
format: "der",
|
|
945
|
+
});
|
|
946
|
+
if (
|
|
947
|
+
publicKey.asymmetricKeyType !== "ed25519" ||
|
|
948
|
+
privateKey.asymmetricKeyType !== "ed25519" ||
|
|
949
|
+
!createPublicKey(privateKey)
|
|
950
|
+
.export({ type: "spki", format: "der" })
|
|
951
|
+
.equals(publicDer)
|
|
952
|
+
) {
|
|
953
|
+
throw new TypeError(
|
|
954
|
+
"Remote membership signing key pair does not match",
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
const keyDigest = sha256(publicDer);
|
|
958
|
+
draft.keyId = `ed25519:${keyDigest}`;
|
|
959
|
+
return {
|
|
960
|
+
publicDer,
|
|
961
|
+
publicKey,
|
|
962
|
+
privateKey,
|
|
963
|
+
keyDigest,
|
|
964
|
+
keyId: draft.keyId,
|
|
965
|
+
};
|
|
966
|
+
},
|
|
967
|
+
{ ...lockOptions, lock },
|
|
968
|
+
);
|
|
969
|
+
} catch (cause) {
|
|
970
|
+
throw unavailable(cause, keyFile);
|
|
971
|
+
}
|
|
972
|
+
return result;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function membershipVerdict(session, binding, requiredScope, now) {
|
|
976
|
+
if (!session) return { ok: false, reason: "membership-session-unknown" };
|
|
977
|
+
if (session.status !== "active") {
|
|
978
|
+
return { ok: false, reason: "membership-session-closed" };
|
|
979
|
+
}
|
|
980
|
+
if (session.expiresAt <= now) {
|
|
981
|
+
return { ok: false, reason: "membership-session-expired" };
|
|
982
|
+
}
|
|
983
|
+
if (session.sessionEpoch !== binding.sessionEpoch) {
|
|
984
|
+
return { ok: false, reason: "stale-session-epoch" };
|
|
985
|
+
}
|
|
986
|
+
const member = session.members.get(binding.principalId);
|
|
987
|
+
if (!member) return { ok: false, reason: "membership-unknown" };
|
|
988
|
+
if (member.status !== "active") {
|
|
989
|
+
return { ok: false, reason: "membership-revoked" };
|
|
990
|
+
}
|
|
991
|
+
if (member.membershipEpoch !== binding.membershipEpoch) {
|
|
992
|
+
return { ok: false, reason: "stale-membership-epoch" };
|
|
993
|
+
}
|
|
994
|
+
if (requiredScope && !member.scopes.includes(requiredScope)) {
|
|
995
|
+
return { ok: false, reason: "membership-scope-denied" };
|
|
996
|
+
}
|
|
997
|
+
return {
|
|
998
|
+
ok: true,
|
|
999
|
+
reason: null,
|
|
1000
|
+
binding: Object.freeze({
|
|
1001
|
+
authorityVersion: REMOTE_MEMBERSHIP_COORDINATOR_VERSION,
|
|
1002
|
+
sessionId: session.sessionId,
|
|
1003
|
+
sessionEpoch: session.sessionEpoch,
|
|
1004
|
+
principalId: member.principalId,
|
|
1005
|
+
membershipEpoch: member.membershipEpoch,
|
|
1006
|
+
scopes: Object.freeze([...member.scopes]),
|
|
1007
|
+
}),
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
export function getRemoteMembershipCoordinatorPaths() {
|
|
1012
|
+
const root = path.join(getMachineSecurityAnchorDir(), "remote-membership-v2");
|
|
1013
|
+
return Object.freeze({
|
|
1014
|
+
stateFile: path.join(root, "coordinator.json"),
|
|
1015
|
+
keyFile: path.join(root, "coordinator-ed25519.json"),
|
|
1016
|
+
witnessFile: path.join(root, "coordinator-witness.json"),
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
export class DurableRemoteMembershipCoordinator {
|
|
1021
|
+
constructor({
|
|
1022
|
+
stateFile,
|
|
1023
|
+
keyFile,
|
|
1024
|
+
witnessFile,
|
|
1025
|
+
authorityLockFile = null,
|
|
1026
|
+
now = () => Date.now(),
|
|
1027
|
+
createId = randomUUID,
|
|
1028
|
+
createSecret = () => randomBytes(32).toString("base64url"),
|
|
1029
|
+
defaultLeaseTtlMs = DEFAULT_LEASE_TTL_MS,
|
|
1030
|
+
maxLeaseTtlMs = MAX_LEASE_TTL_MS,
|
|
1031
|
+
defaultChallengeTtlMs = DEFAULT_CHALLENGE_TTL_MS,
|
|
1032
|
+
maxChallengeTtlMs = MAX_CHALLENGE_TTL_MS,
|
|
1033
|
+
lockOptions,
|
|
1034
|
+
lock = withFileLock,
|
|
1035
|
+
faultHooks = null,
|
|
1036
|
+
} = {}) {
|
|
1037
|
+
const defaults = getRemoteMembershipCoordinatorPaths();
|
|
1038
|
+
this.stateFile = path.resolve(stateFile || defaults.stateFile);
|
|
1039
|
+
this.keyFile = path.resolve(keyFile || defaults.keyFile);
|
|
1040
|
+
this.witnessFile = path.resolve(witnessFile || defaults.witnessFile);
|
|
1041
|
+
this.authorityLockFile = path.resolve(
|
|
1042
|
+
authorityLockFile || `${this.stateFile}.authority`,
|
|
1043
|
+
);
|
|
1044
|
+
this._now = now;
|
|
1045
|
+
this._createId = createId;
|
|
1046
|
+
this._createSecret = createSecret;
|
|
1047
|
+
this._defaultLeaseTtlMs = Math.max(1, Number(defaultLeaseTtlMs));
|
|
1048
|
+
this._maxLeaseTtlMs = Math.max(
|
|
1049
|
+
this._defaultLeaseTtlMs,
|
|
1050
|
+
Number(maxLeaseTtlMs),
|
|
1051
|
+
);
|
|
1052
|
+
this._defaultChallengeTtlMs = Math.max(1, Number(defaultChallengeTtlMs));
|
|
1053
|
+
this._maxChallengeTtlMs = Math.max(
|
|
1054
|
+
this._defaultChallengeTtlMs,
|
|
1055
|
+
Number(maxChallengeTtlMs),
|
|
1056
|
+
);
|
|
1057
|
+
this._lockOptions = lockOptions;
|
|
1058
|
+
this._lock = lock;
|
|
1059
|
+
this._faultHooks = faultHooks || {};
|
|
1060
|
+
this._quarantined = false;
|
|
1061
|
+
this._serverInstanceId = `server-${requiredString(
|
|
1062
|
+
this._createSecret(),
|
|
1063
|
+
"serverInstanceId",
|
|
1064
|
+
4096,
|
|
1065
|
+
)}`;
|
|
1066
|
+
this._authenticationChallenges = new Map();
|
|
1067
|
+
fs.mkdirSync(path.dirname(this.authorityLockFile), {
|
|
1068
|
+
recursive: true,
|
|
1069
|
+
mode: 0o700,
|
|
1070
|
+
});
|
|
1071
|
+
this._key = this._withAuthorityLock(() =>
|
|
1072
|
+
loadOrCreateSigningKey(this.keyFile, this._lockOptions, this._lock),
|
|
1073
|
+
);
|
|
1074
|
+
this.coordinatorId = this._key.keyId;
|
|
1075
|
+
this._observedGeneration = 0n;
|
|
1076
|
+
this._observedAuthorityTimeMs = 0;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
_withAuthorityLock(task) {
|
|
1080
|
+
if (this._quarantined) {
|
|
1081
|
+
throw coordinatorError(
|
|
1082
|
+
REMOTE_MEMBERSHIP_COORDINATOR_UNAVAILABLE_CODE,
|
|
1083
|
+
"Remote membership coordinator is quarantined after an unknown commit; restart and reconcile before reuse",
|
|
1084
|
+
null,
|
|
1085
|
+
{ filePath: this.stateFile, commitState: "unknown" },
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
return this._lock(this.authorityLockFile, task, {
|
|
1089
|
+
timeoutMs: 30_000,
|
|
1090
|
+
staleMs: 30_000,
|
|
1091
|
+
failIfUnavailable: true,
|
|
1092
|
+
...this._lockOptions,
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
_authorityNow(snapshot, label = "coordinator clock") {
|
|
1097
|
+
const now = safeTimestamp(this._now(), label);
|
|
1098
|
+
const durableAtMs = Math.max(
|
|
1099
|
+
snapshot.lastOccurredAtMs,
|
|
1100
|
+
this._observedAuthorityTimeMs,
|
|
1101
|
+
);
|
|
1102
|
+
if (now < durableAtMs) {
|
|
1103
|
+
throw rollback("authority clock moved backward", {
|
|
1104
|
+
observedAtMs: now,
|
|
1105
|
+
durableAtMs,
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
this._observedAuthorityTimeMs = now;
|
|
1109
|
+
return now;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
trustDescriptor() {
|
|
1113
|
+
return Object.freeze({
|
|
1114
|
+
authorityVersion: REMOTE_MEMBERSHIP_COORDINATOR_VERSION,
|
|
1115
|
+
algorithm: "ed25519",
|
|
1116
|
+
coordinatorId: this.coordinatorId,
|
|
1117
|
+
keyId: this._key.keyId,
|
|
1118
|
+
publicKeySpki: this._key.publicDer.toString("base64url"),
|
|
1119
|
+
publicKeySha256: this._key.keyDigest,
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
relayAuthorityDescriptor() {
|
|
1124
|
+
return Object.freeze({
|
|
1125
|
+
authorityVersion: REMOTE_MEMBERSHIP_COORDINATOR_VERSION,
|
|
1126
|
+
coordinatorId: this.coordinatorId,
|
|
1127
|
+
serverInstanceId: this._serverInstanceId,
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
_observe(snapshot) {
|
|
1132
|
+
if (snapshot.generation < this._observedGeneration) {
|
|
1133
|
+
throw rollback("live coordinator generation moved backward", {
|
|
1134
|
+
observedGeneration: String(snapshot.generation),
|
|
1135
|
+
currentGeneration: String(this._observedGeneration),
|
|
1136
|
+
filePath: this.stateFile,
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
this._observedGeneration = snapshot.generation;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
_validateWitness(witness) {
|
|
1143
|
+
if (
|
|
1144
|
+
!hasExactKeys(witness, [
|
|
1145
|
+
"coordinatorId",
|
|
1146
|
+
"generation",
|
|
1147
|
+
"headHash",
|
|
1148
|
+
"pending",
|
|
1149
|
+
"version",
|
|
1150
|
+
]) ||
|
|
1151
|
+
witness.version !== WITNESS_VERSION ||
|
|
1152
|
+
witness.coordinatorId !== this.coordinatorId ||
|
|
1153
|
+
!NON_NEGATIVE_RE.test(String(witness.generation)) ||
|
|
1154
|
+
(witness.headHash !== null && !DIGEST_RE.test(String(witness.headHash)))
|
|
1155
|
+
) {
|
|
1156
|
+
throw new TypeError("Remote membership witness is invalid");
|
|
1157
|
+
}
|
|
1158
|
+
if (witness.pending !== null) {
|
|
1159
|
+
if (
|
|
1160
|
+
!hasExactKeys(witness.pending, [
|
|
1161
|
+
"generation",
|
|
1162
|
+
"headHash",
|
|
1163
|
+
"previousGeneration",
|
|
1164
|
+
"previousHeadHash",
|
|
1165
|
+
]) ||
|
|
1166
|
+
!POSITIVE_RE.test(String(witness.pending.generation)) ||
|
|
1167
|
+
!NON_NEGATIVE_RE.test(String(witness.pending.previousGeneration)) ||
|
|
1168
|
+
!DIGEST_RE.test(String(witness.pending.headHash)) ||
|
|
1169
|
+
(witness.pending.previousHeadHash !== null &&
|
|
1170
|
+
!DIGEST_RE.test(String(witness.pending.previousHeadHash))) ||
|
|
1171
|
+
BigInt(witness.pending.generation) !==
|
|
1172
|
+
BigInt(witness.generation) + 1n ||
|
|
1173
|
+
witness.pending.previousGeneration !== witness.generation ||
|
|
1174
|
+
witness.pending.previousHeadHash !== witness.headHash
|
|
1175
|
+
) {
|
|
1176
|
+
throw new TypeError(
|
|
1177
|
+
"Remote membership witness pending commit is invalid",
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
return witness;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
_writeInitialWitness() {
|
|
1185
|
+
mutateSecurityStore(
|
|
1186
|
+
this.witnessFile,
|
|
1187
|
+
WITNESS_LABEL,
|
|
1188
|
+
(draft) => {
|
|
1189
|
+
if (Object.keys(draft).length !== 0) {
|
|
1190
|
+
this._validateWitness(draft);
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
draft.version = WITNESS_VERSION;
|
|
1194
|
+
draft.coordinatorId = this.coordinatorId;
|
|
1195
|
+
draft.generation = "0";
|
|
1196
|
+
draft.headHash = null;
|
|
1197
|
+
draft.pending = null;
|
|
1198
|
+
},
|
|
1199
|
+
{ ...this._lockOptions, lock: this._lock },
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
_reconcileWitness(snapshot) {
|
|
1204
|
+
const filePath = this.witnessFile;
|
|
1205
|
+
try {
|
|
1206
|
+
let witness = readSecurityStore(filePath, WITNESS_LABEL);
|
|
1207
|
+
if (Object.keys(witness).length === 0) {
|
|
1208
|
+
if (snapshot.generation !== 0n || snapshot.headHash !== null) {
|
|
1209
|
+
throw rollback("durable coordinator state has no rollback witness", {
|
|
1210
|
+
observedGeneration: String(snapshot.generation),
|
|
1211
|
+
filePath: this.stateFile,
|
|
1212
|
+
witnessFile: filePath,
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
this._writeInitialWitness();
|
|
1216
|
+
witness = readSecurityStore(filePath, WITNESS_LABEL);
|
|
1217
|
+
}
|
|
1218
|
+
this._validateWitness(witness);
|
|
1219
|
+
const committedMatches =
|
|
1220
|
+
BigInt(witness.generation) === snapshot.generation &&
|
|
1221
|
+
witness.headHash === snapshot.headHash;
|
|
1222
|
+
const pendingMatches =
|
|
1223
|
+
witness.pending !== null &&
|
|
1224
|
+
BigInt(witness.pending.generation) === snapshot.generation &&
|
|
1225
|
+
witness.pending.headHash === snapshot.headHash;
|
|
1226
|
+
if (!committedMatches && !pendingMatches) {
|
|
1227
|
+
throw rollback(
|
|
1228
|
+
"state and witness do not describe one recoverable commit",
|
|
1229
|
+
{
|
|
1230
|
+
witnessedGeneration: witness.generation,
|
|
1231
|
+
pendingGeneration: witness.pending?.generation || null,
|
|
1232
|
+
observedGeneration: String(snapshot.generation),
|
|
1233
|
+
filePath: this.stateFile,
|
|
1234
|
+
witnessFile: filePath,
|
|
1235
|
+
},
|
|
1236
|
+
);
|
|
1237
|
+
}
|
|
1238
|
+
if (witness.pending !== null) {
|
|
1239
|
+
mutateSecurityStore(
|
|
1240
|
+
filePath,
|
|
1241
|
+
WITNESS_LABEL,
|
|
1242
|
+
(draft) => {
|
|
1243
|
+
this._validateWitness(draft);
|
|
1244
|
+
if (canonicalJson(draft) !== canonicalJson(witness)) {
|
|
1245
|
+
throw rollback("coordinator witness changed during recovery");
|
|
1246
|
+
}
|
|
1247
|
+
if (pendingMatches) {
|
|
1248
|
+
draft.generation = witness.pending.generation;
|
|
1249
|
+
draft.headHash = witness.pending.headHash;
|
|
1250
|
+
}
|
|
1251
|
+
draft.pending = null;
|
|
1252
|
+
},
|
|
1253
|
+
{ ...this._lockOptions, lock: this._lock },
|
|
1254
|
+
);
|
|
1255
|
+
}
|
|
1256
|
+
} catch (cause) {
|
|
1257
|
+
if (cause?.code === REMOTE_MEMBERSHIP_COORDINATOR_ROLLBACK_CODE) {
|
|
1258
|
+
throw cause;
|
|
1259
|
+
}
|
|
1260
|
+
throw unavailable(cause, filePath);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
_prepareWitness(previous, next) {
|
|
1265
|
+
try {
|
|
1266
|
+
mutateSecurityStore(
|
|
1267
|
+
this.witnessFile,
|
|
1268
|
+
WITNESS_LABEL,
|
|
1269
|
+
(draft) => {
|
|
1270
|
+
this._validateWitness(draft);
|
|
1271
|
+
if (
|
|
1272
|
+
draft.pending !== null ||
|
|
1273
|
+
BigInt(draft.generation) !== previous.generation ||
|
|
1274
|
+
draft.headHash !== previous.headHash ||
|
|
1275
|
+
next.generation !== previous.generation + 1n
|
|
1276
|
+
) {
|
|
1277
|
+
throw rollback(
|
|
1278
|
+
"coordinator witness cannot prepare the next commit",
|
|
1279
|
+
);
|
|
1280
|
+
}
|
|
1281
|
+
draft.pending = {
|
|
1282
|
+
generation: String(next.generation),
|
|
1283
|
+
headHash: next.headHash,
|
|
1284
|
+
previousGeneration: String(previous.generation),
|
|
1285
|
+
previousHeadHash: previous.headHash,
|
|
1286
|
+
};
|
|
1287
|
+
},
|
|
1288
|
+
{ ...this._lockOptions, lock: this._lock },
|
|
1289
|
+
);
|
|
1290
|
+
} catch (cause) {
|
|
1291
|
+
if (cause?.code === REMOTE_MEMBERSHIP_COORDINATOR_ROLLBACK_CODE) {
|
|
1292
|
+
throw cause;
|
|
1293
|
+
}
|
|
1294
|
+
throw unavailable(cause, this.witnessFile);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
_commitWitness(snapshot) {
|
|
1299
|
+
try {
|
|
1300
|
+
mutateSecurityStore(
|
|
1301
|
+
this.witnessFile,
|
|
1302
|
+
WITNESS_LABEL,
|
|
1303
|
+
(draft) => {
|
|
1304
|
+
this._validateWitness(draft);
|
|
1305
|
+
if (
|
|
1306
|
+
draft.pending === null ||
|
|
1307
|
+
BigInt(draft.pending.generation) !== snapshot.generation ||
|
|
1308
|
+
draft.pending.headHash !== snapshot.headHash
|
|
1309
|
+
) {
|
|
1310
|
+
throw rollback("coordinator witness lost its prepared commit");
|
|
1311
|
+
}
|
|
1312
|
+
draft.generation = draft.pending.generation;
|
|
1313
|
+
draft.headHash = draft.pending.headHash;
|
|
1314
|
+
draft.pending = null;
|
|
1315
|
+
},
|
|
1316
|
+
{ ...this._lockOptions, lock: this._lock },
|
|
1317
|
+
);
|
|
1318
|
+
} catch (cause) {
|
|
1319
|
+
if (cause?.code === REMOTE_MEMBERSHIP_COORDINATOR_ROLLBACK_CODE) {
|
|
1320
|
+
throw cause;
|
|
1321
|
+
}
|
|
1322
|
+
throw unavailable(cause, this.witnessFile);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
_readLocked() {
|
|
1327
|
+
try {
|
|
1328
|
+
const existed = fs.existsSync(this.stateFile);
|
|
1329
|
+
const snapshot = replayStore(
|
|
1330
|
+
readSecurityStore(this.stateFile, STORE_LABEL),
|
|
1331
|
+
this.coordinatorId,
|
|
1332
|
+
{ missing: !existed },
|
|
1333
|
+
);
|
|
1334
|
+
this._observe(snapshot);
|
|
1335
|
+
this._reconcileWitness(snapshot);
|
|
1336
|
+
return snapshot;
|
|
1337
|
+
} catch (cause) {
|
|
1338
|
+
throw unavailable(cause, this.stateFile);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
_read() {
|
|
1343
|
+
try {
|
|
1344
|
+
return this._withAuthorityLock(() => this._readLocked());
|
|
1345
|
+
} catch (cause) {
|
|
1346
|
+
throw unavailable(cause, this.stateFile);
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
_withRead(task) {
|
|
1351
|
+
try {
|
|
1352
|
+
return this._withAuthorityLock(() => {
|
|
1353
|
+
const snapshot = this._readLocked();
|
|
1354
|
+
const now = this._authorityNow(snapshot);
|
|
1355
|
+
return task(snapshot, now);
|
|
1356
|
+
});
|
|
1357
|
+
} catch (cause) {
|
|
1358
|
+
throw unavailable(cause, this.stateFile);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
_mutate(buildEvent) {
|
|
1363
|
+
let prepared = false;
|
|
1364
|
+
try {
|
|
1365
|
+
return this._withAuthorityLock(() => {
|
|
1366
|
+
const previous = this._readLocked();
|
|
1367
|
+
const now = this._authorityNow(previous);
|
|
1368
|
+
const generation = previous.generation + 1n;
|
|
1369
|
+
const event = {
|
|
1370
|
+
schema: EVENT_SCHEMA,
|
|
1371
|
+
eventId: requiredString(this._createId(), "eventId"),
|
|
1372
|
+
generation: String(generation),
|
|
1373
|
+
previousGeneration: String(previous.generation),
|
|
1374
|
+
previousEventHash: previous.headHash,
|
|
1375
|
+
occurredAtMs: now,
|
|
1376
|
+
...buildEvent(previous, String(generation), now),
|
|
1377
|
+
};
|
|
1378
|
+
event.eventHash = eventHash(event);
|
|
1379
|
+
const nextStore = {
|
|
1380
|
+
version: STORE_VERSION,
|
|
1381
|
+
coordinatorId: this.coordinatorId,
|
|
1382
|
+
generation: String(generation),
|
|
1383
|
+
headHash: event.eventHash,
|
|
1384
|
+
events: [...previous.events, event],
|
|
1385
|
+
};
|
|
1386
|
+
const verified = replayStore(nextStore, this.coordinatorId);
|
|
1387
|
+
this._prepareWitness(previous, verified);
|
|
1388
|
+
prepared = true;
|
|
1389
|
+
const result = mutateSecurityStore(
|
|
1390
|
+
this.stateFile,
|
|
1391
|
+
STORE_LABEL,
|
|
1392
|
+
(draft) => {
|
|
1393
|
+
const current = replayStore(draft, this.coordinatorId, {
|
|
1394
|
+
missing: !fs.existsSync(this.stateFile),
|
|
1395
|
+
});
|
|
1396
|
+
if (
|
|
1397
|
+
current.generation !== previous.generation ||
|
|
1398
|
+
current.headHash !== previous.headHash
|
|
1399
|
+
) {
|
|
1400
|
+
throw rollback(
|
|
1401
|
+
"coordinator state changed outside its authority lock",
|
|
1402
|
+
);
|
|
1403
|
+
}
|
|
1404
|
+
for (const key of Object.keys(draft)) delete draft[key];
|
|
1405
|
+
Object.assign(draft, structuredClone(nextStore));
|
|
1406
|
+
return { snapshot: verified, event };
|
|
1407
|
+
},
|
|
1408
|
+
{ ...this._lockOptions, lock: this._lock },
|
|
1409
|
+
);
|
|
1410
|
+
this._faultHooks.afterStateCommit?.({
|
|
1411
|
+
event: structuredClone(result.event),
|
|
1412
|
+
stateFile: this.stateFile,
|
|
1413
|
+
witnessFile: this.witnessFile,
|
|
1414
|
+
});
|
|
1415
|
+
this._observe(result.snapshot);
|
|
1416
|
+
this._commitWitness(result.snapshot);
|
|
1417
|
+
prepared = false;
|
|
1418
|
+
return result;
|
|
1419
|
+
});
|
|
1420
|
+
} catch (cause) {
|
|
1421
|
+
if (prepared) {
|
|
1422
|
+
this._quarantined = true;
|
|
1423
|
+
cause.commitState = "unknown";
|
|
1424
|
+
}
|
|
1425
|
+
throw unavailable(cause, this.stateFile);
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
_statement(kind, payload, generation, issuedAtMs) {
|
|
1430
|
+
const core = {
|
|
1431
|
+
schema: REMOTE_MEMBERSHIP_STATEMENT_SCHEMA,
|
|
1432
|
+
authorityVersion: REMOTE_MEMBERSHIP_COORDINATOR_VERSION,
|
|
1433
|
+
algorithm: "ed25519",
|
|
1434
|
+
coordinatorId: this.coordinatorId,
|
|
1435
|
+
keyId: this._key.keyId,
|
|
1436
|
+
generation: String(generation),
|
|
1437
|
+
issuedAtMs: safeTimestamp(issuedAtMs, "statement clock"),
|
|
1438
|
+
kind,
|
|
1439
|
+
payload,
|
|
1440
|
+
};
|
|
1441
|
+
const signature = sign(
|
|
1442
|
+
null,
|
|
1443
|
+
Buffer.from(canonicalJson(core), "utf8"),
|
|
1444
|
+
this._key.privateKey,
|
|
1445
|
+
).toString("base64url");
|
|
1446
|
+
return Object.freeze({ ...core, signature });
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
_snapshotStatement(session, generation, issuedAtMs) {
|
|
1450
|
+
return this._statement(
|
|
1451
|
+
"session.snapshot",
|
|
1452
|
+
publicSessionSnapshot(session, generation),
|
|
1453
|
+
generation,
|
|
1454
|
+
issuedAtMs,
|
|
1455
|
+
);
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
_newAuthenticationChallenge({
|
|
1459
|
+
purpose,
|
|
1460
|
+
session,
|
|
1461
|
+
member,
|
|
1462
|
+
credential,
|
|
1463
|
+
scopes = null,
|
|
1464
|
+
connectionNonce,
|
|
1465
|
+
now,
|
|
1466
|
+
ttlMs = null,
|
|
1467
|
+
}) {
|
|
1468
|
+
const requestedTtl =
|
|
1469
|
+
ttlMs === null
|
|
1470
|
+
? this._defaultChallengeTtlMs
|
|
1471
|
+
: safeTimestamp(ttlMs, "challengeTtlMs");
|
|
1472
|
+
if (requestedTtl <= 0) {
|
|
1473
|
+
throw new TypeError("challengeTtlMs must be positive");
|
|
1474
|
+
}
|
|
1475
|
+
const expiresAtMs = Math.min(
|
|
1476
|
+
now + Math.min(requestedTtl, this._maxChallengeTtlMs),
|
|
1477
|
+
session.expiresAt,
|
|
1478
|
+
);
|
|
1479
|
+
if (expiresAtMs <= now) {
|
|
1480
|
+
throw new Error("Remote membership authentication challenge expired");
|
|
1481
|
+
}
|
|
1482
|
+
for (const [id, prior] of this._authenticationChallenges) {
|
|
1483
|
+
if (prior.expiresAtMs <= now) this._authenticationChallenges.delete(id);
|
|
1484
|
+
}
|
|
1485
|
+
const challengeId = `challenge-${requiredString(
|
|
1486
|
+
this._createId(),
|
|
1487
|
+
"challengeId",
|
|
1488
|
+
)}`;
|
|
1489
|
+
if (this._authenticationChallenges.has(challengeId)) {
|
|
1490
|
+
throw new Error("Remote membership authentication challenge id collided");
|
|
1491
|
+
}
|
|
1492
|
+
const challenge = Object.freeze({
|
|
1493
|
+
schema: AUTHENTICATION_CHALLENGE_SCHEMA,
|
|
1494
|
+
authorityVersion: REMOTE_MEMBERSHIP_COORDINATOR_VERSION,
|
|
1495
|
+
coordinatorId: this.coordinatorId,
|
|
1496
|
+
serverInstanceId: this._serverInstanceId,
|
|
1497
|
+
challengeId,
|
|
1498
|
+
purpose,
|
|
1499
|
+
sessionId: session.sessionId,
|
|
1500
|
+
sessionEpoch: session.sessionEpoch,
|
|
1501
|
+
principalId: credential.principalId,
|
|
1502
|
+
membershipEpoch: member.membershipEpoch,
|
|
1503
|
+
credentialType: credential.type,
|
|
1504
|
+
credentialPublicKey: credential.publicKey,
|
|
1505
|
+
credentialKeySha256: credential.digest,
|
|
1506
|
+
connectionNonce: requiredString(connectionNonce, "connectionNonce", 4096),
|
|
1507
|
+
serverNonce: requiredString(this._createSecret(), "serverNonce", 4096),
|
|
1508
|
+
scopes: scopes === null ? null : Object.freeze([...scopes]),
|
|
1509
|
+
issuedAtMs: now,
|
|
1510
|
+
expiresAtMs,
|
|
1511
|
+
});
|
|
1512
|
+
this._authenticationChallenges.set(challengeId, challenge);
|
|
1513
|
+
return challenge;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
issueMemberJoinChallenge({
|
|
1517
|
+
sessionId,
|
|
1518
|
+
expectedSessionEpoch,
|
|
1519
|
+
scopes,
|
|
1520
|
+
credentialPublicKey,
|
|
1521
|
+
connectionNonce,
|
|
1522
|
+
ttlMs = null,
|
|
1523
|
+
} = {}) {
|
|
1524
|
+
const normalizedSessionId = requiredString(sessionId, "sessionId");
|
|
1525
|
+
const sessionEpoch = String(
|
|
1526
|
+
canonicalEpoch(expectedSessionEpoch, "expectedSessionEpoch"),
|
|
1527
|
+
);
|
|
1528
|
+
const normalizedScopes = normalizeScopes(scopes);
|
|
1529
|
+
const credential = normalizePrincipalCredential(
|
|
1530
|
+
{ type: "ed25519", publicKey: credentialPublicKey },
|
|
1531
|
+
"credential",
|
|
1532
|
+
);
|
|
1533
|
+
return this._withRead((snapshot, now) => {
|
|
1534
|
+
const session = snapshot.sessions.get(normalizedSessionId);
|
|
1535
|
+
if (
|
|
1536
|
+
!session ||
|
|
1537
|
+
session.status !== "active" ||
|
|
1538
|
+
session.sessionEpoch !== sessionEpoch ||
|
|
1539
|
+
session.expiresAt <= now
|
|
1540
|
+
) {
|
|
1541
|
+
throw new Error("Remote membership join challenge denied");
|
|
1542
|
+
}
|
|
1543
|
+
const prior = session.members.get(credential.principalId) || null;
|
|
1544
|
+
if (prior?.status === "active") {
|
|
1545
|
+
throw new Error("Remote membership principal is already active");
|
|
1546
|
+
}
|
|
1547
|
+
return this._newAuthenticationChallenge({
|
|
1548
|
+
purpose: "member.join",
|
|
1549
|
+
session,
|
|
1550
|
+
member: {
|
|
1551
|
+
membershipEpoch: prior
|
|
1552
|
+
? String(BigInt(prior.membershipEpoch) + 1n)
|
|
1553
|
+
: "1",
|
|
1554
|
+
},
|
|
1555
|
+
credential,
|
|
1556
|
+
scopes: normalizedScopes,
|
|
1557
|
+
connectionNonce,
|
|
1558
|
+
now,
|
|
1559
|
+
ttlMs,
|
|
1560
|
+
});
|
|
1561
|
+
});
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
issueSessionResumeChallenge({
|
|
1565
|
+
sessionId,
|
|
1566
|
+
principalId,
|
|
1567
|
+
connectionNonce,
|
|
1568
|
+
ttlMs = null,
|
|
1569
|
+
} = {}) {
|
|
1570
|
+
const normalizedSessionId = requiredString(sessionId, "sessionId");
|
|
1571
|
+
const normalizedPrincipal = requiredString(principalId, "principalId");
|
|
1572
|
+
return this._withRead((snapshot, now) => {
|
|
1573
|
+
const session = snapshot.sessions.get(normalizedSessionId);
|
|
1574
|
+
const member = session?.members.get(normalizedPrincipal);
|
|
1575
|
+
if (
|
|
1576
|
+
!session ||
|
|
1577
|
+
session.status !== "active" ||
|
|
1578
|
+
session.expiresAt <= now ||
|
|
1579
|
+
!member ||
|
|
1580
|
+
member.status !== "active" ||
|
|
1581
|
+
member.credentialType !== "ed25519"
|
|
1582
|
+
) {
|
|
1583
|
+
throw new Error("Remote membership resume challenge denied");
|
|
1584
|
+
}
|
|
1585
|
+
return this._newAuthenticationChallenge({
|
|
1586
|
+
purpose: "session.resume",
|
|
1587
|
+
session,
|
|
1588
|
+
member,
|
|
1589
|
+
credential: normalizePrincipalCredential(
|
|
1590
|
+
{
|
|
1591
|
+
type: member.credentialType,
|
|
1592
|
+
publicKey: member.credentialPublicKey,
|
|
1593
|
+
},
|
|
1594
|
+
"memberCredential",
|
|
1595
|
+
),
|
|
1596
|
+
connectionNonce,
|
|
1597
|
+
now,
|
|
1598
|
+
ttlMs,
|
|
1599
|
+
});
|
|
1600
|
+
});
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
_consumeAuthenticationChallenge(
|
|
1604
|
+
snapshot,
|
|
1605
|
+
now,
|
|
1606
|
+
{ challengeId, connectionNonce, signature },
|
|
1607
|
+
expectedPurpose,
|
|
1608
|
+
) {
|
|
1609
|
+
const normalizedId = requiredString(challengeId, "challengeId");
|
|
1610
|
+
const challenge = this._authenticationChallenges.get(normalizedId);
|
|
1611
|
+
// Delete before any validation. A malformed signature, stale epoch, or
|
|
1612
|
+
// failed state commit consumes the nonce and therefore fails closed.
|
|
1613
|
+
this._authenticationChallenges.delete(normalizedId);
|
|
1614
|
+
if (
|
|
1615
|
+
!challenge ||
|
|
1616
|
+
challenge.purpose !== expectedPurpose ||
|
|
1617
|
+
challenge.connectionNonce !==
|
|
1618
|
+
requiredString(connectionNonce, "connectionNonce", 4096) ||
|
|
1619
|
+
now < challenge.issuedAtMs ||
|
|
1620
|
+
challenge.expiresAtMs <= now
|
|
1621
|
+
) {
|
|
1622
|
+
throw new Error("Remote membership authentication challenge denied");
|
|
1623
|
+
}
|
|
1624
|
+
const session = snapshot.sessions.get(challenge.sessionId);
|
|
1625
|
+
if (
|
|
1626
|
+
!session ||
|
|
1627
|
+
session.status !== "active" ||
|
|
1628
|
+
session.expiresAt <= now ||
|
|
1629
|
+
session.sessionEpoch !== challenge.sessionEpoch
|
|
1630
|
+
) {
|
|
1631
|
+
throw new Error("Remote membership authentication session is stale");
|
|
1632
|
+
}
|
|
1633
|
+
const current = session.members.get(challenge.principalId) || null;
|
|
1634
|
+
if (expectedPurpose === "member.join") {
|
|
1635
|
+
if (
|
|
1636
|
+
current?.status === "active" ||
|
|
1637
|
+
challenge.membershipEpoch !==
|
|
1638
|
+
(current ? String(BigInt(current.membershipEpoch) + 1n) : "1")
|
|
1639
|
+
) {
|
|
1640
|
+
throw new Error("Remote membership join challenge is stale");
|
|
1641
|
+
}
|
|
1642
|
+
} else if (
|
|
1643
|
+
!current ||
|
|
1644
|
+
current.status !== "active" ||
|
|
1645
|
+
current.membershipEpoch !== challenge.membershipEpoch ||
|
|
1646
|
+
current.credentialDigest !== challenge.credentialKeySha256
|
|
1647
|
+
) {
|
|
1648
|
+
throw new Error("Remote membership resume challenge is stale");
|
|
1649
|
+
}
|
|
1650
|
+
const credential = normalizePrincipalCredential(
|
|
1651
|
+
{
|
|
1652
|
+
type: challenge.credentialType,
|
|
1653
|
+
publicKey: challenge.credentialPublicKey,
|
|
1654
|
+
},
|
|
1655
|
+
"challengeCredential",
|
|
1656
|
+
);
|
|
1657
|
+
const signatureBytes = decodeBase64Url(signature, "signature", 256);
|
|
1658
|
+
if (
|
|
1659
|
+
credential.type !== "ed25519" ||
|
|
1660
|
+
signatureBytes.length !== 64 ||
|
|
1661
|
+
!verify(
|
|
1662
|
+
null,
|
|
1663
|
+
authenticationChallengeBytes(challenge),
|
|
1664
|
+
createPublicKey({
|
|
1665
|
+
key: decodeBase64Url(
|
|
1666
|
+
credential.publicKey,
|
|
1667
|
+
"challengeCredential.publicKey",
|
|
1668
|
+
4096,
|
|
1669
|
+
),
|
|
1670
|
+
type: "spki",
|
|
1671
|
+
format: "der",
|
|
1672
|
+
}),
|
|
1673
|
+
signatureBytes,
|
|
1674
|
+
)
|
|
1675
|
+
) {
|
|
1676
|
+
throw new Error("Remote membership possession proof is invalid");
|
|
1677
|
+
}
|
|
1678
|
+
return { challenge, session, current, credential };
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
createSession({
|
|
1682
|
+
sessionId,
|
|
1683
|
+
agentSessionId,
|
|
1684
|
+
scopes,
|
|
1685
|
+
expiresAt,
|
|
1686
|
+
hostPrincipalId = null,
|
|
1687
|
+
hostCredentialPublicKeySpki,
|
|
1688
|
+
} = {}) {
|
|
1689
|
+
const normalizedSessionId = requiredString(sessionId, "sessionId");
|
|
1690
|
+
const normalizedAgent = requiredString(agentSessionId, "agentSessionId");
|
|
1691
|
+
const normalizedScopes = normalizeScopes(scopes);
|
|
1692
|
+
const normalizedExpiresAt = safeTimestamp(expiresAt, "expiresAt");
|
|
1693
|
+
const hostCredential = normalizePrincipalCredential(
|
|
1694
|
+
{
|
|
1695
|
+
type: "ed25519",
|
|
1696
|
+
publicKey: hostCredentialPublicKeySpki,
|
|
1697
|
+
},
|
|
1698
|
+
"hostCredential",
|
|
1699
|
+
);
|
|
1700
|
+
const normalizedHost = hostPrincipalId
|
|
1701
|
+
? requiredString(hostPrincipalId, "hostPrincipalId")
|
|
1702
|
+
: hostCredential.principalId;
|
|
1703
|
+
if (normalizedHost !== hostCredential.principalId) {
|
|
1704
|
+
throw new TypeError(
|
|
1705
|
+
"hostPrincipalId must be derived from its Ed25519 key",
|
|
1706
|
+
);
|
|
1707
|
+
}
|
|
1708
|
+
const result = this._mutate((snapshot, _generation, now) => {
|
|
1709
|
+
if (snapshot.sessions.has(normalizedSessionId)) {
|
|
1710
|
+
throw new Error("Remote membership session already exists");
|
|
1711
|
+
}
|
|
1712
|
+
if (normalizedExpiresAt <= now) {
|
|
1713
|
+
throw new TypeError("expiresAt must be in the future");
|
|
1714
|
+
}
|
|
1715
|
+
return {
|
|
1716
|
+
type: "session.created",
|
|
1717
|
+
sessionId: normalizedSessionId,
|
|
1718
|
+
agentSessionId: normalizedAgent,
|
|
1719
|
+
hostPrincipalId: normalizedHost,
|
|
1720
|
+
hostCredentialType: hostCredential.type,
|
|
1721
|
+
hostCredentialPublicKey: hostCredential.publicKey,
|
|
1722
|
+
hostCredentialDigest: hostCredential.digest,
|
|
1723
|
+
sessionEpoch: "1",
|
|
1724
|
+
membershipEpoch: "1",
|
|
1725
|
+
scopes: normalizedScopes,
|
|
1726
|
+
expiresAt: normalizedExpiresAt,
|
|
1727
|
+
};
|
|
1728
|
+
});
|
|
1729
|
+
const session = result.snapshot.sessions.get(normalizedSessionId);
|
|
1730
|
+
return Object.freeze({
|
|
1731
|
+
authorityVersion: REMOTE_MEMBERSHIP_COORDINATOR_VERSION,
|
|
1732
|
+
trust: this.trustDescriptor(),
|
|
1733
|
+
sessionId: normalizedSessionId,
|
|
1734
|
+
sessionEpoch: session.sessionEpoch,
|
|
1735
|
+
hostPrincipalId: normalizedHost,
|
|
1736
|
+
membershipEpoch: session.members.get(normalizedHost).membershipEpoch,
|
|
1737
|
+
hostCredentialPublicKeySpki: hostCredential.publicKey,
|
|
1738
|
+
statement: this._snapshotStatement(
|
|
1739
|
+
session,
|
|
1740
|
+
result.snapshot.generation,
|
|
1741
|
+
result.event.occurredAtMs,
|
|
1742
|
+
),
|
|
1743
|
+
});
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
resumeSession({ challengeId, connectionNonce, signature } = {}) {
|
|
1747
|
+
try {
|
|
1748
|
+
return this._withAuthorityLock(() => {
|
|
1749
|
+
const snapshot = this._readLocked();
|
|
1750
|
+
const now = this._authorityNow(snapshot);
|
|
1751
|
+
const authenticated = this._consumeAuthenticationChallenge(
|
|
1752
|
+
snapshot,
|
|
1753
|
+
now,
|
|
1754
|
+
{ challengeId, connectionNonce, signature },
|
|
1755
|
+
"session.resume",
|
|
1756
|
+
);
|
|
1757
|
+
const session = authenticated.session;
|
|
1758
|
+
return Object.freeze({
|
|
1759
|
+
authorityVersion: REMOTE_MEMBERSHIP_COORDINATOR_VERSION,
|
|
1760
|
+
trust: this.trustDescriptor(),
|
|
1761
|
+
session: publicSessionSnapshot(session, snapshot.generation),
|
|
1762
|
+
principalId: authenticated.challenge.principalId,
|
|
1763
|
+
membershipEpoch: authenticated.challenge.membershipEpoch,
|
|
1764
|
+
nextConnectionNonce: requiredString(
|
|
1765
|
+
this._createSecret(),
|
|
1766
|
+
"nextConnectionNonce",
|
|
1767
|
+
4096,
|
|
1768
|
+
),
|
|
1769
|
+
statement: this._snapshotStatement(session, snapshot.generation, now),
|
|
1770
|
+
});
|
|
1771
|
+
});
|
|
1772
|
+
} catch (cause) {
|
|
1773
|
+
throw unavailable(cause, this.stateFile);
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
joinMember({ challengeId, connectionNonce, signature } = {}) {
|
|
1778
|
+
const result = this._mutate((snapshot, _generation, now) => {
|
|
1779
|
+
const authenticated = this._consumeAuthenticationChallenge(
|
|
1780
|
+
snapshot,
|
|
1781
|
+
now,
|
|
1782
|
+
{ challengeId, connectionNonce, signature },
|
|
1783
|
+
"member.join",
|
|
1784
|
+
);
|
|
1785
|
+
const { challenge, session, current: prior, credential } = authenticated;
|
|
1786
|
+
if (session.hostPrincipalId === challenge.principalId) {
|
|
1787
|
+
throw new Error("Cannot replace the host membership");
|
|
1788
|
+
}
|
|
1789
|
+
return {
|
|
1790
|
+
type: "member.joined",
|
|
1791
|
+
sessionId: session.sessionId,
|
|
1792
|
+
expectedSessionEpoch: challenge.sessionEpoch,
|
|
1793
|
+
principalId: challenge.principalId,
|
|
1794
|
+
previousMembershipEpoch: prior?.membershipEpoch || null,
|
|
1795
|
+
membershipEpoch: challenge.membershipEpoch,
|
|
1796
|
+
credentialType: credential.type,
|
|
1797
|
+
credentialPublicKey: credential.publicKey,
|
|
1798
|
+
credentialDigest: credential.digest,
|
|
1799
|
+
scopes: challenge.scopes,
|
|
1800
|
+
};
|
|
1801
|
+
});
|
|
1802
|
+
const session = result.snapshot.sessions.get(result.event.sessionId);
|
|
1803
|
+
const member = session.members.get(result.event.principalId);
|
|
1804
|
+
return Object.freeze({
|
|
1805
|
+
authorityVersion: REMOTE_MEMBERSHIP_COORDINATOR_VERSION,
|
|
1806
|
+
principalId: member.principalId,
|
|
1807
|
+
credentialType: member.credentialType,
|
|
1808
|
+
credentialPublicKey: member.credentialPublicKey,
|
|
1809
|
+
sessionEpoch: session.sessionEpoch,
|
|
1810
|
+
membershipEpoch: member.membershipEpoch,
|
|
1811
|
+
nextConnectionNonce: requiredString(
|
|
1812
|
+
this._createSecret(),
|
|
1813
|
+
"nextConnectionNonce",
|
|
1814
|
+
4096,
|
|
1815
|
+
),
|
|
1816
|
+
statement: this._snapshotStatement(
|
|
1817
|
+
session,
|
|
1818
|
+
result.snapshot.generation,
|
|
1819
|
+
result.event.occurredAtMs,
|
|
1820
|
+
),
|
|
1821
|
+
});
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
joinRelayMember({
|
|
1825
|
+
sessionId,
|
|
1826
|
+
expectedSessionEpoch,
|
|
1827
|
+
scopes,
|
|
1828
|
+
mobilePeerId,
|
|
1829
|
+
mobilePublicKey,
|
|
1830
|
+
pairingTokenDigest,
|
|
1831
|
+
possessionCapability,
|
|
1832
|
+
} = {}) {
|
|
1833
|
+
const normalizedSessionId = requiredString(sessionId, "sessionId");
|
|
1834
|
+
const sessionEpoch = String(
|
|
1835
|
+
canonicalEpoch(expectedSessionEpoch, "expectedSessionEpoch"),
|
|
1836
|
+
);
|
|
1837
|
+
const normalizedScopes = normalizeScopes(scopes);
|
|
1838
|
+
const normalizedPeer = requiredString(mobilePeerId, "mobilePeerId");
|
|
1839
|
+
if (!DIGEST_RE.test(String(pairingTokenDigest))) {
|
|
1840
|
+
throw new TypeError("pairingTokenDigest must be a SHA-256 digest");
|
|
1841
|
+
}
|
|
1842
|
+
const credential = normalizePrincipalCredential(
|
|
1843
|
+
{ type: "relay-x25519", publicKey: mobilePublicKey },
|
|
1844
|
+
"relayCredential",
|
|
1845
|
+
);
|
|
1846
|
+
const result = this._mutate((snapshot, _generation, now) => {
|
|
1847
|
+
const proof = consumeRemoteRelayPossessionCapability(
|
|
1848
|
+
possessionCapability,
|
|
1849
|
+
{
|
|
1850
|
+
sessionId: normalizedSessionId,
|
|
1851
|
+
mobilePeerId: normalizedPeer,
|
|
1852
|
+
mobilePublicKey: credential.publicKey,
|
|
1853
|
+
pairingTokenDigest,
|
|
1854
|
+
...this.relayAuthorityDescriptor(),
|
|
1855
|
+
},
|
|
1856
|
+
);
|
|
1857
|
+
const session = snapshot.sessions.get(normalizedSessionId);
|
|
1858
|
+
if (
|
|
1859
|
+
!session ||
|
|
1860
|
+
session.status !== "active" ||
|
|
1861
|
+
session.sessionEpoch !== sessionEpoch
|
|
1862
|
+
) {
|
|
1863
|
+
throw new Error("Remote relay membership join denied");
|
|
1864
|
+
}
|
|
1865
|
+
const prior = session.members.get(credential.principalId) || null;
|
|
1866
|
+
if (prior?.status === "active") {
|
|
1867
|
+
throw new Error("Remote relay principal is already active");
|
|
1868
|
+
}
|
|
1869
|
+
if (
|
|
1870
|
+
proof.envelopeSequence <= 0 ||
|
|
1871
|
+
!DIGEST_RE.test(proof.envelopeTranscriptHash) ||
|
|
1872
|
+
proof.pairingExpiresAtMs <= now ||
|
|
1873
|
+
canonicalJson(proof.authorizedScopes) !==
|
|
1874
|
+
canonicalJson(normalizeScopes(proof.authorizedScopes)) ||
|
|
1875
|
+
normalizedScopes.some(
|
|
1876
|
+
(scope) => !proof.authorizedScopes.includes(scope),
|
|
1877
|
+
)
|
|
1878
|
+
) {
|
|
1879
|
+
throw new Error(
|
|
1880
|
+
"Remote relay transcript proof is malformed, expired, or over-scoped",
|
|
1881
|
+
);
|
|
1882
|
+
}
|
|
1883
|
+
return {
|
|
1884
|
+
type: "member.joined",
|
|
1885
|
+
sessionId: normalizedSessionId,
|
|
1886
|
+
expectedSessionEpoch: sessionEpoch,
|
|
1887
|
+
principalId: credential.principalId,
|
|
1888
|
+
previousMembershipEpoch: prior?.membershipEpoch || null,
|
|
1889
|
+
membershipEpoch: prior
|
|
1890
|
+
? String(BigInt(prior.membershipEpoch) + 1n)
|
|
1891
|
+
: "1",
|
|
1892
|
+
credentialType: credential.type,
|
|
1893
|
+
credentialPublicKey: credential.publicKey,
|
|
1894
|
+
credentialDigest: credential.digest,
|
|
1895
|
+
scopes: normalizedScopes,
|
|
1896
|
+
};
|
|
1897
|
+
});
|
|
1898
|
+
const session = result.snapshot.sessions.get(normalizedSessionId);
|
|
1899
|
+
const member = session.members.get(result.event.principalId);
|
|
1900
|
+
return Object.freeze({
|
|
1901
|
+
authorityVersion: REMOTE_MEMBERSHIP_COORDINATOR_VERSION,
|
|
1902
|
+
principalId: member.principalId,
|
|
1903
|
+
credentialType: member.credentialType,
|
|
1904
|
+
credentialPublicKey: member.credentialPublicKey,
|
|
1905
|
+
sessionEpoch: session.sessionEpoch,
|
|
1906
|
+
membershipEpoch: member.membershipEpoch,
|
|
1907
|
+
statement: this._snapshotStatement(
|
|
1908
|
+
session,
|
|
1909
|
+
result.snapshot.generation,
|
|
1910
|
+
result.event.occurredAtMs,
|
|
1911
|
+
),
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
readMembership(binding, requiredScope = null) {
|
|
1916
|
+
let normalized;
|
|
1917
|
+
try {
|
|
1918
|
+
normalized = {
|
|
1919
|
+
sessionId: requiredString(binding?.sessionId, "sessionId"),
|
|
1920
|
+
sessionEpoch: String(
|
|
1921
|
+
canonicalEpoch(binding?.sessionEpoch, "sessionEpoch"),
|
|
1922
|
+
),
|
|
1923
|
+
principalId: requiredString(binding?.principalId, "principalId"),
|
|
1924
|
+
membershipEpoch: String(
|
|
1925
|
+
canonicalEpoch(binding?.membershipEpoch, "membershipEpoch"),
|
|
1926
|
+
),
|
|
1927
|
+
};
|
|
1928
|
+
} catch {
|
|
1929
|
+
return { ok: false, reason: "membership-binding-required" };
|
|
1930
|
+
}
|
|
1931
|
+
return this._withRead((snapshot, now) =>
|
|
1932
|
+
membershipVerdict(
|
|
1933
|
+
snapshot.sessions.get(normalized.sessionId),
|
|
1934
|
+
normalized,
|
|
1935
|
+
requiredScope,
|
|
1936
|
+
now,
|
|
1937
|
+
),
|
|
1938
|
+
);
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
revokeMember({
|
|
1942
|
+
sessionId,
|
|
1943
|
+
principalId,
|
|
1944
|
+
hostPrincipalId,
|
|
1945
|
+
expectedSessionEpoch,
|
|
1946
|
+
expectedMembershipEpoch,
|
|
1947
|
+
expectedHostMembershipEpoch,
|
|
1948
|
+
} = {}) {
|
|
1949
|
+
const normalizedSessionId = requiredString(sessionId, "sessionId");
|
|
1950
|
+
const normalizedPrincipal = requiredString(principalId, "principalId");
|
|
1951
|
+
const sessionEpoch = String(
|
|
1952
|
+
canonicalEpoch(expectedSessionEpoch, "expectedSessionEpoch"),
|
|
1953
|
+
);
|
|
1954
|
+
const membershipEpoch = String(
|
|
1955
|
+
canonicalEpoch(expectedMembershipEpoch, "expectedMembershipEpoch"),
|
|
1956
|
+
);
|
|
1957
|
+
const normalizedHost = requiredString(hostPrincipalId, "hostPrincipalId");
|
|
1958
|
+
const hostMembershipEpoch = String(
|
|
1959
|
+
canonicalEpoch(
|
|
1960
|
+
expectedHostMembershipEpoch,
|
|
1961
|
+
"expectedHostMembershipEpoch",
|
|
1962
|
+
),
|
|
1963
|
+
);
|
|
1964
|
+
const result = this._mutate((snapshot, _generation, now) => {
|
|
1965
|
+
const session = snapshot.sessions.get(normalizedSessionId);
|
|
1966
|
+
const verdict = membershipVerdict(
|
|
1967
|
+
session,
|
|
1968
|
+
{
|
|
1969
|
+
sessionId: normalizedSessionId,
|
|
1970
|
+
sessionEpoch,
|
|
1971
|
+
principalId: normalizedPrincipal,
|
|
1972
|
+
membershipEpoch,
|
|
1973
|
+
},
|
|
1974
|
+
null,
|
|
1975
|
+
now,
|
|
1976
|
+
);
|
|
1977
|
+
const host = session?.members.get(normalizedHost);
|
|
1978
|
+
if (
|
|
1979
|
+
!verdict.ok ||
|
|
1980
|
+
normalizedPrincipal === session.hostPrincipalId ||
|
|
1981
|
+
normalizedHost !== session.hostPrincipalId ||
|
|
1982
|
+
!host ||
|
|
1983
|
+
host.status !== "active" ||
|
|
1984
|
+
host.membershipEpoch !== hostMembershipEpoch
|
|
1985
|
+
) {
|
|
1986
|
+
throw new Error(`Remote membership revoke denied: ${verdict.reason}`);
|
|
1987
|
+
}
|
|
1988
|
+
const cancelledLeaseIds = [...session.leases.values()]
|
|
1989
|
+
.filter(
|
|
1990
|
+
(lease) =>
|
|
1991
|
+
lease.principalId === normalizedPrincipal &&
|
|
1992
|
+
(lease.status === "active" || lease.status === "acked"),
|
|
1993
|
+
)
|
|
1994
|
+
.map((lease) => lease.leaseId)
|
|
1995
|
+
.sort();
|
|
1996
|
+
return {
|
|
1997
|
+
type: "member.revoked",
|
|
1998
|
+
sessionId: normalizedSessionId,
|
|
1999
|
+
expectedSessionEpoch: sessionEpoch,
|
|
2000
|
+
hostPrincipalId: normalizedHost,
|
|
2001
|
+
expectedHostMembershipEpoch: hostMembershipEpoch,
|
|
2002
|
+
principalId: normalizedPrincipal,
|
|
2003
|
+
expectedMembershipEpoch: membershipEpoch,
|
|
2004
|
+
membershipEpoch: String(BigInt(membershipEpoch) + 1n),
|
|
2005
|
+
cancelledLeaseIds,
|
|
2006
|
+
};
|
|
2007
|
+
});
|
|
2008
|
+
const session = result.snapshot.sessions.get(normalizedSessionId);
|
|
2009
|
+
return Object.freeze({
|
|
2010
|
+
authorityVersion: REMOTE_MEMBERSHIP_COORDINATOR_VERSION,
|
|
2011
|
+
revokedMembershipEpoch:
|
|
2012
|
+
session.members.get(normalizedPrincipal).membershipEpoch,
|
|
2013
|
+
cancelledLeaseIds: Object.freeze([...result.event.cancelledLeaseIds]),
|
|
2014
|
+
statement: this._snapshotStatement(
|
|
2015
|
+
session,
|
|
2016
|
+
result.snapshot.generation,
|
|
2017
|
+
result.event.occurredAtMs,
|
|
2018
|
+
),
|
|
2019
|
+
});
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
closeSession({
|
|
2023
|
+
sessionId,
|
|
2024
|
+
hostPrincipalId,
|
|
2025
|
+
expectedSessionEpoch,
|
|
2026
|
+
expectedHostMembershipEpoch,
|
|
2027
|
+
} = {}) {
|
|
2028
|
+
const normalizedSessionId = requiredString(sessionId, "sessionId");
|
|
2029
|
+
const normalizedHost = requiredString(hostPrincipalId, "hostPrincipalId");
|
|
2030
|
+
const sessionEpoch = String(
|
|
2031
|
+
canonicalEpoch(expectedSessionEpoch, "expectedSessionEpoch"),
|
|
2032
|
+
);
|
|
2033
|
+
const hostMembershipEpoch = String(
|
|
2034
|
+
canonicalEpoch(
|
|
2035
|
+
expectedHostMembershipEpoch,
|
|
2036
|
+
"expectedHostMembershipEpoch",
|
|
2037
|
+
),
|
|
2038
|
+
);
|
|
2039
|
+
const result = this._mutate((snapshot) => {
|
|
2040
|
+
const session = snapshot.sessions.get(normalizedSessionId);
|
|
2041
|
+
const host = session?.members.get(normalizedHost);
|
|
2042
|
+
if (
|
|
2043
|
+
!session ||
|
|
2044
|
+
session.status !== "active" ||
|
|
2045
|
+
session.hostPrincipalId !== normalizedHost ||
|
|
2046
|
+
session.sessionEpoch !== sessionEpoch ||
|
|
2047
|
+
!host ||
|
|
2048
|
+
host.status !== "active" ||
|
|
2049
|
+
host.membershipEpoch !== hostMembershipEpoch
|
|
2050
|
+
) {
|
|
2051
|
+
throw new Error("Remote membership session close denied");
|
|
2052
|
+
}
|
|
2053
|
+
const cancelledLeaseIds = [...session.leases.values()]
|
|
2054
|
+
.filter(
|
|
2055
|
+
(lease) => lease.status === "active" || lease.status === "acked",
|
|
2056
|
+
)
|
|
2057
|
+
.map((lease) => lease.leaseId)
|
|
2058
|
+
.sort();
|
|
2059
|
+
return {
|
|
2060
|
+
type: "session.closed",
|
|
2061
|
+
sessionId: normalizedSessionId,
|
|
2062
|
+
expectedSessionEpoch: sessionEpoch,
|
|
2063
|
+
hostPrincipalId: normalizedHost,
|
|
2064
|
+
expectedHostMembershipEpoch: hostMembershipEpoch,
|
|
2065
|
+
sessionEpoch: String(BigInt(sessionEpoch) + 1n),
|
|
2066
|
+
cancelledLeaseIds,
|
|
2067
|
+
};
|
|
2068
|
+
});
|
|
2069
|
+
const session = result.snapshot.sessions.get(normalizedSessionId);
|
|
2070
|
+
return Object.freeze({
|
|
2071
|
+
authorityVersion: REMOTE_MEMBERSHIP_COORDINATOR_VERSION,
|
|
2072
|
+
sessionEpoch: session.sessionEpoch,
|
|
2073
|
+
cancelledLeaseIds: Object.freeze([...result.event.cancelledLeaseIds]),
|
|
2074
|
+
statement: this._snapshotStatement(
|
|
2075
|
+
session,
|
|
2076
|
+
result.snapshot.generation,
|
|
2077
|
+
result.event.occurredAtMs,
|
|
2078
|
+
),
|
|
2079
|
+
});
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
createApprovalLease({
|
|
2083
|
+
sessionId,
|
|
2084
|
+
sessionEpoch,
|
|
2085
|
+
principalId,
|
|
2086
|
+
membershipEpoch,
|
|
2087
|
+
hostPrincipalId,
|
|
2088
|
+
requestId,
|
|
2089
|
+
fingerprint,
|
|
2090
|
+
binding,
|
|
2091
|
+
expiresAt = null,
|
|
2092
|
+
} = {}) {
|
|
2093
|
+
const normalized = {
|
|
2094
|
+
sessionId: requiredString(sessionId, "sessionId"),
|
|
2095
|
+
sessionEpoch: String(canonicalEpoch(sessionEpoch, "sessionEpoch")),
|
|
2096
|
+
principalId: requiredString(principalId, "principalId"),
|
|
2097
|
+
membershipEpoch: String(
|
|
2098
|
+
canonicalEpoch(membershipEpoch, "membershipEpoch"),
|
|
2099
|
+
),
|
|
2100
|
+
hostPrincipalId: requiredString(hostPrincipalId, "hostPrincipalId"),
|
|
2101
|
+
requestId: requiredString(requestId, "requestId"),
|
|
2102
|
+
fingerprint: requiredString(fingerprint, "fingerprint"),
|
|
2103
|
+
binding: requiredString(binding, "binding"),
|
|
2104
|
+
};
|
|
2105
|
+
const requestedExpiry =
|
|
2106
|
+
expiresAt == null ? null : safeTimestamp(expiresAt, "expiresAt");
|
|
2107
|
+
const result = this._mutate((snapshot, _generation, now) => {
|
|
2108
|
+
const boundedExpiry = Math.min(
|
|
2109
|
+
requestedExpiry == null
|
|
2110
|
+
? now + this._defaultLeaseTtlMs
|
|
2111
|
+
: requestedExpiry,
|
|
2112
|
+
now + this._maxLeaseTtlMs,
|
|
2113
|
+
);
|
|
2114
|
+
const session = snapshot.sessions.get(normalized.sessionId);
|
|
2115
|
+
const verdict = membershipVerdict(session, normalized, "approve", now);
|
|
2116
|
+
if (
|
|
2117
|
+
!verdict.ok ||
|
|
2118
|
+
session.hostPrincipalId !== normalized.hostPrincipalId
|
|
2119
|
+
) {
|
|
2120
|
+
throw new Error(`Remote approval lease denied: ${verdict.reason}`);
|
|
2121
|
+
}
|
|
2122
|
+
if (boundedExpiry <= now)
|
|
2123
|
+
throw new Error("Remote approval lease expired");
|
|
2124
|
+
const prior = [...session.leases.values()].find(
|
|
2125
|
+
(lease) => lease.requestId === normalized.requestId,
|
|
2126
|
+
);
|
|
2127
|
+
if (prior) {
|
|
2128
|
+
const same =
|
|
2129
|
+
prior.principalId === normalized.principalId &&
|
|
2130
|
+
prior.membershipEpoch === normalized.membershipEpoch &&
|
|
2131
|
+
prior.sessionEpoch === normalized.sessionEpoch &&
|
|
2132
|
+
prior.hostPrincipalId === normalized.hostPrincipalId &&
|
|
2133
|
+
prior.fingerprint === normalized.fingerprint &&
|
|
2134
|
+
prior.binding === normalized.binding;
|
|
2135
|
+
if (!same) throw new Error("Approval request lease binding changed");
|
|
2136
|
+
throw new Error("Approval request already has an execution lease");
|
|
2137
|
+
}
|
|
2138
|
+
return {
|
|
2139
|
+
type: "lease.created",
|
|
2140
|
+
sessionId: normalized.sessionId,
|
|
2141
|
+
expectedSessionEpoch: normalized.sessionEpoch,
|
|
2142
|
+
leaseId: `lease-${this._createId()}`,
|
|
2143
|
+
principalId: normalized.principalId,
|
|
2144
|
+
membershipEpoch: normalized.membershipEpoch,
|
|
2145
|
+
hostPrincipalId: normalized.hostPrincipalId,
|
|
2146
|
+
requestId: normalized.requestId,
|
|
2147
|
+
fingerprint: normalized.fingerprint,
|
|
2148
|
+
binding: normalized.binding,
|
|
2149
|
+
expiresAt: Math.min(boundedExpiry, session.expiresAt),
|
|
2150
|
+
};
|
|
2151
|
+
});
|
|
2152
|
+
const session = result.snapshot.sessions.get(normalized.sessionId);
|
|
2153
|
+
const lease = session.leases.get(result.event.leaseId);
|
|
2154
|
+
return Object.freeze({
|
|
2155
|
+
authorityVersion: REMOTE_MEMBERSHIP_COORDINATOR_VERSION,
|
|
2156
|
+
lease: publicLease(lease),
|
|
2157
|
+
statement: this._statement(
|
|
2158
|
+
"lease.created",
|
|
2159
|
+
publicLease(lease),
|
|
2160
|
+
result.snapshot.generation,
|
|
2161
|
+
result.event.occurredAtMs,
|
|
2162
|
+
),
|
|
2163
|
+
});
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
ackApprovalLease({
|
|
2167
|
+
sessionId,
|
|
2168
|
+
leaseId,
|
|
2169
|
+
hostPrincipalId,
|
|
2170
|
+
expectedHostMembershipEpoch,
|
|
2171
|
+
expectedCreatedGeneration,
|
|
2172
|
+
hostReceiptDigest,
|
|
2173
|
+
} = {}) {
|
|
2174
|
+
const normalizedSessionId = requiredString(sessionId, "sessionId");
|
|
2175
|
+
const normalizedLeaseId = requiredString(leaseId, "leaseId");
|
|
2176
|
+
const normalizedHost = requiredString(hostPrincipalId, "hostPrincipalId");
|
|
2177
|
+
const createdGeneration = String(
|
|
2178
|
+
canonicalEpoch(expectedCreatedGeneration, "expectedCreatedGeneration"),
|
|
2179
|
+
);
|
|
2180
|
+
const hostMembershipEpoch = String(
|
|
2181
|
+
canonicalEpoch(
|
|
2182
|
+
expectedHostMembershipEpoch,
|
|
2183
|
+
"expectedHostMembershipEpoch",
|
|
2184
|
+
),
|
|
2185
|
+
);
|
|
2186
|
+
if (!DIGEST_RE.test(String(hostReceiptDigest))) {
|
|
2187
|
+
throw new TypeError("hostReceiptDigest must be a SHA-256 digest");
|
|
2188
|
+
}
|
|
2189
|
+
const result = this._mutate((snapshot, _generation, now) => {
|
|
2190
|
+
const session = snapshot.sessions.get(normalizedSessionId);
|
|
2191
|
+
const lease = session?.leases.get(normalizedLeaseId);
|
|
2192
|
+
const host = session?.members.get(normalizedHost);
|
|
2193
|
+
if (
|
|
2194
|
+
!session ||
|
|
2195
|
+
session.status !== "active" ||
|
|
2196
|
+
!lease ||
|
|
2197
|
+
lease.status !== "active" ||
|
|
2198
|
+
lease.hostPrincipalId !== normalizedHost ||
|
|
2199
|
+
!host ||
|
|
2200
|
+
host.status !== "active" ||
|
|
2201
|
+
host.membershipEpoch !== hostMembershipEpoch ||
|
|
2202
|
+
lease.createdGeneration !== createdGeneration ||
|
|
2203
|
+
lease.expiresAt <= now
|
|
2204
|
+
) {
|
|
2205
|
+
throw new Error("Remote approval lease ACK denied");
|
|
2206
|
+
}
|
|
2207
|
+
const member = session.members.get(lease.principalId);
|
|
2208
|
+
if (
|
|
2209
|
+
!member ||
|
|
2210
|
+
member.status !== "active" ||
|
|
2211
|
+
member.membershipEpoch !== lease.membershipEpoch
|
|
2212
|
+
) {
|
|
2213
|
+
throw new Error("Remote approval lease was revoked before ACK");
|
|
2214
|
+
}
|
|
2215
|
+
return {
|
|
2216
|
+
type: "lease.acked",
|
|
2217
|
+
sessionId: normalizedSessionId,
|
|
2218
|
+
expectedSessionEpoch: session.sessionEpoch,
|
|
2219
|
+
leaseId: normalizedLeaseId,
|
|
2220
|
+
hostPrincipalId: normalizedHost,
|
|
2221
|
+
expectedHostMembershipEpoch: hostMembershipEpoch,
|
|
2222
|
+
expectedCreatedGeneration: createdGeneration,
|
|
2223
|
+
hostReceiptDigest,
|
|
2224
|
+
};
|
|
2225
|
+
});
|
|
2226
|
+
const lease = result.snapshot.sessions
|
|
2227
|
+
.get(normalizedSessionId)
|
|
2228
|
+
.leases.get(normalizedLeaseId);
|
|
2229
|
+
return Object.freeze({
|
|
2230
|
+
authorityVersion: REMOTE_MEMBERSHIP_COORDINATOR_VERSION,
|
|
2231
|
+
lease: publicLease(lease),
|
|
2232
|
+
statement: this._statement(
|
|
2233
|
+
"lease.acked",
|
|
2234
|
+
publicLease(lease),
|
|
2235
|
+
result.snapshot.generation,
|
|
2236
|
+
result.event.occurredAtMs,
|
|
2237
|
+
),
|
|
2238
|
+
});
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
consumeApprovalLease({
|
|
2242
|
+
sessionId,
|
|
2243
|
+
leaseId,
|
|
2244
|
+
hostPrincipalId,
|
|
2245
|
+
expectedHostMembershipEpoch,
|
|
2246
|
+
expectedAckedGeneration,
|
|
2247
|
+
expectedMembershipEpoch,
|
|
2248
|
+
requestId,
|
|
2249
|
+
fingerprint,
|
|
2250
|
+
binding,
|
|
2251
|
+
} = {}) {
|
|
2252
|
+
const normalized = {
|
|
2253
|
+
sessionId: requiredString(sessionId, "sessionId"),
|
|
2254
|
+
leaseId: requiredString(leaseId, "leaseId"),
|
|
2255
|
+
hostPrincipalId: requiredString(hostPrincipalId, "hostPrincipalId"),
|
|
2256
|
+
expectedHostMembershipEpoch: String(
|
|
2257
|
+
canonicalEpoch(
|
|
2258
|
+
expectedHostMembershipEpoch,
|
|
2259
|
+
"expectedHostMembershipEpoch",
|
|
2260
|
+
),
|
|
2261
|
+
),
|
|
2262
|
+
expectedAckedGeneration: String(
|
|
2263
|
+
canonicalEpoch(expectedAckedGeneration, "expectedAckedGeneration"),
|
|
2264
|
+
),
|
|
2265
|
+
expectedMembershipEpoch: String(
|
|
2266
|
+
canonicalEpoch(expectedMembershipEpoch, "expectedMembershipEpoch"),
|
|
2267
|
+
),
|
|
2268
|
+
requestId: requiredString(requestId, "requestId"),
|
|
2269
|
+
fingerprint: requiredString(fingerprint, "fingerprint"),
|
|
2270
|
+
binding: requiredString(binding, "binding"),
|
|
2271
|
+
};
|
|
2272
|
+
const result = this._mutate((snapshot, _generation, now) => {
|
|
2273
|
+
const session = snapshot.sessions.get(normalized.sessionId);
|
|
2274
|
+
const lease = session?.leases.get(normalized.leaseId);
|
|
2275
|
+
const host = session?.members.get(normalized.hostPrincipalId);
|
|
2276
|
+
if (
|
|
2277
|
+
!session ||
|
|
2278
|
+
session.status !== "active" ||
|
|
2279
|
+
!lease ||
|
|
2280
|
+
lease.status !== "acked" ||
|
|
2281
|
+
lease.hostPrincipalId !== normalized.hostPrincipalId ||
|
|
2282
|
+
!host ||
|
|
2283
|
+
host.status !== "active" ||
|
|
2284
|
+
host.membershipEpoch !== normalized.expectedHostMembershipEpoch ||
|
|
2285
|
+
lease.ackedGeneration !== normalized.expectedAckedGeneration ||
|
|
2286
|
+
lease.membershipEpoch !== normalized.expectedMembershipEpoch ||
|
|
2287
|
+
lease.requestId !== normalized.requestId ||
|
|
2288
|
+
lease.fingerprint !== normalized.fingerprint ||
|
|
2289
|
+
lease.binding !== normalized.binding ||
|
|
2290
|
+
lease.expiresAt <= now
|
|
2291
|
+
) {
|
|
2292
|
+
throw new Error("Remote approval execution lease consume denied");
|
|
2293
|
+
}
|
|
2294
|
+
const member = session.members.get(lease.principalId);
|
|
2295
|
+
if (
|
|
2296
|
+
!member ||
|
|
2297
|
+
member.status !== "active" ||
|
|
2298
|
+
member.membershipEpoch !== lease.membershipEpoch
|
|
2299
|
+
) {
|
|
2300
|
+
throw new Error("Remote approval execution lease was revoked");
|
|
2301
|
+
}
|
|
2302
|
+
return {
|
|
2303
|
+
type: "lease.consumed",
|
|
2304
|
+
sessionId: normalized.sessionId,
|
|
2305
|
+
expectedSessionEpoch: session.sessionEpoch,
|
|
2306
|
+
leaseId: normalized.leaseId,
|
|
2307
|
+
hostPrincipalId: normalized.hostPrincipalId,
|
|
2308
|
+
expectedHostMembershipEpoch: normalized.expectedHostMembershipEpoch,
|
|
2309
|
+
expectedAckedGeneration: normalized.expectedAckedGeneration,
|
|
2310
|
+
expectedMembershipEpoch: normalized.expectedMembershipEpoch,
|
|
2311
|
+
requestId: normalized.requestId,
|
|
2312
|
+
fingerprint: normalized.fingerprint,
|
|
2313
|
+
binding: normalized.binding,
|
|
2314
|
+
};
|
|
2315
|
+
});
|
|
2316
|
+
const lease = result.snapshot.sessions
|
|
2317
|
+
.get(normalized.sessionId)
|
|
2318
|
+
.leases.get(normalized.leaseId);
|
|
2319
|
+
return Object.freeze({
|
|
2320
|
+
authorityVersion: REMOTE_MEMBERSHIP_COORDINATOR_VERSION,
|
|
2321
|
+
dispatchAuthorized: true,
|
|
2322
|
+
lease: publicLease(lease),
|
|
2323
|
+
statement: this._statement(
|
|
2324
|
+
"lease.consumed",
|
|
2325
|
+
publicLease(lease),
|
|
2326
|
+
result.snapshot.generation,
|
|
2327
|
+
result.event.occurredAtMs,
|
|
2328
|
+
),
|
|
2329
|
+
});
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
cancelApprovalLease({
|
|
2333
|
+
sessionId,
|
|
2334
|
+
leaseId,
|
|
2335
|
+
hostPrincipalId,
|
|
2336
|
+
expectedHostMembershipEpoch,
|
|
2337
|
+
reason,
|
|
2338
|
+
} = {}) {
|
|
2339
|
+
const normalizedSessionId = requiredString(sessionId, "sessionId");
|
|
2340
|
+
const normalizedLeaseId = requiredString(leaseId, "leaseId");
|
|
2341
|
+
const normalizedHost = requiredString(hostPrincipalId, "hostPrincipalId");
|
|
2342
|
+
const hostMembershipEpoch = String(
|
|
2343
|
+
canonicalEpoch(
|
|
2344
|
+
expectedHostMembershipEpoch,
|
|
2345
|
+
"expectedHostMembershipEpoch",
|
|
2346
|
+
),
|
|
2347
|
+
);
|
|
2348
|
+
const normalizedReason = requiredString(reason, "reason");
|
|
2349
|
+
const result = this._mutate((snapshot) => {
|
|
2350
|
+
const session = snapshot.sessions.get(normalizedSessionId);
|
|
2351
|
+
const lease = session?.leases.get(normalizedLeaseId);
|
|
2352
|
+
const host = session?.members.get(normalizedHost);
|
|
2353
|
+
if (
|
|
2354
|
+
!session ||
|
|
2355
|
+
!lease ||
|
|
2356
|
+
(lease.status !== "active" && lease.status !== "acked") ||
|
|
2357
|
+
lease.hostPrincipalId !== normalizedHost ||
|
|
2358
|
+
!host ||
|
|
2359
|
+
host.status !== "active" ||
|
|
2360
|
+
host.membershipEpoch !== hostMembershipEpoch
|
|
2361
|
+
) {
|
|
2362
|
+
throw new Error("Remote approval lease cancellation denied");
|
|
2363
|
+
}
|
|
2364
|
+
return {
|
|
2365
|
+
type: "lease.cancelled",
|
|
2366
|
+
sessionId: normalizedSessionId,
|
|
2367
|
+
expectedSessionEpoch: session.sessionEpoch,
|
|
2368
|
+
leaseId: normalizedLeaseId,
|
|
2369
|
+
hostPrincipalId: normalizedHost,
|
|
2370
|
+
expectedHostMembershipEpoch: hostMembershipEpoch,
|
|
2371
|
+
reason: normalizedReason,
|
|
2372
|
+
};
|
|
2373
|
+
});
|
|
2374
|
+
const lease = result.snapshot.sessions
|
|
2375
|
+
.get(normalizedSessionId)
|
|
2376
|
+
.leases.get(normalizedLeaseId);
|
|
2377
|
+
return Object.freeze({
|
|
2378
|
+
authorityVersion: REMOTE_MEMBERSHIP_COORDINATOR_VERSION,
|
|
2379
|
+
lease: publicLease(lease),
|
|
2380
|
+
statement: this._statement(
|
|
2381
|
+
"lease.cancelled",
|
|
2382
|
+
publicLease(lease),
|
|
2383
|
+
result.snapshot.generation,
|
|
2384
|
+
result.event.occurredAtMs,
|
|
2385
|
+
),
|
|
2386
|
+
});
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
snapshotSession(sessionId) {
|
|
2390
|
+
const normalizedSessionId = requiredString(sessionId, "sessionId");
|
|
2391
|
+
return this._withRead((snapshot, now) => {
|
|
2392
|
+
const session = snapshot.sessions.get(normalizedSessionId);
|
|
2393
|
+
if (!session) throw new Error("Remote membership session not found");
|
|
2394
|
+
return Object.freeze({
|
|
2395
|
+
session: publicSessionSnapshot(session, snapshot.generation),
|
|
2396
|
+
statement: this._snapshotStatement(session, snapshot.generation, now),
|
|
2397
|
+
});
|
|
2398
|
+
});
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
export const _remoteMembershipCoordinatorInternals = Object.freeze({
|
|
2403
|
+
authenticationChallengeBytes,
|
|
2404
|
+
canonicalJson,
|
|
2405
|
+
eventHash,
|
|
2406
|
+
normalizePrincipalCredential,
|
|
2407
|
+
replayStore,
|
|
2408
|
+
sha256,
|
|
2409
|
+
});
|