@serfab/cadre-host 1.13.0 → 1.14.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 (337) hide show
  1. package/README.md +332 -423
  2. package/dist/bin/host.d.ts +6 -3
  3. package/dist/bin/host.js +301 -505
  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 +49 -0
  9. package/dist/bin/nat-output.js +127 -0
  10. package/dist/bin/nat-output.js.map +1 -0
  11. package/dist/hosted/claim-details.d.ts +44 -0
  12. package/dist/hosted/claim-details.js +66 -0
  13. package/dist/hosted/claim-details.js.map +1 -0
  14. package/dist/hosted/hosted-node-service.d.ts +318 -0
  15. package/dist/hosted/hosted-node-service.js +666 -0
  16. package/dist/hosted/hosted-node-service.js.map +1 -0
  17. package/dist/hosted/hosted-node-store.d.ts +40 -0
  18. package/dist/hosted/hosted-node-store.js +126 -0
  19. package/dist/hosted/hosted-node-store.js.map +1 -0
  20. package/dist/{donation/donation-supervisor.d.ts → hosted/hosted-node-supervisor.d.ts} +70 -53
  21. package/dist/hosted/hosted-node-supervisor.js +396 -0
  22. package/dist/hosted/hosted-node-supervisor.js.map +1 -0
  23. package/dist/hosted/hosted-node-watcher.d.ts +83 -0
  24. package/dist/hosted/hosted-node-watcher.js +224 -0
  25. package/dist/hosted/hosted-node-watcher.js.map +1 -0
  26. package/dist/hosted/index.d.ts +16 -0
  27. package/dist/hosted/index.js +13 -0
  28. package/dist/hosted/index.js.map +1 -0
  29. package/dist/hosted/node-status.d.ts +35 -0
  30. package/dist/hosted/node-status.js +61 -0
  31. package/dist/hosted/node-status.js.map +1 -0
  32. package/dist/hosted/types.d.ts +134 -0
  33. package/dist/hosted/types.js +22 -0
  34. package/dist/hosted/types.js.map +1 -0
  35. package/dist/index.d.ts +12 -24
  36. package/dist/index.js +9 -19
  37. package/dist/index.js.map +1 -1
  38. package/dist/installer/config.d.ts +4 -34
  39. package/dist/installer/config.js +3 -18
  40. package/dist/installer/config.js.map +1 -1
  41. package/dist/installer/index.d.ts +7 -19
  42. package/dist/installer/index.js +14 -69
  43. package/dist/installer/index.js.map +1 -1
  44. package/dist/installer/paths.d.ts +0 -2
  45. package/dist/installer/paths.js +0 -4
  46. package/dist/installer/paths.js.map +1 -1
  47. package/dist/installer/wizard.d.ts +0 -7
  48. package/dist/installer/wizard.js +1 -8
  49. package/dist/installer/wizard.js.map +1 -1
  50. package/dist/nat/address-resolver.d.ts +18 -17
  51. package/dist/nat/address-resolver.js +79 -25
  52. package/dist/nat/address-resolver.js.map +1 -1
  53. package/dist/nat/address-watch.d.ts +37 -0
  54. package/dist/nat/address-watch.js +84 -0
  55. package/dist/nat/address-watch.js.map +1 -0
  56. package/dist/nat/ddns/duckdns.d.ts +2 -1
  57. package/dist/nat/ddns/duckdns.js +5 -1
  58. package/dist/nat/ddns/duckdns.js.map +1 -1
  59. package/dist/nat/ddns/index.d.ts +6 -0
  60. package/dist/nat/ddns/index.js.map +1 -1
  61. package/dist/nat/index.d.ts +12 -9
  62. package/dist/nat/index.js +6 -5
  63. package/dist/nat/index.js.map +1 -1
  64. package/dist/nat/nat-service.d.ts +203 -77
  65. package/dist/nat/nat-service.js +602 -237
  66. package/dist/nat/nat-service.js.map +1 -1
  67. package/dist/nat/nat-store.d.ts +12 -5
  68. package/dist/nat/nat-store.js +114 -30
  69. package/dist/nat/nat-store.js.map +1 -1
  70. package/dist/nat/port-mapper.d.ts +83 -79
  71. package/dist/nat/port-mapper.js +148 -189
  72. package/dist/nat/port-mapper.js.map +1 -1
  73. package/dist/nat/reachability.d.ts +31 -15
  74. package/dist/nat/reachability.js +97 -22
  75. package/dist/nat/reachability.js.map +1 -1
  76. package/dist/nat/types.d.ts +78 -27
  77. package/dist/nat/types.js +8 -5
  78. package/dist/nat/types.js.map +1 -1
  79. package/dist/orchestrator/host-process-orchestrator.d.ts +29 -71
  80. package/dist/orchestrator/host-process-orchestrator.js +78 -218
  81. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  82. package/dist/{installer/identity.d.ts → orchestrator/identity-file.d.ts} +5 -5
  83. package/dist/{installer/identity.js → orchestrator/identity-file.js} +6 -6
  84. package/dist/orchestrator/identity-file.js.map +1 -0
  85. package/dist/orchestrator/index.d.ts +2 -3
  86. package/dist/orchestrator/index.js +1 -1
  87. package/dist/orchestrator/index.js.map +1 -1
  88. package/dist/orchestrator/node-identity.d.ts +6 -6
  89. package/dist/orchestrator/node-identity.js +7 -7
  90. package/dist/orchestrator/node-identity.js.map +1 -1
  91. package/dist/orchestrator/port-allocator.d.ts +5 -6
  92. package/dist/orchestrator/port-allocator.js +9 -11
  93. package/dist/orchestrator/port-allocator.js.map +1 -1
  94. package/dist/orchestrator/state-store.d.ts +3 -5
  95. package/dist/orchestrator/state-store.js +2 -10
  96. package/dist/orchestrator/state-store.js.map +1 -1
  97. package/dist/orchestrator/types.d.ts +33 -22
  98. package/dist/orchestrator/types.js.map +1 -1
  99. package/dist/server/error-handler.d.ts +5 -25
  100. package/dist/server/error-handler.js +11 -74
  101. package/dist/server/error-handler.js.map +1 -1
  102. package/dist/server/events/types.d.ts +7 -11
  103. package/dist/server/events/types.js +2 -1
  104. package/dist/server/events/types.js.map +1 -1
  105. package/dist/server/index.d.ts +11 -37
  106. package/dist/server/index.js +30 -48
  107. package/dist/server/index.js.map +1 -1
  108. package/dist/server/routes/hosted-nodes.d.ts +28 -0
  109. package/dist/server/routes/hosted-nodes.js +68 -0
  110. package/dist/server/routes/hosted-nodes.js.map +1 -0
  111. package/dist/server/routes/nat.d.ts +4 -1
  112. package/dist/server/routes/nat.js +13 -2
  113. package/dist/server/routes/nat.js.map +1 -1
  114. package/dist/server/routes/nodes.d.ts +6 -20
  115. package/dist/server/routes/nodes.js +7 -86
  116. package/dist/server/routes/nodes.js.map +1 -1
  117. package/dist/server/routes/settings.d.ts +2 -7
  118. package/dist/server/routes/settings.js +3 -7
  119. package/dist/server/routes/settings.js.map +1 -1
  120. package/dist/server/routes/status.d.ts +2 -25
  121. package/dist/server/routes/status.js +1 -11
  122. package/dist/server/routes/status.js.map +1 -1
  123. package/dist/server/server.js +8 -8
  124. package/dist/server/settings-store.d.ts +2 -2
  125. package/dist/server/settings-store.js +2 -2
  126. package/dist/server/static.js +1 -2
  127. package/dist/server/static.js.map +1 -1
  128. package/dist/ui/assets/index-4FWuU8ye.js +19 -0
  129. package/dist/ui/assets/index-4FWuU8ye.js.map +1 -0
  130. package/dist/ui/assets/index-Di9Q4d01.css +1 -0
  131. package/dist/ui/index.html +2 -2
  132. package/dist/update/index.d.ts +2 -2
  133. package/dist/update/index.js +2 -2
  134. package/dist/update/store.d.ts +1 -1
  135. package/dist/update/store.js +1 -1
  136. package/dist/update/types.d.ts +1 -1
  137. package/dist/update/types.js +1 -1
  138. package/package.json +5 -6
  139. package/src/__tests__/cli-nat.smoke.test.ts +219 -212
  140. package/src/__tests__/cli.smoke.test.ts +1 -1
  141. package/src/__tests__/global-setup.ts +2 -3
  142. package/src/__tests__/orchestrator-claim-secret.test.ts +112 -0
  143. package/src/__tests__/orchestrator-handle-contract.ts +134 -134
  144. package/src/__tests__/orchestrator-node-identity.test.ts +182 -203
  145. package/src/__tests__/orchestrator-ports.test.ts +124 -143
  146. package/src/__tests__/orchestrator-push.test.ts +138 -146
  147. package/src/__tests__/orchestrator.test.ts +1019 -1063
  148. package/src/bin/host.ts +368 -553
  149. package/src/bin/join-output.ts +120 -0
  150. package/src/bin/nat-output.ts +177 -0
  151. package/src/{donation → hosted}/__tests__/fake-orchestrator.test.ts +173 -173
  152. package/src/{donation → hosted}/__tests__/fake-orchestrator.ts +265 -243
  153. package/src/hosted/__tests__/hosted-node-service.test.ts +609 -0
  154. package/src/hosted/__tests__/hosted-node-supervisor.test.ts +474 -0
  155. package/src/hosted/__tests__/test-invitation.ts +33 -0
  156. package/src/hosted/claim-details.ts +96 -0
  157. package/src/hosted/hosted-node-service.ts +795 -0
  158. package/src/hosted/hosted-node-store.ts +144 -0
  159. package/src/hosted/hosted-node-supervisor.ts +439 -0
  160. package/src/hosted/hosted-node-watcher.ts +250 -0
  161. package/src/hosted/index.ts +53 -0
  162. package/src/hosted/node-status.ts +71 -0
  163. package/src/hosted/types.ts +153 -0
  164. package/src/index.ts +72 -91
  165. package/src/installer/__tests__/config.test.ts +15 -40
  166. package/src/installer/__tests__/installer.smoke.test.ts +5 -86
  167. package/src/installer/__tests__/wizard.test.ts +56 -64
  168. package/src/installer/config.ts +135 -175
  169. package/src/installer/index.ts +15 -83
  170. package/src/installer/paths.ts +104 -109
  171. package/src/installer/wizard.ts +87 -103
  172. package/src/nat/__tests__/address-resolver.test.ts +107 -82
  173. package/src/nat/__tests__/nat-service.test.ts +433 -408
  174. package/src/nat/__tests__/nat-store.test.ts +127 -93
  175. package/src/nat/__tests__/port-mapper.test.ts +30 -191
  176. package/src/nat/__tests__/reachability.test.ts +96 -13
  177. package/src/nat/address-resolver.ts +94 -52
  178. package/src/nat/address-watch.ts +92 -0
  179. package/src/nat/ddns/duckdns.ts +5 -1
  180. package/src/nat/ddns/index.ts +6 -0
  181. package/src/nat/index.ts +87 -71
  182. package/src/nat/nat-service.ts +889 -501
  183. package/src/nat/nat-store.ts +237 -154
  184. package/src/nat/port-mapper.ts +241 -329
  185. package/src/nat/reachability.ts +134 -25
  186. package/src/nat/types.ts +209 -159
  187. package/src/{installer/__tests__/identity.test.ts → orchestrator/__tests__/identity-file.test.ts} +34 -34
  188. package/src/orchestrator/__tests__/node-identity.test.ts +62 -62
  189. package/src/orchestrator/host-process-orchestrator.ts +81 -243
  190. package/src/{installer/identity.ts → orchestrator/identity-file.ts} +56 -56
  191. package/src/orchestrator/index.ts +9 -10
  192. package/src/orchestrator/node-identity.ts +41 -41
  193. package/src/orchestrator/port-allocator.ts +71 -73
  194. package/src/orchestrator/state-store.ts +76 -90
  195. package/src/orchestrator/types.ts +34 -23
  196. package/src/server/__tests__/bus.test.ts +38 -38
  197. package/src/server/__tests__/error-handler.test.ts +81 -118
  198. package/src/server/__tests__/fakes.ts +40 -44
  199. package/src/server/__tests__/hosted-nodes-route.test.ts +160 -0
  200. package/src/server/__tests__/nodes-route.test.ts +114 -283
  201. package/src/server/__tests__/publishers.test.ts +193 -180
  202. package/src/server/__tests__/server.smoke.test.ts +134 -223
  203. package/src/server/__tests__/settings-route.test.ts +179 -195
  204. package/src/server/__tests__/sse-route.test.ts +119 -119
  205. package/src/server/__tests__/status-route.test.ts +82 -121
  206. package/src/server/error-handler.ts +112 -178
  207. package/src/server/events/types.ts +20 -24
  208. package/src/server/index.ts +223 -267
  209. package/src/server/routes/hosted-nodes.ts +83 -0
  210. package/src/server/routes/nat.ts +75 -63
  211. package/src/server/routes/nodes.ts +96 -188
  212. package/src/server/routes/settings.ts +115 -123
  213. package/src/server/routes/status.ts +102 -134
  214. package/src/server/server.ts +8 -8
  215. package/src/server/settings-store.ts +2 -2
  216. package/src/server/static.ts +1 -2
  217. package/src/update/index.ts +2 -2
  218. package/src/update/store.ts +1 -1
  219. package/src/update/types.ts +1 -1
  220. package/dist/auth/duration.d.ts +0 -1
  221. package/dist/auth/duration.js +0 -32
  222. package/dist/auth/duration.js.map +0 -1
  223. package/dist/auth/index.d.ts +0 -11
  224. package/dist/auth/index.js +0 -9
  225. package/dist/auth/index.js.map +0 -1
  226. package/dist/auth/self-label.d.ts +0 -36
  227. package/dist/auth/self-label.js +0 -57
  228. package/dist/auth/self-label.js.map +0 -1
  229. package/dist/auth/trust-circle-store.d.ts +0 -26
  230. package/dist/auth/trust-circle-store.js +0 -129
  231. package/dist/auth/trust-circle-store.js.map +0 -1
  232. package/dist/auth/trust-circle.d.ts +0 -156
  233. package/dist/auth/trust-circle.js +0 -307
  234. package/dist/auth/trust-circle.js.map +0 -1
  235. package/dist/auth/types.d.ts +0 -84
  236. package/dist/auth/types.js +0 -24
  237. package/dist/auth/types.js.map +0 -1
  238. package/dist/donation/donation-service.d.ts +0 -389
  239. package/dist/donation/donation-service.js +0 -828
  240. package/dist/donation/donation-service.js.map +0 -1
  241. package/dist/donation/donation-store.d.ts +0 -51
  242. package/dist/donation/donation-store.js +0 -152
  243. package/dist/donation/donation-store.js.map +0 -1
  244. package/dist/donation/donation-supervisor.js +0 -364
  245. package/dist/donation/donation-supervisor.js.map +0 -1
  246. package/dist/donation/grant-service.d.ts +0 -80
  247. package/dist/donation/grant-service.js +0 -210
  248. package/dist/donation/grant-service.js.map +0 -1
  249. package/dist/donation/grant-store.d.ts +0 -38
  250. package/dist/donation/grant-store.js +0 -135
  251. package/dist/donation/grant-store.js.map +0 -1
  252. package/dist/donation/index.d.ts +0 -20
  253. package/dist/donation/index.js +0 -17
  254. package/dist/donation/index.js.map +0 -1
  255. package/dist/donation/types.d.ts +0 -224
  256. package/dist/donation/types.js +0 -40
  257. package/dist/donation/types.js.map +0 -1
  258. package/dist/installer/identity.js.map +0 -1
  259. package/dist/owner/index.d.ts +0 -11
  260. package/dist/owner/index.js +0 -11
  261. package/dist/owner/index.js.map +0 -1
  262. package/dist/owner/owner-node-client.d.ts +0 -110
  263. package/dist/owner/owner-node-client.js +0 -184
  264. package/dist/owner/owner-node-client.js.map +0 -1
  265. package/dist/server/routes/bootstrap-node-validation.d.ts +0 -115
  266. package/dist/server/routes/bootstrap-node-validation.js +0 -197
  267. package/dist/server/routes/bootstrap-node-validation.js.map +0 -1
  268. package/dist/server/routes/grants-admin.d.ts +0 -29
  269. package/dist/server/routes/grants-admin.js +0 -58
  270. package/dist/server/routes/grants-admin.js.map +0 -1
  271. package/dist/server/routes/grants.d.ts +0 -28
  272. package/dist/server/routes/grants.js +0 -165
  273. package/dist/server/routes/grants.js.map +0 -1
  274. package/dist/server/routes/provision-request-validation.d.ts +0 -45
  275. package/dist/server/routes/provision-request-validation.js +0 -97
  276. package/dist/server/routes/provision-request-validation.js.map +0 -1
  277. package/dist/server/routes/strands.d.ts +0 -20
  278. package/dist/server/routes/strands.js +0 -38
  279. package/dist/server/routes/strands.js.map +0 -1
  280. package/dist/server/routes/trust-circle.d.ts +0 -17
  281. package/dist/server/routes/trust-circle.js +0 -35
  282. package/dist/server/routes/trust-circle.js.map +0 -1
  283. package/dist/strands/index.d.ts +0 -7
  284. package/dist/strands/index.js +0 -6
  285. package/dist/strands/index.js.map +0 -1
  286. package/dist/strands/strand-service.d.ts +0 -50
  287. package/dist/strands/strand-service.js +0 -103
  288. package/dist/strands/strand-service.js.map +0 -1
  289. package/dist/strands/types.d.ts +0 -93
  290. package/dist/strands/types.js +0 -23
  291. package/dist/strands/types.js.map +0 -1
  292. package/dist/ui/assets/index-C6nELNfC.js +0 -23
  293. package/dist/ui/assets/index-C6nELNfC.js.map +0 -1
  294. package/dist/ui/assets/index-TtIYSeZO.css +0 -1
  295. package/src/__tests__/cli-invite.smoke.test.ts +0 -192
  296. package/src/__tests__/host-owner.smoke.test.ts +0 -123
  297. package/src/__tests__/orchestrator-owner.test.ts +0 -234
  298. package/src/__tests__/orchestrator-pin-keys.test.ts +0 -152
  299. package/src/auth/__tests__/duration.test.ts +0 -28
  300. package/src/auth/__tests__/self-label.test.ts +0 -85
  301. package/src/auth/__tests__/trust-circle-integration.test.ts +0 -152
  302. package/src/auth/__tests__/trust-circle-store.test.ts +0 -115
  303. package/src/auth/__tests__/trust-circle.test.ts +0 -445
  304. package/src/auth/duration.ts +0 -33
  305. package/src/auth/index.ts +0 -21
  306. package/src/auth/self-label.ts +0 -74
  307. package/src/auth/trust-circle-store.ts +0 -155
  308. package/src/auth/trust-circle.ts +0 -395
  309. package/src/auth/types.ts +0 -99
  310. package/src/donation/__tests__/donation-service.test.ts +0 -1193
  311. package/src/donation/__tests__/donation-supervisor.test.ts +0 -548
  312. package/src/donation/__tests__/grant-service.test.ts +0 -213
  313. package/src/donation/__tests__/grant-store.test.ts +0 -113
  314. package/src/donation/donation-service.ts +0 -1033
  315. package/src/donation/donation-store.ts +0 -176
  316. package/src/donation/donation-supervisor.ts +0 -411
  317. package/src/donation/grant-service.ts +0 -252
  318. package/src/donation/grant-store.ts +0 -152
  319. package/src/donation/index.ts +0 -65
  320. package/src/donation/types.ts +0 -260
  321. package/src/owner/__tests__/owner-node-client.test.ts +0 -194
  322. package/src/owner/index.ts +0 -12
  323. package/src/owner/owner-node-client.ts +0 -259
  324. package/src/server/__tests__/bootstrap-node-validation.test.ts +0 -138
  325. package/src/server/__tests__/grants-admin-route.test.ts +0 -171
  326. package/src/server/__tests__/grants-route.test.ts +0 -394
  327. package/src/server/__tests__/strands-route.test.ts +0 -234
  328. package/src/server/routes/bootstrap-node-validation.ts +0 -206
  329. package/src/server/routes/grants-admin.ts +0 -74
  330. package/src/server/routes/grants.ts +0 -193
  331. package/src/server/routes/provision-request-validation.ts +0 -105
  332. package/src/server/routes/strands.ts +0 -54
  333. package/src/server/routes/trust-circle.ts +0 -49
  334. package/src/strands/__tests__/strand-service.test.ts +0 -165
  335. package/src/strands/index.ts +0 -14
  336. package/src/strands/strand-service.ts +0 -135
  337. package/src/strands/types.ts +0 -102
@@ -0,0 +1,250 @@
1
+ import debug from 'debug';
2
+
3
+ import { decodeCadreInvitation } from '@serfab/cadre-core';
4
+
5
+ import type { NodeStateListener } from '../orchestrator/types.js';
6
+ import type { HostedNodeStore } from './hosted-node-store.js';
7
+ import { errorMessage, readNodeStatus, reportsClaim, type NodeStatus } from './node-status.js';
8
+ import type { HostedNode, HostedNodeChange, HostedNodeStatus } from './types.js';
9
+
10
+ const log = debug('cadre:host:hosted-node-watcher');
11
+
12
+ /** How often an unclaimed node's `/status` is read for the claim, and a joining node's for its redemption. */
13
+ export const HOSTED_NODE_CLAIM_POLL_MS = 2_000;
14
+
15
+ /** How often a joined node's `/status` is read for its connection count. */
16
+ export const HOSTED_NODE_CONNECTED_POLL_MS = 15_000;
17
+
18
+ /**
19
+ * The orchestrator surface the watcher needs: the exit/start signal, when the orchestrator
20
+ * has one, and the stop of a node whose invitation failed.
21
+ */
22
+ export interface WatchedOrchestrator {
23
+ onStateChange?(listener: NodeStateListener): () => void;
24
+ stopContainer(dockerId: string): Promise<void>;
25
+ }
26
+
27
+ /** Statuses the watcher reads every tick: a node still on its way into its cadre. */
28
+ const PENDING_STATUSES: ReadonlySet<HostedNodeStatus> = new Set<HostedNodeStatus>(['unclaimed', 'joining']);
29
+
30
+ export interface HostedNodeWatcherOptions {
31
+ store: HostedNodeStore;
32
+ orchestrator: WatchedOrchestrator;
33
+ now: () => Date;
34
+ /** Where a claim or a liveness change is reported (`HostedNodeService.emit`). */
35
+ emit: (change: HostedNodeChange) => void;
36
+ }
37
+
38
+ /**
39
+ * Follows each hosted node's `/status`: an `unclaimed` record becomes `joined` when
40
+ * the node reports a finished claim; a `joining` record becomes `joined` when a member
41
+ * accepted its invitation, or `error` when the redemption failed; and a `joined`
42
+ * record's `connected` follows the node's control-connection count. Best-effort: a poll
43
+ * that fails is retried on the next tick, and nothing here throws out of the timer.
44
+ *
45
+ * One timer at the claim cadence drives all three: every tick polls every unclaimed and
46
+ * joining node, and each joined node every {@link HOSTED_NODE_CONNECTED_POLL_MS}, or at once when it
47
+ * was poked — the orchestrator's state change on a respawn pokes the node, and the
48
+ * poke stands until a poll succeeds, so a respawned child is noticed on its first
49
+ * answer rather than at the next 15 s mark. Polls are serialized on one promise
50
+ * tail so a timer pass and a poked pass cannot both write one record.
51
+ *
52
+ * NOTE: the 2 s timer ticks while nothing is unclaimed (one cached store read, no
53
+ * I/O per tick); if it ever shows up in a profile, stop it when no record is
54
+ * unclaimed and restart it from `poke`.
55
+ */
56
+ export class HostedNodeWatcher {
57
+ private readonly store: HostedNodeStore;
58
+ private readonly orchestrator: WatchedOrchestrator;
59
+ private readonly now: () => Date;
60
+ private readonly emit: (change: HostedNodeChange) => void;
61
+
62
+ private tail: Promise<void> = Promise.resolve();
63
+ private timer?: ReturnType<typeof setInterval>;
64
+ private unsubscribe?: () => void;
65
+ /** When each joined node's `/status` last answered (ms since epoch). */
66
+ private readonly lastPolledAt = new Map<string, number>();
67
+ /** Joined nodes to poll on the next pass regardless of cadence. */
68
+ private readonly due = new Set<string>();
69
+
70
+ constructor(opts: HostedNodeWatcherOptions) {
71
+ this.store = opts.store;
72
+ this.orchestrator = opts.orchestrator;
73
+ this.now = opts.now;
74
+ this.emit = opts.emit;
75
+ }
76
+
77
+ /** Start the timer and the state-change subscription, and poll once. Idempotent. */
78
+ start(): void {
79
+ if (this.timer) return;
80
+ this.unsubscribe = this.orchestrator.onStateChange?.((info) => { this.poke(info.id); });
81
+ this.timer = setInterval(() => { this.poke(); }, HOSTED_NODE_CLAIM_POLL_MS);
82
+ this.timer.unref();
83
+ this.poke();
84
+ }
85
+
86
+ stop(): void {
87
+ if (this.timer) {
88
+ clearInterval(this.timer);
89
+ this.timer = undefined;
90
+ }
91
+ this.unsubscribe?.();
92
+ this.unsubscribe = undefined;
93
+ }
94
+
95
+ /** Ask for a pass now; `id` names a joined node to poll ahead of its cadence. Fire-and-forget. */
96
+ poke(id?: string): void {
97
+ if (id) this.due.add(id);
98
+ void this.poll().catch((err) => { log('status poll failed: %s', errorMessage(err)); });
99
+ }
100
+
101
+ /** One pass over every unclaimed, joining and due joined record, serialized against the others. */
102
+ poll(): Promise<void> {
103
+ const next = this.tail.then(() => this.pollOnce(), () => this.pollOnce());
104
+ this.tail = next.then(() => undefined, () => undefined);
105
+ return next;
106
+ }
107
+
108
+ private async pollOnce(): Promise<void> {
109
+ let records: HostedNode[];
110
+ try {
111
+ records = this.store.list().filter((n) => PENDING_STATUSES.has(n.status) || n.status === 'joined');
112
+ } catch (err) {
113
+ // A malformed hosted-nodes.json throws on every load; log rather than let it escape the timer.
114
+ log('poll could not list hosted nodes: %s', errorMessage(err));
115
+ return;
116
+ }
117
+ this.forgetRemoved(new Set(records.map((r) => r.id)));
118
+ for (const record of records) {
119
+ if (this.shouldPoll(record)) await this.pollOne(record);
120
+ }
121
+ }
122
+
123
+ /** Drop the cadence and poke bookkeeping of nodes no longer on record, so neither map grows with removals. */
124
+ private forgetRemoved(live: Set<string>): void {
125
+ for (const id of this.lastPolledAt.keys()) if (!live.has(id)) this.lastPolledAt.delete(id);
126
+ for (const id of this.due) if (!live.has(id)) this.due.delete(id);
127
+ }
128
+
129
+ private shouldPoll(record: HostedNode): boolean {
130
+ if (PENDING_STATUSES.has(record.status) || this.due.has(record.id)) return true;
131
+ const last = this.lastPolledAt.get(record.id);
132
+ return last === undefined || this.now().getTime() - last >= HOSTED_NODE_CONNECTED_POLL_MS;
133
+ }
134
+
135
+ private async pollOne(record: HostedNode): Promise<void> {
136
+ if (!record.statusEndpoint) return;
137
+ let status: NodeStatus;
138
+ try {
139
+ status = await readNodeStatus(record.statusEndpoint);
140
+ } catch (err) {
141
+ log('hosted node %s did not answer /status (retried next tick): %s', record.id, errorMessage(err));
142
+ return;
143
+ }
144
+ this.lastPolledAt.set(record.id, this.now().getTime());
145
+ this.due.delete(record.id);
146
+
147
+ // Re-read: `record` predates the round-trip and `store.put` replaces the whole
148
+ // row, so writing that copy back would resurrect a node removed meanwhile, or
149
+ // undo a respawn's new handles. The store is synchronous, so this read-decide-
150
+ // write is atomic against the event loop as long as no `await` sneaks in.
151
+ const current = this.store.get(record.id);
152
+ if (!current || current.status !== record.status) return;
153
+ if (current.status === 'unclaimed') {
154
+ this.applyUnclaimed(current, status);
155
+ } else if (current.status === 'joining') {
156
+ await this.applyJoining(current, status);
157
+ } else {
158
+ this.applyJoined(current, status);
159
+ }
160
+ }
161
+
162
+ /** An unclaimed node that reports a finished claim is `joined`, with the party and owner the claim named. */
163
+ private applyUnclaimed(current: HostedNode, status: NodeStatus): void {
164
+ if (!reportsClaim(status)) return;
165
+ this.store.put({
166
+ ...current,
167
+ status: 'joined',
168
+ partyId: status.node.partyId,
169
+ ownerKey: status.node.claimedBy,
170
+ peerId: current.peerId ?? status.peerId,
171
+ connected: status.node.connectionPaths.total > 0,
172
+ updatedAt: this.now().toISOString(),
173
+ });
174
+ log('hosted node %s was claimed by owner %s into cadre %s', current.id, status.node.claimedBy.slice(0, 8), status.node.partyId);
175
+ this.emit({ kind: 'claimed', id: current.id });
176
+ }
177
+
178
+ /**
179
+ * A joining node whose redemption settled: `joined` with the member that admitted it and
180
+ * the invitation's issuer as its owner, or `error` with the reason and whether Retry may
181
+ * help. A failed node's child is stopped after the write, as a give-up's is, and kept
182
+ * with its workdir for Retry; the write comes first so the exit the stop raises finds
183
+ * a record the supervisor leaves alone. Still `pending`: nothing.
184
+ */
185
+ private async applyJoining(current: HostedNode, status: NodeStatus): Promise<void> {
186
+ const invitation = status.node.invitation;
187
+ if (invitation?.state === 'accepted') {
188
+ const ownerKey = issuerOf(current);
189
+ this.store.put({
190
+ ...current,
191
+ status: 'joined',
192
+ ...(ownerKey ? { ownerKey } : {}),
193
+ ...(invitation.memberPeerId ? { memberPeerId: invitation.memberPeerId } : {}),
194
+ peerId: current.peerId ?? status.peerId,
195
+ connected: status.node.connectionPaths.total > 0,
196
+ updatedAt: this.now().toISOString(),
197
+ });
198
+ log('hosted node %s joined cadre %s at member %s', current.id, current.partyId, invitation.memberPeerId ?? '(unnamed)');
199
+ this.emit({ kind: 'joined', id: current.id });
200
+ return;
201
+ }
202
+ if (invitation?.state !== 'failed') return;
203
+ const error = invitation.error ?? 'the invitation could not be redeemed';
204
+ this.store.put({
205
+ ...current,
206
+ status: 'error',
207
+ error,
208
+ retryable: invitation.retryable === true,
209
+ peerId: current.peerId ?? status.peerId,
210
+ updatedAt: this.now().toISOString(),
211
+ });
212
+ log('hosted node %s could not join cadre %s (%s): %s', current.id, current.partyId, invitation.retryable ? 'retryable' : 'final', error);
213
+ this.emit({ kind: 'changed', id: current.id });
214
+ if (!current.dockerId) return;
215
+ try {
216
+ await this.orchestrator.stopContainer(current.dockerId);
217
+ } catch (err) {
218
+ log('failed to stop hosted node %s after its invitation failed: %s', current.id, errorMessage(err));
219
+ }
220
+ }
221
+
222
+ /**
223
+ * A joined node's `connected` follows its control-connection count; published only on a
224
+ * change. Its `/status` keeps reporting the invitation for as long as the child that
225
+ * redeemed it runs, and whatever it says there is ignored: the node is a member, and the
226
+ * record says so.
227
+ */
228
+ private applyJoined(current: HostedNode, status: NodeStatus): void {
229
+ const connected = status.node.connectionPaths.total > 0;
230
+ if (current.connected === connected) return;
231
+ this.store.put({ ...current, connected, updatedAt: this.now().toISOString() });
232
+ log('hosted node %s is now %s', current.id, connected ? 'connected' : 'disconnected');
233
+ this.emit({ kind: 'changed', id: current.id });
234
+ }
235
+ }
236
+
237
+ /**
238
+ * The owner a joined invitation node is shown under: the issuer of its invitation. The
239
+ * record's bundle decoded at `join`, so this only fails on a hand-edited
240
+ * `hosted-nodes.json`; the node is a member either way, so the record says so without one.
241
+ */
242
+ function issuerOf(node: HostedNode): string | undefined {
243
+ if (node.join.kind !== 'invitation') return undefined;
244
+ try {
245
+ return decodeCadreInvitation(node.join.encoded).invite.issuerKey;
246
+ } catch (err) {
247
+ log('hosted node %s carries an invitation that no longer decodes: %s', node.id, errorMessage(err));
248
+ return undefined;
249
+ }
250
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Hosted nodes: the host's "Join a cadre" action and what follows from it. See
3
+ * ./types.ts for the record, ./hosted-node-service.ts for the lifecycle,
4
+ * ./hosted-node-watcher.ts for following each node's `/status`, ./claim-details.ts for
5
+ * the QR payload, and ./hosted-node-supervisor.ts for the respawn invariant.
6
+ */
7
+
8
+ export { HostedNodeStore } from './hosted-node-store.js';
9
+ export {
10
+ HostedNodeService,
11
+ HOSTED_NODE_SPAWNING_TTL_MS,
12
+ HOSTED_NODE_REAP_SWEEP_MS,
13
+ } from './hosted-node-service.js';
14
+ export type {
15
+ HostedNodeOrchestrator,
16
+ HostedNodeServiceOptions,
17
+ JoinOptions,
18
+ RespawnOptions,
19
+ RespawnResult,
20
+ } from './hosted-node-service.js';
21
+ export type { ClaimDetails, HostedNodeAddressSource } from './claim-details.js';
22
+ export {
23
+ HOSTED_NODE_CLAIM_POLL_MS,
24
+ HOSTED_NODE_CONNECTED_POLL_MS,
25
+ } from './hosted-node-watcher.js';
26
+ export {
27
+ HostedNodeSupervisor,
28
+ HOSTED_NODE_RESPAWN_BACKOFF_BASE_MS,
29
+ HOSTED_NODE_RESPAWN_BACKOFF_MAX_MS,
30
+ HOSTED_NODE_RESPAWN_MAX_ATTEMPTS,
31
+ HOSTED_NODE_RESPAWN_HEALTHY_MS,
32
+ HOSTED_NODE_RESPAWN_SWEEP_MS,
33
+ } from './hosted-node-supervisor.js';
34
+ export type {
35
+ HostedNodeSupervisorOptions,
36
+ SupervisedOrchestrator,
37
+ } from './hosted-node-supervisor.js';
38
+ export { PLACEHOLDER_PARTY } from './node-status.js';
39
+ export { HostedNodeError } from './types.js';
40
+ export type {
41
+ HostedNode,
42
+ HostedNodeChange,
43
+ HostedNodeChangeKind,
44
+ HostedNodeChangeListener,
45
+ HostedNodeErrorCode,
46
+ HostedNodeClaimJoin,
47
+ HostedNodeFile,
48
+ HostedNodeInvitationJoin,
49
+ HostedNodeJoin,
50
+ HostedNodeJoinView,
51
+ HostedNodeStatus,
52
+ HostedNodeView,
53
+ } from './types.js';
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Reading a hosted child's `/status` — the one window the host has onto a node it
3
+ * runs: its peer id and addresses before the claim, and the claim itself, the party
4
+ * and the owner key after it. Shared by `HostedNodeService.claimDetails` and the
5
+ * status watcher.
6
+ */
7
+
8
+ // The child's `/status` is typed against the CLI's own answer, so a field the CLI
9
+ // renames is a compile error here rather than a watcher that never sees a claim.
10
+ import type { HealthStatus } from '@serfab/cadre-cli';
11
+
12
+ import { HostedNodeError } from './types.js';
13
+
14
+ /** The party an unclaimed child's config names; the claim replaces it. */
15
+ export const PLACEHOLDER_PARTY = 'unclaimed';
16
+
17
+ /** The `/status` URL for a spawn's `/health` URL (same server and port). */
18
+ export function statusUrlFor(healthEndpoint: string): string {
19
+ const url = new URL(healthEndpoint);
20
+ url.pathname = '/status';
21
+ return url.toString();
22
+ }
23
+
24
+ /** A `/status` answer from a node that has its peer identity. */
25
+ export type NodeStatus = HealthStatus & { peerId: string };
26
+
27
+ /**
28
+ * The child's `/status`, fresh every call. Throws `node_unavailable` while the child
29
+ * does not answer, answers with an error, or has no peer identity yet — every one of
30
+ * which a caller retries.
31
+ */
32
+ export async function readNodeStatus(statusEndpoint: string): Promise<NodeStatus> {
33
+ let res: Response;
34
+ try {
35
+ res = await fetch(statusEndpoint);
36
+ } catch (err) {
37
+ throw new HostedNodeError('node_unavailable', `Hosted node unreachable: ${errorMessage(err)}`);
38
+ }
39
+ if (!res.ok) {
40
+ throw new HostedNodeError('node_unavailable', `Hosted node /status returned ${res.status}`);
41
+ }
42
+ let status: HealthStatus;
43
+ try {
44
+ status = (await res.json()) as HealthStatus;
45
+ } catch (err) {
46
+ throw new HostedNodeError('node_unavailable', `Hosted node /status is not JSON: ${errorMessage(err)}`);
47
+ }
48
+ if (!status.peerId || !status.multiaddrs?.length) {
49
+ throw new HostedNodeError('node_unavailable', 'Hosted node has no peer identity yet');
50
+ }
51
+ return status as NodeStatus;
52
+ }
53
+
54
+ /**
55
+ * Whether `/status` reports a finished claim: a claim on record AND the party it
56
+ * named. `claim` flips to `claimed` the moment the node records the claim, but the
57
+ * node then restarts in-process into the claimed party, and until that restart
58
+ * finishes `partyId` is still the placeholder (or empty while the node object is
59
+ * being rebuilt). Writing `joined` on the flag alone would record the placeholder
60
+ * as the node's cadre.
61
+ */
62
+ export function reportsClaim(status: NodeStatus): status is NodeStatus & { node: { claimedBy: string } } {
63
+ return status.node.claim === 'claimed'
64
+ && status.node.claimedBy !== undefined
65
+ && status.node.partyId !== ''
66
+ && status.node.partyId !== PLACEHOLDER_PARTY;
67
+ }
68
+
69
+ export function errorMessage(err: unknown): string {
70
+ return err instanceof Error ? err.message : String(err);
71
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Hosted-node types for cadre-host.
3
+ *
4
+ * A **hosted node** is a child process the host runs for some cadre. It gets into its
5
+ * cadre one of two ways: the host starts it waiting to be claimed and shows its addresses
6
+ * and claim secret as a QR code, which the owner's phone scans to claim the node
7
+ * (`CadreNode.claimNode`); or the host starts it with a cadre invitation the owner's app
8
+ * minted, which the node redeems at a member of the cadre. Either way the host holds no
9
+ * owner key and is not an owner of any cadre its nodes serve.
10
+ *
11
+ * Records live in `<dataDir>/hosted-nodes.json` (`HostedNodeStore`). A record's id is also
12
+ * its orchestrator container id and the name of its working directory.
13
+ */
14
+
15
+ /**
16
+ * Lifecycle of one hosted node:
17
+ * spawning → the record is written, the child not yet spawned.
18
+ * unclaimed → (claim) the child is up with a claim secret, waiting for a phone to claim it.
19
+ * joining → (invitation) the child is up, its redemption of the invitation not yet settled.
20
+ * joined → in its cadre: claimed, or the invitation accepted by a member.
21
+ * error → the supervisor gave up on it, the stuck-`spawning` reap found it, or the
22
+ * invitation was refused or no member could be reached (`retryable` says which).
23
+ *
24
+ * Removal deletes the row; there is no terminal "removed" status. An `error` record keeps
25
+ * its working directory (its identity key) until it is removed or reset.
26
+ */
27
+ export type HostedNodeStatus = 'spawning' | 'unclaimed' | 'joining' | 'joined' | 'error';
28
+
29
+ /**
30
+ * The node was started with a claim secret and waits for a phone to present it.
31
+ */
32
+ export interface HostedNodeClaimJoin {
33
+ kind: 'claim';
34
+ /** The node's one-time claim secret, base64url. Only `claimDetails` ever returns it. */
35
+ secret: string;
36
+ }
37
+
38
+ /**
39
+ * The node was started with a cadre invitation and redeems it at a member of the cadre.
40
+ * Kept for a respawn while `joining`: the child redeems again, and a member answers a node
41
+ * that already got in as accepted.
42
+ */
43
+ export interface HostedNodeInvitationJoin {
44
+ kind: 'invitation';
45
+ /**
46
+ * The bundle as the owner's app encoded it (`encodeCadreInvitation`). It carries the
47
+ * invitation's private key, so it is redacted like the claim secret.
48
+ *
49
+ * NOTE: kept on disk after the node joins, though nothing reads it then (a joined node is
50
+ * respawned without it). An invitation with uses left admits whoever reads it; if
51
+ * multi-use invitations get pasted into hosts, drop it at the watcher's `joined` write.
52
+ */
53
+ encoded: string;
54
+ }
55
+
56
+ /** How a node gets into its cadre. */
57
+ export type HostedNodeJoin = HostedNodeClaimJoin | HostedNodeInvitationJoin;
58
+
59
+ /** One hosted node, as persisted in `hosted-nodes.json`. */
60
+ export interface HostedNode {
61
+ /** `hn_<base64url of 12 random bytes>` — also the orchestrator container id and workdir name. */
62
+ id: string;
63
+ join: HostedNodeJoin;
64
+ /**
65
+ * A claim node: `unclaimed` (the placeholder party the child's config names) until the
66
+ * claim reports the party; the claimant's party after. An invitation node: the
67
+ * invitation's party from the start.
68
+ */
69
+ partyId: string;
70
+ /** Hosted nodes run the `storage` profile: they participate in strands and are dialable. */
71
+ profile: 'storage';
72
+ status: HostedNodeStatus;
73
+ /** Orchestrator handle (opaque `pid:token`), set once the child is spawned. */
74
+ dockerId?: string;
75
+ /** The child's loopback `/status` URL, from the spawn result. */
76
+ statusEndpoint?: string;
77
+ /** Read once from `/status`; the claim details need it before the claim. */
78
+ peerId?: string;
79
+ /**
80
+ * Set when the node joins: `/status.node.claimedBy` for a claim, the invitation's issuer
81
+ * key for an invitation. The UI shows its first 8 characters.
82
+ */
83
+ ownerKey?: string;
84
+ /** An invitation node, once joined: the member that admitted it, when its address named one. */
85
+ memberPeerId?: string;
86
+ /** From the last status poll: the node holds at least one control connection. */
87
+ connected?: boolean;
88
+ /**
89
+ * Respawn bookkeeping, owned by the supervisor's backoff. Absent until the first
90
+ * respawn attempt. Not secret — it rides along in `HostedNodeView`.
91
+ */
92
+ respawn?: { attempts: number; lastAttemptAt: string };
93
+ createdAt: string;
94
+ updatedAt: string;
95
+ /** Failure detail when `status === 'error'`. */
96
+ error?: string;
97
+ /**
98
+ * Set with `error` when an invitation failed: whether Retry may start the node again with
99
+ * the same invitation (no member could be reached), or the invitation itself was refused.
100
+ * Absent on every other `error`.
101
+ */
102
+ retryable?: boolean;
103
+ }
104
+
105
+ /** `join` without its secret: what `list`, `get`, the routes and the events carry. */
106
+ export type HostedNodeJoinView = { kind: HostedNodeJoin['kind'] };
107
+
108
+ /** The wire shape of a hosted node: the record with the claim secret or the invitation stripped. */
109
+ export interface HostedNodeView extends Omit<HostedNode, 'join'> {
110
+ join: HostedNodeJoinView;
111
+ }
112
+
113
+ /** On-disk shape of `hosted-nodes.json`. Keyed by node id. */
114
+ export interface HostedNodeFile {
115
+ version: 1;
116
+ nodes: Record<string, Omit<HostedNode, 'id'>>;
117
+ }
118
+
119
+ /** Error codes thrown by the hosted-node service, mapped to HTTP status by `server/error-handler.ts`. */
120
+ export type HostedNodeErrorCode =
121
+ | 'not_found' // 404 — no such hosted node
122
+ | 'invalid_state' // 409 — the operation does not apply to the node's current status
123
+ | 'invalid_request' // 400 — malformed input
124
+ | 'node_unavailable' // 503 — the child's /status does not answer yet
125
+ | 'orchestrator_error' // 500 — spawn / stop failure
126
+ | 'storage_error'; // 500 — hosted-nodes.json read/write failure
127
+
128
+ /** Typed hosted-node error carrying a stable `code` for HTTP mapping. */
129
+ export class HostedNodeError extends Error {
130
+ readonly code: HostedNodeErrorCode;
131
+
132
+ constructor(code: HostedNodeErrorCode, message: string) {
133
+ super(message);
134
+ this.name = 'HostedNodeError';
135
+ this.code = code;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * What changed about a hosted node: `added` (a join started the node), `claimed` (the
141
+ * watcher saw the claim), `joined` (the watcher saw a member accept the invitation),
142
+ * `removed`, or `changed` (anything else: a respawn, a liveness change, a refused
143
+ * invitation, a retry, a give-up). The server publishes each as the `hosted-nodes-changed`
144
+ * SSE event.
145
+ */
146
+ export type HostedNodeChangeKind = 'added' | 'claimed' | 'joined' | 'removed' | 'changed';
147
+
148
+ export interface HostedNodeChange {
149
+ kind: HostedNodeChangeKind;
150
+ id: string;
151
+ }
152
+
153
+ export type HostedNodeChangeListener = (change: HostedNodeChange) => void;