@serfab/cadre-host 1.13.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (352) hide show
  1. package/README.md +347 -423
  2. package/dist/bin/host.d.ts +6 -3
  3. package/dist/bin/host.js +332 -524
  4. package/dist/bin/host.js.map +1 -1
  5. package/dist/bin/join-output.d.ts +56 -0
  6. package/dist/bin/join-output.js +92 -0
  7. package/dist/bin/join-output.js.map +1 -0
  8. package/dist/bin/nat-output.d.ts +54 -0
  9. package/dist/bin/nat-output.js +138 -0
  10. package/dist/bin/nat-output.js.map +1 -0
  11. package/dist/bin/ui-access.d.ts +22 -0
  12. package/dist/bin/ui-access.js +44 -0
  13. package/dist/bin/ui-access.js.map +1 -0
  14. package/dist/hosted/claim-details.d.ts +66 -0
  15. package/dist/hosted/claim-details.js +60 -0
  16. package/dist/hosted/claim-details.js.map +1 -0
  17. package/dist/hosted/hosted-node-service.d.ts +325 -0
  18. package/dist/hosted/hosted-node-service.js +678 -0
  19. package/dist/hosted/hosted-node-service.js.map +1 -0
  20. package/dist/hosted/hosted-node-store.d.ts +40 -0
  21. package/dist/hosted/hosted-node-store.js +126 -0
  22. package/dist/hosted/hosted-node-store.js.map +1 -0
  23. package/dist/{donation/donation-supervisor.d.ts → hosted/hosted-node-supervisor.d.ts} +70 -53
  24. package/dist/hosted/hosted-node-supervisor.js +396 -0
  25. package/dist/hosted/hosted-node-supervisor.js.map +1 -0
  26. package/dist/hosted/hosted-node-watcher.d.ts +95 -0
  27. package/dist/hosted/hosted-node-watcher.js +260 -0
  28. package/dist/hosted/hosted-node-watcher.js.map +1 -0
  29. package/dist/hosted/index.d.ts +16 -0
  30. package/dist/hosted/index.js +13 -0
  31. package/dist/hosted/index.js.map +1 -0
  32. package/dist/hosted/node-status.d.ts +51 -0
  33. package/dist/hosted/node-status.js +77 -0
  34. package/dist/hosted/node-status.js.map +1 -0
  35. package/dist/hosted/types.d.ts +141 -0
  36. package/dist/hosted/types.js +22 -0
  37. package/dist/hosted/types.js.map +1 -0
  38. package/dist/index.d.ts +12 -24
  39. package/dist/index.js +9 -19
  40. package/dist/index.js.map +1 -1
  41. package/dist/installer/config.d.ts +27 -34
  42. package/dist/installer/config.js +46 -15
  43. package/dist/installer/config.js.map +1 -1
  44. package/dist/installer/index.d.ts +9 -19
  45. package/dist/installer/index.js +21 -85
  46. package/dist/installer/index.js.map +1 -1
  47. package/dist/installer/paths.d.ts +0 -2
  48. package/dist/installer/paths.js +0 -4
  49. package/dist/installer/paths.js.map +1 -1
  50. package/dist/installer/wizard.d.ts +12 -9
  51. package/dist/installer/wizard.js +15 -14
  52. package/dist/installer/wizard.js.map +1 -1
  53. package/dist/nat/address-resolver.d.ts +18 -17
  54. package/dist/nat/address-resolver.js +79 -25
  55. package/dist/nat/address-resolver.js.map +1 -1
  56. package/dist/nat/address-watch.d.ts +37 -0
  57. package/dist/nat/address-watch.js +84 -0
  58. package/dist/nat/address-watch.js.map +1 -0
  59. package/dist/nat/ddns/duckdns.d.ts +2 -1
  60. package/dist/nat/ddns/duckdns.js +5 -1
  61. package/dist/nat/ddns/duckdns.js.map +1 -1
  62. package/dist/nat/ddns/index.d.ts +6 -0
  63. package/dist/nat/ddns/index.js.map +1 -1
  64. package/dist/nat/index.d.ts +13 -9
  65. package/dist/nat/index.js +7 -5
  66. package/dist/nat/index.js.map +1 -1
  67. package/dist/nat/nat-service.d.ts +220 -77
  68. package/dist/nat/nat-service.js +653 -239
  69. package/dist/nat/nat-service.js.map +1 -1
  70. package/dist/nat/nat-store.d.ts +12 -5
  71. package/dist/nat/nat-store.js +124 -29
  72. package/dist/nat/nat-store.js.map +1 -1
  73. package/dist/nat/network-mode.d.ts +19 -0
  74. package/dist/nat/network-mode.js +32 -0
  75. package/dist/nat/network-mode.js.map +1 -0
  76. package/dist/nat/port-mapper.d.ts +83 -79
  77. package/dist/nat/port-mapper.js +148 -189
  78. package/dist/nat/port-mapper.js.map +1 -1
  79. package/dist/nat/reachability.d.ts +32 -15
  80. package/dist/nat/reachability.js +110 -22
  81. package/dist/nat/reachability.js.map +1 -1
  82. package/dist/nat/types.d.ts +98 -27
  83. package/dist/nat/types.js +8 -5
  84. package/dist/nat/types.js.map +1 -1
  85. package/dist/orchestrator/host-process-orchestrator.d.ts +32 -74
  86. package/dist/orchestrator/host-process-orchestrator.js +84 -233
  87. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  88. package/dist/{installer/identity.d.ts → orchestrator/identity-file.d.ts} +5 -5
  89. package/dist/{installer/identity.js → orchestrator/identity-file.js} +6 -6
  90. package/dist/orchestrator/identity-file.js.map +1 -0
  91. package/dist/orchestrator/index.d.ts +2 -3
  92. package/dist/orchestrator/index.js +1 -1
  93. package/dist/orchestrator/index.js.map +1 -1
  94. package/dist/orchestrator/node-identity.d.ts +6 -6
  95. package/dist/orchestrator/node-identity.js +7 -7
  96. package/dist/orchestrator/node-identity.js.map +1 -1
  97. package/dist/orchestrator/port-allocator.d.ts +5 -6
  98. package/dist/orchestrator/port-allocator.js +9 -11
  99. package/dist/orchestrator/port-allocator.js.map +1 -1
  100. package/dist/orchestrator/state-store.d.ts +3 -5
  101. package/dist/orchestrator/state-store.js +2 -10
  102. package/dist/orchestrator/state-store.js.map +1 -1
  103. package/dist/orchestrator/types.d.ts +33 -22
  104. package/dist/orchestrator/types.js.map +1 -1
  105. package/dist/package-version.d.ts +1 -0
  106. package/dist/package-version.js +4 -0
  107. package/dist/package-version.js.map +1 -0
  108. package/dist/server/error-handler.d.ts +5 -25
  109. package/dist/server/error-handler.js +11 -74
  110. package/dist/server/error-handler.js.map +1 -1
  111. package/dist/server/events/types.d.ts +7 -11
  112. package/dist/server/events/types.js +2 -1
  113. package/dist/server/events/types.js.map +1 -1
  114. package/dist/server/index.d.ts +11 -37
  115. package/dist/server/index.js +30 -48
  116. package/dist/server/index.js.map +1 -1
  117. package/dist/server/routes/hosted-nodes.d.ts +28 -0
  118. package/dist/server/routes/hosted-nodes.js +68 -0
  119. package/dist/server/routes/hosted-nodes.js.map +1 -0
  120. package/dist/server/routes/nat.d.ts +4 -1
  121. package/dist/server/routes/nat.js +13 -2
  122. package/dist/server/routes/nat.js.map +1 -1
  123. package/dist/server/routes/nodes.d.ts +6 -20
  124. package/dist/server/routes/nodes.js +7 -86
  125. package/dist/server/routes/nodes.js.map +1 -1
  126. package/dist/server/routes/settings.d.ts +3 -7
  127. package/dist/server/routes/settings.js +18 -8
  128. package/dist/server/routes/settings.js.map +1 -1
  129. package/dist/server/routes/status.d.ts +2 -25
  130. package/dist/server/routes/status.js +3 -43
  131. package/dist/server/routes/status.js.map +1 -1
  132. package/dist/server/server.js +8 -8
  133. package/dist/server/settings-store.d.ts +2 -2
  134. package/dist/server/settings-store.js +2 -2
  135. package/dist/server/static.js +1 -2
  136. package/dist/server/static.js.map +1 -1
  137. package/dist/ui/assets/index-BHd8CWZA.css +1 -0
  138. package/dist/ui/assets/index-Ccr2GjPT.js +23 -0
  139. package/dist/ui/assets/index-Ccr2GjPT.js.map +1 -0
  140. package/dist/ui/index.html +2 -2
  141. package/dist/update/index.d.ts +2 -2
  142. package/dist/update/index.js +2 -2
  143. package/dist/update/store.d.ts +1 -1
  144. package/dist/update/store.js +1 -1
  145. package/dist/update/types.d.ts +1 -1
  146. package/dist/update/types.js +1 -1
  147. package/package.json +5 -7
  148. package/src/__tests__/cli-nat.smoke.test.ts +219 -212
  149. package/src/__tests__/cli.smoke.test.ts +2 -2
  150. package/src/__tests__/global-setup.ts +2 -3
  151. package/src/__tests__/orchestrator-claim-secret.test.ts +111 -0
  152. package/src/__tests__/orchestrator-env-scrub.test.ts +160 -163
  153. package/src/__tests__/orchestrator-handle-contract.ts +134 -134
  154. package/src/__tests__/orchestrator-node-identity.test.ts +177 -203
  155. package/src/__tests__/orchestrator-ports.test.ts +124 -143
  156. package/src/__tests__/orchestrator-push.test.ts +138 -146
  157. package/src/__tests__/orchestrator.test.ts +1017 -1063
  158. package/src/__tests__/ui-access.test.ts +30 -0
  159. package/src/bin/host.ts +397 -572
  160. package/src/bin/join-output.ts +120 -0
  161. package/src/bin/nat-output.ts +188 -0
  162. package/src/bin/ui-access.ts +50 -0
  163. package/src/{donation → hosted}/__tests__/fake-orchestrator.test.ts +172 -173
  164. package/src/{donation → hosted}/__tests__/fake-orchestrator.ts +43 -23
  165. package/src/hosted/__tests__/hosted-node-service.test.ts +708 -0
  166. package/src/hosted/__tests__/hosted-node-supervisor.test.ts +475 -0
  167. package/src/hosted/__tests__/test-invitation.ts +33 -0
  168. package/src/hosted/claim-details.ts +103 -0
  169. package/src/hosted/hosted-node-service.ts +814 -0
  170. package/src/hosted/hosted-node-store.ts +144 -0
  171. package/src/hosted/hosted-node-supervisor.ts +439 -0
  172. package/src/hosted/hosted-node-watcher.ts +288 -0
  173. package/src/hosted/index.ts +53 -0
  174. package/src/hosted/node-status.ts +97 -0
  175. package/src/hosted/types.ts +160 -0
  176. package/src/index.ts +72 -91
  177. package/src/installer/__tests__/config.test.ts +15 -40
  178. package/src/installer/__tests__/installer.smoke.test.ts +184 -263
  179. package/src/installer/__tests__/wizard.test.ts +25 -13
  180. package/src/installer/config.ts +201 -175
  181. package/src/installer/index.ts +252 -327
  182. package/src/installer/paths.ts +104 -109
  183. package/src/installer/wizard.ts +24 -26
  184. package/src/nat/__tests__/address-resolver.test.ts +107 -82
  185. package/src/nat/__tests__/nat-service.test.ts +608 -515
  186. package/src/nat/__tests__/nat-store.test.ts +49 -12
  187. package/src/nat/__tests__/network-mode.test.ts +38 -0
  188. package/src/nat/__tests__/port-mapper.test.ts +30 -191
  189. package/src/nat/__tests__/reachability.test.ts +96 -13
  190. package/src/nat/address-resolver.ts +94 -52
  191. package/src/nat/address-watch.ts +92 -0
  192. package/src/nat/ddns/duckdns.ts +5 -1
  193. package/src/nat/ddns/index.ts +6 -0
  194. package/src/nat/index.ts +33 -13
  195. package/src/nat/nat-service.ts +942 -501
  196. package/src/nat/nat-store.ts +126 -31
  197. package/src/nat/network-mode.ts +36 -0
  198. package/src/nat/port-mapper.ts +241 -329
  199. package/src/nat/reachability.ts +155 -34
  200. package/src/nat/types.ts +110 -37
  201. package/src/{installer/__tests__/identity.test.ts → orchestrator/__tests__/identity-file.test.ts} +34 -34
  202. package/src/orchestrator/__tests__/node-identity.test.ts +62 -62
  203. package/src/orchestrator/host-process-orchestrator.ts +1089 -1260
  204. package/src/{installer/identity.ts → orchestrator/identity-file.ts} +56 -56
  205. package/src/orchestrator/index.ts +9 -10
  206. package/src/orchestrator/node-identity.ts +41 -41
  207. package/src/orchestrator/port-allocator.ts +71 -73
  208. package/src/orchestrator/state-store.ts +76 -90
  209. package/src/orchestrator/types.ts +34 -23
  210. package/src/package-version.ts +4 -0
  211. package/src/server/__tests__/bus.test.ts +38 -38
  212. package/src/server/__tests__/error-handler.test.ts +81 -118
  213. package/src/server/__tests__/fakes.ts +15 -18
  214. package/src/server/__tests__/hosted-nodes-route.test.ts +161 -0
  215. package/src/server/__tests__/nodes-route.test.ts +114 -283
  216. package/src/server/__tests__/publishers.test.ts +193 -180
  217. package/src/server/__tests__/server.smoke.test.ts +134 -223
  218. package/src/server/__tests__/settings-route.test.ts +210 -195
  219. package/src/server/__tests__/sse-route.test.ts +119 -119
  220. package/src/server/__tests__/status-route.test.ts +82 -121
  221. package/src/server/error-handler.ts +112 -178
  222. package/src/server/events/types.ts +20 -24
  223. package/src/server/index.ts +223 -267
  224. package/src/server/routes/hosted-nodes.ts +83 -0
  225. package/src/server/routes/nat.ts +75 -63
  226. package/src/server/routes/nodes.ts +96 -188
  227. package/src/server/routes/settings.ts +20 -15
  228. package/src/server/routes/status.ts +5 -67
  229. package/src/server/server.ts +8 -8
  230. package/src/server/settings-store.ts +2 -2
  231. package/src/server/static.ts +1 -2
  232. package/src/update/index.ts +2 -2
  233. package/src/update/store.ts +1 -1
  234. package/src/update/types.ts +1 -1
  235. package/dist/auth/duration.d.ts +0 -1
  236. package/dist/auth/duration.js +0 -32
  237. package/dist/auth/duration.js.map +0 -1
  238. package/dist/auth/index.d.ts +0 -11
  239. package/dist/auth/index.js +0 -9
  240. package/dist/auth/index.js.map +0 -1
  241. package/dist/auth/self-label.d.ts +0 -36
  242. package/dist/auth/self-label.js +0 -57
  243. package/dist/auth/self-label.js.map +0 -1
  244. package/dist/auth/trust-circle-store.d.ts +0 -26
  245. package/dist/auth/trust-circle-store.js +0 -129
  246. package/dist/auth/trust-circle-store.js.map +0 -1
  247. package/dist/auth/trust-circle.d.ts +0 -156
  248. package/dist/auth/trust-circle.js +0 -307
  249. package/dist/auth/trust-circle.js.map +0 -1
  250. package/dist/auth/types.d.ts +0 -84
  251. package/dist/auth/types.js +0 -24
  252. package/dist/auth/types.js.map +0 -1
  253. package/dist/donation/donation-service.d.ts +0 -389
  254. package/dist/donation/donation-service.js +0 -828
  255. package/dist/donation/donation-service.js.map +0 -1
  256. package/dist/donation/donation-store.d.ts +0 -51
  257. package/dist/donation/donation-store.js +0 -152
  258. package/dist/donation/donation-store.js.map +0 -1
  259. package/dist/donation/donation-supervisor.js +0 -364
  260. package/dist/donation/donation-supervisor.js.map +0 -1
  261. package/dist/donation/grant-service.d.ts +0 -80
  262. package/dist/donation/grant-service.js +0 -210
  263. package/dist/donation/grant-service.js.map +0 -1
  264. package/dist/donation/grant-store.d.ts +0 -38
  265. package/dist/donation/grant-store.js +0 -135
  266. package/dist/donation/grant-store.js.map +0 -1
  267. package/dist/donation/index.d.ts +0 -20
  268. package/dist/donation/index.js +0 -17
  269. package/dist/donation/index.js.map +0 -1
  270. package/dist/donation/types.d.ts +0 -224
  271. package/dist/donation/types.js +0 -40
  272. package/dist/donation/types.js.map +0 -1
  273. package/dist/installer/identity.js.map +0 -1
  274. package/dist/owner/index.d.ts +0 -11
  275. package/dist/owner/index.js +0 -11
  276. package/dist/owner/index.js.map +0 -1
  277. package/dist/owner/owner-node-client.d.ts +0 -110
  278. package/dist/owner/owner-node-client.js +0 -184
  279. package/dist/owner/owner-node-client.js.map +0 -1
  280. package/dist/server/routes/bootstrap-node-validation.d.ts +0 -115
  281. package/dist/server/routes/bootstrap-node-validation.js +0 -197
  282. package/dist/server/routes/bootstrap-node-validation.js.map +0 -1
  283. package/dist/server/routes/grants-admin.d.ts +0 -29
  284. package/dist/server/routes/grants-admin.js +0 -58
  285. package/dist/server/routes/grants-admin.js.map +0 -1
  286. package/dist/server/routes/grants.d.ts +0 -28
  287. package/dist/server/routes/grants.js +0 -165
  288. package/dist/server/routes/grants.js.map +0 -1
  289. package/dist/server/routes/provision-request-validation.d.ts +0 -45
  290. package/dist/server/routes/provision-request-validation.js +0 -97
  291. package/dist/server/routes/provision-request-validation.js.map +0 -1
  292. package/dist/server/routes/strands.d.ts +0 -20
  293. package/dist/server/routes/strands.js +0 -38
  294. package/dist/server/routes/strands.js.map +0 -1
  295. package/dist/server/routes/trust-circle.d.ts +0 -17
  296. package/dist/server/routes/trust-circle.js +0 -35
  297. package/dist/server/routes/trust-circle.js.map +0 -1
  298. package/dist/strands/index.d.ts +0 -7
  299. package/dist/strands/index.js +0 -6
  300. package/dist/strands/index.js.map +0 -1
  301. package/dist/strands/strand-service.d.ts +0 -50
  302. package/dist/strands/strand-service.js +0 -103
  303. package/dist/strands/strand-service.js.map +0 -1
  304. package/dist/strands/types.d.ts +0 -93
  305. package/dist/strands/types.js +0 -23
  306. package/dist/strands/types.js.map +0 -1
  307. package/dist/ui/assets/index-C6nELNfC.js +0 -23
  308. package/dist/ui/assets/index-C6nELNfC.js.map +0 -1
  309. package/dist/ui/assets/index-TtIYSeZO.css +0 -1
  310. package/src/__tests__/cli-invite.smoke.test.ts +0 -192
  311. package/src/__tests__/host-owner.smoke.test.ts +0 -123
  312. package/src/__tests__/orchestrator-owner.test.ts +0 -234
  313. package/src/__tests__/orchestrator-pin-keys.test.ts +0 -152
  314. package/src/auth/__tests__/duration.test.ts +0 -28
  315. package/src/auth/__tests__/self-label.test.ts +0 -85
  316. package/src/auth/__tests__/trust-circle-integration.test.ts +0 -152
  317. package/src/auth/__tests__/trust-circle-store.test.ts +0 -115
  318. package/src/auth/__tests__/trust-circle.test.ts +0 -445
  319. package/src/auth/duration.ts +0 -33
  320. package/src/auth/index.ts +0 -21
  321. package/src/auth/self-label.ts +0 -74
  322. package/src/auth/trust-circle-store.ts +0 -155
  323. package/src/auth/trust-circle.ts +0 -395
  324. package/src/auth/types.ts +0 -99
  325. package/src/donation/__tests__/donation-service.test.ts +0 -1193
  326. package/src/donation/__tests__/donation-supervisor.test.ts +0 -548
  327. package/src/donation/__tests__/grant-service.test.ts +0 -213
  328. package/src/donation/__tests__/grant-store.test.ts +0 -113
  329. package/src/donation/donation-service.ts +0 -1033
  330. package/src/donation/donation-store.ts +0 -176
  331. package/src/donation/donation-supervisor.ts +0 -411
  332. package/src/donation/grant-service.ts +0 -252
  333. package/src/donation/grant-store.ts +0 -152
  334. package/src/donation/index.ts +0 -65
  335. package/src/donation/types.ts +0 -260
  336. package/src/owner/__tests__/owner-node-client.test.ts +0 -194
  337. package/src/owner/index.ts +0 -12
  338. package/src/owner/owner-node-client.ts +0 -259
  339. package/src/server/__tests__/bootstrap-node-validation.test.ts +0 -138
  340. package/src/server/__tests__/grants-admin-route.test.ts +0 -171
  341. package/src/server/__tests__/grants-route.test.ts +0 -394
  342. package/src/server/__tests__/strands-route.test.ts +0 -234
  343. package/src/server/routes/bootstrap-node-validation.ts +0 -206
  344. package/src/server/routes/grants-admin.ts +0 -74
  345. package/src/server/routes/grants.ts +0 -193
  346. package/src/server/routes/provision-request-validation.ts +0 -105
  347. package/src/server/routes/strands.ts +0 -54
  348. package/src/server/routes/trust-circle.ts +0 -49
  349. package/src/strands/__tests__/strand-service.test.ts +0 -165
  350. package/src/strands/index.ts +0 -14
  351. package/src/strands/strand-service.ts +0 -135
  352. package/src/strands/types.ts +0 -102
@@ -0,0 +1,708 @@
1
+ /**
2
+ * HostedNodeService unit tests — "Join a cadre" and what follows, on the host
3
+ * side, exercised against a fake orchestrator (no real child processes), a real
4
+ * on-disk HostedNodeStore, a stub NAT layer and a stubbed `globalThis.fetch` for
5
+ * the child's `/status`.
6
+ *
7
+ * Covers: join → unclaimed (claim secret threaded to the child, persisted,
8
+ * redacted), the claim details, the status watcher (claim → joined, liveness, a
9
+ * row removed mid-poll, and a node an owner removed from its cadre), a join by invitation through a failure, Retry and the
10
+ * member's acceptance, respawn (same party, same secret, same ports), remove,
11
+ * reset, and the stuck-spawning reap.
12
+ *
13
+ * A recurring theme: **an ending that lands mid-operation wins.** `join`,
14
+ * `respawn` and the watcher each hold an entry-time copy of the record across a
15
+ * slow `await`, and `HostedNodeStore.put` replaces the whole row — so each must
16
+ * re-read before writing or it resurrects a node the admin just removed. The
17
+ * suites drive that race through `FakeOrchestrator.onCreate` / `onSpawned` (the
18
+ * spawn window) and the `fetch` stub (the status window).
19
+ */
20
+
21
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
22
+ import { mkdtempSync, rmSync } from 'node:fs';
23
+ import { tmpdir } from 'node:os';
24
+ import { join } from 'node:path';
25
+
26
+ import { decodeNodeClaimPayload } from '@serfab/cadre-core';
27
+
28
+ import type { ClaimAddressSettings } from '../../installer/config.js';
29
+ import type { NetworkMode, NodeReachability } from '../../nat/types.js';
30
+ import type { HostedNodeAddressSource } from '../claim-details.js';
31
+ import { HostedNodeService, HOSTED_NODE_SPAWNING_TTL_MS } from '../hosted-node-service.js';
32
+ import { HostedNodeStore } from '../hosted-node-store.js';
33
+ import type { HostedNode, HostedNodeChange } from '../types.js';
34
+ import { HostedNodeError } from '../types.js';
35
+ import { FakeOrchestrator } from './fake-orchestrator.js';
36
+ import { ISSUER_KEY, MEMBER_PEER_ID, encodedTestInvitation } from './test-invitation.js';
37
+
38
+ /** A peer id the child reports; the payload codec checks it parses. */
39
+ const PEER_ID = '12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp';
40
+ /** A relay's peer id, for relay addresses. */
41
+ const OTHER_PEER_ID = '12D3KooWQ7wMHcR6Z9zr2qmFyKW6pYp1VzJPfgoTHKGdEfMXz2Ve';
42
+ /** The owner key `/status.node.claimedBy` reports after a claim. */
43
+ const OWNER_KEY = Buffer.alloc(32, 7).toString('base64url');
44
+
45
+ /** What a home server's child reports: loopback, the LAN, a Docker bridge and a VPN interface, TCP and WebSocket. */
46
+ const HOME_SERVER_ADDRS = [
47
+ `/ip4/127.0.0.1/tcp/4101/ws/p2p/${PEER_ID}`,
48
+ `/ip4/192.168.1.20/tcp/4001/p2p/${PEER_ID}`,
49
+ `/ip4/192.168.1.20/tcp/4101/ws/p2p/${PEER_ID}`,
50
+ `/ip4/172.18.0.1/tcp/4101/ws/p2p/${PEER_ID}`,
51
+ `/ip4/10.9.9.1/tcp/4101/ws/p2p/${PEER_ID}`,
52
+ ];
53
+
54
+ /** A NAT layer with no public address and no verdicts. */
55
+ const NO_ADDRESSES: HostedNodeAddressSource = {
56
+ publicAddressesFor: () => [],
57
+ getStatus: () => ({ nodes: [], gateway: { lanAddress: null }, network: { mode: 'lan' } }),
58
+ };
59
+
60
+ /** A store whose next `put` fails once — the post-spawn write-failure path. */
61
+ class FlakyStore extends HostedNodeStore {
62
+ failNextPut = false;
63
+
64
+ override put(node: HostedNode): void {
65
+ if (this.failNextPut) {
66
+ this.failNextPut = false;
67
+ throw new HostedNodeError('storage_error', 'disk full');
68
+ }
69
+ super.put(node);
70
+ }
71
+ }
72
+
73
+ let tmpRoot: string;
74
+ const originalFetch = globalThis.fetch;
75
+
76
+ beforeEach(() => {
77
+ tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-hosted-svc-'));
78
+ });
79
+
80
+ afterEach(() => {
81
+ globalThis.fetch = originalFetch;
82
+ try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
83
+ });
84
+
85
+ /** What a child's `/status` answers, in the parts the service reads. */
86
+ interface StatusAnswer {
87
+ peerId?: string | null;
88
+ multiaddrs?: string[];
89
+ claim?: 'awaiting' | 'claimed';
90
+ partyId?: string;
91
+ claimedBy?: string;
92
+ connections?: number;
93
+ invitation?: { state: 'pending' | 'accepted' | 'failed'; memberPeerId?: string; error?: string; retryable?: boolean };
94
+ removed?: { stampId: string; signerKey: string | null; at: number };
95
+ }
96
+
97
+ function statusBody(answer: StatusAnswer): unknown {
98
+ const peerId = answer.peerId === undefined ? PEER_ID : answer.peerId;
99
+ return {
100
+ status: 'healthy',
101
+ peerId,
102
+ multiaddrs: answer.multiaddrs ?? [`/ip4/127.0.0.1/tcp/4101/ws/p2p/${peerId}`, `/ip4/192.168.1.20/tcp/4101/ws/p2p/${peerId}`],
103
+ node: {
104
+ running: true,
105
+ peerId,
106
+ partyId: answer.partyId ?? 'unclaimed',
107
+ profile: 'storage',
108
+ strands: { total: 0, syncing: 0, active: 0, idle: 0, hibernating: 0 },
109
+ connectionPaths: { total: answer.connections ?? 0, relayed: 0, direct: answer.connections ?? 0, stuckOnRelay: 0, byTransport: {} },
110
+ claim: answer.claim ?? 'awaiting',
111
+ ...(answer.claimedBy ? { claimedBy: answer.claimedBy } : {}),
112
+ ...(answer.invitation ? { invitation: answer.invitation } : {}),
113
+ removed: answer.removed ?? null,
114
+ },
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Stub the child's `/status` with `answer`. `duringRequest` — when given — runs
120
+ * exactly once and is awaited before the response resolves, so whatever it starts
121
+ * lands *inside* the status window. It is the `fetch` analogue of
122
+ * `FakeOrchestrator.onCreate`.
123
+ */
124
+ function stubStatusFetch(answer: StatusAnswer | 'down', duringRequest?: () => unknown): void {
125
+ let ran: Promise<unknown> | undefined;
126
+ globalThis.fetch = (async () => {
127
+ ran ??= (async () => duringRequest?.())();
128
+ await ran;
129
+ if (answer === 'down') throw new Error('ECONNREFUSED');
130
+ return { ok: true, json: async () => statusBody(answer) } as unknown as Response;
131
+ }) as typeof globalThis.fetch;
132
+ }
133
+
134
+ interface Harness {
135
+ orch: FakeOrchestrator;
136
+ store: HostedNodeStore;
137
+ svc: HostedNodeService;
138
+ changes: HostedNodeChange[];
139
+ }
140
+
141
+ function makeHarness(opts: {
142
+ store?: HostedNodeStore;
143
+ addresses?: HostedNodeAddressSource;
144
+ now?: () => Date;
145
+ claimAddressSettings?: () => ClaimAddressSettings | undefined;
146
+ primaryLan?: () => Promise<string | undefined>;
147
+ } = {}): Harness {
148
+ const orch = new FakeOrchestrator();
149
+ const store = opts.store ?? new HostedNodeStore(join(tmpRoot, 'hosted'));
150
+ const svc = new HostedNodeService({
151
+ orchestrator: orch,
152
+ store,
153
+ addresses: opts.addresses ?? NO_ADDRESSES,
154
+ // Deterministic: never probe the test machine's real route unless a test says so.
155
+ primaryLan: opts.primaryLan ?? (async () => undefined),
156
+ ...(opts.claimAddressSettings ? { claimAddressSettings: opts.claimAddressSettings } : {}),
157
+ ...(opts.now ? { now: opts.now } : {}),
158
+ });
159
+ const changes: HostedNodeChange[] = [];
160
+ svc.onChange((c) => changes.push(c));
161
+ return { orch, store, svc, changes };
162
+ }
163
+
164
+ function requireNode(store: HostedNodeStore, id: string): HostedNode {
165
+ const node = store.get(id);
166
+ if (!node) throw new Error(`no hosted node ${id}`);
167
+ return node;
168
+ }
169
+
170
+ function secretOf(node: HostedNode): string {
171
+ if (node.join.kind !== 'claim') throw new Error(`hosted node ${node.id} joins by ${node.join.kind}, not by claim`);
172
+ return node.join.secret;
173
+ }
174
+
175
+ describe('HostedNodeService.join', () => {
176
+ it('spawns a storage node under the placeholder party with a fresh claim secret, then records it unclaimed', async () => {
177
+ const { orch, store, svc, changes } = makeHarness();
178
+
179
+ const view = await svc.join();
180
+
181
+ expect(view.id).toMatch(/^hn_[A-Za-z0-9_-]{16}$/);
182
+ expect(view).toMatchObject({ status: 'unclaimed', partyId: 'unclaimed', profile: 'storage', join: { kind: 'claim' }, dockerId: 'dock_1' });
183
+ // The secret never crosses the boundary.
184
+ expect((view.join as Record<string, unknown>).secret).toBeUndefined();
185
+
186
+ const stored = requireNode(store, view.id);
187
+ expect(secretOf(stored)).toMatch(/^[A-Za-z0-9_-]{43}$/);
188
+ expect(stored.statusEndpoint).toBe('http://127.0.0.1:9001/status');
189
+ // The child got exactly the record's secret, through the request and nothing else.
190
+ expect(orch.createCalls).toEqual([{
191
+ containerId: view.id,
192
+ partyId: 'unclaimed',
193
+ profile: 'storage',
194
+ claimSecret: secretOf(stored),
195
+ }]);
196
+ expect(changes).toEqual([{ kind: 'added', id: view.id }]);
197
+ });
198
+
199
+ it('gives two joins at once their own id, secret and child', async () => {
200
+ const { orch, store, svc } = makeHarness();
201
+ orch.createDelayMs = 10;
202
+
203
+ const [a, b] = await Promise.all([svc.join(), svc.join()]);
204
+
205
+ expect(a.id).not.toBe(b.id);
206
+ expect(secretOf(requireNode(store, a.id))).not.toBe(secretOf(requireNode(store, b.id)));
207
+ expect(orch.createCalls.map((c) => c.containerId).sort()).toEqual([a.id, b.id].sort());
208
+ });
209
+
210
+ it('lets a remove that lands mid-spawn win, and reclaims the new child', async () => {
211
+ const { orch, store, svc } = makeHarness();
212
+
213
+ // The admin's DELETE lands inside the spawn window. The record names no
214
+ // dockerId yet, so remove's own cleanup finds nothing to stop — the abandon
215
+ // path here is the only thing that can reclaim this child.
216
+ orch.createDelayMs = 20;
217
+ let removed: Promise<void> | undefined;
218
+ orch.onCreate = (request) => { removed ??= svc.remove(request.containerId); };
219
+
220
+ await expect(svc.join()).rejects.toMatchObject({ code: 'not_found' });
221
+ await removed;
222
+
223
+ expect(store.list()).toEqual([]);
224
+ expect(orch.removed).toEqual(['dock_1']);
225
+ });
226
+
227
+ it('marks the record error and reclaims nothing when the spawn itself throws', async () => {
228
+ const { orch, store, svc, changes } = makeHarness();
229
+ orch.failCreate = true;
230
+
231
+ await expect(svc.join()).rejects.toMatchObject({ code: 'orchestrator_error' });
232
+
233
+ const [stored] = store.list();
234
+ expect(stored?.status).toBe('error');
235
+ expect(stored?.error).toContain('spawn boom');
236
+ expect(orch.removed).toEqual([]);
237
+ // `createContainer`'s own unwind removed the directory its failed spawn created.
238
+ expect(orch.reclaimedWorkdirs).toEqual([]);
239
+ expect(changes).toEqual([{ kind: 'changed', id: stored!.id }]);
240
+ });
241
+
242
+ it('reclaims the child and marks error when the post-spawn write fails', async () => {
243
+ const store = new FlakyStore(join(tmpRoot, 'hosted'));
244
+ const { orch, svc } = makeHarness({ store });
245
+ // The first put (the spawning row) succeeds; the second (unclaimed) fails.
246
+ orch.onSpawned = () => { store.failNextPut = true; };
247
+
248
+ await expect(svc.join()).rejects.toMatchObject({ code: 'orchestrator_error' });
249
+
250
+ expect(orch.removed).toEqual(['dock_1']);
251
+ expect(store.list()[0]?.status).toBe('error');
252
+ });
253
+ });
254
+
255
+ describe('HostedNodeService.claimDetails', () => {
256
+ it('answers the QR payload: phone-dialable public addresses first, then only the gateway\'s LAN address', async () => {
257
+ const reachability: NodeReachability = {
258
+ nodeId: '', running: true, verdict: 'mapped', reason: null,
259
+ tcp: { internalPort: 4001, externalPort: 4001, source: 'upnp', leaseExpiresAt: null, error: null },
260
+ ws: { internalPort: 4101, externalPort: 4101, source: 'upnp', leaseExpiresAt: null, error: null },
261
+ publicAddrs: [],
262
+ };
263
+ const asked: Array<{ nodeId: string; ports: { p2p: number; ws?: number } }> = [];
264
+ const { store, svc } = makeHarness({
265
+ addresses: {
266
+ publicAddressesFor: (nodeId, ports) => {
267
+ asked.push({ nodeId, ports });
268
+ return ['/ip4/203.0.113.5/tcp/4001', '/ip4/203.0.113.5/tcp/4101/ws'];
269
+ },
270
+ getStatus: () => ({ nodes: [{ ...reachability, nodeId: asked[0]?.nodeId ?? '' }], gateway: { lanAddress: '192.168.1.20' }, network: { mode: 'lan' } }),
271
+ },
272
+ });
273
+ const view = await svc.join();
274
+ stubStatusFetch({ multiaddrs: HOME_SERVER_ADDRS });
275
+
276
+ const details = await svc.claimDetails(view.id);
277
+
278
+ // The fake's ports for dock_1, straight from the orchestrator handle.
279
+ expect(asked).toEqual([{ nodeId: view.id, ports: { health: 9001, metrics: 9101, p2p: 4001, ws: 4101 } }]);
280
+ expect(details.peerId).toBe(PEER_ID);
281
+ // No TCP (a phone cannot dial it), no Docker bridge or VPN interface.
282
+ expect(details.multiaddrs).toEqual([
283
+ `/ip4/203.0.113.5/tcp/4101/ws/p2p/${PEER_ID}`,
284
+ `/ip4/192.168.1.20/tcp/4101/ws/p2p/${PEER_ID}`,
285
+ ]);
286
+ expect(details.reachability?.verdict).toBe('mapped');
287
+ expect(decodeNodeClaimPayload(details.payload)).toEqual({
288
+ peerId: PEER_ID,
289
+ multiaddrs: details.multiaddrs,
290
+ secret: secretOf(requireNode(store, view.id)),
291
+ });
292
+ // The peer id is cached on the record; the view carries it.
293
+ expect(svc.get(view.id)?.peerId).toBe(PEER_ID);
294
+ });
295
+
296
+ it('falls back to the machine\'s primary address when no UPnP gateway answered', async () => {
297
+ const { svc } = makeHarness({ primaryLan: async () => '172.18.0.1' });
298
+ const view = await svc.join();
299
+ stubStatusFetch({ multiaddrs: HOME_SERVER_ADDRS });
300
+ expect((await svc.claimDetails(view.id)).multiaddrs).toEqual([`/ip4/172.18.0.1/tcp/4101/ws/p2p/${PEER_ID}`]);
301
+ });
302
+
303
+ it('follows claimAddresses: a public name replaces public IPs, lan none or an IP, auto in public mode, and an exact addrs list', async () => {
304
+ let settings: ClaimAddressSettings | undefined = { lan: 'none' };
305
+ let mode: NetworkMode = 'lan';
306
+ const { svc } = makeHarness({
307
+ addresses: {
308
+ publicAddressesFor: () => ['/dns4/home.example.org/tcp/4101/ws', '/ip4/203.0.113.5/tcp/4101/ws'],
309
+ getStatus: () => ({ nodes: [], gateway: { lanAddress: '192.168.1.20' }, network: { mode } }),
310
+ },
311
+ claimAddressSettings: () => settings,
312
+ });
313
+ const view = await svc.join();
314
+ stubStatusFetch({ multiaddrs: HOME_SERVER_ADDRS });
315
+
316
+ expect((await svc.claimDetails(view.id)).multiaddrs).toEqual([`/dns4/home.example.org/tcp/4101/ws/p2p/${PEER_ID}`]);
317
+ settings = { lan: '10.9.9.1' };
318
+ expect((await svc.claimDetails(view.id)).multiaddrs).toEqual([
319
+ `/dns4/home.example.org/tcp/4101/ws/p2p/${PEER_ID}`,
320
+ `/ip4/10.9.9.1/tcp/4101/ws/p2p/${PEER_ID}`,
321
+ ]);
322
+ // A VPS shares no LAN with a phone: automatic keeps no private address there.
323
+ settings = undefined;
324
+ mode = 'public';
325
+ expect((await svc.claimDetails(view.id)).multiaddrs).toEqual([`/dns4/home.example.org/tcp/4101/ws/p2p/${PEER_ID}`]);
326
+ // A relay address that ends at its hop names no destination, so it gains the node's id too.
327
+ const relayHop = `/dns4/relay.example.org/tcp/443/wss/p2p/${OTHER_PEER_ID}/p2p-circuit`;
328
+ settings = { addrs: ['/dns4/proxy.example.org/tcp/443/wss', relayHop] };
329
+ expect((await svc.claimDetails(view.id)).multiaddrs).toEqual([
330
+ `/dns4/proxy.example.org/tcp/443/wss/p2p/${PEER_ID}`,
331
+ `${relayHop}/p2p/${PEER_ID}`,
332
+ ]);
333
+ });
334
+
335
+ it('is 503 until the child answers, and 409 once the node is no longer unclaimed', async () => {
336
+ const { store, svc } = makeHarness();
337
+ const view = await svc.join();
338
+
339
+ stubStatusFetch('down');
340
+ await expect(svc.claimDetails(view.id)).rejects.toMatchObject({ code: 'node_unavailable' });
341
+ stubStatusFetch({ peerId: null });
342
+ await expect(svc.claimDetails(view.id)).rejects.toMatchObject({ code: 'node_unavailable' });
343
+
344
+ store.put({ ...requireNode(store, view.id), status: 'joined', partyId: 'party-P' });
345
+ await expect(svc.claimDetails(view.id)).rejects.toMatchObject({ code: 'invalid_state' });
346
+ await expect(svc.claimDetails('hn_nobody')).rejects.toMatchObject({ code: 'not_found' });
347
+ });
348
+ });
349
+
350
+ describe('HostedNodeService status watcher', () => {
351
+ it('writes joined with the party and owner the claim named, once the node is in that party', async () => {
352
+ const { store, svc, changes } = makeHarness();
353
+ const view = await svc.join();
354
+ changes.length = 0;
355
+
356
+ // The node records the claim before it restarts into the party: `claimed`
357
+ // with the placeholder party is a claim in progress, not a joined node.
358
+ stubStatusFetch({ claim: 'claimed', claimedBy: OWNER_KEY, partyId: 'unclaimed' });
359
+ await svc.pollStatuses();
360
+ expect(requireNode(store, view.id).status).toBe('unclaimed');
361
+ expect(changes).toEqual([]);
362
+
363
+ stubStatusFetch({ claim: 'claimed', claimedBy: OWNER_KEY, partyId: 'party-P', connections: 1 });
364
+ await svc.pollStatuses();
365
+ expect(requireNode(store, view.id)).toMatchObject({
366
+ status: 'joined', partyId: 'party-P', ownerKey: OWNER_KEY, peerId: PEER_ID, connected: true,
367
+ });
368
+ expect(changes).toEqual([{ kind: 'claimed', id: view.id }]);
369
+
370
+ // A joined node's `connected` follows the count, published only on a change.
371
+ await svc.pollStatuses();
372
+ expect(changes).toHaveLength(1);
373
+ stubStatusFetch({ claim: 'claimed', claimedBy: OWNER_KEY, partyId: 'party-P', connections: 0 });
374
+ void svc.pollStatuses(); // the 15 s cadence: a second poll in the same tick is skipped
375
+ await svc.pollStatuses();
376
+ expect(requireNode(store, view.id).connected).toBe(true);
377
+ });
378
+
379
+ it('writes nothing for a row removed while its poll was in flight', async () => {
380
+ const { orch, store, svc, changes } = makeHarness();
381
+ const view = await svc.join();
382
+ changes.length = 0;
383
+
384
+ let removed: Promise<void> | undefined;
385
+ stubStatusFetch({ claim: 'claimed', claimedBy: OWNER_KEY, partyId: 'party-P' }, () => {
386
+ removed = svc.remove(view.id);
387
+ return removed;
388
+ });
389
+
390
+ await svc.pollStatuses();
391
+ await removed;
392
+
393
+ expect(store.get(view.id)).toBeUndefined();
394
+ expect(orch.removed).toEqual(['dock_1']);
395
+ expect(changes).toEqual([{ kind: 'removed', id: view.id }]);
396
+ });
397
+
398
+ it('writes error, not retryable, for a joined node an owner removed, and stops its child', async () => {
399
+ let clock = Date.parse('2026-10-01T00:00:00Z');
400
+ const { orch, store, svc, changes } = makeHarness({ now: () => new Date(clock) });
401
+ const view = await svc.join();
402
+ stubStatusFetch({ claim: 'claimed', claimedBy: OWNER_KEY, partyId: 'party-P', connections: 1 });
403
+ await svc.pollStatuses();
404
+ changes.length = 0;
405
+
406
+ // The child has stopped its node, so it reports the removal and no peer identity.
407
+ stubStatusFetch({
408
+ peerId: null, multiaddrs: [], claim: 'claimed', claimedBy: OWNER_KEY, partyId: 'party-P',
409
+ removed: { stampId: 'stamp-1', signerKey: OWNER_KEY, at: clock },
410
+ });
411
+ clock += 15_000;
412
+ await svc.pollStatuses();
413
+
414
+ expect(requireNode(store, view.id)).toMatchObject({
415
+ status: 'error', error: 'Removed from its cadre by an owner', retryable: false, removedByOwner: true, partyId: 'party-P',
416
+ });
417
+ expect(orch.stopped).toEqual(['dock_1']);
418
+ expect(orch.removed).toEqual([]);
419
+ expect(changes).toEqual([{ kind: 'changed', id: view.id }]);
420
+ });
421
+
422
+ it('records the reported party and owner for an unclaimed node that reports its removal', async () => {
423
+ // The claim and the removal both happened between polls (or while the host was down), so
424
+ // the record still carries the placeholder party.
425
+ const { store, svc } = makeHarness();
426
+ const view = await svc.join();
427
+ stubStatusFetch({
428
+ peerId: null, multiaddrs: [], claim: 'claimed', claimedBy: OWNER_KEY, partyId: 'party-P',
429
+ removed: { stampId: 'stamp-1', signerKey: null, at: 0 },
430
+ });
431
+ await svc.pollStatuses();
432
+
433
+ expect(requireNode(store, view.id)).toMatchObject({ status: 'error', removedByOwner: true, partyId: 'party-P', ownerKey: OWNER_KEY });
434
+ });
435
+
436
+ it('retries a node that does not answer on the next pass', async () => {
437
+ const { store, svc } = makeHarness();
438
+ const view = await svc.join();
439
+
440
+ stubStatusFetch('down');
441
+ await svc.pollStatuses();
442
+ expect(requireNode(store, view.id).status).toBe('unclaimed');
443
+
444
+ stubStatusFetch({ claim: 'claimed', claimedBy: OWNER_KEY, partyId: 'party-P' });
445
+ await svc.pollStatuses();
446
+ expect(requireNode(store, view.id).status).toBe('joined');
447
+ });
448
+ });
449
+
450
+ describe('HostedNodeService join by invitation', () => {
451
+ it('follows the node from joining through a failure no member answered, a Retry, and the member\'s acceptance', async () => {
452
+ const { orch, store, svc, changes } = makeHarness();
453
+ const invitation = encodedTestInvitation('party-P');
454
+
455
+ const view = await svc.join({ invitation });
456
+
457
+ // The invitation's party from the start; the bundle reaches the child and nothing else.
458
+ expect(view).toMatchObject({ status: 'joining', partyId: 'party-P', join: { kind: 'invitation' } });
459
+ expect(JSON.stringify(view)).not.toContain(invitation);
460
+ expect(orch.createCalls).toEqual([{ containerId: view.id, partyId: 'party-P', profile: 'storage', invitation }]);
461
+
462
+ stubStatusFetch({ partyId: 'party-P', invitation: { state: 'pending' } });
463
+ await svc.pollStatuses();
464
+ expect(requireNode(store, view.id).status).toBe('joining');
465
+
466
+ // No member reachable: `error`, kept for Retry, its child stopped but not reclaimed.
467
+ stubStatusFetch({ partyId: 'party-P', invitation: { state: 'failed', error: 'No member named in the invitation could be reached.', retryable: true } });
468
+ await svc.pollStatuses();
469
+ expect(requireNode(store, view.id)).toMatchObject({ status: 'error', error: 'No member named in the invitation could be reached.', retryable: true });
470
+ expect(orch.stopped).toEqual(['dock_1']);
471
+ expect(orch.removed).toEqual([]);
472
+
473
+ // The new child starts its redemption over.
474
+ stubStatusFetch({ partyId: 'party-P', invitation: { state: 'pending' } });
475
+ const retried = await svc.retry(view.id);
476
+ expect(retried).toMatchObject({ status: 'joining', dockerId: 'dock_2' });
477
+ expect(retried.error).toBeUndefined();
478
+ expect(retried.retryable).toBeUndefined();
479
+ expect(orch.createCalls[1]).toMatchObject({ containerId: view.id, invitation });
480
+ // An explicit Retry spends no respawn attempt.
481
+ expect(requireNode(store, view.id).respawn).toBeUndefined();
482
+
483
+ stubStatusFetch({ partyId: 'party-P', connections: 1, invitation: { state: 'accepted', memberPeerId: MEMBER_PEER_ID } });
484
+ await svc.pollStatuses();
485
+ expect(requireNode(store, view.id)).toMatchObject({
486
+ status: 'joined', partyId: 'party-P', ownerKey: ISSUER_KEY, memberPeerId: MEMBER_PEER_ID, peerId: PEER_ID, connected: true,
487
+ });
488
+ expect(changes.map((c) => c.kind)).toEqual(['added', 'changed', 'changed', 'changed', 'joined']);
489
+ });
490
+ });
491
+
492
+ // The orchestrator refuses to re-spawn a container whose child is still running, so
493
+ // each case that expects a spawn has the first child (`dock_1`) go down first.
494
+ describe('HostedNodeService.respawn', () => {
495
+ it('replays the record\'s party and secret, comes back on the same ports, swaps the handles and leaves status alone', async () => {
496
+ const { orch, store, svc, changes } = makeHarness();
497
+ const view = await svc.join();
498
+ const secret = secretOf(requireNode(store, view.id));
499
+ store.put({ ...requireNode(store, view.id), status: 'joined', partyId: 'party-P', ownerKey: OWNER_KEY, connected: true });
500
+ const portsBefore = orch.getNode(view.id)!.ports;
501
+ changes.length = 0;
502
+
503
+ orch.crash('dock_1');
504
+ const result = await svc.respawn(view.id);
505
+
506
+ expect(result).toMatchObject({ outcome: 'respawned', node: { status: 'joined', dockerId: 'dock_2' } });
507
+ // The claimed party now, and the same secret: cadre-cli honours claim.json and
508
+ // answers a rival `already-claimed` with the secret still set.
509
+ expect(orch.createCalls[1]).toEqual({
510
+ containerId: view.id, partyId: 'party-P', profile: 'storage', claimSecret: secret,
511
+ });
512
+ expect(orch.getNode(view.id)!.ports).toEqual(portsBefore);
513
+
514
+ const stored = requireNode(store, view.id);
515
+ expect(stored).toMatchObject({ status: 'joined', partyId: 'party-P', ownerKey: OWNER_KEY, dockerId: 'dock_2', statusEndpoint: 'http://127.0.0.1:9001/status' });
516
+ expect(stored.respawn?.attempts).toBe(1);
517
+ // Unknown until the watcher reads the new child.
518
+ expect(stored.connected).toBeUndefined();
519
+ // Nothing was stopped or reclaimed — the workdir (identity key, node-local
520
+ // stores) has to survive for the respawn to be the same node.
521
+ expect(orch.stopped).toEqual([]);
522
+ expect(orch.removed).toEqual([]);
523
+ expect(changes).toEqual([{ kind: 'changed', id: view.id }]);
524
+ });
525
+
526
+ it('refuses an error or spawning record', async () => {
527
+ const { orch, store, svc } = makeHarness();
528
+ const view = await svc.join();
529
+
530
+ store.put({ ...requireNode(store, view.id), status: 'error', error: 'gave up' });
531
+ await expect(svc.respawn(view.id)).rejects.toMatchObject({ code: 'invalid_state' });
532
+ // A host that died mid-join leaves exactly this row; replaying it would race the join.
533
+ store.put({ ...requireNode(store, view.id), status: 'spawning' });
534
+ await expect(svc.respawn(view.id)).rejects.toMatchObject({ code: 'invalid_state' });
535
+ expect(orch.createCalls).toHaveLength(1);
536
+ });
537
+
538
+ it('records the attempt and throws when the spawn fails, keeping the old handles', async () => {
539
+ const { orch, store, svc } = makeHarness();
540
+ const view = await svc.join();
541
+ orch.failCreate = true;
542
+
543
+ await expect(svc.respawn(view.id)).rejects.toMatchObject({ code: 'orchestrator_error' });
544
+
545
+ const stored = requireNode(store, view.id);
546
+ expect(stored.respawn?.attempts).toBe(1);
547
+ expect(stored.dockerId).toBe('dock_1');
548
+ expect(stored.status).toBe('unclaimed');
549
+ });
550
+
551
+ it('stops — but never reclaims — the new child when the post-spawn write fails', async () => {
552
+ const store = new FlakyStore(join(tmpRoot, 'hosted'));
553
+ const { orch, svc } = makeHarness({ store });
554
+ const view = await svc.join();
555
+ store.failNextPut = true;
556
+
557
+ orch.crash('dock_1');
558
+ await expect(svc.respawn(view.id)).rejects.toMatchObject({ code: 'orchestrator_error' });
559
+
560
+ // Stopped, not removed: `removeContainer` deletes the workdir, and the
561
+ // workdir is the identity key that makes a later respawn the SAME node.
562
+ expect(orch.stopped).toEqual(['dock_2']);
563
+ expect(orch.removed).toEqual([]);
564
+ // The record names the child that actually exists; the attempt failed, so
565
+ // status and updatedAt are left alone.
566
+ const stored = requireNode(store, view.id);
567
+ expect(stored).toMatchObject({ dockerId: 'dock_2', status: 'unclaimed', updatedAt: view.updatedAt });
568
+ expect(stored.respawn?.attempts).toBe(1);
569
+ });
570
+
571
+ it('lets a remove that lands after the spawn dropped the old handle win, and reclaims the new child', async () => {
572
+ const { orch, store, svc } = makeHarness();
573
+ const view = await svc.join();
574
+
575
+ // The admin's DELETE lands in the window *after* the spawn dropped the old
576
+ // handle — the window `abandonRespawn` was written for. The remove aimed its
577
+ // stop and reclaim at dock_1, which the spawn had already dropped, so it
578
+ // cleaned up nothing at all; dock_2 is the only thing holding the ports and workdir.
579
+ let removed: Promise<void> | undefined;
580
+ orch.onSpawned = () => { removed ??= svc.remove(view.id); };
581
+
582
+ orch.crash('dock_1');
583
+ const result = await svc.respawn(view.id);
584
+ await removed;
585
+
586
+ expect(result).toEqual({ outcome: 'abandoned' });
587
+ expect(orch.stopped).toEqual(['dock_2']);
588
+ expect(orch.removed).toEqual(['dock_2']);
589
+ expect(store.get(view.id)).toBeUndefined();
590
+ });
591
+
592
+ it('stops but does not reclaim the new child when the record goes error mid-spawn', async () => {
593
+ const { orch, store, svc } = makeHarness();
594
+ const view = await svc.join();
595
+
596
+ // An `error` write landing inside the spawn window. Both spawns share one
597
+ // workdir, so reclaiming here would delete the identity key `error` keeps;
598
+ // the record is pointed at the new child instead, so a later remove reclaims it.
599
+ orch.createDelayMs = 20;
600
+ orch.onCreate = () => {
601
+ store.put({ ...requireNode(store, view.id), status: 'error', error: 'gave up' });
602
+ };
603
+
604
+ orch.crash('dock_1');
605
+ const result = await svc.respawn(view.id);
606
+
607
+ expect(result).toEqual({ outcome: 'abandoned', status: 'error' });
608
+ expect(orch.stopped).toContain('dock_2');
609
+ expect(orch.removed).toEqual([]);
610
+ expect(requireNode(store, view.id)).toMatchObject({ status: 'error', dockerId: 'dock_2' });
611
+ });
612
+ });
613
+
614
+ describe('HostedNodeService.remove', () => {
615
+ it('deletes the row BEFORE stopping the child, then reclaims it', async () => {
616
+ const { orch, store, svc, changes } = makeHarness();
617
+ const view = await svc.join();
618
+ changes.length = 0;
619
+ // The stop fires the orchestrator's state-change; the supervisor's pass must
620
+ // already find no record, or it brings back a node the admin just removed.
621
+ let rowAtStop: HostedNode | undefined;
622
+ orch.onStop = () => { rowAtStop = store.get(view.id); };
623
+
624
+ await svc.remove(view.id);
625
+
626
+ expect(rowAtStop).toBeUndefined();
627
+ expect(orch.stopped).toEqual(['dock_1']);
628
+ expect(orch.removed).toEqual(['dock_1']);
629
+ expect(orch.reclaimedWorkdirs).toEqual([]);
630
+ expect(changes).toEqual([{ kind: 'removed', id: view.id }]);
631
+ // Gone for good: the second remove finds neither a row nor a handle.
632
+ await expect(svc.remove(view.id)).rejects.toMatchObject({ code: 'not_found' });
633
+ });
634
+
635
+ it('reclaims the workdir by name for a record that never got a dockerId', async () => {
636
+ const { orch, store, svc } = makeHarness();
637
+ const at = '2025-01-01T00:00:00.000Z';
638
+ store.put({
639
+ id: 'hn_stuck', join: { kind: 'claim', secret: 's' }, partyId: 'unclaimed', profile: 'storage',
640
+ status: 'spawning', createdAt: at, updatedAt: at,
641
+ });
642
+
643
+ await svc.remove('hn_stuck');
644
+
645
+ expect(store.get('hn_stuck')).toBeUndefined();
646
+ expect(orch.reclaimedWorkdirs).toEqual(['hn_stuck']);
647
+ expect(orch.stopped).toEqual([]);
648
+ });
649
+
650
+ it('removes a handle no record names, so a lost hosted-nodes.json cannot strand a child', async () => {
651
+ const { orch, svc, changes } = makeHarness();
652
+ const spawn = await orch.createContainer({ containerId: 'hn_orphan', partyId: 'party-P', profile: 'storage' });
653
+
654
+ await svc.remove('hn_orphan');
655
+
656
+ expect(orch.stopped).toEqual([spawn.dockerId]);
657
+ expect(orch.removed).toEqual([spawn.dockerId]);
658
+ expect(changes).toEqual([{ kind: 'removed', id: 'hn_orphan' }]);
659
+ });
660
+ });
661
+
662
+ describe('HostedNodeService.reset', () => {
663
+ it('removes the node and starts a fresh one with a new id and secret', async () => {
664
+ const { orch, store, svc } = makeHarness();
665
+ const first = await svc.join();
666
+ const firstSecret = secretOf(requireNode(store, first.id));
667
+
668
+ const second = await svc.reset(first.id);
669
+
670
+ expect(second.id).not.toBe(first.id);
671
+ expect(second.status).toBe('unclaimed');
672
+ expect(store.get(first.id)).toBeUndefined();
673
+ expect(secretOf(requireNode(store, second.id))).not.toBe(firstSecret);
674
+ expect(orch.removed).toEqual(['dock_1']);
675
+ expect(orch.createCalls[1]?.containerId).toBe(second.id);
676
+ });
677
+ });
678
+
679
+ describe('HostedNodeService.reapStuckSpawning', () => {
680
+ /** A record whose host died right after writing the `spawning` row. */
681
+ const stuckRecord = (id: string, at: string): HostedNode => ({
682
+ id, join: { kind: 'claim', secret: 's' }, partyId: 'unclaimed', profile: 'storage',
683
+ status: 'spawning', createdAt: at, updatedAt: at,
684
+ });
685
+
686
+ it('marks a record stuck past the TTL error and reclaims what the orchestrator can find', async () => {
687
+ let clock = new Date('2025-01-01T00:00:00.000Z');
688
+ const { orch, store, svc, changes } = makeHarness({ now: () => clock });
689
+ // One whose child was spawned before the host died (the orchestrator knows it
690
+ // by dockerId), one that never got that far.
691
+ const spawn = await orch.createContainer({ containerId: 'hn_spawned', partyId: 'unclaimed', profile: 'storage' });
692
+ store.put(stuckRecord('hn_spawned', clock.toISOString()));
693
+ store.put(stuckRecord('hn_never', clock.toISOString()));
694
+ clock = new Date(clock.getTime() + HOSTED_NODE_SPAWNING_TTL_MS + 60_000);
695
+ store.put(stuckRecord('hn_fresh', clock.toISOString()));
696
+
697
+ const reaped = await svc.reapStuckSpawning();
698
+
699
+ expect(reaped.sort()).toEqual(['hn_never', 'hn_spawned']);
700
+ expect(requireNode(store, 'hn_spawned').status).toBe('error');
701
+ expect(requireNode(store, 'hn_never').error).toMatch(/stuck starting/);
702
+ expect(requireNode(store, 'hn_fresh').status).toBe('spawning');
703
+ expect(orch.stopped).toEqual([spawn.dockerId]);
704
+ expect(orch.removed).toEqual([spawn.dockerId]);
705
+ expect(orch.reclaimedWorkdirs).toEqual(['hn_never']);
706
+ expect(changes.map((c) => c.id).sort()).toEqual(['hn_never', 'hn_spawned']);
707
+ });
708
+ });