@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,34 +1,155 @@
1
- import type { DirectReachability, PortForwardMode } from './types.js';
2
-
3
- export interface ReachabilityInput {
4
- portMode: PortForwardMode;
5
- cgnatDetected: boolean;
6
- }
7
-
8
- /**
9
- * v1 reachability heuristic.
10
- *
11
- * Returns:
12
- * - 'cgnat' when CGNAT was detected (highest signal — UPnP succeeded but our
13
- * external IP isn't actually reachable from the open internet).
14
- * - 'unreachable' when port mapping failed.
15
- * - 'reachable' when an auto mapping succeeded.
16
- * - 'unknown' otherwise (manual config, disabled UPnP, etc).
17
- *
18
- * A future ticket will replace this with libp2p AutoNAT or a sereus-owned
19
- * dial-back probe (see the follow-up section of `6.3-cadre-host-nat`).
20
- */
21
- export function evaluateReachability(input: ReachabilityInput): DirectReachability {
22
- if (input.cgnatDetected) return 'cgnat';
23
- switch (input.portMode) {
24
- case 'auto-upnp':
25
- case 'auto-natpmp':
26
- return 'reachable';
27
- case 'failed':
28
- return 'unreachable';
29
- case 'manual':
30
- case 'disabled':
31
- default:
32
- return 'unknown';
33
- }
34
- }
1
+ /**
2
+ * Reachability verdicts: one per hosted node, and the host-level roll-up the
3
+ * dashboard badge shows. Heuristics over the mapping table, not a dial-back —
4
+ * libp2p AutoNAT is a later ticket.
5
+ */
6
+
7
+ import type {
8
+ DirectReachability,
9
+ NodeReachability,
10
+ NodeVerdict,
11
+ PortKind,
12
+ PortRoute,
13
+ } from './types.js';
14
+
15
+ export interface NodeVerdictInput {
16
+ tcp: PortRoute;
17
+ /** Null for a handle persisted by an older build without a WebSocket port. */
18
+ ws: PortRoute | null;
19
+ /** Whether a public host part exists: a DDNS hostname, or a public IPv4. */
20
+ hostKnown: boolean;
21
+ cgnatDetected: boolean;
22
+ upnpEnabled: boolean;
23
+ gatewayFound: boolean;
24
+ /** The address a user forwards to; null when no gateway was found. */
25
+ lanAddress: string | null;
26
+ }
27
+
28
+ export interface NodeVerdictResult {
29
+ verdict: NodeVerdict;
30
+ reason: string | null;
31
+ }
32
+
33
+ /**
34
+ * `unreachable` when either port has no usable route or the host part is
35
+ * unknown; otherwise `manual` when either port is forwarded by hand, `direct`
36
+ * when the ports are reached as-is (public mode, with the firewall reminder as
37
+ * the reason), else `mapped`. A UPnP route under CGNAT is not usable: the
38
+ * router's mapping is on a carrier-private address.
39
+ */
40
+ export function evaluateNodeReachability(input: NodeVerdictInput): NodeVerdictResult {
41
+ const ports = portRoutes(input);
42
+ const failing = ports.filter(({ route }) => !usable(route, input.cgnatDetected));
43
+ if (!input.hostKnown) {
44
+ return { verdict: 'unreachable', reason: HOST_UNKNOWN_REASON };
45
+ }
46
+ if (failing.length > 0) {
47
+ return { verdict: 'unreachable', reason: describeFailures(failing, input) };
48
+ }
49
+ if (ports.some(({ route }) => route.source === 'manual')) return { verdict: 'manual', reason: null };
50
+ if (ports.some(({ route }) => route.source === 'direct')) return { verdict: 'direct', reason: firewallReminder(ports) };
51
+ return { verdict: 'mapped', reason: null };
52
+ }
53
+
54
+ /**
55
+ * Host roll-up: `cgnat` when CGNAT is detected and no node is reachable
56
+ * through a manual route; `unknown` with no running nodes; `reachable` when
57
+ * every running node is `mapped`/`manual`/`direct`; else `unreachable`.
58
+ */
59
+ export function evaluateHostReachability(
60
+ nodes: ReadonlyArray<Pick<NodeReachability, 'running' | 'verdict'>>,
61
+ cgnatDetected: boolean,
62
+ ): DirectReachability {
63
+ const running = nodes.filter((n) => n.running);
64
+ if (cgnatDetected && !running.some((n) => n.verdict === 'manual')) return 'cgnat';
65
+ if (running.length === 0) return 'unknown';
66
+ return running.every((n) => n.verdict !== 'unreachable') ? 'reachable' : 'unreachable';
67
+ }
68
+
69
+ const HOST_UNKNOWN_REASON =
70
+ "This host's public address is unknown: no dynamic DNS hostname is set and no public IPv4 address was detected. " +
71
+ 'Set a DDNS hostname, or check that this machine can reach the internet.';
72
+
73
+ interface PortEntry {
74
+ kind: PortKind;
75
+ route: PortRoute;
76
+ }
77
+
78
+ function portRoutes(input: NodeVerdictInput): PortEntry[] {
79
+ const out: PortEntry[] = [{ kind: 'tcp', route: input.tcp }];
80
+ if (input.ws) out.push({ kind: 'ws', route: input.ws });
81
+ return out;
82
+ }
83
+
84
+ function usable(route: PortRoute, cgnatDetected: boolean): boolean {
85
+ if (route.externalPort === null || route.source === null) return false;
86
+ return !(cgnatDetected && route.source === 'upnp');
87
+ }
88
+
89
+ type FailureCause = 'cgnat' | 'refused' | 'upnp_off' | 'no_gateway' | 'pending';
90
+
91
+ /** Behind CGNAT no router-side remedy helps, so CGNAT outranks whatever else failed the port. */
92
+ function causeOf(route: PortRoute, input: NodeVerdictInput): FailureCause {
93
+ if (input.cgnatDetected) return 'cgnat';
94
+ if (route.error) return 'refused';
95
+ if (!input.upnpEnabled) return 'upnp_off';
96
+ if (!input.gatewayFound) return 'no_gateway';
97
+ return 'pending';
98
+ }
99
+
100
+ /** One sentence per distinct cause, each naming every port that failed for it. */
101
+ function describeFailures(failing: PortEntry[], input: NodeVerdictInput): string {
102
+ const byCause = new Map<FailureCause, PortEntry[]>();
103
+ for (const entry of failing) {
104
+ const cause = causeOf(entry.route, input);
105
+ byCause.set(cause, [...(byCause.get(cause) ?? []), entry]);
106
+ }
107
+ return [...byCause.entries()].map(([cause, entries]) => describeCause(cause, entries, input)).join(' ');
108
+ }
109
+
110
+ function describeCause(cause: FailureCause, entries: PortEntry[], input: NodeVerdictInput): string {
111
+ const names = entries.map(({ kind, route }) => `${kindLabel(kind)} port ${route.internalPort}`);
112
+ const lan = input.lanAddress ?? "this machine's LAN address";
113
+ const forwardHint = `Forward ${entries.length === 1 ? 'port' : 'ports'} ${listPorts(entries)} to ${lan} on your router, then tell cadre-host the external ${entries.length === 1 ? 'port' : 'ports'}.`;
114
+ switch (cause) {
115
+ case 'cgnat':
116
+ return `This host is behind carrier-grade NAT, so the ${joinNames(names)} cannot be reached from outside through your router, and neither UPnP nor a port forward on your router will help; a relay is needed.`;
117
+ case 'refused': {
118
+ const errors = entries.map(({ route }) => route.error).filter((e): e is string => !!e);
119
+ return `Router refused the ${joinNames(names)} mapping (${errors.join('; ')}). ${forwardHint}`;
120
+ }
121
+ case 'upnp_off':
122
+ return `UPnP is off, so the ${joinNames(names)} ${entries.length === 1 ? 'is' : 'are'} not mapped. Turn UPnP on, or: ${lowerFirst(forwardHint)}`;
123
+ case 'no_gateway':
124
+ return `No UPnP router answered, so the ${joinNames(names)} ${entries.length === 1 ? 'is' : 'are'} not mapped. Enable UPnP on your router, or: ${lowerFirst(forwardHint)}`;
125
+ case 'pending':
126
+ return `The ${joinNames(names)} ${entries.length === 1 ? 'is' : 'are'} not mapped yet; the router is being asked.`;
127
+ }
128
+ }
129
+
130
+ /** Public mode: nothing to map, but a host or cloud firewall can still block the ports. */
131
+ function firewallReminder(ports: PortEntry[]): string {
132
+ const numbers = ports.map(({ route }) => route.internalPort);
133
+ const ufw = numbers.length === 2 && Math.abs(numbers[0]! - numbers[1]!) === 1
134
+ ? `${Math.min(...numbers)}:${Math.max(...numbers)}/tcp`
135
+ : `${numbers.join(',')}/tcp`;
136
+ return `Reached directly at this machine's public address. Allow TCP ${ports.length === 1 ? 'port' : 'ports'} ${listPorts(ports)} through any firewall `
137
+ + `(for example: sudo ufw allow ${ufw}; and any cloud-provider firewall).`;
138
+ }
139
+
140
+ function kindLabel(kind: PortKind): string {
141
+ return kind === 'tcp' ? 'TCP' : 'WebSocket';
142
+ }
143
+
144
+ function listPorts(entries: PortEntry[]): string {
145
+ return joinNames(entries.map(({ route }) => String(route.internalPort)));
146
+ }
147
+
148
+ function joinNames(names: string[]): string {
149
+ if (names.length <= 1) return names[0] ?? '';
150
+ return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
151
+ }
152
+
153
+ function lowerFirst(s: string): string {
154
+ return s.charAt(0).toLowerCase() + s.slice(1);
155
+ }
package/src/nat/types.ts CHANGED
@@ -1,52 +1,113 @@
1
1
  /**
2
2
  * NAT/DDNS types for cadre-host.
3
3
  *
4
- * cadre-host runs on home/SMB machines that are typically behind NAT. To be
5
- * dialable from the open internet the host must:
6
- * 1. Punch a port forward on the upstream router (UPnP/NAT-PMP).
4
+ * cadre-host runs on home/SMB machines that are typically behind NAT. For the
5
+ * nodes it runs to be dialable from the open internet the host must:
6
+ * 1. Map every hosted node's two libp2p ports (TCP, and the WebSocket port a
7
+ * phone dials) through the upstream router, or let the user forward them
8
+ * by hand.
7
9
  * 2. Detect its external IP (and whether it's behind CGNAT).
8
10
  * 3. Optionally publish a stable hostname via dynamic DNS.
9
11
  *
10
- * State for these three concerns lives in `<rootDir>/nat.json` (user settings)
11
- * and is exposed as a single `NatStatusSnapshot` to the management UI.
12
+ * State for these concerns lives in `<rootDir>/nat.json` (user settings) and is
13
+ * exposed as a single `NatStatusSnapshot` to the management UI, with one
14
+ * `NodeReachability` entry per hosted node.
12
15
  *
13
16
  * Credentials for DDNS providers are NOT stored in `nat.json`. They go through
14
17
  * the SecretsStore (keytar, with a 0600 file fallback).
15
18
  */
16
19
 
17
- /** Port-forwarding mode currently in effect. */
18
- export type PortForwardMode =
19
- | 'auto-upnp'
20
- | 'auto-natpmp'
21
- | 'manual'
22
- | 'failed'
23
- | 'disabled';
24
-
25
- /** Reachability verdict — best-effort heuristic until AutoNAT lands. */
20
+ /** Reachability roll-up across the host's running nodes — see `reachability.ts`. */
26
21
  export type DirectReachability = 'reachable' | 'unreachable' | 'unknown' | 'cgnat';
27
22
 
28
- /** Snapshot returned to the management UI / CLI. */
29
- export interface NatStatusSnapshot {
30
- // Port forwarding
31
- portMode: PortForwardMode;
32
- externalPort: number;
23
+ /** The two ports of a hosted node that are mapped: libp2p TCP and libp2p WebSocket. */
24
+ export type PortKind = 'tcp' | 'ws';
25
+
26
+ /**
27
+ * How this machine meets the internet. `lan`: behind a router (home or office), so node ports need
28
+ * UPnP or a forward on the router. `public`: an interface holds a public IPv4 (a VPS, a server with a
29
+ * routed address), so node ports are reachable at their own numbers unless a firewall blocks them.
30
+ */
31
+ export type NetworkMode = 'lan' | 'public';
32
+
33
+ /** The `network` setting: `auto` picks the mode from this machine's interfaces. */
34
+ export type NetworkSetting = 'auto' | NetworkMode;
35
+
36
+ /** The network section of the status snapshot. */
37
+ export interface NatNetworkStatus {
38
+ setting: NetworkSetting;
39
+ /** The mode in effect: the setting, or what `auto` found. */
40
+ mode: NetworkMode;
41
+ /** The public IPv4 an interface holds, when one does; what `auto` decides on. */
42
+ publicInterfaceIp: string | null;
43
+ }
44
+
45
+ /** Where a port's external route came from. `direct`: public mode, the port as-is. */
46
+ export type PortRouteSource = 'upnp' | 'manual' | 'direct';
47
+
48
+ /** How one of a node's ports is reached from outside the home network. */
49
+ export interface PortRoute {
33
50
  internalPort: number;
34
- /** IP the router reports as its WAN address. Null when not auto-mapped. */
51
+ /** Port reachable from outside; null when there is no route. */
52
+ externalPort: number | null;
53
+ source: PortRouteSource | null;
54
+ /** ISO timestamp when the router lease expires; UPnP routes only. */
55
+ leaseExpiresAt: string | null;
56
+ /** Last mapping failure, in plain language; null when the last attempt succeeded. */
57
+ error: string | null;
58
+ }
59
+
60
+ /** Per-node verdict. `direct`: reachable at this machine's public address, firewall permitting. */
61
+ export type NodeVerdict = 'mapped' | 'manual' | 'direct' | 'unreachable';
62
+
63
+ /** Reachability of one hosted node, as reported in the status snapshot. */
64
+ export interface NodeReachability {
65
+ nodeId: string;
66
+ /** Whether the node's process is running; a stopped node keeps its last routes until the unmap grace elapses. */
67
+ running: boolean;
68
+ verdict: NodeVerdict;
69
+ /**
70
+ * Plain-language reason and remedy when unreachable, e.g. "Router refused
71
+ * the WebSocket port mapping. Forward port 10004 to 192.168.1.20 on your
72
+ * router, then tell cadre-host the external port." For `direct`, the
73
+ * firewall reminder: which ports to allow.
74
+ */
75
+ reason: string | null;
76
+ tcp: PortRoute;
77
+ /** Null for a handle persisted by an older build without a WebSocket port. */
78
+ ws: PortRoute | null;
79
+ /** Multiaddrs (no `/p2p/` suffix) through which the node is reachable from outside. */
80
+ publicAddrs: string[];
81
+ }
82
+
83
+ /** The UPnP gateway as the host last saw it. */
84
+ export interface NatGatewayStatus {
85
+ found: boolean;
86
+ /** This machine's address on the router's subnet — the address a user forwards to. */
87
+ lanAddress: string | null;
88
+ /** The WAN address the router reports for itself. */
35
89
  routerExternalIp: string | null;
36
- /** ISO timestamp when the current mapping lease expires. */
37
- mappingLeaseExpiresAt: string | null;
90
+ /** Why discovery or the last gateway operation failed; null when it did not. */
91
+ lastError: string | null;
92
+ }
93
+
94
+ /** Snapshot returned to the management UI / CLI. */
95
+ export interface NatStatusSnapshot {
96
+ network: NatNetworkStatus;
97
+ upnpEnabled: boolean;
98
+ gateway: NatGatewayStatus;
38
99
 
39
- // External IP (from public probe; may differ from routerExternalIp under CGNAT)
100
+ // External IP (from the public probe, falling back to the router's report).
40
101
  externalIp: string | null;
41
102
  externalIpDetectedAt: string | null;
42
103
  cgnatDetected: boolean;
43
104
 
44
- // Reachability verdict
105
+ /** Roll-up across running nodes; see `reachability.ts`. */
45
106
  directReachability: DirectReachability;
46
107
  lastTestedAt: string | null;
47
108
 
48
- // DDNS
49
109
  ddns: NatDdnsStatus;
110
+ nodes: NodeReachability[];
50
111
  }
51
112
 
52
113
  /** DDNS sub-section of the status snapshot. */
@@ -65,18 +126,30 @@ export interface NatDdnsStatus {
65
126
  lastError: string | null;
66
127
  }
67
128
 
129
+ /** The external ports a user forwarded on their router for one node, per port kind. */
130
+ export interface ManualForward {
131
+ tcp?: number;
132
+ ws?: number;
133
+ }
134
+
135
+ /** Patch applied by `PUT /nat/nodes/:nodeId/forward`: `null` clears that port. */
136
+ export interface ManualForwardPatch {
137
+ tcp?: number | null;
138
+ ws?: number | null;
139
+ }
140
+
68
141
  /**
69
142
  * On-disk shape of `nat.json`. NEVER contains DDNS credentials — those live
70
143
  * in the SecretsStore.
71
144
  */
72
145
  export interface NatSettingsFile {
73
146
  version: 1;
74
- /** External port to map (and announce). Default 4001. */
75
- externalPort: number;
76
- /** Internal libp2p port. Default 4001. */
77
- internalPort: number;
78
- /** Whether to attempt UPnP/NAT-PMP at all. */
147
+ /** LAN or public-IP mode; `auto` (default) decides from the interfaces. Absent in older files. */
148
+ network: NetworkSetting;
149
+ /** Whether to ask the router for port mappings at all. Ignored in public mode. */
79
150
  upnpEnabled: boolean;
151
+ /** Per node id: the external port the user forwarded on their router, per port kind. */
152
+ forwards: Record<string, ManualForward>;
80
153
  ddns: NatDdnsSettings;
81
154
  }
82
155
 
@@ -107,8 +180,8 @@ export type NatErrorCode =
107
180
  | 'secrets_unavailable'
108
181
  | 'storage_error'
109
182
  | 'invalid_config'
110
- /** The owner node's admin channel is unreachable / not ready. */
111
- | 'node_unavailable';
183
+ /** A manual forward named a node id the host does not run. */
184
+ | 'unknown_node';
112
185
 
113
186
  /** Typed error carrying a stable `code` for HTTP mapping in the local-ui. */
114
187
  export class NatError extends Error {
@@ -139,11 +212,10 @@ export interface DdnsProviderInfo {
139
212
  }
140
213
 
141
214
  /**
142
- * Typed HTTP handlers exposed to `cadre-host-local-ui` for Fastify wiring.
215
+ * Typed HTTP handlers exposed to the local UI server for Fastify wiring.
143
216
  *
144
- * Like TrustCircleHandlers, these take typed objects and either return typed
145
- * results or throw a `NatError` whose `.code` the UI ticket maps to an HTTP
146
- * status code.
217
+ * These take typed objects and either return typed results or throw a
218
+ * `NatError` whose `.code` the error handler maps to an HTTP status code.
147
219
  */
148
220
  export interface NatHandlers {
149
221
  getStatus(): Promise<NatStatusSnapshot>;
@@ -155,5 +227,6 @@ export interface NatHandlers {
155
227
  config: Record<string, string>;
156
228
  externallyManaged?: boolean;
157
229
  }): Promise<NatStatusSnapshot>;
158
- putSettings(body: Partial<Omit<NatSettingsFile, 'version'>>): Promise<NatStatusSnapshot>;
230
+ putSettings(body: Partial<Omit<NatSettingsFile, 'version' | 'forwards'>>): Promise<NatStatusSnapshot>;
231
+ putForward(nodeId: string, patch: ManualForwardPatch): Promise<NatStatusSnapshot>;
159
232
  }
@@ -1,34 +1,34 @@
1
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
- import { mkdtempSync, rmSync, statSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
5
-
6
- import { generateIdentity, loadIdentity } from '../identity.js';
7
-
8
- describe('installer identity', () => {
9
- let tmp: string;
10
-
11
- beforeEach(() => {
12
- tmp = mkdtempSync(join(tmpdir(), 'cadre-host-id-'));
13
- });
14
-
15
- afterEach(() => {
16
- rmSync(tmp, { recursive: true, force: true });
17
- });
18
-
19
- it('round-trips an Ed25519 keypair through the file', async () => {
20
- const path = join(tmp, 'identity.key');
21
- const generated = await generateIdentity(path);
22
- expect(generated.peerId).toMatch(/^12D3Koo/);
23
-
24
- const loaded = loadIdentity(path);
25
- expect(loaded.peerId).toBe(generated.peerId);
26
- });
27
-
28
- it.runIf(process.platform !== 'win32')('persists with mode 0600 on POSIX', async () => {
29
- const path = join(tmp, 'identity.key');
30
- await generateIdentity(path);
31
- const mode = statSync(path).mode & 0o777;
32
- expect(mode).toBe(0o600);
33
- });
34
- });
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import { mkdtempSync, rmSync, statSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ import { generateIdentity, loadIdentity } from '../identity-file.js';
7
+
8
+ describe('identity file', () => {
9
+ let tmp: string;
10
+
11
+ beforeEach(() => {
12
+ tmp = mkdtempSync(join(tmpdir(), 'cadre-host-id-'));
13
+ });
14
+
15
+ afterEach(() => {
16
+ rmSync(tmp, { recursive: true, force: true });
17
+ });
18
+
19
+ it('round-trips an Ed25519 keypair through the file', async () => {
20
+ const path = join(tmp, 'identity.key');
21
+ const generated = await generateIdentity(path);
22
+ expect(generated.peerId).toMatch(/^12D3Koo/);
23
+
24
+ const loaded = loadIdentity(path);
25
+ expect(loaded.peerId).toBe(generated.peerId);
26
+ });
27
+
28
+ it.runIf(process.platform !== 'win32')('persists with mode 0600 on POSIX', async () => {
29
+ const path = join(tmp, 'identity.key');
30
+ await generateIdentity(path);
31
+ const mode = statSync(path).mode & 0o777;
32
+ expect(mode).toBe(0o600);
33
+ });
34
+ });
@@ -1,62 +1,62 @@
1
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
- import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
5
-
6
- import { ensureNodeIdentity, nodeIdentityPath } from '../node-identity.js';
7
-
8
- describe('ensureNodeIdentity', () => {
9
- let tmp: string;
10
-
11
- beforeEach(() => {
12
- tmp = mkdtempSync(join(tmpdir(), 'cadre-node-id-'));
13
- });
14
-
15
- afterEach(() => {
16
- rmSync(tmp, { recursive: true, force: true });
17
- });
18
-
19
- it('generates an identity inside the workdir on first call', async () => {
20
- const { path, peerId } = await ensureNodeIdentity(tmp);
21
- expect(path).toBe(nodeIdentityPath(tmp));
22
- expect(path).toBe(join(tmp, 'identity.key'));
23
- expect(peerId).toMatch(/^12D3Koo/);
24
- expect(statSync(path).size).toBeGreaterThan(0);
25
- });
26
-
27
- // Reuse — not rotation — is the load-bearing property: a re-spawn of the same
28
- // container must land on the same peer id or the cadre that admitted it sees a
29
- // stranger.
30
- it('reuses the existing key on a second call', async () => {
31
- const first = await ensureNodeIdentity(tmp);
32
- const bytesBefore = readFileSync(first.path);
33
-
34
- const second = await ensureNodeIdentity(tmp);
35
- expect(second.peerId).toBe(first.peerId);
36
- expect(readFileSync(second.path).equals(bytesBefore)).toBe(true);
37
- });
38
-
39
- it('creates the workdir when it does not exist yet', async () => {
40
- const workdir = join(tmp, 'nested', 'donated-1');
41
- const { path, peerId } = await ensureNodeIdentity(workdir);
42
- expect(path).toBe(join(workdir, 'identity.key'));
43
- expect(peerId).toMatch(/^12D3Koo/);
44
- });
45
-
46
- // A key that is present but unreadable must FAIL the spawn, not fall through
47
- // to `generateIdentity`. Regenerating would hand the cadre a stranger under
48
- // the same containerId — silently reproducing the bug this module exists to
49
- // fix — where a throw surfaces the damaged file to the operator.
50
- it('throws rather than re-keying when the existing file does not decode', async () => {
51
- const path = nodeIdentityPath(tmp);
52
- writeFileSync(path, 'not a protobuf private key', 'utf8');
53
-
54
- await expect(ensureNodeIdentity(tmp)).rejects.toThrow();
55
- expect(readFileSync(path, 'utf8')).toBe('not a protobuf private key');
56
- });
57
-
58
- it.runIf(process.platform !== 'win32')('persists with mode 0600 on POSIX', async () => {
59
- const { path } = await ensureNodeIdentity(tmp);
60
- expect(statSync(path).mode & 0o777).toBe(0o600);
61
- });
62
- });
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ import { ensureNodeIdentity, nodeIdentityPath } from '../node-identity.js';
7
+
8
+ describe('ensureNodeIdentity', () => {
9
+ let tmp: string;
10
+
11
+ beforeEach(() => {
12
+ tmp = mkdtempSync(join(tmpdir(), 'cadre-node-id-'));
13
+ });
14
+
15
+ afterEach(() => {
16
+ rmSync(tmp, { recursive: true, force: true });
17
+ });
18
+
19
+ it('generates an identity inside the workdir on first call', async () => {
20
+ const { path, peerId } = await ensureNodeIdentity(tmp);
21
+ expect(path).toBe(nodeIdentityPath(tmp));
22
+ expect(path).toBe(join(tmp, 'identity.key'));
23
+ expect(peerId).toMatch(/^12D3Koo/);
24
+ expect(statSync(path).size).toBeGreaterThan(0);
25
+ });
26
+
27
+ // Reuse — not rotation — is the load-bearing property: a re-spawn of the same
28
+ // container must land on the same peer id or the cadre that admitted it sees a
29
+ // stranger.
30
+ it('reuses the existing key on a second call', async () => {
31
+ const first = await ensureNodeIdentity(tmp);
32
+ const bytesBefore = readFileSync(first.path);
33
+
34
+ const second = await ensureNodeIdentity(tmp);
35
+ expect(second.peerId).toBe(first.peerId);
36
+ expect(readFileSync(second.path).equals(bytesBefore)).toBe(true);
37
+ });
38
+
39
+ it('creates the workdir when it does not exist yet', async () => {
40
+ const workdir = join(tmp, 'nested', 'hn_1');
41
+ const { path, peerId } = await ensureNodeIdentity(workdir);
42
+ expect(path).toBe(join(workdir, 'identity.key'));
43
+ expect(peerId).toMatch(/^12D3Koo/);
44
+ });
45
+
46
+ // A key that is present but unreadable must FAIL the spawn, not fall through
47
+ // to `generateIdentity`. Regenerating would hand the cadre a stranger under
48
+ // the same containerId — silently reproducing the bug this module exists to
49
+ // fix — where a throw surfaces the damaged file to the operator.
50
+ it('throws rather than re-keying when the existing file does not decode', async () => {
51
+ const path = nodeIdentityPath(tmp);
52
+ writeFileSync(path, 'not a protobuf private key', 'utf8');
53
+
54
+ await expect(ensureNodeIdentity(tmp)).rejects.toThrow();
55
+ expect(readFileSync(path, 'utf8')).toBe('not a protobuf private key');
56
+ });
57
+
58
+ it.runIf(process.platform !== 'win32')('persists with mode 0600 on POSIX', async () => {
59
+ const { path } = await ensureNodeIdentity(tmp);
60
+ expect(statSync(path).mode & 0o777).toBe(0o600);
61
+ });
62
+ });