chainlesschain 0.159.0 → 0.161.2
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 +1 -1
- package/src/assets/web-panel/.build-hash +1 -1
- package/src/assets/web-panel/assets/AIOps-Cchx1iXI.css +1 -0
- package/src/assets/web-panel/assets/AIOps-CoZ9bIqF.js +1 -0
- package/src/assets/web-panel/assets/ActionButton-BvMi4awy.js +1 -0
- package/src/assets/web-panel/assets/Analytics-C1-TXmTC.css +1 -0
- package/src/assets/web-panel/assets/Analytics-hRk2ziup.js +3 -0
- package/src/assets/web-panel/assets/AppLayout-CtGprHSx.css +1 -0
- package/src/assets/web-panel/assets/AppLayout-_JR3Gko8.js +1 -0
- package/src/assets/web-panel/assets/Audit-6ZMsXmrO.css +1 -0
- package/src/assets/web-panel/assets/Audit-D8WmaHdX.js +1 -0
- package/src/assets/web-panel/assets/Backup-CY9QozR7.css +1 -0
- package/src/assets/web-panel/assets/Backup-CogYVeiE.js +1 -0
- package/src/assets/web-panel/assets/BaseInput-NAp5_OPY.js +1 -0
- package/src/assets/web-panel/assets/{Chat-DmX5bWvL.css → Chat-ByiYUboW.css} +1 -1
- package/src/assets/web-panel/assets/Chat-DkQnhjfk.js +2 -0
- package/src/assets/web-panel/assets/Checkbox-C9dkWb-7.js +1 -0
- package/src/assets/web-panel/assets/Codegen-BHyJ3j-p.js +1 -0
- package/src/assets/web-panel/assets/Codegen-BLP7id2a.css +1 -0
- package/src/assets/web-panel/assets/Col-JyQOivHb.js +1 -0
- package/src/assets/web-panel/assets/Community-C2RejeOY.css +1 -0
- package/src/assets/web-panel/assets/Community-UMq5QuBA.js +1 -0
- package/src/assets/web-panel/assets/Compact-DGlwooBJ.js +1 -0
- package/src/assets/web-panel/assets/Compliance-2rWGO55k.js +1 -0
- package/src/assets/web-panel/assets/Compliance-DOys4Ov1.css +1 -0
- package/src/assets/web-panel/assets/{Cowork-CFkkMMDK.js → Cowork-V-tDxtrt.js} +4 -4
- package/src/assets/web-panel/assets/Cron-YgEeQvdV.js +2 -0
- package/src/assets/web-panel/assets/Crosschain-C7Le4Pte.css +1 -0
- package/src/assets/web-panel/assets/Crosschain-Cgd5cRKn.js +1 -0
- package/src/assets/web-panel/assets/DID-BX6k3jZi.css +1 -0
- package/src/assets/web-panel/assets/DID-swdBCdMZ.js +2 -0
- package/src/assets/web-panel/assets/Dashboard-ClnWtxsT.js +3 -0
- package/src/assets/web-panel/assets/Dashboard-MFDcsVcM.css +1 -0
- package/src/assets/web-panel/assets/Dropdown-mlITwb7d.js +1 -0
- package/src/assets/web-panel/assets/Federation-CgmfLbx1.css +1 -0
- package/src/assets/web-panel/assets/Federation-DtUN3wQa.js +1 -0
- package/src/assets/web-panel/assets/{FormItemContext-Be6TSNxz.js → FormItemContext-BYmWDwAT.js} +1 -1
- package/src/assets/web-panel/assets/{Git-DGcuBXST.css → Git-DPuaGtg7.css} +1 -1
- package/src/assets/web-panel/assets/Git-Du1k1iHz.js +2 -0
- package/src/assets/web-panel/assets/Governance-B2TFaWsf.js +1 -0
- package/src/assets/web-panel/assets/Governance-BoipmXaM.css +1 -0
- package/src/assets/web-panel/assets/Inference-BWxYJF9-.css +1 -0
- package/src/assets/web-panel/assets/Inference-Cm_hmXla.js +1 -0
- package/src/assets/web-panel/assets/KnowledgeGraph-CztPDA96.css +1 -0
- package/src/assets/web-panel/assets/KnowledgeGraph-DLaLMo4r.js +1 -0
- package/src/assets/web-panel/assets/Logs-CElvIBBJ.js +2 -0
- package/src/assets/web-panel/assets/Marketplace-BlR3RCDV.js +1 -0
- package/src/assets/web-panel/assets/Marketplace-Djp5q9dS.css +1 -0
- package/src/assets/web-panel/assets/McpTools-BpHkrlka.js +5 -0
- package/src/assets/web-panel/assets/McpTools-CDjHmzxH.css +1 -0
- package/src/assets/web-panel/assets/Memory-Bcc2hxOA.css +1 -0
- package/src/assets/web-panel/assets/Memory-DeU9ys_m.js +2 -0
- package/src/assets/web-panel/assets/Mtc-CCJZpnJo.js +6 -0
- package/src/assets/web-panel/assets/Mtc-Cc8OJxe_.css +1 -0
- package/src/assets/web-panel/assets/NLProgramming-B_Tie6j1.js +1 -0
- package/src/assets/web-panel/assets/NLProgramming-CLOvy-35.css +1 -0
- package/src/assets/web-panel/assets/Notes-BcpuirPj.js +7 -0
- package/src/assets/web-panel/assets/Organization-BX-cIO8M.css +1 -0
- package/src/assets/web-panel/assets/Organization-B_SHESSc.js +4 -0
- package/src/assets/web-panel/assets/Overflow-R2SOGT0l.js +1 -0
- package/src/assets/web-panel/assets/{OverrideContext-GHsJf9ok.js → OverrideContext-Nubhv68k.js} +1 -1
- package/src/assets/web-panel/assets/P2P-Cx88UaiD.css +1 -0
- package/src/assets/web-panel/assets/P2P-IYYy3cEd.js +2 -0
- package/src/assets/web-panel/assets/Permissions-CR1N42yW.js +4 -0
- package/src/assets/web-panel/assets/Pipeline-DxkXqrH2.css +1 -0
- package/src/assets/web-panel/assets/Pipeline-nwFpKsU_.js +1 -0
- package/src/assets/web-panel/assets/Privacy-BGpz72PX.js +1 -0
- package/src/assets/web-panel/assets/Privacy-CrsfSFKd.css +1 -0
- package/src/assets/web-panel/assets/ProjectSettings-CEDhpgbs.js +2 -0
- package/src/assets/web-panel/assets/Projects-B5IgXt-x.css +1 -0
- package/src/assets/web-panel/assets/Projects-DABi6ylb.js +2 -0
- package/src/assets/web-panel/assets/Providers-HzrcE8ma.js +2 -0
- package/src/assets/web-panel/assets/QuickAsk-6FgX9DC6.js +1 -0
- package/src/assets/web-panel/assets/Recommend-BPhQwye7.js +1 -0
- package/src/assets/web-panel/assets/Recommend-CH6wKzGo.css +1 -0
- package/src/assets/web-panel/assets/Reputation-BL6hTN1s.js +1 -0
- package/src/assets/web-panel/assets/Reputation-D6VPNEd0.css +1 -0
- package/src/assets/web-panel/assets/Row-2akLU3YS.js +1 -0
- package/src/assets/web-panel/assets/RssFeed-D6qNq6Ht.js +3 -0
- package/src/assets/web-panel/assets/Search-B6RalzTB.css +1 -0
- package/src/assets/web-panel/assets/Search-R_b-u9oL.js +1 -0
- package/src/assets/web-panel/assets/Security-13K57V_v.css +1 -0
- package/src/assets/web-panel/assets/Security-DdW4hu_4.js +4 -0
- package/src/assets/web-panel/assets/Services-CnzEzGFN.js +2 -0
- package/src/assets/web-panel/assets/Skeleton-D6RevdW2.js +8 -0
- package/src/assets/web-panel/assets/Skills-DD5ReHH7.js +1 -0
- package/src/assets/web-panel/assets/Sla-CaQOOsjD.js +1 -0
- package/src/assets/web-panel/assets/Sla-K19oOyQc.css +1 -0
- package/src/assets/web-panel/assets/SpeechSettings-D-pGIn9Z.js +1 -0
- package/src/assets/web-panel/assets/SpeechSettings-DYPJTDKz.css +1 -0
- package/src/assets/web-panel/assets/Tasks-BbdO_i4Q.js +1 -0
- package/src/assets/web-panel/assets/Templates-BWTV8-2E.css +1 -0
- package/src/assets/web-panel/assets/Templates-CupAugDn.js +1 -0
- package/src/assets/web-panel/assets/Tenant-D3zkSAV0.css +1 -0
- package/src/assets/web-panel/assets/Tenant-rseAzHcY.js +1 -0
- package/src/assets/web-panel/assets/Tokens-BBOdNRHQ.css +1 -0
- package/src/assets/web-panel/assets/Tokens-DXMokNbR.js +1 -0
- package/src/assets/web-panel/assets/Trigger-DkSZjOlY.js +1 -0
- package/src/assets/web-panel/assets/Trust-CKb7QDH1.js +1 -0
- package/src/assets/web-panel/assets/Trust-DeOo0xAh.css +1 -0
- package/src/assets/web-panel/assets/UkeySign-Cux8_Ib_.js +1 -0
- package/src/assets/web-panel/assets/VideoEditing-CSOjdBZg.js +1 -0
- package/src/assets/web-panel/assets/VideoEditing-DksiizfS.css +1 -0
- package/src/assets/web-panel/assets/Wallet-DHbi5dHt.js +4 -0
- package/src/assets/web-panel/assets/Wallet-gR0ZvZbK.css +1 -0
- package/src/assets/web-panel/assets/WebAuthn-BkGDI33-.js +5 -0
- package/src/assets/web-panel/assets/WebAuthn-SSh5VhVO.css +1 -0
- package/src/assets/web-panel/assets/WorkflowEditor-BFZ3RYva.js +1 -0
- package/src/assets/web-panel/assets/WorkflowEditor-IiwsD8Kh.css +1 -0
- package/src/assets/web-panel/assets/{chat-DY27mJje.js → chat-BQ-Nk2XY.js} +1 -1
- package/src/assets/web-panel/assets/{collapseMotion-CyadT_6x.js → collapseMotion-BIjDVXtT.js} +1 -1
- package/src/assets/web-panel/assets/{colors-B7fDvuJc.js → colors-D2tTvuDI.js} +1 -1
- package/src/assets/web-panel/assets/{compact-item-DgthOVXi.js → compact-item-CqCEUZiy.js} +1 -1
- package/src/assets/web-panel/assets/{createContext-Blw2vgkG.js → createContext-C6HFlAQP.js} +1 -1
- package/src/assets/web-panel/assets/echarts-DmBLM6YO.js +19 -0
- package/src/assets/web-panel/assets/{hasIn-BfL1HJZl.js → hasIn-yp2CbhYc.js} +1 -1
- package/src/assets/web-panel/assets/icons-DvZE-RKs.js +57 -0
- package/src/assets/web-panel/assets/index-4cn1LmJ9.js +1 -0
- package/src/assets/web-panel/assets/index-B0jkl2Zb.js +1 -0
- package/src/assets/web-panel/assets/{index-BHnaIQEm.js → index-B2qFUwGb.js} +2 -2
- package/src/assets/web-panel/assets/index-B74gWYqD.js +1 -0
- package/src/assets/web-panel/assets/index-B8Qxu0q2.js +1 -0
- package/src/assets/web-panel/assets/index-B9b_mz4I.js +55 -0
- package/src/assets/web-panel/assets/index-BAA1SFp1.js +1 -0
- package/src/assets/web-panel/assets/index-BJeE7n_I.js +12 -0
- package/src/assets/web-panel/assets/index-BJoK7MkB.js +13 -0
- package/src/assets/web-panel/assets/{index-DyRzaN4b.js → index-BTL2yIvT.js} +3 -3
- package/src/assets/web-panel/assets/index-BlVnFOFL.js +1 -0
- package/src/assets/web-panel/assets/{index-x8iHfZRd.js → index-Blq49aTW.js} +3 -3
- package/src/assets/web-panel/assets/{index-BsleJWGy.js → index-BxSsO6Sm.js} +2 -2
- package/src/assets/web-panel/assets/index-CA3g3EpL.js +1 -0
- package/src/assets/web-panel/assets/{index-BuEOhTAw.js → index-CFoFkVUt.js} +5 -5
- package/src/assets/web-panel/assets/index-CWhXxdyo.js +1 -0
- package/src/assets/web-panel/assets/index-CXf0zL5i.js +13 -0
- package/src/assets/web-panel/assets/index-CcwZodUl.js +1 -0
- package/src/assets/web-panel/assets/index-Cd6m6ynF.js +1 -0
- package/src/assets/web-panel/assets/index-CfX1DEtk.css +1 -0
- package/src/assets/web-panel/assets/index-Cfy9l115.js +1 -0
- package/src/assets/web-panel/assets/index-CijiVpfO.js +1 -0
- package/src/assets/web-panel/assets/index-Cpfx7-LN.js +12 -0
- package/src/assets/web-panel/assets/index-CrEEL63u.js +1 -0
- package/src/assets/web-panel/assets/index-CucxAdwN.js +6 -0
- package/src/assets/web-panel/assets/index-CvuBD5TK.js +3 -0
- package/src/assets/web-panel/assets/index-Cw4v7ezB.js +65 -0
- package/src/assets/web-panel/assets/{index-00RNC3ZQ.js → index-D34iabcS.js} +8 -8
- package/src/assets/web-panel/assets/index-DEKuiAPQ.js +3 -0
- package/src/assets/web-panel/assets/index-Dj4P0iWm.js +1 -0
- package/src/assets/web-panel/assets/index-DjeNNVwu.js +1 -0
- package/src/assets/web-panel/assets/index-DlgMVieO.js +1 -0
- package/src/assets/web-panel/assets/{index-CiN4NEAa.js → index-DnehXcB-.js} +2 -2
- package/src/assets/web-panel/assets/index-JszcDpsT.js +7 -0
- package/src/assets/web-panel/assets/index-KdFFI-p3.js +1 -0
- package/src/assets/web-panel/assets/index-NNymVAza.js +3 -0
- package/src/assets/web-panel/assets/index-aw2DwKj-.js +21 -0
- package/src/assets/web-panel/assets/index-nzDx0JAR.js +1 -0
- package/src/assets/web-panel/assets/index-w1xShUDf.js +1 -0
- package/src/assets/web-panel/assets/{initDefaultProps-BeWIEzBr.js → initDefaultProps-DfdVxwz6.js} +1 -1
- package/src/assets/web-panel/assets/motion-CL0bdvJg.js +11 -0
- package/src/assets/web-panel/assets/move-Bo9Fgzv7.js +4 -0
- package/src/assets/web-panel/assets/mtc-parser-pGMSt10g.js +1 -0
- package/src/assets/web-panel/assets/{omit-BS0H_YEP.js → omit-C-cc6wHr.js} +1 -1
- package/src/assets/web-panel/assets/{pickAttrs-BOGgGau8.js → pickAttrs-CjLp5RN-.js} +1 -1
- package/src/assets/web-panel/assets/{placementArrow--c5TQkDQ.js → placementArrow-CJa8gsqa.js} +1 -1
- package/src/assets/web-panel/assets/{responsiveObserve-CrHOX7jp.js → responsiveObserve-CcDj3P-p.js} +1 -1
- package/src/assets/web-panel/assets/slide-CpvbHO26.js +4 -0
- package/src/assets/web-panel/assets/statusUtils-BK69kP1U.js +1 -0
- package/src/assets/web-panel/assets/{styleChecker-CiDrXnbi.js → styleChecker-CDvBRzsG.js} +1 -1
- package/src/assets/web-panel/assets/useFlexGapSupport-CRN_hzJt.js +1 -0
- package/src/assets/web-panel/assets/useFs-BD-YRwbU.js +1 -0
- package/src/assets/web-panel/assets/{useMergedState-CLY-UHry.js → useMergedState-TP9VIF2K.js} +1 -1
- package/src/assets/web-panel/assets/{useRefs-HzOoMkZk.js → useRefs-BhIz_lC3.js} +1 -1
- package/src/assets/web-panel/assets/{useState-yWVBuz1S.js → useState-CpKsyozn.js} +1 -1
- package/src/assets/web-panel/assets/vendor-B6ToihkA.js +1 -0
- package/src/assets/web-panel/assets/vnode-Bzp-FsbB.js +1 -0
- package/src/assets/web-panel/assets/{ws-4ur1fGsk.js → ws-D_5-FRIb.js} +1 -1
- package/src/assets/web-panel/assets/zoom-BHqpWXJV.js +4 -0
- package/src/assets/web-panel/index.html +4 -4
- package/src/commands/audit.js +296 -0
- package/src/commands/crosschain.js +564 -8
- package/src/commands/init.js +16 -1
- package/src/commands/mtc.js +2735 -86
- package/src/lib/audit-mtc.js +504 -0
- package/src/lib/cross-chain-mtc.js +904 -0
- package/src/assets/web-panel/assets/AIOps-Bfzpnnlg.css +0 -1
- package/src/assets/web-panel/assets/AIOps-ztGJppiG.js +0 -1
- package/src/assets/web-panel/assets/ActionButton-DCuat-R-.js +0 -1
- package/src/assets/web-panel/assets/Analytics-BFI7jbwM.css +0 -1
- package/src/assets/web-panel/assets/Analytics-m_EtU-RR.js +0 -3
- package/src/assets/web-panel/assets/AppLayout-8HizVjee.css +0 -1
- package/src/assets/web-panel/assets/AppLayout-Crz8jbEC.js +0 -1
- package/src/assets/web-panel/assets/Audit-CNLWjMXN.js +0 -1
- package/src/assets/web-panel/assets/Audit-kU7vbN-D.css +0 -1
- package/src/assets/web-panel/assets/Backup-CggaSq9O.js +0 -1
- package/src/assets/web-panel/assets/Backup-DxLiJzmk.css +0 -1
- package/src/assets/web-panel/assets/BaseInput-BssiYvl6.js +0 -1
- package/src/assets/web-panel/assets/Chat-tuVfpQUO.js +0 -2
- package/src/assets/web-panel/assets/Checkbox-BUUkXdQg.js +0 -1
- package/src/assets/web-panel/assets/Codegen-AVAcL7NA.css +0 -1
- package/src/assets/web-panel/assets/Codegen-CHu-7YGr.js +0 -1
- package/src/assets/web-panel/assets/Col-2IaMFwmX.js +0 -1
- package/src/assets/web-panel/assets/Community-CYL7Fvjq.js +0 -1
- package/src/assets/web-panel/assets/Community-DqDfLQui.css +0 -1
- package/src/assets/web-panel/assets/Compact-5yRVELhA.js +0 -1
- package/src/assets/web-panel/assets/Compliance-CKuIDJHK.js +0 -1
- package/src/assets/web-panel/assets/Compliance-CKxw6vIq.css +0 -1
- package/src/assets/web-panel/assets/Cron-DIfkI7vf.js +0 -2
- package/src/assets/web-panel/assets/Crosschain-BkjY-lst.js +0 -1
- package/src/assets/web-panel/assets/Crosschain-DThGgQk8.css +0 -1
- package/src/assets/web-panel/assets/DID-BDvsVa08.css +0 -1
- package/src/assets/web-panel/assets/DID-ChzRkgNy.js +0 -2
- package/src/assets/web-panel/assets/Dashboard-Cviwdc26.css +0 -1
- package/src/assets/web-panel/assets/Dashboard-DKrbXVNn.js +0 -3
- package/src/assets/web-panel/assets/Dropdown-BYGoxH1z.js +0 -1
- package/src/assets/web-panel/assets/Federation-B8QX-IaA.js +0 -1
- package/src/assets/web-panel/assets/Federation-BftELHDw.css +0 -1
- package/src/assets/web-panel/assets/Git-BBnGgbBR.js +0 -2
- package/src/assets/web-panel/assets/Governance-BfmfQBGB.css +0 -1
- package/src/assets/web-panel/assets/Governance-CxUHZMsp.js +0 -1
- package/src/assets/web-panel/assets/Inference-BlnOG71q.js +0 -1
- package/src/assets/web-panel/assets/Inference-EFFc7eNZ.css +0 -1
- package/src/assets/web-panel/assets/Keyframes-C7fCrnlS.js +0 -1
- package/src/assets/web-panel/assets/KnowledgeGraph-U8ps3aGJ.css +0 -1
- package/src/assets/web-panel/assets/KnowledgeGraph-vkVq38kC.js +0 -19
- package/src/assets/web-panel/assets/Logs-Bxx7WARH.js +0 -2
- package/src/assets/web-panel/assets/Marketplace-B-4uYu_j.css +0 -1
- package/src/assets/web-panel/assets/Marketplace-Bh8ExT9_.js +0 -1
- package/src/assets/web-panel/assets/McpTools-CTQrNVAQ.css +0 -1
- package/src/assets/web-panel/assets/McpTools-Djj_a3ko.js +0 -5
- package/src/assets/web-panel/assets/Memory-DRghrGJr.css +0 -1
- package/src/assets/web-panel/assets/Memory-DlgNgAov.js +0 -2
- package/src/assets/web-panel/assets/NLProgramming-BN3jaoen.js +0 -1
- package/src/assets/web-panel/assets/NLProgramming-jURs-f-a.css +0 -1
- package/src/assets/web-panel/assets/Notes-De7mIkkV.js +0 -7
- package/src/assets/web-panel/assets/Organization-B-98mdK2.js +0 -4
- package/src/assets/web-panel/assets/Organization-DdOOM4ic.css +0 -1
- package/src/assets/web-panel/assets/Overflow-G0I8IlY3.js +0 -1
- package/src/assets/web-panel/assets/P2P-DIUgHZ1z.js +0 -2
- package/src/assets/web-panel/assets/P2P-OEzOeMZX.css +0 -1
- package/src/assets/web-panel/assets/Permissions-B8XycCVk.js +0 -4
- package/src/assets/web-panel/assets/Pipeline-DyqCLFVr.css +0 -1
- package/src/assets/web-panel/assets/Pipeline-cUpETlXS.js +0 -1
- package/src/assets/web-panel/assets/Portal-SsPhn64D.js +0 -1
- package/src/assets/web-panel/assets/Privacy-B6J89UBw.js +0 -1
- package/src/assets/web-panel/assets/Privacy-B_cAicd1.css +0 -1
- package/src/assets/web-panel/assets/ProjectSettings-2Ftw0zt_.js +0 -2
- package/src/assets/web-panel/assets/Projects-CcOdFpgr.js +0 -2
- package/src/assets/web-panel/assets/Projects-DxKelI5h.css +0 -1
- package/src/assets/web-panel/assets/Providers-D0r2qSf-.js +0 -2
- package/src/assets/web-panel/assets/QuickAsk-CwsPpfkq.js +0 -1
- package/src/assets/web-panel/assets/Recommend-BYEDetJm.js +0 -1
- package/src/assets/web-panel/assets/Recommend-DgNSCgRX.css +0 -1
- package/src/assets/web-panel/assets/Reputation-Bli4hBGH.js +0 -1
- package/src/assets/web-panel/assets/Reputation-y-46ThW8.css +0 -1
- package/src/assets/web-panel/assets/Row-DTW9_BYi.js +0 -1
- package/src/assets/web-panel/assets/RssFeed-kwj_himl.js +0 -3
- package/src/assets/web-panel/assets/Search-BTk9rglb.css +0 -1
- package/src/assets/web-panel/assets/Search-DymcqASO.js +0 -1
- package/src/assets/web-panel/assets/Security-Bh3yvyNN.js +0 -4
- package/src/assets/web-panel/assets/Security-Dwxw7rfP.css +0 -1
- package/src/assets/web-panel/assets/Services-O4UZaYur.js +0 -2
- package/src/assets/web-panel/assets/Skeleton-B82oQZTz.js +0 -8
- package/src/assets/web-panel/assets/Skills-BfkrC05g.js +0 -1
- package/src/assets/web-panel/assets/Sla-C1WYuQKf.css +0 -1
- package/src/assets/web-panel/assets/Sla-DEs5XCIf.js +0 -1
- package/src/assets/web-panel/assets/SpeechSettings-BWMhb10j.js +0 -1
- package/src/assets/web-panel/assets/SpeechSettings-CiKvsIyV.css +0 -1
- package/src/assets/web-panel/assets/Tasks-CIBUb9M1.js +0 -1
- package/src/assets/web-panel/assets/Templates-DAkzr0xS.css +0 -1
- package/src/assets/web-panel/assets/Templates-iqSuJY-O.js +0 -1
- package/src/assets/web-panel/assets/Tenant-BJr-h-_0.css +0 -1
- package/src/assets/web-panel/assets/Tenant-CLqZjkVq.js +0 -1
- package/src/assets/web-panel/assets/Tokens-Bwz8aQtK.js +0 -1
- package/src/assets/web-panel/assets/Tokens-KvJRHQcl.css +0 -1
- package/src/assets/web-panel/assets/Trigger-CxnChVoS.js +0 -1
- package/src/assets/web-panel/assets/Trust-BLI308Ik.css +0 -1
- package/src/assets/web-panel/assets/Trust-Bp23lyZl.js +0 -1
- package/src/assets/web-panel/assets/UkeySign-_VAMquoh.js +0 -1
- package/src/assets/web-panel/assets/VideoEditing-BA1N-5kq.css +0 -1
- package/src/assets/web-panel/assets/VideoEditing-Bxqwg4zW.js +0 -1
- package/src/assets/web-panel/assets/Wallet-Cvht6Yrh.js +0 -4
- package/src/assets/web-panel/assets/Wallet-DnIumafl.css +0 -1
- package/src/assets/web-panel/assets/WebAuthn-CNPl2VQR.css +0 -1
- package/src/assets/web-panel/assets/WebAuthn-DD4EomJu.js +0 -5
- package/src/assets/web-panel/assets/WorkflowEditor-CEKNTS5G.css +0 -1
- package/src/assets/web-panel/assets/WorkflowEditor-CJy5e0fl.js +0 -1
- package/src/assets/web-panel/assets/_plugin-vue_export-helper-DlAUqK2U.js +0 -1
- package/src/assets/web-panel/assets/icons-CJACPYXu.js +0 -57
- package/src/assets/web-panel/assets/index-B9B9Zbf3.js +0 -1
- package/src/assets/web-panel/assets/index-BAsMNMbh.js +0 -1
- package/src/assets/web-panel/assets/index-BB5y5Y0z.js +0 -14
- package/src/assets/web-panel/assets/index-BVjDIZQ4.js +0 -1
- package/src/assets/web-panel/assets/index-BZeRnzuY.js +0 -55
- package/src/assets/web-panel/assets/index-BZfoAtZC.js +0 -1
- package/src/assets/web-panel/assets/index-Bl2nXm2J.js +0 -13
- package/src/assets/web-panel/assets/index-Bw1dwHWm.js +0 -1
- package/src/assets/web-panel/assets/index-Bw_UV2ez.js +0 -13
- package/src/assets/web-panel/assets/index-C8GYpC65.js +0 -1
- package/src/assets/web-panel/assets/index-CBHlKa-J.js +0 -3
- package/src/assets/web-panel/assets/index-CL-7KCFI.js +0 -3
- package/src/assets/web-panel/assets/index-CqjUUbil.js +0 -12
- package/src/assets/web-panel/assets/index-Cqtt1N0F.js +0 -1
- package/src/assets/web-panel/assets/index-CyGyEIVX.css +0 -1
- package/src/assets/web-panel/assets/index-D1QP9Ue1.js +0 -1
- package/src/assets/web-panel/assets/index-D6BUjL6I.js +0 -1
- package/src/assets/web-panel/assets/index-DN7ywgBJ.js +0 -1
- package/src/assets/web-panel/assets/index-DasW8LIs.js +0 -1
- package/src/assets/web-panel/assets/index-Dd9McFt_.js +0 -1
- package/src/assets/web-panel/assets/index-DdzLEdL6.js +0 -7
- package/src/assets/web-panel/assets/index-DhML6F3z.js +0 -1
- package/src/assets/web-panel/assets/index-DjN0nHiV.js +0 -1
- package/src/assets/web-panel/assets/index-DjhWpiZW.js +0 -1
- package/src/assets/web-panel/assets/index-DouFuKIR.js +0 -1
- package/src/assets/web-panel/assets/index-DzWwjWYN.js +0 -21
- package/src/assets/web-panel/assets/index-Dza_w3kG.js +0 -1
- package/src/assets/web-panel/assets/index-Q4qgOtOe.js +0 -12
- package/src/assets/web-panel/assets/index-U4Zd5IK6.js +0 -8
- package/src/assets/web-panel/assets/index-Vit9TJBu.js +0 -36
- package/src/assets/web-panel/assets/index-lkJllVbJ.js +0 -6
- package/src/assets/web-panel/assets/index-uMWaXVtR.js +0 -3
- package/src/assets/web-panel/assets/index-uwh_ikIt.js +0 -1
- package/src/assets/web-panel/assets/motion-CIQOKmi6.js +0 -11
- package/src/assets/web-panel/assets/move-ZgRPlBji.js +0 -4
- package/src/assets/web-panel/assets/slide-Dd2mJUD0.js +0 -4
- package/src/assets/web-panel/assets/statusUtils-5QFvAofV.js +0 -1
- package/src/assets/web-panel/assets/transition-CVd2ueaJ.js +0 -1
- package/src/assets/web-panel/assets/useConfigInject-DIkkDpt1.js +0 -2
- package/src/assets/web-panel/assets/useFlexGapSupport-rrtj6f1h.js +0 -1
- package/src/assets/web-panel/assets/useFs-CK8VYPus.js +0 -1
- package/src/assets/web-panel/assets/vendor-D0Qjn73K.js +0 -1
- package/src/assets/web-panel/assets/vnode-xRp-KMjS.js +0 -1
- package/src/assets/web-panel/assets/zoom-CrI_kdTW.js +0 -4
package/src/commands/mtc.js
CHANGED
|
@@ -11,30 +11,72 @@
|
|
|
11
11
|
import fs from "node:fs";
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import chalk from "chalk";
|
|
14
|
+
import { ed25519 as nobleEd25519 } from "@noble/curves/ed25519";
|
|
14
15
|
import { logger } from "../lib/logger.js";
|
|
15
16
|
import { bootstrap, shutdown } from "../runtime/bootstrap.js";
|
|
16
17
|
import { getAllIdentities, getIdentity } from "../lib/did-manager.js";
|
|
17
18
|
import { CLISkillLoader } from "../lib/skill-loader.js";
|
|
19
|
+
import { getHomeDir } from "../lib/paths.js";
|
|
18
20
|
import mtcLib from "@chainlesschain/core-mtc";
|
|
19
21
|
|
|
20
22
|
const {
|
|
21
|
-
MerkleTree,
|
|
22
23
|
encodeHashStr,
|
|
23
24
|
sha256,
|
|
24
|
-
leafHash,
|
|
25
25
|
jcs,
|
|
26
26
|
LandmarkCache,
|
|
27
27
|
verify,
|
|
28
|
-
SCHEMA_ENVELOPE,
|
|
29
|
-
SCHEMA_TREE_HEAD,
|
|
30
28
|
SCHEMA_LANDMARK,
|
|
31
|
-
TREE_HEAD_SIG_PREFIX,
|
|
32
29
|
ed25519,
|
|
30
|
+
slhDsa,
|
|
31
|
+
assembleBatch,
|
|
33
32
|
} = mtcLib;
|
|
34
33
|
|
|
35
34
|
const STOPGAP_BANNER = chalk.yellow(
|
|
36
|
-
"⚠
|
|
35
|
+
"⚠ Tree-head signed with Ed25519 (classical default). Pass --alg slh-dsa-128f for FIPS 205 post-quantum signatures.",
|
|
37
36
|
);
|
|
37
|
+
const PQC_BANNER = chalk.green(
|
|
38
|
+
"✓ Tree-head signed with SLH-DSA-SHA2-128F (FIPS 205 post-quantum).",
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve --alg flag to a signer module + key sizes for read-back.
|
|
43
|
+
* ed25519 (classical, 32-byte sk) is the default; slh-dsa-128f is opt-in.
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* Build a verifier that handles either Ed25519 or SLH-DSA tree-head signatures
|
|
47
|
+
* based on the landmark's trust_anchors. Each per-algorithm verifier rejects
|
|
48
|
+
* signatures of the wrong alg via `signatureObj.alg !== ALG`, so chaining
|
|
49
|
+
* them is safe — at most one will accept.
|
|
50
|
+
*/
|
|
51
|
+
function makeMultiAlgVerifier(landmark) {
|
|
52
|
+
const ed = ed25519.makeVerifierFromLandmark(landmark);
|
|
53
|
+
const slh = slhDsa.makeVerifierFromLandmark(landmark);
|
|
54
|
+
return (signingInput, signatureObj) =>
|
|
55
|
+
ed(signingInput, signatureObj) || slh(signingInput, signatureObj);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function resolveSigner(algFlag) {
|
|
59
|
+
const alg = (algFlag || "ed25519").toLowerCase();
|
|
60
|
+
if (alg === "ed25519") {
|
|
61
|
+
return {
|
|
62
|
+
name: "ed25519",
|
|
63
|
+
signer: ed25519,
|
|
64
|
+
secretKeyLen: 32,
|
|
65
|
+
banner: STOPGAP_BANNER,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (alg === "slh-dsa-128f" || alg === "slh-dsa-sha2-128f") {
|
|
69
|
+
return {
|
|
70
|
+
name: "slh-dsa-128f",
|
|
71
|
+
signer: slhDsa,
|
|
72
|
+
secretKeyLen: 64,
|
|
73
|
+
banner: PQC_BANNER,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
throw new Error(
|
|
77
|
+
`Unknown --alg: ${algFlag} (supported: ed25519, slh-dsa-128f)`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
38
80
|
|
|
39
81
|
function readJsonFile(filePath) {
|
|
40
82
|
const raw = fs.readFileSync(filePath, "utf-8");
|
|
@@ -50,91 +92,362 @@ function writeJsonFile(filePath, obj) {
|
|
|
50
92
|
fs.writeFileSync(filePath, JSON.stringify(obj, null, 2), "utf-8");
|
|
51
93
|
}
|
|
52
94
|
|
|
53
|
-
function loadOrGenerateKeyPair(secretKeyPath) {
|
|
95
|
+
function loadOrGenerateKeyPair(secretKeyPath, signerInfo) {
|
|
96
|
+
const sig = signerInfo || resolveSigner(null);
|
|
54
97
|
if (secretKeyPath && fs.existsSync(secretKeyPath)) {
|
|
55
98
|
const secretKey = Buffer.from(
|
|
56
99
|
fs.readFileSync(secretKeyPath, "utf-8").trim(),
|
|
57
100
|
"hex",
|
|
58
101
|
);
|
|
59
|
-
if (secretKey.length !==
|
|
102
|
+
if (secretKey.length !== sig.secretKeyLen) {
|
|
60
103
|
throw new Error(
|
|
61
|
-
`Secret key file ${secretKeyPath} must contain
|
|
104
|
+
`Secret key file ${secretKeyPath} must contain ${sig.secretKeyLen} bytes for ${sig.name}`,
|
|
62
105
|
);
|
|
63
106
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
107
|
+
let publicKey;
|
|
108
|
+
if (sig.name === "ed25519") {
|
|
109
|
+
publicKey = Buffer.from(nobleEd25519.getPublicKey(secretKey));
|
|
110
|
+
} else {
|
|
111
|
+
publicKey = sig.signer.getPublicKey(secretKey);
|
|
112
|
+
}
|
|
70
113
|
return {
|
|
71
114
|
secretKey,
|
|
72
115
|
publicKey,
|
|
73
|
-
pubkeyId:
|
|
116
|
+
pubkeyId: sig.signer.pubkeyId(publicKey),
|
|
74
117
|
};
|
|
75
118
|
}
|
|
76
|
-
return
|
|
119
|
+
return sig.signer.generateKeyPair();
|
|
77
120
|
}
|
|
78
121
|
|
|
79
122
|
function buildBatch(rawLeaves, opts) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
123
|
+
// Phase 3.2: federation path takes precedence when --federation is set.
|
|
124
|
+
if (opts.federation) {
|
|
125
|
+
return buildFederatedBatch(rawLeaves, opts);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const sig = resolveSigner(opts.alg);
|
|
129
|
+
const keys = loadOrGenerateKeyPair(opts.secretKeyFile, sig);
|
|
130
|
+
const { landmark, envelopes, treeHeadId } = assembleBatch(
|
|
131
|
+
rawLeaves,
|
|
132
|
+
keys,
|
|
133
|
+
{
|
|
134
|
+
namespace: opts.namespace,
|
|
135
|
+
issuer: opts.issuer,
|
|
136
|
+
issuedAt: opts.issuedAt,
|
|
137
|
+
expiresAt: opts.expiresAt,
|
|
138
|
+
},
|
|
139
|
+
sig.signer,
|
|
140
|
+
);
|
|
141
|
+
return { landmark, envelopes, treeHeadId, keys, signerInfo: sig };
|
|
142
|
+
}
|
|
85
143
|
|
|
86
|
-
|
|
144
|
+
/**
|
|
145
|
+
* Federation-mode batch: loads all members of the named federation from
|
|
146
|
+
* the local registry, signs the tree_head with each member's key, and
|
|
147
|
+
* assembles an M-of-N landmark via assembleBatchFederated.
|
|
148
|
+
*
|
|
149
|
+
* @param {Array<object>} rawLeaves
|
|
150
|
+
* @param {{
|
|
151
|
+
* federation: string, // federation id from `cc mtc federation join <id>`
|
|
152
|
+
* threshold?: number, // M (default = N = all members)
|
|
153
|
+
* namespace: string,
|
|
154
|
+
* issuer: string, // federation-level issuer (overrides individual member issuers in tree_head only)
|
|
155
|
+
* issuedAt?: string,
|
|
156
|
+
* expiresAt?: string,
|
|
157
|
+
* }} opts
|
|
158
|
+
*/
|
|
159
|
+
function buildFederatedBatch(rawLeaves, opts) {
|
|
160
|
+
const registry = loadFederationRegistry();
|
|
161
|
+
const fed = registry.federations[opts.federation];
|
|
162
|
+
if (!fed) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`unknown federation "${opts.federation}" — run \`cc mtc federation join ${opts.federation} --member-id <m>\` first`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
const members = Object.values(fed.members || {});
|
|
168
|
+
if (members.length === 0) {
|
|
169
|
+
throw new Error(`federation "${opts.federation}" has no members`);
|
|
170
|
+
}
|
|
87
171
|
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
172
|
+
const threshold = Number.isInteger(opts.threshold)
|
|
173
|
+
? opts.threshold
|
|
174
|
+
: members.length;
|
|
91
175
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
176
|
+
// Load each member's signing key from disk
|
|
177
|
+
const signers = members.map((m) => {
|
|
178
|
+
if (!m.key_file || !fs.existsSync(m.key_file)) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`member "${m.member_id}" key file missing: ${m.key_file}`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
// Match alg from registry, not from --alg flag (each member is fixed)
|
|
184
|
+
let sigInfo;
|
|
185
|
+
if (m.alg === "Ed25519") {
|
|
186
|
+
sigInfo = resolveSigner("ed25519");
|
|
187
|
+
} else if (m.alg === "SLH-DSA-SHA2-128F") {
|
|
188
|
+
sigInfo = resolveSigner("slh-dsa-128f");
|
|
189
|
+
} else {
|
|
190
|
+
throw new Error(`member "${m.member_id}" has unknown alg: ${m.alg}`);
|
|
191
|
+
}
|
|
192
|
+
const keys = loadOrGenerateKeyPair(m.key_file, sigInfo);
|
|
193
|
+
return {
|
|
194
|
+
secretKey: keys.secretKey,
|
|
195
|
+
publicKey: keys.publicKey,
|
|
196
|
+
signer: sigInfo.signer,
|
|
197
|
+
issuer: m.issuer,
|
|
198
|
+
};
|
|
108
199
|
});
|
|
109
200
|
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
201
|
+
const fedSignerInfo = {
|
|
202
|
+
name: "federation",
|
|
203
|
+
threshold,
|
|
204
|
+
members: members.length,
|
|
205
|
+
member_ids: members.map((m) => m.member_id),
|
|
206
|
+
banner: chalk.cyan(
|
|
207
|
+
`✓ Federated tree-head — ${threshold}-of-${members.length} multi-signature (federation: ${opts.federation})`,
|
|
208
|
+
),
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const { landmark, envelopes, treeHeadId } = mtcLib.assembleBatchFederated(
|
|
212
|
+
rawLeaves,
|
|
213
|
+
signers,
|
|
214
|
+
{
|
|
215
|
+
namespace: opts.namespace,
|
|
216
|
+
issuer: opts.issuer,
|
|
217
|
+
threshold,
|
|
218
|
+
issuedAt: opts.issuedAt,
|
|
219
|
+
expiresAt: opts.expiresAt,
|
|
120
220
|
},
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
landmark,
|
|
225
|
+
envelopes,
|
|
226
|
+
treeHeadId,
|
|
227
|
+
keys: signers[0], // first member's key (publish-skills tries to persist; harmless to surface)
|
|
228
|
+
signerInfo: fedSignerInfo,
|
|
121
229
|
};
|
|
230
|
+
}
|
|
122
231
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
232
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
233
|
+
// Marketplace publisher daemon (Phase 2 marketplace path)
|
|
234
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
const PUBLISH_STATE_SCHEMA = "mtc-skill-publish-state/v1";
|
|
237
|
+
|
|
238
|
+
function canonicalSkillsFingerprint(skills) {
|
|
239
|
+
// Sort by id for deterministic fingerprint, then JCS-canonicalize a tuple
|
|
240
|
+
// of (id, version, content_hash). This matches what batch-skills hashes
|
|
241
|
+
// into each leaf, so changing only metadata doesn't trigger churn but
|
|
242
|
+
// bumping a skill's version or body does.
|
|
243
|
+
const sorted = [...skills].sort((a, b) => a.id.localeCompare(b.id));
|
|
244
|
+
const tuples = sorted.map((s) => ({
|
|
245
|
+
id: s.id,
|
|
246
|
+
version: s.version,
|
|
247
|
+
category: s.category,
|
|
248
|
+
activation: s.activation,
|
|
249
|
+
description: s.description,
|
|
250
|
+
}));
|
|
251
|
+
return encodeHashStr(sha256(jcs(tuples)));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function loadPublishState(filePath) {
|
|
255
|
+
if (!filePath || !fs.existsSync(filePath)) {
|
|
256
|
+
return {
|
|
257
|
+
schema: PUBLISH_STATE_SCHEMA,
|
|
258
|
+
last_seq: 0,
|
|
259
|
+
last_fingerprint: null,
|
|
260
|
+
last_published_at: null,
|
|
261
|
+
history: [],
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
const obj = readJsonFile(filePath);
|
|
265
|
+
if (obj.schema !== PUBLISH_STATE_SCHEMA) {
|
|
266
|
+
throw new Error(`state file ${filePath} has unknown schema: ${obj.schema}`);
|
|
267
|
+
}
|
|
268
|
+
return obj;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function savePublishState(filePath, state) {
|
|
272
|
+
// Atomic write: tmp + rename. Avoids the next run reading a truncated state
|
|
273
|
+
// file if the process crashes mid-write (which would silently reset last_seq
|
|
274
|
+
// and start re-publishing batches at 000001).
|
|
275
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
276
|
+
const tmp = `${filePath}.${process.pid}.tmp`;
|
|
277
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), "utf-8");
|
|
278
|
+
fs.renameSync(tmp, filePath);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* One iteration: load skills, compare fingerprint, optionally build a batch
|
|
283
|
+
* and persist state. Returns a structured result regardless of whether a
|
|
284
|
+
* batch was produced (for json output + tests).
|
|
285
|
+
*/
|
|
286
|
+
function publishSkillsOnce(options) {
|
|
287
|
+
const loader = new CLISkillLoader();
|
|
288
|
+
const allSkills = loader.loadAll();
|
|
289
|
+
|
|
290
|
+
let skills;
|
|
291
|
+
if (options.skill && options.skill.length > 0) {
|
|
292
|
+
const want = new Set(options.skill);
|
|
293
|
+
skills = allSkills.filter((s) => want.has(s.id));
|
|
294
|
+
} else {
|
|
295
|
+
skills = allSkills;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const state = loadPublishState(options.stateFile);
|
|
299
|
+
if (skills.length === 0) {
|
|
300
|
+
return {
|
|
301
|
+
iteration: "skipped",
|
|
302
|
+
reason: "no skills discovered",
|
|
303
|
+
skills_count: 0,
|
|
304
|
+
last_seq: state.last_seq,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const fingerprint = canonicalSkillsFingerprint(skills);
|
|
309
|
+
if (state.last_fingerprint === fingerprint) {
|
|
310
|
+
return {
|
|
311
|
+
iteration: "skipped",
|
|
312
|
+
reason: "fingerprint unchanged",
|
|
313
|
+
skills_count: skills.length,
|
|
314
|
+
last_seq: state.last_seq,
|
|
315
|
+
fingerprint,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const nextSeq = state.last_seq + 1;
|
|
320
|
+
const seqStr = String(nextSeq).padStart(6, "0");
|
|
321
|
+
const namespace = `${options.namespacePrefix}/${seqStr}`;
|
|
322
|
+
const batchDir = path.resolve(options.out, seqStr);
|
|
323
|
+
|
|
324
|
+
const rawLeaves = skills.map((s) => ({
|
|
325
|
+
kind: "skill-manifest",
|
|
326
|
+
content_hash: encodeHashStr(
|
|
327
|
+
sha256(
|
|
328
|
+
jcs({
|
|
329
|
+
id: s.id,
|
|
330
|
+
displayName: s.displayName,
|
|
331
|
+
description: s.description,
|
|
332
|
+
version: s.version,
|
|
333
|
+
category: s.category,
|
|
334
|
+
activation: s.activation,
|
|
335
|
+
tags: s.tags,
|
|
336
|
+
}),
|
|
337
|
+
),
|
|
338
|
+
),
|
|
339
|
+
issued_at: new Date().toISOString(),
|
|
340
|
+
subject: `skill:cc:${s.id}@${s.version}`,
|
|
341
|
+
metadata: {
|
|
342
|
+
publisher: options.issuer,
|
|
343
|
+
skill_id: s.id,
|
|
344
|
+
version: s.version,
|
|
345
|
+
category: s.category,
|
|
132
346
|
},
|
|
133
347
|
}));
|
|
134
348
|
|
|
135
|
-
|
|
349
|
+
const { landmark, envelopes, treeHeadId, keys } = buildBatch(rawLeaves, {
|
|
350
|
+
namespace,
|
|
351
|
+
issuer: options.issuer,
|
|
352
|
+
secretKeyFile: options.secretKeyFile,
|
|
353
|
+
alg: options.alg,
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
fs.mkdirSync(batchDir, { recursive: true });
|
|
357
|
+
const landmarkPath = path.join(batchDir, "landmark.json");
|
|
358
|
+
writeJsonFile(landmarkPath, landmark);
|
|
359
|
+
if (options.secretKeyFile && !fs.existsSync(options.secretKeyFile)) {
|
|
360
|
+
fs.mkdirSync(path.dirname(options.secretKeyFile), { recursive: true });
|
|
361
|
+
fs.writeFileSync(options.secretKeyFile, keys.secretKey.toString("hex"), {
|
|
362
|
+
mode: 0o600,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
const envelopePaths = [];
|
|
366
|
+
for (let i = 0; i < envelopes.length; i++) {
|
|
367
|
+
const p = path.join(
|
|
368
|
+
batchDir,
|
|
369
|
+
`envelope-${String(i).padStart(6, "0")}.json`,
|
|
370
|
+
);
|
|
371
|
+
writeJsonFile(p, envelopes[i]);
|
|
372
|
+
envelopePaths.push(p);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const publishedAt = new Date().toISOString();
|
|
376
|
+
state.last_seq = nextSeq;
|
|
377
|
+
state.last_fingerprint = fingerprint;
|
|
378
|
+
state.last_published_at = publishedAt;
|
|
379
|
+
state.history.push({
|
|
380
|
+
seq: seqStr,
|
|
381
|
+
namespace,
|
|
382
|
+
tree_head_id: treeHeadId,
|
|
383
|
+
root_hash: landmark.snapshots[0].tree_head.root_hash,
|
|
384
|
+
tree_size: skills.length,
|
|
385
|
+
fingerprint,
|
|
386
|
+
published_at: publishedAt,
|
|
387
|
+
batch_dir: batchDir,
|
|
388
|
+
});
|
|
389
|
+
savePublishState(options.stateFile, state);
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
iteration: "published",
|
|
393
|
+
seq: seqStr,
|
|
394
|
+
namespace,
|
|
395
|
+
tree_head_id: treeHeadId,
|
|
396
|
+
tree_size: skills.length,
|
|
397
|
+
batch_dir: batchDir,
|
|
398
|
+
landmark_path: landmarkPath,
|
|
399
|
+
envelope_paths: envelopePaths,
|
|
400
|
+
fingerprint,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function publishSkillsLoop(options) {
|
|
405
|
+
const tick = () => {
|
|
406
|
+
try {
|
|
407
|
+
const result = publishSkillsOnce(options);
|
|
408
|
+
if (options.json) {
|
|
409
|
+
console.log(JSON.stringify(result, null, 2));
|
|
410
|
+
} else if (result.iteration === "published") {
|
|
411
|
+
logger.success(
|
|
412
|
+
`[seq ${result.seq}] published ${result.tree_size} skill(s) → ${result.batch_dir}`,
|
|
413
|
+
);
|
|
414
|
+
logger.log(` ${chalk.bold("Tree head ID:")} ${result.tree_head_id}`);
|
|
415
|
+
} else {
|
|
416
|
+
logger.info(`skipped: ${result.reason}`);
|
|
417
|
+
}
|
|
418
|
+
} catch (err) {
|
|
419
|
+
logger.error(`iteration failed: ${err.message}`);
|
|
420
|
+
if (options.once) throw err;
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
if (options.once) {
|
|
425
|
+
tick();
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
tick();
|
|
430
|
+
const ms = Math.max(1, options.interval) * 1000;
|
|
431
|
+
const timer = setInterval(tick, ms);
|
|
432
|
+
const cleanup = () => {
|
|
433
|
+
clearInterval(timer);
|
|
434
|
+
process.exit(0);
|
|
435
|
+
};
|
|
436
|
+
process.once("SIGINT", cleanup);
|
|
437
|
+
process.once("SIGTERM", cleanup);
|
|
438
|
+
// Daemon: never resolve.
|
|
439
|
+
await new Promise(() => {});
|
|
136
440
|
}
|
|
137
441
|
|
|
442
|
+
// Export internals for unit tests
|
|
443
|
+
export const _publishInternals = {
|
|
444
|
+
publishSkillsOnce,
|
|
445
|
+
loadPublishState,
|
|
446
|
+
savePublishState,
|
|
447
|
+
canonicalSkillsFingerprint,
|
|
448
|
+
PUBLISH_STATE_SCHEMA,
|
|
449
|
+
};
|
|
450
|
+
|
|
138
451
|
export function registerMtcCommand(program) {
|
|
139
452
|
const mtc = program
|
|
140
453
|
.command("mtc")
|
|
@@ -155,7 +468,21 @@ export function registerMtcCommand(program) {
|
|
|
155
468
|
.option("--expires-at <iso>", "Override expires_at timestamp")
|
|
156
469
|
.option(
|
|
157
470
|
"--secret-key-file <path>",
|
|
158
|
-
"Reuse
|
|
471
|
+
"Reuse secret key from hex file (creates one if missing); 32 B for ed25519, 64 B for slh-dsa-128f",
|
|
472
|
+
)
|
|
473
|
+
.option(
|
|
474
|
+
"--alg <name>",
|
|
475
|
+
"Signing algorithm: ed25519 (default, classical) | slh-dsa-128f (FIPS 205 post-quantum)",
|
|
476
|
+
"ed25519",
|
|
477
|
+
)
|
|
478
|
+
.option(
|
|
479
|
+
"--federation <id>",
|
|
480
|
+
"Use federation M-of-N multi-sig (overrides --alg / --secret-key-file; signers come from `cc mtc federation join` registry)",
|
|
481
|
+
)
|
|
482
|
+
.option(
|
|
483
|
+
"--threshold <n>",
|
|
484
|
+
"Federation threshold M (default: N = all members)",
|
|
485
|
+
(v) => parseInt(v, 10),
|
|
159
486
|
)
|
|
160
487
|
.option("--json", "Print JSON summary instead of human output")
|
|
161
488
|
.action(async (inputPath, options) => {
|
|
@@ -165,16 +492,17 @@ export function registerMtcCommand(program) {
|
|
|
165
492
|
throw new Error("Input must be a non-empty JSON array of leaves");
|
|
166
493
|
}
|
|
167
494
|
|
|
168
|
-
const { landmark, envelopes, treeHeadId, keys } =
|
|
169
|
-
rawLeaves,
|
|
170
|
-
{
|
|
495
|
+
const { landmark, envelopes, treeHeadId, keys, signerInfo } =
|
|
496
|
+
buildBatch(rawLeaves, {
|
|
171
497
|
namespace: options.namespace,
|
|
172
498
|
issuer: options.issuer,
|
|
173
499
|
issuedAt: options.issuedAt,
|
|
174
500
|
expiresAt: options.expiresAt,
|
|
175
501
|
secretKeyFile: options.secretKeyFile,
|
|
176
|
-
|
|
177
|
-
|
|
502
|
+
alg: options.alg,
|
|
503
|
+
federation: options.federation,
|
|
504
|
+
threshold: options.threshold,
|
|
505
|
+
});
|
|
178
506
|
|
|
179
507
|
const outDir = path.resolve(options.out);
|
|
180
508
|
const landmarkPath = path.join(outDir, "landmark.json");
|
|
@@ -218,7 +546,7 @@ export function registerMtcCommand(program) {
|
|
|
218
546
|
),
|
|
219
547
|
);
|
|
220
548
|
} else {
|
|
221
|
-
logger.log(STOPGAP_BANNER);
|
|
549
|
+
logger.log(signerInfo ? signerInfo.banner : STOPGAP_BANNER);
|
|
222
550
|
logger.success("Batch built");
|
|
223
551
|
logger.log(` ${chalk.bold("Namespace:")} ${options.namespace}`);
|
|
224
552
|
logger.log(` ${chalk.bold("Tree size:")} ${rawLeaves.length}`);
|
|
@@ -253,7 +581,7 @@ export function registerMtcCommand(program) {
|
|
|
253
581
|
const landmark = readJsonFile(options.landmark);
|
|
254
582
|
|
|
255
583
|
const cache = new LandmarkCache({
|
|
256
|
-
signatureVerifier:
|
|
584
|
+
signatureVerifier: makeMultiAlgVerifier(landmark),
|
|
257
585
|
});
|
|
258
586
|
cache.ingest(landmark);
|
|
259
587
|
|
|
@@ -266,7 +594,7 @@ export function registerMtcCommand(program) {
|
|
|
266
594
|
return;
|
|
267
595
|
}
|
|
268
596
|
if (result.ok) {
|
|
269
|
-
logger.log(STOPGAP_BANNER);
|
|
597
|
+
logger.log(signerInfo ? signerInfo.banner : STOPGAP_BANNER);
|
|
270
598
|
logger.success(`Envelope verified`);
|
|
271
599
|
logger.log(
|
|
272
600
|
` ${chalk.bold("Subject:")} ${result.leaf.subject || "(no subject)"}`,
|
|
@@ -375,7 +703,21 @@ export function registerMtcCommand(program) {
|
|
|
375
703
|
.option("--expires-at <iso>", "Override expires_at timestamp")
|
|
376
704
|
.option(
|
|
377
705
|
"--secret-key-file <path>",
|
|
378
|
-
"Reuse
|
|
706
|
+
"Reuse secret key from hex file (creates one if missing); 32 B for ed25519, 64 B for slh-dsa-128f",
|
|
707
|
+
)
|
|
708
|
+
.option(
|
|
709
|
+
"--alg <name>",
|
|
710
|
+
"Signing algorithm: ed25519 (default, classical) | slh-dsa-128f (FIPS 205 post-quantum)",
|
|
711
|
+
"ed25519",
|
|
712
|
+
)
|
|
713
|
+
.option(
|
|
714
|
+
"--federation <id>",
|
|
715
|
+
"Use federation M-of-N multi-sig (overrides --alg / --secret-key-file; signers come from `cc mtc federation join` registry)",
|
|
716
|
+
)
|
|
717
|
+
.option(
|
|
718
|
+
"--threshold <n>",
|
|
719
|
+
"Federation threshold M (default: N = all members)",
|
|
720
|
+
(v) => parseInt(v, 10),
|
|
379
721
|
)
|
|
380
722
|
.option("--json", "Print JSON summary instead of human output")
|
|
381
723
|
.action(async (options) => {
|
|
@@ -417,16 +759,17 @@ export function registerMtcCommand(program) {
|
|
|
417
759
|
};
|
|
418
760
|
});
|
|
419
761
|
|
|
420
|
-
const { landmark, envelopes, treeHeadId, keys } =
|
|
421
|
-
rawLeaves,
|
|
422
|
-
{
|
|
762
|
+
const { landmark, envelopes, treeHeadId, keys, signerInfo } =
|
|
763
|
+
buildBatch(rawLeaves, {
|
|
423
764
|
namespace: options.namespace,
|
|
424
765
|
issuer: options.issuer,
|
|
425
766
|
issuedAt: options.issuedAt,
|
|
426
767
|
expiresAt: options.expiresAt,
|
|
427
768
|
secretKeyFile: options.secretKeyFile,
|
|
428
|
-
|
|
429
|
-
|
|
769
|
+
alg: options.alg,
|
|
770
|
+
federation: options.federation,
|
|
771
|
+
threshold: options.threshold,
|
|
772
|
+
});
|
|
430
773
|
|
|
431
774
|
const outDir = path.resolve(options.out);
|
|
432
775
|
const landmarkPath = path.join(outDir, "landmark.json");
|
|
@@ -472,7 +815,7 @@ export function registerMtcCommand(program) {
|
|
|
472
815
|
),
|
|
473
816
|
);
|
|
474
817
|
} else {
|
|
475
|
-
logger.log(STOPGAP_BANNER);
|
|
818
|
+
logger.log(signerInfo ? signerInfo.banner : STOPGAP_BANNER);
|
|
476
819
|
logger.success(`Batched ${rawLeaves.length} DID(s)`);
|
|
477
820
|
logger.log(` ${chalk.bold("Namespace:")} ${options.namespace}`);
|
|
478
821
|
logger.log(` ${chalk.bold("Tree size:")} ${rawLeaves.length}`);
|
|
@@ -514,7 +857,21 @@ export function registerMtcCommand(program) {
|
|
|
514
857
|
.option("--expires-at <iso>", "Override expires_at timestamp")
|
|
515
858
|
.option(
|
|
516
859
|
"--secret-key-file <path>",
|
|
517
|
-
"Reuse
|
|
860
|
+
"Reuse secret key from hex file (creates one if missing); 32 B for ed25519, 64 B for slh-dsa-128f",
|
|
861
|
+
)
|
|
862
|
+
.option(
|
|
863
|
+
"--alg <name>",
|
|
864
|
+
"Signing algorithm: ed25519 (default, classical) | slh-dsa-128f (FIPS 205 post-quantum)",
|
|
865
|
+
"ed25519",
|
|
866
|
+
)
|
|
867
|
+
.option(
|
|
868
|
+
"--federation <id>",
|
|
869
|
+
"Use federation M-of-N multi-sig (overrides --alg / --secret-key-file; signers come from `cc mtc federation join` registry)",
|
|
870
|
+
)
|
|
871
|
+
.option(
|
|
872
|
+
"--threshold <n>",
|
|
873
|
+
"Federation threshold M (default: N = all members)",
|
|
874
|
+
(v) => parseInt(v, 10),
|
|
518
875
|
)
|
|
519
876
|
.option("--json", "Print JSON summary instead of human output")
|
|
520
877
|
.action(async (options) => {
|
|
@@ -563,16 +920,17 @@ export function registerMtcCommand(program) {
|
|
|
563
920
|
};
|
|
564
921
|
});
|
|
565
922
|
|
|
566
|
-
const { landmark, envelopes, treeHeadId, keys } =
|
|
567
|
-
rawLeaves,
|
|
568
|
-
{
|
|
923
|
+
const { landmark, envelopes, treeHeadId, keys, signerInfo } =
|
|
924
|
+
buildBatch(rawLeaves, {
|
|
569
925
|
namespace: options.namespace,
|
|
570
926
|
issuer: options.issuer,
|
|
571
927
|
issuedAt: options.issuedAt,
|
|
572
928
|
expiresAt: options.expiresAt,
|
|
573
929
|
secretKeyFile: options.secretKeyFile,
|
|
574
|
-
|
|
575
|
-
|
|
930
|
+
alg: options.alg,
|
|
931
|
+
federation: options.federation,
|
|
932
|
+
threshold: options.threshold,
|
|
933
|
+
});
|
|
576
934
|
|
|
577
935
|
const outDir = path.resolve(options.out);
|
|
578
936
|
const landmarkPath = path.join(outDir, "landmark.json");
|
|
@@ -618,7 +976,7 @@ export function registerMtcCommand(program) {
|
|
|
618
976
|
),
|
|
619
977
|
);
|
|
620
978
|
} else {
|
|
621
|
-
logger.log(STOPGAP_BANNER);
|
|
979
|
+
logger.log(signerInfo ? signerInfo.banner : STOPGAP_BANNER);
|
|
622
980
|
logger.success(`Batched ${rawLeaves.length} skill(s)`);
|
|
623
981
|
logger.log(` ${chalk.bold("Namespace:")} ${options.namespace}`);
|
|
624
982
|
logger.log(` ${chalk.bold("Tree size:")} ${rawLeaves.length}`);
|
|
@@ -641,6 +999,154 @@ export function registerMtcCommand(program) {
|
|
|
641
999
|
}
|
|
642
1000
|
});
|
|
643
1001
|
|
|
1002
|
+
// mtc publish-skills — marketplace publisher daemon
|
|
1003
|
+
// Periodically scans CLISkillLoader, detects deltas via a fingerprint, and
|
|
1004
|
+
// when the skill set changes auto-closes a new batch (assembleBatch) into
|
|
1005
|
+
// <out>/<seq>/. Stateful via a JSON state file so re-runs skip unchanged sets.
|
|
1006
|
+
//
|
|
1007
|
+
// Phase 2 marketplace path — does NOT depend on the audit Q-COMP blockers.
|
|
1008
|
+
mtc
|
|
1009
|
+
.command("publish-skills")
|
|
1010
|
+
.description(
|
|
1011
|
+
"Marketplace publisher daemon: detect skill deltas + auto-close batches",
|
|
1012
|
+
)
|
|
1013
|
+
.requiredOption(
|
|
1014
|
+
"--namespace-prefix <prefix>",
|
|
1015
|
+
"Namespace prefix; seq is auto-appended (e.g. mtc/v1/skill)",
|
|
1016
|
+
)
|
|
1017
|
+
.requiredOption("--issuer <issuer>", "MTCA issuer string")
|
|
1018
|
+
.requiredOption(
|
|
1019
|
+
"--out <dir>",
|
|
1020
|
+
"Output root directory (each batch lands in <out>/<seq>/)",
|
|
1021
|
+
)
|
|
1022
|
+
.requiredOption(
|
|
1023
|
+
"--state-file <path>",
|
|
1024
|
+
"State JSON file tracking last_seq + fingerprint",
|
|
1025
|
+
)
|
|
1026
|
+
.option(
|
|
1027
|
+
"--secret-key-file <path>",
|
|
1028
|
+
"Reuse secret key from hex file (creates one if missing); 32 B for ed25519, 64 B for slh-dsa-128f",
|
|
1029
|
+
)
|
|
1030
|
+
.option(
|
|
1031
|
+
"--alg <name>",
|
|
1032
|
+
"Signing algorithm: ed25519 (default, classical) | slh-dsa-128f (FIPS 205 post-quantum)",
|
|
1033
|
+
"ed25519",
|
|
1034
|
+
)
|
|
1035
|
+
.option(
|
|
1036
|
+
"--federation <id>",
|
|
1037
|
+
"Use federation M-of-N multi-sig (overrides --alg / --secret-key-file; signers come from `cc mtc federation join` registry)",
|
|
1038
|
+
)
|
|
1039
|
+
.option(
|
|
1040
|
+
"--threshold <n>",
|
|
1041
|
+
"Federation threshold M (default: N = all members)",
|
|
1042
|
+
(v) => parseInt(v, 10),
|
|
1043
|
+
)
|
|
1044
|
+
.option(
|
|
1045
|
+
"--interval <seconds>",
|
|
1046
|
+
"Loop interval (default: 600 = 10min, ignored if --once)",
|
|
1047
|
+
(v) => parseInt(v, 10),
|
|
1048
|
+
600,
|
|
1049
|
+
)
|
|
1050
|
+
.option("--once", "Run a single iteration and exit (test/CI use)")
|
|
1051
|
+
.option(
|
|
1052
|
+
"--skill <id>",
|
|
1053
|
+
"Restrict to specific skill ids (repeatable)",
|
|
1054
|
+
(v, prev) => [...(prev || []), v],
|
|
1055
|
+
)
|
|
1056
|
+
.option("--json", "Print JSON summary on each iteration")
|
|
1057
|
+
.action(async (options) => {
|
|
1058
|
+
try {
|
|
1059
|
+
await publishSkillsLoop(options);
|
|
1060
|
+
} catch (err) {
|
|
1061
|
+
logger.error(`mtc publish-skills failed: ${err.message}`);
|
|
1062
|
+
process.exit(1);
|
|
1063
|
+
}
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
// mtc publish-status — read-only inspector for publish-skills state file.
|
|
1067
|
+
// Used by web-panel (browser can't read the filesystem directly; this gives
|
|
1068
|
+
// it a CLI-bridge-friendly query path without exposing the daemon machinery).
|
|
1069
|
+
mtc
|
|
1070
|
+
.command("publish-status <state-file>")
|
|
1071
|
+
.description(
|
|
1072
|
+
"Read a publish-skills state file and print its current state + recent history",
|
|
1073
|
+
)
|
|
1074
|
+
.option(
|
|
1075
|
+
"--limit <n>",
|
|
1076
|
+
"Limit history entries (default: 20, latest first)",
|
|
1077
|
+
(v) => parseInt(v, 10),
|
|
1078
|
+
20,
|
|
1079
|
+
)
|
|
1080
|
+
.option("--json", "Output JSON (default: human)")
|
|
1081
|
+
.action((stateFile, options) => {
|
|
1082
|
+
try {
|
|
1083
|
+
if (!fs.existsSync(stateFile)) {
|
|
1084
|
+
if (options.json) {
|
|
1085
|
+
console.log(
|
|
1086
|
+
JSON.stringify(
|
|
1087
|
+
{ ok: true, exists: false, state_file: stateFile },
|
|
1088
|
+
null,
|
|
1089
|
+
2,
|
|
1090
|
+
),
|
|
1091
|
+
);
|
|
1092
|
+
} else {
|
|
1093
|
+
logger.warn(`state file not found: ${stateFile}`);
|
|
1094
|
+
}
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
const state = loadPublishState(stateFile);
|
|
1098
|
+
const history = Array.isArray(state.history) ? state.history : [];
|
|
1099
|
+
const limited = history.slice().reverse().slice(0, options.limit);
|
|
1100
|
+
if (options.json) {
|
|
1101
|
+
console.log(
|
|
1102
|
+
JSON.stringify(
|
|
1103
|
+
{
|
|
1104
|
+
ok: true,
|
|
1105
|
+
exists: true,
|
|
1106
|
+
state_file: stateFile,
|
|
1107
|
+
last_seq: state.last_seq,
|
|
1108
|
+
last_fingerprint: state.last_fingerprint,
|
|
1109
|
+
last_published_at: state.last_published_at,
|
|
1110
|
+
history_count: history.length,
|
|
1111
|
+
history: limited,
|
|
1112
|
+
},
|
|
1113
|
+
null,
|
|
1114
|
+
2,
|
|
1115
|
+
),
|
|
1116
|
+
);
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
logger.log(chalk.bold(`Publish state: ${stateFile}`));
|
|
1120
|
+
logger.log(` ${chalk.bold("Last seq:")} ${state.last_seq}`);
|
|
1121
|
+
logger.log(
|
|
1122
|
+
` ${chalk.bold("Last published:")} ${state.last_published_at || "(never)"}`,
|
|
1123
|
+
);
|
|
1124
|
+
logger.log(` ${chalk.bold("History entries:")} ${history.length}`);
|
|
1125
|
+
if (limited.length > 0) {
|
|
1126
|
+
logger.log("");
|
|
1127
|
+
logger.log(chalk.bold(`Recent history (latest ${limited.length}):`));
|
|
1128
|
+
for (const h of limited) {
|
|
1129
|
+
logger.log(
|
|
1130
|
+
` ${chalk.cyan(h.seq)} ${h.namespace} size=${h.tree_size} ${chalk.gray(h.published_at)}`,
|
|
1131
|
+
);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
} catch (err) {
|
|
1135
|
+
if (options.json) {
|
|
1136
|
+
console.log(
|
|
1137
|
+
JSON.stringify(
|
|
1138
|
+
{ ok: false, error: err.message, state_file: stateFile },
|
|
1139
|
+
null,
|
|
1140
|
+
2,
|
|
1141
|
+
),
|
|
1142
|
+
);
|
|
1143
|
+
} else {
|
|
1144
|
+
logger.error(`mtc publish-status failed: ${err.message}`);
|
|
1145
|
+
}
|
|
1146
|
+
process.exit(1);
|
|
1147
|
+
}
|
|
1148
|
+
});
|
|
1149
|
+
|
|
644
1150
|
// mtc serve — verifier daemon: subscribe to a transport, persist + verify
|
|
645
1151
|
mtc
|
|
646
1152
|
.command("serve")
|
|
@@ -734,7 +1240,7 @@ export function registerMtcCommand(program) {
|
|
|
734
1240
|
// Lazy-init cache from first landmark's trust_anchors
|
|
735
1241
|
if (!cache) {
|
|
736
1242
|
cache = new LandmarkCache({
|
|
737
|
-
signatureVerifier:
|
|
1243
|
+
signatureVerifier: makeMultiAlgVerifier(landmark),
|
|
738
1244
|
persistDir: options.cacheDir,
|
|
739
1245
|
});
|
|
740
1246
|
if (options.cacheDir) {
|
|
@@ -783,4 +1289,2147 @@ export function registerMtcCommand(program) {
|
|
|
783
1289
|
process.exit(1);
|
|
784
1290
|
}
|
|
785
1291
|
});
|
|
1292
|
+
|
|
1293
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
1294
|
+
// Phase 3 federation MTCA commands
|
|
1295
|
+
// M-of-N multi-sig is implemented in core-mtc/lib/batch.js;
|
|
1296
|
+
// federation member tracking lives in ~/.chainlesschain/federation/
|
|
1297
|
+
// members.json (one entry per joined federation, keyed by federation id).
|
|
1298
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
1299
|
+
registerFederationCommands(mtc);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1303
|
+
// Federation member registry helpers (Phase 3.1)
|
|
1304
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1305
|
+
|
|
1306
|
+
const FEDERATION_REGISTRY_SCHEMA = "mtc-federation-registry/v1";
|
|
1307
|
+
|
|
1308
|
+
function getFederationDir() {
|
|
1309
|
+
const home = getHomeDir();
|
|
1310
|
+
return path.join(home, "federation");
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
function getFederationRegistryPath() {
|
|
1314
|
+
return path.join(getFederationDir(), "members.json");
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
function loadFederationRegistry() {
|
|
1318
|
+
const file = getFederationRegistryPath();
|
|
1319
|
+
if (!fs.existsSync(file)) {
|
|
1320
|
+
return { schema: FEDERATION_REGISTRY_SCHEMA, federations: {} };
|
|
1321
|
+
}
|
|
1322
|
+
const obj = readJsonFile(file);
|
|
1323
|
+
if (obj.schema !== FEDERATION_REGISTRY_SCHEMA) {
|
|
1324
|
+
throw new Error(
|
|
1325
|
+
`federation registry has unknown schema: ${obj.schema} (expected ${FEDERATION_REGISTRY_SCHEMA})`,
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
if (!obj.federations || typeof obj.federations !== "object") {
|
|
1329
|
+
obj.federations = {};
|
|
1330
|
+
}
|
|
1331
|
+
return obj;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function saveFederationRegistry(registry) {
|
|
1335
|
+
const file = getFederationRegistryPath();
|
|
1336
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1337
|
+
// Atomic write to survive crash mid-write
|
|
1338
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
1339
|
+
fs.writeFileSync(tmp, JSON.stringify(registry, null, 2), "utf-8");
|
|
1340
|
+
fs.renameSync(tmp, file);
|
|
786
1341
|
}
|
|
1342
|
+
|
|
1343
|
+
function registerFederationCommands(mtc) {
|
|
1344
|
+
const fed = mtc
|
|
1345
|
+
.command("federation")
|
|
1346
|
+
.description("Phase 3 federation MTCA — manage M-of-N member registry");
|
|
1347
|
+
|
|
1348
|
+
// mtc federation join <federation-id>
|
|
1349
|
+
fed
|
|
1350
|
+
.command("join <federation-id>")
|
|
1351
|
+
.description(
|
|
1352
|
+
"Join a federation: generate a member keypair (or reuse existing) and register it locally",
|
|
1353
|
+
)
|
|
1354
|
+
.requiredOption(
|
|
1355
|
+
"--member-id <id>",
|
|
1356
|
+
"Local member identifier within the federation",
|
|
1357
|
+
)
|
|
1358
|
+
.option(
|
|
1359
|
+
"--alg <name>",
|
|
1360
|
+
"Signing algorithm: ed25519 (default) | slh-dsa-128f",
|
|
1361
|
+
"ed25519",
|
|
1362
|
+
)
|
|
1363
|
+
.option(
|
|
1364
|
+
"--issuer <issuer>",
|
|
1365
|
+
"Member-level issuer string (default: mtca:cc:<federation-id>:<member-id>)",
|
|
1366
|
+
)
|
|
1367
|
+
.option("--key-file <path>", "Reuse existing secret key from hex file")
|
|
1368
|
+
.option("--json", "Print JSON summary")
|
|
1369
|
+
.action((federationId, options) => {
|
|
1370
|
+
try {
|
|
1371
|
+
const sig = resolveSigner(options.alg);
|
|
1372
|
+
const issuer =
|
|
1373
|
+
options.issuer || `mtca:cc:${federationId}:${options.memberId}`;
|
|
1374
|
+
const registry = loadFederationRegistry();
|
|
1375
|
+
const fedEntry = registry.federations[federationId] || {
|
|
1376
|
+
federation_id: federationId,
|
|
1377
|
+
members: {},
|
|
1378
|
+
joined_at: new Date().toISOString(),
|
|
1379
|
+
};
|
|
1380
|
+
if (fedEntry.members[options.memberId]) {
|
|
1381
|
+
throw new Error(
|
|
1382
|
+
`member "${options.memberId}" already registered in federation "${federationId}" — leave first to rejoin`,
|
|
1383
|
+
);
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// Generate or load the keypair
|
|
1387
|
+
let keys;
|
|
1388
|
+
if (options.keyFile && fs.existsSync(options.keyFile)) {
|
|
1389
|
+
keys = loadOrGenerateKeyPair(options.keyFile, sig);
|
|
1390
|
+
} else {
|
|
1391
|
+
keys = sig.signer.generateKeyPair();
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// Persist the member's secret key under federation/keys/.
|
|
1395
|
+
// Race-safe: 'wx' fails if another concurrent join already won, in
|
|
1396
|
+
// which case we stick with that file rather than overwrite. Member
|
|
1397
|
+
// re-join is blocked at the registry level above, so this only
|
|
1398
|
+
// guards against simultaneous-join racing the same path.
|
|
1399
|
+
const keysDir = path.join(getFederationDir(), "keys");
|
|
1400
|
+
fs.mkdirSync(keysDir, { recursive: true });
|
|
1401
|
+
const keyPath = path.join(
|
|
1402
|
+
keysDir,
|
|
1403
|
+
`${federationId}.${options.memberId}.hex`,
|
|
1404
|
+
);
|
|
1405
|
+
try {
|
|
1406
|
+
fs.writeFileSync(keyPath, keys.secretKey.toString("hex"), {
|
|
1407
|
+
mode: 0o600,
|
|
1408
|
+
flag: "wx",
|
|
1409
|
+
});
|
|
1410
|
+
} catch (err) {
|
|
1411
|
+
if (err.code !== "EEXIST") throw err;
|
|
1412
|
+
// Concurrent join wrote the file first — re-load from disk and
|
|
1413
|
+
// align our keys so registry + key file match.
|
|
1414
|
+
keys = loadOrGenerateKeyPair(keyPath, sig);
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
const trustAnchor = sig.signer.trustAnchorEntry(keys.publicKey, issuer);
|
|
1418
|
+
fedEntry.members[options.memberId] = {
|
|
1419
|
+
member_id: options.memberId,
|
|
1420
|
+
issuer,
|
|
1421
|
+
alg: sig.signer.ALG,
|
|
1422
|
+
pubkey_id: trustAnchor.pubkey_id,
|
|
1423
|
+
pubkey_jwk: trustAnchor.pubkey_jwk,
|
|
1424
|
+
key_file: keyPath,
|
|
1425
|
+
joined_at: new Date().toISOString(),
|
|
1426
|
+
};
|
|
1427
|
+
registry.federations[federationId] = fedEntry;
|
|
1428
|
+
saveFederationRegistry(registry);
|
|
1429
|
+
|
|
1430
|
+
if (options.json) {
|
|
1431
|
+
console.log(
|
|
1432
|
+
JSON.stringify(
|
|
1433
|
+
{
|
|
1434
|
+
ok: true,
|
|
1435
|
+
federation_id: federationId,
|
|
1436
|
+
member_id: options.memberId,
|
|
1437
|
+
issuer,
|
|
1438
|
+
alg: sig.signer.ALG,
|
|
1439
|
+
pubkey_id: trustAnchor.pubkey_id,
|
|
1440
|
+
key_file: keyPath,
|
|
1441
|
+
},
|
|
1442
|
+
null,
|
|
1443
|
+
2,
|
|
1444
|
+
),
|
|
1445
|
+
);
|
|
1446
|
+
} else {
|
|
1447
|
+
logger.success(
|
|
1448
|
+
`joined federation "${federationId}" as "${options.memberId}"`,
|
|
1449
|
+
);
|
|
1450
|
+
logger.log(` ${chalk.bold("Issuer:")} ${issuer}`);
|
|
1451
|
+
logger.log(` ${chalk.bold("Algorithm:")} ${sig.signer.ALG}`);
|
|
1452
|
+
logger.log(` ${chalk.bold("Pubkey id:")} ${trustAnchor.pubkey_id}`);
|
|
1453
|
+
logger.log(` ${chalk.bold("Key file:")} ${chalk.cyan(keyPath)}`);
|
|
1454
|
+
}
|
|
1455
|
+
} catch (err) {
|
|
1456
|
+
logger.error(`mtc federation join failed: ${err.message}`);
|
|
1457
|
+
process.exit(1);
|
|
1458
|
+
}
|
|
1459
|
+
});
|
|
1460
|
+
|
|
1461
|
+
// mtc federation leave <federation-id> --member-id <id>
|
|
1462
|
+
fed
|
|
1463
|
+
.command("leave <federation-id>")
|
|
1464
|
+
.description(
|
|
1465
|
+
"Leave a federation: remove the member entry from the local registry",
|
|
1466
|
+
)
|
|
1467
|
+
.requiredOption("--member-id <id>", "Member id to remove")
|
|
1468
|
+
.option(
|
|
1469
|
+
"--keep-key",
|
|
1470
|
+
"Keep the secret key file on disk (default: removes the key file as well)",
|
|
1471
|
+
)
|
|
1472
|
+
.option("--json", "Print JSON summary")
|
|
1473
|
+
.action((federationId, options) => {
|
|
1474
|
+
try {
|
|
1475
|
+
const registry = loadFederationRegistry();
|
|
1476
|
+
const fedEntry = registry.federations[federationId];
|
|
1477
|
+
if (!fedEntry || !fedEntry.members[options.memberId]) {
|
|
1478
|
+
throw new Error(
|
|
1479
|
+
`member "${options.memberId}" not found in federation "${federationId}"`,
|
|
1480
|
+
);
|
|
1481
|
+
}
|
|
1482
|
+
const member = fedEntry.members[options.memberId];
|
|
1483
|
+
delete fedEntry.members[options.memberId];
|
|
1484
|
+
if (Object.keys(fedEntry.members).length === 0) {
|
|
1485
|
+
delete registry.federations[federationId];
|
|
1486
|
+
}
|
|
1487
|
+
saveFederationRegistry(registry);
|
|
1488
|
+
|
|
1489
|
+
if (
|
|
1490
|
+
!options.keepKey &&
|
|
1491
|
+
member.key_file &&
|
|
1492
|
+
fs.existsSync(member.key_file)
|
|
1493
|
+
) {
|
|
1494
|
+
try {
|
|
1495
|
+
fs.unlinkSync(member.key_file);
|
|
1496
|
+
} catch (_err) {
|
|
1497
|
+
/* non-fatal */
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
if (options.json) {
|
|
1502
|
+
console.log(
|
|
1503
|
+
JSON.stringify(
|
|
1504
|
+
{
|
|
1505
|
+
ok: true,
|
|
1506
|
+
federation_id: federationId,
|
|
1507
|
+
member_id: options.memberId,
|
|
1508
|
+
key_file_removed: !options.keepKey,
|
|
1509
|
+
},
|
|
1510
|
+
null,
|
|
1511
|
+
2,
|
|
1512
|
+
),
|
|
1513
|
+
);
|
|
1514
|
+
} else {
|
|
1515
|
+
logger.success(
|
|
1516
|
+
`left federation "${federationId}" — member "${options.memberId}" removed${
|
|
1517
|
+
options.keepKey ? "" : " (key file deleted)"
|
|
1518
|
+
}`,
|
|
1519
|
+
);
|
|
1520
|
+
}
|
|
1521
|
+
} catch (err) {
|
|
1522
|
+
logger.error(`mtc federation leave failed: ${err.message}`);
|
|
1523
|
+
process.exit(1);
|
|
1524
|
+
}
|
|
1525
|
+
});
|
|
1526
|
+
|
|
1527
|
+
// mtc federation status [federation-id]
|
|
1528
|
+
fed
|
|
1529
|
+
.command("status [federation-id]")
|
|
1530
|
+
.description("Show registered federations and their members")
|
|
1531
|
+
.option("--json", "Output JSON")
|
|
1532
|
+
.action((federationId, options) => {
|
|
1533
|
+
try {
|
|
1534
|
+
const registry = loadFederationRegistry();
|
|
1535
|
+
const data = federationId
|
|
1536
|
+
? { [federationId]: registry.federations[federationId] || null }
|
|
1537
|
+
: registry.federations;
|
|
1538
|
+
|
|
1539
|
+
if (options.json) {
|
|
1540
|
+
console.log(JSON.stringify({ ok: true, federations: data }, null, 2));
|
|
1541
|
+
return;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
const ids = Object.keys(data).filter((k) => data[k]);
|
|
1545
|
+
if (ids.length === 0) {
|
|
1546
|
+
logger.info(
|
|
1547
|
+
"no federations registered (run `cc mtc federation join <id> --member-id <m>`)",
|
|
1548
|
+
);
|
|
1549
|
+
return;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
for (const id of ids) {
|
|
1553
|
+
const f = data[id];
|
|
1554
|
+
const memberCount = Object.keys(f.members || {}).length;
|
|
1555
|
+
logger.log(chalk.bold(`Federation: ${chalk.cyan(id)}`));
|
|
1556
|
+
logger.log(` ${chalk.bold("Joined at:")} ${f.joined_at || "—"}`);
|
|
1557
|
+
logger.log(` ${chalk.bold("Members:")} ${memberCount}`);
|
|
1558
|
+
for (const m of Object.values(f.members || {})) {
|
|
1559
|
+
logger.log(
|
|
1560
|
+
` · ${chalk.green(m.member_id)} (${m.alg}) ${chalk.gray(m.pubkey_id.slice(0, 18) + "…")}`,
|
|
1561
|
+
);
|
|
1562
|
+
logger.log(` issuer: ${m.issuer}`);
|
|
1563
|
+
logger.log(` key: ${chalk.gray(m.key_file)}`);
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
} catch (err) {
|
|
1567
|
+
logger.error(`mtc federation status failed: ${err.message}`);
|
|
1568
|
+
process.exit(1);
|
|
1569
|
+
}
|
|
1570
|
+
});
|
|
1571
|
+
|
|
1572
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
1573
|
+
// Phase 3.3 — federation discovery via filesystem drop-zone.
|
|
1574
|
+
// Each member periodically writes a self-signed announce to a shared
|
|
1575
|
+
// directory (NFS / SMB / Syncthing / USB stick). Other nodes scan the
|
|
1576
|
+
// dir + ingest valid announces into a TTL-evicting roster cache.
|
|
1577
|
+
//
|
|
1578
|
+
// Production note: real libp2p gossipsub-based discovery (auto-announce
|
|
1579
|
+
// on a pubsub topic) is the natural next layer — the announce schema +
|
|
1580
|
+
// verify + cache are transport-agnostic, so wiring gossipsub is purely
|
|
1581
|
+
// a delivery question. Filesystem mode covers LAN / shared-fs / offline
|
|
1582
|
+
// use cases without any p2p network code.
|
|
1583
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
1584
|
+
fed
|
|
1585
|
+
.command("discover <federation-id>")
|
|
1586
|
+
.description(
|
|
1587
|
+
"Subscribe to federation announces via filesystem drop-zone or libp2p gossipsub",
|
|
1588
|
+
)
|
|
1589
|
+
.option(
|
|
1590
|
+
"--transport <kind>",
|
|
1591
|
+
"Transport: filesystem (default, --drop-zone required) | libp2p (--listen + --connect)",
|
|
1592
|
+
"filesystem",
|
|
1593
|
+
)
|
|
1594
|
+
.option(
|
|
1595
|
+
"--drop-zone <dir>",
|
|
1596
|
+
"[filesystem] Shared directory all federation members read+write to (NFS / Syncthing / SMB)",
|
|
1597
|
+
)
|
|
1598
|
+
.option(
|
|
1599
|
+
"--listen <multiaddr>",
|
|
1600
|
+
"[libp2p] Listen address (default: /ip4/127.0.0.1/tcp/0)",
|
|
1601
|
+
)
|
|
1602
|
+
.option(
|
|
1603
|
+
"--connect <multiaddr>",
|
|
1604
|
+
"[libp2p] Dial this peer on startup (repeatable)",
|
|
1605
|
+
(v, prev) => [...(prev || []), v],
|
|
1606
|
+
)
|
|
1607
|
+
.option(
|
|
1608
|
+
"--member-id <id>",
|
|
1609
|
+
"If joined as this member, also publish a self-announce (omit = listen-only mode)",
|
|
1610
|
+
)
|
|
1611
|
+
.option(
|
|
1612
|
+
"--ttl <seconds>",
|
|
1613
|
+
"Announce TTL (default 600 = 10 min); a re-announce fires at TTL/3",
|
|
1614
|
+
(v) => parseInt(v, 10),
|
|
1615
|
+
600,
|
|
1616
|
+
)
|
|
1617
|
+
.option("--once", "Announce once + scan once + exit (test/CI use)")
|
|
1618
|
+
.option(
|
|
1619
|
+
"--cache-dir <dir>",
|
|
1620
|
+
"Persist accepted announces to this dir for restart resume",
|
|
1621
|
+
)
|
|
1622
|
+
.option(
|
|
1623
|
+
"--scan-interval <seconds>",
|
|
1624
|
+
"[filesystem] Drop-zone poll interval (default 30)",
|
|
1625
|
+
(v) => parseInt(v, 10),
|
|
1626
|
+
30,
|
|
1627
|
+
)
|
|
1628
|
+
.option(
|
|
1629
|
+
"--mesh-wait-ms <n>",
|
|
1630
|
+
"[libp2p] Mesh formation wait before announce (default 1500)",
|
|
1631
|
+
(v) => parseInt(v, 10),
|
|
1632
|
+
1500,
|
|
1633
|
+
)
|
|
1634
|
+
.option("--json", "Print JSON status snapshot (used with --once)")
|
|
1635
|
+
.action(async (federationId, options) => {
|
|
1636
|
+
try {
|
|
1637
|
+
await runFederationDiscover(federationId, options);
|
|
1638
|
+
} catch (err) {
|
|
1639
|
+
logger.error(`mtc federation discover failed: ${err.message}`);
|
|
1640
|
+
process.exit(1);
|
|
1641
|
+
}
|
|
1642
|
+
});
|
|
1643
|
+
|
|
1644
|
+
registerFederationGovernanceCommands(fed);
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1648
|
+
// Federation governance log (MTC_联邦治理_v1.md §9.1) — 8 subcommands
|
|
1649
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1650
|
+
|
|
1651
|
+
function getGovernanceLogPath(federationId) {
|
|
1652
|
+
return path.join(getFederationDir(), "governance", `${federationId}.jsonl`);
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
function loadGovernanceLog(federationId) {
|
|
1656
|
+
const file = getGovernanceLogPath(federationId);
|
|
1657
|
+
if (!fs.existsSync(file)) return [];
|
|
1658
|
+
const raw = fs.readFileSync(file, "utf-8");
|
|
1659
|
+
const events = [];
|
|
1660
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
1661
|
+
if (!line.trim()) continue;
|
|
1662
|
+
try {
|
|
1663
|
+
events.push(JSON.parse(line));
|
|
1664
|
+
} catch (_err) {
|
|
1665
|
+
/* skip corrupt line — replay-from-source-of-truth, don't crash */
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
return events;
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
function appendGovernanceEvent(federationId, event) {
|
|
1672
|
+
const file = getGovernanceLogPath(federationId);
|
|
1673
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1674
|
+
fs.appendFileSync(file, JSON.stringify(event) + "\n", "utf-8");
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
/**
|
|
1678
|
+
* Look up a member's keys + alg for signing a governance event as that
|
|
1679
|
+
* member. Throws if not joined.
|
|
1680
|
+
*/
|
|
1681
|
+
function loadMemberSigner(federationId, memberId) {
|
|
1682
|
+
const registry = loadFederationRegistry();
|
|
1683
|
+
const fedEntry = registry.federations[federationId];
|
|
1684
|
+
if (!fedEntry || !fedEntry.members[memberId]) {
|
|
1685
|
+
throw new Error(
|
|
1686
|
+
`not joined as "${memberId}" in federation "${federationId}" — \`cc mtc federation join ${federationId} --member-id ${memberId}\` first`,
|
|
1687
|
+
);
|
|
1688
|
+
}
|
|
1689
|
+
const member = fedEntry.members[memberId];
|
|
1690
|
+
if (!member.key_file || !fs.existsSync(member.key_file)) {
|
|
1691
|
+
throw new Error(`member key file missing: ${member.key_file}`);
|
|
1692
|
+
}
|
|
1693
|
+
let signerInfo;
|
|
1694
|
+
if (member.alg === "Ed25519") signerInfo = resolveSigner("ed25519");
|
|
1695
|
+
else if (member.alg === "SLH-DSA-SHA2-128F")
|
|
1696
|
+
signerInfo = resolveSigner("slh-dsa-128f");
|
|
1697
|
+
else throw new Error(`unknown member alg: ${member.alg}`);
|
|
1698
|
+
const keys = loadOrGenerateKeyPair(member.key_file, signerInfo);
|
|
1699
|
+
return { member, keys, alg: member.alg };
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
function emitAndPersist(federationId, params) {
|
|
1703
|
+
const event = mtcLib.createGovernanceEvent({
|
|
1704
|
+
federationId,
|
|
1705
|
+
...params,
|
|
1706
|
+
});
|
|
1707
|
+
appendGovernanceEvent(federationId, event);
|
|
1708
|
+
return event;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
/**
|
|
1712
|
+
* Pre-flight check that a confirm-* event has its matching propose-*.
|
|
1713
|
+
* Reads the local governance log + checks for an unresolved proposal.
|
|
1714
|
+
* Throws on missing — caller decides whether to log a warning or hard-fail.
|
|
1715
|
+
*
|
|
1716
|
+
* @param {string} federationId
|
|
1717
|
+
* @param {string} proposalType — "propose-revoke" | "propose-threshold"
|
|
1718
|
+
* @param {(payload: object) => boolean} matcher — returns true when payload matches
|
|
1719
|
+
*/
|
|
1720
|
+
function requireOpenProposal(federationId, proposalType, matcher) {
|
|
1721
|
+
const events = loadGovernanceLog(federationId);
|
|
1722
|
+
// Walk forward; an open proposal is one not yet matched by a confirm of
|
|
1723
|
+
// the same target. v0.1 quorum gating only checks "is there at least one
|
|
1724
|
+
// proposal event with a matching target" — full quorum cooldown logic
|
|
1725
|
+
// lives in lib replay, this is a CLI-side guardrail.
|
|
1726
|
+
// Matcher signature: (payload, event) => boolean
|
|
1727
|
+
const open = events.some(
|
|
1728
|
+
(e) =>
|
|
1729
|
+
e && e.event_type === proposalType && e.payload && matcher(e.payload, e),
|
|
1730
|
+
);
|
|
1731
|
+
if (!open) {
|
|
1732
|
+
throw new Error(
|
|
1733
|
+
`no open ${proposalType} proposal matches — emit ${proposalType} first`,
|
|
1734
|
+
);
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
/**
|
|
1739
|
+
* Internal helper used by both the `governance-publish` CLI handler and
|
|
1740
|
+
* the `governance-sync-serve` daemon. Pure side-effect free aside from
|
|
1741
|
+
* filesystem writes to <drop-zone>/federation-governance/<fed>/.
|
|
1742
|
+
*
|
|
1743
|
+
* @returns {{ federation_id, drop_zone, local_total, published, skipped }}
|
|
1744
|
+
*/
|
|
1745
|
+
function runGovernancePublish(federationId, dropZone) {
|
|
1746
|
+
const events = loadGovernanceLog(federationId);
|
|
1747
|
+
const targetDir = path.join(dropZone, "federation-governance", federationId);
|
|
1748
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
1749
|
+
let published = 0;
|
|
1750
|
+
let skipped = 0;
|
|
1751
|
+
for (const ev of events) {
|
|
1752
|
+
if (!ev || typeof ev.event_id !== "string") continue;
|
|
1753
|
+
const target = path.join(targetDir, `${ev.event_id}.json`);
|
|
1754
|
+
if (fs.existsSync(target)) {
|
|
1755
|
+
skipped++;
|
|
1756
|
+
continue;
|
|
1757
|
+
}
|
|
1758
|
+
const tmp = `${target}.${process.pid}.tmp`;
|
|
1759
|
+
fs.writeFileSync(tmp, JSON.stringify(ev, null, 2), "utf-8");
|
|
1760
|
+
fs.renameSync(tmp, target);
|
|
1761
|
+
published++;
|
|
1762
|
+
}
|
|
1763
|
+
return {
|
|
1764
|
+
federation_id: federationId,
|
|
1765
|
+
drop_zone: targetDir,
|
|
1766
|
+
local_total: events.length,
|
|
1767
|
+
published,
|
|
1768
|
+
skipped,
|
|
1769
|
+
};
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
/**
|
|
1773
|
+
* Internal helper for governance-pull + governance-sync-serve.
|
|
1774
|
+
* Reads remote events from drop-zone, optionally signature-verifies them,
|
|
1775
|
+
* dedupes by event_id against local log, sorts chronologically, and appends
|
|
1776
|
+
* new events to the local jsonl.
|
|
1777
|
+
*
|
|
1778
|
+
* @returns {{
|
|
1779
|
+
* federation_id, drop_zone, remote_total, local_total_before,
|
|
1780
|
+
* appended, duplicates, invalid_signature, unknown_signer
|
|
1781
|
+
* }}
|
|
1782
|
+
*/
|
|
1783
|
+
function statsPath(federationId) {
|
|
1784
|
+
return path.join(
|
|
1785
|
+
getFederationDir(),
|
|
1786
|
+
"governance",
|
|
1787
|
+
`${federationId}.sync-stats.json`,
|
|
1788
|
+
);
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
function loadStats(federationId) {
|
|
1792
|
+
const file = statsPath(federationId);
|
|
1793
|
+
if (!fs.existsSync(file)) return {};
|
|
1794
|
+
try {
|
|
1795
|
+
return JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
1796
|
+
} catch (_err) {
|
|
1797
|
+
return {};
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
function saveStats(federationId, stats) {
|
|
1802
|
+
const file = statsPath(federationId);
|
|
1803
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1804
|
+
// Atomic write so polling readers never see partial JSON
|
|
1805
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
1806
|
+
fs.writeFileSync(tmp, JSON.stringify(stats, null, 2), "utf-8");
|
|
1807
|
+
fs.renameSync(tmp, file);
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
function runGovernancePull(federationId, dropZone, opts = {}) {
|
|
1811
|
+
const sourceDir = path.join(dropZone, "federation-governance", federationId);
|
|
1812
|
+
if (!fs.existsSync(sourceDir)) {
|
|
1813
|
+
// Daemon-friendly: empty drop-zone is "nothing to pull yet", not an error.
|
|
1814
|
+
return {
|
|
1815
|
+
federation_id: federationId,
|
|
1816
|
+
drop_zone: sourceDir,
|
|
1817
|
+
remote_total: 0,
|
|
1818
|
+
local_total_before: loadGovernanceLog(federationId).length,
|
|
1819
|
+
appended: 0,
|
|
1820
|
+
duplicates: 0,
|
|
1821
|
+
invalid_signature: 0,
|
|
1822
|
+
unknown_signer: 0,
|
|
1823
|
+
};
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
const remote = [];
|
|
1827
|
+
for (const name of fs.readdirSync(sourceDir)) {
|
|
1828
|
+
if (!name.endsWith(".json")) continue;
|
|
1829
|
+
try {
|
|
1830
|
+
const ev = JSON.parse(
|
|
1831
|
+
fs.readFileSync(path.join(sourceDir, name), "utf-8"),
|
|
1832
|
+
);
|
|
1833
|
+
if (ev && typeof ev.event_id === "string") remote.push(ev);
|
|
1834
|
+
} catch (_err) {
|
|
1835
|
+
/* skip malformed */
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
let invalid = 0;
|
|
1840
|
+
let unknown = 0;
|
|
1841
|
+
let candidates = remote;
|
|
1842
|
+
if (opts.verify) {
|
|
1843
|
+
const registry = loadFederationRegistry();
|
|
1844
|
+
const fedEntry = registry.federations[federationId] || { members: {} };
|
|
1845
|
+
const lookup = (actor /* , keyId */) => {
|
|
1846
|
+
const m = fedEntry.members[actor];
|
|
1847
|
+
if (!m || !m.pubkey_jwk) return null;
|
|
1848
|
+
try {
|
|
1849
|
+
return Buffer.from(m.pubkey_jwk.x, "base64url");
|
|
1850
|
+
} catch (_err) {
|
|
1851
|
+
return null;
|
|
1852
|
+
}
|
|
1853
|
+
};
|
|
1854
|
+
const result = mtcLib.verifyGovernanceLog(remote, lookup);
|
|
1855
|
+
invalid = result.invalid.length;
|
|
1856
|
+
unknown = result.unknown.length;
|
|
1857
|
+
candidates = result.valid;
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
const localEvents = loadGovernanceLog(federationId);
|
|
1861
|
+
const localIds = new Set(
|
|
1862
|
+
localEvents.filter((e) => e && e.event_id).map((e) => e.event_id),
|
|
1863
|
+
);
|
|
1864
|
+
const newEvents = candidates.filter((e) => !localIds.has(e.event_id));
|
|
1865
|
+
const sorted = mtcLib.sortGovernanceEventsChronologically(newEvents);
|
|
1866
|
+
for (const ev of sorted) appendGovernanceEvent(federationId, ev);
|
|
1867
|
+
|
|
1868
|
+
return {
|
|
1869
|
+
federation_id: federationId,
|
|
1870
|
+
drop_zone: sourceDir,
|
|
1871
|
+
remote_total: remote.length,
|
|
1872
|
+
local_total_before: localEvents.length,
|
|
1873
|
+
appended: sorted.length,
|
|
1874
|
+
duplicates: candidates.length - newEvents.length,
|
|
1875
|
+
invalid_signature: invalid,
|
|
1876
|
+
unknown_signer: unknown,
|
|
1877
|
+
};
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
function registerFederationGovernanceCommands(fed) {
|
|
1881
|
+
// mtc federation invite — propose adding a candidate member
|
|
1882
|
+
fed
|
|
1883
|
+
.command("invite <federation-id> <candidate-member-id>")
|
|
1884
|
+
.description("Propose adding a candidate member (governance.log event)")
|
|
1885
|
+
.requiredOption("--actor <member-id>", "Existing member casting the invite")
|
|
1886
|
+
.requiredOption("--candidate-pubkey-id <id>", "Candidate's pubkey_id")
|
|
1887
|
+
.option("--candidate-alg <alg>", "ed25519 | slh-dsa-128f", "ed25519")
|
|
1888
|
+
.option("--json", "JSON output")
|
|
1889
|
+
.action((federationId, candidateMemberId, options) => {
|
|
1890
|
+
try {
|
|
1891
|
+
const { keys, alg } = loadMemberSigner(federationId, options.actor);
|
|
1892
|
+
const event = emitAndPersist(federationId, {
|
|
1893
|
+
eventType: "invite",
|
|
1894
|
+
actorMemberId: options.actor,
|
|
1895
|
+
secretKey: keys.secretKey,
|
|
1896
|
+
publicKey: keys.publicKey,
|
|
1897
|
+
alg,
|
|
1898
|
+
payload: {
|
|
1899
|
+
candidate_member_id: candidateMemberId,
|
|
1900
|
+
candidate_pubkey_id: options.candidatePubkeyId,
|
|
1901
|
+
candidate_alg: options.candidateAlg,
|
|
1902
|
+
},
|
|
1903
|
+
});
|
|
1904
|
+
if (options.json) return console.log(JSON.stringify(event, null, 2));
|
|
1905
|
+
logger.success(
|
|
1906
|
+
`Invited ${candidateMemberId} into ${federationId} (event ${event.event_id})`,
|
|
1907
|
+
);
|
|
1908
|
+
} catch (err) {
|
|
1909
|
+
logger.error(`mtc federation invite failed: ${err.message}`);
|
|
1910
|
+
process.exit(1);
|
|
1911
|
+
}
|
|
1912
|
+
});
|
|
1913
|
+
|
|
1914
|
+
// mtc federation vote — vote on an outstanding invite
|
|
1915
|
+
fed
|
|
1916
|
+
.command("vote <federation-id> <candidate-member-id>")
|
|
1917
|
+
.description("Vote on an outstanding invite")
|
|
1918
|
+
.requiredOption("--actor <member-id>", "Voting member")
|
|
1919
|
+
.requiredOption("--decision <approve|reject>", "Vote decision")
|
|
1920
|
+
.option("--json", "JSON output")
|
|
1921
|
+
.action((federationId, candidateMemberId, options) => {
|
|
1922
|
+
try {
|
|
1923
|
+
if (!["approve", "reject"].includes(options.decision)) {
|
|
1924
|
+
throw new Error("--decision must be approve or reject");
|
|
1925
|
+
}
|
|
1926
|
+
const { keys, alg } = loadMemberSigner(federationId, options.actor);
|
|
1927
|
+
const event = emitAndPersist(federationId, {
|
|
1928
|
+
eventType: "vote",
|
|
1929
|
+
actorMemberId: options.actor,
|
|
1930
|
+
secretKey: keys.secretKey,
|
|
1931
|
+
publicKey: keys.publicKey,
|
|
1932
|
+
alg,
|
|
1933
|
+
payload: {
|
|
1934
|
+
invite_target_member_id: candidateMemberId,
|
|
1935
|
+
decision: options.decision,
|
|
1936
|
+
},
|
|
1937
|
+
});
|
|
1938
|
+
if (options.json) return console.log(JSON.stringify(event, null, 2));
|
|
1939
|
+
logger.success(
|
|
1940
|
+
`${options.actor} voted ${options.decision} on ${candidateMemberId} (event ${event.event_id})`,
|
|
1941
|
+
);
|
|
1942
|
+
} catch (err) {
|
|
1943
|
+
logger.error(`mtc federation vote failed: ${err.message}`);
|
|
1944
|
+
process.exit(1);
|
|
1945
|
+
}
|
|
1946
|
+
});
|
|
1947
|
+
|
|
1948
|
+
// mtc federation propose-revoke
|
|
1949
|
+
fed
|
|
1950
|
+
.command("propose-revoke <federation-id> <target-member-id>")
|
|
1951
|
+
.description("Propose revoking a member (7-day grace period)")
|
|
1952
|
+
.requiredOption("--actor <member-id>", "Proposing member")
|
|
1953
|
+
.requiredOption("--reason <text>", "Reason (e.g. inactive, key-compromise)")
|
|
1954
|
+
.option("--json", "JSON output")
|
|
1955
|
+
.action((federationId, targetMemberId, options) => {
|
|
1956
|
+
try {
|
|
1957
|
+
const { keys, alg } = loadMemberSigner(federationId, options.actor);
|
|
1958
|
+
const event = emitAndPersist(federationId, {
|
|
1959
|
+
eventType: "propose-revoke",
|
|
1960
|
+
actorMemberId: options.actor,
|
|
1961
|
+
secretKey: keys.secretKey,
|
|
1962
|
+
publicKey: keys.publicKey,
|
|
1963
|
+
alg,
|
|
1964
|
+
payload: { target_member_id: targetMemberId, reason: options.reason },
|
|
1965
|
+
});
|
|
1966
|
+
if (options.json) return console.log(JSON.stringify(event, null, 2));
|
|
1967
|
+
logger.success(
|
|
1968
|
+
`Proposed revoke of ${targetMemberId} (reason: ${options.reason}, event ${event.event_id})`,
|
|
1969
|
+
);
|
|
1970
|
+
} catch (err) {
|
|
1971
|
+
logger.error(`mtc federation propose-revoke failed: ${err.message}`);
|
|
1972
|
+
process.exit(1);
|
|
1973
|
+
}
|
|
1974
|
+
});
|
|
1975
|
+
|
|
1976
|
+
// mtc federation confirm-revoke
|
|
1977
|
+
fed
|
|
1978
|
+
.command("confirm-revoke <federation-id> <target-member-id>")
|
|
1979
|
+
.description("Confirm a previously-proposed revoke (after grace period)")
|
|
1980
|
+
.requiredOption("--actor <member-id>", "Confirming member")
|
|
1981
|
+
.option(
|
|
1982
|
+
"--reason <text>",
|
|
1983
|
+
"Confirmation reason (key-compromise → mark key compromised)",
|
|
1984
|
+
)
|
|
1985
|
+
.option(
|
|
1986
|
+
"--no-quorum-check",
|
|
1987
|
+
"Skip the pre-flight check that a matching propose-revoke exists (caller assumes responsibility)",
|
|
1988
|
+
)
|
|
1989
|
+
.option("--json", "JSON output")
|
|
1990
|
+
.action((federationId, targetMemberId, options) => {
|
|
1991
|
+
try {
|
|
1992
|
+
if (options.quorumCheck !== false) {
|
|
1993
|
+
requireOpenProposal(
|
|
1994
|
+
federationId,
|
|
1995
|
+
"propose-revoke",
|
|
1996
|
+
(p) => p.target_member_id === targetMemberId,
|
|
1997
|
+
);
|
|
1998
|
+
}
|
|
1999
|
+
const { keys, alg } = loadMemberSigner(federationId, options.actor);
|
|
2000
|
+
const event = emitAndPersist(federationId, {
|
|
2001
|
+
eventType: "confirm-revoke",
|
|
2002
|
+
actorMemberId: options.actor,
|
|
2003
|
+
secretKey: keys.secretKey,
|
|
2004
|
+
publicKey: keys.publicKey,
|
|
2005
|
+
alg,
|
|
2006
|
+
payload: { target_member_id: targetMemberId, reason: options.reason },
|
|
2007
|
+
});
|
|
2008
|
+
if (options.json) return console.log(JSON.stringify(event, null, 2));
|
|
2009
|
+
logger.success(
|
|
2010
|
+
`Confirmed revoke of ${targetMemberId} (event ${event.event_id})`,
|
|
2011
|
+
);
|
|
2012
|
+
} catch (err) {
|
|
2013
|
+
logger.error(`mtc federation confirm-revoke failed: ${err.message}`);
|
|
2014
|
+
process.exit(1);
|
|
2015
|
+
}
|
|
2016
|
+
});
|
|
2017
|
+
|
|
2018
|
+
// mtc federation rotate-key
|
|
2019
|
+
fed
|
|
2020
|
+
.command("rotate-key <federation-id>")
|
|
2021
|
+
.description("Rotate the actor's signing key to a new pubkey")
|
|
2022
|
+
.requiredOption("--actor <member-id>", "Member rotating their key")
|
|
2023
|
+
.requiredOption("--new-pubkey-id <id>", "New public-key id")
|
|
2024
|
+
.option("--new-alg <alg>", "ed25519 | slh-dsa-128f")
|
|
2025
|
+
.option("--json", "JSON output")
|
|
2026
|
+
.action((federationId, options) => {
|
|
2027
|
+
try {
|
|
2028
|
+
const { keys, alg } = loadMemberSigner(federationId, options.actor);
|
|
2029
|
+
const event = emitAndPersist(federationId, {
|
|
2030
|
+
eventType: "rotate-key",
|
|
2031
|
+
actorMemberId: options.actor,
|
|
2032
|
+
secretKey: keys.secretKey,
|
|
2033
|
+
publicKey: keys.publicKey,
|
|
2034
|
+
alg,
|
|
2035
|
+
payload: {
|
|
2036
|
+
new_pubkey_id: options.newPubkeyId,
|
|
2037
|
+
new_alg: options.newAlg,
|
|
2038
|
+
},
|
|
2039
|
+
});
|
|
2040
|
+
if (options.json) return console.log(JSON.stringify(event, null, 2));
|
|
2041
|
+
logger.success(
|
|
2042
|
+
`${options.actor} rotated key to ${options.newPubkeyId} (event ${event.event_id})`,
|
|
2043
|
+
);
|
|
2044
|
+
} catch (err) {
|
|
2045
|
+
logger.error(`mtc federation rotate-key failed: ${err.message}`);
|
|
2046
|
+
process.exit(1);
|
|
2047
|
+
}
|
|
2048
|
+
});
|
|
2049
|
+
|
|
2050
|
+
// mtc federation propose-threshold
|
|
2051
|
+
fed
|
|
2052
|
+
.command("propose-threshold <federation-id> <new-threshold>")
|
|
2053
|
+
.description("Propose a new M-of-N threshold (30-day cooldown)")
|
|
2054
|
+
.requiredOption("--actor <member-id>", "Proposing member")
|
|
2055
|
+
.option("--json", "JSON output")
|
|
2056
|
+
.action((federationId, newThreshold, options) => {
|
|
2057
|
+
try {
|
|
2058
|
+
const target = parseInt(newThreshold, 10);
|
|
2059
|
+
if (!Number.isInteger(target) || target < 1) {
|
|
2060
|
+
throw new Error("new-threshold must be a positive integer");
|
|
2061
|
+
}
|
|
2062
|
+
const { keys, alg } = loadMemberSigner(federationId, options.actor);
|
|
2063
|
+
const event = emitAndPersist(federationId, {
|
|
2064
|
+
eventType: "propose-threshold",
|
|
2065
|
+
actorMemberId: options.actor,
|
|
2066
|
+
secretKey: keys.secretKey,
|
|
2067
|
+
publicKey: keys.publicKey,
|
|
2068
|
+
alg,
|
|
2069
|
+
payload: { proposed_threshold: target },
|
|
2070
|
+
});
|
|
2071
|
+
if (options.json) return console.log(JSON.stringify(event, null, 2));
|
|
2072
|
+
logger.success(
|
|
2073
|
+
`Proposed threshold ${target} (event ${event.event_id}, 30-day cooldown)`,
|
|
2074
|
+
);
|
|
2075
|
+
} catch (err) {
|
|
2076
|
+
logger.error(`mtc federation propose-threshold failed: ${err.message}`);
|
|
2077
|
+
process.exit(1);
|
|
2078
|
+
}
|
|
2079
|
+
});
|
|
2080
|
+
|
|
2081
|
+
// mtc federation confirm-threshold — apply a specific (or most recent) propose-threshold
|
|
2082
|
+
fed
|
|
2083
|
+
.command("confirm-threshold <federation-id>")
|
|
2084
|
+
.description(
|
|
2085
|
+
"Confirm a propose-threshold (default: most recent; --proposal-event-id picks a specific one)",
|
|
2086
|
+
)
|
|
2087
|
+
.requiredOption("--actor <member-id>", "Confirming member")
|
|
2088
|
+
.option(
|
|
2089
|
+
"--proposal-event-id <id>",
|
|
2090
|
+
"Specific propose-threshold event_id (CRDT-style explicit selection when multiple proposals are open)",
|
|
2091
|
+
)
|
|
2092
|
+
.option(
|
|
2093
|
+
"--no-quorum-check",
|
|
2094
|
+
"Skip the pre-flight check that an open propose-threshold exists",
|
|
2095
|
+
)
|
|
2096
|
+
.option("--json", "JSON output")
|
|
2097
|
+
.action((federationId, options) => {
|
|
2098
|
+
try {
|
|
2099
|
+
if (options.quorumCheck !== false) {
|
|
2100
|
+
requireOpenProposal(federationId, "propose-threshold", (p, ev) => {
|
|
2101
|
+
if (!Number.isInteger(p.proposed_threshold)) return false;
|
|
2102
|
+
if (
|
|
2103
|
+
options.proposalEventId &&
|
|
2104
|
+
ev &&
|
|
2105
|
+
ev.event_id !== options.proposalEventId
|
|
2106
|
+
) {
|
|
2107
|
+
return false;
|
|
2108
|
+
}
|
|
2109
|
+
return true;
|
|
2110
|
+
});
|
|
2111
|
+
}
|
|
2112
|
+
const { keys, alg } = loadMemberSigner(federationId, options.actor);
|
|
2113
|
+
const payload = options.proposalEventId
|
|
2114
|
+
? { proposal_event_id: options.proposalEventId }
|
|
2115
|
+
: {};
|
|
2116
|
+
const event = emitAndPersist(federationId, {
|
|
2117
|
+
eventType: "confirm-threshold",
|
|
2118
|
+
actorMemberId: options.actor,
|
|
2119
|
+
secretKey: keys.secretKey,
|
|
2120
|
+
publicKey: keys.publicKey,
|
|
2121
|
+
alg,
|
|
2122
|
+
payload,
|
|
2123
|
+
});
|
|
2124
|
+
if (options.json) return console.log(JSON.stringify(event, null, 2));
|
|
2125
|
+
logger.success(
|
|
2126
|
+
`Confirmed${options.proposalEventId ? " specific" : ""} threshold proposal (event ${event.event_id})`,
|
|
2127
|
+
);
|
|
2128
|
+
} catch (err) {
|
|
2129
|
+
logger.error(`mtc federation confirm-threshold failed: ${err.message}`);
|
|
2130
|
+
process.exit(1);
|
|
2131
|
+
}
|
|
2132
|
+
});
|
|
2133
|
+
|
|
2134
|
+
// mtc federation fork
|
|
2135
|
+
fed
|
|
2136
|
+
.command("fork <federation-id> <new-federation-id>")
|
|
2137
|
+
.description(
|
|
2138
|
+
"Spawn a new federation with a subset of current members (members leave the original)",
|
|
2139
|
+
)
|
|
2140
|
+
.requiredOption("--actor <member-id>", "Forking member")
|
|
2141
|
+
.requiredOption(
|
|
2142
|
+
"--members <ids>",
|
|
2143
|
+
"Comma-separated list of member-ids leaving for the new federation",
|
|
2144
|
+
)
|
|
2145
|
+
.option("--json", "JSON output")
|
|
2146
|
+
.action((federationId, newFedId, options) => {
|
|
2147
|
+
try {
|
|
2148
|
+
const memberIds = options.members
|
|
2149
|
+
.split(",")
|
|
2150
|
+
.map((s) => s.trim())
|
|
2151
|
+
.filter(Boolean);
|
|
2152
|
+
const { keys, alg } = loadMemberSigner(federationId, options.actor);
|
|
2153
|
+
const event = emitAndPersist(federationId, {
|
|
2154
|
+
eventType: "fork",
|
|
2155
|
+
actorMemberId: options.actor,
|
|
2156
|
+
secretKey: keys.secretKey,
|
|
2157
|
+
publicKey: keys.publicKey,
|
|
2158
|
+
alg,
|
|
2159
|
+
payload: {
|
|
2160
|
+
new_federation_id: newFedId,
|
|
2161
|
+
member_ids: memberIds,
|
|
2162
|
+
},
|
|
2163
|
+
});
|
|
2164
|
+
if (options.json) return console.log(JSON.stringify(event, null, 2));
|
|
2165
|
+
logger.success(
|
|
2166
|
+
`Forked ${federationId} → ${newFedId} (members: ${memberIds.join(", ")}; event ${event.event_id})`,
|
|
2167
|
+
);
|
|
2168
|
+
} catch (err) {
|
|
2169
|
+
logger.error(`mtc federation fork failed: ${err.message}`);
|
|
2170
|
+
process.exit(1);
|
|
2171
|
+
}
|
|
2172
|
+
});
|
|
2173
|
+
|
|
2174
|
+
// mtc federation merge
|
|
2175
|
+
fed
|
|
2176
|
+
.command("merge <federation-id> <other-federation-id> <new-federation-id>")
|
|
2177
|
+
.description(
|
|
2178
|
+
"Mark this federation as winding down into a new merged federation",
|
|
2179
|
+
)
|
|
2180
|
+
.requiredOption("--actor <member-id>", "Merging member")
|
|
2181
|
+
.option("--json", "JSON output")
|
|
2182
|
+
.action((federationId, otherFedId, newFedId, options) => {
|
|
2183
|
+
try {
|
|
2184
|
+
const { keys, alg } = loadMemberSigner(federationId, options.actor);
|
|
2185
|
+
const event = emitAndPersist(federationId, {
|
|
2186
|
+
eventType: "merge",
|
|
2187
|
+
actorMemberId: options.actor,
|
|
2188
|
+
secretKey: keys.secretKey,
|
|
2189
|
+
publicKey: keys.publicKey,
|
|
2190
|
+
alg,
|
|
2191
|
+
payload: {
|
|
2192
|
+
other_federation_id: otherFedId,
|
|
2193
|
+
new_federation_id: newFedId,
|
|
2194
|
+
},
|
|
2195
|
+
});
|
|
2196
|
+
if (options.json) return console.log(JSON.stringify(event, null, 2));
|
|
2197
|
+
logger.success(
|
|
2198
|
+
`Merged ${federationId} + ${otherFedId} → ${newFedId} (event ${event.event_id})`,
|
|
2199
|
+
);
|
|
2200
|
+
} catch (err) {
|
|
2201
|
+
logger.error(`mtc federation merge failed: ${err.message}`);
|
|
2202
|
+
process.exit(1);
|
|
2203
|
+
}
|
|
2204
|
+
});
|
|
2205
|
+
|
|
2206
|
+
// mtc federation governance-sync-libp2p — pubsub gossipsub variant
|
|
2207
|
+
fed
|
|
2208
|
+
.command("governance-sync-libp2p <federation-id>")
|
|
2209
|
+
.description(
|
|
2210
|
+
"Sync governance events over libp2p gossipsub (topic mtc-federation-governance/v1/<fed>)",
|
|
2211
|
+
)
|
|
2212
|
+
.option("--listen <maddr>", "libp2p listen multiaddr", "/ip4/0.0.0.0/tcp/0")
|
|
2213
|
+
.option(
|
|
2214
|
+
"--connect <maddr>",
|
|
2215
|
+
"Seed peer multiaddr (repeatable)",
|
|
2216
|
+
(v, prev) => (prev ? [...prev, v] : [v]),
|
|
2217
|
+
[],
|
|
2218
|
+
)
|
|
2219
|
+
.option(
|
|
2220
|
+
"--interval <seconds>",
|
|
2221
|
+
"Publish-new-events interval (default: 10)",
|
|
2222
|
+
(v) => parseInt(v, 10),
|
|
2223
|
+
10,
|
|
2224
|
+
)
|
|
2225
|
+
.option("--verify", "Verify signatures before appending received events")
|
|
2226
|
+
.option(
|
|
2227
|
+
"--once",
|
|
2228
|
+
"Subscribe + publish-once + wait <interval> seconds, then exit (test/cron)",
|
|
2229
|
+
)
|
|
2230
|
+
.option("--json", "Per-tick JSON output")
|
|
2231
|
+
.action(async (federationId, options) => {
|
|
2232
|
+
try {
|
|
2233
|
+
await runGovernanceSyncLibp2p(federationId, options);
|
|
2234
|
+
} catch (err) {
|
|
2235
|
+
logger.error(
|
|
2236
|
+
`mtc federation governance-sync-libp2p failed: ${err.message}`,
|
|
2237
|
+
);
|
|
2238
|
+
process.exit(1);
|
|
2239
|
+
}
|
|
2240
|
+
});
|
|
2241
|
+
|
|
2242
|
+
// mtc federation governance-sync-stats — read live tick stats written by sync daemons
|
|
2243
|
+
fed
|
|
2244
|
+
.command("governance-sync-stats <federation-id>")
|
|
2245
|
+
.description(
|
|
2246
|
+
"Read live sync stats (last tick + cumulative counters) written by governance-sync-serve / governance-sync-libp2p",
|
|
2247
|
+
)
|
|
2248
|
+
.option("--json", "JSON output")
|
|
2249
|
+
.action((federationId, options) => {
|
|
2250
|
+
try {
|
|
2251
|
+
const file = path.join(
|
|
2252
|
+
getFederationDir(),
|
|
2253
|
+
"governance",
|
|
2254
|
+
`${federationId}.sync-stats.json`,
|
|
2255
|
+
);
|
|
2256
|
+
if (!fs.existsSync(file)) {
|
|
2257
|
+
const empty = {
|
|
2258
|
+
federation_id: federationId,
|
|
2259
|
+
stats_file: file,
|
|
2260
|
+
available: false,
|
|
2261
|
+
note: "No sync daemon has written stats yet — start governance-sync-serve or governance-sync-libp2p first.",
|
|
2262
|
+
};
|
|
2263
|
+
if (options.json) return console.log(JSON.stringify(empty, null, 2));
|
|
2264
|
+
logger.warn(empty.note);
|
|
2265
|
+
return;
|
|
2266
|
+
}
|
|
2267
|
+
const data = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
2268
|
+
if (options.json) return console.log(JSON.stringify(data, null, 2));
|
|
2269
|
+
logger.log(`Federation: ${federationId}`);
|
|
2270
|
+
logger.log(`Last tick: ${data.last_tick_at || "—"}`);
|
|
2271
|
+
logger.log(`Mode: ${data.mode || "—"}`);
|
|
2272
|
+
if (data.publish) {
|
|
2273
|
+
logger.log(
|
|
2274
|
+
`Publish: last=${data.publish.last_published || 0} new (${data.publish.last_skipped || 0} skipped); total=${data.publish.total_published || 0}`,
|
|
2275
|
+
);
|
|
2276
|
+
}
|
|
2277
|
+
if (data.pull) {
|
|
2278
|
+
logger.log(
|
|
2279
|
+
`Pull: last=${data.pull.last_appended || 0} new (dedup=${data.pull.last_duplicates || 0}); total=${data.pull.total_appended || 0}`,
|
|
2280
|
+
);
|
|
2281
|
+
}
|
|
2282
|
+
if (data.libp2p) {
|
|
2283
|
+
logger.log(
|
|
2284
|
+
`Libp2p: wire received=${data.libp2p.wire_received || 0} appended=${data.libp2p.wire_appended || 0} invalid=${data.libp2p.wire_invalid || 0} unknown=${data.libp2p.wire_unknown || 0}`,
|
|
2285
|
+
);
|
|
2286
|
+
}
|
|
2287
|
+
} catch (err) {
|
|
2288
|
+
logger.error(
|
|
2289
|
+
`mtc federation governance-sync-stats failed: ${err.message}`,
|
|
2290
|
+
);
|
|
2291
|
+
process.exit(1);
|
|
2292
|
+
}
|
|
2293
|
+
});
|
|
2294
|
+
|
|
2295
|
+
// mtc federation cross-trust-create — emit a cross-federation trust anchor record (v0.3)
|
|
2296
|
+
fed
|
|
2297
|
+
.command("cross-trust-create <host-fed> <trusted-fed>")
|
|
2298
|
+
.description(
|
|
2299
|
+
"Build a cross-federation trust anchor record (host accepts landmarks from trusted)",
|
|
2300
|
+
)
|
|
2301
|
+
.requiredOption(
|
|
2302
|
+
"--threshold <m>",
|
|
2303
|
+
"Trusted federation's threshold at snapshot time",
|
|
2304
|
+
(v) => parseInt(v, 10),
|
|
2305
|
+
)
|
|
2306
|
+
.requiredOption(
|
|
2307
|
+
"--member <id:pubkey>",
|
|
2308
|
+
"Trusted member entry, id:pubkey_id (repeatable)",
|
|
2309
|
+
(v, prev) => (prev ? [...prev, v] : [v]),
|
|
2310
|
+
[],
|
|
2311
|
+
)
|
|
2312
|
+
.option(
|
|
2313
|
+
"--accepted-kinds <kinds>",
|
|
2314
|
+
"Comma-separated landmark kinds to accept (default: did,skill,bridge,audit)",
|
|
2315
|
+
)
|
|
2316
|
+
.option("--expires-at <iso>", "ISO 8601 expiry (default: 90 days)")
|
|
2317
|
+
.option(
|
|
2318
|
+
"--out <path>",
|
|
2319
|
+
"Write the anchor JSON to a file (otherwise stdout)",
|
|
2320
|
+
)
|
|
2321
|
+
.option("--json", "JSON output (default unless --out given)")
|
|
2322
|
+
.action((hostFed, trustedFed, options) => {
|
|
2323
|
+
try {
|
|
2324
|
+
const roster = (options.member || []).map((entry) => {
|
|
2325
|
+
const [member_id, pubkey_id] = entry.split(":", 2);
|
|
2326
|
+
if (!member_id || !pubkey_id) {
|
|
2327
|
+
throw new Error(
|
|
2328
|
+
`bad --member entry "${entry}", expected id:pubkey_id`,
|
|
2329
|
+
);
|
|
2330
|
+
}
|
|
2331
|
+
return { member_id, pubkey_id, alg: "ed25519" };
|
|
2332
|
+
});
|
|
2333
|
+
const anchor = mtcLib.createCrossFederationTrustAnchor({
|
|
2334
|
+
host_federation_id: hostFed,
|
|
2335
|
+
trusted_federation_id: trustedFed,
|
|
2336
|
+
member_roster_snapshot: roster,
|
|
2337
|
+
threshold: options.threshold,
|
|
2338
|
+
accepted_kinds: options.acceptedKinds
|
|
2339
|
+
? options.acceptedKinds.split(",").map((s) => s.trim())
|
|
2340
|
+
: undefined,
|
|
2341
|
+
expires_at: options.expiresAt,
|
|
2342
|
+
});
|
|
2343
|
+
const json = JSON.stringify(anchor, null, 2);
|
|
2344
|
+
if (options.out) {
|
|
2345
|
+
fs.mkdirSync(path.dirname(options.out), { recursive: true });
|
|
2346
|
+
fs.writeFileSync(options.out, json, "utf-8");
|
|
2347
|
+
if (options.json) console.log(json);
|
|
2348
|
+
else
|
|
2349
|
+
logger.success(
|
|
2350
|
+
`Cross-fed trust anchor written: ${options.out} (expires ${anchor.expires_at})`,
|
|
2351
|
+
);
|
|
2352
|
+
} else {
|
|
2353
|
+
console.log(json);
|
|
2354
|
+
}
|
|
2355
|
+
} catch (err) {
|
|
2356
|
+
logger.error(
|
|
2357
|
+
`mtc federation cross-trust-create failed: ${err.message}`,
|
|
2358
|
+
);
|
|
2359
|
+
process.exit(1);
|
|
2360
|
+
}
|
|
2361
|
+
});
|
|
2362
|
+
|
|
2363
|
+
// mtc federation cross-trust-validate — validate an anchor's structure + freshness
|
|
2364
|
+
fed
|
|
2365
|
+
.command("cross-trust-validate <anchor-path>")
|
|
2366
|
+
.description("Validate a cross-federation trust anchor JSON file")
|
|
2367
|
+
.option("--json", "JSON output")
|
|
2368
|
+
.action((anchorPath, options) => {
|
|
2369
|
+
try {
|
|
2370
|
+
const anchor = JSON.parse(fs.readFileSync(anchorPath, "utf-8"));
|
|
2371
|
+
const result = mtcLib.validateCrossFederationTrustAnchor(anchor);
|
|
2372
|
+
if (options.json) {
|
|
2373
|
+
console.log(
|
|
2374
|
+
JSON.stringify({ ...result, anchor_path: anchorPath }, null, 2),
|
|
2375
|
+
);
|
|
2376
|
+
if (!result.ok) process.exit(2);
|
|
2377
|
+
return;
|
|
2378
|
+
}
|
|
2379
|
+
if (result.ok) {
|
|
2380
|
+
logger.success(
|
|
2381
|
+
`✓ Anchor valid: ${anchor.host_federation_id} → ${anchor.trusted_federation_id} (expires ${anchor.expires_at})`,
|
|
2382
|
+
);
|
|
2383
|
+
} else {
|
|
2384
|
+
logger.error(`✗ Anchor invalid: ${result.code}`);
|
|
2385
|
+
process.exit(2);
|
|
2386
|
+
}
|
|
2387
|
+
} catch (err) {
|
|
2388
|
+
logger.error(
|
|
2389
|
+
`mtc federation cross-trust-validate failed: ${err.message}`,
|
|
2390
|
+
);
|
|
2391
|
+
process.exit(1);
|
|
2392
|
+
}
|
|
2393
|
+
});
|
|
2394
|
+
|
|
2395
|
+
// mtc federation audit — independent third-party auditor of governance.log (v0.3)
|
|
2396
|
+
fed
|
|
2397
|
+
.command("audit <federation-id>")
|
|
2398
|
+
.description(
|
|
2399
|
+
"Offline audit: replay governance.log + verify each event signature against the rolling roster",
|
|
2400
|
+
)
|
|
2401
|
+
.option("--json", "JSON output (full report incl. final_state)")
|
|
2402
|
+
.option("--summary", "Show only the finding counts + ok/fail")
|
|
2403
|
+
.action((federationId, options) => {
|
|
2404
|
+
try {
|
|
2405
|
+
const events = loadGovernanceLog(federationId);
|
|
2406
|
+
const report = mtcLib.auditGovernanceLog(events, federationId);
|
|
2407
|
+
if (options.json) return console.log(JSON.stringify(report, null, 2));
|
|
2408
|
+
const errorCount = report.findings.filter(
|
|
2409
|
+
(f) => f.severity === "error",
|
|
2410
|
+
).length;
|
|
2411
|
+
const warnCount = report.findings.filter(
|
|
2412
|
+
(f) => f.severity === "warn",
|
|
2413
|
+
).length;
|
|
2414
|
+
if (options.summary) {
|
|
2415
|
+
logger.log(
|
|
2416
|
+
`${report.ok ? "✓" : "✗"} ${federationId}: ${report.events_count} events, ${errorCount} errors, ${warnCount} warnings`,
|
|
2417
|
+
);
|
|
2418
|
+
if (!report.ok) process.exit(2);
|
|
2419
|
+
return;
|
|
2420
|
+
}
|
|
2421
|
+
logger.log(`Federation: ${federationId}`);
|
|
2422
|
+
logger.log(`Events: ${report.events_count}`);
|
|
2423
|
+
logger.log(
|
|
2424
|
+
`Audit: ${report.ok ? "✓ PASS" : "✗ FAIL"} (errors=${errorCount}, warnings=${warnCount})`,
|
|
2425
|
+
);
|
|
2426
|
+
if (report.findings.length > 0) {
|
|
2427
|
+
logger.log(`\nFindings:`);
|
|
2428
|
+
for (const f of report.findings) {
|
|
2429
|
+
const sev = f.severity === "error" ? "ERROR" : "WARN ";
|
|
2430
|
+
logger.log(` [${sev}] ${f.code}: ${f.message}`);
|
|
2431
|
+
logger.log(` event_id=${f.event_id}`);
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
if (!report.ok) process.exit(2);
|
|
2435
|
+
} catch (err) {
|
|
2436
|
+
logger.error(`mtc federation audit failed: ${err.message}`);
|
|
2437
|
+
process.exit(1);
|
|
2438
|
+
}
|
|
2439
|
+
});
|
|
2440
|
+
|
|
2441
|
+
// v0.3 #2 — On-chain governance anchor (Q-COMP-3 unlocked 2026-05-03)
|
|
2442
|
+
// The CLI ships filesystem-backed mock chain client; production wires
|
|
2443
|
+
// a real ConsortiumChainClient via --chain-impl <module> (future).
|
|
2444
|
+
|
|
2445
|
+
fed
|
|
2446
|
+
.command("governance-anchor <federation-id>")
|
|
2447
|
+
.description(
|
|
2448
|
+
"Compute snapshot hash of governance.log + publish to a chain-anchor store (Q-COMP-3 v0.3 #2)",
|
|
2449
|
+
)
|
|
2450
|
+
.requiredOption("--actor <member-id>", "Anchoring member")
|
|
2451
|
+
.requiredOption(
|
|
2452
|
+
"--chain-store <dir>",
|
|
2453
|
+
"Filesystem dir simulating the chain anchor store (production: --chain-impl swap-in)",
|
|
2454
|
+
)
|
|
2455
|
+
.option("--chain-name <name>", "Chain name label", "consortium-mock")
|
|
2456
|
+
.option("--json", "JSON output")
|
|
2457
|
+
.action(async (federationId, options) => {
|
|
2458
|
+
try {
|
|
2459
|
+
const events = loadGovernanceLog(federationId);
|
|
2460
|
+
const record = mtcLib.buildGovernanceAnchorRecord(
|
|
2461
|
+
events,
|
|
2462
|
+
federationId,
|
|
2463
|
+
options.actor,
|
|
2464
|
+
);
|
|
2465
|
+
const client = new mtcLib.FilesystemChainAnchorClient({
|
|
2466
|
+
rootDir: options.chainStore,
|
|
2467
|
+
chainName: options.chainName,
|
|
2468
|
+
});
|
|
2469
|
+
const receipt = await client.publish(record);
|
|
2470
|
+
const out = {
|
|
2471
|
+
federation_id: federationId,
|
|
2472
|
+
anchored: true,
|
|
2473
|
+
snapshot_hash: record.snapshot_hash,
|
|
2474
|
+
events_count: record.events_count,
|
|
2475
|
+
last_event_id: record.last_event_id,
|
|
2476
|
+
tx_hash: receipt.tx_hash,
|
|
2477
|
+
block_height: receipt.block_height,
|
|
2478
|
+
chain_name: options.chainName,
|
|
2479
|
+
anchored_at: receipt.anchored_at,
|
|
2480
|
+
};
|
|
2481
|
+
if (options.json) return console.log(JSON.stringify(out, null, 2));
|
|
2482
|
+
logger.success(
|
|
2483
|
+
`Anchored ${federationId} snapshot (${record.events_count} events, hash=${record.snapshot_hash}) → tx=${receipt.tx_hash} @ block=${receipt.block_height}`,
|
|
2484
|
+
);
|
|
2485
|
+
} catch (err) {
|
|
2486
|
+
logger.error(`mtc federation governance-anchor failed: ${err.message}`);
|
|
2487
|
+
process.exit(1);
|
|
2488
|
+
}
|
|
2489
|
+
});
|
|
2490
|
+
|
|
2491
|
+
fed
|
|
2492
|
+
.command("governance-verify-anchor <federation-id>")
|
|
2493
|
+
.description(
|
|
2494
|
+
"Fetch latest chain anchor + compare against local governance.log snapshot hash",
|
|
2495
|
+
)
|
|
2496
|
+
.requiredOption(
|
|
2497
|
+
"--chain-store <dir>",
|
|
2498
|
+
"Filesystem dir simulating the chain anchor store",
|
|
2499
|
+
)
|
|
2500
|
+
.option("--json", "JSON output")
|
|
2501
|
+
.action(async (federationId, options) => {
|
|
2502
|
+
try {
|
|
2503
|
+
const client = new mtcLib.FilesystemChainAnchorClient({
|
|
2504
|
+
rootDir: options.chainStore,
|
|
2505
|
+
});
|
|
2506
|
+
const latest = await client.fetchLatest(federationId);
|
|
2507
|
+
if (!latest) {
|
|
2508
|
+
const out = {
|
|
2509
|
+
federation_id: federationId,
|
|
2510
|
+
ok: false,
|
|
2511
|
+
code: "NO_ANCHOR_ON_CHAIN",
|
|
2512
|
+
message:
|
|
2513
|
+
"No anchor record found for this federation in the chain store",
|
|
2514
|
+
};
|
|
2515
|
+
if (options.json) {
|
|
2516
|
+
console.log(JSON.stringify(out, null, 2));
|
|
2517
|
+
process.exit(2);
|
|
2518
|
+
return;
|
|
2519
|
+
}
|
|
2520
|
+
logger.error(out.message);
|
|
2521
|
+
process.exit(2);
|
|
2522
|
+
}
|
|
2523
|
+
const events = loadGovernanceLog(federationId);
|
|
2524
|
+
const result = mtcLib.verifyGovernanceAnchor(latest, events);
|
|
2525
|
+
const out = {
|
|
2526
|
+
federation_id: federationId,
|
|
2527
|
+
...result,
|
|
2528
|
+
anchor_block_height: latest.block_height,
|
|
2529
|
+
anchor_tx_hash: latest.tx_hash,
|
|
2530
|
+
anchor_anchored_at: latest.anchored_at,
|
|
2531
|
+
};
|
|
2532
|
+
if (options.json) {
|
|
2533
|
+
console.log(JSON.stringify(out, null, 2));
|
|
2534
|
+
if (!result.ok) process.exit(2);
|
|
2535
|
+
return;
|
|
2536
|
+
}
|
|
2537
|
+
if (result.ok) {
|
|
2538
|
+
logger.success(
|
|
2539
|
+
`✓ Anchor matches: ${result.expected_hash} (block=${latest.block_height}, anchored=${latest.anchored_at})`,
|
|
2540
|
+
);
|
|
2541
|
+
} else {
|
|
2542
|
+
logger.error(
|
|
2543
|
+
`✗ Anchor mismatch: ${result.code}\n expected: ${result.expected_hash}\n actual: ${result.actual_hash}`,
|
|
2544
|
+
);
|
|
2545
|
+
if (result.drift) {
|
|
2546
|
+
logger.log(
|
|
2547
|
+
` drift: events_count_diff=${result.drift.events_count_diff}`,
|
|
2548
|
+
);
|
|
2549
|
+
}
|
|
2550
|
+
process.exit(2);
|
|
2551
|
+
}
|
|
2552
|
+
} catch (err) {
|
|
2553
|
+
logger.error(
|
|
2554
|
+
`mtc federation governance-verify-anchor failed: ${err.message}`,
|
|
2555
|
+
);
|
|
2556
|
+
process.exit(1);
|
|
2557
|
+
}
|
|
2558
|
+
});
|
|
2559
|
+
|
|
2560
|
+
// mtc federation governance-sync-serve — daemon: periodically publish + pull
|
|
2561
|
+
fed
|
|
2562
|
+
.command("governance-sync-serve <federation-id>")
|
|
2563
|
+
.description(
|
|
2564
|
+
"Daemon: periodically publish local governance events + pull remote ones from a shared drop-zone",
|
|
2565
|
+
)
|
|
2566
|
+
.requiredOption("--drop-zone <dir>", "Shared filesystem directory")
|
|
2567
|
+
.option(
|
|
2568
|
+
"--interval <seconds>",
|
|
2569
|
+
"Sync interval (default: 60)",
|
|
2570
|
+
(v) => parseInt(v, 10),
|
|
2571
|
+
60,
|
|
2572
|
+
)
|
|
2573
|
+
.option(
|
|
2574
|
+
"--verify",
|
|
2575
|
+
"Verify signatures against local registry on pull (default: trust schema only)",
|
|
2576
|
+
)
|
|
2577
|
+
.option("--once", "Sync once and exit (no daemon loop)")
|
|
2578
|
+
.option("--json", "Emit per-tick JSON results to stdout")
|
|
2579
|
+
.action(async (federationId, options) => {
|
|
2580
|
+
const tick = () => {
|
|
2581
|
+
const stamp = new Date().toISOString();
|
|
2582
|
+
try {
|
|
2583
|
+
const pubResult = runGovernancePublish(
|
|
2584
|
+
federationId,
|
|
2585
|
+
options.dropZone,
|
|
2586
|
+
);
|
|
2587
|
+
const pullResult = runGovernancePull(federationId, options.dropZone, {
|
|
2588
|
+
verify: !!options.verify,
|
|
2589
|
+
});
|
|
2590
|
+
// Persist live stats so governance-sync-stats / web GUI can poll
|
|
2591
|
+
const stats = loadStats(federationId);
|
|
2592
|
+
stats.federation_id = federationId;
|
|
2593
|
+
stats.mode = "filesystem";
|
|
2594
|
+
stats.last_tick_at = stamp;
|
|
2595
|
+
stats.publish = stats.publish || { total_published: 0 };
|
|
2596
|
+
stats.publish.last_published = pubResult.published;
|
|
2597
|
+
stats.publish.last_skipped = pubResult.skipped;
|
|
2598
|
+
stats.publish.total_published =
|
|
2599
|
+
(stats.publish.total_published || 0) + pubResult.published;
|
|
2600
|
+
stats.pull = stats.pull || { total_appended: 0 };
|
|
2601
|
+
stats.pull.last_appended = pullResult.appended;
|
|
2602
|
+
stats.pull.last_duplicates = pullResult.duplicates;
|
|
2603
|
+
stats.pull.last_invalid = pullResult.invalid_signature;
|
|
2604
|
+
stats.pull.last_unknown = pullResult.unknown_signer;
|
|
2605
|
+
stats.pull.total_appended =
|
|
2606
|
+
(stats.pull.total_appended || 0) + pullResult.appended;
|
|
2607
|
+
saveStats(federationId, stats);
|
|
2608
|
+
|
|
2609
|
+
if (options.json) {
|
|
2610
|
+
console.log(
|
|
2611
|
+
JSON.stringify(
|
|
2612
|
+
{
|
|
2613
|
+
tick_at: stamp,
|
|
2614
|
+
publish: pubResult,
|
|
2615
|
+
pull: pullResult,
|
|
2616
|
+
},
|
|
2617
|
+
null,
|
|
2618
|
+
2,
|
|
2619
|
+
),
|
|
2620
|
+
);
|
|
2621
|
+
} else {
|
|
2622
|
+
console.log(
|
|
2623
|
+
`[${stamp}] publish: ${pubResult.published} new (${pubResult.skipped} skipped) | pull: ${pullResult.appended} new (dedup ${pullResult.duplicates}, invalid ${pullResult.invalid_signature}, unknown ${pullResult.unknown_signer})`,
|
|
2624
|
+
);
|
|
2625
|
+
}
|
|
2626
|
+
} catch (err) {
|
|
2627
|
+
console.error(`[${stamp}] tick error: ${err.message}`);
|
|
2628
|
+
}
|
|
2629
|
+
};
|
|
2630
|
+
|
|
2631
|
+
tick();
|
|
2632
|
+
if (options.once) return;
|
|
2633
|
+
|
|
2634
|
+
console.log(
|
|
2635
|
+
`governance-sync-serve: federation=${federationId} interval=${options.interval}s drop-zone=${options.dropZone}. Ctrl-C to stop.`,
|
|
2636
|
+
);
|
|
2637
|
+
const handle = setInterval(tick, options.interval * 1000);
|
|
2638
|
+
const stop = () => {
|
|
2639
|
+
clearInterval(handle);
|
|
2640
|
+
console.log("governance-sync-serve: stopped.");
|
|
2641
|
+
process.exit(0);
|
|
2642
|
+
};
|
|
2643
|
+
process.on("SIGINT", stop);
|
|
2644
|
+
process.on("SIGTERM", stop);
|
|
2645
|
+
await new Promise(() => {});
|
|
2646
|
+
});
|
|
2647
|
+
|
|
2648
|
+
// mtc federation governance-publish — push local events to a shared drop-zone
|
|
2649
|
+
fed
|
|
2650
|
+
.command("governance-publish <federation-id>")
|
|
2651
|
+
.description(
|
|
2652
|
+
"Publish local governance events to a shared drop-zone (filesystem path / NFS / Syncthing)",
|
|
2653
|
+
)
|
|
2654
|
+
.requiredOption("--drop-zone <dir>", "Shared filesystem directory")
|
|
2655
|
+
.option("--json", "JSON output")
|
|
2656
|
+
.action((federationId, options) => {
|
|
2657
|
+
try {
|
|
2658
|
+
const result = runGovernancePublish(federationId, options.dropZone);
|
|
2659
|
+
if (options.json) return console.log(JSON.stringify(result, null, 2));
|
|
2660
|
+
logger.success(
|
|
2661
|
+
`Published ${result.published} new event(s) (${result.skipped} already in drop-zone) to ${result.drop_zone}`,
|
|
2662
|
+
);
|
|
2663
|
+
} catch (err) {
|
|
2664
|
+
logger.error(
|
|
2665
|
+
`mtc federation governance-publish failed: ${err.message}`,
|
|
2666
|
+
);
|
|
2667
|
+
process.exit(1);
|
|
2668
|
+
}
|
|
2669
|
+
});
|
|
2670
|
+
|
|
2671
|
+
// mtc federation governance-pull — pull events from a shared drop-zone, dedupe + verify, append locally
|
|
2672
|
+
fed
|
|
2673
|
+
.command("governance-pull <federation-id>")
|
|
2674
|
+
.description(
|
|
2675
|
+
"Pull governance events from a shared drop-zone, dedupe by event_id, append new ones to the local log (with optional signature verify)",
|
|
2676
|
+
)
|
|
2677
|
+
.requiredOption("--drop-zone <dir>", "Shared filesystem directory")
|
|
2678
|
+
.option(
|
|
2679
|
+
"--verify",
|
|
2680
|
+
"Verify signatures against local registry before appending (default: trust schema only)",
|
|
2681
|
+
)
|
|
2682
|
+
.option("--json", "JSON output")
|
|
2683
|
+
.action((federationId, options) => {
|
|
2684
|
+
try {
|
|
2685
|
+
// CLI surface keeps the strict "must exist" contract; the daemon
|
|
2686
|
+
// helper treats absent drop-zone as "nothing yet" and returns zeros.
|
|
2687
|
+
const sourceDir = path.join(
|
|
2688
|
+
options.dropZone,
|
|
2689
|
+
"federation-governance",
|
|
2690
|
+
federationId,
|
|
2691
|
+
);
|
|
2692
|
+
if (!fs.existsSync(sourceDir)) {
|
|
2693
|
+
throw new Error(
|
|
2694
|
+
`drop-zone has no events for ${federationId}: ${sourceDir}`,
|
|
2695
|
+
);
|
|
2696
|
+
}
|
|
2697
|
+
const result = runGovernancePull(federationId, options.dropZone, {
|
|
2698
|
+
verify: !!options.verify,
|
|
2699
|
+
});
|
|
2700
|
+
if (options.json) return console.log(JSON.stringify(result, null, 2));
|
|
2701
|
+
logger.success(
|
|
2702
|
+
`Pulled ${result.appended} new event(s) (dedup: ${result.duplicates}, invalid: ${result.invalid_signature}, unknown: ${result.unknown_signer})`,
|
|
2703
|
+
);
|
|
2704
|
+
} catch (err) {
|
|
2705
|
+
logger.error(`mtc federation governance-pull failed: ${err.message}`);
|
|
2706
|
+
process.exit(1);
|
|
2707
|
+
}
|
|
2708
|
+
});
|
|
2709
|
+
|
|
2710
|
+
// mtc federation governance-log — show all events + replayed state
|
|
2711
|
+
fed
|
|
2712
|
+
.command("governance-log <federation-id>")
|
|
2713
|
+
.description("Show governance.log events + current replayed state")
|
|
2714
|
+
.option("--json", "JSON output")
|
|
2715
|
+
.option("--events-only", "Only print events, skip replay state")
|
|
2716
|
+
.action((federationId, options) => {
|
|
2717
|
+
try {
|
|
2718
|
+
const events = loadGovernanceLog(federationId);
|
|
2719
|
+
if (options.eventsOnly) {
|
|
2720
|
+
if (options.json) return console.log(JSON.stringify(events, null, 2));
|
|
2721
|
+
for (const e of events) {
|
|
2722
|
+
logger.log(
|
|
2723
|
+
`${e.issued_at} ${e.event_type.padEnd(20)} actor=${e.actor_member_id} event_id=${e.event_id}`,
|
|
2724
|
+
);
|
|
2725
|
+
}
|
|
2726
|
+
return;
|
|
2727
|
+
}
|
|
2728
|
+
const state = mtcLib.replayGovernanceLog(events, federationId);
|
|
2729
|
+
if (options.json) {
|
|
2730
|
+
return console.log(JSON.stringify({ events, state }, null, 2));
|
|
2731
|
+
}
|
|
2732
|
+
logger.log(
|
|
2733
|
+
`Federation: ${state.federation_id} status=${state.status} threshold=${state.threshold}`,
|
|
2734
|
+
);
|
|
2735
|
+
logger.log(`Members (${state.members.length}):`);
|
|
2736
|
+
for (const m of state.members) {
|
|
2737
|
+
logger.log(
|
|
2738
|
+
` ${m.member_id.padEnd(20)} weight=${m.weight} status=${m.status} alg=${m.alg}`,
|
|
2739
|
+
);
|
|
2740
|
+
}
|
|
2741
|
+
if (state.pending_invites.length) {
|
|
2742
|
+
logger.log(`Pending invites (${state.pending_invites.length}):`);
|
|
2743
|
+
for (const i of state.pending_invites) {
|
|
2744
|
+
logger.log(
|
|
2745
|
+
` ${i.member_id} approve=${i.votes.approve.length}/${i.required} reject=${i.votes.reject.length}`,
|
|
2746
|
+
);
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
if (state.pending_threshold) {
|
|
2750
|
+
logger.log(
|
|
2751
|
+
`Pending threshold: ${state.pending_threshold.target} (activates ${state.pending_threshold.activates_at})`,
|
|
2752
|
+
);
|
|
2753
|
+
}
|
|
2754
|
+
if (state.archived_keys.length || state.compromised_keys.length) {
|
|
2755
|
+
logger.log(
|
|
2756
|
+
`Archived keys: ${state.archived_keys.length}, compromised: ${state.compromised_keys.length}`,
|
|
2757
|
+
);
|
|
2758
|
+
}
|
|
2759
|
+
} catch (err) {
|
|
2760
|
+
logger.error(`mtc federation governance-log failed: ${err.message}`);
|
|
2761
|
+
process.exit(1);
|
|
2762
|
+
}
|
|
2763
|
+
});
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
2767
|
+
// Federation discover daemon (Phase 3.3)
|
|
2768
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
2769
|
+
|
|
2770
|
+
function getDiscoverAnnouncesDir(dropZone, federationId) {
|
|
2771
|
+
return path.join(dropZone, "federation-announces", federationId);
|
|
2772
|
+
}
|
|
2773
|
+
|
|
2774
|
+
function getDiscoverFilename(announce) {
|
|
2775
|
+
// pubkey_id is "sha256:base64url" — replace : for cross-platform safety
|
|
2776
|
+
const safe = announce.pubkey_id.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
2777
|
+
return `${safe}.json`;
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
function publishAnnounce(dropZone, announce) {
|
|
2781
|
+
const dir = getDiscoverAnnouncesDir(dropZone, announce.federation_id);
|
|
2782
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
2783
|
+
const filename = getDiscoverFilename(announce);
|
|
2784
|
+
const target = path.join(dir, filename);
|
|
2785
|
+
// Atomic write: tmp + rename
|
|
2786
|
+
const tmp = `${target}.${process.pid}.tmp`;
|
|
2787
|
+
fs.writeFileSync(tmp, JSON.stringify(announce, null, 2), "utf-8");
|
|
2788
|
+
fs.renameSync(tmp, target);
|
|
2789
|
+
return target;
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
function scanDropZone(dropZone, federationId) {
|
|
2793
|
+
const dir = getDiscoverAnnouncesDir(dropZone, federationId);
|
|
2794
|
+
if (!fs.existsSync(dir)) return [];
|
|
2795
|
+
return fs
|
|
2796
|
+
.readdirSync(dir)
|
|
2797
|
+
.filter((n) => n.endsWith(".json"))
|
|
2798
|
+
.map((n) => {
|
|
2799
|
+
const file = path.join(dir, n);
|
|
2800
|
+
try {
|
|
2801
|
+
return { file, announce: JSON.parse(fs.readFileSync(file, "utf-8")) };
|
|
2802
|
+
} catch (err) {
|
|
2803
|
+
return { file, error: err.message };
|
|
2804
|
+
}
|
|
2805
|
+
});
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2808
|
+
/**
|
|
2809
|
+
* Helper: load member key + return {announceBuilder, member} for self-announce.
|
|
2810
|
+
* Used by both filesystem and libp2p paths.
|
|
2811
|
+
*/
|
|
2812
|
+
function loadFederationMemberForAnnounce(federationId, memberId, ttlSeconds) {
|
|
2813
|
+
const registry = loadFederationRegistry();
|
|
2814
|
+
const fedEntry = registry.federations[federationId];
|
|
2815
|
+
if (!fedEntry || !fedEntry.members[memberId]) {
|
|
2816
|
+
throw new Error(
|
|
2817
|
+
`not joined as "${memberId}" in federation "${federationId}" — run \`cc mtc federation join ${federationId} --member-id ${memberId}\` first`,
|
|
2818
|
+
);
|
|
2819
|
+
}
|
|
2820
|
+
const member = fedEntry.members[memberId];
|
|
2821
|
+
if (!member.key_file || !fs.existsSync(member.key_file)) {
|
|
2822
|
+
throw new Error(`member key file missing: ${member.key_file}`);
|
|
2823
|
+
}
|
|
2824
|
+
let signerInfo;
|
|
2825
|
+
if (member.alg === "Ed25519") signerInfo = resolveSigner("ed25519");
|
|
2826
|
+
else if (member.alg === "SLH-DSA-SHA2-128F")
|
|
2827
|
+
signerInfo = resolveSigner("slh-dsa-128f");
|
|
2828
|
+
else throw new Error(`unknown member alg: ${member.alg}`);
|
|
2829
|
+
const keys = loadOrGenerateKeyPair(member.key_file, signerInfo);
|
|
2830
|
+
|
|
2831
|
+
return {
|
|
2832
|
+
member,
|
|
2833
|
+
buildAnnounce: () =>
|
|
2834
|
+
mtcLib.createMemberAnnounce({
|
|
2835
|
+
federationId,
|
|
2836
|
+
memberId,
|
|
2837
|
+
issuer: member.issuer,
|
|
2838
|
+
secretKey: keys.secretKey,
|
|
2839
|
+
publicKey: keys.publicKey,
|
|
2840
|
+
signer: signerInfo.signer,
|
|
2841
|
+
ttlSeconds,
|
|
2842
|
+
}),
|
|
2843
|
+
};
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2846
|
+
async function runFederationDiscover(federationId, options) {
|
|
2847
|
+
const transport = (options.transport || "filesystem").toLowerCase();
|
|
2848
|
+
if (transport === "libp2p") {
|
|
2849
|
+
return runFederationDiscoverLibp2p(federationId, options);
|
|
2850
|
+
}
|
|
2851
|
+
if (transport !== "filesystem") {
|
|
2852
|
+
throw new Error(
|
|
2853
|
+
`Unknown --transport: ${options.transport} (supported: filesystem, libp2p)`,
|
|
2854
|
+
);
|
|
2855
|
+
}
|
|
2856
|
+
if (!options.dropZone) {
|
|
2857
|
+
throw new Error("--drop-zone is required when --transport=filesystem");
|
|
2858
|
+
}
|
|
2859
|
+
return runFederationDiscoverFilesystem(federationId, options);
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2862
|
+
async function runFederationDiscoverFilesystem(federationId, options) {
|
|
2863
|
+
const FederationAnnounceCache = mtcLib.FederationAnnounceCache;
|
|
2864
|
+
|
|
2865
|
+
const cache = new FederationAnnounceCache({
|
|
2866
|
+
persistDir: options.cacheDir,
|
|
2867
|
+
});
|
|
2868
|
+
|
|
2869
|
+
// Build self-announce iff --member-id provided
|
|
2870
|
+
let selfAnnounceFn = null;
|
|
2871
|
+
if (options.memberId) {
|
|
2872
|
+
const { buildAnnounce } = loadFederationMemberForAnnounce(
|
|
2873
|
+
federationId,
|
|
2874
|
+
options.memberId,
|
|
2875
|
+
options.ttl,
|
|
2876
|
+
);
|
|
2877
|
+
|
|
2878
|
+
selfAnnounceFn = () => {
|
|
2879
|
+
const ann = buildAnnounce();
|
|
2880
|
+
const written = publishAnnounce(options.dropZone, ann);
|
|
2881
|
+
return { announce: ann, file: written };
|
|
2882
|
+
};
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
function scanAndIngest() {
|
|
2886
|
+
const entries = scanDropZone(options.dropZone, federationId);
|
|
2887
|
+
let accepted = 0;
|
|
2888
|
+
let rejected = 0;
|
|
2889
|
+
const failures = [];
|
|
2890
|
+
for (const e of entries) {
|
|
2891
|
+
if (e.error) {
|
|
2892
|
+
rejected++;
|
|
2893
|
+
failures.push({ file: e.file, code: "PARSE_ERROR" });
|
|
2894
|
+
continue;
|
|
2895
|
+
}
|
|
2896
|
+
const r = cache.ingest(e.announce);
|
|
2897
|
+
if (r.accepted) accepted++;
|
|
2898
|
+
else {
|
|
2899
|
+
rejected++;
|
|
2900
|
+
failures.push({ file: e.file, code: r.reason });
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
return { scanned: entries.length, accepted, rejected, failures };
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
function snapshot() {
|
|
2907
|
+
return {
|
|
2908
|
+
federation_id: federationId,
|
|
2909
|
+
drop_zone: options.dropZone,
|
|
2910
|
+
members: cache.listMembers(federationId).map((m) => ({
|
|
2911
|
+
member_id: m.member_id,
|
|
2912
|
+
issuer: m.issuer,
|
|
2913
|
+
alg: m.alg,
|
|
2914
|
+
pubkey_id: m.pubkey_id,
|
|
2915
|
+
announced_at: m.announced_at,
|
|
2916
|
+
ttl_seconds: m.ttl_seconds,
|
|
2917
|
+
})),
|
|
2918
|
+
};
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
// First pass: announce self + scan
|
|
2922
|
+
let selfFile = null;
|
|
2923
|
+
if (selfAnnounceFn) {
|
|
2924
|
+
const r = selfAnnounceFn();
|
|
2925
|
+
selfFile = r.file;
|
|
2926
|
+
}
|
|
2927
|
+
const firstScan = scanAndIngest();
|
|
2928
|
+
|
|
2929
|
+
if (options.once) {
|
|
2930
|
+
const out = {
|
|
2931
|
+
ok: true,
|
|
2932
|
+
federation_id: federationId,
|
|
2933
|
+
self_announce_file: selfFile,
|
|
2934
|
+
scan: firstScan,
|
|
2935
|
+
...snapshot(),
|
|
2936
|
+
};
|
|
2937
|
+
if (options.json) {
|
|
2938
|
+
console.log(JSON.stringify(out, null, 2));
|
|
2939
|
+
} else {
|
|
2940
|
+
logger.success(
|
|
2941
|
+
`discovered ${out.members.length} member(s) in federation ${federationId}`,
|
|
2942
|
+
);
|
|
2943
|
+
for (const m of out.members) {
|
|
2944
|
+
logger.log(
|
|
2945
|
+
` · ${chalk.green(m.member_id)} (${m.alg}) ${chalk.gray(m.pubkey_id.slice(0, 18) + "…")}`,
|
|
2946
|
+
);
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
2949
|
+
return;
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
// Daemon: re-announce + re-scan on intervals
|
|
2953
|
+
const reannounceMs = Math.max(60, Math.floor(options.ttl / 3)) * 1000;
|
|
2954
|
+
const scanMs = Math.max(1, options.scanInterval) * 1000;
|
|
2955
|
+
|
|
2956
|
+
let scanTimer = null;
|
|
2957
|
+
let announceTimer = null;
|
|
2958
|
+
function cleanup() {
|
|
2959
|
+
if (scanTimer) clearInterval(scanTimer);
|
|
2960
|
+
if (announceTimer) clearInterval(announceTimer);
|
|
2961
|
+
process.exit(0);
|
|
2962
|
+
}
|
|
2963
|
+
process.once("SIGINT", cleanup);
|
|
2964
|
+
process.once("SIGTERM", cleanup);
|
|
2965
|
+
|
|
2966
|
+
// Re-entrancy guard: if a scan tick takes longer than scanInterval (large
|
|
2967
|
+
// drop-zone, slow disk), don't let setInterval stack up overlapping ticks.
|
|
2968
|
+
let scanInProgress = false;
|
|
2969
|
+
scanTimer = setInterval(() => {
|
|
2970
|
+
if (scanInProgress) return;
|
|
2971
|
+
scanInProgress = true;
|
|
2972
|
+
try {
|
|
2973
|
+
const r = scanAndIngest();
|
|
2974
|
+
if (options.json) {
|
|
2975
|
+
console.log(
|
|
2976
|
+
JSON.stringify({ tick: "scan", ...r, ...snapshot() }, null, 2),
|
|
2977
|
+
);
|
|
2978
|
+
} else {
|
|
2979
|
+
logger.log(
|
|
2980
|
+
`[${new Date().toISOString()}] scan: ${r.accepted}+/${r.scanned} accepted, ${cache.listMembers(federationId).length} live`,
|
|
2981
|
+
);
|
|
2982
|
+
}
|
|
2983
|
+
} catch (err) {
|
|
2984
|
+
logger.error(`scan failed: ${err.message}`);
|
|
2985
|
+
} finally {
|
|
2986
|
+
scanInProgress = false;
|
|
2987
|
+
}
|
|
2988
|
+
}, scanMs);
|
|
2989
|
+
|
|
2990
|
+
if (selfAnnounceFn) {
|
|
2991
|
+
announceTimer = setInterval(() => {
|
|
2992
|
+
try {
|
|
2993
|
+
selfAnnounceFn();
|
|
2994
|
+
if (!options.json) {
|
|
2995
|
+
logger.log(
|
|
2996
|
+
`[${new Date().toISOString()}] re-announced self in federation ${federationId}`,
|
|
2997
|
+
);
|
|
2998
|
+
}
|
|
2999
|
+
} catch (err) {
|
|
3000
|
+
logger.error(`self-announce failed: ${err.message}`);
|
|
3001
|
+
}
|
|
3002
|
+
}, reannounceMs);
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
logger.success(
|
|
3006
|
+
`federation discover daemon running (drop-zone: ${options.dropZone}, scan: ${options.scanInterval}s, ttl: ${options.ttl}s)${
|
|
3007
|
+
selfAnnounceFn ? `, announcing as ${options.memberId}` : ", listen-only"
|
|
3008
|
+
}`,
|
|
3009
|
+
);
|
|
3010
|
+
await new Promise(() => {});
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
const FEDERATION_TOPIC_PREFIX = "mtc-federation/v1";
|
|
3014
|
+
|
|
3015
|
+
function federationTopic(federationId) {
|
|
3016
|
+
return `${FEDERATION_TOPIC_PREFIX}/${federationId}`;
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3019
|
+
function governanceTopic(federationId) {
|
|
3020
|
+
return `mtc-federation-governance/v1/${federationId}`;
|
|
3021
|
+
}
|
|
3022
|
+
|
|
3023
|
+
/**
|
|
3024
|
+
* libp2p gossipsub-based governance sync (Phase 2 of v0.9 sync work).
|
|
3025
|
+
*
|
|
3026
|
+
* Each peer subscribes to mtc-federation-governance/v1/<fed>; on each tick
|
|
3027
|
+
* the peer publishes any local events that haven't been published yet
|
|
3028
|
+
* (tracked in <governance-dir>/<fed>.libp2p-pos.json — a tiny offset file
|
|
3029
|
+
* mapping event_id → already-published flag). Receivers dedupe + optionally
|
|
3030
|
+
* verify each event before appending to their local jsonl.
|
|
3031
|
+
*
|
|
3032
|
+
* --once mode subscribes, publishes one batch, waits one interval to drain
|
|
3033
|
+
* inbox, then exits — suitable for cron / tests.
|
|
3034
|
+
*/
|
|
3035
|
+
async function runGovernanceSyncLibp2p(federationId, options) {
|
|
3036
|
+
const { Libp2pTransport } =
|
|
3037
|
+
await import("@chainlesschain/core-mtc/transports/libp2p");
|
|
3038
|
+
|
|
3039
|
+
const node = await Libp2pTransport.create({
|
|
3040
|
+
listen: options.listen,
|
|
3041
|
+
mode: "gossipsub",
|
|
3042
|
+
});
|
|
3043
|
+
|
|
3044
|
+
const closeOnError = async (err) => {
|
|
3045
|
+
try {
|
|
3046
|
+
await node.close();
|
|
3047
|
+
} catch (_e) {
|
|
3048
|
+
/* ignore */
|
|
3049
|
+
}
|
|
3050
|
+
throw err;
|
|
3051
|
+
};
|
|
3052
|
+
|
|
3053
|
+
try {
|
|
3054
|
+
return await runGovernanceSyncLibp2pInner(federationId, options, node);
|
|
3055
|
+
} catch (err) {
|
|
3056
|
+
return closeOnError(err);
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
function loadLibp2pPubMarkers(federationId) {
|
|
3061
|
+
const file = path.join(
|
|
3062
|
+
getFederationDir(),
|
|
3063
|
+
"governance",
|
|
3064
|
+
`${federationId}.libp2p-pos.json`,
|
|
3065
|
+
);
|
|
3066
|
+
if (!fs.existsSync(file)) return new Set();
|
|
3067
|
+
try {
|
|
3068
|
+
return new Set(JSON.parse(fs.readFileSync(file, "utf-8")));
|
|
3069
|
+
} catch (_err) {
|
|
3070
|
+
return new Set();
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
|
|
3074
|
+
function saveLibp2pPubMarkers(federationId, ids) {
|
|
3075
|
+
const file = path.join(
|
|
3076
|
+
getFederationDir(),
|
|
3077
|
+
"governance",
|
|
3078
|
+
`${federationId}.libp2p-pos.json`,
|
|
3079
|
+
);
|
|
3080
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
3081
|
+
fs.writeFileSync(file, JSON.stringify([...ids]), "utf-8");
|
|
3082
|
+
}
|
|
3083
|
+
|
|
3084
|
+
async function runGovernanceSyncLibp2pInner(federationId, options, node) {
|
|
3085
|
+
const topic = governanceTopic(federationId);
|
|
3086
|
+
let received = 0;
|
|
3087
|
+
let appendedFromWire = 0;
|
|
3088
|
+
let invalidFromWire = 0;
|
|
3089
|
+
let unknownFromWire = 0;
|
|
3090
|
+
|
|
3091
|
+
// verify lookup (built once from local registry)
|
|
3092
|
+
let verifyLookup = null;
|
|
3093
|
+
if (options.verify) {
|
|
3094
|
+
const registry = loadFederationRegistry();
|
|
3095
|
+
const fedEntry = registry.federations[federationId] || { members: {} };
|
|
3096
|
+
verifyLookup = (actor) => {
|
|
3097
|
+
const m = fedEntry.members[actor];
|
|
3098
|
+
if (!m || !m.pubkey_jwk) return null;
|
|
3099
|
+
try {
|
|
3100
|
+
return Buffer.from(m.pubkey_jwk.x, "base64url");
|
|
3101
|
+
} catch (_err) {
|
|
3102
|
+
return null;
|
|
3103
|
+
}
|
|
3104
|
+
};
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3107
|
+
// Subscribe + dispatch
|
|
3108
|
+
node.subscribeRaw(topic, (bytes) => {
|
|
3109
|
+
received++;
|
|
3110
|
+
let ev;
|
|
3111
|
+
try {
|
|
3112
|
+
ev = JSON.parse(new TextDecoder().decode(bytes));
|
|
3113
|
+
} catch (_err) {
|
|
3114
|
+
return;
|
|
3115
|
+
}
|
|
3116
|
+
if (!ev || typeof ev.event_id !== "string") return;
|
|
3117
|
+
|
|
3118
|
+
if (verifyLookup) {
|
|
3119
|
+
const result = mtcLib.verifyGovernanceLog([ev], verifyLookup);
|
|
3120
|
+
if (result.invalid.length) {
|
|
3121
|
+
invalidFromWire++;
|
|
3122
|
+
return;
|
|
3123
|
+
}
|
|
3124
|
+
if (result.unknown.length) {
|
|
3125
|
+
unknownFromWire++;
|
|
3126
|
+
return;
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
3129
|
+
|
|
3130
|
+
// Dedupe vs local log
|
|
3131
|
+
const local = loadGovernanceLog(federationId);
|
|
3132
|
+
if (local.some((e) => e && e.event_id === ev.event_id)) return;
|
|
3133
|
+
appendGovernanceEvent(federationId, ev);
|
|
3134
|
+
appendedFromWire++;
|
|
3135
|
+
});
|
|
3136
|
+
|
|
3137
|
+
// Dial seed peers
|
|
3138
|
+
for (const peer of options.connect || []) {
|
|
3139
|
+
try {
|
|
3140
|
+
await node.dial(peer);
|
|
3141
|
+
} catch (err) {
|
|
3142
|
+
console.warn(`[libp2p] dial ${peer} failed: ${err.message}`);
|
|
3143
|
+
}
|
|
3144
|
+
}
|
|
3145
|
+
|
|
3146
|
+
const publishTick = async () => {
|
|
3147
|
+
const stamp = new Date().toISOString();
|
|
3148
|
+
const local = loadGovernanceLog(federationId);
|
|
3149
|
+
const published = loadLibp2pPubMarkers(federationId);
|
|
3150
|
+
let publishedThisTick = 0;
|
|
3151
|
+
for (const ev of local) {
|
|
3152
|
+
if (!ev || typeof ev.event_id !== "string") continue;
|
|
3153
|
+
if (published.has(ev.event_id)) continue;
|
|
3154
|
+
try {
|
|
3155
|
+
await node.publishRaw(topic, JSON.stringify(ev));
|
|
3156
|
+
published.add(ev.event_id);
|
|
3157
|
+
publishedThisTick++;
|
|
3158
|
+
} catch (err) {
|
|
3159
|
+
console.warn(`[libp2p] publish ${ev.event_id} failed: ${err.message}`);
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
if (publishedThisTick > 0) saveLibp2pPubMarkers(federationId, published);
|
|
3163
|
+
|
|
3164
|
+
// Persist live stats so governance-sync-stats / web GUI can poll
|
|
3165
|
+
const stats = loadStats(federationId);
|
|
3166
|
+
stats.federation_id = federationId;
|
|
3167
|
+
stats.mode = "libp2p";
|
|
3168
|
+
stats.last_tick_at = stamp;
|
|
3169
|
+
stats.publish = stats.publish || { total_published: 0 };
|
|
3170
|
+
stats.publish.last_published = publishedThisTick;
|
|
3171
|
+
stats.publish.total_published =
|
|
3172
|
+
(stats.publish.total_published || 0) + publishedThisTick;
|
|
3173
|
+
stats.libp2p = stats.libp2p || {};
|
|
3174
|
+
stats.libp2p.wire_received = received;
|
|
3175
|
+
stats.libp2p.wire_appended = appendedFromWire;
|
|
3176
|
+
stats.libp2p.wire_invalid = invalidFromWire;
|
|
3177
|
+
stats.libp2p.wire_unknown = unknownFromWire;
|
|
3178
|
+
stats.libp2p.topic = topic;
|
|
3179
|
+
saveStats(federationId, stats);
|
|
3180
|
+
|
|
3181
|
+
if (options.json) {
|
|
3182
|
+
console.log(
|
|
3183
|
+
JSON.stringify(
|
|
3184
|
+
{
|
|
3185
|
+
tick_at: stamp,
|
|
3186
|
+
published: publishedThisTick,
|
|
3187
|
+
wire_received: received,
|
|
3188
|
+
wire_appended: appendedFromWire,
|
|
3189
|
+
wire_invalid: invalidFromWire,
|
|
3190
|
+
wire_unknown: unknownFromWire,
|
|
3191
|
+
},
|
|
3192
|
+
null,
|
|
3193
|
+
2,
|
|
3194
|
+
),
|
|
3195
|
+
);
|
|
3196
|
+
} else {
|
|
3197
|
+
console.log(
|
|
3198
|
+
`[${stamp}] published ${publishedThisTick} new event(s); wire received=${received} appended=${appendedFromWire} invalid=${invalidFromWire} unknown=${unknownFromWire}`,
|
|
3199
|
+
);
|
|
3200
|
+
}
|
|
3201
|
+
};
|
|
3202
|
+
|
|
3203
|
+
await publishTick();
|
|
3204
|
+
|
|
3205
|
+
if (options.once) {
|
|
3206
|
+
// Wait one interval to drain inbox, then exit cleanly
|
|
3207
|
+
await new Promise((r) => setTimeout(r, options.interval * 1000));
|
|
3208
|
+
await publishTick();
|
|
3209
|
+
await node.close();
|
|
3210
|
+
return;
|
|
3211
|
+
}
|
|
3212
|
+
|
|
3213
|
+
console.log(
|
|
3214
|
+
`governance-sync-libp2p: federation=${federationId} topic=${topic} interval=${options.interval}s. Ctrl-C to stop.`,
|
|
3215
|
+
);
|
|
3216
|
+
const handle = setInterval(publishTick, options.interval * 1000);
|
|
3217
|
+
const stop = async () => {
|
|
3218
|
+
clearInterval(handle);
|
|
3219
|
+
try {
|
|
3220
|
+
await node.close();
|
|
3221
|
+
} catch (_e) {
|
|
3222
|
+
/* ignore */
|
|
3223
|
+
}
|
|
3224
|
+
console.log("governance-sync-libp2p: stopped.");
|
|
3225
|
+
process.exit(0);
|
|
3226
|
+
};
|
|
3227
|
+
process.on("SIGINT", stop);
|
|
3228
|
+
process.on("SIGTERM", stop);
|
|
3229
|
+
await new Promise(() => {});
|
|
3230
|
+
}
|
|
3231
|
+
|
|
3232
|
+
async function runFederationDiscoverLibp2p(federationId, options) {
|
|
3233
|
+
const FederationAnnounceCache = mtcLib.FederationAnnounceCache;
|
|
3234
|
+
const { Libp2pTransport } =
|
|
3235
|
+
await import("@chainlesschain/core-mtc/transports/libp2p");
|
|
3236
|
+
|
|
3237
|
+
const cache = new FederationAnnounceCache({
|
|
3238
|
+
persistDir: options.cacheDir,
|
|
3239
|
+
});
|
|
3240
|
+
|
|
3241
|
+
let selfBuildAnnounce = null;
|
|
3242
|
+
let selfMember = null;
|
|
3243
|
+
if (options.memberId) {
|
|
3244
|
+
const { member, buildAnnounce } = loadFederationMemberForAnnounce(
|
|
3245
|
+
federationId,
|
|
3246
|
+
options.memberId,
|
|
3247
|
+
options.ttl,
|
|
3248
|
+
);
|
|
3249
|
+
selfBuildAnnounce = buildAnnounce;
|
|
3250
|
+
selfMember = member;
|
|
3251
|
+
}
|
|
3252
|
+
|
|
3253
|
+
// Spin up gossipsub libp2p node
|
|
3254
|
+
const node = await Libp2pTransport.create({
|
|
3255
|
+
listen: options.listen,
|
|
3256
|
+
mode: "gossipsub",
|
|
3257
|
+
});
|
|
3258
|
+
|
|
3259
|
+
// Helper: tear down the node on any error path so we don't leak the
|
|
3260
|
+
// libp2p host when initialization throws after node creation.
|
|
3261
|
+
const closeNodeOnError = async (err) => {
|
|
3262
|
+
try {
|
|
3263
|
+
await node.close();
|
|
3264
|
+
} catch (_e) {
|
|
3265
|
+
/* ignore close errors during error cleanup */
|
|
3266
|
+
}
|
|
3267
|
+
throw err;
|
|
3268
|
+
};
|
|
3269
|
+
|
|
3270
|
+
try {
|
|
3271
|
+
return await runFederationDiscoverLibp2pInner(
|
|
3272
|
+
federationId,
|
|
3273
|
+
options,
|
|
3274
|
+
node,
|
|
3275
|
+
cache,
|
|
3276
|
+
selfBuildAnnounce,
|
|
3277
|
+
selfMember,
|
|
3278
|
+
);
|
|
3279
|
+
} catch (err) {
|
|
3280
|
+
return closeNodeOnError(err);
|
|
3281
|
+
}
|
|
3282
|
+
}
|
|
3283
|
+
|
|
3284
|
+
async function runFederationDiscoverLibp2pInner(
|
|
3285
|
+
federationId,
|
|
3286
|
+
options,
|
|
3287
|
+
node,
|
|
3288
|
+
cache,
|
|
3289
|
+
selfBuildAnnounce,
|
|
3290
|
+
selfMember,
|
|
3291
|
+
) {
|
|
3292
|
+
const topic = federationTopic(federationId);
|
|
3293
|
+
|
|
3294
|
+
// Subscribe + dispatch into cache
|
|
3295
|
+
let bytesReceived = 0;
|
|
3296
|
+
node.subscribeRaw(topic, (bytes) => {
|
|
3297
|
+
bytesReceived++;
|
|
3298
|
+
try {
|
|
3299
|
+
cache.ingest(JSON.parse(new TextDecoder().decode(bytes)));
|
|
3300
|
+
} catch (_err) {
|
|
3301
|
+
/* malformed announce — drop */
|
|
3302
|
+
}
|
|
3303
|
+
});
|
|
3304
|
+
|
|
3305
|
+
// Dial seed peers
|
|
3306
|
+
for (const peer of options.connect || []) {
|
|
3307
|
+
try {
|
|
3308
|
+
await node.connect(peer);
|
|
3309
|
+
} catch (err) {
|
|
3310
|
+
logger.warn(`connect to ${peer} failed: ${err.message}`);
|
|
3311
|
+
}
|
|
3312
|
+
}
|
|
3313
|
+
|
|
3314
|
+
// Mesh formation wait
|
|
3315
|
+
const meshWaitMs = Math.max(0, options.meshWaitMs ?? 1500);
|
|
3316
|
+
if (meshWaitMs > 0) await new Promise((r) => setTimeout(r, meshWaitMs));
|
|
3317
|
+
|
|
3318
|
+
async function publishSelf() {
|
|
3319
|
+
if (!selfBuildAnnounce) return null;
|
|
3320
|
+
const ann = selfBuildAnnounce();
|
|
3321
|
+
const result = await node.publishRaw(topic, JSON.stringify(ann));
|
|
3322
|
+
return { announce: ann, recipients: result.recipients };
|
|
3323
|
+
}
|
|
3324
|
+
|
|
3325
|
+
function snapshot() {
|
|
3326
|
+
return {
|
|
3327
|
+
federation_id: federationId,
|
|
3328
|
+
transport: "libp2p",
|
|
3329
|
+
multiaddrs: node.multiaddrs(),
|
|
3330
|
+
peer_id: node.peerIdString(),
|
|
3331
|
+
members: cache.listMembers(federationId).map((m) => ({
|
|
3332
|
+
member_id: m.member_id,
|
|
3333
|
+
issuer: m.issuer,
|
|
3334
|
+
alg: m.alg,
|
|
3335
|
+
pubkey_id: m.pubkey_id,
|
|
3336
|
+
announced_at: m.announced_at,
|
|
3337
|
+
ttl_seconds: m.ttl_seconds,
|
|
3338
|
+
})),
|
|
3339
|
+
};
|
|
3340
|
+
}
|
|
3341
|
+
|
|
3342
|
+
// First pass: announce self
|
|
3343
|
+
let firstPublish = null;
|
|
3344
|
+
if (selfBuildAnnounce) {
|
|
3345
|
+
firstPublish = await publishSelf();
|
|
3346
|
+
}
|
|
3347
|
+
|
|
3348
|
+
if (options.once) {
|
|
3349
|
+
// Wait briefly for any incoming announces from peers we just dialed
|
|
3350
|
+
if ((options.connect || []).length > 0) {
|
|
3351
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
3352
|
+
}
|
|
3353
|
+
const out = {
|
|
3354
|
+
ok: true,
|
|
3355
|
+
...snapshot(),
|
|
3356
|
+
self_announce: firstPublish
|
|
3357
|
+
? {
|
|
3358
|
+
member_id: options.memberId,
|
|
3359
|
+
issuer: selfMember?.issuer,
|
|
3360
|
+
recipients: firstPublish.recipients,
|
|
3361
|
+
}
|
|
3362
|
+
: null,
|
|
3363
|
+
bytes_received: bytesReceived,
|
|
3364
|
+
};
|
|
3365
|
+
if (options.json) {
|
|
3366
|
+
console.log(JSON.stringify(out, null, 2));
|
|
3367
|
+
} else {
|
|
3368
|
+
logger.success(
|
|
3369
|
+
`libp2p discover: peer_id=${out.peer_id}, ${out.members.length} member(s) cached`,
|
|
3370
|
+
);
|
|
3371
|
+
for (const a of out.multiaddrs) logger.log(` listen: ${a}`);
|
|
3372
|
+
for (const m of out.members) {
|
|
3373
|
+
logger.log(
|
|
3374
|
+
` · ${chalk.green(m.member_id)} (${m.alg}) ${chalk.gray(m.pubkey_id.slice(0, 18) + "…")}`,
|
|
3375
|
+
);
|
|
3376
|
+
}
|
|
3377
|
+
}
|
|
3378
|
+
await node.close();
|
|
3379
|
+
return;
|
|
3380
|
+
}
|
|
3381
|
+
|
|
3382
|
+
// Daemon: re-announce on TTL/3
|
|
3383
|
+
const reannounceMs = Math.max(60, Math.floor(options.ttl / 3)) * 1000;
|
|
3384
|
+
let announceTimer = null;
|
|
3385
|
+
if (selfBuildAnnounce) {
|
|
3386
|
+
announceTimer = setInterval(async () => {
|
|
3387
|
+
try {
|
|
3388
|
+
await publishSelf();
|
|
3389
|
+
if (!options.json) {
|
|
3390
|
+
logger.log(
|
|
3391
|
+
`[${new Date().toISOString()}] re-announced via libp2p in federation ${federationId}`,
|
|
3392
|
+
);
|
|
3393
|
+
}
|
|
3394
|
+
} catch (err) {
|
|
3395
|
+
logger.error(`self-announce failed: ${err.message}`);
|
|
3396
|
+
}
|
|
3397
|
+
}, reannounceMs);
|
|
3398
|
+
}
|
|
3399
|
+
|
|
3400
|
+
const cleanup = async () => {
|
|
3401
|
+
if (announceTimer) clearInterval(announceTimer);
|
|
3402
|
+
try {
|
|
3403
|
+
await node.close();
|
|
3404
|
+
} catch (_err) {
|
|
3405
|
+
/* ignore */
|
|
3406
|
+
}
|
|
3407
|
+
process.exit(0);
|
|
3408
|
+
};
|
|
3409
|
+
process.once("SIGINT", cleanup);
|
|
3410
|
+
process.once("SIGTERM", cleanup);
|
|
3411
|
+
|
|
3412
|
+
logger.success(
|
|
3413
|
+
`federation discover daemon running (libp2p, peer_id: ${node.peerIdString()})${
|
|
3414
|
+
selfBuildAnnounce
|
|
3415
|
+
? `, announcing as ${options.memberId} (TTL ${options.ttl}s)`
|
|
3416
|
+
: ", listen-only"
|
|
3417
|
+
}`,
|
|
3418
|
+
);
|
|
3419
|
+
for (const a of node.multiaddrs()) {
|
|
3420
|
+
logger.log(` listen: ${a}`);
|
|
3421
|
+
}
|
|
3422
|
+
await new Promise(() => {});
|
|
3423
|
+
}
|
|
3424
|
+
|
|
3425
|
+
// Internals exported for tests
|
|
3426
|
+
export const _federationInternals = {
|
|
3427
|
+
FEDERATION_REGISTRY_SCHEMA,
|
|
3428
|
+
loadFederationRegistry,
|
|
3429
|
+
saveFederationRegistry,
|
|
3430
|
+
getFederationDir,
|
|
3431
|
+
getFederationRegistryPath,
|
|
3432
|
+
publishAnnounce,
|
|
3433
|
+
scanDropZone,
|
|
3434
|
+
getDiscoverAnnouncesDir,
|
|
3435
|
+
};
|