@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
@@ -1,195 +1,210 @@
1
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
- import { tmpdir } from 'node:os';
3
- import { join } from 'node:path';
4
-
5
- import Fastify from 'fastify';
6
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
7
-
8
- import { registerErrorHandler } from '../error-handler.js';
9
- import { registerSettingsRoutes } from '../routes/settings.js';
10
- import { HostSettingsStore } from '../settings-store.js';
11
- import type { NatService } from '../../nat/index.js';
12
- import type { NatStatusSnapshot } from '../../nat/types.js';
13
- import type { UpdateService } from '../../update/index.js';
14
- import type { UpdateSettings } from '../../update/types.js';
15
-
16
- function fakeNat(): { svc: NatService; calls: Array<unknown> } {
17
- const calls: unknown[] = [];
18
- const stub = {
19
- putSettings: async (patch: unknown) => { calls.push(patch); return {} as NatStatusSnapshot; },
20
- } as unknown as NatService;
21
- return { svc: stub, calls };
22
- }
23
-
24
- function fakeUpdate(): { svc: UpdateService; calls: UpdateSettings[] } {
25
- const calls: UpdateSettings[] = [];
26
- const stub = {
27
- putSettings: async (patch: { autoApply?: boolean; manifestUrl?: string }) => {
28
- calls.push({ autoApply: patch.autoApply ?? false, ...(patch.manifestUrl ? { manifestUrl: patch.manifestUrl } : {}) });
29
- return { autoApply: patch.autoApply ?? false } as UpdateSettings;
30
- },
31
- } as unknown as UpdateService;
32
- return { svc: stub, calls };
33
- }
34
-
35
- function writeV2Config(dir: string): string {
36
- const cfg = {
37
- version: 2,
38
- installId: 'inst-x',
39
- uiPort: 8765,
40
- libp2pPort: 4001,
41
- dataDir: dir,
42
- identityPath: join(dir, 'identity.key'),
43
- upnpEnabled: true,
44
- installedAt: '2025-01-01T00:00:00Z',
45
- installerVersion: '0.6.0',
46
- updates: { autoApply: false },
47
- };
48
- const path = join(dir, 'host.config.json');
49
- writeFileSync(path, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
50
- return path;
51
- }
52
-
53
- function writeV1Config(dir: string): string {
54
- const cfg = {
55
- version: 1,
56
- installId: 'inst-x',
57
- uiPort: 8765,
58
- libp2pPort: 4001,
59
- dataDir: dir,
60
- identityPath: join(dir, 'identity.key'),
61
- upnpEnabled: true,
62
- installedAt: '2025-01-01T00:00:00Z',
63
- installerVersion: '0.6.0',
64
- };
65
- const path = join(dir, 'host.config.json');
66
- writeFileSync(path, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
67
- return path;
68
- }
69
-
70
- describe('/api/settings routes', () => {
71
- let app: ReturnType<typeof Fastify>;
72
- let dataDir: string;
73
-
74
- beforeEach(async () => {
75
- dataDir = mkdtempSync(join(tmpdir(), 'cadre-host-settings-'));
76
- });
77
-
78
- afterEach(async () => {
79
- if (app) await app.close();
80
- rmSync(dataDir, { recursive: true, force: true });
81
- });
82
-
83
- it('GET returns the persisted config', async () => {
84
- writeV2Config(dataDir);
85
- app = Fastify();
86
- registerErrorHandler(app);
87
- registerSettingsRoutes(app, {
88
- settingsStore: new HostSettingsStore({ dataDir }),
89
- nat: fakeNat().svc,
90
- });
91
- const res = await app.inject({ method: 'GET', url: '/api/settings' });
92
- expect(res.statusCode).toBe(200);
93
- const body = res.json() as { data: { uiPort: number; updates: { autoApply: boolean } } };
94
- expect(body.data.uiPort).toBe(8765);
95
- expect(body.data.updates.autoApply).toBe(false);
96
- });
97
-
98
- it('PUT propagates upnpEnabled to nat service and host.config.json', async () => {
99
- writeV2Config(dataDir);
100
- const nat = fakeNat();
101
- app = Fastify();
102
- registerErrorHandler(app);
103
- registerSettingsRoutes(app, { settingsStore: new HostSettingsStore({ dataDir }), nat: nat.svc });
104
- const res = await app.inject({
105
- method: 'PUT',
106
- url: '/api/settings',
107
- headers: { 'content-type': 'application/json' },
108
- payload: JSON.stringify({ upnpEnabled: false }),
109
- });
110
- expect(res.statusCode).toBe(200);
111
- expect(nat.calls).toEqual([{ upnpEnabled: false }]);
112
- const body = res.json() as { data: { upnpEnabled: boolean } };
113
- expect(body.data.upnpEnabled).toBe(false);
114
- });
115
-
116
- it('PUT updates.autoApply propagates to update service', async () => {
117
- writeV2Config(dataDir);
118
- const nat = fakeNat();
119
- const update = fakeUpdate();
120
- app = Fastify();
121
- registerErrorHandler(app);
122
- registerSettingsRoutes(app, { settingsStore: new HostSettingsStore({ dataDir }), nat: nat.svc, update: update.svc });
123
- const res = await app.inject({
124
- method: 'PUT',
125
- url: '/api/settings',
126
- headers: { 'content-type': 'application/json' },
127
- payload: JSON.stringify({ updates: { autoApply: true } }),
128
- });
129
- expect(res.statusCode).toBe(200);
130
- expect(update.calls).toEqual([{ autoApply: true }]);
131
- const body = res.json() as { data: { updates: { autoApply: boolean } } };
132
- expect(body.data.updates.autoApply).toBe(true);
133
- });
134
-
135
- it('PUT uiPort returns 400 invalid_setting', async () => {
136
- writeV2Config(dataDir);
137
- app = Fastify();
138
- registerErrorHandler(app);
139
- registerSettingsRoutes(app, { settingsStore: new HostSettingsStore({ dataDir }), nat: fakeNat().svc });
140
- const res = await app.inject({
141
- method: 'PUT',
142
- url: '/api/settings',
143
- headers: { 'content-type': 'application/json' },
144
- payload: JSON.stringify({ uiPort: 9999 }),
145
- });
146
- expect(res.statusCode).toBe(400);
147
- const body = res.json() as { error: { code: string; message: string } };
148
- expect(body.error.code).toBe('invalid_setting');
149
- expect(body.error.message).toMatch(/uiPort/);
150
- });
151
-
152
- it('PUT ownCadre returns 400 invalid_setting (install-time only)', async () => {
153
- writeV2Config(dataDir);
154
- app = Fastify();
155
- registerErrorHandler(app);
156
- registerSettingsRoutes(app, { settingsStore: new HostSettingsStore({ dataDir }), nat: fakeNat().svc });
157
- const res = await app.inject({
158
- method: 'PUT',
159
- url: '/api/settings',
160
- headers: { 'content-type': 'application/json' },
161
- payload: JSON.stringify({ ownCadre: { enabled: true } }),
162
- });
163
- expect(res.statusCode).toBe(400);
164
- const body = res.json() as { error: { code: string; message: string } };
165
- expect(body.error.code).toBe('invalid_setting');
166
- expect(body.error.message).toMatch(/ownCadre/);
167
- });
168
-
169
- it('PUT unknown setting returns 400', async () => {
170
- writeV2Config(dataDir);
171
- app = Fastify();
172
- registerErrorHandler(app);
173
- registerSettingsRoutes(app, { settingsStore: new HostSettingsStore({ dataDir }), nat: fakeNat().svc });
174
- const res = await app.inject({
175
- method: 'PUT',
176
- url: '/api/settings',
177
- headers: { 'content-type': 'application/json' },
178
- payload: JSON.stringify({ totallyMadeUp: 'value' }),
179
- });
180
- expect(res.statusCode).toBe(400);
181
- });
182
-
183
- it('rejects a v1 host.config.json without rewriting it', async () => {
184
- const path = writeV1Config(dataDir);
185
- const raw = readFileSync(path, 'utf8');
186
- app = Fastify();
187
- registerErrorHandler(app);
188
- registerSettingsRoutes(app, { settingsStore: new HostSettingsStore({ dataDir }), nat: fakeNat().svc });
189
- const res = await app.inject({ method: 'GET', url: '/api/settings' });
190
- expect(res.statusCode).toBe(500);
191
- const body = res.json() as { error: { message: string } };
192
- expect(body.error.message).toMatch(/unsupported version=1/);
193
- expect(readFileSync(path, 'utf8')).toBe(raw);
194
- });
195
- });
1
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ import Fastify from 'fastify';
6
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
7
+
8
+ import { registerErrorHandler } from '../error-handler.js';
9
+ import { registerSettingsRoutes } from '../routes/settings.js';
10
+ import { HostSettingsStore } from '../settings-store.js';
11
+ import type { NatService } from '../../nat/index.js';
12
+ import type { NatStatusSnapshot } from '../../nat/types.js';
13
+ import type { UpdateService } from '../../update/index.js';
14
+ import type { UpdateSettings } from '../../update/types.js';
15
+
16
+ function fakeNat(): { svc: NatService; calls: Array<unknown> } {
17
+ const calls: unknown[] = [];
18
+ const stub = {
19
+ putSettings: async (patch: unknown) => { calls.push(patch); return {} as NatStatusSnapshot; },
20
+ } as unknown as NatService;
21
+ return { svc: stub, calls };
22
+ }
23
+
24
+ function fakeUpdate(): { svc: UpdateService; calls: UpdateSettings[] } {
25
+ const calls: UpdateSettings[] = [];
26
+ const stub = {
27
+ putSettings: async (patch: { autoApply?: boolean; manifestUrl?: string }) => {
28
+ calls.push({ autoApply: patch.autoApply ?? false, ...(patch.manifestUrl ? { manifestUrl: patch.manifestUrl } : {}) });
29
+ return { autoApply: patch.autoApply ?? false } as UpdateSettings;
30
+ },
31
+ } as unknown as UpdateService;
32
+ return { svc: stub, calls };
33
+ }
34
+
35
+ function writeConfig(dir: string): string {
36
+ const cfg = {
37
+ version: 3,
38
+ installId: 'inst-x',
39
+ uiPort: 8765,
40
+ dataDir: dir,
41
+ upnpEnabled: true,
42
+ installedAt: '2025-01-01T00:00:00Z',
43
+ installerVersion: '0.6.0',
44
+ updates: { autoApply: false },
45
+ };
46
+ const path = join(dir, 'host.config.json');
47
+ writeFileSync(path, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
48
+ return path;
49
+ }
50
+
51
+ /** A founder-era file: refused on read, never migrated. */
52
+ function writeV2Config(dir: string): string {
53
+ const cfg = {
54
+ version: 2,
55
+ installId: 'inst-x',
56
+ uiPort: 8765,
57
+ libp2pPort: 4001,
58
+ dataDir: dir,
59
+ identityPath: join(dir, 'identity.key'),
60
+ upnpEnabled: true,
61
+ installedAt: '2025-01-01T00:00:00Z',
62
+ installerVersion: '0.6.0',
63
+ updates: { autoApply: false },
64
+ ownCadre: { enabled: true },
65
+ };
66
+ const path = join(dir, 'host.config.json');
67
+ writeFileSync(path, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
68
+ return path;
69
+ }
70
+
71
+ describe('/api/settings routes', () => {
72
+ let app: ReturnType<typeof Fastify>;
73
+ let dataDir: string;
74
+
75
+ beforeEach(async () => {
76
+ dataDir = mkdtempSync(join(tmpdir(), 'cadre-host-settings-'));
77
+ });
78
+
79
+ afterEach(async () => {
80
+ if (app) await app.close();
81
+ rmSync(dataDir, { recursive: true, force: true });
82
+ });
83
+
84
+ it('GET returns the persisted config', async () => {
85
+ writeConfig(dataDir);
86
+ app = Fastify();
87
+ registerErrorHandler(app);
88
+ registerSettingsRoutes(app, {
89
+ settingsStore: new HostSettingsStore({ dataDir }),
90
+ nat: fakeNat().svc,
91
+ });
92
+ const res = await app.inject({ method: 'GET', url: '/api/settings' });
93
+ expect(res.statusCode).toBe(200);
94
+ const body = res.json() as { data: { uiPort: number; updates: { autoApply: boolean } } };
95
+ expect(body.data.uiPort).toBe(8765);
96
+ expect(body.data.updates.autoApply).toBe(false);
97
+ });
98
+
99
+ it('PUT propagates upnpEnabled to nat service and host.config.json', async () => {
100
+ writeConfig(dataDir);
101
+ const nat = fakeNat();
102
+ app = Fastify();
103
+ registerErrorHandler(app);
104
+ registerSettingsRoutes(app, { settingsStore: new HostSettingsStore({ dataDir }), nat: nat.svc });
105
+ const res = await app.inject({
106
+ method: 'PUT',
107
+ url: '/api/settings',
108
+ headers: { 'content-type': 'application/json' },
109
+ payload: JSON.stringify({ upnpEnabled: false }),
110
+ });
111
+ expect(res.statusCode).toBe(200);
112
+ expect(nat.calls).toEqual([{ upnpEnabled: false }]);
113
+ const body = res.json() as { data: { upnpEnabled: boolean } };
114
+ expect(body.data.upnpEnabled).toBe(false);
115
+ });
116
+
117
+ it('PUT updates.autoApply propagates to update service', async () => {
118
+ writeConfig(dataDir);
119
+ const nat = fakeNat();
120
+ const update = fakeUpdate();
121
+ app = Fastify();
122
+ registerErrorHandler(app);
123
+ registerSettingsRoutes(app, { settingsStore: new HostSettingsStore({ dataDir }), nat: nat.svc, update: update.svc });
124
+ const res = await app.inject({
125
+ method: 'PUT',
126
+ url: '/api/settings',
127
+ headers: { 'content-type': 'application/json' },
128
+ payload: JSON.stringify({ updates: { autoApply: true } }),
129
+ });
130
+ expect(res.statusCode).toBe(200);
131
+ expect(update.calls).toEqual([{ autoApply: true }]);
132
+ const body = res.json() as { data: { updates: { autoApply: boolean } } };
133
+ expect(body.data.updates.autoApply).toBe(true);
134
+ });
135
+
136
+ it('PUT uiPort returns 400 invalid_setting', async () => {
137
+ writeConfig(dataDir);
138
+ app = Fastify();
139
+ registerErrorHandler(app);
140
+ registerSettingsRoutes(app, { settingsStore: new HostSettingsStore({ dataDir }), nat: fakeNat().svc });
141
+ const res = await app.inject({
142
+ method: 'PUT',
143
+ url: '/api/settings',
144
+ headers: { 'content-type': 'application/json' },
145
+ payload: JSON.stringify({ uiPort: 9999 }),
146
+ });
147
+ expect(res.statusCode).toBe(400);
148
+ const body = res.json() as { error: { code: string; message: string } };
149
+ expect(body.error.code).toBe('invalid_setting');
150
+ expect(body.error.message).toMatch(/uiPort/);
151
+ });
152
+
153
+ it('PUT claimAddresses persists it, and refuses a bad lan or address naming the field', async () => {
154
+ writeConfig(dataDir);
155
+ app = Fastify();
156
+ registerErrorHandler(app);
157
+ const settingsStore = new HostSettingsStore({ dataDir });
158
+ registerSettingsRoutes(app, { settingsStore, nat: fakeNat().svc });
159
+ const put = (payload: unknown) => app!.inject({
160
+ method: 'PUT',
161
+ url: '/api/settings',
162
+ headers: { 'content-type': 'application/json' },
163
+ payload: JSON.stringify(payload),
164
+ });
165
+
166
+ const ok = await put({ claimAddresses: { lan: '192.168.2.27', addrs: [' /dns4/bateman.cc/tcp/51234/ws ', ''] } });
167
+ expect(ok.statusCode).toBe(200);
168
+ expect(settingsStore.read().claimAddresses).toEqual({ lan: '192.168.2.27', addrs: ['/dns4/bateman.cc/tcp/51234/ws'] });
169
+
170
+ for (const [payload, message] of [
171
+ [{ claimAddresses: { lan: 'sometimes' } }, /claimAddresses.lan/],
172
+ [{ claimAddresses: { addrs: ['not a multiaddr'] } }, /claimAddresses.addrs/],
173
+ [{ claimAddresses: { addrs: ['/dns4/a.example/tcp/443/wss/p2p/12D3KooWQ7wMHcR6Z9zr2qmFyKW6pYp1VzJPfgoTHKGdEfMXz2Ve'] } }, /must not end in \/p2p/],
174
+ [{ claimAddresses: { extra: [] } }, /claimAddresses.extra/],
175
+ [{ claimAddresses: 'auto' }, /must be an object/],
176
+ ] as const) {
177
+ const res = await put(payload);
178
+ expect(res.statusCode).toBe(400);
179
+ expect((res.json() as { error: { message: string } }).error.message).toMatch(message);
180
+ }
181
+ expect(settingsStore.read().claimAddresses?.lan).toBe('192.168.2.27');
182
+ });
183
+
184
+ it('PUT unknown setting returns 400', async () => {
185
+ writeConfig(dataDir);
186
+ app = Fastify();
187
+ registerErrorHandler(app);
188
+ registerSettingsRoutes(app, { settingsStore: new HostSettingsStore({ dataDir }), nat: fakeNat().svc });
189
+ const res = await app.inject({
190
+ method: 'PUT',
191
+ url: '/api/settings',
192
+ headers: { 'content-type': 'application/json' },
193
+ payload: JSON.stringify({ totallyMadeUp: 'value' }),
194
+ });
195
+ expect(res.statusCode).toBe(400);
196
+ });
197
+
198
+ it('rejects a v2 host.config.json without rewriting it', async () => {
199
+ const path = writeV2Config(dataDir);
200
+ const raw = readFileSync(path, 'utf8');
201
+ app = Fastify();
202
+ registerErrorHandler(app);
203
+ registerSettingsRoutes(app, { settingsStore: new HostSettingsStore({ dataDir }), nat: fakeNat().svc });
204
+ const res = await app.inject({ method: 'GET', url: '/api/settings' });
205
+ expect(res.statusCode).toBe(500);
206
+ const body = res.json() as { error: { message: string } };
207
+ expect(body.error.message).toMatch(/unsupported version=2/);
208
+ expect(readFileSync(path, 'utf8')).toBe(raw);
209
+ });
210
+ });
@@ -1,119 +1,119 @@
1
- import { createServer, type AddressInfo } from 'node:net';
2
-
3
- import Fastify from 'fastify';
4
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
5
-
6
- import { EventBus } from '../events/bus.js';
7
- import { registerSseRoute } from '../events/sse-route.js';
8
-
9
- async function pickFreePort(): Promise<number> {
10
- return await new Promise<number>((resolve, reject) => {
11
- const server = createServer();
12
- server.unref();
13
- server.on('error', reject);
14
- server.listen({ host: '127.0.0.1', port: 0 }, () => {
15
- const addr = server.address() as AddressInfo;
16
- server.close(() => resolve(addr.port));
17
- });
18
- });
19
- }
20
-
21
- /** Parse SSE chunks and append decoded events to `events`. */
22
- function parseSseChunk(buf: string, state: { current: { type: string; data: string } }, events: Array<{ type: string; data: string }>): string {
23
- let buffered = buf;
24
- let nl: number;
25
- while ((nl = buffered.indexOf('\n')) !== -1) {
26
- const line = buffered.slice(0, nl).replace(/\r$/, '');
27
- buffered = buffered.slice(nl + 1);
28
- if (line === '') {
29
- if (state.current.type || state.current.data) {
30
- events.push({ ...state.current });
31
- state.current.type = '';
32
- state.current.data = '';
33
- }
34
- continue;
35
- }
36
- if (line.startsWith(':')) continue;
37
- if (line.startsWith('event:')) { state.current.type = line.slice(6).trim(); continue; }
38
- if (line.startsWith('data:')) { state.current.data = line.slice(5).trim(); continue; }
39
- }
40
- return buffered;
41
- }
42
-
43
- describe('GET /api/events (SSE)', () => {
44
- let app: ReturnType<typeof Fastify>;
45
- let bus: EventBus;
46
- let port: number;
47
-
48
- beforeEach(async () => {
49
- port = await pickFreePort();
50
- bus = new EventBus();
51
- app = Fastify();
52
- registerSseRoute(app, { events: bus, heartbeatIntervalMs: 50 });
53
- await app.listen({ host: '127.0.0.1', port });
54
- });
55
-
56
- afterEach(async () => {
57
- await app.close();
58
- });
59
-
60
- it('delivers published events in order and cleans up on disconnect', async () => {
61
- const { request: httpRequest } = await import('node:http');
62
- const events: Array<{ type: string; data: string }> = [];
63
- const state = { current: { type: '', data: '' } };
64
- let leftover = '';
65
- let done = false;
66
-
67
- const req = httpRequest({
68
- hostname: '127.0.0.1',
69
- port,
70
- path: '/api/events',
71
- method: 'GET',
72
- headers: { accept: 'text/event-stream' },
73
- });
74
-
75
- const responseReady = new Promise<void>((resolve) => {
76
- req.on('response', (res) => {
77
- res.setEncoding('utf8');
78
- res.on('data', (chunk: string) => {
79
- leftover = parseSseChunk(leftover + chunk, state, events);
80
- });
81
- res.on('end', () => { done = true; });
82
- resolve();
83
- });
84
- });
85
- req.end();
86
- await responseReady;
87
-
88
- // Give the connection a beat to settle on the server, then publish.
89
- await new Promise<void>((r) => setTimeout(r, 30));
90
- bus.publish({ type: 'trust-circle-changed', kind: 'invited' });
91
- bus.publish({ type: 'trust-circle-changed', kind: 'redeemed' });
92
- bus.publish({ type: 'connectivity-changed', portMode: 'auto-upnp', directReachability: 'reachable' });
93
-
94
- // Wait until at least 3 events have arrived (or 1s).
95
- const deadline = Date.now() + 1000;
96
- while (events.length < 3 && Date.now() < deadline) {
97
- await new Promise<void>((r) => setTimeout(r, 25));
98
- }
99
-
100
- expect(events.length).toBeGreaterThanOrEqual(3);
101
- expect(events.slice(0, 3).map((e) => e.type)).toEqual([
102
- 'trust-circle-changed',
103
- 'trust-circle-changed',
104
- 'connectivity-changed',
105
- ]);
106
- const first = JSON.parse(events[0]!.data) as { kind: string };
107
- expect(first.kind).toBe('invited');
108
-
109
- expect(bus.listenerCount()).toBe(1);
110
- req.destroy();
111
- // Give the server's 'close' handler a tick to run.
112
- const dropDeadline = Date.now() + 1000;
113
- while (bus.listenerCount() > 0 && Date.now() < dropDeadline) {
114
- await new Promise<void>((r) => setTimeout(r, 25));
115
- }
116
- expect(bus.listenerCount()).toBe(0);
117
- void done;
118
- });
119
- });
1
+ import { createServer, type AddressInfo } from 'node:net';
2
+
3
+ import Fastify from 'fastify';
4
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
5
+
6
+ import { EventBus } from '../events/bus.js';
7
+ import { registerSseRoute } from '../events/sse-route.js';
8
+
9
+ async function pickFreePort(): Promise<number> {
10
+ return await new Promise<number>((resolve, reject) => {
11
+ const server = createServer();
12
+ server.unref();
13
+ server.on('error', reject);
14
+ server.listen({ host: '127.0.0.1', port: 0 }, () => {
15
+ const addr = server.address() as AddressInfo;
16
+ server.close(() => resolve(addr.port));
17
+ });
18
+ });
19
+ }
20
+
21
+ /** Parse SSE chunks and append decoded events to `events`. */
22
+ function parseSseChunk(buf: string, state: { current: { type: string; data: string } }, events: Array<{ type: string; data: string }>): string {
23
+ let buffered = buf;
24
+ let nl: number;
25
+ while ((nl = buffered.indexOf('\n')) !== -1) {
26
+ const line = buffered.slice(0, nl).replace(/\r$/, '');
27
+ buffered = buffered.slice(nl + 1);
28
+ if (line === '') {
29
+ if (state.current.type || state.current.data) {
30
+ events.push({ ...state.current });
31
+ state.current.type = '';
32
+ state.current.data = '';
33
+ }
34
+ continue;
35
+ }
36
+ if (line.startsWith(':')) continue;
37
+ if (line.startsWith('event:')) { state.current.type = line.slice(6).trim(); continue; }
38
+ if (line.startsWith('data:')) { state.current.data = line.slice(5).trim(); continue; }
39
+ }
40
+ return buffered;
41
+ }
42
+
43
+ describe('GET /api/events (SSE)', () => {
44
+ let app: ReturnType<typeof Fastify>;
45
+ let bus: EventBus;
46
+ let port: number;
47
+
48
+ beforeEach(async () => {
49
+ port = await pickFreePort();
50
+ bus = new EventBus();
51
+ app = Fastify();
52
+ registerSseRoute(app, { events: bus, heartbeatIntervalMs: 50 });
53
+ await app.listen({ host: '127.0.0.1', port });
54
+ });
55
+
56
+ afterEach(async () => {
57
+ await app.close();
58
+ });
59
+
60
+ it('delivers published events in order and cleans up on disconnect', async () => {
61
+ const { request: httpRequest } = await import('node:http');
62
+ const events: Array<{ type: string; data: string }> = [];
63
+ const state = { current: { type: '', data: '' } };
64
+ let leftover = '';
65
+ let done = false;
66
+
67
+ const req = httpRequest({
68
+ hostname: '127.0.0.1',
69
+ port,
70
+ path: '/api/events',
71
+ method: 'GET',
72
+ headers: { accept: 'text/event-stream' },
73
+ });
74
+
75
+ const responseReady = new Promise<void>((resolve) => {
76
+ req.on('response', (res) => {
77
+ res.setEncoding('utf8');
78
+ res.on('data', (chunk: string) => {
79
+ leftover = parseSseChunk(leftover + chunk, state, events);
80
+ });
81
+ res.on('end', () => { done = true; });
82
+ resolve();
83
+ });
84
+ });
85
+ req.end();
86
+ await responseReady;
87
+
88
+ // Give the connection a beat to settle on the server, then publish.
89
+ await new Promise<void>((r) => setTimeout(r, 30));
90
+ bus.publish({ type: 'hosted-nodes-changed', kind: 'added', nodeId: 'hn_a' });
91
+ bus.publish({ type: 'hosted-nodes-changed', kind: 'claimed', nodeId: 'hn_a' });
92
+ bus.publish({ type: 'connectivity-changed', directReachability: 'reachable' });
93
+
94
+ // Wait until at least 3 events have arrived (or 1s).
95
+ const deadline = Date.now() + 1000;
96
+ while (events.length < 3 && Date.now() < deadline) {
97
+ await new Promise<void>((r) => setTimeout(r, 25));
98
+ }
99
+
100
+ expect(events.length).toBeGreaterThanOrEqual(3);
101
+ expect(events.slice(0, 3).map((e) => e.type)).toEqual([
102
+ 'hosted-nodes-changed',
103
+ 'hosted-nodes-changed',
104
+ 'connectivity-changed',
105
+ ]);
106
+ const first = JSON.parse(events[0]!.data) as { kind: string };
107
+ expect(first.kind).toBe('added');
108
+
109
+ expect(bus.listenerCount()).toBe(1);
110
+ req.destroy();
111
+ // Give the server's 'close' handler a tick to run.
112
+ const dropDeadline = Date.now() + 1000;
113
+ while (bus.listenerCount() > 0 && Date.now() < dropDeadline) {
114
+ await new Promise<void>((r) => setTimeout(r, 25));
115
+ }
116
+ expect(bus.listenerCount()).toBe(0);
117
+ void done;
118
+ });
119
+ });