@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
package/README.md CHANGED
@@ -1,423 +1,347 @@
1
- # @serfab/cadre-host
2
-
3
- Self-hosted cadre node manager for basement-PC deployments. Runs one always-on machine whose primary job is to **donate cadre nodes to other people's cadres**: a friend or family member keeps their own device as the authority for their cadre, and your box contributes always-on capacity by running an extra node that joins *theirs*. It exposes a localhost web UI to manage that, and can optionally also run a personal cadre of your own.
4
-
5
- The sibling of [`@serfab/cadre-provider`](../cadre-provider/README.md): the provider donates nodes to paying tenants with API keys, billing, and Docker; cadre-host donates them for free to a handful of people you trust, as native child processes, with a one-shot installer.
6
-
7
- cadre-host has **two independent roles**, and only the first is on by default:
8
-
9
- - **Node donor (primary, always on).** Contribute nodes to cadres *other people* own. Who may ask is gated by **grant tokens** you hand out. Needs no cadre of your own.
10
- - **Founder (opt-in — `ownCadre.enabled` in `host.config.json`, default `false`).** *Also* run your own personal cadre on this machine. This is what turns on the trust circle (`/auth/*`) and the NAT/DDNS layer (`/nat/*`); both are unmounted and 404 until you enable it.
11
-
12
- [docs/cadre-host.md](../../docs/cadre-host.md) is the design source of truth for both roles.
13
-
14
- ## Install
15
-
16
- cadre-host installs entirely within your user account. **No step requires root**, with one optional exception (`loginctl enable-linger`) called out under *Root requirements* below.
17
-
18
- Pick whichever install style suits you:
19
-
20
- ### Global install (binary on PATH)
21
-
22
- ```bash
23
- npm install -g @serfab/cadre-host # sudo only if your npm prefix is system-owned (e.g. /usr/lib)
24
- cadre-host install
25
- ```
26
-
27
- ### Local install (self-contained — recommended if you want everything under one folder)
28
-
29
- ```bash
30
- mkdir -p ~/cadre && cd ~/cadre
31
- npm install @serfab/cadre-host
32
- npx cadre-host install --data-dir ~/cadre/data
33
- ```
34
-
35
- From inside `~/cadre`, `npx cadre-host <command>` runs the local binary (`grant`, `invite`, `nat`, `uninstall`, …) without any path prefix — `npx` resolves it from `node_modules/.bin/`. **Caveat:** outside `~/cadre`, `npx cadre-host` won't find the local install and will silently download a fresh copy from the npm registry. To avoid that, either always `cd ~/cadre` first or symlink the binary onto your PATH:
36
-
37
- ```bash
38
- ln -s ~/cadre/node_modules/.bin/cadre-host ~/.local/bin/cadre-host
39
- # now `cadre-host grant issue …` works from anywhere
40
- ```
41
-
42
- If you'd rather skip `npx` and the symlink, the explicit path `./node_modules/.bin/cadre-host <command>` always works from `~/cadre` too.
43
-
44
- The wizard (run either way):
45
-
46
- 1. Prompts for the data directory, UI port, libp2p port, UPnP toggle, and whether to **also run your own personal cadre on this machine** — the opt-in founder role, default **no** (defaults shown in `[...]`).
47
- 2. Generates a fresh Ed25519 node identity (`<dataDir>/identity.key`, mode 600 on POSIX).
48
- 3. Writes `<dataDir>/host.config.json` and seeds `<dataDir>/nat.json` with the chosen libp2p port.
49
- 4. Registers a per-user service: `systemctl --user` unit (Linux), `LaunchAgent` (macOS), or NSSM service (Windows; requires `nssm.exe` on PATH — see `service/README.md`).
50
- 5. Opens `http://127.0.0.1:<uiPort>/` in your browser.
51
- 6. **Founder installs only, and only when interactive:** issues a 24-hour enrollment invite for your own cadre and prints it as both a QR code and a paste-friendly token. A donor-only install has no cadre of its own to enroll into, so this step is silently skipped — you hand out a grant token instead (see [*After install*](#after-install--donating-your-first-node)).
52
-
53
- Run `cadre-host install --non-interactive --data-dir <path>` for unattended provisioning; add `--own-cadre` to enable the founder role without prompting.
54
-
55
- ### Root requirements
56
-
57
- The installer never writes outside your home directory. On Linux the unit goes to `$XDG_CONFIG_HOME/systemd/user/cadre-host.service` (default `~/.config/systemd/user/cadre-host.service`); the data dir defaults to `$XDG_DATA_HOME/cadre-host` (default `~/.local/share/cadre-host`) or wherever you point `--data-dir`. Registering a `systemctl --user` unit does **not** require root — any user can do it.
58
-
59
- There is **one optional** root command:
60
-
61
- ```bash
62
- sudo loginctl enable-linger <your-user>
63
- ```
64
-
65
- `enable-linger` is what allows your `systemctl --user` services to start at boot and keep running after you log out. Without linger, cadre-host runs only while you have an active session (desktop login or SSH) and stops when that session ends. With linger, it runs whenever the machine is on.
66
-
67
- The installer attempts `loginctl enable-linger` for you and logs a warning if it can't (you aren't root, `loginctl` is missing, running inside a container, …). **The install succeeds either way** — you can enable linger separately, before or after `cadre-host install`. Without linger you can still verify everything by logging in and running `systemctl --user start cadre-host` by hand.
68
-
69
- ### System-wide install (not yet supported)
70
-
71
- `cadre-host install --system` is accepted by the CLI but currently errors out. A proper system-wide install — dedicated `cadre` user, unit at `/etc/systemd/system/`, install under `/opt/cadre/...` — needs additional work around data-dir ownership and capability dropping and is tracked separately.
72
-
73
- ### Service-host details
74
-
75
- The rendered unit files live at:
76
-
77
- | Platform | Path |
78
- | -------- | ---- |
79
- | Linux | `~/.config/systemd/user/cadre-host.service` |
80
- | macOS | `~/Library/LaunchAgents/com.serfab.cadre-host.plist` |
81
- | Windows | NSSM-managed service `CadreHost` (registry-stored config) |
82
-
83
- See [`service/README.md`](./service/README.md) for templates, manual-smoke instructions, and the cross-platform CI gap.
84
-
85
- ## After install — donating your first node
86
-
87
- `cadre-host install` leaves you with a running management service, a local UI, and **no cadre nodes yet**. cadre-host never pre-spawns nodes; each one materializes when someone you trust asks for one. This walkthrough goes from "install just finished" to "first donated node is running."
88
-
89
- It assumes the **default install — donor-only**, i.e. you answered *no* to the wizard's *"Also run your own personal cadre on this machine?"*. In that mode this host's whole job is to lend always-on capacity to *other people's* cadres: your friend's phone stays the authority for their cadre, and your box runs an extra node that joins **theirs**. Your host never holds their owner key and never becomes the authority for their data. See [docs/cadre-host.md § Node donation](../../docs/cadre-host.md#node-donation-the-primary-role) for the full lifecycle.
90
-
91
- If you answered *yes*, everything below still applies — you additionally get the opt-in **founder** surfaces, covered in [the founder section](#the-founder-role--running-your-own-cadre-here-opt-in) after step 5.
92
-
93
- ### 1. Verify the service is running
94
-
95
- ```bash
96
- systemctl --user status cadre-host
97
- ```
98
-
99
- Look for `Active: active (running)`. If it's not active, `journalctl --user -u cadre-host -f` shows the live log.
100
-
101
- ### 2. Open the local UI
102
-
103
- The UI listens on `http://127.0.0.1:<uiPort>/` (default port 8765) on the host machine itself.
104
-
105
- - **On the host:** open the URL in a browser, or run `cadre-host ui` to print + open it.
106
- - **From a different machine on your LAN** (most basement PCs are headless): the UI is bound to loopback only and has no authentication — that's intentional, the trust boundary is "same machine, same user as cadre-host." Use SSH port-forwarding from the client side:
107
-
108
- ```bash
109
- # from your laptop:
110
- ssh -L 8765:127.0.0.1:8765 user@my-basement-pc
111
- # then open http://127.0.0.1:8765 in the laptop's browser
112
- ```
113
-
114
- The forward stays up as long as the SSH session does.
115
-
116
- ### 3. Issue your first grant token
117
-
118
- Decide whose cadre you want to help keep online — a family member, a friend, or your own phone. That person already has (or is about to create) **their own** cadre; you are donating capacity to it, not enrolling them into anything of yours.
119
-
120
- Before anyone can ask, you issue them a **grant token**: a bearer credential meaning "this person may ask my host to donate nodes."
121
-
122
- ```bash
123
- cadre-host grant issue "<label>"
124
- ```
125
-
126
- `<label>` is a human-readable name **you** choose to identify this grantee in your grant list. It's purely for your own bookkeeping — the system doesn't use it for anything. Quote it if it has spaces. Examples:
127
-
128
- ```bash
129
- cadre-host grant issue "Mom's cadre"
130
- cadre-host grant issue alice
131
- cadre-host grant issue "Friend — hobby group"
132
- ```
133
-
134
- The command prints two things — a terminal-rendered QR code and the encoded token — both representing the same secret. The recipient can use either form. `--max-nodes N` caps how many donated nodes that grantee may keep running here at once (default 1); `--ttl 30d` gives the grant an expiry (`s`/`m`/`h`/`d` suffixes) — omit it and the grant never expires; `--no-qr` prints only the token.
135
-
136
- Hand the QR or token to the grantee in person if possible. **Anyone who gets the token can claim what it grants — the right to make your machine run nodes for them** — so treat it like a password. One difference from a trust-circle invite: a grant is **not** one-time. It stays spendable, up to `--max-nodes` at a time, until it expires or you revoke it.
137
-
138
- Terminology aside: your **trust circle** in the everyday sense is the handful of people you have handed a grant token to — the people allowed to have a node donated to them here. Don't confuse it with the *feature* named trust circle (`cadre-host trust`, the `/auth/*` routes, the UI's Trust Circle page): that one is membership in the host's **own** cadre and exists only in the founder role.
139
-
140
- ### 4. The grantee requests a node
141
-
142
- The grantee's cadre authority — typically their phone — presents the grant token as `Authorization: Bearer <grant-token>` and drives the donation lifecycle against your host:
143
-
144
- 1. `POST /grants` with their party id, owner public key(s) and, optionally, bootstrap addresses → your host spawns a child cadre node that pins **their** owner key and joins **their** cadre. Each bootstrap address given must be a full multiaddr naming both where to reach the peer and who it is (`/dns4/…/tcp/443/wss/p2p/12D3KooW…`); anything the donated node could not dial is refused as `400 invalid_request` naming the bad entry, before anything is spawned. A phone sends none: nothing can dial a phone, so it dials the node instead.
145
- 2. `GET /grants/:id/peer` → the new node's peerId and multiaddrs, including the WebSocket (`/ws`) address a phone dials.
146
- 3. Their device signs a seed for that peer and `PUT /grants/:id/seed` hands it back; the node accepts it precisely because their owner key was pinned at spawn.
147
- 4. `DELETE /grants/:id` when they're done — the node is stopped and removed.
148
-
149
- At that moment:
150
-
151
- - cadre-host spawns a child cadre-node process for this grantee's cadre (the manager itself never joins a cadre).
152
- - The node appears on the Nodes page of the UI, and the grant it was spent against shows up in `cadre-host grant list`.
153
- - The node stays up: if it crashes or dies in a reboot, cadre-host respawns it from the donation's recorded spawn inputs.
154
-
155
- Until someone requests a node, the Nodes page in the UI stays empty — that's expected.
156
-
157
- **What is not built yet (v1):** `/grants` mounts on the same loopback-only management server as everything else, so today a grantee can only reach it from *this machine* or through an SSH tunnel like the one in step 2. Letting a friend's phone reach it across the internet is deferred (`backlog/feat-cadre-host-wan-grant-reachability`), and no app drives the four calls above for you yet — it is raw HTTP today. Issuing grants and running donated nodes work now; the last hop from a remote phone does not.
158
-
159
- ### 5. Manage grants
160
-
161
- The dashboard's **Grants** page (`cadre-host ui`, then *Grants*) does everything below from the browser: issue a grant (with the same QR code and copyable token), see how many nodes each grant is using and which ones, show an active grant's token again to re-share it, and revoke a grant with or without its nodes. From the command line:
162
-
163
- ```bash
164
- $ cadre-host grant list
165
- Grants:
166
- Zx8kq1... Mom's cadre live=1 max=2
167
- Ld93af... Friend — hobby group live=0 max=1 (expires 2026-09-01T12:00:00Z)
168
- ```
169
-
170
- Revoke a grant:
171
-
172
- ```bash
173
- $ cadre-host grant revoke <token>
174
- revoked grant: Zx8kq1...
175
- terminated 2 donated node(s)
176
- ```
177
-
178
- Revoking denies every future request on that token — including the grantee's own `DELETE /grants/:id`, which is refused (403) once the grant is revoked — and shuts down every node already donated under it: each is stopped and its working directory (its identity key and node-local data) deleted. Pass `--keep-nodes` to revoke without touching the nodes already running; they then stay up until you end them yourself.
179
-
180
- To shut down one donated node — under a revoked grant or a live one — use its id, which is the id the UI's Nodes page shows (`grn_…`):
181
-
182
- ```bash
183
- cadre-host grant terminate <donation-id>
184
- ```
185
-
186
- A donated node's page in the UI offers **Terminate** (the same call) rather than Stop: the respawn supervisor treats a live donation as "expected to be running" and would bring a merely stopped node straight back. Revoking a grant again is safe and ends whatever is still running under it.
187
-
188
- ## The founder role — running your own cadre here (opt-in)
189
-
190
- Everything above needs no cadre of your own. If you *also* want this machine to run your **own** personal cadre — your devices, your trust circle, your data — that is the **founder** role. It is opt-in: answer *yes* to the wizard's *"Also run your own personal cadre on this machine?"*, or install with `cadre-host install --own-cadre`. It is stored as `ownCadre.enabled` in `<dataDir>/host.config.json` (default **false**) and is install-time only — to change it later, edit that file and restart the service.
191
-
192
- Until it is enabled, the founder-only surfaces are **not mounted** and return **404**:
193
-
194
- | Surface | Commands | Local UI page |
195
- | --- | --- | --- |
196
- | `/auth/*` — trust circle | `cadre-host invite`, `cadre-host trust list`, `cadre-host trust revoke` | Trust Circle |
197
- | `/nat/*` — NAT/DDNS | `cadre-host nat …` | Connectivity |
198
- | `/api/strands` | — | Strands |
199
-
200
- So if you followed the default install and `cadre-host invite` or `cadre-host nat status` reports a 404, nothing is broken — those belong to a role you didn't turn on. The UI leaves those three pages out of its nav on a donor-only install.
201
-
202
- The rest of this section applies **only** with the founder role enabled.
203
-
204
- ### Enrolling your own devices (founder role)
205
-
206
- ```bash
207
- cadre-host invite "<label>"
208
- ```
209
-
210
- Same bookkeeping-only `<label>` rule as a grant. The command prints a QR code and an encoded invite string — both representing the same single-use token. By default the token expires in 24 hours; override with `--ttl 7d` (or `30m`, `1h`, etc.).
211
-
212
- Hand the QR or token to the device's owner in person if possible. **Anyone who gets the token can claim the cadre identity it grants**, so treat it like a one-time password.
213
-
214
- On the invitee's device, they install a cadre-aware app — the React Native reference app at [`@serfab/reference-app-rn`](../reference-app-rn), or any sApp built on `@serfab/cadre-core` — and scan or paste the invite. The app dials your cadre-host over libp2p (NAT/DDNS permitting; see below) and redeems the token, which authorizes their peer in *your* cadre and lets them participate in its strands (shared SQL databases).
215
-
216
- ```bash
217
- $ cadre-host trust list
218
- Members:
219
- 12D3KooWHcdEf... kyle's laptop [self]
220
- 12D3KooW2aB... Mom's phone
221
-
222
- Pending invites:
223
- invite_a3f8... Friend — tablet (expires 2026-06-01T12:00:00Z)
224
- ```
225
-
226
- A **peerId** is a libp2p identity string — for the common Ed25519 case it starts with `12D3Koo...` (you'll also see `16Uiu2HAm...` for secp256k1 or `Qm...` for legacy RSA). Each member has exactly one.
227
-
228
- Remove a pending invite or evict an existing member:
229
-
230
- ```bash
231
- cadre-host trust revoke <token-or-peerId>
232
- ```
233
-
234
- cadre-host auto-detects whether the argument is a pending-invite token or a member peerId. Force interpretation with `--kind invite` or `--kind member` if it ever guesses wrong.
235
-
236
- ### Reachability — can people actually reach you? (founder role)
237
-
238
- Reachability work targets your **own** cadre's owner node. After enabling the founder role (and any time your network changes):
239
-
240
- ```bash
241
- cadre-host nat status # current external IP, port-mapping state, reachability
242
- cadre-host nat test # re-run the reachability probe right now
243
- ```
244
-
245
- If `Reachability` shows anything other than `direct`, peers will fall back to libp2p relays — slower but still functional. If UPnP didn't work on your router, the UI's **Connectivity** page has manual port-forwarding instructions.
246
-
247
- When your residential IP changes (it will), members can't find you on the old IP. DDNS (a hostname that auto-updates to your current IP) fixes this:
248
-
249
- ```bash
250
- # DuckDNS — cadre-host updates the record itself. Sign up at duckdns.org and grab a token first.
251
- cadre-host nat ddns set duckdns --hostname mybox.duckdns.org --token <duckdns-token>
252
-
253
- # Externally-managed — your router or another tool updates DNS; cadre-host just records the hostname.
254
- cadre-host nat ddns external --hostname mybox.example.com
255
- ```
256
-
257
- If you use the DuckDNS form, the token is stored in the OS keychain when `libsecret` is installed (`sudo apt install libsecret-1-0` on Debian/Ubuntu), and unencrypted in `<dataDir>/nat-secrets.json` otherwise. The service logs a warning at startup when it falls back to unencrypted storage.
258
-
259
- Donated nodes do **not** depend on any of this — they dial outward into the grantee's cadre. Per-donated-node WAN reachability is separate, deferred work (`backlog/feat-cadre-host-wan-grant-reachability`).
260
-
261
- ## CLI reference
262
-
263
- All commands except `install`, `uninstall`, `start`, `ui`, and the `push` group talk to the running cadre-host management API over loopback. They print a connection error if the service isn't running.
264
-
265
- Commands marked **founder role only** additionally need `ownCadre.enabled`; on a donor-only install their routes are unmounted and the command reports a 404.
266
-
267
- The `cadre-host push` group is the exception on both counts. It needs **no running service** — the commands write straight to the data dir's secret store and `host.config.json` — and it is **not** founder-role only, because donated nodes get push credentials too. Private keys land in the OS keychain when one is available, otherwise a plain-JSON fallback at `<dataDir>/nat-secrets.json` (mode `0600` on POSIX; **on Windows the permission bits don't apply, so any account on the machine can read it** — install keytar's native dependency to avoid that); the non-secret bits (APNs bundle id / sandbox toggle, cooldown, debounce) land in `host.config.json`. Credentials are re-resolved on every node spawn, so restart cadre-host to apply them immediately.
268
-
269
- ### `cadre-host status`
270
-
271
- Print whether the service is registered and currently active.
272
-
273
- ```
274
- $ cadre-host status
275
- Service installed: yes
276
- Service running: yes
277
- ```
278
-
279
- ### `cadre-host ui [--no-browser]`
280
-
281
- Print the local-UI URL (e.g. `http://127.0.0.1:8765`) and open it in the default browser. Reads `uiPort` from `host.config.json`; doesn't require the service to be running (if not, the browser will fail to connect — that's feedback enough). Pass `--no-browser` to just print the URL.
282
-
283
- ### `cadre-host grant issue <label> [--max-nodes N] [--ttl <duration>] [--no-qr]`
284
-
285
- Issue a grant token — the credential that lets one person ask this host to donate cadre nodes into *their* cadre. `<label>` is whatever human-readable name helps you track the grantee — quote it if it has spaces. `--max-nodes` caps how many donated nodes that grantee may keep running here at once (default 1); `--ttl` gives the grant an expiry (`s`/`m`/`h`/`d` suffixes, e.g. `30d`) — omit it and the grant never expires. Prints a QR code and the encoded token; `--no-qr` prints the token only.
286
-
287
- ### `cadre-host grant list`
288
-
289
- Print every issued grant token with its label, live donated nodes (`live=`), node cap (`max=`), expiry, and revoked state.
290
-
291
- ### `cadre-host grant revoke <token> [--keep-nodes]`
292
-
293
- Revoke a grant token. Every future request presenting it is denied, the grantee's own `DELETE /grants/:id` included, and every node already donated under it is shut down (stopped, working directory deleted). Prints how many were terminated. `--keep-nodes` revokes only and leaves those nodes running. See [*Manage grants*](#5-manage-grants).
294
-
295
- ### `cadre-host grant terminate <donation-id>`
296
-
297
- Shut down one donated node — stop it and delete its working directory — whatever state its grant is in. `<donation-id>` is the `grn_…` id the Nodes page shows.
298
-
299
- ### `cadre-host invite <label> [--ttl <duration>]`
300
-
301
- **Founder role only.** Generate a single-use invite token to add a member to your trust circle. `<label>` is whatever human-readable name helps you track the invitee — quote it if it has spaces. `--ttl` defaults to `24h`; accepts `s`/`m`/`h`/`d` suffixes (e.g. `7d`, `30m`). Prints a QR code and the encoded invite string.
302
-
303
- ### `cadre-host trust list`
304
-
305
- **Founder role only.** Print all current trust-circle members (with peerIds) and pending invites (with tokens and expiry).
306
-
307
- ### `cadre-host trust revoke <token-or-peerId> [--kind invite|member|auto]`
308
-
309
- **Founder role only.** Revoke a pending invite (by token) or evict an existing member (by peerId). `--kind` defaults to `auto`, which decides based on whether the argument looks like a peerId.
310
-
311
- ### `cadre-host nat status [--json]`
312
-
313
- **Founder role only** (as is every `cadre-host nat` subcommand — NAT/DDNS maps a port for the host's *own* owner node). Print current NAT state — port-mapping mode, external IP, CGNAT detection, direct-reachability result, DDNS configuration. With `--json`, dumps the raw response from the management API.
314
-
315
- ### `cadre-host nat test [--json]`
316
-
317
- Re-run the reachability probe right now and print the updated NAT state. Useful after changing port-forwarding rules on your router.
318
-
319
- ### `cadre-host nat settings [--external-port N] [--internal-port N] [--no-upnp]`
320
-
321
- Adjust NAT settings. Pass only the flag(s) you want to change.
322
-
323
- ### `cadre-host nat ddns set <provider> --hostname <h> [--token <t>]`
324
-
325
- Configure cadre-host to push DNS updates itself. Currently the only `<provider>` is `duckdns`. If `--token` is omitted, the command prompts for it (with echo suppressed) when stdin is a TTY. The token is stored in the OS keychain when available, otherwise unencrypted at `<dataDir>/nat-secrets.json` (a startup warning surfaces this).
326
-
327
- ### `cadre-host nat ddns external --hostname <h>`
328
-
329
- Tell cadre-host that some other tool (your router firmware, a separate `ddclient`, etc.) is updating DNS, and to record the hostname for publishing to peers without trying to update it itself.
330
-
331
- ### `cadre-host push fcm --project-id <id> --client-email <email> [--private-key-file <path>] [--private-key <pem>] [--data-dir <path>]`
332
-
333
- Store Firebase Cloud Messaging (Android) service-account credentials so the owner/storage node can wake suspended mobile apps. The three values come from the Firebase service-account JSON (`project_id`, `client_email`, `private_key`). Supply the key either as a file (`--private-key-file`, preferred) or inline (`--private-key`); with neither, the command exits with an error. See [docs/cadre-host.md § Push credentials](../../docs/cadre-host.md#push-credentials-fcmapns) for how to mint the credentials and how they reach the spawned node.
334
-
335
- ### `cadre-host push apns --key-id <id> --team-id <id> --bundle-id <id> [--private-key-file <path>] [--private-key <pem>] [--production] [--data-dir <path>]`
336
-
337
- Store Apple Push Notification service (iOS) auth-key credentials. `--key-id`/`--team-id` identify the `.p8` auth key downloaded from the Apple Developer portal; `--bundle-id` becomes the `apns-topic`. As with `push fcm`, pass the key via `--private-key-file` (preferred) or `--private-key`. Targets the **sandbox** APNs host by default — pass `--production` for an App Store build. A token minted for one host is rejected by the other, so this must match the build under test.
338
-
339
- ### `cadre-host push options [--cooldown-ms <ms>] [--debounce-ms <ms>] [--data-dir <path>]`
340
-
341
- Set the non-secret push tuning knobs: `--cooldown-ms` is the minimum gap between wakes for one (peer, strand) pair (anti-spam), `--debounce-ms` the per-strand burst-coalescing window. Pass only the flag(s) you want to change.
342
-
343
- ### `cadre-host push clear <target> [--data-dir <path>]`
344
-
345
- Remove stored push credentials. `<target>` is `fcm`, `apns`, or `all`. Clearing `apns` also drops the bundle id / sandbox toggle from `host.config.json`. With nothing configured, no `push` block is written into the spawned node's `cadre.json` and the node falls back to control-network push-wake only.
346
-
347
- ### `cadre-host push status [--data-dir <path>]`
348
-
349
- Print which push platforms are configured, the APNs bundle id and sandbox/production mode, and the current cooldown/debounce values. Never prints key material.
350
-
351
- ### `cadre-host start [--data-dir <path>] [--no-tui]`
352
-
353
- Run cadre-host in the foreground. Normally invoked by the service unit, not directly; after `install --no-service` it is how the host runs. `--data-dir` overrides the install-time data directory (also honors `$CADRE_HOST_DATA_DIR`).
354
-
355
- ### `cadre-host install [--no-service] [...flags]`
356
-
357
- Run the first-run wizard. See [**Install**](#install) at the top of this README.
358
-
359
- `--no-service` writes the data dir (identity, `host.config.json`, `nat.json`) and stops there: no OS service is registered, no browser opens, and no enrollment invite is issued. Run the host by hand with `cadre-host start --data-dir <path>`. This is the setup for a test session, for example the phone walkthrough in [`docs/reference-app-rn.md`](../../docs/reference-app-rn.md) ("Borrowing a Node From a cadre-host"). Don't run `start` on top of a service install: the service already binds `uiPort`, and a second host on the same data dir moves to the next free port.
360
-
361
- ### `cadre-host uninstall [--remove-data] [--yes]`
362
-
363
- Stop and deregister the service. Preserves the data dir by default; pass `--remove-data --yes` to wipe identity, issued grants, donated-node records, trust circle, NAT state, and update state too.
364
-
365
- ```bash
366
- cadre-host uninstall # stop + deregister, keep data
367
- cadre-host uninstall --remove-data --yes # also delete the data dir
368
- ```
369
-
370
- ## What `cadre-host start` does today
371
-
372
- `start` loads `host.config.json` + the identity, brings up the orchestrator, the donation grant layer, and the update service, and binds the Fastify management server on `127.0.0.1:<uiPort>` (loopback only). Only when `ownCadre.enabled` does it also spawn the host's own owner node and bring up the trust-circle and NAT services. Routes:
373
-
374
- - `/grants-admin` (issue/list/revoke grants, where revoke also shuts down the grant's donated nodes unless `?keepNodes=true`, and `DELETE /grants-admin/donations/:id` to shut down one donated node — no bearer; same-machine admin) and `/grants` (the bearer-gated surface a grantee drives to request, seed, and release a donated node) — the always-on donor surface.
375
- - `/update/*` (update flow) — matches the CLI's contract.
376
- - `/auth/*` (trust circle), `/nat/*` (NAT/DDNS), `/api/strands` — **founder role only**; left unmounted and 404 on a donor-only install.
377
- - `/api/status`, `/api/nodes`, `/api/nodes/:id/{logs,stop,start,restart}` (stop/start/restart act on the owner node only; a donated node answers 501), `/api/settings`, `/api/events` (Server-Sent Events) — the local-UI surface consumed by the Svelte SPA.
378
- - `/` — the SPA bundle (or a placeholder HTML when running from source before the SPA is built — see `6.5.2-cadre-host-local-ui-spa`).
379
-
380
- If the configured `uiPort` is in use the server tries `uiPort+1..uiPort+9`; on total failure it exits with a message listing every port attempted. An origin guard rejects requests whose `Host` or `Origin` is not `127.0.0.1[:port]` / `localhost[:port]` (defeats DNS-rebind from a malicious page). There is no login — the security model is "same machine as the cadre-host user" (see threat model below).
381
-
382
- ## Updates
383
-
384
- cadre-host checks `https://releases.serfab.io/cadre-host/latest.json` once per `start` and every 24 hours thereafter. **Notify-by-default**: an available update is recorded in `<dataDir>/update-state.json` and surfaced by the local UI; the user clicks "apply" to install it. Auto-apply is opt-in via the local-UI settings page (writes `updates.autoApply: true` into `host.config.json`).
385
-
386
- The manifest URL is overridable two ways:
387
- - `CADRE_HOST_UPDATE_MANIFEST_URL` env var (wins over config).
388
- - `updates.manifestUrl` in `host.config.json` (settable from the local UI).
389
-
390
- Manifests are signed with Ed25519; cadre-host refuses to apply any release whose signature doesn't match the embedded release key. For CI / dev signing, set `CADRE_HOST_UPDATE_DEV_KEY` to a base64-encoded raw 32-byte public key.
391
-
392
- **Threat model.** Any local process running as the cadre-host user can fully control cadre-host (read identity, issue or revoke grants, mutate the trust circle, install arbitrary global packages). Signature verification protects against a compromised release CDN — it is **not** a defense against local-machine compromise. Treat the host like any other long-running service: limit who can run shells as that user, keep the OS patched, and rely on grant tokens — plus, in the founder role, trust-circle membership — for inter-cadre auth.
393
-
394
- Apply flow: re-fetch + re-verify the manifest, record `applyInProgress`, run `npm install -g @serfab/cadre-host@<version>` (5-minute timeout), and restart the OS service unit so the new binary takes effect. On install failure, the previous version is reinstalled and the error is surfaced via `update-state.json` — the still-running binary continues to serve. The service-host restart is best-effort; if it fails, the binary swap already succeeded and the user can restart manually.
395
-
396
- ## Local UI
397
-
398
- `cadre-host start` serves a Svelte 5 SPA at `http://127.0.0.1:<uiPort>/`. **Local-only by design:** the server binds to loopback (`127.0.0.1`) only and rejects requests whose `Host` or `Origin` header is not a loopback hostname, so the UI is unreachable from your LAN even though it has no login. To use it from another machine, SSH-port-forward as shown in [*After install*, step 2](#2-open-the-local-ui).
399
-
400
- Seven pages cover the day-to-day operations. Three of them belong to the opt-in founder role and are marked as such:
401
-
402
- - **Home / Status** — green/yellow/red dot, service version + uptime, "update available" banner. A donor-only install shows a Donation tile linking to Grants; the founder role shows trust-circle-size and connectivity tiles instead.
403
- - **Nodes** — per-managed-node detail, recent stats, log tail (last 200 lines, "Refresh" pulls again). Your own owner node (founder role) has start/stop/restart; a donated node has **Terminate** instead, the same as `cadre-host grant terminate <id>`. `cadre-host` v1 doesn't auto-spawn nodes, so this list is empty until a grantee requests a donated node (or, in the founder role, until your own owner node starts).
404
- - **Grants** — issue grant tokens (QR + copy), see each grant's node usage and the donated nodes under it (linked to their node pages), show an active grant's token again, revoke a grant with or without its nodes. Same `/grants-admin` surface as `cadre-host grant`.
405
- - **Settings** — update preferences (autoApply toggle, manifest URL override), install metadata (install ID, data dir, ports), uninstall pointer.
406
- - **Trust Circle** *(founder role only)* — list members, invite a friend (modal generates a paste-friendly token + QR), revoke pending invites or remove members.
407
- - **Connectivity** *(founder role only)* — port-forwarding status, "Test reachability", DDNS provider configuration, manual port-forward instructions when UPnP isn't working.
408
- - **Strands** *(founder role only)* — the shared SQL databases your own cadre belongs to.
409
-
410
- On a donor-only install the founder-only pages are left out of the nav, and opening one by its address shows a note instead of the page.
411
-
412
- The SPA opens an `EventSource` against `/api/events` and re-fetches the relevant slice when a node state changes, the trust circle or the grants change, connectivity changes, or an update is announced. No login — the page is bound to loopback only, with an Origin/Host guard for DNS-rebind defence. See the threat-model note in the *Updates* section above and in [docs/cadre-host.md](../../docs/cadre-host.md) for the full security posture.
413
-
414
- ### Building the SPA
415
-
416
- `yarn workspace @serfab/cadre-host build` compiles both the server (TypeScript) and the SPA (`vite build` against `ui/`). The bundle lands in `dist/ui/` and is served by the same Fastify instance that handles `/api/*` and friends. When `dist/ui/` is missing (e.g. running from source without building), the server still answers all API routes and shows a placeholder at `/` explaining how to build.
417
-
418
- For UI-only iteration: `yarn workspace @serfab/cadre-host dev:ui` starts Vite on `:5173` and proxies `/api`, `/auth`, `/nat`, `/update` to `127.0.0.1:8765` (override with `CADRE_HOST_PORT`).
419
-
420
- ## More
421
-
422
- - [docs/cadre-host.md](../../docs/cadre-host.md) — persona, package boundary, deployment model, security posture.
423
- - [docs/architecture.md](../../docs/architecture.md) — overall cadre architecture.
1
+ # @serfab/cadre-host
2
+
3
+ Self-hosted cadre node manager for basement-PC deployments. Runs one always-on machine whose job is to **run always-on nodes for cadres that live on people's phones**: your own cadre first, and a friend's or a family member's if they scan a code you show them. The phone stays the authority for its cadre, and your box runs a node that joins it. It exposes a localhost web UI to manage that. It holds no owner key and never runs a cadre of its own.
4
+
5
+ The sibling of [`@serfab/cadre-provider`](../cadre-provider/README.md): the provider hosts nodes for paying tenants with API keys, billing, and Docker; cadre-host hosts them for free for a handful of people you trust, as native child processes, with a one-shot installer.
6
+
7
+ `cadre-host join` starts a node and shows a QR code; the phone that owns the cadre scans it to claim the node. A cadre that already has a member this machine can reach can instead hand the node an invitation (`cadre-host join --invitation`). The NAT/DDNS layer (`/nat/*`) maps the ports of every node this machine runs.
8
+
9
+ [docs/cadre-host.md](../../docs/cadre-host.md) is the design source of truth.
10
+
11
+ ## Install
12
+
13
+ cadre-host installs entirely within your user account. **No step requires root**, with one optional exception (`loginctl enable-linger`) called out under *Root requirements* below.
14
+
15
+ Pick whichever install style suits you:
16
+
17
+ ### Global install (binary on PATH)
18
+
19
+ ```bash
20
+ npm install -g @serfab/cadre-host # sudo only if your npm prefix is system-owned (e.g. /usr/lib)
21
+ cadre-host install
22
+ ```
23
+
24
+ ### Local install (self-contained — recommended if you want everything under one folder)
25
+
26
+ ```bash
27
+ mkdir -p ~/cadre && cd ~/cadre
28
+ npm install @serfab/cadre-host
29
+ npx cadre-host install --data-dir ~/cadre/data
30
+ ```
31
+
32
+ From inside `~/cadre`, `npx cadre-host <command>` runs the local binary (`join`, `nat`, `uninstall`, …) without any path prefix — `npx` resolves it from `node_modules/.bin/`. **Caveat:** outside `~/cadre`, `npx cadre-host` won't find the local install and will silently download a fresh copy from the npm registry. To avoid that, either always `cd ~/cadre` first or symlink the binary onto your PATH:
33
+
34
+ ```bash
35
+ ln -s ~/cadre/node_modules/.bin/cadre-host ~/.local/bin/cadre-host
36
+ # now `cadre-host join` works from anywhere
37
+ ```
38
+
39
+ If you'd rather skip `npx` and the symlink, the explicit path `./node_modules/.bin/cadre-host <command>` always works from `~/cadre` too.
40
+
41
+ The wizard (run either way):
42
+
43
+ 1. Prompts for the data directory and UI port (defaults shown in `[...]`), skipping any you gave as a flag. It asks about UPnP only on a machine behind a router: one that holds a public IP address (a VPS) is reached directly, and the wizard says so instead of asking.
44
+ 2. Writes `<dataDir>/host.config.json` and seeds `<dataDir>/nat.json` with the UPnP choice.
45
+ 3. Registers a per-user service: `systemctl --user` unit (Linux), `LaunchAgent` (macOS), or NSSM service (Windows; requires `nssm.exe` on PATH — see `service/README.md`).
46
+ 4. Prints how to open the UI: the URL on a desktop (and opens it), or, over SSH or on a machine with no display, the exact `ssh -L` command to run from your own computer. Everything else (a public name, joining a cadre, ports) happens in the UI.
47
+
48
+ Run `cadre-host install --non-interactive --data-dir <path>` for unattended provisioning.
49
+
50
+ ### Root requirements
51
+
52
+ The installer never writes outside your home directory. On Linux the unit goes to `$XDG_CONFIG_HOME/systemd/user/cadre-host.service` (default `~/.config/systemd/user/cadre-host.service`); the data dir defaults to `$XDG_DATA_HOME/cadre-host` (default `~/.local/share/cadre-host`) or wherever you point `--data-dir`. Registering a `systemctl --user` unit does **not** require root — any user can do it.
53
+
54
+ There is **one optional** root command:
55
+
56
+ ```bash
57
+ sudo loginctl enable-linger <your-user>
58
+ ```
59
+
60
+ `enable-linger` is what allows your `systemctl --user` services to start at boot and keep running after you log out. Without linger, cadre-host runs only while you have an active session (desktop login or SSH) and stops when that session ends. With linger, it runs whenever the machine is on.
61
+
62
+ The installer attempts `loginctl enable-linger` for you and logs a warning if it can't (you aren't root, `loginctl` is missing, running inside a container, …). **The install succeeds either way** — you can enable linger separately, before or after `cadre-host install`. Without linger you can still verify everything by logging in and running `systemctl --user start cadre-host` by hand.
63
+
64
+ ### System-wide install (not yet supported)
65
+
66
+ `cadre-host install --system` is accepted by the CLI but currently errors out. A proper system-wide install — dedicated `cadre` user, unit at `/etc/systemd/system/`, install under `/opt/cadre/...` — needs additional work around data-dir ownership and capability dropping and is tracked separately.
67
+
68
+ ### Service-host details
69
+
70
+ The rendered unit files live at:
71
+
72
+ | Platform | Path |
73
+ | -------- | ---- |
74
+ | Linux | `~/.config/systemd/user/cadre-host.service` |
75
+ | macOS | `~/Library/LaunchAgents/com.serfab.cadre-host.plist` |
76
+ | Windows | NSSM-managed service `CadreHost` (registry-stored config) |
77
+
78
+ See [`service/README.md`](./service/README.md) for templates, manual-smoke instructions, and the cross-platform CI gap.
79
+
80
+ ## After install — joining your first cadre
81
+
82
+ `cadre-host install` leaves you with a running management service, a local UI, and **no cadre nodes yet**. cadre-host never pre-spawns nodes; each one starts when you click **Join a cadre** in the local UI or run `cadre-host join`. This walkthrough goes from "install just finished" to "first hosted node is claimed."
83
+
84
+ Each node this host runs joins a cadre that lives on a phone — yours, or a friend's — and that phone stays the authority for the cadre. Your host never holds an owner key and never becomes the authority for anyone's data. See [docs/cadre-host.md § Join a cadre](../../docs/cadre-host.md#join-a-cadre) for the full lifecycle.
85
+
86
+ ### 1. Verify the service is running
87
+
88
+ ```bash
89
+ systemctl --user status cadre-host
90
+ ```
91
+
92
+ Look for `Active: active (running)`. If it's not active, `journalctl --user -u cadre-host -f` shows the live log.
93
+
94
+ ### 2. Open the local UI
95
+
96
+ The UI listens on `http://127.0.0.1:<uiPort>/` (default port 8765) on the host machine itself.
97
+
98
+ - **On the host:** open the URL in a browser, or run `cadre-host ui` to print + open it.
99
+ - **From a different machine on your LAN** (most basement PCs are headless): the UI is bound to loopback only and has no authentication — that's intentional, the trust boundary is "same machine, same user as cadre-host." Use SSH port-forwarding from the client side:
100
+
101
+ ```bash
102
+ # from your laptop:
103
+ ssh -L 8765:127.0.0.1:8765 user@my-basement-pc
104
+ # then open http://127.0.0.1:8765 in the laptop's browser
105
+ ```
106
+
107
+ The forward stays up as long as the SSH session does.
108
+
109
+ ### 3. Join your cadre
110
+
111
+ Decide whose cadre this node is for — a family member, a friend, or your own phone. That person already has **their own** cadre on their phone; you are adding a node to it, not enrolling them into anything of yours.
112
+
113
+ In the local UI, open **Join** and click **Join a cadre**. The page starts the node and shows a QR code and the same text with a copy button. Scan the code with the Sereus app on the phone that owns the cadre, or paste the text into it. The line under the node changes to `Claimed by owner <fingerprint> into cadre <partyId>` once the phone has claimed it, then to `Connected to the cadre`, and the code gives way to a link to the node's page. **Cancel** removes a node nobody has claimed yet. Closing the tab loses nothing: the Join page lists every node still waiting to be claimed.
114
+
115
+ From a shell instead:
116
+
117
+ ```bash
118
+ cadre-host join
119
+ ```
120
+
121
+ The command starts the node and prints a QR code and the same text under it, then waits and prints `✓ Claimed by owner <fingerprint> into cadre <partyId>` once the phone has claimed the node; the node now appears on the Nodes page as claimed. `--no-qr` prints only the text (on stdout, alone, so it can be piped); `--no-wait` exits once the code is shown, and the node keeps waiting. Ctrl-C while waiting also leaves the node waiting.
122
+
123
+ If the page or the command warns that the node cannot be reached from outside your home network, a phone on your home network can still claim it; see [Reachability](#reachability--can-people-actually-reach-your-nodes) for the ports to forward.
124
+
125
+ To host a node for a friend, join again and let the friend scan the new code. **Anyone who scans the code claims the node**, so show it only to the person it is for; if the wrong person claimed it, **Reset** on the node's page (or `cadre-host node reset <id>`) removes that node and starts a fresh one with a new code.
126
+
127
+ **The second way: paste an invitation.** Once the cadre already has a member this machine can reach (an always-on node, here or elsewhere), the owner's app can copy a cadre invitation instead of scanning anything. Paste it under **Or paste a cadre invitation** on the Join page, or run:
128
+
129
+ ```bash
130
+ cadre-host join --invitation "<the invitation text>"
131
+ ```
132
+
133
+ The node starts in the cadre the invitation names and redeems it at one of the cadre's members; the line under it reads `Joining cadre <partyId>…`, then `Joined cadre <partyId> at member <peerId>`. This does not work for a cadre's **first** always-on node: its only members are phones, which nothing can dial, so the join fails with "No member … could be reached". Use the QR code for that one. When no member could be reached because the member is offline for a while, **Retry** on the node (or `cadre-host node retry <id>`) starts it again with the same invitation; an invitation the cadre refused (expired, used up, or made for another device) cannot be retried, so remove the node and join with a fresh one.
134
+
135
+ ### 4. Manage hosted nodes
136
+
137
+ ```bash
138
+ cadre-host node list # id, status, cadre, owner fingerprint, connected
139
+ cadre-host node remove <id> # stop the node and delete its data on this machine
140
+ cadre-host node reset <id> # remove it and start a fresh one with a new code
141
+ cadre-host node retry <id> # start an invitation node again once a member is reachable
142
+ ```
143
+
144
+ `<id>` is the `hn_…` id the list and the UI's Nodes page show. Removing a node deletes its record, stops the child and deletes its working directory (its identity key and node-local data); the cadre keeps the node's row until its owner removes it there. A hosted node's page in the UI offers **Remove** and **Reset** (the same calls; **Retry** on a failed node is a reset, except on an invitation node no member could be reached for, where it is `node retry`) rather than Stop: the respawn supervisor treats a claimed, joining or waiting node as expected to be running and would bring a merely stopped node straight back. A node that crashes or dies in a reboot is respawned with the same identity, ports and code, so a QR code already shown stays valid and a claimed node stays in its cadre.
145
+
146
+ ## Reachability — can people actually reach your nodes?
147
+
148
+ Every node this machine runs needs two ports reachable from outside your home network: its libp2p TCP port and its WebSocket port (the one a phone dials). How they get there depends on where the machine sits, and cadre-host works that out itself (**Connectivity → Network**, or `cadre-host nat settings --network auto|lan|public`):
149
+
150
+ - **Behind a router** (a home or office network): cadre-host asks the router to map both ports over UPnP as each node starts, or you forward them by hand (below).
151
+ - **On a public IP address** (a VPS, or a server with a routed address): there is nothing to map. Each node is reached at its own ports, and the status shows them as `direct`. Allow them through any firewall on the machine (`sudo ufw allow <tcp>:<ws>/tcp`, which the status prints for you) and in your hosting provider's firewall. No `nat forward` is needed. A cloud machine whose interface holds only a private address while the provider forwards its public IP to it (AWS, GCP) reads as behind a router: set `--network public` there.
152
+
153
+ `auto` (the default) picks public when one of the machine's interfaces holds a public IPv4 address, re-checked every few minutes. After installing (and any time your network changes):
154
+
155
+ ```bash
156
+ cadre-host nat status # network mode, UPnP and router state, external IP, and per node: mapped / forwarded by hand / direct / unreachable, with what to do
157
+ cadre-host nat test # re-run the probes right now
158
+ ```
159
+
160
+ If a node reads `unreachable`, the status says which of its ports to forward on your router and to which address on this machine, then the command that records the external ports you chose:
161
+
162
+ ```bash
163
+ cadre-host nat forward hn_abc123 --tcp 10003 --ws 10004 # the ports your router forwards to that node
164
+ cadre-host nat forward hn_abc123 --clear # forget them
165
+ ```
166
+
167
+ The UI's **Connectivity** page shows the same per node, with an "I forwarded these ports" form. A node whose public addresses change restarts to announce them to the cadre it belongs to, at most once every 10 minutes; a node that restarts or respawns keeps its ports, so the forward stays valid. If your ISP uses carrier-grade NAT, a port forward will not help and the status says so. See [docs/cadre-host.md § Manual port forwarding](../../docs/cadre-host.md#manual-port-forwarding).
168
+
169
+ **Which addresses the QR code carries.** A phone tries each address in turn, so the code carries only ones a phone can use: the public addresses (your DDNS name when set, otherwise the public IP) and one LAN address for phones at home. Docker bridges, VPN interfaces and TCP are left out. By default the LAN address is this machine's address on the router's network. To choose it, or to leave it out (a machine reached only by its public name), or to list exact addresses instead, use **Settings → Claim code addresses** in the UI, or set `claimAddresses` in `host.config.json`:
170
+
171
+ ```json
172
+ "claimAddresses": { "lan": "192.168.1.20" }
173
+ "claimAddresses": { "lan": "none" }
174
+ "claimAddresses": { "addrs": ["/dns4/node.example.org/tcp/443/wss"] }
175
+ ```
176
+
177
+ The next code shown uses the change; no restart is needed. Exact `addrs` apply to every node this host runs, each code appending that node's own peer ID, so leave `/p2p/…` off them and give each node its own address (or run one node).
178
+
179
+ When your residential IP changes (it will), members can't find you on the old IP. DDNS (a hostname that auto-updates to your current IP) fixes this:
180
+
181
+ ```bash
182
+ # DuckDNS — cadre-host updates the record itself. Sign up at duckdns.org and grab a token first.
183
+ cadre-host nat ddns set duckdns --hostname mybox.duckdns.org --token <duckdns-token>
184
+
185
+ # Externally-managed — your router or another tool updates DNS; cadre-host just records the hostname.
186
+ cadre-host nat ddns external --hostname mybox.example.com
187
+ ```
188
+
189
+ If you use the DuckDNS form, the token is stored in the OS keychain when `libsecret` is installed (`sudo apt install libsecret-1-0` on Debian/Ubuntu), and unencrypted in `<dataDir>/nat-secrets.json` otherwise. The service logs a warning at startup when it falls back to unencrypted storage.
190
+
191
+ ## CLI reference
192
+
193
+ All commands except `install`, `uninstall`, `status`, `start`, `ui`, and the `push` group talk to the running cadre-host management API over loopback. They print a connection error if the service isn't running.
194
+
195
+ The `cadre-host push` group needs **no running service** — the commands write straight to the data dir's secret store and `host.config.json`. Private keys land in the OS keychain when one is available, otherwise a plain-JSON fallback at `<dataDir>/nat-secrets.json` (mode `0600` on POSIX; **on Windows the permission bits don't apply, so any account on the machine can read it** — install keytar's native dependency to avoid that); the non-secret bits (APNs bundle id / sandbox toggle, cooldown, debounce) land in `host.config.json`. Credentials are re-resolved on every node spawn, so a node picks them up the next time it is spawned. Every storage-profile node carries them when they are configured, hosted nodes included.
196
+
197
+ ### `cadre-host status`
198
+
199
+ Print whether the service is registered and currently active.
200
+
201
+ ```
202
+ $ cadre-host status
203
+ Service installed: yes
204
+ Service running: yes
205
+ ```
206
+
207
+ ### `cadre-host ui [--no-browser]`
208
+
209
+ Print the local-UI URL (e.g. `http://127.0.0.1:8765`) and open it in the default browser. Reads `uiPort` from `host.config.json`; doesn't require the service to be running (if not, the browser will fail to connect — that's feedback enough). Pass `--no-browser` to just print the URL.
210
+
211
+ ### `cadre-host join [--no-qr] [--no-wait]`
212
+
213
+ Start a hosted node waiting to be claimed and show the code the owner's phone scans to add it to their cadre: a QR code and the same text, then (unless `--no-wait`) wait until the phone claims it and print `✓ Claimed by owner <fingerprint> into cadre <partyId>`. `--no-qr` prints the text only, on stdout alone. See [*Join your cadre*](#3-join-your-cadre).
214
+
215
+ ### `cadre-host join --invitation <encoded> [--no-wait]`
216
+
217
+ Start a hosted node that redeems a cadre invitation the owner's app copied, instead of showing a code: `Joining cadre <partyId> through the invitation…`, then (unless `--no-wait`) wait and print `✓ Joined cadre <partyId> at member <peerId>`, or `✗ Could not join: <message>` and exit 1. When no member could be reached, it says what to do: for the cadre's first always-on node, use `cadre-host join` without `--invitation` and scan the QR code instead; otherwise `cadre-host node retry <id>` once a member is online. An invitation that does not decode is refused before any node starts. See [*Join your cadre*](#3-join-your-cadre).
218
+
219
+ ### `cadre-host node list`
220
+
221
+ Print every hosted node: id, status (`spawning`, `unclaimed`, `joining`, `joined` or `error`), cadre, owner fingerprint (the first 8 characters of the owner key) and whether a joined node is connected.
222
+
223
+ ### `cadre-host node remove <id>`
224
+
225
+ Stop one hosted node and delete its data on this machine — its record, the child and its working directory. Its cadre keeps the node's row until the owner removes it there. `<id>` is the `hn_…` id the list shows.
226
+
227
+ ### `cadre-host node reset <id> [--no-qr] [--no-wait]`
228
+
229
+ Remove a hosted node and start a fresh one with a new code, then show the code and wait as `join` does. For a node someone else claimed first, or one that failed. An invitation node is replaced by one waiting to be claimed too.
230
+
231
+ ### `cadre-host node retry <id> [--no-wait]`
232
+
233
+ Start an invitation node again after no member of its cadre could be reached, with the same invitation and the same identity, then wait as `join --invitation` does. Refused (409) for any other node, including one whose invitation the cadre refused.
234
+
235
+ ### `cadre-host nat status [--json]`
236
+
237
+ Print current NAT state — whether UPnP is on and a router was found (and this machine's address on its network), external IP, CGNAT detection, the host-level reachability result and the DDNS configuration — then one block per hosted node: its id and verdict (`mapped`, `manual` or `unreachable`), the TCP and WebSocket ports as internal → external with where the route came from (UPnP or forwarded by hand), and its public addresses. An `unreachable` node adds why, and the forward to make: which ports to forward to which address on this machine, and the `cadre-host nat forward` command to run afterwards. With `--json`, dumps the raw response from the management API.
238
+
239
+ ### `cadre-host nat test [--json]`
240
+
241
+ Re-run the reachability probe right now and print the updated NAT state. Useful after changing port-forwarding rules on your router.
242
+
243
+ ### `cadre-host nat forward <nodeId> [--tcp <port>] [--ws <port>] [--clear-tcp] [--clear-ws] [--clear]`
244
+
245
+ Record the external ports your router forwards to one node, by the node id `nat status` shows: `--tcp` for its TCP port, `--ws` for its WebSocket port, `--clear-tcp`/`--clear-ws`/`--clear` to forget them. Ports are whole numbers 1–65535. Prints the node's block from the updated status. If the node's public addresses changed, it restarts to announce them; a node already restarted for an address change in the last 10 minutes waits until those 10 minutes are up. An id the host does not run is refused with a pointer to `nat status`.
246
+
247
+ ### `cadre-host nat settings [--upnp|--no-upnp]`
248
+
249
+ Turn UPnP port mapping on or off. Turning it off releases every mapping the router granted and keeps the ports you forwarded by hand.
250
+
251
+ ### `cadre-host nat ddns set <provider> --hostname <h> [--token <t>]`
252
+
253
+ Configure cadre-host to push DNS updates itself. Currently the only `<provider>` is `duckdns`. If `--token` is omitted, the command prompts for it (with echo suppressed) when stdin is a TTY. The token is stored in the OS keychain when available, otherwise unencrypted at `<dataDir>/nat-secrets.json` (a startup warning surfaces this).
254
+
255
+ ### `cadre-host nat ddns external --hostname <h>`
256
+
257
+ Tell cadre-host that some other tool (your router firmware, a separate `ddclient`, etc.) is updating DNS, and to record the hostname for publishing to peers without trying to update it itself.
258
+
259
+ ### `cadre-host push fcm --project-id <id> --client-email <email> [--private-key-file <path>] [--private-key <pem>] [--data-dir <path>]`
260
+
261
+ Store Firebase Cloud Messaging (Android) service-account credentials so a storage node can wake suspended mobile apps. The three values come from the Firebase service-account JSON (`project_id`, `client_email`, `private_key`). Supply the key either as a file (`--private-key-file`, preferred) or inline (`--private-key`); with neither, the command exits with an error. See [docs/cadre-host.md § Push credentials](../../docs/cadre-host.md#push-credentials-fcmapns) for how to mint the credentials and how they reach the spawned node.
262
+
263
+ ### `cadre-host push apns --key-id <id> --team-id <id> --bundle-id <id> [--private-key-file <path>] [--private-key <pem>] [--production] [--data-dir <path>]`
264
+
265
+ Store Apple Push Notification service (iOS) auth-key credentials. `--key-id`/`--team-id` identify the `.p8` auth key downloaded from the Apple Developer portal; `--bundle-id` becomes the `apns-topic`. As with `push fcm`, pass the key via `--private-key-file` (preferred) or `--private-key`. Targets the **sandbox** APNs host by default — pass `--production` for an App Store build. A token minted for one host is rejected by the other, so this must match the build under test.
266
+
267
+ ### `cadre-host push options [--cooldown-ms <ms>] [--debounce-ms <ms>] [--data-dir <path>]`
268
+
269
+ Set the non-secret push tuning knobs: `--cooldown-ms` is the minimum gap between wakes for one (peer, strand) pair (anti-spam), `--debounce-ms` the per-strand burst-coalescing window. Pass only the flag(s) you want to change.
270
+
271
+ ### `cadre-host push clear <target> [--data-dir <path>]`
272
+
273
+ Remove stored push credentials. `<target>` is `fcm`, `apns`, or `all`. Clearing `apns` also drops the bundle id / sandbox toggle from `host.config.json`. With nothing configured, no `push` block is written into the spawned node's `cadre.json` and the node falls back to control-network push-wake only.
274
+
275
+ ### `cadre-host push status [--data-dir <path>]`
276
+
277
+ Print which push platforms are configured, the APNs bundle id and sandbox/production mode, and the current cooldown/debounce values. Never prints key material.
278
+
279
+ ### `cadre-host start [--data-dir <path>] [--no-tui]`
280
+
281
+ Run cadre-host in the foreground. Normally invoked by the service unit, not directly; after `install --no-service` it is how the host runs. `--data-dir` overrides the install-time data directory (also honors `$CADRE_HOST_DATA_DIR`).
282
+
283
+ ### `cadre-host install [--no-service] [...flags]`
284
+
285
+ Run the first-run wizard. See [**Install**](#install) at the top of this README.
286
+
287
+ `--no-service` writes the data dir (`host.config.json`, `nat.json`) and stops there: no OS service is registered and no browser opens. Run the host by hand with `cadre-host start --data-dir <path>`. This is the setup for a test session, for example a phone walkthrough against a host run from a checkout ([`docs/reference-app-rn.md`](../../docs/reference-app-rn.md)). Don't run `start` on top of a service install: the service already binds `uiPort`, and a second host on the same data dir moves to the next free port.
288
+
289
+ ### `cadre-host uninstall [--remove-data] [--yes]`
290
+
291
+ Stop and deregister the service. Preserves the data dir by default; pass `--remove-data --yes` to wipe node identities, hosted-node records, NAT state, and update state too.
292
+
293
+ ```bash
294
+ cadre-host uninstall # stop + deregister, keep data
295
+ cadre-host uninstall --remove-data --yes # also delete the data dir
296
+ ```
297
+
298
+ ## What `cadre-host start` does today
299
+
300
+ `start` loads `host.config.json`, brings up the orchestrator, the NAT layer, the hosted-node service with its watcher and supervisor, and the update service, and binds the Fastify management server on `127.0.0.1:<uiPort>` (loopback only). Routes:
301
+
302
+ - `/api/hosted-nodes` (list, start a node waiting to be claimed or one that redeems an invitation, its claim details, remove, reset, retry — no bearer; same-machine admin) — the surface `cadre-host join` and `cadre-host node` drive.
303
+ - `/update/*` (update flow) — matches the CLI's contract.
304
+ - `/nat/*` (NAT/DDNS) — every hosted node's ports are mapped, and `PUT /nat/nodes/:nodeId/forward` records the ports you forwarded by hand (what `cadre-host nat forward` calls).
305
+ - `/api/status`, `/api/nodes`, `/api/nodes/:id`, `/api/nodes/:id/logs`, `/api/settings`, `/api/events` (Server-Sent Events) — the local-UI surface consumed by the Svelte SPA. `/api/nodes` is read-only: end a hosted node through `DELETE /api/hosted-nodes/:id`.
306
+ - `/` — the SPA bundle (or a placeholder HTML when running from source before the SPA is built — see [Building the SPA](#building-the-spa)).
307
+
308
+ If the configured `uiPort` is in use the server tries `uiPort+1..uiPort+9`; on total failure it exits with a message listing every port attempted. An origin guard rejects requests whose `Host` or `Origin` is not `127.0.0.1[:port]` / `localhost[:port]` (defeats DNS-rebind from a malicious page). There is no login — the security model is "same machine as the cadre-host user" (see threat model below).
309
+
310
+ ## Updates
311
+
312
+ cadre-host checks `https://releases.serfab.io/cadre-host/latest.json` once per `start` and every 24 hours thereafter. **Notify-by-default**: an available update is recorded in `<dataDir>/update-state.json` and surfaced by the local UI; the user clicks "apply" to install it. Auto-apply is opt-in via the local-UI settings page (writes `updates.autoApply: true` into `host.config.json`).
313
+
314
+ The manifest URL is overridable two ways:
315
+ - `CADRE_HOST_UPDATE_MANIFEST_URL` env var (wins over config).
316
+ - `updates.manifestUrl` in `host.config.json` (settable from the local UI).
317
+
318
+ Manifests are signed with Ed25519; cadre-host refuses to apply any release whose signature doesn't match the embedded release key. For CI / dev signing, set `CADRE_HOST_UPDATE_DEV_KEY` to a base64-encoded raw 32-byte public key.
319
+
320
+ **Threat model.** Any local process running as the cadre-host user can fully control cadre-host (read node identities, start or remove hosted nodes, install arbitrary global packages). Signature verification protects against a compromised release CDN — it is **not** a defense against local-machine compromise. Treat the host like any other long-running service: limit who can run shells as that user, keep the OS patched, and show a join code only to the person it is for.
321
+
322
+ Apply flow: re-fetch + re-verify the manifest, record `applyInProgress`, run `npm install -g @serfab/cadre-host@<version>` (5-minute timeout), and restart the OS service unit so the new binary takes effect. On install failure, the previous version is reinstalled and the error is surfaced via `update-state.json` — the still-running binary continues to serve. The service-host restart is best-effort; if it fails, the binary swap already succeeded and the user can restart manually.
323
+
324
+ ## Local UI
325
+
326
+ `cadre-host start` serves a Svelte 5 SPA at `http://127.0.0.1:<uiPort>/`. **Local-only by design:** the server binds to loopback (`127.0.0.1`) only and rejects requests whose `Host` or `Origin` header is not a loopback hostname, so the UI is unreachable from your LAN even though it has no login. To use it from another machine, SSH-port-forward as shown in [*After install*, step 2](#2-open-the-local-ui).
327
+
328
+ Five pages cover the day-to-day operations:
329
+
330
+ - **Home / Status** — green/yellow/red dot, service version + uptime, "update available" banner, a connectivity tile ("N of M nodes reachable from outside", or plainly that none can be, linking to Connectivity), a hosted-nodes tile ("N joined, M waiting", plus failed ones when there are any, linking to Join), and the running-node count.
331
+ - **Connectivity** — UPnP and router status, "Test reachability", a carrier-grade NAT notice when detected, UPnP toggle and DDNS provider configuration; then one entry per hosted node, labelled with the cadre it joined, "waiting to be claimed" or "joining cadre <partyId>", with its reachability, its TCP and WebSocket ports (internal → external, and whether UPnP or a hand forward provides the route), its public addresses (copyable), what to forward when it cannot be reached, and an "I forwarded these ports" form. A node's page shows the same entry for that node.
332
+ - **Nodes** — one row per hosted node with its status, cadre, owner fingerprint and whether it is connected. A node's page adds recent stats, the log tail (last 200 lines, "Refresh" pulls again) and a **Cadre** card: the code and **Cancel** while it waits to be claimed, **Cancel** while it redeems an invitation, **Reset** and **Remove** once joined (**Remove** alone for an invitation node), **Retry** and **Remove** when it failed (for an invitation node, **Retry** only when no member could be reached). cadre-host doesn't auto-spawn nodes, so this list is empty until you join a cadre.
333
+ - **Join** — the **Join a cadre** button and the **Or paste a cadre invitation** field, then each node waiting to be claimed with its QR code, its text, its reachability and a live line that follows the claim, and each node redeeming an invitation with its live line, or its error, the first-node hint and **Retry** when no member could be reached (see [*After install*, step 3](#3-join-your-cadre)).
334
+ - **Settings** — update preferences (autoApply toggle, manifest URL override), install metadata (install ID, data dir, UI port), uninstall pointer.
335
+
336
+ The SPA opens an `EventSource` against `/api/events` and re-fetches the relevant slice when a node state changes, a hosted node is added, claimed, joins, fails or is removed, connectivity changes, or an update is announced. No login — the page is bound to loopback only, with an Origin/Host guard for DNS-rebind defence. See the threat-model note in the *Updates* section above and in [docs/cadre-host.md](../../docs/cadre-host.md) for the full security posture.
337
+
338
+ ### Building the SPA
339
+
340
+ `yarn workspace @serfab/cadre-host build` compiles both the server (TypeScript) and the SPA (`vite build` against `ui/`). The bundle lands in `dist/ui/` and is served by the same Fastify instance that handles `/api/*` and friends. When `dist/ui/` is missing (e.g. running from source without building), the server still answers all API routes and shows a placeholder at `/` explaining how to build.
341
+
342
+ For UI-only iteration: `yarn workspace @serfab/cadre-host dev:ui` starts Vite on `:5173` and proxies `/api`, `/nat`, `/update` to `127.0.0.1:8765` (override with `CADRE_HOST_PORT`).
343
+
344
+ ## More
345
+
346
+ - [docs/cadre-host.md](../../docs/cadre-host.md) — persona, deployment model, both ways to join a cadre, hosted-node lifecycle, NAT, security posture.
347
+ - [docs/architecture.md](../../docs/architecture.md) — overall cadre architecture.