@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,2175 @@
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
+
56
+ import type { Libp2p } from "libp2p";
57
+ import type { Connection, PeerId, PrivateKey, Stream } from "@libp2p/interface";
58
+ import type { FretService } from "p2p-fret";
59
+ import { hashPeerId, readAllBounded } from "p2p-fret";
60
+ import {
61
+ RingHash,
62
+ createRegistrationStore,
63
+ createSlotAssigner,
64
+ createCohortGossipBus,
65
+ createWillingnessCheck,
66
+ createPromotionLifecycle,
67
+ createColdStartManager,
68
+ createTrafficCounters,
69
+ createRenewalCohortSide,
70
+ createMembershipVerifier,
71
+ createMembershipSourceRouter,
72
+ createMembershipCertPublisher,
73
+ createCohortSigner,
74
+ createCohortMemberEngine,
75
+ createCohortTopicService,
76
+ createLoadBarometer,
77
+ createTierAddressing,
78
+ createRegisterRateLimiter,
79
+ createCorrelationReplayGuard,
80
+ createTopicBudget,
81
+ createBootstrapEvidence,
82
+ LruMap,
83
+ coreProfile,
84
+ DEFAULT_MIN_SIGS,
85
+ DEFAULT_MAX_NO_POW_TIER,
86
+ DEFAULT_TRAFFIC_WINDOW_SECONDS,
87
+ DEFAULT_TTL_MS,
88
+ bytesToB64url,
89
+ b64urlToBytes,
90
+ bytesEqual,
91
+ compareBytes,
92
+ encodeCohortMessage,
93
+ decodeCohortMessage,
94
+ membershipCertSignable,
95
+ membershipCertSigningPayload,
96
+ toCohortTopicSummary,
97
+ validateRegisterV1,
98
+ validateRenewV1,
99
+ validateSignRequestV1,
100
+ validateSignReplyV1,
101
+ validatePromotionNoticeV1,
102
+ validateDemotionNoticeV1,
103
+ registerSigningPayload,
104
+ renewSigningPayload,
105
+ cohortGossipSigningPayload,
106
+ promotionNoticeSigningPayload,
107
+ demotionNoticeSigningPayload,
108
+ type BootstrapEvidence,
109
+ type BootstrapEvidenceDeps,
110
+ type CohortGossipV1,
111
+ type CohortGossipSignable,
112
+ type CohortTopicService,
113
+ type CohortMemberEngine,
114
+ type CohortSnapshot,
115
+ type CohortSnapshotView,
116
+ type CohortSigner,
117
+ type CohortView,
118
+ type CorrelationReplayGuardConfig,
119
+ type DemotionNoticeV1,
120
+ type Forwarder,
121
+ type ITopicRouter,
122
+ type MembershipCertPublisher,
123
+ type MembershipCertV1,
124
+ type MembershipVerifier,
125
+ type NodeProfile,
126
+ type ParticipantSigner,
127
+ type PromotionConfig,
128
+ type PromotionNoticeV1,
129
+ type RegisterRateLimiter,
130
+ type RegisterRateLimiterConfig,
131
+ type RegisterReplyV1,
132
+ type RegisterV1,
133
+ type RegistrationRecord,
134
+ type RenewReplyV1,
135
+ type RenewV1,
136
+ type RingCoord,
137
+ type RotationAttestation,
138
+ type SignKind,
139
+ type SignReplyV1,
140
+ type SignRequestV1,
141
+ type Tier,
142
+ type TopicBudgetConfig,
143
+ type TopicTrafficV1,
144
+ type IMembershipSource,
145
+ type TrustRoot,
146
+ } from "@optimystic/db-core";
147
+ import { randomBytes } from "@libp2p/crypto";
148
+ import { peerIdFromString } from "@libp2p/peer-id";
149
+ import { FretTopicRouter } from "./topic-router.js";
150
+ import { FretCohortGossipTransport, type CohortPeerResolver } from "./cohort-gossip-transport.js";
151
+ import { buildCohortGossip, createPendingDeltas, DEFAULT_GOSSIP_INTERVAL_MS } from "./cohort-gossip-driver.js";
152
+ import { FretMembershipSource } from "./membership-source.js";
153
+ import { FretMembershipPublishSink } from "./membership-publish-sink.js";
154
+ import { FretCohortThresholdCrypto, createVerifyOnlyThresholdCrypto } from "./threshold-crypto.js";
155
+ import { FretTrustAnchor } from "./fret-trust-anchor.js";
156
+ import { FretSizeEstimator } from "./size-estimator.js";
157
+ import { peerIdToBytes, bytesToPeerIdString } from "./peer-codec.js";
158
+ import { signPeer, verifyPeerSig } from "./peer-sig.js";
159
+ import { createPoWVerifier, createReputationVerifier, type BootstrapReputationView } from "./bootstrap-evidence-verifiers.js";
160
+ import { createParentReferenceVerifier, createDefaultParentTopicView, type BootstrapParentTopicView } from "./bootstrap-parent-reference.js";
161
+ import { createBootstrapEvidenceBuilder } from "./bootstrap-evidence-builder.js";
162
+ import { DEFAULT_COHORT_TOPIC_PROTOCOLS, cohortTopicProtocolList, type CohortTopicProtocols } from "./protocols.js";
163
+ import { requestResponse, DEFAULT_STREAM_MAX_BYTES } from "./stream-util.js";
164
+ import { createLogger } from "../logger.js";
165
+
166
+ const log = createLogger("cohort-topic");
167
+
168
+ export interface CohortTopicHostOptions {
169
+ /** Per-node tier profile. Default {@link coreProfile}. */
170
+ readonly profile?: NodeProfile;
171
+ /** Network name (namespaces the four protocol IDs); default uses the canonical IDs. */
172
+ readonly protocols?: CohortTopicProtocols;
173
+ /** Requested cohort size `wantK`. Default 16. */
174
+ readonly wantK?: number;
175
+ /** Threshold signers `minSigs = k − x`. Default {@link DEFAULT_MIN_SIGS}. */
176
+ readonly minSigs?: number;
177
+ /** Fan-out per tier `F`. Default 16. */
178
+ readonly fanout?: number;
179
+ /** Per-frame ceiling. Default {@link DEFAULT_STREAM_MAX_BYTES}. */
180
+ readonly maxBytes?: number;
181
+ /**
182
+ * Gossip-round cadence in ms (the periodic driver tick). Default {@link DEFAULT_GOSSIP_INTERVAL_MS}
183
+ * (~one round). Each tick drives every live {@link CoordEngine}'s gossip broadcast, TTL sweep,
184
+ * membership-cert refresh, and demotion check; the refresh (5 min) and demotion hysteresis (5 min)
185
+ * self-gate on elapsed time, so a fast tick is safe.
186
+ */
187
+ readonly gossipIntervalMs?: number;
188
+ /**
189
+ * The node's libp2p Ed25519 private key. Required for the live participant signer: register/renew
190
+ * bodies are peer-key-signed over their canonical image, and inbound register/`reattach` signatures
191
+ * are verified against the participant's claimed peer id. libp2p does not expose the key off
192
+ * `node.peerId`, so it is threaded explicitly (mirrors `clusterMember` / `DisputeService`, sourced
193
+ * from `options.privateKey ?? generateKeyPair('Ed25519')` in `libp2p-node-base.ts`). When omitted,
194
+ * the host falls back to the interim empty-string signer (one-time warn) and does **not** enforce
195
+ * inbound participant-signature verification, so unit tests compose without a key.
196
+ */
197
+ readonly privateKey?: PrivateKey;
198
+ /**
199
+ * Anti-DoS guard wiring (gap 6). The per-{@link CoordEngine} guards (rate limiter, replay guard,
200
+ * topic budget) and the node-level bootstrap-evidence policy are always constructed with documented
201
+ * defaults; this lets a caller (or a test) tune them. Omit for production defaults.
202
+ */
203
+ readonly antiDos?: CohortTopicAntiDosOptions;
204
+ /**
205
+ * Promotion / demotion lifecycle tuning (`cap_promote`, hysteresis windows, slope lookahead) applied
206
+ * to every {@link CoordEngine}'s {@link PromotionLifecycle}. Omit for the production defaults
207
+ * (`cap_promote = 64`, …); supply a lowered `capPromote` to drive promotion with a small participant
208
+ * count (the live-tier e2e). The single-cohort milestone never overrides `childCohortCount` / `treeTier`
209
+ * — those stay coord-derived — so only the count/load thresholds are tunable here.
210
+ */
211
+ readonly promotion?: PromotionConfig;
212
+ /**
213
+ * Optional committed-state existence reader backing the **committed-tier (T0/T1)** parent-reference
214
+ * bootstrap-evidence check (`cohort-topic-bootstrap-parent-reference`). Given `coord_0(parentTopicId)`,
215
+ * returns whether the node locally knows the parent topic's committed cohort exists. Threaded from
216
+ * node-base when a coord-keyed committed-membership index is available; when omitted, T0/T1 parent-ref
217
+ * existence fails closed (a FRET-cached cert must not vouch for committed-tier existence — committed-tier
218
+ * integrity) while T2/T3 parent-ref still consults the FRET membership cache. See
219
+ * {@link createDefaultParentTopicView}.
220
+ */
221
+ readonly committedParentTopicReader?: (coord: RingCoord) => boolean;
222
+ /**
223
+ * The participant's initial trust roots: the genesis-block-related cohorts, validated against the
224
+ * genesis block hash **out-of-band by the caller before seeding** (`docs/cohort-topic.md`
225
+ * §Bootstrapping trust). They are the base case of every attestation chain — a cert matching a root by
226
+ * `(coord, epoch, member-set)` is trusted directly, ahead of the FRET-ring direct anchor — so a forged
227
+ * cert for a genesis cohort is rejected even before the ring is consulted. Threaded straight into
228
+ * `createMembershipVerifier({ trustRoots })`.
229
+ *
230
+ * **Network-config, empty by default.** The concrete genesis-cohort set is a property of a specific
231
+ * network's genesis block; this is the typed seam, not a fabricated value. Omitted (the default `[]`)
232
+ * the verifier behaves exactly as with no roots — the chain bottoms out at the direct anchor / TOFU, so
233
+ * a network with no genesis cohort configured is never broken. Do **not** seed fake roots here.
234
+ */
235
+ readonly genesisTrustRoots?: readonly TrustRoot[];
236
+ /**
237
+ * Wall clock (ms) the `/sign` membership endorser uses for its `stabilizedAt` far-future sanity bound
238
+ * (a member refuses to co-sign a cert whose `stabilizedAt` is more than a few seconds ahead of its own
239
+ * clock — `cohort-topic-sign-endorsement-payload-binding`). Defaults to {@link Date.now}, the right
240
+ * choice for a production node. A **virtual-time** test harness (which drives publish `stabilizedAt`
241
+ * from arbitrary explicit timestamps rather than wall clock) injects a clock that does not trip the
242
+ * bound — e.g. `() => Number.POSITIVE_INFINITY` — so its synthetic future timestamps are not rejected.
243
+ */
244
+ readonly now?: () => number;
245
+ }
246
+
247
+ // The reputation-view shape the bootstrap-evidence referee verifier consults — `{ isBanned, getScore }`
248
+ // (a subset of `IPeerReputation`; `PeerReputationService` satisfies it directly). Defined with the
249
+ // verifier it backs and re-exported here so existing importers keep resolving it off the host module.
250
+ export type { BootstrapReputationView } from "./bootstrap-evidence-verifiers.js";
251
+
252
+ /**
253
+ * Anti-DoS wiring overrides for a {@link CohortTopicHost} (`docs/cohort-topic.md` §Anti-DoS).
254
+ *
255
+ * The rate limiter, replay guard, and topic budget are **per-served-coord** (they key on
256
+ * `(peer, topic)` / per-cohort topic state, which is coord-scoped) — one set is built per
257
+ * {@link CoordEngine} from these configs. The bootstrap-evidence policy is **node-level** (a
258
+ * tier→verifier policy with no per-coord state) and is shared by every engine.
259
+ */
260
+ export interface CohortTopicAntiDosOptions {
261
+ /** Per-`(peer, topic)` register rate limiter. Default `register_rate_per_peer = 4 / 60 s`, exponential back-off. */
262
+ readonly rateLimiter?: RegisterRateLimiterConfig;
263
+ /** Correlation-id freshness + replay guard. Default `maxAge = 60 s`, `futureSkew = 5 s`. */
264
+ readonly replayGuard?: CorrelationReplayGuardConfig;
265
+ /** Per-cohort forwarder-state budget. Default `topics_max = 2048`, LRU by participant count. */
266
+ readonly topicBudget?: TopicBudgetConfig;
267
+ /**
268
+ * Bootstrap-evidence verifiers for cold-root instantiation. db-core embeds no PoW / reputation /
269
+ * committed-work scheme; inject the real checks here. Any verifier supplied wins over the defaults
270
+ * below; the gate is **never** left undefined (an unset gate means cold-root bootstrap is
271
+ * unauthenticated — `docs/cohort-topic.md` §Anti-DoS).
272
+ */
273
+ readonly bootstrapEvidence?: BootstrapEvidenceDeps;
274
+ /**
275
+ * Optional peer-reputation view backing the **reputation-endorsement** evidence path: a *referee*
276
+ * peer-key-signs the bootstrap bound image, and the cohort admits it iff the signature verifies and
277
+ * the referee is sufficiently reputable here (not banned **and** below the deprioritize threshold).
278
+ * `PeerReputationService` satisfies this `{ isBanned, getScore }` shape directly. Supplying it (or any
279
+ * `bootstrapEvidence` override) makes the gate **configured**: the real PoW verifier runs, the referee
280
+ * verifier backs the reputation path, the real signed-parent-reference verifier backs the parent-ref path
281
+ * (permissive-but-logged only at T0/T1 when this host has no committed-existence backing — see
282
+ * {@link createBootstrapEvidencePolicy}), and any otherwise-unfilled verifier fails closed — so a
283
+ * banned/low-rep referee cannot slip the T2/T3 `PoW || reputation || parent-ref` disjunction. When omitted
284
+ * (and no `bootstrapEvidence` verifier is supplied), the gate is permissive-but-logged (the
285
+ * entirely-unconfigured interim node).
286
+ */
287
+ readonly reputation?: BootstrapReputationView;
288
+ /**
289
+ * Proof-of-work difficulty (leading zero bits) the real PoW verifier requires. Default the db-core
290
+ * `DEFAULT_POW_DIFFICULTY_BITS`. A low value (e.g. `0`) keeps minting fast/deterministic in tests.
291
+ * Applies to both the cohort-side PoW verifier and this node's participant-side PoW builder.
292
+ */
293
+ readonly powDifficultyBits?: number;
294
+ /**
295
+ * Strict "sufficient reputation" cutoff for a bootstrap referee — a referee with `getScore < this` is
296
+ * accepted. Default the reputation service's `deprioritize` threshold.
297
+ */
298
+ readonly deprioritizeThreshold?: number;
299
+ /**
300
+ * Existence view backing the **signed parent-reference** evidence path (`verifyParentReference`). A test
301
+ * seam: when supplied it overrides *which* existence view the real verifier consults (the host default is
302
+ * built from the FRET membership cache + addressing + the optional
303
+ * {@link CohortTopicHostOptions.committedParentTopicReader}). It does **not** by itself make the gate
304
+ * "configured" — only a `reputation` view or a `bootstrapEvidence` override flips that (see
305
+ * {@link createBootstrapEvidencePolicy}); an unconfigured host stays permissive and never consults this
306
+ * view. Pair it with `reputation`/`bootstrapEvidence` to exercise the real parent-reference verifier. See
307
+ * {@link createDefaultParentTopicView} / {@link createParentReferenceVerifier}.
308
+ */
309
+ readonly parentTopicView?: BootstrapParentTopicView;
310
+ }
311
+
312
+ /**
313
+ * One cohort the node serves, bound to a FRET-routed coordinate. Owns the per-coord slice of cohort
314
+ * state; the node-wide collaborators are injected (see {@link CoordEngineContext}).
315
+ */
316
+ export interface CoordEngine {
317
+ /** The served ring coordinate this engine is the cohort for. */
318
+ readonly servedCoord: RingCoord;
319
+ /** Tree tier `d` the served coord was instantiated at (fixed by the first register). */
320
+ readonly treeTier: number;
321
+ /** Cohort-side register/renew/sweep engine driven by the protocol handlers + activity callback. */
322
+ readonly engine: CohortMemberEngine;
323
+ /** The FRET-assembled cohort around {@link servedCoord} (self prepended, deduped) + epoch. */
324
+ cohort(): CohortSnapshotView;
325
+ /**
326
+ * The cohort member peer-id strings this engine served under `epoch` (the **current** or the
327
+ * immediately-**prior** observed epoch), or `undefined` if it tracked no such epoch. Drives the `/sign`
328
+ * `"rotation"` endorsement gate: a member endorses a hand-off from `prevEpoch` only when it was a member
329
+ * of the cohort at that epoch. Returns `undefined` past the two-deep history (a rapid double rotation —
330
+ * the requester then re-anchors). See `cohort-topic-trust-anchor-rotation-production`.
331
+ */
332
+ cohortIdentityAt(epoch: Uint8Array): readonly string[] | undefined;
333
+ /** True iff this engine currently holds any registration record (a cold probe leaves it empty). */
334
+ hasState(): boolean;
335
+ /** True iff this engine holds the record for `(topicId, participantId)` — the renewal lookup key. */
336
+ holds(topicId: Uint8Array, participantId: Uint8Array): boolean;
337
+ /**
338
+ * This cohort's locally-known direct registration records for `topicId` (the cohort-side read the
339
+ * matchmaking `QueryV1` handler / aggregate-count producer serve from — `docs/matchmaking.md`
340
+ * §Seeker query). A renewed record is live; a TTL-swept one is gone after the next `gossipRound`.
341
+ */
342
+ records(topicId: Uint8Array): readonly RegistrationRecord[];
343
+ /**
344
+ * This cohort's current gossip-derived traffic barometer for `topicId` (own last-published counts +
345
+ * siblings' last-gossiped summaries + `directParticipants` from the store). The matchmaking `QueryV1`
346
+ * reply attaches it and the seeker hang-out decision consumes it (`docs/matchmaking.md` §Hang-out vs.
347
+ * continue). Non-mutating — a synchronous read over the same in-memory state a `gossipRound` mutates,
348
+ * lagging at most one gossip round (it reflects the last frozen summaries, never raw mid-round counts).
349
+ */
350
+ topicTraffic(topicId: Uint8Array): TopicTrafficV1;
351
+ /**
352
+ * Publish a fresh threshold-signed `MembershipCertV1` on a cohort-membership-change / stabilization
353
+ * event (republishes only when the first `k − x` members changed). Returns the cert if published.
354
+ * Resolves `undefined` if no republish was needed or the quorum was unreachable this round.
355
+ */
356
+ onStabilized(now: number): Promise<MembershipCertV1 | undefined>;
357
+ /**
358
+ * Periodic membership-cert refresh hook for the gossip-cadence driver to call (republishes once
359
+ * `T_membership_refresh` has elapsed). Returns the cert if (re)published, else `undefined`.
360
+ */
361
+ pumpMembership(now: number): Promise<MembershipCertV1 | undefined>;
362
+ /**
363
+ * One gossip round: TTL-sweep stale records (firing the `evicted` deltas), freeze the per-topic
364
+ * traffic summaries, drain the accumulated record/eviction deltas, and broadcast a signed
365
+ * {@link CohortGossipV1} (willingness/load/traffic + deltas) to the cohort. Returns the broadcast
366
+ * frame, or `undefined` when the engine is idle (no resident topics and no deltas → nothing to send).
367
+ */
368
+ gossipRound(now: number): Promise<CohortGossipV1 | undefined>;
369
+ /**
370
+ * Time-driven demotion check across this engine's resident topics; broadcasts any threshold-signed
371
+ * {@link DemotionNoticeV1} the lifecycle returns (root tier-0 cohorts never demote). No-op without a
372
+ * signing key (the verify-only per-coord signer cannot assemble a notice).
373
+ */
374
+ demotionTick(now: number): Promise<void>;
375
+ /** The merged per-member gossip view (willingness / load / per-topic summaries) for this cohort. */
376
+ cohortView(): CohortView;
377
+ /** True iff this engine currently serves `topicId` (holds a record or a cold-start forwarder for it). */
378
+ servesTopic(topicId: Uint8Array): boolean;
379
+ /**
380
+ * Whether `topicId` currently occupies a slot in this coord's anti-DoS topic budget. A drained topic
381
+ * stays resident-but-cold (`{@link budgetParticipantCount} === 0`) until a new topic reuses its slot;
382
+ * after that reuse `budgetHasTopic` is `false`. Test/diagnostic introspection over the per-coord budget
383
+ * (distinct from {@link servesTopic}, which a never-demoted cold-start forwarder keeps `true`).
384
+ */
385
+ budgetHasTopic(topicId: Uint8Array): boolean;
386
+ /**
387
+ * The direct-participant count the topic budget last recorded for a resident `topicId` (its LRU
388
+ * eviction key), or `undefined` when `topicId` holds no budget slot. A resident reporting `0` has
389
+ * drained and is the next coldest-evictable candidate. Test/diagnostic introspection.
390
+ */
391
+ budgetParticipantCount(topicId: Uint8Array): number | undefined;
392
+ /**
393
+ * The cold-start {@link Forwarder} this engine instantiated for `topicId`, or `undefined` if none.
394
+ * Exposes the parent-link lifecycle (`awaiting_parent` → `serving`) for the cold-start wiring (gap 7).
395
+ */
396
+ forwarder(topicId: Uint8Array): Forwarder | undefined;
397
+ /** Whether `topicId` is in promoted mode here — reflects both locally-originated and remotely-applied state. */
398
+ isPromoted(topicId: Uint8Array): boolean;
399
+ /** Adopt a verified promotion notice into this cohort's local state (see {@link NoticeApplyTarget}). */
400
+ applyPromotionNotice(notice: PromotionNoticeV1, now: number): void;
401
+ /** Adopt a verified demotion notice into this cohort's local state (see {@link NoticeApplyTarget}). */
402
+ applyDemotionNotice(notice: DemotionNoticeV1, now: number): void;
403
+ /** Tear down the per-coord gossip subscription. */
404
+ close(): void;
405
+ }
406
+
407
+ /** Lazy `servedCoord → CoordEngine` registry: one engine per coord FRET routes to this node. */
408
+ export interface CoordRegistry {
409
+ /**
410
+ * The {@link CoordEngine} for `coord`, creating + caching it on first touch. `treeTier` and
411
+ * `participantCoord` seed a freshly-created engine's coord-derived tier inputs (ignored if the
412
+ * engine already exists). Synchronous, so concurrent activity callbacks for the same coord share
413
+ * one engine without a second being constructed.
414
+ */
415
+ forCoord(coord: RingCoord, treeTier: number, participantCoord: Uint8Array): CoordEngine;
416
+ /** The engine holding the record for `(topicId, participantId)`, or `undefined` (renewal dispatch). */
417
+ findHolder(topicId: Uint8Array, participantId: Uint8Array): CoordEngine | undefined;
418
+ /**
419
+ * The already-instantiated engine for `coord`, or `undefined` — a pure lookup that (unlike
420
+ * {@link forCoord}) never creates one. The `/sign` `"rotation"` endorsement gate uses it to consult this
421
+ * node's prior-epoch membership for the requested coord without spuriously instantiating a cohort it
422
+ * does not serve.
423
+ */
424
+ findByCoord(coord: RingCoord): CoordEngine | undefined;
425
+ /**
426
+ * The engine serving `topicId` at `treeTier`, or `undefined` (inbound promote/demote notice dispatch).
427
+ * A served coord embeds `(tier, topic)`, so at most one engine matches — the cohort the notice's
428
+ * signers belong to. `undefined` means this node serves no such cohort (e.g. a demotion arriving at a
429
+ * parent that does not track the child), so the notice is dropped rather than applied.
430
+ */
431
+ findServing(topicId: Uint8Array, treeTier: number): CoordEngine | undefined;
432
+ /** Every live engine (stop + sweep). */
433
+ all(): readonly CoordEngine[];
434
+ /** Close every engine's gossip subscription. */
435
+ close(): void;
436
+ }
437
+
438
+ /** A running cohort-topic node: the participant service plus the per-coord cohort registry, on one FRET node. */
439
+ export interface CohortTopicHost {
440
+ /** Participant-facing substrate API (node scope, not a coord). */
441
+ readonly service: CohortTopicService;
442
+ /** Per-served-coord cohort engines (driven by the protocol handlers + activity callback). */
443
+ readonly registry: CoordRegistry;
444
+ /** The four registered protocol IDs. */
445
+ readonly protocols: CohortTopicProtocols;
446
+ /**
447
+ * The node's effective {@link NodeProfile} (Edge / Core). Exposed so a layered application (reactivity)
448
+ * can apply the same profile gate the host configured its engines with (e.g. Edge ⇒ subscriber-only).
449
+ */
450
+ readonly profile: NodeProfile;
451
+ /**
452
+ * The intra-cohort gossip transport. Exposed so a layered application (reactivity push-state gossip) can
453
+ * ride its `broadcastOver` seam — the same one the promote-notice broadcast reuses — rather than standing
454
+ * up a second transport with duplicate cohort peer resolution.
455
+ */
456
+ readonly gossipTransport: FretCohortGossipTransport;
457
+ /**
458
+ * The node-level `promote`-handler anti-abuse gate (per-`(peer, topic)` rate limiter + per-`(topic, tier)`
459
+ * `effectiveAt` high-water). Exposed for test/diagnostic introspection over its bounded-memory state — the
460
+ * limiter's `size` and the `highWater` `LruMap` — and so the gossip-cadence sweep wiring is observable.
461
+ */
462
+ readonly promoteGate: PromoteGate;
463
+ /** Unregister the four protocols and tear down every coord engine. */
464
+ stop(): Promise<void>;
465
+ }
466
+
467
+ /** Node-wide collaborators injected into every {@link CoordEngine} (shared singletons). */
468
+ interface CoordEngineContext {
469
+ readonly hash: RingHash;
470
+ readonly addressing: ReturnType<typeof createTierAddressing>;
471
+ readonly slots: ReturnType<typeof createSlotAssigner>;
472
+ readonly barometer: ReturnType<typeof createLoadBarometer>;
473
+ readonly transport: FretCohortGossipTransport;
474
+ readonly profile: NodeProfile;
475
+ readonly selfMemberBytes: Uint8Array;
476
+ readonly wantK: number;
477
+ readonly minSigs: number;
478
+ readonly maxBytes: number;
479
+ /** Membership-cert sink the per-coord publisher serves through (node-wide; serves this node's cohort). */
480
+ readonly publishSink: FretMembershipPublishSink;
481
+ /**
482
+ * The node's libp2p key, threaded so each coord engine's threshold signer can add self's own chunk.
483
+ * Absent → key-less interim mode: the per-coord signer cannot assemble (the publisher/promotion paths
484
+ * are not driven in that mode), so threshold signing is unavailable until a key is supplied.
485
+ */
486
+ readonly privateKey?: PrivateKey;
487
+ /** FRET-backed router; a coord engine routes its cold-start forwarder→parent link over it (gap 7). */
488
+ readonly router: ITopicRouter;
489
+ /** Per-coord anti-DoS guard configs (one guard set is built per {@link CoordEngine}). */
490
+ readonly antiDos: {
491
+ readonly rateLimiter?: RegisterRateLimiterConfig;
492
+ readonly replayGuard?: CorrelationReplayGuardConfig;
493
+ readonly topicBudget?: TopicBudgetConfig;
494
+ };
495
+ /** Node-level bootstrap-evidence policy, shared by every engine (no per-coord state). */
496
+ readonly bootstrapEvidence: BootstrapEvidence;
497
+ /** Promotion-lifecycle config applied to every engine (test seam for a lowered `cap_promote`). */
498
+ readonly promotionConfig?: PromotionConfig;
499
+ /** Dial a cohort member's `/sign` RPC (the threshold-assembly collection seam). */
500
+ readonly dialSign: (peerIdStr: string, request: SignRequestV1) => Promise<SignReplyV1>;
501
+ /** FRET two-sided assembly around `coord`, self prepended + deduped, with a deterministic epoch. */
502
+ readonly cohortAround: (coord: RingCoord) => CohortSnapshotView;
503
+ /** Verify an inbound `RegisterV1`'s participant peer-key signature (live-signer mode only). */
504
+ readonly verifyRegisterSig?: (reg: RegisterV1) => boolean;
505
+ /** Verify a privileged `RenewV1`'s participant peer-key signature — gates both the `reattach`
506
+ * promotion and the `withdraw` eviction (live-signer mode only). */
507
+ readonly verifyParticipantSig?: (renew: RenewV1) => boolean;
508
+ /**
509
+ * Sign an outbound `CohortGossipV1` envelope with the node peer key over its canonical image
510
+ * ({@link cohortGossipSigningPayload}). Live-signer mode only; absent → gossip ships unsigned (interim,
511
+ * matching the participant signer) and the receiver's {@link verifyGossip} gate is likewise absent.
512
+ */
513
+ readonly signGossip?: (g: CohortGossipSignable) => Promise<string>;
514
+ /**
515
+ * Authenticate an inbound `CohortGossipV1` for a served `coord`: its `fromMember` peer-key signature
516
+ * must verify over the gossip image **and** `fromMember` must be a member of the cohort around `coord`.
517
+ * Live-signer mode only; absent → the bus skips the gate (key-less / unit composition).
518
+ */
519
+ readonly verifyGossip?: (g: CohortGossipV1, coord: RingCoord) => boolean;
520
+ /**
521
+ * Broadcast a freshly threshold-signed promotion/demotion notice this engine produced over the
522
+ * `promote` protocol — to the cohort around `servedCoord`, plus the parent coord for a demotion.
523
+ * Wired by the host; absent in key-less / unit composition.
524
+ */
525
+ readonly broadcastNotice?: (notice: PromotionNoticeV1 | DemotionNoticeV1, servedCoord: RingCoord) => void;
526
+ /**
527
+ * Hook a freshly published `MembershipCertV1` (from {@link CoordEngine.onStabilized} /
528
+ * {@link CoordEngine.pumpMembership}) into the node's verifier cache, so this node can verify inbound
529
+ * notices signed by its own cohort without a network refetch. Absent in unit composition.
530
+ */
531
+ readonly onCertPublished?: (cert: MembershipCertV1) => void;
532
+ }
533
+
534
+ /**
535
+ * Build and start a {@link CohortTopicHost} on `node` over `fret`. Registers the four protocols and
536
+ * sets FRET's activity handler. Async because the node's ring coordinate is derived via FRET's
537
+ * `hashPeerId`.
538
+ */
539
+ export async function createCohortTopicHost(node: Libp2p, fret: FretService, options: CohortTopicHostOptions = {}): Promise<CohortTopicHost> {
540
+ const profile = options.profile ?? coreProfile();
541
+ const protocols = options.protocols ?? DEFAULT_COHORT_TOPIC_PROTOCOLS;
542
+ const wantK = options.wantK ?? 16;
543
+ const minSigs = options.minSigs ?? DEFAULT_MIN_SIGS;
544
+ const fanout = options.fanout ?? 16;
545
+ const maxBytes = options.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
546
+ const gossipIntervalMs = options.gossipIntervalMs ?? DEFAULT_GOSSIP_INTERVAL_MS;
547
+
548
+ const hash = new RingHash();
549
+ const selfPeerStr = node.peerId.toString();
550
+ const selfMemberBytes = peerIdToBytes(node.peerId); // dialable member id
551
+ const selfCoord = await hashPeerId(node.peerId); // ring position P (the participant gossip handle)
552
+ const addressing = createTierAddressing(hash, fanout);
553
+
554
+ // --- cohort resolver (FRET two-sided assembly around a coord) ---
555
+ const resolver: CohortPeerResolver = {
556
+ cohortPeers(coord: RingCoord, wants: number): string[] {
557
+ return fret.assembleCohort(coord, wants);
558
+ },
559
+ };
560
+
561
+ // --- ports (node-wide singletons, injected into every coord engine) ---
562
+ const router = new FretTopicRouter(node, fret, { registerProtocol: protocols.register, maxBytes });
563
+ const sizeEstimator = new FretSizeEstimator(fret);
564
+ const gossipTransport = new FretCohortGossipTransport(node, resolver, { gossipProtocol: protocols.gossip, wants: wantK, selfPeerId: selfPeerStr });
565
+ const membershipSource = new FretMembershipSource(node, resolver, { membershipProtocol: protocols.membership, wants: wantK, maxBytes });
566
+ const publishSink = new FretMembershipPublishSink();
567
+
568
+ const slots = createSlotAssigner(hash);
569
+ const barometer = createLoadBarometer();
570
+
571
+ // Participant-side verifier signer: verify-only (it never assembles), so it needs no key / dial seam.
572
+ // The real k − x assembly lives in each CoordEngine's own threshold signer (constructed per coord).
573
+ const verifyingSigner = createCohortSigner(createVerifyOnlyThresholdCrypto(), minSigs);
574
+
575
+ // Collect one cohort member's `/sign` endorsement over the new fifth protocol.
576
+ const dialSign = async (peerIdStr: string, request: SignRequestV1): Promise<SignReplyV1> => {
577
+ const reply = await requestResponse(node, peerIdFromString(peerIdStr), protocols.sign, encodeCohortMessage(request, maxBytes), maxBytes);
578
+ return validateSignReplyV1(decodeCohortMessage(reply, maxBytes));
579
+ };
580
+
581
+ /** FRET assembly around `coord`: self prepended + deduped; epoch = H(sorted member join). */
582
+ const cohortAround = (coord: RingCoord): CohortSnapshotView => {
583
+ const peerStrs = fret.assembleCohort(coord, wantK);
584
+ const members = [selfMemberBytes, ...peerStrs.filter((p) => p !== selfPeerStr).map((p) => peerIdToBytes(p))];
585
+ // Deterministic epoch from the sorted member set so a membership change rotates the epoch.
586
+ const epochInput = members.map(bytesToB64url).sort().join("|");
587
+ const cohortEpoch = hash.H(new TextEncoder().encode(epochInput));
588
+ return { members, cohortEpoch };
589
+ };
590
+
591
+ // --- participant peer-key signing seam (gap 2) ---
592
+ // The live signer needs the node's libp2p key (libp2p does not expose it off `node.peerId`, so it
593
+ // arrives via options). When absent we keep the interim empty-string signer AND skip inbound
594
+ // participant-signature verification, so key-less unit/mock flows still compose. The signer id is
595
+ // the participant's wire identity (`participantCoord` / `participantId`), which IS its dialable
596
+ // peer-id bytes (db-core threads `self = selfMemberBytes`), so it round-trips to a verifiable key.
597
+ const participantSigner = createParticipantSigner(options.privateKey, log);
598
+ const verifyRegisterSig = options.privateKey === undefined
599
+ ? undefined
600
+ : (reg: RegisterV1): boolean =>
601
+ reg.signature.length > 0 &&
602
+ verifyPeerSig(b64urlToBytes(reg.participantCoord), registerSigningPayload(reg), b64urlToBytes(reg.signature));
603
+ const verifyParticipantSig = options.privateKey === undefined
604
+ ? undefined
605
+ : (renew: RenewV1): boolean =>
606
+ renew.signature.length > 0 &&
607
+ verifyPeerSig(b64urlToBytes(renew.participantId), renewSigningPayload(renew), b64urlToBytes(renew.signature));
608
+
609
+ // --- intra-cohort gossip authenticity seam (gap 5) ---
610
+ // Same peer-key signing pattern as register/renew, applied to the gossip envelope: the originator
611
+ // signs its canonical image, and a receiver drops a frame whose `fromMember` signature does not
612
+ // verify or that comes from a non-cohort member (so willingness/load can't be spoofed and forged
613
+ // records can't replicate). Key-less mode ships/accepts unsigned gossip (documented interim), exactly
614
+ // like the participant signer.
615
+ const nodeKey = options.privateKey;
616
+ const signGossip = nodeKey === undefined
617
+ ? undefined
618
+ : async (g: CohortGossipSignable): Promise<string> => bytesToB64url(await signPeer(nodeKey, cohortGossipSigningPayload(g)));
619
+ const verifyGossip = nodeKey === undefined
620
+ ? undefined
621
+ : (g: CohortGossipV1, coord: RingCoord): boolean => {
622
+ if (g.signature.length === 0) {
623
+ return false;
624
+ }
625
+ const fromBytes = b64urlToBytes(g.fromMember);
626
+ if (!verifyPeerSig(fromBytes, cohortGossipSigningPayload(g), b64urlToBytes(g.signature))) {
627
+ return false;
628
+ }
629
+ const members = cohortAround(coord).members.map(bytesToPeerIdString);
630
+ return members.includes(bytesToPeerIdString(fromBytes));
631
+ };
632
+
633
+ // --- outbound notice broadcast (gap 4) ---
634
+ // A coord engine that threshold-signs a promotion/demotion notice hands it here; we fan it over the
635
+ // `promote` protocol to the cohort around the served coord (siblings adopt the state) and, for a
636
+ // demotion, additionally to the parent coord (childCohortCount bookkeeping). Reuses the gossip
637
+ // transport's cohort peer resolution.
638
+ const broadcastNotice = (notice: PromotionNoticeV1 | DemotionNoticeV1, servedCoord: RingCoord): void => {
639
+ const frame = encodeCohortMessage(notice, maxBytes);
640
+ for (const coord of noticeBroadcastCoords(notice, servedCoord)) {
641
+ gossipTransport.broadcastOver(protocols.promote, coord, frame);
642
+ }
643
+ };
644
+
645
+ // --- anti-DoS (gap 6) ---
646
+ // The bootstrap-evidence policy is node-level (a tier→verifier policy with no per-coord state), so it
647
+ // is built once and shared by every coord engine. The per-coord guards (rate limiter, replay guard,
648
+ // topic budget) are built per CoordEngine from `antiDos` below — they key on `(peer, topic)` /
649
+ // per-cohort topic state, which is coord-scoped, and must not share state across coords.
650
+ // The signed-parent-reference existence view (cohort-topic-bootstrap-parent-reference). A test override
651
+ // (`antiDos.parentTopicView`) wins; otherwise the host default, tier-routed over the FRET membership
652
+ // cache (T2/T3) and the optional committed reader (T0/T1 — fail-closed without one, committed-tier
653
+ // integrity). Synchronous + local: it reads the in-memory caches the node already holds, never a dial.
654
+ const parentTopicView = options.antiDos?.parentTopicView ?? createDefaultParentTopicView({
655
+ membershipSource,
656
+ addressing,
657
+ committedReader: options.committedParentTopicReader,
658
+ });
659
+ // Does this host actually have a committed-existence backing to gate T0/T1 parent-refs against? Only an
660
+ // explicit `parentTopicView` override (the test seam / future production index) or a wired
661
+ // `committedParentTopicReader` provides one. Absent both (today's production node), a T0/T1 root cannot
662
+ // mint any acceptable parent-ref — a root has no parent and the default view fails T0/T1 closed — so the
663
+ // policy keeps T0/T1 permissive-but-logged rather than regressing cold-start origination. See
664
+ // {@link createBootstrapEvidencePolicy}.
665
+ const hasCommittedParentBacking =
666
+ options.antiDos?.parentTopicView !== undefined || options.committedParentTopicReader !== undefined;
667
+ const bootstrapEvidence = createBootstrapEvidencePolicy(options.antiDos, hash, log, parentTopicView, hasCommittedParentBacking);
668
+
669
+ // Node-level `promote`-handler anti-abuse gate (`cohort-topic-promote-handler-verify-amplification`):
670
+ // a per-(peer, topic) rate limiter (own instance — the register-path limiter is per-coord inside each
671
+ // engine; this handler is node-level) plus the per-(topic, tier) effectiveAt high-water. Defaults to
672
+ // `register_rate_per_peer` (4 / min / peer / topic) with exponential back-off.
673
+ const promoteGate = createPromoteGate(options.antiDos?.rateLimiter);
674
+
675
+ const ctx: CoordEngineContext = {
676
+ hash,
677
+ addressing,
678
+ slots,
679
+ barometer,
680
+ transport: gossipTransport,
681
+ profile,
682
+ selfMemberBytes,
683
+ wantK,
684
+ minSigs,
685
+ maxBytes,
686
+ publishSink,
687
+ privateKey: options.privateKey,
688
+ router,
689
+ antiDos: {
690
+ rateLimiter: options.antiDos?.rateLimiter,
691
+ replayGuard: options.antiDos?.replayGuard,
692
+ topicBudget: options.antiDos?.topicBudget,
693
+ },
694
+ bootstrapEvidence,
695
+ promotionConfig: options.promotion,
696
+ dialSign,
697
+ cohortAround,
698
+ verifyRegisterSig,
699
+ verifyParticipantSig,
700
+ signGossip,
701
+ verifyGossip,
702
+ broadcastNotice,
703
+ // Cache this node's own freshly-published cohort cert into the verifier, so an inbound notice
704
+ // signed by this node's cohort verifies locally without a network refetch. `verifier` is declared
705
+ // just below; the closure only runs on a (later) publish, after it is initialized.
706
+ onCertPublished: (cert: MembershipCertV1): void => verifier.cache(cert),
707
+ };
708
+ const registry = createCoordRegistry(ctx);
709
+
710
+ // --- intra-cohort sign endorsement (the `/sign` handler body) ---
711
+ // A member dials us to endorse a threshold-signed artifact; we sign the exact request payload iff we
712
+ // and the requester share the cohort+epoch around `coord`. Exported `handleSignRequest` is the testable
713
+ // core; here we bind it to this node's key + the FRET assembly around the requested coord.
714
+ const signEndorse = (request: SignRequestV1, fromPeerStr: string): Promise<SignReplyV1> =>
715
+ handleSignRequest(request, fromPeerStr, {
716
+ privateKey: options.privateKey,
717
+ selfMember: selfMemberBytes,
718
+ cohortMembersAround: (coord: RingCoord): string[] => cohortAround(coord).members.map(bytesToPeerIdString),
719
+ currentEpoch: (coord: RingCoord): Uint8Array => cohortAround(coord).cohortEpoch,
720
+ // Rotation endorsement consults the served coord engine's prior-epoch membership history; a coord
721
+ // this node does not serve has no engine, so the hand-off is refused (no spurious instantiation).
722
+ priorCohortMembersAt: (coord: RingCoord, epoch: Uint8Array): readonly string[] | undefined =>
723
+ registry.findByCoord(coord)?.cohortIdentityAt(epoch),
724
+ // Membership binding: re-derive our own canonical cert fields from the SAME `cohortAround` snapshot
725
+ // the per-coord publisher signs over, so a falsified `members` / `cohortCoord` / internal-epoch
726
+ // payload is refused. `stabilizedAt: 0` — the endorser ignores it (it only bounds the value
727
+ // far-future via `now`), matching the dep contract (cohort-topic-sign-endorsement-payload-binding).
728
+ expectedMembershipFields: (coord: RingCoord): { cohortCoord: string; cohortEpoch: string; members: string[] } => {
729
+ const snap = cohortAround(coord);
730
+ const { cohortCoord, cohortEpoch, members } = membershipCertSignable({
731
+ coord,
732
+ cohortEpoch: snap.cohortEpoch,
733
+ members: snap.members,
734
+ stabilizedAt: 0,
735
+ });
736
+ return { cohortCoord, cohortEpoch, members };
737
+ },
738
+ // Wall clock for the `stabilizedAt` far-future bound. Production: Date.now. A virtual-time harness
739
+ // injects a non-tripping clock (its publish `stabilizedAt` is synthetic, not wall-clock).
740
+ now: options.now ?? ((): number => Date.now()),
741
+ });
742
+
743
+ // --- participant-side composition (node scope) ---
744
+ // The participant service exposes a node-level gossip handle (around the node's own ring position)
745
+ // and the membership verifier; its register/renew walk drives the FRET router, not a coord engine.
746
+ const participantStore = createRegistrationStore();
747
+ const participantGossipBus = createCohortGossipBus({
748
+ transport: gossipTransport,
749
+ store: participantStore,
750
+ coord: selfCoord,
751
+ localEpoch: (): Uint8Array => cohortAround(selfCoord).cohortEpoch,
752
+ // Same per-coord auth gate as the coord engines: only a signed gossip from a member of the cohort
753
+ // around the node's own ring position merges here (live-signer mode); absent in key-less composition.
754
+ verifyInbound: verifyGossip === undefined ? undefined : (g): boolean => verifyGossip(g, selfCoord),
755
+ });
756
+ const certSource: IMembershipSource = membershipSource;
757
+ const membershipRouter = createMembershipSourceRouter({ committed: certSource, fret: certSource });
758
+ // --- direct trust anchor (cohort-topic-trust-anchor-fret-binding) ---
759
+ // Bind the db-core trust gate's direct anchor to the FRET ring: for a coord this node serves (a covered,
760
+ // non-partitioned T2/T3 coord whose cohort includes self), the cert's signing quorum is checked against
761
+ // the ring's two-sided assembly — a forged unrelated keyset is `"rejected"`, a legit (slack-tolerant)
762
+ // cohort `"anchored"`, everything else `"unknown"` (→ chain / TOFU). Committed tiers (T0/T1) stay
763
+ // `"unknown"` here so this composes with the future tx-log anchor rather than fighting it. `wantK` matches
764
+ // the cohort size `cohortAround` publishes certs over; `selfPeerStr` is the coverage handle.
765
+ const trustAnchor = new FretTrustAnchor(fret, { k: wantK, selfPeerId: selfPeerStr });
766
+ const verifier = createMembershipVerifier({
767
+ signer: verifyingSigner,
768
+ router: membershipRouter,
769
+ minSigs,
770
+ anchor: trustAnchor,
771
+ // Genesis trust roots (network-config; empty by default → no roots, identical to pre-seam behavior).
772
+ trustRoots: options.genesisTrustRoots ?? [],
773
+ });
774
+ // --- participant-side cold-start evidence builder (gap 6) ---
775
+ // Mints the evidence the participant attaches on a cold-root `bootstrap: true` re-issue. PoW (T2/T3) is
776
+ // keyless, so even a key-less host can bootstrap those tiers; the proof is bound to the register's own
777
+ // (topicId, tier, participantCoord, timestamp) tuple so a verifier reconstructs the same image. T0/T1
778
+ // carries no evidence here (the builder supports an `endorse` self-vouch seam — see
779
+ // `bootstrap-evidence-builder.ts` — but origination at those tiers is the committed-parent-reference
780
+ // follow-on `cohort-topic-bootstrap-parent-reference`, so it is intentionally left unwired for now).
781
+ const buildBootstrapEvidence = createBootstrapEvidenceBuilder({
782
+ hash,
783
+ bits: options.antiDos?.powDifficultyBits,
784
+ });
785
+
786
+ const service = createCohortTopicService({
787
+ // `self` is the dialable peer-id bytes (not the ring coord): db-core carries it as the
788
+ // participant's wire identity, so the cohort can verify its peer-key signature (see signing seam).
789
+ self: selfMemberBytes,
790
+ hash,
791
+ router,
792
+ sizeEstimator,
793
+ signer: participantSigner,
794
+ gossipBus: participantGossipBus,
795
+ verifier,
796
+ buildBootstrapEvidence,
797
+ config: { fanout, wantK, minSigs, maxMessageBytes: maxBytes },
798
+ });
799
+
800
+ // --- register dispatch: recompute the served coord and run the cohort decision on its engine ---
801
+ const dispatchRegister = async (reg: RegisterV1, fretCohort: readonly string[] | undefined, now: number): Promise<RegisterReplyV1> => {
802
+ const topicId = b64urlToBytes(reg.topicId);
803
+ const participantCoord = b64urlToBytes(reg.participantCoord);
804
+ // FRET's ActivityHandler does not carry the routed key, so recompute it from the frame. For tier
805
+ // `d` this equals the participant's `coord_d(self, topicId)` routing key by construction, i.e. the
806
+ // coordinate FRET routed to (§Tier addressing).
807
+ const servedCoord = addressing.coord(reg.treeTier, participantCoord, topicId);
808
+ const coordEngine = registry.forCoord(servedCoord, reg.treeTier, participantCoord);
809
+ if (fretCohort !== undefined) {
810
+ crossCheckCohort(fret, wantK, servedCoord, fretCohort);
811
+ }
812
+ // `parentCoord` for a cold-start forwarder's parent registration; undefined at the root.
813
+ const parentCoord = reg.treeTier > 0 ? addressing.coord(reg.treeTier - 1, participantCoord, topicId) : undefined;
814
+ return coordEngine.engine.handleRegister(reg, { followOn: false, treeTier: reg.treeTier, parentCoord }, now);
815
+ };
816
+
817
+ // --- protocol handlers + activity callback ---
818
+ // Await registration so the host is not returned (and dialed) before the five handlers are live —
819
+ // and, crucially, before the gossip driver below starts ticking (no tick may run on a half-wired node).
820
+ await registerProtocolHandlers(node, protocols, registry, dispatchRegister, signEndorse, verifier, promoteGate, gossipTransport, publishSink, membershipSource, selfCoord, maxBytes);
821
+ fret.setActivityHandler(async (activity: string, cohort: string[]): Promise<{ commitCertificate: string }> => {
822
+ const reg = validateRegisterV1(decodeCohortMessage(b64urlToBytes(activity), maxBytes));
823
+ const reply = await dispatchRegister(reg, cohort, Date.now());
824
+ return { commitCertificate: bytesToB64url(encodeCohortMessage(reply, maxBytes)) };
825
+ });
826
+
827
+ // --- periodic gossip-cadence driver (gap 5) ---
828
+ // db-core has no timer port (confirmed), so the host owns a single raw `setInterval`. Each tick drives
829
+ // every live coord engine's gossip round + membership refresh + demotion check; the membership-refresh
830
+ // (5 min) and demotion (5 min) hysteresis self-gate on elapsed time, so a fast tick is safe and cheap
831
+ // (idle empty engines build no frame). A re-entrancy guard skips a tick that overlaps a slow prior one;
832
+ // `stopped` short-circuits any tick that fires after `stop()`.
833
+ let stopped = false;
834
+ let ticking = false;
835
+ const driveTick = async (): Promise<void> => {
836
+ if (stopped || ticking) {
837
+ return;
838
+ }
839
+ ticking = true;
840
+ try {
841
+ const now = Date.now();
842
+ for (const engine of registry.all()) {
843
+ if (stopped) {
844
+ break;
845
+ }
846
+ try {
847
+ await engine.gossipRound(now);
848
+ await engine.pumpMembership(now);
849
+ await engine.demotionTick(now);
850
+ } catch (err) {
851
+ log("cohort-topic: gossip tick failed for a coord engine: %o", err);
852
+ }
853
+ }
854
+ // Proactive idle reclaim of the node-level promote-gate limiter on the same cadence the per-coord
855
+ // limiters sweep on. The limiter's inline `maxKeys` cap is the hard worst-case bound; this sweep is
856
+ // the steady-state reclaim of idle `(peer, topic)` keys. Runs after the per-engine loop, inside the
857
+ // `ticking` guard and `stopped` short-circuit, so it is single-threaded with no race on `stop()`.
858
+ promoteGate.rateLimiter.sweep(now);
859
+ } finally {
860
+ ticking = false;
861
+ }
862
+ };
863
+ const timer = setInterval((): void => {
864
+ void driveTick();
865
+ }, gossipIntervalMs);
866
+ // Node timers keep the event loop alive; cohort gossip should not pin a process that is otherwise idle.
867
+ (timer as { unref?: () => void }).unref?.();
868
+
869
+ return {
870
+ service,
871
+ registry,
872
+ protocols,
873
+ profile,
874
+ gossipTransport,
875
+ promoteGate,
876
+ stop: async (): Promise<void> => {
877
+ stopped = true;
878
+ clearInterval(timer);
879
+ registry.close();
880
+ participantGossipBus.close();
881
+ await node.unhandle(cohortTopicProtocolList(protocols));
882
+ },
883
+ };
884
+ }
885
+
886
+ // --- participant signing seam ---
887
+
888
+ /**
889
+ * The participant body signer. With a node key it peer-key-signs the canonical register/renew byte
890
+ * image (base64url). Without one it is the interim empty-string signer with a one-time warn that the
891
+ * bodies are unsigned — keeping key-less mock/unit flows composable (e.g. the four-protocol handshake
892
+ * test). The `signRegister`/`signRenew` bodies are the wire-payload-helper inputs verbatim.
893
+ */
894
+ function createParticipantSigner(
895
+ privateKey: PrivateKey | undefined,
896
+ log: (formatter: string, ...args: unknown[]) => void,
897
+ ): ParticipantSigner {
898
+ if (privateKey === undefined) {
899
+ let warned = false;
900
+ const warnOnce = (): void => {
901
+ if (warned) {
902
+ return;
903
+ }
904
+ warned = true;
905
+ log("no privateKey supplied to createCohortTopicHost; participant RegisterV1/RenewV1 bodies are UNSIGNED (interim — supply options.privateKey for peer-key signing)");
906
+ };
907
+ return {
908
+ signRegister: (): Promise<string> => {
909
+ warnOnce();
910
+ return Promise.resolve("");
911
+ },
912
+ signRenew: (): Promise<string> => {
913
+ warnOnce();
914
+ return Promise.resolve("");
915
+ },
916
+ };
917
+ }
918
+ return {
919
+ signRegister: async (body): Promise<string> => bytesToB64url(await signPeer(privateKey, registerSigningPayload(body))),
920
+ signRenew: async (body): Promise<string> => bytesToB64url(await signPeer(privateKey, renewSigningPayload(body))),
921
+ };
922
+ }
923
+
924
+ // --- anti-DoS: node-level bootstrap-evidence policy (gap 6) ---
925
+
926
+ /**
927
+ * Build the node-level {@link BootstrapEvidence} policy a cold root demands of a `bootstrap: true`
928
+ * registration (`docs/cohort-topic.md` §Anti-DoS bullet 4). The policy itself (tier-dependent: T0/T1
929
+ * need a signed parent reference; T2/T3 accept PoW OR reputation OR a parent reference) is real db-core
930
+ * logic — this only supplies the **verifiers**, which db-core deliberately does not embed (no specific
931
+ * PoW / reputation / committed-work scheme).
932
+ *
933
+ * Resolution per verifier (an injected `antiDos.bootstrapEvidence` override always wins — the test seam):
934
+ *
935
+ * - `verifyPoW` — when **configured**, the real {@link createPoWVerifier} (self-contained: one hash over
936
+ * the bound preimage). This is a working PoW path, not deny.
937
+ * - `verifyReputation` — when a reputation **view** is supplied, the real {@link createReputationVerifier}
938
+ * (a referee peer-key-signs the bound image; the cohort checks the signature + the referee's local
939
+ * reputation). Else (configured, no view) fail closed.
940
+ * - `verifyParentReference` — the real {@link createParentReferenceVerifier} over `parentTopicView`: a
941
+ * participant-signed reference to a parent topic that the node locally knows exists (committed/membership
942
+ * state). It is the *only* accepted evidence for T0/T1 and the third T2/T3 option. The view is always
943
+ * available (the host builds a default from the membership cache + addressing), so this is the real
944
+ * verifier at T2/T3 and at T0/T1 **once a committed backing is wired**.
945
+ *
946
+ * **T0/T1 without a committed backing (the cold-start-origination interim posture).** A real production
947
+ * node wires a reputation view (so it is *configured*) but, today, no committed-existence backing
948
+ * (`hasCommittedParentBacking === false`): there is no coord-keyed committed-membership index yet, so the
949
+ * default `parentTopicView` fails T0/T1 closed, and the participant-side builder mints no parentRef for a
950
+ * brand-new root (a root has no parent to reference). At those tiers the policy consults *only*
951
+ * `verifyParentReference`, so a real, unfilled T0/T1 parent-ref gate would make cold-start origination
952
+ * impossible. While that backing does not exist we therefore keep T0/T1 **permissive-but-logged** when
953
+ * `hasCommittedParentBacking` is false, and run the real verifier otherwise — i.e. at T2/T3 always, and at
954
+ * T0/T1 once a committed backing IS supplied (the test seam `antiDos.parentTopicView`, or a future
955
+ * production `committedParentTopicReader`). This is the documented refinement of the parent-reference work,
956
+ * narrowed so it does not regress the just-landed real T0/T1 gating exercised with an explicit view.
957
+ *
958
+ * "Configured" = any reputation view or explicit `bootstrapEvidence` override is set; once configured, an
959
+ * unfilled verifier fails **closed** so a banned/low-rep referee cannot slip the T2/T3
960
+ * `verifyPoW || verifyReputation || verifyParentReference` disjunction. The permissive-but-logged
961
+ * fallback is reserved for the *entirely unconfigured* interim node (and the configured-but-no-committed-
962
+ * backing T0/T1 origination path above) — a one-time warning, never an undefined gate — so the
963
+ * db-core/mock-tier flows that bootstrap tier-0 without evidence still pass.
964
+ *
965
+ * @param hasCommittedParentBacking Whether this host has a committed-existence backing to gate T0/T1
966
+ * parent-refs against (an explicit `antiDos.parentTopicView` override or a wired
967
+ * `committedParentTopicReader`). False on today's production node, where T0/T1 stays permissive.
968
+ */
969
+ function createBootstrapEvidencePolicy(
970
+ antiDos: CohortTopicAntiDosOptions | undefined,
971
+ hash: RingHash,
972
+ log: (formatter: string, ...args: unknown[]) => void,
973
+ parentTopicView: BootstrapParentTopicView,
974
+ hasCommittedParentBacking: boolean,
975
+ ): BootstrapEvidence {
976
+ const overrides = antiDos?.bootstrapEvidence;
977
+ const reputation = antiDos?.reputation;
978
+
979
+ // Once ANY real gating is configured (a reputation view or explicit verifiers), an unfilled verifier
980
+ // must fail **closed** (deny), not open — otherwise a permissive verifier short-circuits the T2/T3
981
+ // `||` disjunction and admits even a banned peer. The permissive-but-logged fallback below is
982
+ // therefore reserved for the *entirely unconfigured* interim node.
983
+ const configured = overrides !== undefined || reputation !== undefined;
984
+ // Mirror the db-core policy's tier split (an override config wins, else the default) so the T0/T1 branch
985
+ // of the parent-reference gate below matches the tiers at which the policy consults it exclusively.
986
+ const maxNoPowTier = overrides?.config?.maxNoPowTier ?? DEFAULT_MAX_NO_POW_TIER;
987
+
988
+ // The real, self-contained PoW verifier — available whenever configured (no view / subsystem needed).
989
+ const realPoW = createPoWVerifier({ hash, bits: antiDos?.powDifficultyBits });
990
+ // The real referee verifier — only when a reputation view is supplied (the T2/T3 reputation path).
991
+ const realReputation = reputation === undefined
992
+ ? undefined
993
+ : createReputationVerifier({ reputation, deprioritizeThreshold: antiDos?.deprioritizeThreshold });
994
+ // The real signed-parent-reference verifier over the existence view (T2/T3 always; T0/T1 once a committed
995
+ // backing is wired). Replaces the interim reputation stand-in: it demands a real, existing parent.
996
+ const realParentReference = createParentReferenceVerifier({ parentTopicView });
997
+
998
+ const deny = (): boolean => false;
999
+
1000
+ // Permissive-but-logged fallback (one warning total): keeps the gate defined while the production
1001
+ // evidence schemes are unwired, instead of silently leaving cold-root bootstrap unauthenticated.
1002
+ let warned = false;
1003
+ const permissive = (kind: string): ((reg: RegisterV1) => boolean) => (reg: RegisterV1): boolean => {
1004
+ void reg;
1005
+ if (!warned) {
1006
+ warned = true;
1007
+ 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);
1008
+ }
1009
+ return true;
1010
+ };
1011
+ // An unconfigured verifier: deny when the policy is otherwise configured (fail closed), else permissive.
1012
+ const fallback = (kind: string): ((reg: RegisterV1) => boolean) => configured ? deny : permissive(kind);
1013
+
1014
+ // The configured parent-reference gate. At T0/T1 (`reg.tier <= maxNoPowTier`) with NO committed backing,
1015
+ // a brand-new root cannot mint any acceptable parent-ref (it has no parent; the default view fails closed)
1016
+ // and the policy consults only this verifier there — so admit permissively-but-logged rather than regress
1017
+ // cold-start origination. At T2/T3, and at T0/T1 once a committed backing IS wired (the test seam / future
1018
+ // production index), run the real verifier.
1019
+ const permissiveT0T1 = permissive("parent-reference (T0/T1, no committed backing)");
1020
+ const parentReferenceGate = (reg: RegisterV1): boolean => {
1021
+ if (reg.tier <= maxNoPowTier && !hasCommittedParentBacking) {
1022
+ return permissiveT0T1(reg);
1023
+ }
1024
+ return realParentReference(reg);
1025
+ };
1026
+
1027
+ return createBootstrapEvidence({
1028
+ // Configured ⇒ the real PoW path; unconfigured ⇒ permissive (preserves the bare-host tier-0 flows).
1029
+ verifyPoW: overrides?.verifyPoW ?? (configured ? realPoW : fallback("proof-of-work")),
1030
+ verifyReputation: overrides?.verifyReputation ?? realReputation ?? fallback("reputation"),
1031
+ // The real signed-parent-reference verifier when configured (an explicit override still wins), gated so
1032
+ // T0/T1-without-committed-backing stays permissive; unconfigured ⇒ permissive (preserves the bare-host
1033
+ // tier-0 flows). No longer the reputation stand-in.
1034
+ verifyParentReference: overrides?.verifyParentReference ?? (configured ? parentReferenceGate : fallback("parent-reference")),
1035
+ config: overrides?.config,
1036
+ });
1037
+ }
1038
+
1039
+ // --- cold-start: forwarder → parent registration transport (gap 7) ---
1040
+
1041
+ /** Inputs to {@link registerForwarderWithParent} (captured per coord engine + per instantiating register). */
1042
+ interface ForwarderLink {
1043
+ readonly topicId: Uint8Array;
1044
+ /** The tier-`(d − 1)` parent cohort coord this link routes to. */
1045
+ readonly parentCoord: Uint8Array;
1046
+ /** The forwarder's tree tier `d` (`> 0` — the root never links to a parent). */
1047
+ readonly treeTier: number;
1048
+ /** The topic's capacity tier (T0–T3); stamps the link frame's `tier`. Defaults to 0 if absent. */
1049
+ readonly opTier?: number;
1050
+ /** The participant coord that seeded this engine — keeps the link frame's recompute consistent. */
1051
+ readonly participantCoord: Uint8Array;
1052
+ }
1053
+
1054
+ /**
1055
+ * Route a forwarder→parent link to `parentCoord` and resolve on the round-trip (the parent ack).
1056
+ *
1057
+ * The link is a `RegisterV1`-style frame routed over {@link ITopicRouter.routeAndAct} keyed at the
1058
+ * parent coord: it rides the parent's serving tier (`treeTier − 1`) with this engine's seed
1059
+ * `participantCoord`, so the parent recomputes `servedCoord = coord_{d−1}(participantCoord, topicId) =
1060
+ * parentCoord`. A fresh CSPRNG `correlationId` keeps it clear of the parent's replay guard on retry.
1061
+ * Resolution of the route is treated as the ack (richer child-link confirmation — the parent recording
1062
+ * `childCohortCount` over a dedicated child-link frame — is the follow-on
1063
+ * `cohort-topic-parent-child-link`); a rejection propagates so the cold-start manager keeps the
1064
+ * forwarder `awaiting_parent`.
1065
+ */
1066
+ async function registerForwarderWithParent(ctx: CoordEngineContext, link: ForwarderLink): Promise<void> {
1067
+ const frame: RegisterV1 = {
1068
+ v: 1,
1069
+ topicId: bytesToB64url(link.topicId),
1070
+ tier: clampTier(link.opTier ?? 0),
1071
+ treeTier: Math.max(0, link.treeTier - 1),
1072
+ participantCoord: bytesToB64url(link.participantCoord),
1073
+ ttl: DEFAULT_TTL_MS,
1074
+ // Not a root cold-start: a follow-on link to an already-promoted parent, so no bootstrap evidence.
1075
+ bootstrap: false,
1076
+ timestamp: Date.now(),
1077
+ correlationId: bytesToB64url(randomBytes(16)),
1078
+ // Interim: the forwarder cohort cannot sign as the participant; the dedicated child-link frame
1079
+ // (follow-on) carries the cohort threshold signature instead.
1080
+ signature: "",
1081
+ };
1082
+ await ctx.router.routeAndAct(link.parentCoord, encodeCohortMessage(frame, ctx.maxBytes), { wantK: ctx.wantK, minSigs: ctx.minSigs });
1083
+ }
1084
+
1085
+ /** Clamp an op tier to the valid T0–T3 range so the link frame validates at a (future) real parent. */
1086
+ function clampTier(tier: number): number {
1087
+ if (!Number.isInteger(tier) || tier < 0) {
1088
+ return 0;
1089
+ }
1090
+ return tier > 3 ? 3 : tier;
1091
+ }
1092
+
1093
+ // --- registry + coord engine ---
1094
+
1095
+ /** Build the lazy `servedCoord → CoordEngine` registry over the shared collaborators. */
1096
+ function createCoordRegistry(ctx: CoordEngineContext): CoordRegistry {
1097
+ const engines = new Map<string, CoordEngine>();
1098
+ return {
1099
+ forCoord(coord: RingCoord, treeTier: number, participantCoord: Uint8Array): CoordEngine {
1100
+ const key = bytesToB64url(coord);
1101
+ // Compute-if-absent, synchronously — no async gap, so two concurrent callers for the same coord
1102
+ // share one engine rather than racing to construct a second.
1103
+ let engine = engines.get(key);
1104
+ if (engine === undefined) {
1105
+ engine = createCoordEngine(ctx, coord, treeTier, participantCoord);
1106
+ engines.set(key, engine);
1107
+ }
1108
+ return engine;
1109
+ },
1110
+ findByCoord(coord: RingCoord): CoordEngine | undefined {
1111
+ return engines.get(bytesToB64url(coord));
1112
+ },
1113
+ findHolder(topicId: Uint8Array, participantId: Uint8Array): CoordEngine | undefined {
1114
+ for (const engine of engines.values()) {
1115
+ if (engine.holds(topicId, participantId)) {
1116
+ return engine;
1117
+ }
1118
+ }
1119
+ return undefined;
1120
+ },
1121
+ findServing(topicId: Uint8Array, treeTier: number): CoordEngine | undefined {
1122
+ for (const engine of engines.values()) {
1123
+ if (engine.treeTier === treeTier && engine.servesTopic(topicId)) {
1124
+ return engine;
1125
+ }
1126
+ }
1127
+ return undefined;
1128
+ },
1129
+ all(): readonly CoordEngine[] {
1130
+ return [...engines.values()];
1131
+ },
1132
+ close(): void {
1133
+ for (const engine of engines.values()) {
1134
+ engine.close();
1135
+ }
1136
+ engines.clear();
1137
+ },
1138
+ };
1139
+ }
1140
+
1141
+ /**
1142
+ * One observed cohort identity for a served coord: the deterministic epoch (`H(sorted member set)`),
1143
+ * its base64url key, and the member set in both byte and peer-id-string form. The epoch is a pure
1144
+ * function of the member set, so an epoch match implies a member-set match.
1145
+ */
1146
+ interface CohortIdentity {
1147
+ readonly epoch: Uint8Array;
1148
+ readonly epochKey: string;
1149
+ readonly memberBytes: readonly Uint8Array[];
1150
+ readonly memberStrs: readonly string[];
1151
+ }
1152
+
1153
+ /**
1154
+ * Per-{@link CoordEngine} epoch-rotation bookkeeping (`cohort-topic-trust-anchor-rotation-production`).
1155
+ * Two roles, one small object:
1156
+ *
1157
+ * - **Producer** — {@link predecessor} is the identity of the *last published* cert; a publish whose
1158
+ * first `k − x` differ from it is a rotation, and the predecessor identity scopes the rotation `/sign`
1159
+ * round (its epoch is `prevEpoch`, its members are the outgoing cohort to collect from).
1160
+ * - **Endorser** — {@link membersAt} answers "was I a member of the cohort at `epoch`?" over a two-deep
1161
+ * observed-epoch history ({@link current} + {@link prior}), kept fresh by {@link observe} on every
1162
+ * cohort assembly. A request for an epoch past that window is refused (the rapid-double-rotation gap).
1163
+ *
1164
+ * The observed history (for endorsing) is distinct from `lastPublished` (for producing): a non-deciding
1165
+ * member endorses rotations it never published, so it cannot rely on its own publish history alone.
1166
+ */
1167
+ class RotationState {
1168
+ private current: CohortIdentity | undefined;
1169
+ private prior: CohortIdentity | undefined;
1170
+ private lastPublished: CohortIdentity | undefined;
1171
+
1172
+ /**
1173
+ * Record the engine's current cohort identity, shifting the previous current into {@link prior} on an
1174
+ * epoch change. Cheap on the hot path: `build` is only invoked when `epochKey` actually changes (the
1175
+ * member set is unchanged within an epoch, so there is nothing to refresh).
1176
+ */
1177
+ observe(epochKey: string, build: () => CohortIdentity): void {
1178
+ if (this.current?.epochKey === epochKey) {
1179
+ return;
1180
+ }
1181
+ const next = build();
1182
+ this.prior = this.current;
1183
+ this.current = next;
1184
+ }
1185
+
1186
+ /** Mark `identity` as the most recently published cert's identity (the rotation chain predecessor). */
1187
+ recordPublished(identity: CohortIdentity): void {
1188
+ this.lastPublished = identity;
1189
+ }
1190
+
1191
+ /** The last-published identity (the predecessor a fresh rotation attests from), or `undefined`. */
1192
+ predecessor(): CohortIdentity | undefined {
1193
+ return this.lastPublished;
1194
+ }
1195
+
1196
+ /** The member peer-id strings observed under `epochKey` (current or prior), or `undefined`. */
1197
+ membersAt(epochKey: string): readonly string[] | undefined {
1198
+ if (this.current?.epochKey === epochKey) {
1199
+ return this.current.memberStrs;
1200
+ }
1201
+ if (this.prior?.epochKey === epochKey) {
1202
+ return this.prior.memberStrs;
1203
+ }
1204
+ return undefined;
1205
+ }
1206
+ }
1207
+
1208
+ /**
1209
+ * Compose one {@link CoordEngine} bound to `servedCoord`. The cohort it threshold-signs / shards with
1210
+ * is the FRET assembly around `servedCoord` (not the node's own ring position). The promotion tier
1211
+ * inputs are coord-derived: `treeTier` is fixed at instantiation; `parentCoord` is
1212
+ * `coord_{d-1}(participantCoord, topicId)` (the shard's parent shares the prefix, so any participant
1213
+ * routed here yields the same parent); `childCohortCount` is `0` for the single-cohort milestone.
1214
+ */
1215
+ function createCoordEngine(ctx: CoordEngineContext, servedCoord: RingCoord, treeTier: number, participantCoord: Uint8Array): CoordEngine {
1216
+ const store = createRegistrationStore();
1217
+ // Epoch-rotation bookkeeping. `cohort()` observes every assembly so the endorser history stays fresh
1218
+ // (the gossip-cadence driver assembles each round); the producer reads `predecessor()` on publish.
1219
+ const rotationState = new RotationState();
1220
+ const identityOf = (view: { members: readonly Uint8Array[]; cohortEpoch: Uint8Array }): CohortIdentity => {
1221
+ const memberBytes = [...view.members];
1222
+ return {
1223
+ epoch: view.cohortEpoch,
1224
+ epochKey: bytesToB64url(view.cohortEpoch),
1225
+ memberBytes,
1226
+ memberStrs: memberBytes.map(bytesToPeerIdString),
1227
+ };
1228
+ };
1229
+ const cohort = (): CohortSnapshotView => {
1230
+ const view = ctx.cohortAround(servedCoord);
1231
+ rotationState.observe(bytesToB64url(view.cohortEpoch), () => identityOf(view));
1232
+ return view;
1233
+ };
1234
+ const localEpoch = (): Uint8Array => cohort().cohortEpoch;
1235
+
1236
+ // Inbound gossip is routed to this bus by its `coord`; the optional auth gate (live-signer mode) drops
1237
+ // a frame whose `fromMember` signature is bad or who is not a member of the cohort around THIS coord.
1238
+ const bus = createCohortGossipBus({
1239
+ transport: ctx.transport,
1240
+ store,
1241
+ coord: servedCoord,
1242
+ localEpoch,
1243
+ verifyInbound: ctx.verifyGossip === undefined ? undefined : (g): boolean => ctx.verifyGossip!(g, servedCoord),
1244
+ // Sibling-drain half of the topic-budget release: a topic whose participants are sharded onto a
1245
+ // sibling primary drains into this store as a gossip eviction (never this member's own TTL sweep),
1246
+ // so re-`touch` the budget down from the post-delete store count — mirroring the engine's `sweepStale`
1247
+ // re-touch. `topicBudget` is a forward `const` here, only read when this callback fires at merge time
1248
+ // (long after both are initialized). A no-op for a topic the budget does not hold (`touch` guards it).
1249
+ onRecordsEvicted: (topicIds): void => {
1250
+ for (const topicId of topicIds) {
1251
+ topicBudget.touch(topicId, store.directParticipants(topicId));
1252
+ }
1253
+ },
1254
+ });
1255
+ const view = bus.view();
1256
+ const selfMember = bytesToB64url(ctx.selfMemberBytes);
1257
+
1258
+ // Per-touch replication delta queue: the renewal cohort side appends each served touch / TTL eviction
1259
+ // here; the next gossip round drains the batch into the broadcast frame (one round, not one per ping).
1260
+ const pending = createPendingDeltas();
1261
+
1262
+ // Per-coord threshold signers: each assembles a real k − x signature by signing locally and collecting
1263
+ // the rest of the cohort around THIS served coord over the `/sign` RPC. `membership` signs the cert;
1264
+ // `promotion` signs promote/demote notices (kind drives the dialed members' endorsement policy). In
1265
+ // key-less interim mode there is no key to sign self's chunk, so the signer is verify-only (the
1266
+ // publisher / promotion paths are simply not driven without a key).
1267
+ const makeCoordSigner = (kind: SignKind): CohortSigner => {
1268
+ if (ctx.privateKey === undefined) {
1269
+ return createCohortSigner(createVerifyOnlyThresholdCrypto(), ctx.minSigs);
1270
+ }
1271
+ const crypto = new FretCohortThresholdCrypto({
1272
+ kind,
1273
+ privateKey: ctx.privateKey,
1274
+ selfMember: ctx.selfMemberBytes,
1275
+ coord: (): RingCoord => servedCoord,
1276
+ cohortEpoch: localEpoch,
1277
+ cohortMembers: (): string[] => cohort().members.map(bytesToPeerIdString),
1278
+ dialSign: ctx.dialSign,
1279
+ });
1280
+ return createCohortSigner(crypto, ctx.minSigs);
1281
+ };
1282
+ const noticeSigner = makeCoordSigner("promotion");
1283
+ const membershipSigner = makeCoordSigner("membership");
1284
+
1285
+ // Cohort-side membership-cert publisher: threshold-signs a MembershipCertV1 over this coord's cohort
1286
+ // and serves it through the node's publish sink. Driven by the onStabilized / pumpMembership hooks.
1287
+ const membershipPublisher: MembershipCertPublisher = createMembershipCertPublisher({
1288
+ signer: membershipSigner,
1289
+ sink: ctx.publishSink,
1290
+ minSigs: ctx.minSigs,
1291
+ maxMessageBytes: ctx.maxBytes,
1292
+ });
1293
+ const snapshotAt = (now: number): CohortSnapshot => {
1294
+ const { members, cohortEpoch } = cohort();
1295
+ return { coord: servedCoord, cohortEpoch, members, stabilizedAt: now };
1296
+ };
1297
+ // Key-less interim mode has only a verify-only per-coord signer (its `assemble` rejects), so the
1298
+ // publish hooks must no-op rather than surface a rejected promise — matching the documented
1299
+ // "publisher paths are simply not driven without a key" contract. Without this guard a future
1300
+ // gossip-cadence driver iterating `registry.all()` would reject on every key-less engine.
1301
+ const canPublish = ctx.privateKey !== undefined;
1302
+
1303
+ // --- epoch-rotation attestation production (cohort-topic-trust-anchor-rotation-production) ---
1304
+ // The first `k − x` members of the cert's ascending order; a change across a publish is a rotation
1305
+ // (mirrors the publisher's own republish gate — same inputs, so the two agree on what is a rotation).
1306
+ const firstKx = (members: readonly Uint8Array[]): string[] =>
1307
+ [...members].sort(compareBytes).slice(0, ctx.minSigs).map(bytesToB64url);
1308
+ const firstKxChanged = (a: CohortIdentity, b: CohortIdentity): boolean =>
1309
+ !sameStringOrder(firstKx(a.memberBytes), firstKx(b.memberBytes));
1310
+
1311
+ /**
1312
+ * Threshold-sign the new cert's canonical payload under the **predecessor** cohort identity, producing the
1313
+ * `{ prevEpoch, rotationSig, rotationSigners }` attestation — or `undefined` if the predecessor quorum is
1314
+ * unreachable (mass churn / partition), in which case the caller publishes the rotation cert WITHOUT an
1315
+ * attestation (trust falls to the direct anchor / TOFU, no worse than a non-rotation publish). The `/sign`
1316
+ * round is scoped to the prior epoch's members (`kind: "rotation"`), so the endorsers are the genuinely
1317
+ * outgoing cohort. The payload is built through the SAME `membershipCertSignable` the publisher signs, so
1318
+ * the signature image matches exactly (the db-core chain check verifies `rotationSig` over it).
1319
+ */
1320
+ const produceRotation = async (snapshot: CohortSnapshot, predecessor: CohortIdentity): Promise<RotationAttestation | undefined> => {
1321
+ const payload = membershipCertSigningPayload(membershipCertSignable(snapshot));
1322
+ const selfStr = bytesToPeerIdString(ctx.selfMemberBytes);
1323
+ const crypto = new FretCohortThresholdCrypto({
1324
+ kind: "rotation",
1325
+ privateKey: ctx.privateKey!, // canPublish guard: rotation only runs from a publish, which no-ops key-less
1326
+ selfMember: ctx.selfMemberBytes,
1327
+ coord: (): RingCoord => servedCoord,
1328
+ cohortEpoch: (): Uint8Array => predecessor.epoch, // prevEpoch — scopes the endorsement to the prior epoch
1329
+ cohortMembers: (): string[] => [...predecessor.memberStrs], // dial the OUTGOING cohort
1330
+ dialSign: ctx.dialSign,
1331
+ selfEligible: (): boolean => predecessor.memberStrs.includes(selfStr),
1332
+ });
1333
+ const signer = createCohortSigner(crypto, ctx.minSigs);
1334
+ try {
1335
+ const { thresholdSig, signers } = await signer.thresholdSign(payload);
1336
+ return { prevEpoch: predecessor.epoch, rotationSig: thresholdSig, rotationSigners: signers };
1337
+ } catch (err) {
1338
+ log("cohort-topic: rotation attestation skipped at coord %s — predecessor quorum unavailable: %o", bytesToB64url(servedCoord), err);
1339
+ return undefined;
1340
+ }
1341
+ };
1342
+
1343
+ /**
1344
+ * Publish (or refresh) this cohort's membership cert, attaching a rotation attestation when the first
1345
+ * `k − x` changed since the last publish. `refresh` selects the publisher path: `false` for a
1346
+ * stabilization event ({@link CoordEngine.onStabilized}), `true` for the periodic refresh
1347
+ * ({@link CoordEngine.pumpMembership}). A first-`k − x` change is a stabilization regardless of which hook
1348
+ * fired, so it routes through `onStabilized` (which republishes promptly on the change) carrying the
1349
+ * attestation; the `/sign` round runs only on that change, so it costs one round per rotation, never per
1350
+ * tick. Key-less interim mode no-ops (the verify-only signer cannot assemble).
1351
+ */
1352
+ const publishMembership = async (now: number, refresh: boolean): Promise<MembershipCertV1 | undefined> => {
1353
+ if (!canPublish) {
1354
+ return undefined;
1355
+ }
1356
+ const snapshot = snapshotAt(now); // also observes the current identity (snapshotAt → cohort())
1357
+ const current = identityOf(snapshot);
1358
+ const predecessor = rotationState.predecessor();
1359
+ const rotating = predecessor !== undefined && firstKxChanged(predecessor, current);
1360
+ let published: MembershipCertV1 | undefined;
1361
+ if (rotating) {
1362
+ const rotation = await produceRotation(snapshot, predecessor!);
1363
+ published = await membershipPublisher.onStabilized(snapshot, now, rotation);
1364
+ } else {
1365
+ published = await (refresh ? membershipPublisher.tick(snapshot, now) : membershipPublisher.onStabilized(snapshot, now));
1366
+ }
1367
+ if (published !== undefined) {
1368
+ rotationState.recordPublished(current);
1369
+ ctx.onCertPublished?.(published);
1370
+ }
1371
+ return published;
1372
+ };
1373
+
1374
+ const willingness = createWillingnessCheck({
1375
+ barometer: ctx.barometer,
1376
+ view,
1377
+ selfMember,
1378
+ primaryTopicCount: (tier: Tier): number => countPrimaryTopics(store, ctx.selfMemberBytes, tier),
1379
+ config: { cohortSize: ctx.wantK },
1380
+ });
1381
+ const traffic = createTrafficCounters({ view, store, selfMember });
1382
+ const promotion = createPromotionLifecycle({
1383
+ store,
1384
+ loadBucket: (topicId: Uint8Array): number => ctx.barometer.bucket(tierOfTopic(store, topicId)),
1385
+ // Single-cohort milestone: a tier-0 cohort with no children. Child-cohort tracking is a follow-on.
1386
+ childCohortCount: (): number => 0,
1387
+ treeTier: (): number => treeTier,
1388
+ // `coord_{d-1}(P, topicId)`; never invoked at the root (demotion is gated on `treeTier > 0`), so
1389
+ // the `d = 0` branch (clamped to `coord_0`) is a well-formed placeholder that the lifecycle skips.
1390
+ parentCoord: (topicId: Uint8Array): Uint8Array => ctx.addressing.coord(Math.max(0, treeTier - 1), participantCoord, topicId),
1391
+ cohortEpoch: localEpoch,
1392
+ signer: noticeSigner,
1393
+ // Production defaults (cap_promote = 64, …) unless the host was given a promotion override — the
1394
+ // live-tier e2e lowers `capPromote` to drive promotion with a small participant count. The
1395
+ // coord-derived inputs above (treeTier / childCohortCount / parentCoord) are never overridden.
1396
+ config: ctx.promotionConfig ?? { capPromote: undefined },
1397
+ });
1398
+ // Cold-start forwarder → parent registration (gap 7). A freshly-instantiated tier-`d > 0` forwarder
1399
+ // registers with its tier-`(d − 1)` parent cohort at `parentCoord` so the parent counts it as a child;
1400
+ // the ColdStartManager holds the forwarder in `awaiting_parent` (accepts participants, holds
1401
+ // parent-involving ops) until this resolves. This supplies the TRANSPORT: route a forwarder-link frame
1402
+ // to `parentCoord` over the same `RouteAndMaybeAct` path a participant register rides. A resolved
1403
+ // round-trip is the parent ack (flip to `serving`); a rejected/timed-out route leaves the forwarder
1404
+ // `awaiting_parent` for a later retry and never crashes the instantiating register (cold-start fires
1405
+ // this fire-and-forget). The parent-side child-cohort RECORDING (`childCohortCount`, a dedicated
1406
+ // child-link frame) is a follow-on (`tickets/backlog/cohort-topic-parent-child-link`); the
1407
+ // single-tier-0 milestone has no parent (the root serves immediately), so a unit test exercises this.
1408
+ const coldStart = createColdStartManager({
1409
+ parentRegistrar: {
1410
+ registerWithParent: (topicId: Uint8Array, parentCoord: Uint8Array, tier: number, opTier?: number): Promise<void> =>
1411
+ registerForwarderWithParent(ctx, { topicId, parentCoord, treeTier: tier, opTier, participantCoord }),
1412
+ },
1413
+ });
1414
+
1415
+ // Per-coord anti-DoS guards (gap 6): each CoordEngine owns its own set — a rate-limit budget / replay
1416
+ // window / topic budget for coord A is independent of coord B. The bootstrap-evidence policy
1417
+ // (`ctx.bootstrapEvidence`) is node-level and shared by design.
1418
+ const rateLimiter = createRegisterRateLimiter(ctx.antiDos.rateLimiter);
1419
+ // The read-only lookup-probe path gets its OWN per-coord rate limiter (same config, separate budget),
1420
+ // so a probe flood cannot exhaust a participant's register budget at this coord, or vice-versa.
1421
+ const probeRateLimiter = createRegisterRateLimiter(ctx.antiDos.rateLimiter);
1422
+ const replayGuard = createCorrelationReplayGuard(ctx.antiDos.replayGuard);
1423
+ const topicBudget = createTopicBudget(ctx.antiDos.topicBudget);
1424
+ const renewal = createRenewalCohortSide({
1425
+ store,
1426
+ self: ctx.selfMemberBytes,
1427
+ slots: ctx.slots,
1428
+ cohort,
1429
+ gossip: {
1430
+ // Per-touch replication, batched to one gossip round: a served ping/re-attach queues the touched
1431
+ // record; the next round drains it so cohort members converge on the active set + assignments.
1432
+ touch: (rec): void => pending.touch(rec),
1433
+ // A TTL sweep eviction is gossiped so siblings drop the dead record (convergence on eviction).
1434
+ evicted: (rec): void => pending.evicted(rec),
1435
+ },
1436
+ verifyParticipantSig: ctx.verifyParticipantSig,
1437
+ });
1438
+
1439
+ const engine = createCohortMemberEngine({
1440
+ self: ctx.selfMemberBytes,
1441
+ profile: ctx.profile,
1442
+ hash: ctx.hash,
1443
+ store,
1444
+ slots: ctx.slots,
1445
+ willingness,
1446
+ promotion,
1447
+ coldStart,
1448
+ traffic,
1449
+ renewal,
1450
+ cohort,
1451
+ quorumWilling: (tier: Tier): boolean => ctx.profile.willingTiers.has(tier),
1452
+ // Anti-DoS guards (gap 6): per-coord rate/replay/budget; node-level bootstrap-evidence policy.
1453
+ rateLimiter,
1454
+ // Dedicated probe-path rate limiter (independent budget from `rateLimiter`).
1455
+ probeRateLimiter,
1456
+ replayGuard,
1457
+ topicBudget,
1458
+ bootstrapEvidence: ctx.bootstrapEvidence,
1459
+ verifyRegisterSig: ctx.verifyRegisterSig,
1460
+ // Admission-time replication: enqueue the just-admitted record so siblings hold a replica before the
1461
+ // participant's first renewal touch (closes the accept→first-touch durability window). Same queue +
1462
+ // last-writer-wins as the renewal `gossip.touch`.
1463
+ onAdmit: (rec): void => pending.touch(rec),
1464
+ // A promotion notice signed on an arrival is broadcast to the cohort around this served coord
1465
+ // (and the parent for a demotion). The engine only knows the notice; the host adds the coord.
1466
+ onNotice: (notice): void => ctx.broadcastNotice?.(notice, servedCoord),
1467
+ log,
1468
+ });
1469
+
1470
+ /** Distinct topics this engine currently holds state for (the gossip-summary / demotion iteration set). */
1471
+ const residentTopics = (): Uint8Array[] => {
1472
+ const byKey = new Map<string, Uint8Array>();
1473
+ for (const rec of store.listAll()) {
1474
+ byKey.set(bytesToB64url(rec.topicId), rec.topicId);
1475
+ }
1476
+ return [...byKey.values()];
1477
+ };
1478
+
1479
+ // One gossip round: sweep stale records (firing the `evicted` deltas), freeze each resident topic's
1480
+ // traffic summary, drain the touch/evicted deltas, then assemble + sign + broadcast the frame. Idle
1481
+ // empty engines (no topics, no deltas) build no frame and skip the broadcast.
1482
+ const gossipRound = async (now: number): Promise<CohortGossipV1 | undefined> => {
1483
+ engine.sweepStale(now);
1484
+ const topicSummaries = residentTopics().map((topicId) =>
1485
+ toCohortTopicSummary(topicId, traffic.publish(topicId, now), {
1486
+ tier: tierOfTopic(store, topicId),
1487
+ directParticipants: store.directParticipants(topicId),
1488
+ promoted: promotion.isPromoted(topicId),
1489
+ // Single-cohort milestone: no child cohorts tracked. Child-cohort tracking is a follow-on.
1490
+ childCohortCount: 0,
1491
+ }),
1492
+ );
1493
+ const { records, evicted } = pending.drain();
1494
+ const g = buildCohortGossip({
1495
+ fromMember: selfMember,
1496
+ coord: bytesToB64url(servedCoord),
1497
+ cohortEpoch: bytesToB64url(localEpoch()),
1498
+ profile: ctx.profile,
1499
+ barometer: ctx.barometer,
1500
+ windowSeconds: DEFAULT_TRAFFIC_WINDOW_SECONDS,
1501
+ topicSummaries,
1502
+ records,
1503
+ evicted,
1504
+ timestamp: now,
1505
+ });
1506
+ if (g === undefined) {
1507
+ return undefined;
1508
+ }
1509
+ if (ctx.signGossip !== undefined) {
1510
+ g.signature = await ctx.signGossip(g);
1511
+ }
1512
+ bus.broadcast(g);
1513
+ return g;
1514
+ };
1515
+
1516
+ // Time-driven demotion across resident topics; any returned notice is broadcast to the cohort (and the
1517
+ // parent coord) via the same path a promotion uses. Skipped without a key (verify-only signer can't
1518
+ // assemble); for the single-cohort tier-0 milestone the lifecycle never demotes (the root has no parent).
1519
+ const demotionTick = async (now: number): Promise<void> => {
1520
+ if (!canPublish) {
1521
+ return;
1522
+ }
1523
+ for (const topicId of residentTopics()) {
1524
+ let notice: DemotionNoticeV1 | undefined;
1525
+ try {
1526
+ notice = await promotion.maybeDemote(topicId, now);
1527
+ } catch (err) {
1528
+ log("cohort-topic: demotion sign/broadcast failed for topic %s: %o", bytesToB64url(topicId), err);
1529
+ continue;
1530
+ }
1531
+ if (notice !== undefined) {
1532
+ ctx.broadcastNotice?.(notice, servedCoord);
1533
+ }
1534
+ }
1535
+ };
1536
+
1537
+ return {
1538
+ servedCoord,
1539
+ treeTier,
1540
+ engine,
1541
+ cohort,
1542
+ cohortIdentityAt: (epoch: Uint8Array): readonly string[] | undefined => rotationState.membersAt(bytesToB64url(epoch)),
1543
+ hasState: (): boolean => store.listAll().length > 0,
1544
+ holds: (topicId: Uint8Array, participantId: Uint8Array): boolean =>
1545
+ store.getByParticipant(topicId, participantId) !== undefined,
1546
+ records: (topicId: Uint8Array): readonly RegistrationRecord[] => store.listByTopic(topicId),
1547
+ topicTraffic: (topicId: Uint8Array): TopicTrafficV1 => traffic.snapshot(topicId),
1548
+ cohortView: (): CohortView => view,
1549
+ servesTopic: (topicId: Uint8Array): boolean =>
1550
+ store.directParticipants(topicId) > 0 || coldStart.get(topicId) !== undefined,
1551
+ budgetHasTopic: (topicId: Uint8Array): boolean => topicBudget.has(topicId),
1552
+ budgetParticipantCount: (topicId: Uint8Array): number | undefined => topicBudget.participantCount(topicId),
1553
+ forwarder: (topicId: Uint8Array): Forwarder | undefined => coldStart.get(topicId),
1554
+ isPromoted: (topicId: Uint8Array): boolean => promotion.isPromoted(topicId),
1555
+ applyPromotionNotice: (notice, now): void => promotion.applyPromotionNotice(notice, now),
1556
+ applyDemotionNotice: (notice, now): void => promotion.applyDemotionNotice(notice, now),
1557
+ onStabilized: (now: number): Promise<MembershipCertV1 | undefined> => publishMembership(now, false),
1558
+ pumpMembership: (now: number): Promise<MembershipCertV1 | undefined> => publishMembership(now, true),
1559
+ gossipRound,
1560
+ demotionTick,
1561
+ close: (): void => bus.close(),
1562
+ };
1563
+ }
1564
+
1565
+ /**
1566
+ * Resolve an inbound `RenewV1` to the coord engine holding its record and run the renewal. A `RenewV1`
1567
+ * carries no `treeTier`, so the held record — not a recomputed coord — names the cohort. If no engine
1568
+ * on this host holds it (cross-node renewal, post-restart eviction, or replication lag), reply
1569
+ * `unknown_registration` so the participant's failover loop tries its backups and ultimately re-runs
1570
+ * the `d_max` lookup (§TTL and renewal) — never throw.
1571
+ */
1572
+ export function resolveRenew(registry: CoordRegistry, renew: RenewV1, now: number): RenewReplyV1 {
1573
+ const topicId = b64urlToBytes(renew.topicId);
1574
+ const participantId = b64urlToBytes(renew.participantId);
1575
+ const holder = registry.findHolder(topicId, participantId);
1576
+ if (holder === undefined) {
1577
+ return { v: 1, result: "unknown_registration" };
1578
+ }
1579
+ return holder.engine.handleRenew(renew, now);
1580
+ }
1581
+
1582
+ // --- intra-cohort sign endorsement ---
1583
+
1584
+ /** Dependencies for the `/sign` endorsement policy ({@link handleSignRequest}). */
1585
+ export interface SignEndorsementDeps {
1586
+ /** The node's libp2p key (signs the endorsement). Absent → every request is refused (no key to sign with). */
1587
+ readonly privateKey: PrivateKey | undefined;
1588
+ /** Self's dialable member id (UTF-8 peer-id string bytes). */
1589
+ readonly selfMember: Uint8Array;
1590
+ /** Cohort member peer-id strings around `coord` (self included). */
1591
+ readonly cohortMembersAround: (coord: RingCoord) => string[];
1592
+ /** Current cohort epoch (raw bytes) for `coord`. */
1593
+ readonly currentEpoch: (coord: RingCoord) => Uint8Array;
1594
+ /**
1595
+ * For a `"rotation"` request: the cohort member peer-id strings this node served under `epoch` (the
1596
+ * predecessor epoch carried as `request.cohortEpoch`), or `undefined` if it tracked no such epoch. The
1597
+ * host wires it to the served coord's {@link CoordEngine.cohortIdentityAt}; absent → rotation
1598
+ * endorsement is unavailable (a node with no per-coord rotation history refuses the hand-off).
1599
+ */
1600
+ readonly priorCohortMembersAt?: (coord: RingCoord, epoch: Uint8Array) => readonly string[] | undefined;
1601
+ /**
1602
+ * The endorser's own canonical `MembershipCertV1` signable fields for `coord` at its **current** epoch,
1603
+ * re-derived from its own cohort snapshot ({@link membershipCertSignable} with `stabilizedAt`
1604
+ * omitted/ignored). Binds a `membership` endorsement to the endorser's independent view so a cohort
1605
+ * insider cannot collect honest signatures over a cert the cohort never agreed to (falsified `members`
1606
+ * or `cohortCoord`). `cohortEpoch` here MUST equal `bytesToB64url(currentEpoch(coord))`; `members` is the
1607
+ * ascending-sorted base64url cohort set (the cert / sharding order). Absent → a `membership` request is
1608
+ * refused (no view to bind the cert against). See `cohort-topic-sign-endorsement-payload-binding`.
1609
+ */
1610
+ readonly expectedMembershipFields?: (coord: RingCoord) => { cohortCoord: string; cohortEpoch: string; members: string[] };
1611
+ /**
1612
+ * Wall clock (ms) for the `stabilizedAt` far-future sanity bound on a `membership` payload. The host
1613
+ * wires {@link Date.now}; tests inject a fixed clock. Absent → the far-future bound is skipped (only
1614
+ * finiteness is enforced), keeping minimal / key-less composition working.
1615
+ */
1616
+ readonly now?: () => number;
1617
+ }
1618
+
1619
+ /** Tolerated future skew (ms) for a `membership` payload's `stabilizedAt` — a value beyond this is refused. */
1620
+ const SIGN_STABILIZED_AT_SKEW_MS = 5_000;
1621
+
1622
+ /** The canonical signable-image tag a non-`rotation` {@link SignKind} must carry (binds tag ↔ kind). */
1623
+ const SIGNABLE_IMAGE_TAG: Record<Exclude<SignKind, "rotation">, string> = {
1624
+ membership: "MembershipCertV1",
1625
+ promotion: "PromotionNoticeV1",
1626
+ demotion: "DemotionNoticeV1",
1627
+ };
1628
+
1629
+ /**
1630
+ * Decode a `/sign` payload's canonical signable array image — `utf8(JSON.stringify([...]))` produced by
1631
+ * `sig/payloads.ts`. Returns the decoded array, or `undefined` when the bytes are not base64url of a JSON
1632
+ * array. NOTE: the payload is a raw signable image, **not** a `CohortMessageV1`, so it is decoded with
1633
+ * `JSON.parse`, never `decodeCohortMessage`.
1634
+ */
1635
+ function decodeSignableImage(payloadB64: string): unknown[] | undefined {
1636
+ try {
1637
+ const decoded = JSON.parse(new TextDecoder().decode(b64urlToBytes(payloadB64))) as unknown;
1638
+ return Array.isArray(decoded) ? decoded : undefined;
1639
+ } catch {
1640
+ return undefined;
1641
+ }
1642
+ }
1643
+
1644
+ /**
1645
+ * Deep-equal of an unknown payload field against the endorser's own ascending-sorted base64url member
1646
+ * list. Guards the `unknown` (a non-array payload field → not a match) before delegating to the shared
1647
+ * ordered-string-array equality; element-wise `!==` makes a non-string entry a mismatch.
1648
+ */
1649
+ function sameMemberList(image: unknown, expected: readonly string[]): boolean {
1650
+ return Array.isArray(image) && sameStringOrder(image, expected);
1651
+ }
1652
+
1653
+ /**
1654
+ * The `/sign` endorsement policy: decide whether to endorse a {@link SignRequestV1} and, if so, return
1655
+ * this node's Ed25519 peer-key signature over the **exact** request payload. A member endorses only when
1656
+ * both it and the requester are members of the cohort around `coord` under the request's epoch, **and**
1657
+ * the payload bytes re-derive to something this node independently agrees to attest — so it never signs
1658
+ * for outsiders and never blindly signs requester-supplied bytes. It still signs the exact image (no
1659
+ * re-canonicalization), so the assembled signature verifies against what the requester collected.
1660
+ *
1661
+ * **Payload binding (cohort-topic-sign-endorsement-payload-binding).** The cohort + wire-epoch gates never
1662
+ * inspect `request.payload`; without binding it an insider could collect honest signatures over a cert the
1663
+ * cohort never agreed to. After those gates the endorser decodes the canonical signable image
1664
+ * (`sig/payloads.ts` — a `JSON.parse`d array, NOT a `CohortMessageV1`) and refuses unless:
1665
+ *
1666
+ * - **all kinds** — the image tag matches the kind (`membership`→`MembershipCertV1`, etc.) and the
1667
+ * payload-internal `cohortEpoch` equals this node's current epoch for `coord`; and
1668
+ * - **`membership`** — `cohortCoord`, the full `members` list (deep-equal to the endorser's own ascending
1669
+ * re-derived set), and a finite, not-far-future `stabilizedAt` all match its independent view
1670
+ * ({@link SignEndorsementDeps.expectedMembershipFields}). Because epoch = H(members) in this host, the
1671
+ * members and internal-epoch checks are mutually reinforcing: a forged member list cannot also carry the
1672
+ * honest epoch. The participant verifier still independently re-checks `signers ⊆ cert.members`.
1673
+ *
1674
+ * The kind-specific **hot/cold** refinement for `promotion` / `demotion` (the endorser additionally
1675
+ * requiring its own replicated `directParticipants` to be hot / cold) remains deferred: it needs a
1676
+ * per-topic binding the `(payload, minSigs)` port can't carry and gossip record replication that is still
1677
+ * interim. Parked in `cohort-topic-sign-endorsement-hotcold-refinement` (backlog).
1678
+ *
1679
+ * **`"rotation"` (epoch hand-off).** A rotation request carries the **prior** epoch as `cohortEpoch` and
1680
+ * asks the outgoing cohort to sign the *successor* cert. The gate therefore checks **prior**-epoch
1681
+ * membership instead of current: the endorser must have served the cohort at `prevEpoch`, and the
1682
+ * requester must have been a member of that prior cohort too (the genuinely outgoing set). The verifier
1683
+ * still independently re-checks `rotationSigners ⊆ predecessor-cert.members`, so this gate is the
1684
+ * load-shedding sanity check, not the trust root. See `cohort-topic-trust-anchor-rotation-production`.
1685
+ */
1686
+ export async function handleSignRequest(request: SignRequestV1, fromPeerStr: string, deps: SignEndorsementDeps): Promise<SignReplyV1> {
1687
+ if (deps.privateKey === undefined) {
1688
+ return { v: 1, refused: true, reason: "node has no signing key" };
1689
+ }
1690
+ const coord = b64urlToBytes(request.coord);
1691
+ const selfStr = bytesToPeerIdString(deps.selfMember);
1692
+
1693
+ if (request.kind === "rotation") {
1694
+ // Prior-epoch gate: endorse a hand-off only from an epoch THIS node was a member of, and only for a
1695
+ // requester that was a member of that same prior cohort. `request.cohortEpoch` IS the prevEpoch.
1696
+ const prevEpoch = b64urlToBytes(request.cohortEpoch);
1697
+ const priorMembers = deps.priorCohortMembersAt?.(coord, prevEpoch);
1698
+ if (priorMembers === undefined) {
1699
+ return { v: 1, refused: true, reason: "not a member of the prior cohort at prevEpoch" };
1700
+ }
1701
+ if (!priorMembers.includes(selfStr)) {
1702
+ return { v: 1, refused: true, reason: "self not in the prior cohort" };
1703
+ }
1704
+ if (!priorMembers.includes(fromPeerStr)) {
1705
+ return { v: 1, refused: true, reason: "requester not in the prior cohort" };
1706
+ }
1707
+ // Structural sanity only: a rotation carries the SUCCESSOR cert image. Full successor re-derivation is
1708
+ // out of scope here (the endorser is the OUTGOING cohort and may not know the successor member set), so
1709
+ // the prior-epoch gate stays the trust check — but reject a payload that is not even a MembershipCertV1
1710
+ // image so the gate cannot be tricked into signing junk bytes. See the rotation follow-on note.
1711
+ const rotImage = decodeSignableImage(request.payload);
1712
+ if (rotImage === undefined || rotImage[0] !== SIGNABLE_IMAGE_TAG.membership) {
1713
+ return { v: 1, refused: true, reason: "rotation payload is not a MembershipCertV1 image" };
1714
+ }
1715
+ const signature = await signPeer(deps.privateKey, b64urlToBytes(request.payload));
1716
+ return { v: 1, signer: bytesToB64url(deps.selfMember), signature: bytesToB64url(signature) };
1717
+ }
1718
+
1719
+ const members = deps.cohortMembersAround(coord);
1720
+ if (!members.includes(selfStr)) {
1721
+ return { v: 1, refused: true, reason: "not a cohort member for coord" };
1722
+ }
1723
+ if (!members.includes(fromPeerStr)) {
1724
+ return { v: 1, refused: true, reason: "requester not in cohort" };
1725
+ }
1726
+ if (!bytesEqual(b64urlToBytes(request.cohortEpoch), deps.currentEpoch(coord))) {
1727
+ return { v: 1, refused: true, reason: "cohort epoch mismatch" };
1728
+ }
1729
+
1730
+ // --- payload binding: re-derive what we are willing to attest and refuse anything that does not match ---
1731
+ // The wire-field gates above never inspect `request.payload`; without this an insider could collect honest
1732
+ // signatures over a cert the cohort never agreed to (falsified members / kind-mismatched bytes).
1733
+ const image = decodeSignableImage(request.payload);
1734
+ if (image === undefined) {
1735
+ return { v: 1, refused: true, reason: "payload is not a decodable signable image" };
1736
+ }
1737
+ // All kinds — bind tag ↔ kind (a `membership` request must carry a MembershipCertV1 image, closing the
1738
+ // kind-mismatch hole where a kind-agnostic threshold blob verifies for whatever the bytes decode to).
1739
+ const expectedTag = SIGNABLE_IMAGE_TAG[request.kind];
1740
+ if (image[0] !== expectedTag) {
1741
+ return { v: 1, refused: true, reason: `payload kind tag mismatch (expected ${expectedTag})` };
1742
+ }
1743
+ // All kinds — bind the payload-internal `cohortEpoch` to our own current epoch (closes the falsified-internal
1744
+ // -epoch hole, for promotion / demotion too). It is `image[2]` for a MembershipCertV1 image and the last
1745
+ // element for promotion / demotion (see `sig/payloads.ts`).
1746
+ const currentEpochB64 = bytesToB64url(deps.currentEpoch(coord));
1747
+ const embeddedEpoch = request.kind === "membership" ? image[2] : image[image.length - 1];
1748
+ if (embeddedEpoch !== currentEpochB64) {
1749
+ return { v: 1, refused: true, reason: "payload cohortEpoch does not match endorser view" };
1750
+ }
1751
+
1752
+ if (request.kind === "membership") {
1753
+ // The core fix: bind coord + members + stabilizedAt to the endorser's independently re-derived view.
1754
+ // Because epoch = H(members) in this host, a forged member list cannot also carry the honest epoch — the
1755
+ // embedded-epoch gate above and this members gate are mutually reinforcing.
1756
+ const expected = deps.expectedMembershipFields?.(coord);
1757
+ if (expected === undefined) {
1758
+ return { v: 1, refused: true, reason: "no membership view to bind the cert against" };
1759
+ }
1760
+ if (image[1] !== expected.cohortCoord) {
1761
+ return { v: 1, refused: true, reason: "payload cohortCoord does not match endorser view" };
1762
+ }
1763
+ if (!sameMemberList(image[3], expected.members)) {
1764
+ return { v: 1, refused: true, reason: "payload members do not match endorser view" };
1765
+ }
1766
+ const stabilizedAt = image[4];
1767
+ if (typeof stabilizedAt !== "number" || !Number.isFinite(stabilizedAt)) {
1768
+ return { v: 1, refused: true, reason: "payload stabilizedAt is not a finite number" };
1769
+ }
1770
+ if (deps.now !== undefined && stabilizedAt > deps.now() + SIGN_STABILIZED_AT_SKEW_MS) {
1771
+ return { v: 1, refused: true, reason: "payload stabilizedAt is far-future" };
1772
+ }
1773
+ }
1774
+
1775
+ const signature = await signPeer(deps.privateKey, b64urlToBytes(request.payload));
1776
+ return { v: 1, signer: bytesToB64url(deps.selfMember), signature: bytesToB64url(signature) };
1777
+ }
1778
+
1779
+ // --- inbound promote-protocol notices (verify + apply) ---
1780
+
1781
+ /** A decoded `promote`-protocol frame, tagged by which notice it is. */
1782
+ export type InboundNotice =
1783
+ | { readonly kind: "promotion"; readonly notice: PromotionNoticeV1 }
1784
+ | { readonly kind: "demotion"; readonly notice: DemotionNoticeV1 };
1785
+
1786
+ /** Outcome of {@link verifyAndApplyNotice}. */
1787
+ export type NoticeOutcome = "applied" | "untrusted" | "dropped";
1788
+
1789
+ /**
1790
+ * Outcome of {@link handleInboundNotice} — the {@link NoticeOutcome}s plus the cheap pre-verify drops the
1791
+ * anti-abuse gate adds before any signature work runs:
1792
+ *
1793
+ * - `"undecodable"` — the frame is neither a promotion nor a demotion notice.
1794
+ * - `"rate-limited"` — the dialing `(peer, topic)` is over its `register_rate_per_peer` ceiling.
1795
+ * - `"stale"` — the notice's `effectiveAt` is at or below the last *applied* notice for its
1796
+ * `(topic, tier)` (a replay / out-of-order frame); dropped before `verifyMessage`.
1797
+ */
1798
+ export type InboundNoticeResult = NoticeOutcome | "undecodable" | "rate-limited" | "stale";
1799
+
1800
+ /**
1801
+ * Node-level anti-abuse state for the `promote` handler (`cohort-topic-promote-handler-verify-amplification`).
1802
+ * The handler is node-level (one per node, not per coord), so unlike the per-{@link CoordEngine} register-path
1803
+ * guards it owns its own instances here.
1804
+ */
1805
+ export interface PromoteGate {
1806
+ /**
1807
+ * Per-`(peer, topic)` inbound-notice rate limiter (reuses the register-path limiter). A peer streaming
1808
+ * forged notices at one topic is dropped once it exceeds the ceiling, before any verify / membership work.
1809
+ */
1810
+ readonly rateLimiter: RegisterRateLimiter;
1811
+ /**
1812
+ * Per-`(topicId, tier)` high-water of the last *applied* notice's `effectiveAt`. A notice at or below the
1813
+ * water is a replay / out-of-order frame and is dropped before verification. Updated **only** on an
1814
+ * `"applied"` outcome (never on an unverified frame), so a forged notice carrying `effectiveAt = Infinity`
1815
+ * cannot poison the water and lock out legitimate notices.
1816
+ *
1817
+ * **Bounded.** An {@link LruMap} capped at {@link PROMOTE_HIGHWATER_MAX_KEYS} so the retain-forever shape
1818
+ * cannot leak on a long-lived node. Unlike the limiter this is *not* attacker-growable (it is written only
1819
+ * on an `"applied"` outcome, which needs a verified `≥ minSigs` cohort signature — reads of forged
1820
+ * `topicId`s via `.get` create nothing), so it never evicts under legitimate load; the cap is the
1821
+ * belt-and-suspenders bound. Evicting an entry is safe: the engine's {@link PromotionLifecycle} is
1822
+ * independently idempotent and `effectiveAt`-ordered (`PromotionState.lastEffectiveAt`), so an
1823
+ * evicted-then-replayed older notice re-verifies (one bounded, rate-capped `verifyMessage`) and then
1824
+ * **no-ops at the engine** rather than (re-)applying. Water absence only *opens* the gate, never closes it.
1825
+ */
1826
+ readonly highWater: LruMap<string, number>;
1827
+ }
1828
+
1829
+ /**
1830
+ * Hard cap on tracked `(topicId, tier)` high-water entries; the least-recently-touched are evicted beyond
1831
+ * this. A modest bound is plenty — only verified applies grow the map, so it never evicts under legitimate
1832
+ * load — but it caps the otherwise retain-forever shape on a long-lived node.
1833
+ */
1834
+ export const PROMOTE_HIGHWATER_MAX_KEYS = 8192;
1835
+
1836
+ /** Build the default {@link PromoteGate} from the (optional) anti-DoS rate-limiter config. */
1837
+ export function createPromoteGate(rateLimiterConfig?: RegisterRateLimiterConfig): PromoteGate {
1838
+ return { rateLimiter: createRegisterRateLimiter(rateLimiterConfig), highWater: new LruMap<string, number>(PROMOTE_HIGHWATER_MAX_KEYS) };
1839
+ }
1840
+
1841
+ /**
1842
+ * The slice of a {@link CoordEngine} the inbound notice path needs: the cohort coord the signers should
1843
+ * belong to (for verification) and the apply hooks. {@link CoordEngine} satisfies this; tests can pass a
1844
+ * minimal stand-in.
1845
+ */
1846
+ export interface NoticeApplyTarget {
1847
+ readonly servedCoord: RingCoord;
1848
+ applyPromotionNotice(notice: PromotionNoticeV1, now: number): void;
1849
+ applyDemotionNotice(notice: DemotionNoticeV1, now: number): void;
1850
+ }
1851
+
1852
+ /**
1853
+ * Decode a `promote`-protocol frame as a {@link PromotionNoticeV1} or {@link DemotionNoticeV1} (try one,
1854
+ * then the other), or `undefined` if it is neither. The two shapes are disjoint — a promotion carries
1855
+ * `fromTier`/`toTier`, a demotion carries `parentCohortCoord` — so the structural validators cleanly
1856
+ * discriminate.
1857
+ */
1858
+ export function decodeInboundNotice(frame: Uint8Array, maxBytes?: number): InboundNotice | undefined {
1859
+ const decoded = decodeCohortMessage(frame, maxBytes);
1860
+ const promotion = tryValidate(() => validatePromotionNoticeV1(decoded));
1861
+ if (promotion !== undefined) {
1862
+ return { kind: "promotion", notice: promotion };
1863
+ }
1864
+ const demotion = tryValidate(() => validateDemotionNoticeV1(decoded));
1865
+ if (demotion !== undefined) {
1866
+ return { kind: "demotion", notice: demotion };
1867
+ }
1868
+ return undefined;
1869
+ }
1870
+
1871
+ /**
1872
+ * Per-coord minimum interval (ms) between membership refetches on the inbound `promote` path. Caps the
1873
+ * amplification a flood of forged notices can drive: a stream of verify-misses triggers at most one
1874
+ * `source.fetch()` per coord per this window, while a cold cache / membership rotation still re-fetches
1875
+ * once it elapses (eventual refetch preserved). 60 s mirrors the anti-DoS rate window.
1876
+ */
1877
+ export const PROMOTE_REFETCH_MIN_INTERVAL_MS = 60_000;
1878
+
1879
+ /**
1880
+ * Verify an inbound notice's threshold signature against the cohort `MembershipCertV1` for
1881
+ * `target.servedCoord` and, on success, apply it to the target's promotion lifecycle. Returns:
1882
+ *
1883
+ * - `"dropped"` — no local engine serves the notice's `(topic, tier)` (e.g. a demotion arriving at a
1884
+ * parent that does not track the child); nothing to apply to.
1885
+ * - `"untrusted"` — the `signers` are not a `≥ minSigs` subset of the cohort cert, or the multisig does
1886
+ * not verify (a forged single-signer / short-quorum notice); local state is left unchanged.
1887
+ * - `"applied"` — verified and applied.
1888
+ *
1889
+ * The payload is rebuilt with the canonical `sig/payloads` image the signer used — never re-canonicalized
1890
+ * independently. The verifier owns the cert lookup; this function never re-verifies inside the apply step
1891
+ * (db-core trusts this gate).
1892
+ *
1893
+ * **Bounded refetch (anti-amplification).** Both verify calls pass a {@link PROMOTE_REFETCH_MIN_INTERVAL_MS}
1894
+ * refetch bound, so a stream of forged notices drives at most **one** membership `source.fetch()` per coord
1895
+ * per interval rather than one dial per message. Eventual refetch is preserved (full suppression was
1896
+ * rejected: a legitimate sibling-adopt / demotion-to-parent notice whose cohort cert is not yet locally
1897
+ * cached must still be able to fetch it once — see `live-tier.spec.ts` test 4). A node that has cached its
1898
+ * own cohort cert via `onCertPublished` verifies a sibling-adopt notice from cache with zero fetches; a
1899
+ * cold-cache receiver pays one bounded fetch. Per `cohort-topic-promote-handler-verify-amplification`.
1900
+ */
1901
+ export async function verifyAndApplyNotice(
1902
+ inbound: InboundNotice,
1903
+ target: NoticeApplyTarget | undefined,
1904
+ verifier: MembershipVerifier,
1905
+ now: number,
1906
+ ): Promise<NoticeOutcome> {
1907
+ if (target === undefined) {
1908
+ return "dropped";
1909
+ }
1910
+ let signers: Uint8Array[];
1911
+ let sig: Uint8Array;
1912
+ try {
1913
+ signers = inbound.notice.signers.map(b64urlToBytes);
1914
+ sig = b64urlToBytes(inbound.notice.thresholdSig);
1915
+ } catch {
1916
+ return "untrusted"; // a signer / sig that is not valid base64url cannot verify
1917
+ }
1918
+ // Narrow on `inbound` (not a destructured `notice`) so the tier field and apply hook are typed per kind.
1919
+ if (inbound.kind === "promotion") {
1920
+ const payload = promotionNoticeSigningPayload(inbound.notice);
1921
+ const result = await verifier.verifyMessage(signers, target.servedCoord, inbound.notice.fromTier, payload, sig, { minRefetchIntervalMs: PROMOTE_REFETCH_MIN_INTERVAL_MS, now });
1922
+ if (result !== "verified") {
1923
+ return "untrusted";
1924
+ }
1925
+ target.applyPromotionNotice(inbound.notice, now);
1926
+ return "applied";
1927
+ }
1928
+ const payload = demotionNoticeSigningPayload(inbound.notice);
1929
+ const result = await verifier.verifyMessage(signers, target.servedCoord, inbound.notice.tier, payload, sig, { minRefetchIntervalMs: PROMOTE_REFETCH_MIN_INTERVAL_MS, now });
1930
+ if (result !== "verified") {
1931
+ return "untrusted";
1932
+ }
1933
+ target.applyDemotionNotice(inbound.notice, now);
1934
+ return "applied";
1935
+ }
1936
+
1937
+ /**
1938
+ * Full inbound `promote`-frame pipeline with the anti-abuse gate, exported so it is unit-testable without a
1939
+ * live node (`cohort-topic-promote-handler-verify-amplification`). Runs the cheapest checks first — each
1940
+ * step strictly cheaper than the next — so a flood of forged frames is shed before any signature / network
1941
+ * work:
1942
+ *
1943
+ * ```
1944
+ * decode → per-(peer,topic) rate limit → findServing → effectiveAt high-water → verify+apply
1945
+ * ```
1946
+ *
1947
+ * - **Rate limit** (`gate.rateLimiter`) keys on `(from, topicId)`; an over-rate peer is dropped before the
1948
+ * `findServing` map scan and the verify, so a peer cannot amplify junk into verify/network work.
1949
+ * - **High-water** (`gate.highWater`, per `(topicId, tier)`) drops a notice whose `effectiveAt` is at or
1950
+ * below the last *applied* one — a replay / out-of-order frame — before `verifyMessage`. It is advanced
1951
+ * **only** on an `"applied"` outcome, so a forged frame (which never verifies) cannot poison it.
1952
+ * - The receiver-side `cohortEpoch` is intentionally **not** gated on: the epoch rotates on every
1953
+ * membership change, so a legitimately in-flight notice can briefly carry the prior epoch right after a
1954
+ * rotation — making an epoch check a brittle, false-positive-prone filter. The rate limiter + high-water
1955
+ * are the load-bearing defenses; the bounded refetch (in {@link verifyAndApplyNotice}) caps the network
1956
+ * amplification on the verify itself.
1957
+ *
1958
+ * `from` is the dialing peer's substrate bytes ({@link peerIdToBytes}); the node handler converts the
1959
+ * libp2p `PeerId` before calling. One-way contract: the caller sends no ack regardless of outcome.
1960
+ */
1961
+ export async function handleInboundNotice(
1962
+ frame: Uint8Array,
1963
+ from: Uint8Array,
1964
+ registry: CoordRegistry,
1965
+ verifier: MembershipVerifier,
1966
+ gate: PromoteGate,
1967
+ now: number,
1968
+ maxBytes?: number,
1969
+ ): Promise<InboundNoticeResult> {
1970
+ const inbound = decodeInboundNotice(frame, maxBytes);
1971
+ if (inbound === undefined) {
1972
+ log("promote: dropped an undecodable notice frame");
1973
+ return "undecodable";
1974
+ }
1975
+ const tier = inbound.kind === "promotion" ? inbound.notice.fromTier : inbound.notice.tier;
1976
+ const topicId = b64urlToBytes(inbound.notice.topicId);
1977
+
1978
+ // Per-(peer, topic) rate limit — before the findServing scan and the verify.
1979
+ if (gate.rateLimiter.check(from, topicId, now).ok === false) {
1980
+ log("promote: rate-limited %s notice for topic %s tier %d", inbound.kind, inbound.notice.topicId, tier);
1981
+ return "rate-limited";
1982
+ }
1983
+
1984
+ const target = registry.findServing(topicId, tier);
1985
+ if (target === undefined) {
1986
+ log("promote: dropped %s notice for topic %s tier %d (no serving engine)", inbound.kind, inbound.notice.topicId, tier);
1987
+ return "dropped";
1988
+ }
1989
+
1990
+ // Freshness / replay gate: drop an at-or-below-high-water notice before the expensive verify.
1991
+ const waterKey = `${inbound.notice.topicId}|${tier}`;
1992
+ const water = gate.highWater.get(waterKey);
1993
+ if (water !== undefined && inbound.notice.effectiveAt <= water) {
1994
+ 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);
1995
+ return "stale";
1996
+ }
1997
+
1998
+ const outcome = await verifyAndApplyNotice(inbound, target, verifier, now);
1999
+ if (outcome === "applied") {
2000
+ // Advance the high-water only on a *verified-and-applied* notice, so a forged frame cannot poison it.
2001
+ gate.highWater.set(waterKey, inbound.notice.effectiveAt);
2002
+ } else {
2003
+ log("promote: %s %s notice for topic %s tier %d", outcome, inbound.kind, inbound.notice.topicId, tier);
2004
+ }
2005
+ return outcome;
2006
+ }
2007
+
2008
+ /**
2009
+ * The cohort coords a notice is broadcast to over the `promote` protocol: always the cohort around
2010
+ * `servedCoord` (siblings adopt the state); for a demotion, additionally the parent cohort coord (so the
2011
+ * parent's `childCohortCount` bookkeeping converges). Pure — exported for testing the fan-out targets.
2012
+ */
2013
+ export function noticeBroadcastCoords(notice: PromotionNoticeV1 | DemotionNoticeV1, servedCoord: RingCoord): RingCoord[] {
2014
+ if ("parentCohortCoord" in notice) {
2015
+ return [servedCoord, b64urlToBytes(notice.parentCohortCoord)];
2016
+ }
2017
+ return [servedCoord];
2018
+ }
2019
+
2020
+ // --- helpers ---
2021
+
2022
+ /**
2023
+ * Cross-check the cohort FRET routed the activity to against the locally recomputed assembly around
2024
+ * `servedCoord`. A mismatch (a slightly stale routing table) is logged; the recomputed assembly is
2025
+ * trusted, so renewal / gossip / signing — which run outside the activity callback — stay consistent.
2026
+ */
2027
+ function crossCheckCohort(fret: FretService, wantK: number, servedCoord: RingCoord, fretCohort: readonly string[]): void {
2028
+ const assembled = fret.assembleCohort(servedCoord, wantK);
2029
+ if (!sameMemberSet(fretCohort, assembled)) {
2030
+ log(
2031
+ "cohort cross-check mismatch at coord %s: FRET-routed=%o assembled=%o; trusting the recomputed assembly",
2032
+ bytesToB64url(servedCoord),
2033
+ fretCohort,
2034
+ assembled,
2035
+ );
2036
+ }
2037
+ }
2038
+
2039
+ /** Positional equality over two string lists (the first-`k − x` rotation-change check). */
2040
+ function sameStringOrder(a: readonly string[], b: readonly string[]): boolean {
2041
+ if (a.length !== b.length) {
2042
+ return false;
2043
+ }
2044
+ for (let i = 0; i < a.length; i++) {
2045
+ if (a[i] !== b[i]) {
2046
+ return false;
2047
+ }
2048
+ }
2049
+ return true;
2050
+ }
2051
+
2052
+ /** Set equality over two peer-id-string lists (order-independent). */
2053
+ function sameMemberSet(a: readonly string[], b: readonly string[]): boolean {
2054
+ if (a.length !== b.length) {
2055
+ return false;
2056
+ }
2057
+ const seen = new Set(a);
2058
+ return b.every((m) => seen.has(m));
2059
+ }
2060
+
2061
+ /** Count topics this member is currently `primary` for at `tier` (the willingness budget input). */
2062
+ function countPrimaryTopics(store: ReturnType<typeof createRegistrationStore>, self: Uint8Array, tier: Tier): number {
2063
+ const seen = new Set<string>();
2064
+ for (const rec of store.listAll()) {
2065
+ if (rec.tier === tier && bytesToB64url(rec.primary) === bytesToB64url(self)) {
2066
+ seen.add(bytesToB64url(rec.topicId));
2067
+ }
2068
+ }
2069
+ return seen.size;
2070
+ }
2071
+
2072
+ /** Resolve a topic's op tier from any held record (0 if none yet). */
2073
+ function tierOfTopic(store: ReturnType<typeof createRegistrationStore>, topicId: Uint8Array): Tier {
2074
+ const recs = store.listByTopic(topicId);
2075
+ return (recs.length > 0 ? recs[0]!.tier : 0) as Tier;
2076
+ }
2077
+
2078
+ async function registerProtocolHandlers(
2079
+ node: Libp2p,
2080
+ protocols: CohortTopicProtocols,
2081
+ registry: CoordRegistry,
2082
+ dispatchRegister: (reg: RegisterV1, fretCohort: readonly string[] | undefined, now: number) => Promise<RegisterReplyV1>,
2083
+ signEndorse: (request: SignRequestV1, fromPeerStr: string) => Promise<SignReplyV1>,
2084
+ verifier: MembershipVerifier,
2085
+ promoteGate: PromoteGate,
2086
+ gossipTransport: FretCohortGossipTransport,
2087
+ publishSink: FretMembershipPublishSink,
2088
+ membershipSource: FretMembershipSource,
2089
+ selfCoord: RingCoord,
2090
+ maxBytes: number,
2091
+ ): Promise<void> {
2092
+ await Promise.all([
2093
+ // register: a direct dial carries either a RegisterV1 (re-attach walk fallback) or a RenewV1 (ping).
2094
+ node.handle(protocols.register, makeFrameHandler(async (frame): Promise<Uint8Array | undefined> => {
2095
+ const decoded = decodeCohortMessage(frame, maxBytes);
2096
+ const renew = tryValidate(() => validateRenewV1(decoded));
2097
+ if (renew !== undefined) {
2098
+ return encodeCohortMessage(resolveRenew(registry, renew, Date.now()), maxBytes);
2099
+ }
2100
+ const reg = validateRegisterV1(decoded);
2101
+ // Direct dial (not FRET-routed): no cohort member list to cross-check against.
2102
+ const reply = await dispatchRegister(reg, undefined, Date.now());
2103
+ return encodeCohortMessage(reply, maxBytes);
2104
+ }, maxBytes)),
2105
+
2106
+ // cohort-gossip: feed inbound gossip into the shared transport (one-way). It fans the frame to
2107
+ // every coord engine's bus; per-bus epoch matching governs which engine merges the record deltas.
2108
+ node.handle(protocols.gossip, makeFrameHandler(async (frame, from): Promise<Uint8Array | undefined> => {
2109
+ gossipTransport.deliver(from.toString(), frame);
2110
+ return undefined;
2111
+ }, maxBytes)),
2112
+
2113
+ // promote: threshold-signed promotion/demotion notices (one-way, gossip-style fan-out). The dialing
2114
+ // peer arrives as `from`, so the handler can gate per-(peer, topic) before any expensive work. The
2115
+ // full pipeline (rate limit → findServing → effectiveAt high-water → verify+apply, bounded
2116
+ // refetch) lives in the exported `handleInboundNotice`; it logs and never throws on the stream.
2117
+ node.handle(protocols.promote, makeFrameHandler(async (frame, from): Promise<Uint8Array | undefined> => {
2118
+ await handleInboundNotice(frame, peerIdToBytes(from), registry, verifier, promoteGate, Date.now(), maxBytes);
2119
+ return undefined; // one-way: match the gossip-style fan-out (no ack frame)
2120
+ }, maxBytes)),
2121
+
2122
+ // membership: serve this node's latest published cert; cache any cert the requester returns.
2123
+ node.handle(protocols.membership, makeFrameHandler(async (frame): Promise<Uint8Array | undefined> => {
2124
+ void frame; // request frame is the raw coord; this node serves its own cohort cert
2125
+ const latest = publishSink.latest();
2126
+ if (latest !== undefined) {
2127
+ membershipSource.cache(selfCoord, latest);
2128
+ }
2129
+ return latest ?? new Uint8Array(0);
2130
+ }, maxBytes)),
2131
+
2132
+ // sign: per-member endorsement for threshold-signature assembly. Validate the request, run the
2133
+ // endorsement policy, and reply with this node's peer-key signature over the request payload (or a
2134
+ // refusal). One Ed25519 sign and nothing more — the cohort + epoch gate bounds who we sign for.
2135
+ node.handle(protocols.sign, makeFrameHandler(async (frame, from): Promise<Uint8Array | undefined> => {
2136
+ const request = validateSignRequestV1(decodeCohortMessage(frame, maxBytes));
2137
+ const reply = await signEndorse(request, from.toString());
2138
+ return encodeCohortMessage(reply, maxBytes);
2139
+ }, maxBytes)),
2140
+ ]);
2141
+ }
2142
+
2143
+ /** Wrap a frame handler in the read-one / reply-one libp2p stream lifecycle. */
2144
+ function makeFrameHandler(
2145
+ handle: (frame: Uint8Array, from: PeerId) => Promise<Uint8Array | undefined>,
2146
+ maxBytes: number,
2147
+ ): (stream: Stream, connection: Connection) => void {
2148
+ return (stream: Stream, connection: Connection): void => {
2149
+ void (async (): Promise<void> => {
2150
+ try {
2151
+ const frame = await readAllBounded(stream, maxBytes);
2152
+ const reply = await handle(frame, connection.remotePeer);
2153
+ if (reply !== undefined) {
2154
+ stream.send(reply);
2155
+ }
2156
+ await stream.close();
2157
+ } catch {
2158
+ try {
2159
+ stream.abort(new Error("cohort-topic handler error"));
2160
+ } catch {
2161
+ /* already aborted */
2162
+ }
2163
+ }
2164
+ })();
2165
+ };
2166
+ }
2167
+
2168
+ /** Run `fn`, returning `undefined` if it throws (used to try one validator then fall through). */
2169
+ function tryValidate<T>(fn: () => T): T | undefined {
2170
+ try {
2171
+ return fn();
2172
+ } catch {
2173
+ return undefined;
2174
+ }
2175
+ }