@optimystic/db-p2p 0.13.5 → 0.14.1

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.
Files changed (342) hide show
  1. package/README.md +6 -6
  2. package/dist/src/cluster/block-transfer-service.d.ts +67 -8
  3. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  4. package/dist/src/cluster/block-transfer-service.js +101 -62
  5. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  6. package/dist/src/cluster/client.d.ts +18 -1
  7. package/dist/src/cluster/client.d.ts.map +1 -1
  8. package/dist/src/cluster/client.js +55 -39
  9. package/dist/src/cluster/client.js.map +1 -1
  10. package/dist/src/cluster/cluster-error.d.ts +38 -0
  11. package/dist/src/cluster/cluster-error.d.ts.map +1 -0
  12. package/dist/src/cluster/cluster-error.js +51 -0
  13. package/dist/src/cluster/cluster-error.js.map +1 -0
  14. package/dist/src/cluster/cluster-repo.d.ts +135 -3
  15. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  16. package/dist/src/cluster/cluster-repo.js +333 -58
  17. package/dist/src/cluster/cluster-repo.js.map +1 -1
  18. package/dist/src/cluster/commit-cert.d.ts +63 -0
  19. package/dist/src/cluster/commit-cert.d.ts.map +1 -0
  20. package/dist/src/cluster/commit-cert.js +108 -0
  21. package/dist/src/cluster/commit-cert.js.map +1 -0
  22. package/dist/src/cluster/service.d.ts +46 -11
  23. package/dist/src/cluster/service.d.ts.map +1 -1
  24. package/dist/src/cluster/service.js +97 -33
  25. package/dist/src/cluster/service.js.map +1 -1
  26. package/dist/src/cluster/spread-on-churn.d.ts +13 -0
  27. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  28. package/dist/src/cluster/spread-on-churn.js +34 -4
  29. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  30. package/dist/src/cohort-topic/bootstrap-evidence-builder.d.ts +59 -0
  31. package/dist/src/cohort-topic/bootstrap-evidence-builder.d.ts.map +1 -0
  32. package/dist/src/cohort-topic/bootstrap-evidence-builder.js +79 -0
  33. package/dist/src/cohort-topic/bootstrap-evidence-builder.js.map +1 -0
  34. package/dist/src/cohort-topic/bootstrap-evidence-verifiers.d.ts +81 -0
  35. package/dist/src/cohort-topic/bootstrap-evidence-verifiers.d.ts.map +1 -0
  36. package/dist/src/cohort-topic/bootstrap-evidence-verifiers.js +93 -0
  37. package/dist/src/cohort-topic/bootstrap-evidence-verifiers.js.map +1 -0
  38. package/dist/src/cohort-topic/bootstrap-parent-reference.d.ts +108 -0
  39. package/dist/src/cohort-topic/bootstrap-parent-reference.d.ts.map +1 -0
  40. package/dist/src/cohort-topic/bootstrap-parent-reference.js +108 -0
  41. package/dist/src/cohort-topic/bootstrap-parent-reference.js.map +1 -0
  42. package/dist/src/cohort-topic/change-bridge.d.ts +65 -0
  43. package/dist/src/cohort-topic/change-bridge.d.ts.map +1 -0
  44. package/dist/src/cohort-topic/change-bridge.js +76 -0
  45. package/dist/src/cohort-topic/change-bridge.js.map +1 -0
  46. package/dist/src/cohort-topic/cohort-gossip-driver.d.ts +74 -0
  47. package/dist/src/cohort-topic/cohort-gossip-driver.d.ts.map +1 -0
  48. package/dist/src/cohort-topic/cohort-gossip-driver.js +87 -0
  49. package/dist/src/cohort-topic/cohort-gossip-driver.js.map +1 -0
  50. package/dist/src/cohort-topic/cohort-gossip-transport.d.ts +42 -0
  51. package/dist/src/cohort-topic/cohort-gossip-transport.d.ts.map +1 -0
  52. package/dist/src/cohort-topic/cohort-gossip-transport.js +57 -0
  53. package/dist/src/cohort-topic/cohort-gossip-transport.js.map +1 -0
  54. package/dist/src/cohort-topic/fret-trust-anchor.d.ts +100 -0
  55. package/dist/src/cohort-topic/fret-trust-anchor.d.ts.map +1 -0
  56. package/dist/src/cohort-topic/fret-trust-anchor.js +115 -0
  57. package/dist/src/cohort-topic/fret-trust-anchor.js.map +1 -0
  58. package/dist/src/cohort-topic/host.d.ts +573 -0
  59. package/dist/src/cohort-topic/host.d.ts.map +1 -0
  60. package/dist/src/cohort-topic/host.js +1460 -0
  61. package/dist/src/cohort-topic/host.js.map +1 -0
  62. package/dist/src/cohort-topic/index.d.ts +14 -0
  63. package/dist/src/cohort-topic/index.d.ts.map +1 -0
  64. package/dist/src/cohort-topic/index.js +14 -0
  65. package/dist/src/cohort-topic/index.js.map +1 -0
  66. package/dist/src/cohort-topic/membership-publish-sink.d.ts +14 -0
  67. package/dist/src/cohort-topic/membership-publish-sink.d.ts.map +1 -0
  68. package/dist/src/cohort-topic/membership-publish-sink.js +17 -0
  69. package/dist/src/cohort-topic/membership-publish-sink.js.map +1 -0
  70. package/dist/src/cohort-topic/membership-source.d.ts +36 -0
  71. package/dist/src/cohort-topic/membership-source.d.ts.map +1 -0
  72. package/dist/src/cohort-topic/membership-source.js +58 -0
  73. package/dist/src/cohort-topic/membership-source.js.map +1 -0
  74. package/dist/src/cohort-topic/peer-codec.d.ts +18 -0
  75. package/dist/src/cohort-topic/peer-codec.d.ts.map +1 -0
  76. package/dist/src/cohort-topic/peer-codec.js +26 -0
  77. package/dist/src/cohort-topic/peer-codec.js.map +1 -0
  78. package/dist/src/cohort-topic/peer-sig.d.ts +52 -0
  79. package/dist/src/cohort-topic/peer-sig.d.ts.map +1 -0
  80. package/dist/src/cohort-topic/peer-sig.js +83 -0
  81. package/dist/src/cohort-topic/peer-sig.js.map +1 -0
  82. package/dist/src/cohort-topic/protocols.d.ts +49 -0
  83. package/dist/src/cohort-topic/protocols.d.ts.map +1 -0
  84. package/dist/src/cohort-topic/protocols.js +58 -0
  85. package/dist/src/cohort-topic/protocols.js.map +1 -0
  86. package/dist/src/cohort-topic/reactivity-membership-gate.d.ts +49 -0
  87. package/dist/src/cohort-topic/reactivity-membership-gate.d.ts.map +1 -0
  88. package/dist/src/cohort-topic/reactivity-membership-gate.js +42 -0
  89. package/dist/src/cohort-topic/reactivity-membership-gate.js.map +1 -0
  90. package/dist/src/cohort-topic/size-estimator.d.ts +16 -0
  91. package/dist/src/cohort-topic/size-estimator.d.ts.map +1 -0
  92. package/dist/src/cohort-topic/size-estimator.js +16 -0
  93. package/dist/src/cohort-topic/size-estimator.js.map +1 -0
  94. package/dist/src/cohort-topic/stream-util.d.ts +19 -0
  95. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -0
  96. package/dist/src/cohort-topic/stream-util.js +76 -0
  97. package/dist/src/cohort-topic/stream-util.js.map +1 -0
  98. package/dist/src/cohort-topic/threshold-crypto.d.ts +94 -0
  99. package/dist/src/cohort-topic/threshold-crypto.d.ts.map +1 -0
  100. package/dist/src/cohort-topic/threshold-crypto.js +178 -0
  101. package/dist/src/cohort-topic/threshold-crypto.js.map +1 -0
  102. package/dist/src/cohort-topic/topic-router.d.ts +41 -0
  103. package/dist/src/cohort-topic/topic-router.d.ts.map +1 -0
  104. package/dist/src/cohort-topic/topic-router.js +63 -0
  105. package/dist/src/cohort-topic/topic-router.js.map +1 -0
  106. package/dist/src/dispute/cascade.d.ts +179 -0
  107. package/dist/src/dispute/cascade.d.ts.map +1 -0
  108. package/dist/src/dispute/cascade.js +315 -0
  109. package/dist/src/dispute/cascade.js.map +1 -0
  110. package/dist/src/dispute/client.d.ts +2 -1
  111. package/dist/src/dispute/client.d.ts.map +1 -1
  112. package/dist/src/dispute/client.js +12 -3
  113. package/dist/src/dispute/client.js.map +1 -1
  114. package/dist/src/dispute/dispute-service.d.ts +41 -1
  115. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  116. package/dist/src/dispute/dispute-service.js +147 -21
  117. package/dist/src/dispute/dispute-service.js.map +1 -1
  118. package/dist/src/dispute/index.d.ts +2 -0
  119. package/dist/src/dispute/index.d.ts.map +1 -1
  120. package/dist/src/dispute/index.js +2 -0
  121. package/dist/src/dispute/index.js.map +1 -1
  122. package/dist/src/dispute/invalidation.d.ts +266 -0
  123. package/dist/src/dispute/invalidation.d.ts.map +1 -0
  124. package/dist/src/dispute/invalidation.js +405 -0
  125. package/dist/src/dispute/invalidation.js.map +1 -0
  126. package/dist/src/dispute/types.d.ts +14 -0
  127. package/dist/src/dispute/types.d.ts.map +1 -1
  128. package/dist/src/dispute/types.js.map +1 -1
  129. package/dist/src/index.d.ts +4 -0
  130. package/dist/src/index.d.ts.map +1 -1
  131. package/dist/src/index.js +4 -0
  132. package/dist/src/index.js.map +1 -1
  133. package/dist/src/libp2p-key-network.d.ts +7 -0
  134. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  135. package/dist/src/libp2p-key-network.js +49 -5
  136. package/dist/src/libp2p-key-network.js.map +1 -1
  137. package/dist/src/libp2p-node-base.d.ts +45 -3
  138. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  139. package/dist/src/libp2p-node-base.js +522 -5
  140. package/dist/src/libp2p-node-base.js.map +1 -1
  141. package/dist/src/matchmaking/aggregate-counts.d.ts +67 -0
  142. package/dist/src/matchmaking/aggregate-counts.d.ts.map +1 -0
  143. package/dist/src/matchmaking/aggregate-counts.js +60 -0
  144. package/dist/src/matchmaking/aggregate-counts.js.map +1 -0
  145. package/dist/src/matchmaking/index.d.ts +20 -0
  146. package/dist/src/matchmaking/index.d.ts.map +1 -0
  147. package/dist/src/matchmaking/index.js +20 -0
  148. package/dist/src/matchmaking/index.js.map +1 -0
  149. package/dist/src/matchmaking/module.d.ts +181 -0
  150. package/dist/src/matchmaking/module.d.ts.map +1 -0
  151. package/dist/src/matchmaking/module.js +221 -0
  152. package/dist/src/matchmaking/module.js.map +1 -0
  153. package/dist/src/matchmaking/protocols.d.ts +37 -0
  154. package/dist/src/matchmaking/protocols.d.ts.map +1 -0
  155. package/dist/src/matchmaking/protocols.js +42 -0
  156. package/dist/src/matchmaking/protocols.js.map +1 -0
  157. package/dist/src/matchmaking/provider-manager.d.ts +52 -0
  158. package/dist/src/matchmaking/provider-manager.d.ts.map +1 -0
  159. package/dist/src/matchmaking/provider-manager.js +74 -0
  160. package/dist/src/matchmaking/provider-manager.js.map +1 -0
  161. package/dist/src/matchmaking/query-handler.d.ts +34 -0
  162. package/dist/src/matchmaking/query-handler.d.ts.map +1 -0
  163. package/dist/src/matchmaking/query-handler.js +62 -0
  164. package/dist/src/matchmaking/query-handler.js.map +1 -0
  165. package/dist/src/matchmaking/query-transport.d.ts +180 -0
  166. package/dist/src/matchmaking/query-transport.d.ts.map +1 -0
  167. package/dist/src/matchmaking/query-transport.js +315 -0
  168. package/dist/src/matchmaking/query-transport.js.map +1 -0
  169. package/dist/src/matchmaking/seeker-manager.d.ts +39 -0
  170. package/dist/src/matchmaking/seeker-manager.d.ts.map +1 -0
  171. package/dist/src/matchmaking/seeker-manager.js +49 -0
  172. package/dist/src/matchmaking/seeker-manager.js.map +1 -0
  173. package/dist/src/matchmaking/seeker-walk-client.d.ts +127 -0
  174. package/dist/src/matchmaking/seeker-walk-client.d.ts.map +1 -0
  175. package/dist/src/matchmaking/seeker-walk-client.js +189 -0
  176. package/dist/src/matchmaking/seeker-walk-client.js.map +1 -0
  177. package/dist/src/matchmaking/traffic-validation.d.ts +128 -0
  178. package/dist/src/matchmaking/traffic-validation.d.ts.map +1 -0
  179. package/dist/src/matchmaking/traffic-validation.js +105 -0
  180. package/dist/src/matchmaking/traffic-validation.js.map +1 -0
  181. package/dist/src/network/network-manager-service.d.ts +4 -2
  182. package/dist/src/network/network-manager-service.d.ts.map +1 -1
  183. package/dist/src/network/network-manager-service.js +9 -3
  184. package/dist/src/network/network-manager-service.js.map +1 -1
  185. package/dist/src/protocol-client.d.ts +13 -0
  186. package/dist/src/protocol-client.d.ts.map +1 -1
  187. package/dist/src/protocol-client.js +74 -2
  188. package/dist/src/protocol-client.js.map +1 -1
  189. package/dist/src/reactivity/forwarder-host.d.ts +198 -0
  190. package/dist/src/reactivity/forwarder-host.d.ts.map +1 -0
  191. package/dist/src/reactivity/forwarder-host.js +355 -0
  192. package/dist/src/reactivity/forwarder-host.js.map +1 -0
  193. package/dist/src/reactivity/index.d.ts +19 -0
  194. package/dist/src/reactivity/index.d.ts.map +1 -0
  195. package/dist/src/reactivity/index.js +19 -0
  196. package/dist/src/reactivity/index.js.map +1 -0
  197. package/dist/src/reactivity/notify-transport.d.ts +70 -0
  198. package/dist/src/reactivity/notify-transport.d.ts.map +1 -0
  199. package/dist/src/reactivity/notify-transport.js +111 -0
  200. package/dist/src/reactivity/notify-transport.js.map +1 -0
  201. package/dist/src/reactivity/origination-manager.d.ts +110 -0
  202. package/dist/src/reactivity/origination-manager.d.ts.map +1 -0
  203. package/dist/src/reactivity/origination-manager.js +129 -0
  204. package/dist/src/reactivity/origination-manager.js.map +1 -0
  205. package/dist/src/reactivity/protocols.d.ts +43 -0
  206. package/dist/src/reactivity/protocols.d.ts.map +1 -0
  207. package/dist/src/reactivity/protocols.js +50 -0
  208. package/dist/src/reactivity/protocols.js.map +1 -0
  209. package/dist/src/reactivity/push-state-gossip.d.ts +132 -0
  210. package/dist/src/reactivity/push-state-gossip.d.ts.map +1 -0
  211. package/dist/src/reactivity/push-state-gossip.js +231 -0
  212. package/dist/src/reactivity/push-state-gossip.js.map +1 -0
  213. package/dist/src/reactivity/recover-transport.d.ts +167 -0
  214. package/dist/src/reactivity/recover-transport.d.ts.map +1 -0
  215. package/dist/src/reactivity/recover-transport.js +308 -0
  216. package/dist/src/reactivity/recover-transport.js.map +1 -0
  217. package/dist/src/reactivity/rotation-rereg-scheduler.d.ts +142 -0
  218. package/dist/src/reactivity/rotation-rereg-scheduler.d.ts.map +1 -0
  219. package/dist/src/reactivity/rotation-rereg-scheduler.js +223 -0
  220. package/dist/src/reactivity/rotation-rereg-scheduler.js.map +1 -0
  221. package/dist/src/reactivity/subscriber-registry.d.ts +47 -0
  222. package/dist/src/reactivity/subscriber-registry.d.ts.map +1 -0
  223. package/dist/src/reactivity/subscriber-registry.js +87 -0
  224. package/dist/src/reactivity/subscriber-registry.js.map +1 -0
  225. package/dist/src/reactivity/subscription-manager.d.ts +221 -0
  226. package/dist/src/reactivity/subscription-manager.d.ts.map +1 -0
  227. package/dist/src/reactivity/subscription-manager.js +302 -0
  228. package/dist/src/reactivity/subscription-manager.js.map +1 -0
  229. package/dist/src/reactivity/topic-bytes.d.ts +32 -0
  230. package/dist/src/reactivity/topic-bytes.d.ts.map +1 -0
  231. package/dist/src/reactivity/topic-bytes.js +34 -0
  232. package/dist/src/reactivity/topic-bytes.js.map +1 -0
  233. package/dist/src/repo/client.d.ts.map +1 -1
  234. package/dist/src/repo/client.js +51 -23
  235. package/dist/src/repo/client.js.map +1 -1
  236. package/dist/src/repo/service.d.ts +46 -1
  237. package/dist/src/repo/service.d.ts.map +1 -1
  238. package/dist/src/repo/service.js +79 -45
  239. package/dist/src/repo/service.js.map +1 -1
  240. package/dist/src/rpc-deadline.d.ts +37 -0
  241. package/dist/src/rpc-deadline.d.ts.map +1 -0
  242. package/dist/src/rpc-deadline.js +31 -0
  243. package/dist/src/rpc-deadline.js.map +1 -0
  244. package/dist/src/storage/block-storage.d.ts +2 -0
  245. package/dist/src/storage/block-storage.d.ts.map +1 -1
  246. package/dist/src/storage/block-storage.js +95 -2
  247. package/dist/src/storage/block-storage.js.map +1 -1
  248. package/dist/src/storage/i-block-storage.d.ts +25 -0
  249. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  250. package/dist/src/storage/storage-repo.d.ts +53 -2
  251. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  252. package/dist/src/storage/storage-repo.js +193 -9
  253. package/dist/src/storage/storage-repo.js.map +1 -1
  254. package/dist/src/sync/client.d.ts +4 -1
  255. package/dist/src/sync/client.d.ts.map +1 -1
  256. package/dist/src/sync/client.js +5 -2
  257. package/dist/src/sync/client.js.map +1 -1
  258. package/dist/src/testing/cohort-topic-mesh-harness.d.ts +219 -0
  259. package/dist/src/testing/cohort-topic-mesh-harness.d.ts.map +1 -0
  260. package/dist/src/testing/cohort-topic-mesh-harness.js +467 -0
  261. package/dist/src/testing/cohort-topic-mesh-harness.js.map +1 -0
  262. package/dist/src/testing/matchmaking-mesh-harness.d.ts +187 -0
  263. package/dist/src/testing/matchmaking-mesh-harness.d.ts.map +1 -0
  264. package/dist/src/testing/matchmaking-mesh-harness.js +358 -0
  265. package/dist/src/testing/matchmaking-mesh-harness.js.map +1 -0
  266. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  267. package/dist/src/testing/mesh-harness.js +23 -1
  268. package/dist/src/testing/mesh-harness.js.map +1 -1
  269. package/dist/src/testing/reactivity-mesh-harness.d.ts +298 -0
  270. package/dist/src/testing/reactivity-mesh-harness.d.ts.map +1 -0
  271. package/dist/src/testing/reactivity-mesh-harness.js +663 -0
  272. package/dist/src/testing/reactivity-mesh-harness.js.map +1 -0
  273. package/package.json +7 -4
  274. package/src/cluster/block-transfer-service.ts +133 -68
  275. package/src/cluster/client.ts +64 -45
  276. package/src/cluster/cluster-error.ts +64 -0
  277. package/src/cluster/cluster-repo.ts +405 -55
  278. package/src/cluster/commit-cert.ts +139 -0
  279. package/src/cluster/service.ts +116 -44
  280. package/src/cluster/spread-on-churn.ts +328 -285
  281. package/src/cohort-topic/bootstrap-evidence-builder.ts +122 -0
  282. package/src/cohort-topic/bootstrap-evidence-verifiers.ts +132 -0
  283. package/src/cohort-topic/bootstrap-parent-reference.ts +159 -0
  284. package/src/cohort-topic/change-bridge.ts +109 -0
  285. package/src/cohort-topic/cohort-gossip-driver.ts +144 -0
  286. package/src/cohort-topic/cohort-gossip-transport.ts +74 -0
  287. package/src/cohort-topic/fret-trust-anchor.ts +153 -0
  288. package/src/cohort-topic/host.ts +2175 -0
  289. package/src/cohort-topic/index.ts +13 -0
  290. package/src/cohort-topic/membership-publish-sink.ts +20 -0
  291. package/src/cohort-topic/membership-source.ts +68 -0
  292. package/src/cohort-topic/peer-codec.ts +31 -0
  293. package/src/cohort-topic/peer-sig.ts +86 -0
  294. package/src/cohort-topic/protocols.ts +71 -0
  295. package/src/cohort-topic/reactivity-membership-gate.ts +77 -0
  296. package/src/cohort-topic/size-estimator.ts +16 -0
  297. package/src/cohort-topic/stream-util.ts +87 -0
  298. package/src/cohort-topic/threshold-crypto.ts +239 -0
  299. package/src/cohort-topic/topic-router.ts +77 -0
  300. package/src/dispute/cascade.ts +517 -0
  301. package/src/dispute/client.ts +12 -2
  302. package/src/dispute/dispute-service.ts +171 -24
  303. package/src/dispute/index.ts +42 -0
  304. package/src/dispute/invalidation.ts +587 -0
  305. package/src/dispute/types.ts +14 -0
  306. package/src/index.ts +4 -0
  307. package/src/libp2p-key-network.ts +49 -6
  308. package/src/libp2p-node-base.ts +620 -12
  309. package/src/matchmaking/aggregate-counts.ts +104 -0
  310. package/src/matchmaking/index.ts +20 -0
  311. package/src/matchmaking/module.ts +363 -0
  312. package/src/matchmaking/protocols.ts +51 -0
  313. package/src/matchmaking/provider-manager.ts +95 -0
  314. package/src/matchmaking/query-handler.ts +88 -0
  315. package/src/matchmaking/query-transport.ts +483 -0
  316. package/src/matchmaking/seeker-manager.ts +64 -0
  317. package/src/matchmaking/seeker-walk-client.ts +293 -0
  318. package/src/matchmaking/traffic-validation.ts +195 -0
  319. package/src/network/network-manager-service.ts +9 -2
  320. package/src/protocol-client.ts +64 -3
  321. package/src/reactivity/forwarder-host.ts +438 -0
  322. package/src/reactivity/index.ts +19 -0
  323. package/src/reactivity/notify-transport.ts +144 -0
  324. package/src/reactivity/origination-manager.ts +192 -0
  325. package/src/reactivity/protocols.ts +61 -0
  326. package/src/reactivity/push-state-gossip.ts +291 -0
  327. package/src/reactivity/recover-transport.ts +408 -0
  328. package/src/reactivity/rotation-rereg-scheduler.ts +256 -0
  329. package/src/reactivity/subscriber-registry.ts +96 -0
  330. package/src/reactivity/subscription-manager.ts +450 -0
  331. package/src/reactivity/topic-bytes.ts +37 -0
  332. package/src/repo/client.ts +54 -23
  333. package/src/repo/service.ts +90 -42
  334. package/src/rpc-deadline.ts +45 -0
  335. package/src/storage/block-storage.ts +106 -3
  336. package/src/storage/i-block-storage.ts +27 -0
  337. package/src/storage/storage-repo.ts +209 -13
  338. package/src/sync/client.ts +5 -2
  339. package/src/testing/cohort-topic-mesh-harness.ts +613 -0
  340. package/src/testing/matchmaking-mesh-harness.ts +475 -0
  341. package/src/testing/mesh-harness.ts +22 -2
  342. package/src/testing/reactivity-mesh-harness.ts +922 -0
@@ -0,0 +1,1460 @@
1
+ /**
2
+ * Cohort-topic FRET host (`docs/cohort-topic.md` §FRET integration L768-803).
3
+ *
4
+ * Composes the db-core substrate (participant-facing {@link CohortTopicService} + cohort-side
5
+ * {@link CohortMemberEngine}) over the FRET + libp2p ports and runs it as a service on one node:
6
+ *
7
+ * - registers the four `/optimystic/cohort-topic/1.0.0/*` protocols on the libp2p node;
8
+ * - sets FRET's activity handler so a `RouteAndMaybeAct`-routed `RegisterV1` runs the cohort decision;
9
+ * - binds the db-core ports to the FRET-backed adapters (router, gossip, membership, size estimator).
10
+ *
11
+ * **Per-served-coord scoping.** A topic's responsible cohort sits at `coord_0(_, topicId)` (and at
12
+ * `coord_d(P, topicId)` for `d ≥ 1`), which is unrelated to any node's own ring position. A node
13
+ * genuinely belongs to *many* cohorts — one per coord FRET routes to it — so this host keeps a lazy
14
+ * {@link CoordRegistry}: a `Map<servedCoord, CoordEngine>` where each {@link CoordEngine} owns the
15
+ * per-coord slice of state (its own store, gossip bus, willingness/traffic/renewal/cold-start, a
16
+ * {@link PromotionLifecycle} with coord-derived tier inputs, a {@link CohortMemberEngine}, its own
17
+ * {@link MembershipCertPublisher}, and its own real `k − x` threshold signer) and threshold-signs with
18
+ * the FRET cohort *around the served coord*. The node-wide collaborators (`hash`, `slots`, `barometer`,
19
+ * the FRET ports, the participant-facing service, and the verify-only verifier signer) stay singletons
20
+ * and are injected into each engine. The host recomputes the served coord from each decoded `RegisterV1`
21
+ * (`addressing.coord(treeTier, participantCoord, topicId)`), so both the activity callback and the direct
22
+ * `register` protocol handler dispatch to the right cohort.
23
+ *
24
+ * **Real threshold signatures.** Each coord engine assembles a genuine `k − x` cohort signature
25
+ * (a collected per-member Ed25519 multisig) over the new `/sign` protocol — see
26
+ * {@link FretCohortThresholdCrypto} — and drives a {@link MembershipCertPublisher}, so a remote
27
+ * `MembershipVerifier` can verify the served `MembershipCertV1` for real. Assembly needs the node's
28
+ * `options.privateKey`; key-less hosts compose but cannot threshold-sign (the publisher/promotion paths
29
+ * are simply not driven). The periodic membership refresh is exposed as the per-engine
30
+ * {@link CoordEngine.pumpMembership} / {@link CoordEngine.onStabilized} hooks for the gossip-cadence
31
+ * driver to call.
32
+ *
33
+ * **Anti-DoS + cold-start (gaps 6–7).** Each {@link CoordEngine} is injected its own per-coord anti-DoS
34
+ * guards — a `RegisterRateLimiter` (4/min per peer-topic), a `CorrelationReplayGuard` (60 s freshness),
35
+ * and a `TopicBudget` (2048 topics, LRU) — so a budget/limit at one coord is independent of another. The
36
+ * node-level {@link BootstrapEvidence} policy (one tier→verifier policy, no per-coord state) is built once
37
+ * and shared. db-core embeds no PoW / reputation scheme, so the host supplies the real verifiers
38
+ * ({@link createPoWVerifier} / {@link createReputationVerifier}) and the participant-side PoW minter
39
+ * ({@link createBootstrapEvidenceBuilder}): once configured, a node gates cold-root `bootstrap: true` at
40
+ * T2/T3 (PoW, or a referee reputation endorsement when offered, or a signed parent reference) and at T0/T1
41
+ * once a committed-existence backing is wired (a signed parent reference); a configured node with no such
42
+ * backing keeps T0/T1 permissive-but-logged so cold-root origination is not blocked
43
+ * (`cohort-topic-bootstrap-coldstart-origination-regression`), and an entirely unconfigured host stays
44
+ * permissive-but-logged at every tier (never an undefined gate). A cold-started tier-`d > 0` forwarder registers with
45
+ * its tier-`(d − 1)` parent by routing a forwarder-link frame over the router (gap 7), staying
46
+ * `awaiting_parent` until the ack.
47
+ *
48
+ * **Scope.** `followOn` derivation for a promoted-redirect arrival is parked in backlog
49
+ * (`cohort-topic-followon-derivation`); this milestone serves a **single tier-0 cohort**, so `followOn`
50
+ * stays `false` and tier-0 bootstrap instantiation goes through the `bootstrap: true` path. The
51
+ * behavioral substrate is validated at mock-tier by `test/cohort-topic/service.spec.ts` and end-to-end
52
+ * across a real multi-node cohort (real Ed25519 keys, mock transport routing the five protocols + FRET
53
+ * `routeAct`/`assembleCohort`) by `test/cohort-topic/live-tier.spec.ts`.
54
+ */
55
+ import { hashPeerId, readAllBounded } from "p2p-fret";
56
+ import { RingHash, createRegistrationStore, createSlotAssigner, createCohortGossipBus, createWillingnessCheck, createPromotionLifecycle, createColdStartManager, createTrafficCounters, createRenewalCohortSide, createMembershipVerifier, createMembershipSourceRouter, createMembershipCertPublisher, createCohortSigner, createCohortMemberEngine, createCohortTopicService, createLoadBarometer, createTierAddressing, createRegisterRateLimiter, createCorrelationReplayGuard, createTopicBudget, createBootstrapEvidence, LruMap, coreProfile, DEFAULT_MIN_SIGS, DEFAULT_MAX_NO_POW_TIER, DEFAULT_TRAFFIC_WINDOW_SECONDS, DEFAULT_TTL_MS, bytesToB64url, b64urlToBytes, bytesEqual, compareBytes, encodeCohortMessage, decodeCohortMessage, membershipCertSignable, membershipCertSigningPayload, toCohortTopicSummary, validateRegisterV1, validateRenewV1, validateSignRequestV1, validateSignReplyV1, validatePromotionNoticeV1, validateDemotionNoticeV1, registerSigningPayload, renewSigningPayload, cohortGossipSigningPayload, promotionNoticeSigningPayload, demotionNoticeSigningPayload, } from "@optimystic/db-core";
57
+ import { randomBytes } from "@libp2p/crypto";
58
+ import { peerIdFromString } from "@libp2p/peer-id";
59
+ import { FretTopicRouter } from "./topic-router.js";
60
+ import { FretCohortGossipTransport } from "./cohort-gossip-transport.js";
61
+ import { buildCohortGossip, createPendingDeltas, DEFAULT_GOSSIP_INTERVAL_MS } from "./cohort-gossip-driver.js";
62
+ import { FretMembershipSource } from "./membership-source.js";
63
+ import { FretMembershipPublishSink } from "./membership-publish-sink.js";
64
+ import { FretCohortThresholdCrypto, createVerifyOnlyThresholdCrypto } from "./threshold-crypto.js";
65
+ import { FretTrustAnchor } from "./fret-trust-anchor.js";
66
+ import { FretSizeEstimator } from "./size-estimator.js";
67
+ import { peerIdToBytes, bytesToPeerIdString } from "./peer-codec.js";
68
+ import { signPeer, verifyPeerSig } from "./peer-sig.js";
69
+ import { createPoWVerifier, createReputationVerifier } from "./bootstrap-evidence-verifiers.js";
70
+ import { createParentReferenceVerifier, createDefaultParentTopicView } from "./bootstrap-parent-reference.js";
71
+ import { createBootstrapEvidenceBuilder } from "./bootstrap-evidence-builder.js";
72
+ import { DEFAULT_COHORT_TOPIC_PROTOCOLS, cohortTopicProtocolList } from "./protocols.js";
73
+ import { requestResponse, DEFAULT_STREAM_MAX_BYTES } from "./stream-util.js";
74
+ import { createLogger } from "../logger.js";
75
+ const log = createLogger("cohort-topic");
76
+ /**
77
+ * Build and start a {@link CohortTopicHost} on `node` over `fret`. Registers the four protocols and
78
+ * sets FRET's activity handler. Async because the node's ring coordinate is derived via FRET's
79
+ * `hashPeerId`.
80
+ */
81
+ export async function createCohortTopicHost(node, fret, options = {}) {
82
+ const profile = options.profile ?? coreProfile();
83
+ const protocols = options.protocols ?? DEFAULT_COHORT_TOPIC_PROTOCOLS;
84
+ const wantK = options.wantK ?? 16;
85
+ const minSigs = options.minSigs ?? DEFAULT_MIN_SIGS;
86
+ const fanout = options.fanout ?? 16;
87
+ const maxBytes = options.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
88
+ const gossipIntervalMs = options.gossipIntervalMs ?? DEFAULT_GOSSIP_INTERVAL_MS;
89
+ const hash = new RingHash();
90
+ const selfPeerStr = node.peerId.toString();
91
+ const selfMemberBytes = peerIdToBytes(node.peerId); // dialable member id
92
+ const selfCoord = await hashPeerId(node.peerId); // ring position P (the participant gossip handle)
93
+ const addressing = createTierAddressing(hash, fanout);
94
+ // --- cohort resolver (FRET two-sided assembly around a coord) ---
95
+ const resolver = {
96
+ cohortPeers(coord, wants) {
97
+ return fret.assembleCohort(coord, wants);
98
+ },
99
+ };
100
+ // --- ports (node-wide singletons, injected into every coord engine) ---
101
+ const router = new FretTopicRouter(node, fret, { registerProtocol: protocols.register, maxBytes });
102
+ const sizeEstimator = new FretSizeEstimator(fret);
103
+ const gossipTransport = new FretCohortGossipTransport(node, resolver, { gossipProtocol: protocols.gossip, wants: wantK, selfPeerId: selfPeerStr });
104
+ const membershipSource = new FretMembershipSource(node, resolver, { membershipProtocol: protocols.membership, wants: wantK, maxBytes });
105
+ const publishSink = new FretMembershipPublishSink();
106
+ const slots = createSlotAssigner(hash);
107
+ const barometer = createLoadBarometer();
108
+ // Participant-side verifier signer: verify-only (it never assembles), so it needs no key / dial seam.
109
+ // The real k − x assembly lives in each CoordEngine's own threshold signer (constructed per coord).
110
+ const verifyingSigner = createCohortSigner(createVerifyOnlyThresholdCrypto(), minSigs);
111
+ // Collect one cohort member's `/sign` endorsement over the new fifth protocol.
112
+ const dialSign = async (peerIdStr, request) => {
113
+ const reply = await requestResponse(node, peerIdFromString(peerIdStr), protocols.sign, encodeCohortMessage(request, maxBytes), maxBytes);
114
+ return validateSignReplyV1(decodeCohortMessage(reply, maxBytes));
115
+ };
116
+ /** FRET assembly around `coord`: self prepended + deduped; epoch = H(sorted member join). */
117
+ const cohortAround = (coord) => {
118
+ const peerStrs = fret.assembleCohort(coord, wantK);
119
+ const members = [selfMemberBytes, ...peerStrs.filter((p) => p !== selfPeerStr).map((p) => peerIdToBytes(p))];
120
+ // Deterministic epoch from the sorted member set so a membership change rotates the epoch.
121
+ const epochInput = members.map(bytesToB64url).sort().join("|");
122
+ const cohortEpoch = hash.H(new TextEncoder().encode(epochInput));
123
+ return { members, cohortEpoch };
124
+ };
125
+ // --- participant peer-key signing seam (gap 2) ---
126
+ // The live signer needs the node's libp2p key (libp2p does not expose it off `node.peerId`, so it
127
+ // arrives via options). When absent we keep the interim empty-string signer AND skip inbound
128
+ // participant-signature verification, so key-less unit/mock flows still compose. The signer id is
129
+ // the participant's wire identity (`participantCoord` / `participantId`), which IS its dialable
130
+ // peer-id bytes (db-core threads `self = selfMemberBytes`), so it round-trips to a verifiable key.
131
+ const participantSigner = createParticipantSigner(options.privateKey, log);
132
+ const verifyRegisterSig = options.privateKey === undefined
133
+ ? undefined
134
+ : (reg) => reg.signature.length > 0 &&
135
+ verifyPeerSig(b64urlToBytes(reg.participantCoord), registerSigningPayload(reg), b64urlToBytes(reg.signature));
136
+ const verifyParticipantSig = options.privateKey === undefined
137
+ ? undefined
138
+ : (renew) => renew.signature.length > 0 &&
139
+ verifyPeerSig(b64urlToBytes(renew.participantId), renewSigningPayload(renew), b64urlToBytes(renew.signature));
140
+ // --- intra-cohort gossip authenticity seam (gap 5) ---
141
+ // Same peer-key signing pattern as register/renew, applied to the gossip envelope: the originator
142
+ // signs its canonical image, and a receiver drops a frame whose `fromMember` signature does not
143
+ // verify or that comes from a non-cohort member (so willingness/load can't be spoofed and forged
144
+ // records can't replicate). Key-less mode ships/accepts unsigned gossip (documented interim), exactly
145
+ // like the participant signer.
146
+ const nodeKey = options.privateKey;
147
+ const signGossip = nodeKey === undefined
148
+ ? undefined
149
+ : async (g) => bytesToB64url(await signPeer(nodeKey, cohortGossipSigningPayload(g)));
150
+ const verifyGossip = nodeKey === undefined
151
+ ? undefined
152
+ : (g, coord) => {
153
+ if (g.signature.length === 0) {
154
+ return false;
155
+ }
156
+ const fromBytes = b64urlToBytes(g.fromMember);
157
+ if (!verifyPeerSig(fromBytes, cohortGossipSigningPayload(g), b64urlToBytes(g.signature))) {
158
+ return false;
159
+ }
160
+ const members = cohortAround(coord).members.map(bytesToPeerIdString);
161
+ return members.includes(bytesToPeerIdString(fromBytes));
162
+ };
163
+ // --- outbound notice broadcast (gap 4) ---
164
+ // A coord engine that threshold-signs a promotion/demotion notice hands it here; we fan it over the
165
+ // `promote` protocol to the cohort around the served coord (siblings adopt the state) and, for a
166
+ // demotion, additionally to the parent coord (childCohortCount bookkeeping). Reuses the gossip
167
+ // transport's cohort peer resolution.
168
+ const broadcastNotice = (notice, servedCoord) => {
169
+ const frame = encodeCohortMessage(notice, maxBytes);
170
+ for (const coord of noticeBroadcastCoords(notice, servedCoord)) {
171
+ gossipTransport.broadcastOver(protocols.promote, coord, frame);
172
+ }
173
+ };
174
+ // --- anti-DoS (gap 6) ---
175
+ // The bootstrap-evidence policy is node-level (a tier→verifier policy with no per-coord state), so it
176
+ // is built once and shared by every coord engine. The per-coord guards (rate limiter, replay guard,
177
+ // topic budget) are built per CoordEngine from `antiDos` below — they key on `(peer, topic)` /
178
+ // per-cohort topic state, which is coord-scoped, and must not share state across coords.
179
+ // The signed-parent-reference existence view (cohort-topic-bootstrap-parent-reference). A test override
180
+ // (`antiDos.parentTopicView`) wins; otherwise the host default, tier-routed over the FRET membership
181
+ // cache (T2/T3) and the optional committed reader (T0/T1 — fail-closed without one, committed-tier
182
+ // integrity). Synchronous + local: it reads the in-memory caches the node already holds, never a dial.
183
+ const parentTopicView = options.antiDos?.parentTopicView ?? createDefaultParentTopicView({
184
+ membershipSource,
185
+ addressing,
186
+ committedReader: options.committedParentTopicReader,
187
+ });
188
+ // Does this host actually have a committed-existence backing to gate T0/T1 parent-refs against? Only an
189
+ // explicit `parentTopicView` override (the test seam / future production index) or a wired
190
+ // `committedParentTopicReader` provides one. Absent both (today's production node), a T0/T1 root cannot
191
+ // mint any acceptable parent-ref — a root has no parent and the default view fails T0/T1 closed — so the
192
+ // policy keeps T0/T1 permissive-but-logged rather than regressing cold-start origination. See
193
+ // {@link createBootstrapEvidencePolicy}.
194
+ const hasCommittedParentBacking = options.antiDos?.parentTopicView !== undefined || options.committedParentTopicReader !== undefined;
195
+ const bootstrapEvidence = createBootstrapEvidencePolicy(options.antiDos, hash, log, parentTopicView, hasCommittedParentBacking);
196
+ // Node-level `promote`-handler anti-abuse gate (`cohort-topic-promote-handler-verify-amplification`):
197
+ // a per-(peer, topic) rate limiter (own instance — the register-path limiter is per-coord inside each
198
+ // engine; this handler is node-level) plus the per-(topic, tier) effectiveAt high-water. Defaults to
199
+ // `register_rate_per_peer` (4 / min / peer / topic) with exponential back-off.
200
+ const promoteGate = createPromoteGate(options.antiDos?.rateLimiter);
201
+ const ctx = {
202
+ hash,
203
+ addressing,
204
+ slots,
205
+ barometer,
206
+ transport: gossipTransport,
207
+ profile,
208
+ selfMemberBytes,
209
+ wantK,
210
+ minSigs,
211
+ maxBytes,
212
+ publishSink,
213
+ privateKey: options.privateKey,
214
+ router,
215
+ antiDos: {
216
+ rateLimiter: options.antiDos?.rateLimiter,
217
+ replayGuard: options.antiDos?.replayGuard,
218
+ topicBudget: options.antiDos?.topicBudget,
219
+ },
220
+ bootstrapEvidence,
221
+ promotionConfig: options.promotion,
222
+ dialSign,
223
+ cohortAround,
224
+ verifyRegisterSig,
225
+ verifyParticipantSig,
226
+ signGossip,
227
+ verifyGossip,
228
+ broadcastNotice,
229
+ // Cache this node's own freshly-published cohort cert into the verifier, so an inbound notice
230
+ // signed by this node's cohort verifies locally without a network refetch. `verifier` is declared
231
+ // just below; the closure only runs on a (later) publish, after it is initialized.
232
+ onCertPublished: (cert) => verifier.cache(cert),
233
+ };
234
+ const registry = createCoordRegistry(ctx);
235
+ // --- intra-cohort sign endorsement (the `/sign` handler body) ---
236
+ // A member dials us to endorse a threshold-signed artifact; we sign the exact request payload iff we
237
+ // and the requester share the cohort+epoch around `coord`. Exported `handleSignRequest` is the testable
238
+ // core; here we bind it to this node's key + the FRET assembly around the requested coord.
239
+ const signEndorse = (request, fromPeerStr) => handleSignRequest(request, fromPeerStr, {
240
+ privateKey: options.privateKey,
241
+ selfMember: selfMemberBytes,
242
+ cohortMembersAround: (coord) => cohortAround(coord).members.map(bytesToPeerIdString),
243
+ currentEpoch: (coord) => cohortAround(coord).cohortEpoch,
244
+ // Rotation endorsement consults the served coord engine's prior-epoch membership history; a coord
245
+ // this node does not serve has no engine, so the hand-off is refused (no spurious instantiation).
246
+ priorCohortMembersAt: (coord, epoch) => registry.findByCoord(coord)?.cohortIdentityAt(epoch),
247
+ // Membership binding: re-derive our own canonical cert fields from the SAME `cohortAround` snapshot
248
+ // the per-coord publisher signs over, so a falsified `members` / `cohortCoord` / internal-epoch
249
+ // payload is refused. `stabilizedAt: 0` — the endorser ignores it (it only bounds the value
250
+ // far-future via `now`), matching the dep contract (cohort-topic-sign-endorsement-payload-binding).
251
+ expectedMembershipFields: (coord) => {
252
+ const snap = cohortAround(coord);
253
+ const { cohortCoord, cohortEpoch, members } = membershipCertSignable({
254
+ coord,
255
+ cohortEpoch: snap.cohortEpoch,
256
+ members: snap.members,
257
+ stabilizedAt: 0,
258
+ });
259
+ return { cohortCoord, cohortEpoch, members };
260
+ },
261
+ // Wall clock for the `stabilizedAt` far-future bound. Production: Date.now. A virtual-time harness
262
+ // injects a non-tripping clock (its publish `stabilizedAt` is synthetic, not wall-clock).
263
+ now: options.now ?? (() => Date.now()),
264
+ });
265
+ // --- participant-side composition (node scope) ---
266
+ // The participant service exposes a node-level gossip handle (around the node's own ring position)
267
+ // and the membership verifier; its register/renew walk drives the FRET router, not a coord engine.
268
+ const participantStore = createRegistrationStore();
269
+ const participantGossipBus = createCohortGossipBus({
270
+ transport: gossipTransport,
271
+ store: participantStore,
272
+ coord: selfCoord,
273
+ localEpoch: () => cohortAround(selfCoord).cohortEpoch,
274
+ // Same per-coord auth gate as the coord engines: only a signed gossip from a member of the cohort
275
+ // around the node's own ring position merges here (live-signer mode); absent in key-less composition.
276
+ verifyInbound: verifyGossip === undefined ? undefined : (g) => verifyGossip(g, selfCoord),
277
+ });
278
+ const certSource = membershipSource;
279
+ const membershipRouter = createMembershipSourceRouter({ committed: certSource, fret: certSource });
280
+ // --- direct trust anchor (cohort-topic-trust-anchor-fret-binding) ---
281
+ // Bind the db-core trust gate's direct anchor to the FRET ring: for a coord this node serves (a covered,
282
+ // non-partitioned T2/T3 coord whose cohort includes self), the cert's signing quorum is checked against
283
+ // the ring's two-sided assembly — a forged unrelated keyset is `"rejected"`, a legit (slack-tolerant)
284
+ // cohort `"anchored"`, everything else `"unknown"` (→ chain / TOFU). Committed tiers (T0/T1) stay
285
+ // `"unknown"` here so this composes with the future tx-log anchor rather than fighting it. `wantK` matches
286
+ // the cohort size `cohortAround` publishes certs over; `selfPeerStr` is the coverage handle.
287
+ const trustAnchor = new FretTrustAnchor(fret, { k: wantK, selfPeerId: selfPeerStr });
288
+ const verifier = createMembershipVerifier({
289
+ signer: verifyingSigner,
290
+ router: membershipRouter,
291
+ minSigs,
292
+ anchor: trustAnchor,
293
+ // Genesis trust roots (network-config; empty by default → no roots, identical to pre-seam behavior).
294
+ trustRoots: options.genesisTrustRoots ?? [],
295
+ });
296
+ // --- participant-side cold-start evidence builder (gap 6) ---
297
+ // Mints the evidence the participant attaches on a cold-root `bootstrap: true` re-issue. PoW (T2/T3) is
298
+ // keyless, so even a key-less host can bootstrap those tiers; the proof is bound to the register's own
299
+ // (topicId, tier, participantCoord, timestamp) tuple so a verifier reconstructs the same image. T0/T1
300
+ // carries no evidence here (the builder supports an `endorse` self-vouch seam — see
301
+ // `bootstrap-evidence-builder.ts` — but origination at those tiers is the committed-parent-reference
302
+ // follow-on `cohort-topic-bootstrap-parent-reference`, so it is intentionally left unwired for now).
303
+ const buildBootstrapEvidence = createBootstrapEvidenceBuilder({
304
+ hash,
305
+ bits: options.antiDos?.powDifficultyBits,
306
+ });
307
+ const service = createCohortTopicService({
308
+ // `self` is the dialable peer-id bytes (not the ring coord): db-core carries it as the
309
+ // participant's wire identity, so the cohort can verify its peer-key signature (see signing seam).
310
+ self: selfMemberBytes,
311
+ hash,
312
+ router,
313
+ sizeEstimator,
314
+ signer: participantSigner,
315
+ gossipBus: participantGossipBus,
316
+ verifier,
317
+ buildBootstrapEvidence,
318
+ config: { fanout, wantK, minSigs, maxMessageBytes: maxBytes },
319
+ });
320
+ // --- register dispatch: recompute the served coord and run the cohort decision on its engine ---
321
+ const dispatchRegister = async (reg, fretCohort, now) => {
322
+ const topicId = b64urlToBytes(reg.topicId);
323
+ const participantCoord = b64urlToBytes(reg.participantCoord);
324
+ // FRET's ActivityHandler does not carry the routed key, so recompute it from the frame. For tier
325
+ // `d` this equals the participant's `coord_d(self, topicId)` routing key by construction, i.e. the
326
+ // coordinate FRET routed to (§Tier addressing).
327
+ const servedCoord = addressing.coord(reg.treeTier, participantCoord, topicId);
328
+ const coordEngine = registry.forCoord(servedCoord, reg.treeTier, participantCoord);
329
+ if (fretCohort !== undefined) {
330
+ crossCheckCohort(fret, wantK, servedCoord, fretCohort);
331
+ }
332
+ // `parentCoord` for a cold-start forwarder's parent registration; undefined at the root.
333
+ const parentCoord = reg.treeTier > 0 ? addressing.coord(reg.treeTier - 1, participantCoord, topicId) : undefined;
334
+ return coordEngine.engine.handleRegister(reg, { followOn: false, treeTier: reg.treeTier, parentCoord }, now);
335
+ };
336
+ // --- protocol handlers + activity callback ---
337
+ // Await registration so the host is not returned (and dialed) before the five handlers are live —
338
+ // and, crucially, before the gossip driver below starts ticking (no tick may run on a half-wired node).
339
+ await registerProtocolHandlers(node, protocols, registry, dispatchRegister, signEndorse, verifier, promoteGate, gossipTransport, publishSink, membershipSource, selfCoord, maxBytes);
340
+ fret.setActivityHandler(async (activity, cohort) => {
341
+ const reg = validateRegisterV1(decodeCohortMessage(b64urlToBytes(activity), maxBytes));
342
+ const reply = await dispatchRegister(reg, cohort, Date.now());
343
+ return { commitCertificate: bytesToB64url(encodeCohortMessage(reply, maxBytes)) };
344
+ });
345
+ // --- periodic gossip-cadence driver (gap 5) ---
346
+ // db-core has no timer port (confirmed), so the host owns a single raw `setInterval`. Each tick drives
347
+ // every live coord engine's gossip round + membership refresh + demotion check; the membership-refresh
348
+ // (5 min) and demotion (5 min) hysteresis self-gate on elapsed time, so a fast tick is safe and cheap
349
+ // (idle empty engines build no frame). A re-entrancy guard skips a tick that overlaps a slow prior one;
350
+ // `stopped` short-circuits any tick that fires after `stop()`.
351
+ let stopped = false;
352
+ let ticking = false;
353
+ const driveTick = async () => {
354
+ if (stopped || ticking) {
355
+ return;
356
+ }
357
+ ticking = true;
358
+ try {
359
+ const now = Date.now();
360
+ for (const engine of registry.all()) {
361
+ if (stopped) {
362
+ break;
363
+ }
364
+ try {
365
+ await engine.gossipRound(now);
366
+ await engine.pumpMembership(now);
367
+ await engine.demotionTick(now);
368
+ }
369
+ catch (err) {
370
+ log("cohort-topic: gossip tick failed for a coord engine: %o", err);
371
+ }
372
+ }
373
+ // Proactive idle reclaim of the node-level promote-gate limiter on the same cadence the per-coord
374
+ // limiters sweep on. The limiter's inline `maxKeys` cap is the hard worst-case bound; this sweep is
375
+ // the steady-state reclaim of idle `(peer, topic)` keys. Runs after the per-engine loop, inside the
376
+ // `ticking` guard and `stopped` short-circuit, so it is single-threaded with no race on `stop()`.
377
+ promoteGate.rateLimiter.sweep(now);
378
+ }
379
+ finally {
380
+ ticking = false;
381
+ }
382
+ };
383
+ const timer = setInterval(() => {
384
+ void driveTick();
385
+ }, gossipIntervalMs);
386
+ // Node timers keep the event loop alive; cohort gossip should not pin a process that is otherwise idle.
387
+ timer.unref?.();
388
+ return {
389
+ service,
390
+ registry,
391
+ protocols,
392
+ profile,
393
+ gossipTransport,
394
+ promoteGate,
395
+ stop: async () => {
396
+ stopped = true;
397
+ clearInterval(timer);
398
+ registry.close();
399
+ participantGossipBus.close();
400
+ await node.unhandle(cohortTopicProtocolList(protocols));
401
+ },
402
+ };
403
+ }
404
+ // --- participant signing seam ---
405
+ /**
406
+ * The participant body signer. With a node key it peer-key-signs the canonical register/renew byte
407
+ * image (base64url). Without one it is the interim empty-string signer with a one-time warn that the
408
+ * bodies are unsigned — keeping key-less mock/unit flows composable (e.g. the four-protocol handshake
409
+ * test). The `signRegister`/`signRenew` bodies are the wire-payload-helper inputs verbatim.
410
+ */
411
+ function createParticipantSigner(privateKey, log) {
412
+ if (privateKey === undefined) {
413
+ let warned = false;
414
+ const warnOnce = () => {
415
+ if (warned) {
416
+ return;
417
+ }
418
+ warned = true;
419
+ log("no privateKey supplied to createCohortTopicHost; participant RegisterV1/RenewV1 bodies are UNSIGNED (interim — supply options.privateKey for peer-key signing)");
420
+ };
421
+ return {
422
+ signRegister: () => {
423
+ warnOnce();
424
+ return Promise.resolve("");
425
+ },
426
+ signRenew: () => {
427
+ warnOnce();
428
+ return Promise.resolve("");
429
+ },
430
+ };
431
+ }
432
+ return {
433
+ signRegister: async (body) => bytesToB64url(await signPeer(privateKey, registerSigningPayload(body))),
434
+ signRenew: async (body) => bytesToB64url(await signPeer(privateKey, renewSigningPayload(body))),
435
+ };
436
+ }
437
+ // --- anti-DoS: node-level bootstrap-evidence policy (gap 6) ---
438
+ /**
439
+ * Build the node-level {@link BootstrapEvidence} policy a cold root demands of a `bootstrap: true`
440
+ * registration (`docs/cohort-topic.md` §Anti-DoS bullet 4). The policy itself (tier-dependent: T0/T1
441
+ * need a signed parent reference; T2/T3 accept PoW OR reputation OR a parent reference) is real db-core
442
+ * logic — this only supplies the **verifiers**, which db-core deliberately does not embed (no specific
443
+ * PoW / reputation / committed-work scheme).
444
+ *
445
+ * Resolution per verifier (an injected `antiDos.bootstrapEvidence` override always wins — the test seam):
446
+ *
447
+ * - `verifyPoW` — when **configured**, the real {@link createPoWVerifier} (self-contained: one hash over
448
+ * the bound preimage). This is a working PoW path, not deny.
449
+ * - `verifyReputation` — when a reputation **view** is supplied, the real {@link createReputationVerifier}
450
+ * (a referee peer-key-signs the bound image; the cohort checks the signature + the referee's local
451
+ * reputation). Else (configured, no view) fail closed.
452
+ * - `verifyParentReference` — the real {@link createParentReferenceVerifier} over `parentTopicView`: a
453
+ * participant-signed reference to a parent topic that the node locally knows exists (committed/membership
454
+ * state). It is the *only* accepted evidence for T0/T1 and the third T2/T3 option. The view is always
455
+ * available (the host builds a default from the membership cache + addressing), so this is the real
456
+ * verifier at T2/T3 and at T0/T1 **once a committed backing is wired**.
457
+ *
458
+ * **T0/T1 without a committed backing (the cold-start-origination interim posture).** A real production
459
+ * node wires a reputation view (so it is *configured*) but, today, no committed-existence backing
460
+ * (`hasCommittedParentBacking === false`): there is no coord-keyed committed-membership index yet, so the
461
+ * default `parentTopicView` fails T0/T1 closed, and the participant-side builder mints no parentRef for a
462
+ * brand-new root (a root has no parent to reference). At those tiers the policy consults *only*
463
+ * `verifyParentReference`, so a real, unfilled T0/T1 parent-ref gate would make cold-start origination
464
+ * impossible. While that backing does not exist we therefore keep T0/T1 **permissive-but-logged** when
465
+ * `hasCommittedParentBacking` is false, and run the real verifier otherwise — i.e. at T2/T3 always, and at
466
+ * T0/T1 once a committed backing IS supplied (the test seam `antiDos.parentTopicView`, or a future
467
+ * production `committedParentTopicReader`). This is the documented refinement of the parent-reference work,
468
+ * narrowed so it does not regress the just-landed real T0/T1 gating exercised with an explicit view.
469
+ *
470
+ * "Configured" = any reputation view or explicit `bootstrapEvidence` override is set; once configured, an
471
+ * unfilled verifier fails **closed** so a banned/low-rep referee cannot slip the T2/T3
472
+ * `verifyPoW || verifyReputation || verifyParentReference` disjunction. The permissive-but-logged
473
+ * fallback is reserved for the *entirely unconfigured* interim node (and the configured-but-no-committed-
474
+ * backing T0/T1 origination path above) — a one-time warning, never an undefined gate — so the
475
+ * db-core/mock-tier flows that bootstrap tier-0 without evidence still pass.
476
+ *
477
+ * @param hasCommittedParentBacking Whether this host has a committed-existence backing to gate T0/T1
478
+ * parent-refs against (an explicit `antiDos.parentTopicView` override or a wired
479
+ * `committedParentTopicReader`). False on today's production node, where T0/T1 stays permissive.
480
+ */
481
+ function createBootstrapEvidencePolicy(antiDos, hash, log, parentTopicView, hasCommittedParentBacking) {
482
+ const overrides = antiDos?.bootstrapEvidence;
483
+ const reputation = antiDos?.reputation;
484
+ // Once ANY real gating is configured (a reputation view or explicit verifiers), an unfilled verifier
485
+ // must fail **closed** (deny), not open — otherwise a permissive verifier short-circuits the T2/T3
486
+ // `||` disjunction and admits even a banned peer. The permissive-but-logged fallback below is
487
+ // therefore reserved for the *entirely unconfigured* interim node.
488
+ const configured = overrides !== undefined || reputation !== undefined;
489
+ // Mirror the db-core policy's tier split (an override config wins, else the default) so the T0/T1 branch
490
+ // of the parent-reference gate below matches the tiers at which the policy consults it exclusively.
491
+ const maxNoPowTier = overrides?.config?.maxNoPowTier ?? DEFAULT_MAX_NO_POW_TIER;
492
+ // The real, self-contained PoW verifier — available whenever configured (no view / subsystem needed).
493
+ const realPoW = createPoWVerifier({ hash, bits: antiDos?.powDifficultyBits });
494
+ // The real referee verifier — only when a reputation view is supplied (the T2/T3 reputation path).
495
+ const realReputation = reputation === undefined
496
+ ? undefined
497
+ : createReputationVerifier({ reputation, deprioritizeThreshold: antiDos?.deprioritizeThreshold });
498
+ // The real signed-parent-reference verifier over the existence view (T2/T3 always; T0/T1 once a committed
499
+ // backing is wired). Replaces the interim reputation stand-in: it demands a real, existing parent.
500
+ const realParentReference = createParentReferenceVerifier({ parentTopicView });
501
+ const deny = () => false;
502
+ // Permissive-but-logged fallback (one warning total): keeps the gate defined while the production
503
+ // evidence schemes are unwired, instead of silently leaving cold-root bootstrap unauthenticated.
504
+ let warned = false;
505
+ const permissive = (kind) => (reg) => {
506
+ void reg;
507
+ if (!warned) {
508
+ warned = true;
509
+ log("cohort-topic anti-DoS: bootstrap-evidence %s verifier is PERMISSIVE — no PoW/reputation view wired, so cold-root bootstrap is NOT cryptographically gated (interim; inject antiDos.bootstrapEvidence/reputation, see cohort-topic-bootstrap-evidence-scheme)", kind);
510
+ }
511
+ return true;
512
+ };
513
+ // An unconfigured verifier: deny when the policy is otherwise configured (fail closed), else permissive.
514
+ const fallback = (kind) => configured ? deny : permissive(kind);
515
+ // The configured parent-reference gate. At T0/T1 (`reg.tier <= maxNoPowTier`) with NO committed backing,
516
+ // a brand-new root cannot mint any acceptable parent-ref (it has no parent; the default view fails closed)
517
+ // and the policy consults only this verifier there — so admit permissively-but-logged rather than regress
518
+ // cold-start origination. At T2/T3, and at T0/T1 once a committed backing IS wired (the test seam / future
519
+ // production index), run the real verifier.
520
+ const permissiveT0T1 = permissive("parent-reference (T0/T1, no committed backing)");
521
+ const parentReferenceGate = (reg) => {
522
+ if (reg.tier <= maxNoPowTier && !hasCommittedParentBacking) {
523
+ return permissiveT0T1(reg);
524
+ }
525
+ return realParentReference(reg);
526
+ };
527
+ return createBootstrapEvidence({
528
+ // Configured ⇒ the real PoW path; unconfigured ⇒ permissive (preserves the bare-host tier-0 flows).
529
+ verifyPoW: overrides?.verifyPoW ?? (configured ? realPoW : fallback("proof-of-work")),
530
+ verifyReputation: overrides?.verifyReputation ?? realReputation ?? fallback("reputation"),
531
+ // The real signed-parent-reference verifier when configured (an explicit override still wins), gated so
532
+ // T0/T1-without-committed-backing stays permissive; unconfigured ⇒ permissive (preserves the bare-host
533
+ // tier-0 flows). No longer the reputation stand-in.
534
+ verifyParentReference: overrides?.verifyParentReference ?? (configured ? parentReferenceGate : fallback("parent-reference")),
535
+ config: overrides?.config,
536
+ });
537
+ }
538
+ /**
539
+ * Route a forwarder→parent link to `parentCoord` and resolve on the round-trip (the parent ack).
540
+ *
541
+ * The link is a `RegisterV1`-style frame routed over {@link ITopicRouter.routeAndAct} keyed at the
542
+ * parent coord: it rides the parent's serving tier (`treeTier − 1`) with this engine's seed
543
+ * `participantCoord`, so the parent recomputes `servedCoord = coord_{d−1}(participantCoord, topicId) =
544
+ * parentCoord`. A fresh CSPRNG `correlationId` keeps it clear of the parent's replay guard on retry.
545
+ * Resolution of the route is treated as the ack (richer child-link confirmation — the parent recording
546
+ * `childCohortCount` over a dedicated child-link frame — is the follow-on
547
+ * `cohort-topic-parent-child-link`); a rejection propagates so the cold-start manager keeps the
548
+ * forwarder `awaiting_parent`.
549
+ */
550
+ async function registerForwarderWithParent(ctx, link) {
551
+ const frame = {
552
+ v: 1,
553
+ topicId: bytesToB64url(link.topicId),
554
+ tier: clampTier(link.opTier ?? 0),
555
+ treeTier: Math.max(0, link.treeTier - 1),
556
+ participantCoord: bytesToB64url(link.participantCoord),
557
+ ttl: DEFAULT_TTL_MS,
558
+ // Not a root cold-start: a follow-on link to an already-promoted parent, so no bootstrap evidence.
559
+ bootstrap: false,
560
+ timestamp: Date.now(),
561
+ correlationId: bytesToB64url(randomBytes(16)),
562
+ // Interim: the forwarder cohort cannot sign as the participant; the dedicated child-link frame
563
+ // (follow-on) carries the cohort threshold signature instead.
564
+ signature: "",
565
+ };
566
+ await ctx.router.routeAndAct(link.parentCoord, encodeCohortMessage(frame, ctx.maxBytes), { wantK: ctx.wantK, minSigs: ctx.minSigs });
567
+ }
568
+ /** Clamp an op tier to the valid T0–T3 range so the link frame validates at a (future) real parent. */
569
+ function clampTier(tier) {
570
+ if (!Number.isInteger(tier) || tier < 0) {
571
+ return 0;
572
+ }
573
+ return tier > 3 ? 3 : tier;
574
+ }
575
+ // --- registry + coord engine ---
576
+ /** Build the lazy `servedCoord → CoordEngine` registry over the shared collaborators. */
577
+ function createCoordRegistry(ctx) {
578
+ const engines = new Map();
579
+ return {
580
+ forCoord(coord, treeTier, participantCoord) {
581
+ const key = bytesToB64url(coord);
582
+ // Compute-if-absent, synchronously — no async gap, so two concurrent callers for the same coord
583
+ // share one engine rather than racing to construct a second.
584
+ let engine = engines.get(key);
585
+ if (engine === undefined) {
586
+ engine = createCoordEngine(ctx, coord, treeTier, participantCoord);
587
+ engines.set(key, engine);
588
+ }
589
+ return engine;
590
+ },
591
+ findByCoord(coord) {
592
+ return engines.get(bytesToB64url(coord));
593
+ },
594
+ findHolder(topicId, participantId) {
595
+ for (const engine of engines.values()) {
596
+ if (engine.holds(topicId, participantId)) {
597
+ return engine;
598
+ }
599
+ }
600
+ return undefined;
601
+ },
602
+ findServing(topicId, treeTier) {
603
+ for (const engine of engines.values()) {
604
+ if (engine.treeTier === treeTier && engine.servesTopic(topicId)) {
605
+ return engine;
606
+ }
607
+ }
608
+ return undefined;
609
+ },
610
+ all() {
611
+ return [...engines.values()];
612
+ },
613
+ close() {
614
+ for (const engine of engines.values()) {
615
+ engine.close();
616
+ }
617
+ engines.clear();
618
+ },
619
+ };
620
+ }
621
+ /**
622
+ * Per-{@link CoordEngine} epoch-rotation bookkeeping (`cohort-topic-trust-anchor-rotation-production`).
623
+ * Two roles, one small object:
624
+ *
625
+ * - **Producer** — {@link predecessor} is the identity of the *last published* cert; a publish whose
626
+ * first `k − x` differ from it is a rotation, and the predecessor identity scopes the rotation `/sign`
627
+ * round (its epoch is `prevEpoch`, its members are the outgoing cohort to collect from).
628
+ * - **Endorser** — {@link membersAt} answers "was I a member of the cohort at `epoch`?" over a two-deep
629
+ * observed-epoch history ({@link current} + {@link prior}), kept fresh by {@link observe} on every
630
+ * cohort assembly. A request for an epoch past that window is refused (the rapid-double-rotation gap).
631
+ *
632
+ * The observed history (for endorsing) is distinct from `lastPublished` (for producing): a non-deciding
633
+ * member endorses rotations it never published, so it cannot rely on its own publish history alone.
634
+ */
635
+ class RotationState {
636
+ current;
637
+ prior;
638
+ lastPublished;
639
+ /**
640
+ * Record the engine's current cohort identity, shifting the previous current into {@link prior} on an
641
+ * epoch change. Cheap on the hot path: `build` is only invoked when `epochKey` actually changes (the
642
+ * member set is unchanged within an epoch, so there is nothing to refresh).
643
+ */
644
+ observe(epochKey, build) {
645
+ if (this.current?.epochKey === epochKey) {
646
+ return;
647
+ }
648
+ const next = build();
649
+ this.prior = this.current;
650
+ this.current = next;
651
+ }
652
+ /** Mark `identity` as the most recently published cert's identity (the rotation chain predecessor). */
653
+ recordPublished(identity) {
654
+ this.lastPublished = identity;
655
+ }
656
+ /** The last-published identity (the predecessor a fresh rotation attests from), or `undefined`. */
657
+ predecessor() {
658
+ return this.lastPublished;
659
+ }
660
+ /** The member peer-id strings observed under `epochKey` (current or prior), or `undefined`. */
661
+ membersAt(epochKey) {
662
+ if (this.current?.epochKey === epochKey) {
663
+ return this.current.memberStrs;
664
+ }
665
+ if (this.prior?.epochKey === epochKey) {
666
+ return this.prior.memberStrs;
667
+ }
668
+ return undefined;
669
+ }
670
+ }
671
+ /**
672
+ * Compose one {@link CoordEngine} bound to `servedCoord`. The cohort it threshold-signs / shards with
673
+ * is the FRET assembly around `servedCoord` (not the node's own ring position). The promotion tier
674
+ * inputs are coord-derived: `treeTier` is fixed at instantiation; `parentCoord` is
675
+ * `coord_{d-1}(participantCoord, topicId)` (the shard's parent shares the prefix, so any participant
676
+ * routed here yields the same parent); `childCohortCount` is `0` for the single-cohort milestone.
677
+ */
678
+ function createCoordEngine(ctx, servedCoord, treeTier, participantCoord) {
679
+ const store = createRegistrationStore();
680
+ // Epoch-rotation bookkeeping. `cohort()` observes every assembly so the endorser history stays fresh
681
+ // (the gossip-cadence driver assembles each round); the producer reads `predecessor()` on publish.
682
+ const rotationState = new RotationState();
683
+ const identityOf = (view) => {
684
+ const memberBytes = [...view.members];
685
+ return {
686
+ epoch: view.cohortEpoch,
687
+ epochKey: bytesToB64url(view.cohortEpoch),
688
+ memberBytes,
689
+ memberStrs: memberBytes.map(bytesToPeerIdString),
690
+ };
691
+ };
692
+ const cohort = () => {
693
+ const view = ctx.cohortAround(servedCoord);
694
+ rotationState.observe(bytesToB64url(view.cohortEpoch), () => identityOf(view));
695
+ return view;
696
+ };
697
+ const localEpoch = () => cohort().cohortEpoch;
698
+ // Inbound gossip is routed to this bus by its `coord`; the optional auth gate (live-signer mode) drops
699
+ // a frame whose `fromMember` signature is bad or who is not a member of the cohort around THIS coord.
700
+ const bus = createCohortGossipBus({
701
+ transport: ctx.transport,
702
+ store,
703
+ coord: servedCoord,
704
+ localEpoch,
705
+ verifyInbound: ctx.verifyGossip === undefined ? undefined : (g) => ctx.verifyGossip(g, servedCoord),
706
+ // Sibling-drain half of the topic-budget release: a topic whose participants are sharded onto a
707
+ // sibling primary drains into this store as a gossip eviction (never this member's own TTL sweep),
708
+ // so re-`touch` the budget down from the post-delete store count — mirroring the engine's `sweepStale`
709
+ // re-touch. `topicBudget` is a forward `const` here, only read when this callback fires at merge time
710
+ // (long after both are initialized). A no-op for a topic the budget does not hold (`touch` guards it).
711
+ onRecordsEvicted: (topicIds) => {
712
+ for (const topicId of topicIds) {
713
+ topicBudget.touch(topicId, store.directParticipants(topicId));
714
+ }
715
+ },
716
+ });
717
+ const view = bus.view();
718
+ const selfMember = bytesToB64url(ctx.selfMemberBytes);
719
+ // Per-touch replication delta queue: the renewal cohort side appends each served touch / TTL eviction
720
+ // here; the next gossip round drains the batch into the broadcast frame (one round, not one per ping).
721
+ const pending = createPendingDeltas();
722
+ // Per-coord threshold signers: each assembles a real k − x signature by signing locally and collecting
723
+ // the rest of the cohort around THIS served coord over the `/sign` RPC. `membership` signs the cert;
724
+ // `promotion` signs promote/demote notices (kind drives the dialed members' endorsement policy). In
725
+ // key-less interim mode there is no key to sign self's chunk, so the signer is verify-only (the
726
+ // publisher / promotion paths are simply not driven without a key).
727
+ const makeCoordSigner = (kind) => {
728
+ if (ctx.privateKey === undefined) {
729
+ return createCohortSigner(createVerifyOnlyThresholdCrypto(), ctx.minSigs);
730
+ }
731
+ const crypto = new FretCohortThresholdCrypto({
732
+ kind,
733
+ privateKey: ctx.privateKey,
734
+ selfMember: ctx.selfMemberBytes,
735
+ coord: () => servedCoord,
736
+ cohortEpoch: localEpoch,
737
+ cohortMembers: () => cohort().members.map(bytesToPeerIdString),
738
+ dialSign: ctx.dialSign,
739
+ });
740
+ return createCohortSigner(crypto, ctx.minSigs);
741
+ };
742
+ const noticeSigner = makeCoordSigner("promotion");
743
+ const membershipSigner = makeCoordSigner("membership");
744
+ // Cohort-side membership-cert publisher: threshold-signs a MembershipCertV1 over this coord's cohort
745
+ // and serves it through the node's publish sink. Driven by the onStabilized / pumpMembership hooks.
746
+ const membershipPublisher = createMembershipCertPublisher({
747
+ signer: membershipSigner,
748
+ sink: ctx.publishSink,
749
+ minSigs: ctx.minSigs,
750
+ maxMessageBytes: ctx.maxBytes,
751
+ });
752
+ const snapshotAt = (now) => {
753
+ const { members, cohortEpoch } = cohort();
754
+ return { coord: servedCoord, cohortEpoch, members, stabilizedAt: now };
755
+ };
756
+ // Key-less interim mode has only a verify-only per-coord signer (its `assemble` rejects), so the
757
+ // publish hooks must no-op rather than surface a rejected promise — matching the documented
758
+ // "publisher paths are simply not driven without a key" contract. Without this guard a future
759
+ // gossip-cadence driver iterating `registry.all()` would reject on every key-less engine.
760
+ const canPublish = ctx.privateKey !== undefined;
761
+ // --- epoch-rotation attestation production (cohort-topic-trust-anchor-rotation-production) ---
762
+ // The first `k − x` members of the cert's ascending order; a change across a publish is a rotation
763
+ // (mirrors the publisher's own republish gate — same inputs, so the two agree on what is a rotation).
764
+ const firstKx = (members) => [...members].sort(compareBytes).slice(0, ctx.minSigs).map(bytesToB64url);
765
+ const firstKxChanged = (a, b) => !sameStringOrder(firstKx(a.memberBytes), firstKx(b.memberBytes));
766
+ /**
767
+ * Threshold-sign the new cert's canonical payload under the **predecessor** cohort identity, producing the
768
+ * `{ prevEpoch, rotationSig, rotationSigners }` attestation — or `undefined` if the predecessor quorum is
769
+ * unreachable (mass churn / partition), in which case the caller publishes the rotation cert WITHOUT an
770
+ * attestation (trust falls to the direct anchor / TOFU, no worse than a non-rotation publish). The `/sign`
771
+ * round is scoped to the prior epoch's members (`kind: "rotation"`), so the endorsers are the genuinely
772
+ * outgoing cohort. The payload is built through the SAME `membershipCertSignable` the publisher signs, so
773
+ * the signature image matches exactly (the db-core chain check verifies `rotationSig` over it).
774
+ */
775
+ const produceRotation = async (snapshot, predecessor) => {
776
+ const payload = membershipCertSigningPayload(membershipCertSignable(snapshot));
777
+ const selfStr = bytesToPeerIdString(ctx.selfMemberBytes);
778
+ const crypto = new FretCohortThresholdCrypto({
779
+ kind: "rotation",
780
+ privateKey: ctx.privateKey, // canPublish guard: rotation only runs from a publish, which no-ops key-less
781
+ selfMember: ctx.selfMemberBytes,
782
+ coord: () => servedCoord,
783
+ cohortEpoch: () => predecessor.epoch, // prevEpoch — scopes the endorsement to the prior epoch
784
+ cohortMembers: () => [...predecessor.memberStrs], // dial the OUTGOING cohort
785
+ dialSign: ctx.dialSign,
786
+ selfEligible: () => predecessor.memberStrs.includes(selfStr),
787
+ });
788
+ const signer = createCohortSigner(crypto, ctx.minSigs);
789
+ try {
790
+ const { thresholdSig, signers } = await signer.thresholdSign(payload);
791
+ return { prevEpoch: predecessor.epoch, rotationSig: thresholdSig, rotationSigners: signers };
792
+ }
793
+ catch (err) {
794
+ log("cohort-topic: rotation attestation skipped at coord %s — predecessor quorum unavailable: %o", bytesToB64url(servedCoord), err);
795
+ return undefined;
796
+ }
797
+ };
798
+ /**
799
+ * Publish (or refresh) this cohort's membership cert, attaching a rotation attestation when the first
800
+ * `k − x` changed since the last publish. `refresh` selects the publisher path: `false` for a
801
+ * stabilization event ({@link CoordEngine.onStabilized}), `true` for the periodic refresh
802
+ * ({@link CoordEngine.pumpMembership}). A first-`k − x` change is a stabilization regardless of which hook
803
+ * fired, so it routes through `onStabilized` (which republishes promptly on the change) carrying the
804
+ * attestation; the `/sign` round runs only on that change, so it costs one round per rotation, never per
805
+ * tick. Key-less interim mode no-ops (the verify-only signer cannot assemble).
806
+ */
807
+ const publishMembership = async (now, refresh) => {
808
+ if (!canPublish) {
809
+ return undefined;
810
+ }
811
+ const snapshot = snapshotAt(now); // also observes the current identity (snapshotAt → cohort())
812
+ const current = identityOf(snapshot);
813
+ const predecessor = rotationState.predecessor();
814
+ const rotating = predecessor !== undefined && firstKxChanged(predecessor, current);
815
+ let published;
816
+ if (rotating) {
817
+ const rotation = await produceRotation(snapshot, predecessor);
818
+ published = await membershipPublisher.onStabilized(snapshot, now, rotation);
819
+ }
820
+ else {
821
+ published = await (refresh ? membershipPublisher.tick(snapshot, now) : membershipPublisher.onStabilized(snapshot, now));
822
+ }
823
+ if (published !== undefined) {
824
+ rotationState.recordPublished(current);
825
+ ctx.onCertPublished?.(published);
826
+ }
827
+ return published;
828
+ };
829
+ const willingness = createWillingnessCheck({
830
+ barometer: ctx.barometer,
831
+ view,
832
+ selfMember,
833
+ primaryTopicCount: (tier) => countPrimaryTopics(store, ctx.selfMemberBytes, tier),
834
+ config: { cohortSize: ctx.wantK },
835
+ });
836
+ const traffic = createTrafficCounters({ view, store, selfMember });
837
+ const promotion = createPromotionLifecycle({
838
+ store,
839
+ loadBucket: (topicId) => ctx.barometer.bucket(tierOfTopic(store, topicId)),
840
+ // Single-cohort milestone: a tier-0 cohort with no children. Child-cohort tracking is a follow-on.
841
+ childCohortCount: () => 0,
842
+ treeTier: () => treeTier,
843
+ // `coord_{d-1}(P, topicId)`; never invoked at the root (demotion is gated on `treeTier > 0`), so
844
+ // the `d = 0` branch (clamped to `coord_0`) is a well-formed placeholder that the lifecycle skips.
845
+ parentCoord: (topicId) => ctx.addressing.coord(Math.max(0, treeTier - 1), participantCoord, topicId),
846
+ cohortEpoch: localEpoch,
847
+ signer: noticeSigner,
848
+ // Production defaults (cap_promote = 64, …) unless the host was given a promotion override — the
849
+ // live-tier e2e lowers `capPromote` to drive promotion with a small participant count. The
850
+ // coord-derived inputs above (treeTier / childCohortCount / parentCoord) are never overridden.
851
+ config: ctx.promotionConfig ?? { capPromote: undefined },
852
+ });
853
+ // Cold-start forwarder → parent registration (gap 7). A freshly-instantiated tier-`d > 0` forwarder
854
+ // registers with its tier-`(d − 1)` parent cohort at `parentCoord` so the parent counts it as a child;
855
+ // the ColdStartManager holds the forwarder in `awaiting_parent` (accepts participants, holds
856
+ // parent-involving ops) until this resolves. This supplies the TRANSPORT: route a forwarder-link frame
857
+ // to `parentCoord` over the same `RouteAndMaybeAct` path a participant register rides. A resolved
858
+ // round-trip is the parent ack (flip to `serving`); a rejected/timed-out route leaves the forwarder
859
+ // `awaiting_parent` for a later retry and never crashes the instantiating register (cold-start fires
860
+ // this fire-and-forget). The parent-side child-cohort RECORDING (`childCohortCount`, a dedicated
861
+ // child-link frame) is a follow-on (`tickets/backlog/cohort-topic-parent-child-link`); the
862
+ // single-tier-0 milestone has no parent (the root serves immediately), so a unit test exercises this.
863
+ const coldStart = createColdStartManager({
864
+ parentRegistrar: {
865
+ registerWithParent: (topicId, parentCoord, tier, opTier) => registerForwarderWithParent(ctx, { topicId, parentCoord, treeTier: tier, opTier, participantCoord }),
866
+ },
867
+ });
868
+ // Per-coord anti-DoS guards (gap 6): each CoordEngine owns its own set — a rate-limit budget / replay
869
+ // window / topic budget for coord A is independent of coord B. The bootstrap-evidence policy
870
+ // (`ctx.bootstrapEvidence`) is node-level and shared by design.
871
+ const rateLimiter = createRegisterRateLimiter(ctx.antiDos.rateLimiter);
872
+ // The read-only lookup-probe path gets its OWN per-coord rate limiter (same config, separate budget),
873
+ // so a probe flood cannot exhaust a participant's register budget at this coord, or vice-versa.
874
+ const probeRateLimiter = createRegisterRateLimiter(ctx.antiDos.rateLimiter);
875
+ const replayGuard = createCorrelationReplayGuard(ctx.antiDos.replayGuard);
876
+ const topicBudget = createTopicBudget(ctx.antiDos.topicBudget);
877
+ const renewal = createRenewalCohortSide({
878
+ store,
879
+ self: ctx.selfMemberBytes,
880
+ slots: ctx.slots,
881
+ cohort,
882
+ gossip: {
883
+ // Per-touch replication, batched to one gossip round: a served ping/re-attach queues the touched
884
+ // record; the next round drains it so cohort members converge on the active set + assignments.
885
+ touch: (rec) => pending.touch(rec),
886
+ // A TTL sweep eviction is gossiped so siblings drop the dead record (convergence on eviction).
887
+ evicted: (rec) => pending.evicted(rec),
888
+ },
889
+ verifyParticipantSig: ctx.verifyParticipantSig,
890
+ });
891
+ const engine = createCohortMemberEngine({
892
+ self: ctx.selfMemberBytes,
893
+ profile: ctx.profile,
894
+ hash: ctx.hash,
895
+ store,
896
+ slots: ctx.slots,
897
+ willingness,
898
+ promotion,
899
+ coldStart,
900
+ traffic,
901
+ renewal,
902
+ cohort,
903
+ quorumWilling: (tier) => ctx.profile.willingTiers.has(tier),
904
+ // Anti-DoS guards (gap 6): per-coord rate/replay/budget; node-level bootstrap-evidence policy.
905
+ rateLimiter,
906
+ // Dedicated probe-path rate limiter (independent budget from `rateLimiter`).
907
+ probeRateLimiter,
908
+ replayGuard,
909
+ topicBudget,
910
+ bootstrapEvidence: ctx.bootstrapEvidence,
911
+ verifyRegisterSig: ctx.verifyRegisterSig,
912
+ // Admission-time replication: enqueue the just-admitted record so siblings hold a replica before the
913
+ // participant's first renewal touch (closes the accept→first-touch durability window). Same queue +
914
+ // last-writer-wins as the renewal `gossip.touch`.
915
+ onAdmit: (rec) => pending.touch(rec),
916
+ // A promotion notice signed on an arrival is broadcast to the cohort around this served coord
917
+ // (and the parent for a demotion). The engine only knows the notice; the host adds the coord.
918
+ onNotice: (notice) => ctx.broadcastNotice?.(notice, servedCoord),
919
+ log,
920
+ });
921
+ /** Distinct topics this engine currently holds state for (the gossip-summary / demotion iteration set). */
922
+ const residentTopics = () => {
923
+ const byKey = new Map();
924
+ for (const rec of store.listAll()) {
925
+ byKey.set(bytesToB64url(rec.topicId), rec.topicId);
926
+ }
927
+ return [...byKey.values()];
928
+ };
929
+ // One gossip round: sweep stale records (firing the `evicted` deltas), freeze each resident topic's
930
+ // traffic summary, drain the touch/evicted deltas, then assemble + sign + broadcast the frame. Idle
931
+ // empty engines (no topics, no deltas) build no frame and skip the broadcast.
932
+ const gossipRound = async (now) => {
933
+ engine.sweepStale(now);
934
+ const topicSummaries = residentTopics().map((topicId) => toCohortTopicSummary(topicId, traffic.publish(topicId, now), {
935
+ tier: tierOfTopic(store, topicId),
936
+ directParticipants: store.directParticipants(topicId),
937
+ promoted: promotion.isPromoted(topicId),
938
+ // Single-cohort milestone: no child cohorts tracked. Child-cohort tracking is a follow-on.
939
+ childCohortCount: 0,
940
+ }));
941
+ const { records, evicted } = pending.drain();
942
+ const g = buildCohortGossip({
943
+ fromMember: selfMember,
944
+ coord: bytesToB64url(servedCoord),
945
+ cohortEpoch: bytesToB64url(localEpoch()),
946
+ profile: ctx.profile,
947
+ barometer: ctx.barometer,
948
+ windowSeconds: DEFAULT_TRAFFIC_WINDOW_SECONDS,
949
+ topicSummaries,
950
+ records,
951
+ evicted,
952
+ timestamp: now,
953
+ });
954
+ if (g === undefined) {
955
+ return undefined;
956
+ }
957
+ if (ctx.signGossip !== undefined) {
958
+ g.signature = await ctx.signGossip(g);
959
+ }
960
+ bus.broadcast(g);
961
+ return g;
962
+ };
963
+ // Time-driven demotion across resident topics; any returned notice is broadcast to the cohort (and the
964
+ // parent coord) via the same path a promotion uses. Skipped without a key (verify-only signer can't
965
+ // assemble); for the single-cohort tier-0 milestone the lifecycle never demotes (the root has no parent).
966
+ const demotionTick = async (now) => {
967
+ if (!canPublish) {
968
+ return;
969
+ }
970
+ for (const topicId of residentTopics()) {
971
+ let notice;
972
+ try {
973
+ notice = await promotion.maybeDemote(topicId, now);
974
+ }
975
+ catch (err) {
976
+ log("cohort-topic: demotion sign/broadcast failed for topic %s: %o", bytesToB64url(topicId), err);
977
+ continue;
978
+ }
979
+ if (notice !== undefined) {
980
+ ctx.broadcastNotice?.(notice, servedCoord);
981
+ }
982
+ }
983
+ };
984
+ return {
985
+ servedCoord,
986
+ treeTier,
987
+ engine,
988
+ cohort,
989
+ cohortIdentityAt: (epoch) => rotationState.membersAt(bytesToB64url(epoch)),
990
+ hasState: () => store.listAll().length > 0,
991
+ holds: (topicId, participantId) => store.getByParticipant(topicId, participantId) !== undefined,
992
+ records: (topicId) => store.listByTopic(topicId),
993
+ topicTraffic: (topicId) => traffic.snapshot(topicId),
994
+ cohortView: () => view,
995
+ servesTopic: (topicId) => store.directParticipants(topicId) > 0 || coldStart.get(topicId) !== undefined,
996
+ budgetHasTopic: (topicId) => topicBudget.has(topicId),
997
+ budgetParticipantCount: (topicId) => topicBudget.participantCount(topicId),
998
+ forwarder: (topicId) => coldStart.get(topicId),
999
+ isPromoted: (topicId) => promotion.isPromoted(topicId),
1000
+ applyPromotionNotice: (notice, now) => promotion.applyPromotionNotice(notice, now),
1001
+ applyDemotionNotice: (notice, now) => promotion.applyDemotionNotice(notice, now),
1002
+ onStabilized: (now) => publishMembership(now, false),
1003
+ pumpMembership: (now) => publishMembership(now, true),
1004
+ gossipRound,
1005
+ demotionTick,
1006
+ close: () => bus.close(),
1007
+ };
1008
+ }
1009
+ /**
1010
+ * Resolve an inbound `RenewV1` to the coord engine holding its record and run the renewal. A `RenewV1`
1011
+ * carries no `treeTier`, so the held record — not a recomputed coord — names the cohort. If no engine
1012
+ * on this host holds it (cross-node renewal, post-restart eviction, or replication lag), reply
1013
+ * `unknown_registration` so the participant's failover loop tries its backups and ultimately re-runs
1014
+ * the `d_max` lookup (§TTL and renewal) — never throw.
1015
+ */
1016
+ export function resolveRenew(registry, renew, now) {
1017
+ const topicId = b64urlToBytes(renew.topicId);
1018
+ const participantId = b64urlToBytes(renew.participantId);
1019
+ const holder = registry.findHolder(topicId, participantId);
1020
+ if (holder === undefined) {
1021
+ return { v: 1, result: "unknown_registration" };
1022
+ }
1023
+ return holder.engine.handleRenew(renew, now);
1024
+ }
1025
+ /** Tolerated future skew (ms) for a `membership` payload's `stabilizedAt` — a value beyond this is refused. */
1026
+ const SIGN_STABILIZED_AT_SKEW_MS = 5_000;
1027
+ /** The canonical signable-image tag a non-`rotation` {@link SignKind} must carry (binds tag ↔ kind). */
1028
+ const SIGNABLE_IMAGE_TAG = {
1029
+ membership: "MembershipCertV1",
1030
+ promotion: "PromotionNoticeV1",
1031
+ demotion: "DemotionNoticeV1",
1032
+ };
1033
+ /**
1034
+ * Decode a `/sign` payload's canonical signable array image — `utf8(JSON.stringify([...]))` produced by
1035
+ * `sig/payloads.ts`. Returns the decoded array, or `undefined` when the bytes are not base64url of a JSON
1036
+ * array. NOTE: the payload is a raw signable image, **not** a `CohortMessageV1`, so it is decoded with
1037
+ * `JSON.parse`, never `decodeCohortMessage`.
1038
+ */
1039
+ function decodeSignableImage(payloadB64) {
1040
+ try {
1041
+ const decoded = JSON.parse(new TextDecoder().decode(b64urlToBytes(payloadB64)));
1042
+ return Array.isArray(decoded) ? decoded : undefined;
1043
+ }
1044
+ catch {
1045
+ return undefined;
1046
+ }
1047
+ }
1048
+ /**
1049
+ * Deep-equal of an unknown payload field against the endorser's own ascending-sorted base64url member
1050
+ * list. Guards the `unknown` (a non-array payload field → not a match) before delegating to the shared
1051
+ * ordered-string-array equality; element-wise `!==` makes a non-string entry a mismatch.
1052
+ */
1053
+ function sameMemberList(image, expected) {
1054
+ return Array.isArray(image) && sameStringOrder(image, expected);
1055
+ }
1056
+ /**
1057
+ * The `/sign` endorsement policy: decide whether to endorse a {@link SignRequestV1} and, if so, return
1058
+ * this node's Ed25519 peer-key signature over the **exact** request payload. A member endorses only when
1059
+ * both it and the requester are members of the cohort around `coord` under the request's epoch, **and**
1060
+ * the payload bytes re-derive to something this node independently agrees to attest — so it never signs
1061
+ * for outsiders and never blindly signs requester-supplied bytes. It still signs the exact image (no
1062
+ * re-canonicalization), so the assembled signature verifies against what the requester collected.
1063
+ *
1064
+ * **Payload binding (cohort-topic-sign-endorsement-payload-binding).** The cohort + wire-epoch gates never
1065
+ * inspect `request.payload`; without binding it an insider could collect honest signatures over a cert the
1066
+ * cohort never agreed to. After those gates the endorser decodes the canonical signable image
1067
+ * (`sig/payloads.ts` — a `JSON.parse`d array, NOT a `CohortMessageV1`) and refuses unless:
1068
+ *
1069
+ * - **all kinds** — the image tag matches the kind (`membership`→`MembershipCertV1`, etc.) and the
1070
+ * payload-internal `cohortEpoch` equals this node's current epoch for `coord`; and
1071
+ * - **`membership`** — `cohortCoord`, the full `members` list (deep-equal to the endorser's own ascending
1072
+ * re-derived set), and a finite, not-far-future `stabilizedAt` all match its independent view
1073
+ * ({@link SignEndorsementDeps.expectedMembershipFields}). Because epoch = H(members) in this host, the
1074
+ * members and internal-epoch checks are mutually reinforcing: a forged member list cannot also carry the
1075
+ * honest epoch. The participant verifier still independently re-checks `signers ⊆ cert.members`.
1076
+ *
1077
+ * The kind-specific **hot/cold** refinement for `promotion` / `demotion` (the endorser additionally
1078
+ * requiring its own replicated `directParticipants` to be hot / cold) remains deferred: it needs a
1079
+ * per-topic binding the `(payload, minSigs)` port can't carry and gossip record replication that is still
1080
+ * interim. Parked in `cohort-topic-sign-endorsement-hotcold-refinement` (backlog).
1081
+ *
1082
+ * **`"rotation"` (epoch hand-off).** A rotation request carries the **prior** epoch as `cohortEpoch` and
1083
+ * asks the outgoing cohort to sign the *successor* cert. The gate therefore checks **prior**-epoch
1084
+ * membership instead of current: the endorser must have served the cohort at `prevEpoch`, and the
1085
+ * requester must have been a member of that prior cohort too (the genuinely outgoing set). The verifier
1086
+ * still independently re-checks `rotationSigners ⊆ predecessor-cert.members`, so this gate is the
1087
+ * load-shedding sanity check, not the trust root. See `cohort-topic-trust-anchor-rotation-production`.
1088
+ */
1089
+ export async function handleSignRequest(request, fromPeerStr, deps) {
1090
+ if (deps.privateKey === undefined) {
1091
+ return { v: 1, refused: true, reason: "node has no signing key" };
1092
+ }
1093
+ const coord = b64urlToBytes(request.coord);
1094
+ const selfStr = bytesToPeerIdString(deps.selfMember);
1095
+ if (request.kind === "rotation") {
1096
+ // Prior-epoch gate: endorse a hand-off only from an epoch THIS node was a member of, and only for a
1097
+ // requester that was a member of that same prior cohort. `request.cohortEpoch` IS the prevEpoch.
1098
+ const prevEpoch = b64urlToBytes(request.cohortEpoch);
1099
+ const priorMembers = deps.priorCohortMembersAt?.(coord, prevEpoch);
1100
+ if (priorMembers === undefined) {
1101
+ return { v: 1, refused: true, reason: "not a member of the prior cohort at prevEpoch" };
1102
+ }
1103
+ if (!priorMembers.includes(selfStr)) {
1104
+ return { v: 1, refused: true, reason: "self not in the prior cohort" };
1105
+ }
1106
+ if (!priorMembers.includes(fromPeerStr)) {
1107
+ return { v: 1, refused: true, reason: "requester not in the prior cohort" };
1108
+ }
1109
+ // Structural sanity only: a rotation carries the SUCCESSOR cert image. Full successor re-derivation is
1110
+ // out of scope here (the endorser is the OUTGOING cohort and may not know the successor member set), so
1111
+ // the prior-epoch gate stays the trust check — but reject a payload that is not even a MembershipCertV1
1112
+ // image so the gate cannot be tricked into signing junk bytes. See the rotation follow-on note.
1113
+ const rotImage = decodeSignableImage(request.payload);
1114
+ if (rotImage === undefined || rotImage[0] !== SIGNABLE_IMAGE_TAG.membership) {
1115
+ return { v: 1, refused: true, reason: "rotation payload is not a MembershipCertV1 image" };
1116
+ }
1117
+ const signature = await signPeer(deps.privateKey, b64urlToBytes(request.payload));
1118
+ return { v: 1, signer: bytesToB64url(deps.selfMember), signature: bytesToB64url(signature) };
1119
+ }
1120
+ const members = deps.cohortMembersAround(coord);
1121
+ if (!members.includes(selfStr)) {
1122
+ return { v: 1, refused: true, reason: "not a cohort member for coord" };
1123
+ }
1124
+ if (!members.includes(fromPeerStr)) {
1125
+ return { v: 1, refused: true, reason: "requester not in cohort" };
1126
+ }
1127
+ if (!bytesEqual(b64urlToBytes(request.cohortEpoch), deps.currentEpoch(coord))) {
1128
+ return { v: 1, refused: true, reason: "cohort epoch mismatch" };
1129
+ }
1130
+ // --- payload binding: re-derive what we are willing to attest and refuse anything that does not match ---
1131
+ // The wire-field gates above never inspect `request.payload`; without this an insider could collect honest
1132
+ // signatures over a cert the cohort never agreed to (falsified members / kind-mismatched bytes).
1133
+ const image = decodeSignableImage(request.payload);
1134
+ if (image === undefined) {
1135
+ return { v: 1, refused: true, reason: "payload is not a decodable signable image" };
1136
+ }
1137
+ // All kinds — bind tag ↔ kind (a `membership` request must carry a MembershipCertV1 image, closing the
1138
+ // kind-mismatch hole where a kind-agnostic threshold blob verifies for whatever the bytes decode to).
1139
+ const expectedTag = SIGNABLE_IMAGE_TAG[request.kind];
1140
+ if (image[0] !== expectedTag) {
1141
+ return { v: 1, refused: true, reason: `payload kind tag mismatch (expected ${expectedTag})` };
1142
+ }
1143
+ // All kinds — bind the payload-internal `cohortEpoch` to our own current epoch (closes the falsified-internal
1144
+ // -epoch hole, for promotion / demotion too). It is `image[2]` for a MembershipCertV1 image and the last
1145
+ // element for promotion / demotion (see `sig/payloads.ts`).
1146
+ const currentEpochB64 = bytesToB64url(deps.currentEpoch(coord));
1147
+ const embeddedEpoch = request.kind === "membership" ? image[2] : image[image.length - 1];
1148
+ if (embeddedEpoch !== currentEpochB64) {
1149
+ return { v: 1, refused: true, reason: "payload cohortEpoch does not match endorser view" };
1150
+ }
1151
+ if (request.kind === "membership") {
1152
+ // The core fix: bind coord + members + stabilizedAt to the endorser's independently re-derived view.
1153
+ // Because epoch = H(members) in this host, a forged member list cannot also carry the honest epoch — the
1154
+ // embedded-epoch gate above and this members gate are mutually reinforcing.
1155
+ const expected = deps.expectedMembershipFields?.(coord);
1156
+ if (expected === undefined) {
1157
+ return { v: 1, refused: true, reason: "no membership view to bind the cert against" };
1158
+ }
1159
+ if (image[1] !== expected.cohortCoord) {
1160
+ return { v: 1, refused: true, reason: "payload cohortCoord does not match endorser view" };
1161
+ }
1162
+ if (!sameMemberList(image[3], expected.members)) {
1163
+ return { v: 1, refused: true, reason: "payload members do not match endorser view" };
1164
+ }
1165
+ const stabilizedAt = image[4];
1166
+ if (typeof stabilizedAt !== "number" || !Number.isFinite(stabilizedAt)) {
1167
+ return { v: 1, refused: true, reason: "payload stabilizedAt is not a finite number" };
1168
+ }
1169
+ if (deps.now !== undefined && stabilizedAt > deps.now() + SIGN_STABILIZED_AT_SKEW_MS) {
1170
+ return { v: 1, refused: true, reason: "payload stabilizedAt is far-future" };
1171
+ }
1172
+ }
1173
+ const signature = await signPeer(deps.privateKey, b64urlToBytes(request.payload));
1174
+ return { v: 1, signer: bytesToB64url(deps.selfMember), signature: bytesToB64url(signature) };
1175
+ }
1176
+ /**
1177
+ * Hard cap on tracked `(topicId, tier)` high-water entries; the least-recently-touched are evicted beyond
1178
+ * this. A modest bound is plenty — only verified applies grow the map, so it never evicts under legitimate
1179
+ * load — but it caps the otherwise retain-forever shape on a long-lived node.
1180
+ */
1181
+ export const PROMOTE_HIGHWATER_MAX_KEYS = 8192;
1182
+ /** Build the default {@link PromoteGate} from the (optional) anti-DoS rate-limiter config. */
1183
+ export function createPromoteGate(rateLimiterConfig) {
1184
+ return { rateLimiter: createRegisterRateLimiter(rateLimiterConfig), highWater: new LruMap(PROMOTE_HIGHWATER_MAX_KEYS) };
1185
+ }
1186
+ /**
1187
+ * Decode a `promote`-protocol frame as a {@link PromotionNoticeV1} or {@link DemotionNoticeV1} (try one,
1188
+ * then the other), or `undefined` if it is neither. The two shapes are disjoint — a promotion carries
1189
+ * `fromTier`/`toTier`, a demotion carries `parentCohortCoord` — so the structural validators cleanly
1190
+ * discriminate.
1191
+ */
1192
+ export function decodeInboundNotice(frame, maxBytes) {
1193
+ const decoded = decodeCohortMessage(frame, maxBytes);
1194
+ const promotion = tryValidate(() => validatePromotionNoticeV1(decoded));
1195
+ if (promotion !== undefined) {
1196
+ return { kind: "promotion", notice: promotion };
1197
+ }
1198
+ const demotion = tryValidate(() => validateDemotionNoticeV1(decoded));
1199
+ if (demotion !== undefined) {
1200
+ return { kind: "demotion", notice: demotion };
1201
+ }
1202
+ return undefined;
1203
+ }
1204
+ /**
1205
+ * Per-coord minimum interval (ms) between membership refetches on the inbound `promote` path. Caps the
1206
+ * amplification a flood of forged notices can drive: a stream of verify-misses triggers at most one
1207
+ * `source.fetch()` per coord per this window, while a cold cache / membership rotation still re-fetches
1208
+ * once it elapses (eventual refetch preserved). 60 s mirrors the anti-DoS rate window.
1209
+ */
1210
+ export const PROMOTE_REFETCH_MIN_INTERVAL_MS = 60_000;
1211
+ /**
1212
+ * Verify an inbound notice's threshold signature against the cohort `MembershipCertV1` for
1213
+ * `target.servedCoord` and, on success, apply it to the target's promotion lifecycle. Returns:
1214
+ *
1215
+ * - `"dropped"` — no local engine serves the notice's `(topic, tier)` (e.g. a demotion arriving at a
1216
+ * parent that does not track the child); nothing to apply to.
1217
+ * - `"untrusted"` — the `signers` are not a `≥ minSigs` subset of the cohort cert, or the multisig does
1218
+ * not verify (a forged single-signer / short-quorum notice); local state is left unchanged.
1219
+ * - `"applied"` — verified and applied.
1220
+ *
1221
+ * The payload is rebuilt with the canonical `sig/payloads` image the signer used — never re-canonicalized
1222
+ * independently. The verifier owns the cert lookup; this function never re-verifies inside the apply step
1223
+ * (db-core trusts this gate).
1224
+ *
1225
+ * **Bounded refetch (anti-amplification).** Both verify calls pass a {@link PROMOTE_REFETCH_MIN_INTERVAL_MS}
1226
+ * refetch bound, so a stream of forged notices drives at most **one** membership `source.fetch()` per coord
1227
+ * per interval rather than one dial per message. Eventual refetch is preserved (full suppression was
1228
+ * rejected: a legitimate sibling-adopt / demotion-to-parent notice whose cohort cert is not yet locally
1229
+ * cached must still be able to fetch it once — see `live-tier.spec.ts` test 4). A node that has cached its
1230
+ * own cohort cert via `onCertPublished` verifies a sibling-adopt notice from cache with zero fetches; a
1231
+ * cold-cache receiver pays one bounded fetch. Per `cohort-topic-promote-handler-verify-amplification`.
1232
+ */
1233
+ export async function verifyAndApplyNotice(inbound, target, verifier, now) {
1234
+ if (target === undefined) {
1235
+ return "dropped";
1236
+ }
1237
+ let signers;
1238
+ let sig;
1239
+ try {
1240
+ signers = inbound.notice.signers.map(b64urlToBytes);
1241
+ sig = b64urlToBytes(inbound.notice.thresholdSig);
1242
+ }
1243
+ catch {
1244
+ return "untrusted"; // a signer / sig that is not valid base64url cannot verify
1245
+ }
1246
+ // Narrow on `inbound` (not a destructured `notice`) so the tier field and apply hook are typed per kind.
1247
+ if (inbound.kind === "promotion") {
1248
+ const payload = promotionNoticeSigningPayload(inbound.notice);
1249
+ const result = await verifier.verifyMessage(signers, target.servedCoord, inbound.notice.fromTier, payload, sig, { minRefetchIntervalMs: PROMOTE_REFETCH_MIN_INTERVAL_MS, now });
1250
+ if (result !== "verified") {
1251
+ return "untrusted";
1252
+ }
1253
+ target.applyPromotionNotice(inbound.notice, now);
1254
+ return "applied";
1255
+ }
1256
+ const payload = demotionNoticeSigningPayload(inbound.notice);
1257
+ const result = await verifier.verifyMessage(signers, target.servedCoord, inbound.notice.tier, payload, sig, { minRefetchIntervalMs: PROMOTE_REFETCH_MIN_INTERVAL_MS, now });
1258
+ if (result !== "verified") {
1259
+ return "untrusted";
1260
+ }
1261
+ target.applyDemotionNotice(inbound.notice, now);
1262
+ return "applied";
1263
+ }
1264
+ /**
1265
+ * Full inbound `promote`-frame pipeline with the anti-abuse gate, exported so it is unit-testable without a
1266
+ * live node (`cohort-topic-promote-handler-verify-amplification`). Runs the cheapest checks first — each
1267
+ * step strictly cheaper than the next — so a flood of forged frames is shed before any signature / network
1268
+ * work:
1269
+ *
1270
+ * ```
1271
+ * decode → per-(peer,topic) rate limit → findServing → effectiveAt high-water → verify+apply
1272
+ * ```
1273
+ *
1274
+ * - **Rate limit** (`gate.rateLimiter`) keys on `(from, topicId)`; an over-rate peer is dropped before the
1275
+ * `findServing` map scan and the verify, so a peer cannot amplify junk into verify/network work.
1276
+ * - **High-water** (`gate.highWater`, per `(topicId, tier)`) drops a notice whose `effectiveAt` is at or
1277
+ * below the last *applied* one — a replay / out-of-order frame — before `verifyMessage`. It is advanced
1278
+ * **only** on an `"applied"` outcome, so a forged frame (which never verifies) cannot poison it.
1279
+ * - The receiver-side `cohortEpoch` is intentionally **not** gated on: the epoch rotates on every
1280
+ * membership change, so a legitimately in-flight notice can briefly carry the prior epoch right after a
1281
+ * rotation — making an epoch check a brittle, false-positive-prone filter. The rate limiter + high-water
1282
+ * are the load-bearing defenses; the bounded refetch (in {@link verifyAndApplyNotice}) caps the network
1283
+ * amplification on the verify itself.
1284
+ *
1285
+ * `from` is the dialing peer's substrate bytes ({@link peerIdToBytes}); the node handler converts the
1286
+ * libp2p `PeerId` before calling. One-way contract: the caller sends no ack regardless of outcome.
1287
+ */
1288
+ export async function handleInboundNotice(frame, from, registry, verifier, gate, now, maxBytes) {
1289
+ const inbound = decodeInboundNotice(frame, maxBytes);
1290
+ if (inbound === undefined) {
1291
+ log("promote: dropped an undecodable notice frame");
1292
+ return "undecodable";
1293
+ }
1294
+ const tier = inbound.kind === "promotion" ? inbound.notice.fromTier : inbound.notice.tier;
1295
+ const topicId = b64urlToBytes(inbound.notice.topicId);
1296
+ // Per-(peer, topic) rate limit — before the findServing scan and the verify.
1297
+ if (gate.rateLimiter.check(from, topicId, now).ok === false) {
1298
+ log("promote: rate-limited %s notice for topic %s tier %d", inbound.kind, inbound.notice.topicId, tier);
1299
+ return "rate-limited";
1300
+ }
1301
+ const target = registry.findServing(topicId, tier);
1302
+ if (target === undefined) {
1303
+ log("promote: dropped %s notice for topic %s tier %d (no serving engine)", inbound.kind, inbound.notice.topicId, tier);
1304
+ return "dropped";
1305
+ }
1306
+ // Freshness / replay gate: drop an at-or-below-high-water notice before the expensive verify.
1307
+ const waterKey = `${inbound.notice.topicId}|${tier}`;
1308
+ const water = gate.highWater.get(waterKey);
1309
+ if (water !== undefined && inbound.notice.effectiveAt <= water) {
1310
+ log("promote: stale %s notice for topic %s tier %d (effectiveAt %d <= high-water %d)", inbound.kind, inbound.notice.topicId, tier, inbound.notice.effectiveAt, water);
1311
+ return "stale";
1312
+ }
1313
+ const outcome = await verifyAndApplyNotice(inbound, target, verifier, now);
1314
+ if (outcome === "applied") {
1315
+ // Advance the high-water only on a *verified-and-applied* notice, so a forged frame cannot poison it.
1316
+ gate.highWater.set(waterKey, inbound.notice.effectiveAt);
1317
+ }
1318
+ else {
1319
+ log("promote: %s %s notice for topic %s tier %d", outcome, inbound.kind, inbound.notice.topicId, tier);
1320
+ }
1321
+ return outcome;
1322
+ }
1323
+ /**
1324
+ * The cohort coords a notice is broadcast to over the `promote` protocol: always the cohort around
1325
+ * `servedCoord` (siblings adopt the state); for a demotion, additionally the parent cohort coord (so the
1326
+ * parent's `childCohortCount` bookkeeping converges). Pure — exported for testing the fan-out targets.
1327
+ */
1328
+ export function noticeBroadcastCoords(notice, servedCoord) {
1329
+ if ("parentCohortCoord" in notice) {
1330
+ return [servedCoord, b64urlToBytes(notice.parentCohortCoord)];
1331
+ }
1332
+ return [servedCoord];
1333
+ }
1334
+ // --- helpers ---
1335
+ /**
1336
+ * Cross-check the cohort FRET routed the activity to against the locally recomputed assembly around
1337
+ * `servedCoord`. A mismatch (a slightly stale routing table) is logged; the recomputed assembly is
1338
+ * trusted, so renewal / gossip / signing — which run outside the activity callback — stay consistent.
1339
+ */
1340
+ function crossCheckCohort(fret, wantK, servedCoord, fretCohort) {
1341
+ const assembled = fret.assembleCohort(servedCoord, wantK);
1342
+ if (!sameMemberSet(fretCohort, assembled)) {
1343
+ log("cohort cross-check mismatch at coord %s: FRET-routed=%o assembled=%o; trusting the recomputed assembly", bytesToB64url(servedCoord), fretCohort, assembled);
1344
+ }
1345
+ }
1346
+ /** Positional equality over two string lists (the first-`k − x` rotation-change check). */
1347
+ function sameStringOrder(a, b) {
1348
+ if (a.length !== b.length) {
1349
+ return false;
1350
+ }
1351
+ for (let i = 0; i < a.length; i++) {
1352
+ if (a[i] !== b[i]) {
1353
+ return false;
1354
+ }
1355
+ }
1356
+ return true;
1357
+ }
1358
+ /** Set equality over two peer-id-string lists (order-independent). */
1359
+ function sameMemberSet(a, b) {
1360
+ if (a.length !== b.length) {
1361
+ return false;
1362
+ }
1363
+ const seen = new Set(a);
1364
+ return b.every((m) => seen.has(m));
1365
+ }
1366
+ /** Count topics this member is currently `primary` for at `tier` (the willingness budget input). */
1367
+ function countPrimaryTopics(store, self, tier) {
1368
+ const seen = new Set();
1369
+ for (const rec of store.listAll()) {
1370
+ if (rec.tier === tier && bytesToB64url(rec.primary) === bytesToB64url(self)) {
1371
+ seen.add(bytesToB64url(rec.topicId));
1372
+ }
1373
+ }
1374
+ return seen.size;
1375
+ }
1376
+ /** Resolve a topic's op tier from any held record (0 if none yet). */
1377
+ function tierOfTopic(store, topicId) {
1378
+ const recs = store.listByTopic(topicId);
1379
+ return (recs.length > 0 ? recs[0].tier : 0);
1380
+ }
1381
+ async function registerProtocolHandlers(node, protocols, registry, dispatchRegister, signEndorse, verifier, promoteGate, gossipTransport, publishSink, membershipSource, selfCoord, maxBytes) {
1382
+ await Promise.all([
1383
+ // register: a direct dial carries either a RegisterV1 (re-attach walk fallback) or a RenewV1 (ping).
1384
+ node.handle(protocols.register, makeFrameHandler(async (frame) => {
1385
+ const decoded = decodeCohortMessage(frame, maxBytes);
1386
+ const renew = tryValidate(() => validateRenewV1(decoded));
1387
+ if (renew !== undefined) {
1388
+ return encodeCohortMessage(resolveRenew(registry, renew, Date.now()), maxBytes);
1389
+ }
1390
+ const reg = validateRegisterV1(decoded);
1391
+ // Direct dial (not FRET-routed): no cohort member list to cross-check against.
1392
+ const reply = await dispatchRegister(reg, undefined, Date.now());
1393
+ return encodeCohortMessage(reply, maxBytes);
1394
+ }, maxBytes)),
1395
+ // cohort-gossip: feed inbound gossip into the shared transport (one-way). It fans the frame to
1396
+ // every coord engine's bus; per-bus epoch matching governs which engine merges the record deltas.
1397
+ node.handle(protocols.gossip, makeFrameHandler(async (frame, from) => {
1398
+ gossipTransport.deliver(from.toString(), frame);
1399
+ return undefined;
1400
+ }, maxBytes)),
1401
+ // promote: threshold-signed promotion/demotion notices (one-way, gossip-style fan-out). The dialing
1402
+ // peer arrives as `from`, so the handler can gate per-(peer, topic) before any expensive work. The
1403
+ // full pipeline (rate limit → findServing → effectiveAt high-water → verify+apply, bounded
1404
+ // refetch) lives in the exported `handleInboundNotice`; it logs and never throws on the stream.
1405
+ node.handle(protocols.promote, makeFrameHandler(async (frame, from) => {
1406
+ await handleInboundNotice(frame, peerIdToBytes(from), registry, verifier, promoteGate, Date.now(), maxBytes);
1407
+ return undefined; // one-way: match the gossip-style fan-out (no ack frame)
1408
+ }, maxBytes)),
1409
+ // membership: serve this node's latest published cert; cache any cert the requester returns.
1410
+ node.handle(protocols.membership, makeFrameHandler(async (frame) => {
1411
+ void frame; // request frame is the raw coord; this node serves its own cohort cert
1412
+ const latest = publishSink.latest();
1413
+ if (latest !== undefined) {
1414
+ membershipSource.cache(selfCoord, latest);
1415
+ }
1416
+ return latest ?? new Uint8Array(0);
1417
+ }, maxBytes)),
1418
+ // sign: per-member endorsement for threshold-signature assembly. Validate the request, run the
1419
+ // endorsement policy, and reply with this node's peer-key signature over the request payload (or a
1420
+ // refusal). One Ed25519 sign and nothing more — the cohort + epoch gate bounds who we sign for.
1421
+ node.handle(protocols.sign, makeFrameHandler(async (frame, from) => {
1422
+ const request = validateSignRequestV1(decodeCohortMessage(frame, maxBytes));
1423
+ const reply = await signEndorse(request, from.toString());
1424
+ return encodeCohortMessage(reply, maxBytes);
1425
+ }, maxBytes)),
1426
+ ]);
1427
+ }
1428
+ /** Wrap a frame handler in the read-one / reply-one libp2p stream lifecycle. */
1429
+ function makeFrameHandler(handle, maxBytes) {
1430
+ return (stream, connection) => {
1431
+ void (async () => {
1432
+ try {
1433
+ const frame = await readAllBounded(stream, maxBytes);
1434
+ const reply = await handle(frame, connection.remotePeer);
1435
+ if (reply !== undefined) {
1436
+ stream.send(reply);
1437
+ }
1438
+ await stream.close();
1439
+ }
1440
+ catch {
1441
+ try {
1442
+ stream.abort(new Error("cohort-topic handler error"));
1443
+ }
1444
+ catch {
1445
+ /* already aborted */
1446
+ }
1447
+ }
1448
+ })();
1449
+ };
1450
+ }
1451
+ /** Run `fn`, returning `undefined` if it throws (used to try one validator then fall through). */
1452
+ function tryValidate(fn) {
1453
+ try {
1454
+ return fn();
1455
+ }
1456
+ catch {
1457
+ return undefined;
1458
+ }
1459
+ }
1460
+ //# sourceMappingURL=host.js.map