@serfab/cadre-host 0.7.1

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 (300) hide show
  1. package/README.md +101 -0
  2. package/dist/auth/duration.d.ts +1 -0
  3. package/dist/auth/duration.js +32 -0
  4. package/dist/auth/duration.js.map +1 -0
  5. package/dist/auth/index.d.ts +9 -0
  6. package/dist/auth/index.js +8 -0
  7. package/dist/auth/index.js.map +1 -0
  8. package/dist/auth/trust-circle-store.d.ts +26 -0
  9. package/dist/auth/trust-circle-store.js +129 -0
  10. package/dist/auth/trust-circle-store.js.map +1 -0
  11. package/dist/auth/trust-circle.d.ts +91 -0
  12. package/dist/auth/trust-circle.js +230 -0
  13. package/dist/auth/trust-circle.js.map +1 -0
  14. package/dist/auth/types.d.ts +81 -0
  15. package/dist/auth/types.js +24 -0
  16. package/dist/auth/types.js.map +1 -0
  17. package/dist/bin/host.d.ts +13 -0
  18. package/dist/bin/host.js +826 -0
  19. package/dist/bin/host.js.map +1 -0
  20. package/dist/index.d.ts +32 -0
  21. package/dist/index.js +27 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/installer/browser.d.ts +14 -0
  24. package/dist/installer/browser.js +35 -0
  25. package/dist/installer/browser.js.map +1 -0
  26. package/dist/installer/config.d.ts +43 -0
  27. package/dist/installer/config.js +113 -0
  28. package/dist/installer/config.js.map +1 -0
  29. package/dist/installer/identity.d.ts +23 -0
  30. package/dist/installer/identity.js +36 -0
  31. package/dist/installer/identity.js.map +1 -0
  32. package/dist/installer/index.d.ts +83 -0
  33. package/dist/installer/index.js +204 -0
  34. package/dist/installer/index.js.map +1 -0
  35. package/dist/installer/paths.d.ts +33 -0
  36. package/dist/installer/paths.js +84 -0
  37. package/dist/installer/paths.js.map +1 -0
  38. package/dist/installer/platform.d.ts +9 -0
  39. package/dist/installer/platform.js +21 -0
  40. package/dist/installer/platform.js.map +1 -0
  41. package/dist/installer/service-host/index.d.ts +11 -0
  42. package/dist/installer/service-host/index.js +16 -0
  43. package/dist/installer/service-host/index.js.map +1 -0
  44. package/dist/installer/service-host/launchd.d.ts +16 -0
  45. package/dist/installer/service-host/launchd.js +79 -0
  46. package/dist/installer/service-host/launchd.js.map +1 -0
  47. package/dist/installer/service-host/nssm.d.ts +20 -0
  48. package/dist/installer/service-host/nssm.js +88 -0
  49. package/dist/installer/service-host/nssm.js.map +1 -0
  50. package/dist/installer/service-host/systemd.d.ts +16 -0
  51. package/dist/installer/service-host/systemd.js +78 -0
  52. package/dist/installer/service-host/systemd.js.map +1 -0
  53. package/dist/installer/service-host/template.d.ts +6 -0
  54. package/dist/installer/service-host/template.js +20 -0
  55. package/dist/installer/service-host/template.js.map +1 -0
  56. package/dist/installer/service-host/types.d.ts +40 -0
  57. package/dist/installer/service-host/types.js +6 -0
  58. package/dist/installer/service-host/types.js.map +1 -0
  59. package/dist/installer/wizard.d.ts +31 -0
  60. package/dist/installer/wizard.js +67 -0
  61. package/dist/installer/wizard.js.map +1 -0
  62. package/dist/nat/address-resolver.d.ts +22 -0
  63. package/dist/nat/address-resolver.js +31 -0
  64. package/dist/nat/address-resolver.js.map +1 -0
  65. package/dist/nat/ddns/duckdns.d.ts +12 -0
  66. package/dist/nat/ddns/duckdns.js +57 -0
  67. package/dist/nat/ddns/duckdns.js.map +1 -0
  68. package/dist/nat/ddns/index.d.ts +30 -0
  69. package/dist/nat/ddns/index.js +21 -0
  70. package/dist/nat/ddns/index.js.map +1 -0
  71. package/dist/nat/ddns/updater.d.ts +68 -0
  72. package/dist/nat/ddns/updater.js +194 -0
  73. package/dist/nat/ddns/updater.js.map +1 -0
  74. package/dist/nat/external-ip.d.ts +62 -0
  75. package/dist/nat/external-ip.js +114 -0
  76. package/dist/nat/external-ip.js.map +1 -0
  77. package/dist/nat/index.d.ts +22 -0
  78. package/dist/nat/index.js +16 -0
  79. package/dist/nat/index.js.map +1 -0
  80. package/dist/nat/nat-service.d.ts +101 -0
  81. package/dist/nat/nat-service.js +284 -0
  82. package/dist/nat/nat-service.js.map +1 -0
  83. package/dist/nat/nat-store.d.ts +19 -0
  84. package/dist/nat/nat-store.js +136 -0
  85. package/dist/nat/nat-store.js.map +1 -0
  86. package/dist/nat/port-mapper.d.ts +98 -0
  87. package/dist/nat/port-mapper.js +226 -0
  88. package/dist/nat/port-mapper.js.map +1 -0
  89. package/dist/nat/reachability.d.ts +19 -0
  90. package/dist/nat/reachability.js +29 -0
  91. package/dist/nat/reachability.js.map +1 -0
  92. package/dist/nat/secrets/file-store.d.ts +23 -0
  93. package/dist/nat/secrets/file-store.js +102 -0
  94. package/dist/nat/secrets/file-store.js.map +1 -0
  95. package/dist/nat/secrets/index.d.ts +36 -0
  96. package/dist/nat/secrets/index.js +56 -0
  97. package/dist/nat/secrets/index.js.map +1 -0
  98. package/dist/nat/secrets/keytar-store.d.ts +18 -0
  99. package/dist/nat/secrets/keytar-store.js +52 -0
  100. package/dist/nat/secrets/keytar-store.js.map +1 -0
  101. package/dist/nat/types.d.ts +120 -0
  102. package/dist/nat/types.js +24 -0
  103. package/dist/nat/types.js.map +1 -0
  104. package/dist/orchestrator/host-process-orchestrator.d.ts +87 -0
  105. package/dist/orchestrator/host-process-orchestrator.js +557 -0
  106. package/dist/orchestrator/host-process-orchestrator.js.map +1 -0
  107. package/dist/orchestrator/index.d.ts +3 -0
  108. package/dist/orchestrator/index.js +2 -0
  109. package/dist/orchestrator/index.js.map +1 -0
  110. package/dist/orchestrator/log-rotator.d.ts +25 -0
  111. package/dist/orchestrator/log-rotator.js +56 -0
  112. package/dist/orchestrator/log-rotator.js.map +1 -0
  113. package/dist/orchestrator/pid-liveness.d.ts +11 -0
  114. package/dist/orchestrator/pid-liveness.js +26 -0
  115. package/dist/orchestrator/pid-liveness.js.map +1 -0
  116. package/dist/orchestrator/port-allocator.d.ts +16 -0
  117. package/dist/orchestrator/port-allocator.js +38 -0
  118. package/dist/orchestrator/port-allocator.js.map +1 -0
  119. package/dist/orchestrator/state-store.d.ts +32 -0
  120. package/dist/orchestrator/state-store.js +44 -0
  121. package/dist/orchestrator/state-store.js.map +1 -0
  122. package/dist/orchestrator/types.d.ts +85 -0
  123. package/dist/orchestrator/types.js +31 -0
  124. package/dist/orchestrator/types.js.map +1 -0
  125. package/dist/server/error-handler.d.ts +26 -0
  126. package/dist/server/error-handler.js +95 -0
  127. package/dist/server/error-handler.js.map +1 -0
  128. package/dist/server/events/bus.d.ts +17 -0
  129. package/dist/server/events/bus.js +36 -0
  130. package/dist/server/events/bus.js.map +1 -0
  131. package/dist/server/events/sse-route.d.ts +22 -0
  132. package/dist/server/events/sse-route.js +65 -0
  133. package/dist/server/events/sse-route.js.map +1 -0
  134. package/dist/server/events/types.d.ts +25 -0
  135. package/dist/server/events/types.js +8 -0
  136. package/dist/server/events/types.js.map +1 -0
  137. package/dist/server/index.d.ts +67 -0
  138. package/dist/server/index.js +149 -0
  139. package/dist/server/index.js.map +1 -0
  140. package/dist/server/origin-guard.d.ts +24 -0
  141. package/dist/server/origin-guard.js +72 -0
  142. package/dist/server/origin-guard.js.map +1 -0
  143. package/dist/server/routes/nat.d.ts +16 -0
  144. package/dist/server/routes/nat.js +42 -0
  145. package/dist/server/routes/nat.js.map +1 -0
  146. package/dist/server/routes/nodes.d.ts +23 -0
  147. package/dist/server/routes/nodes.js +128 -0
  148. package/dist/server/routes/nodes.js.map +1 -0
  149. package/dist/server/routes/settings.d.ts +26 -0
  150. package/dist/server/routes/settings.js +97 -0
  151. package/dist/server/routes/settings.js.map +1 -0
  152. package/dist/server/routes/status.d.ts +42 -0
  153. package/dist/server/routes/status.js +76 -0
  154. package/dist/server/routes/status.js.map +1 -0
  155. package/dist/server/routes/trust-circle.d.ts +17 -0
  156. package/dist/server/routes/trust-circle.js +35 -0
  157. package/dist/server/routes/trust-circle.js.map +1 -0
  158. package/dist/server/routes/update.d.ts +16 -0
  159. package/dist/server/routes/update.js +29 -0
  160. package/dist/server/routes/update.js.map +1 -0
  161. package/dist/server/server.d.ts +40 -0
  162. package/dist/server/server.js +90 -0
  163. package/dist/server/server.js.map +1 -0
  164. package/dist/server/settings-store.d.ts +24 -0
  165. package/dist/server/settings-store.js +27 -0
  166. package/dist/server/settings-store.js.map +1 -0
  167. package/dist/server/static.d.ts +25 -0
  168. package/dist/server/static.js +177 -0
  169. package/dist/server/static.js.map +1 -0
  170. package/dist/ui/assets/index-BwU5mJGF.css +1 -0
  171. package/dist/ui/assets/index-C9uChk2P.js +14 -0
  172. package/dist/ui/assets/index-C9uChk2P.js.map +1 -0
  173. package/dist/ui/index.html +14 -0
  174. package/dist/update/apply.d.ts +37 -0
  175. package/dist/update/apply.js +102 -0
  176. package/dist/update/apply.js.map +1 -0
  177. package/dist/update/index.d.ts +101 -0
  178. package/dist/update/index.js +228 -0
  179. package/dist/update/index.js.map +1 -0
  180. package/dist/update/manifest.d.ts +48 -0
  181. package/dist/update/manifest.js +152 -0
  182. package/dist/update/manifest.js.map +1 -0
  183. package/dist/update/release-key.d.ts +24 -0
  184. package/dist/update/release-key.js +52 -0
  185. package/dist/update/release-key.js.map +1 -0
  186. package/dist/update/store.d.ts +22 -0
  187. package/dist/update/store.js +81 -0
  188. package/dist/update/store.js.map +1 -0
  189. package/dist/update/types.d.ts +90 -0
  190. package/dist/update/types.js +22 -0
  191. package/dist/update/types.js.map +1 -0
  192. package/dist/update/version.d.ts +18 -0
  193. package/dist/update/version.js +76 -0
  194. package/dist/update/version.js.map +1 -0
  195. package/package.json +80 -0
  196. package/service/README.md +86 -0
  197. package/service/cadre-host.service.tmpl +16 -0
  198. package/service/com.serfab.cadre-host.plist.tmpl +35 -0
  199. package/service/install-service.ps1 +98 -0
  200. package/service/uninstall-service.ps1 +19 -0
  201. package/src/__tests__/cli-invite.smoke.test.ts +187 -0
  202. package/src/__tests__/cli-nat.smoke.test.ts +207 -0
  203. package/src/__tests__/cli.smoke.test.ts +41 -0
  204. package/src/__tests__/orchestrator.test.ts +570 -0
  205. package/src/auth/__tests__/duration.test.ts +28 -0
  206. package/src/auth/__tests__/trust-circle-integration.test.ts +128 -0
  207. package/src/auth/__tests__/trust-circle-store.test.ts +115 -0
  208. package/src/auth/__tests__/trust-circle.test.ts +334 -0
  209. package/src/auth/duration.ts +33 -0
  210. package/src/auth/index.ts +19 -0
  211. package/src/auth/trust-circle-store.ts +155 -0
  212. package/src/auth/trust-circle.ts +304 -0
  213. package/src/auth/types.ts +96 -0
  214. package/src/bin/host.ts +905 -0
  215. package/src/index.ts +167 -0
  216. package/src/installer/__tests__/config.test.ts +86 -0
  217. package/src/installer/__tests__/identity.test.ts +34 -0
  218. package/src/installer/__tests__/installer.smoke.test.ts +124 -0
  219. package/src/installer/__tests__/paths.test.ts +47 -0
  220. package/src/installer/__tests__/service-host.test.ts +62 -0
  221. package/src/installer/__tests__/wizard.test.ts +60 -0
  222. package/src/installer/browser.ts +42 -0
  223. package/src/installer/config.ts +153 -0
  224. package/src/installer/identity.ts +49 -0
  225. package/src/installer/index.ts +303 -0
  226. package/src/installer/paths.ts +109 -0
  227. package/src/installer/platform.ts +27 -0
  228. package/src/installer/service-host/index.ts +21 -0
  229. package/src/installer/service-host/launchd.ts +90 -0
  230. package/src/installer/service-host/nssm.ts +100 -0
  231. package/src/installer/service-host/systemd.ts +88 -0
  232. package/src/installer/service-host/template.ts +21 -0
  233. package/src/installer/service-host/types.ts +48 -0
  234. package/src/installer/wizard.ts +92 -0
  235. package/src/nat/__tests__/address-resolver.test.ts +82 -0
  236. package/src/nat/__tests__/ddns-duckdns.test.ts +83 -0
  237. package/src/nat/__tests__/ddns-updater.test.ts +253 -0
  238. package/src/nat/__tests__/external-ip.test.ts +124 -0
  239. package/src/nat/__tests__/nat-service.test.ts +305 -0
  240. package/src/nat/__tests__/nat-store.test.ts +93 -0
  241. package/src/nat/__tests__/port-mapper.test.ts +191 -0
  242. package/src/nat/__tests__/reachability.test.ts +24 -0
  243. package/src/nat/__tests__/secrets.test.ts +156 -0
  244. package/src/nat/address-resolver.ts +51 -0
  245. package/src/nat/ddns/duckdns.ts +76 -0
  246. package/src/nat/ddns/index.ts +55 -0
  247. package/src/nat/ddns/updater.ts +243 -0
  248. package/src/nat/external-ip.ts +154 -0
  249. package/src/nat/index.ts +71 -0
  250. package/src/nat/nat-service.ts +375 -0
  251. package/src/nat/nat-store.ts +154 -0
  252. package/src/nat/port-mapper.ts +329 -0
  253. package/src/nat/reachability.ts +34 -0
  254. package/src/nat/secrets/file-store.ts +130 -0
  255. package/src/nat/secrets/index.ts +82 -0
  256. package/src/nat/secrets/keytar-store.ts +67 -0
  257. package/src/nat/types.ts +157 -0
  258. package/src/orchestrator/host-process-orchestrator.ts +626 -0
  259. package/src/orchestrator/index.ts +3 -0
  260. package/src/orchestrator/log-rotator.ts +53 -0
  261. package/src/orchestrator/pid-liveness.ts +24 -0
  262. package/src/orchestrator/port-allocator.ts +43 -0
  263. package/src/orchestrator/state-store.ts +72 -0
  264. package/src/orchestrator/types.ts +95 -0
  265. package/src/server/__tests__/bus.test.ts +38 -0
  266. package/src/server/__tests__/error-handler.test.ts +96 -0
  267. package/src/server/__tests__/nodes-route.test.ts +179 -0
  268. package/src/server/__tests__/origin-guard.test.ts +72 -0
  269. package/src/server/__tests__/publishers.test.ts +216 -0
  270. package/src/server/__tests__/server.smoke.test.ts +178 -0
  271. package/src/server/__tests__/settings-route.test.ts +177 -0
  272. package/src/server/__tests__/sse-route.test.ts +119 -0
  273. package/src/server/__tests__/status-route.test.ts +113 -0
  274. package/src/server/error-handler.ts +108 -0
  275. package/src/server/events/bus.ts +42 -0
  276. package/src/server/events/sse-route.ts +81 -0
  277. package/src/server/events/types.ts +22 -0
  278. package/src/server/index.ts +209 -0
  279. package/src/server/origin-guard.ts +84 -0
  280. package/src/server/routes/nat.ts +63 -0
  281. package/src/server/routes/nodes.ts +147 -0
  282. package/src/server/routes/settings.ts +115 -0
  283. package/src/server/routes/status.ts +108 -0
  284. package/src/server/routes/trust-circle.ts +49 -0
  285. package/src/server/routes/update.ts +43 -0
  286. package/src/server/server.ts +122 -0
  287. package/src/server/settings-store.ts +37 -0
  288. package/src/server/static.ts +191 -0
  289. package/src/update/__tests__/apply.test.ts +101 -0
  290. package/src/update/__tests__/manifest.test.ts +93 -0
  291. package/src/update/__tests__/store.test.ts +81 -0
  292. package/src/update/__tests__/update-service.integration.test.ts +221 -0
  293. package/src/update/__tests__/version.test.ts +31 -0
  294. package/src/update/apply.ts +148 -0
  295. package/src/update/index.ts +303 -0
  296. package/src/update/manifest.ts +172 -0
  297. package/src/update/release-key.ts +55 -0
  298. package/src/update/store.ts +101 -0
  299. package/src/update/types.ts +109 -0
  300. package/src/update/version.ts +80 -0
@@ -0,0 +1,63 @@
1
+ /**
2
+ * NAT/DDNS HTTP routes — thin adapters around `NatHandlers`
3
+ * (`createNatHandlers(service)`). Side-effects (settings change, reachability
4
+ * re-test, DDNS reconfiguration) emit a `connectivity-changed` bus event.
5
+ *
6
+ * Mount path: `/nat/*` — matches the existing CLI (cadre-host nat ...).
7
+ */
8
+
9
+ import type { FastifyInstance } from 'fastify';
10
+
11
+ import type { NatHandlers, NatSettingsFile, NatStatusSnapshot } from '../../nat/types.js';
12
+ import type { EventBus } from '../events/bus.js';
13
+
14
+ export interface NatRoutesOptions {
15
+ handlers: NatHandlers;
16
+ events: EventBus;
17
+ }
18
+
19
+ export function registerNatRoutes(app: FastifyInstance, opts: NatRoutesOptions): void {
20
+ const { handlers, events } = opts;
21
+
22
+ app.get('/nat/status', async () => handlers.getStatus());
23
+
24
+ app.post('/nat/test', async () => {
25
+ const snap = await handlers.testReachability();
26
+ publishConnectivity(events, snap);
27
+ return snap;
28
+ });
29
+
30
+ app.get('/nat/providers', async () => handlers.listDdnsProviders());
31
+
32
+ app.put('/nat/ddns', async (request) => {
33
+ const body = (request.body ?? {}) as {
34
+ providerId?: string;
35
+ hostname?: string;
36
+ config?: Record<string, string>;
37
+ externallyManaged?: boolean;
38
+ };
39
+ const snap = await handlers.putDdns({
40
+ providerId: body.providerId ?? '',
41
+ hostname: body.hostname ?? '',
42
+ config: body.config ?? {},
43
+ externallyManaged: body.externallyManaged ?? false,
44
+ });
45
+ publishConnectivity(events, snap);
46
+ return snap;
47
+ });
48
+
49
+ app.put('/nat/settings', async (request) => {
50
+ const body = (request.body ?? {}) as Partial<Omit<NatSettingsFile, 'version'>>;
51
+ const snap = await handlers.putSettings(body);
52
+ publishConnectivity(events, snap);
53
+ return snap;
54
+ });
55
+ }
56
+
57
+ export function publishConnectivity(events: EventBus, snap: NatStatusSnapshot): void {
58
+ events.publish({
59
+ type: 'connectivity-changed',
60
+ portMode: snap.portMode,
61
+ directReachability: snap.directReachability,
62
+ });
63
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * /api/nodes — list managed cadre nodes, look up details, and control
3
+ * lifecycle.
4
+ *
5
+ * Bus events for lifecycle transitions are emitted by the orchestrator's
6
+ * `onStateChange` (forwarded by `createLocalUiServer.start`) — the route
7
+ * handlers don't re-publish.
8
+ *
9
+ * v1 limits: cadre-host doesn't yet auto-spawn nodes (the trust-circle →
10
+ * node mapping ticket is separate). Until it lands, `listNodes()` is
11
+ * usually empty and start/restart are stubs. Stop on a running node works.
12
+ */
13
+
14
+ import { existsSync, openSync, readSync, closeSync, statSync } from 'node:fs';
15
+
16
+ import type { FastifyInstance } from 'fastify';
17
+
18
+ import type { HostProcessOrchestrator } from '../../orchestrator/index.js';
19
+ import { defaultLogPath } from '../../orchestrator/log-rotator.js';
20
+
21
+ const DEFAULT_LOG_LINES = 200;
22
+ const MAX_LOG_LINES = 2000;
23
+
24
+ export interface NodesRoutesOptions {
25
+ orchestrator: HostProcessOrchestrator;
26
+ }
27
+
28
+ export function registerNodesRoutes(app: FastifyInstance, opts: NodesRoutesOptions): void {
29
+ const { orchestrator } = opts;
30
+
31
+ app.get('/api/nodes', async () => {
32
+ return { ok: true, data: { nodes: orchestrator.listNodes() } };
33
+ });
34
+
35
+ app.get<{ Params: { id: string } }>('/api/nodes/:id', async (request, reply) => {
36
+ const node = orchestrator.getNode(request.params.id);
37
+ if (!node) {
38
+ return reply.code(404).send({
39
+ ok: false,
40
+ error: { code: 'not_found', message: `Unknown node: ${request.params.id}` },
41
+ });
42
+ }
43
+ let stats = null;
44
+ try {
45
+ stats = await orchestrator.getStats(node.dockerId);
46
+ } catch {
47
+ // Stats unavailable (dead process etc.) — surface null.
48
+ }
49
+ return { ok: true, data: { node, stats } };
50
+ });
51
+
52
+ app.get<{ Params: { id: string }; Querystring: { lines?: string } }>(
53
+ '/api/nodes/:id/logs',
54
+ async (request, reply) => {
55
+ const node = orchestrator.getNode(request.params.id);
56
+ if (!node) {
57
+ return reply.code(404).send({
58
+ ok: false,
59
+ error: { code: 'not_found', message: `Unknown node: ${request.params.id}` },
60
+ });
61
+ }
62
+ const requested = Number(request.query.lines ?? DEFAULT_LOG_LINES);
63
+ const tail = Number.isFinite(requested)
64
+ ? Math.max(1, Math.min(MAX_LOG_LINES, Math.floor(requested)))
65
+ : DEFAULT_LOG_LINES;
66
+ const lines = tailLogFile(defaultLogPath(node.workdir), tail);
67
+ return { ok: true, data: { lines } };
68
+ },
69
+ );
70
+
71
+ app.post<{ Params: { id: string } }>('/api/nodes/:id/stop', async (request, reply) => {
72
+ const dockerId = orchestrator.resolveDockerId(request.params.id);
73
+ if (!dockerId) {
74
+ return reply.code(404).send({
75
+ ok: false,
76
+ error: { code: 'not_found', message: `Unknown node: ${request.params.id}` },
77
+ });
78
+ }
79
+ await orchestrator.stopContainer(dockerId);
80
+ // The orchestrator emits its own onStateChange — createLocalUiServer
81
+ // forwards that to the bus. Don't double-publish here.
82
+ return { ok: true };
83
+ });
84
+
85
+ // start / restart: the orchestrator's createContainer requires a full
86
+ // OrchestratorCreateRequest (partyId, bootstrapNodes, profile, ...).
87
+ // cadre-host doesn't yet own a "spawn this node from saved config" path
88
+ // — that lives in the trust-circle-driven node spawner planned for a
89
+ // follow-up ticket. Until then these endpoints stub out cleanly so the
90
+ // SPA gets a predictable error rather than a route-not-found 404.
91
+ app.post<{ Params: { id: string } }>('/api/nodes/:id/start', async (request, reply) => {
92
+ return reply.code(501).send({
93
+ ok: false,
94
+ error: {
95
+ code: 'not_implemented',
96
+ message:
97
+ `start ${request.params.id}: cadre-host v1 has no auto-spawn path. ` +
98
+ `Restart the cadre-host service to bring up persisted nodes.`,
99
+ },
100
+ });
101
+ });
102
+
103
+ app.post<{ Params: { id: string } }>('/api/nodes/:id/restart', async (request, reply) => {
104
+ return reply.code(501).send({
105
+ ok: false,
106
+ error: {
107
+ code: 'not_implemented',
108
+ message:
109
+ `restart ${request.params.id}: cadre-host v1 has no auto-spawn path. ` +
110
+ `Restart the cadre-host service to bring up persisted nodes.`,
111
+ },
112
+ });
113
+ });
114
+ }
115
+
116
+ /**
117
+ * Read the last `n` lines from a log file by walking the tail in chunks.
118
+ * Returns [] for a missing file.
119
+ */
120
+ export function tailLogFile(path: string, n: number): string[] {
121
+ if (!existsSync(path)) return [];
122
+ const fd = openSync(path, 'r');
123
+ try {
124
+ const size = statSync(path).size;
125
+ const chunkSize = 8192;
126
+ let pos = size;
127
+ const buffers: Buffer[] = [];
128
+ let newlineCount = 0;
129
+ while (pos > 0 && newlineCount <= n) {
130
+ const readLen = Math.min(chunkSize, pos);
131
+ pos -= readLen;
132
+ const buf = Buffer.alloc(readLen);
133
+ readSync(fd, buf, 0, readLen, pos);
134
+ buffers.unshift(buf);
135
+ for (let i = 0; i < buf.length; i++) {
136
+ if (buf[i] === 0x0a) newlineCount++;
137
+ }
138
+ }
139
+ const joined = Buffer.concat(buffers).toString('utf8');
140
+ const lines = joined.split('\n');
141
+ const trimmed = lines[lines.length - 1] === '' ? lines.slice(0, -1) : lines;
142
+ return trimmed.slice(-n);
143
+ } finally {
144
+ closeSync(fd);
145
+ }
146
+ }
147
+
@@ -0,0 +1,115 @@
1
+ /**
2
+ * /api/settings — host-level settings UI surface.
3
+ *
4
+ * The persisted shape is `host.config.json` (managed by the installer).
5
+ * This route is a thin passthrough plus a write-whitelist:
6
+ *
7
+ * - uiPort rejected (restart required; only edit at install time)
8
+ * - libp2pPort rejected (restart required)
9
+ * - upnpEnabled accepted → also propagated to NatService.putSettings
10
+ * - updates.autoApply accepted → propagated to UpdateService.putSettings
11
+ * - updates.manifestUrl accepted → propagated to UpdateService.putSettings
12
+ * - anything else 400 invalid_setting
13
+ *
14
+ * GET returns the full `HostConfigFile` (the SPA wants the full shape so it
15
+ * can render a read-only view of the rejected keys).
16
+ */
17
+
18
+ import type { FastifyInstance } from 'fastify';
19
+
20
+ import type { NatService } from '../../nat/index.js';
21
+ import type { UpdateService } from '../../update/index.js';
22
+ import type { HostSettingsStore } from '../settings-store.js';
23
+
24
+ const FORBIDDEN_KEYS = new Set<string>(['uiPort', 'libp2pPort', 'dataDir', 'identityPath', 'installId', 'installedAt', 'installerVersion', 'version']);
25
+
26
+ const WRITABLE_TOP_KEYS = new Set<string>(['upnpEnabled', 'updates']);
27
+ const WRITABLE_UPDATES_KEYS = new Set<string>(['autoApply', 'manifestUrl']);
28
+
29
+ export interface SettingsRoutesOptions {
30
+ settingsStore: HostSettingsStore;
31
+ nat: NatService;
32
+ update?: UpdateService;
33
+ }
34
+
35
+ export function registerSettingsRoutes(app: FastifyInstance, opts: SettingsRoutesOptions): void {
36
+ const { settingsStore, nat, update } = opts;
37
+
38
+ app.get('/api/settings', async () => {
39
+ return { ok: true, data: settingsStore.read() };
40
+ });
41
+
42
+ app.put('/api/settings', async (request, reply) => {
43
+ const body = (request.body ?? {}) as Record<string, unknown>;
44
+ const validation = validatePatch(body);
45
+ if (validation.error) {
46
+ return reply.code(400).send({
47
+ ok: false,
48
+ error: { code: 'invalid_setting', message: validation.error },
49
+ });
50
+ }
51
+
52
+ const current = settingsStore.read();
53
+ const patch: { upnpEnabled?: boolean; updates?: typeof current.updates } = {};
54
+
55
+ if ('upnpEnabled' in body) {
56
+ patch.upnpEnabled = body.upnpEnabled as boolean;
57
+ // Propagate UPnP toggle into the running NatService so the change
58
+ // takes effect immediately. NatService persists into nat.json — that's
59
+ // independent of host.config.json's `upnpEnabled` (which the installer
60
+ // uses on first run to seed nat.json). Update both for consistency.
61
+ await nat.putSettings({ upnpEnabled: body.upnpEnabled as boolean });
62
+ }
63
+ if ('updates' in body) {
64
+ const updates = body.updates as { autoApply?: boolean; manifestUrl?: string };
65
+ const nextUpdates = { ...current.updates };
66
+ if ('autoApply' in updates) nextUpdates.autoApply = updates.autoApply as boolean;
67
+ if ('manifestUrl' in updates) nextUpdates.manifestUrl = updates.manifestUrl as string;
68
+ patch.updates = nextUpdates;
69
+ if (update) {
70
+ const propagate: { autoApply?: boolean; manifestUrl?: string } = {};
71
+ if ('autoApply' in updates) propagate.autoApply = updates.autoApply as boolean;
72
+ if ('manifestUrl' in updates) propagate.manifestUrl = updates.manifestUrl as string;
73
+ await update.putSettings(propagate);
74
+ }
75
+ }
76
+
77
+ const next = settingsStore.update(patch);
78
+ return { ok: true, data: next };
79
+ });
80
+ }
81
+
82
+ function validatePatch(body: Record<string, unknown>): { error?: string } {
83
+ for (const key of Object.keys(body)) {
84
+ if (FORBIDDEN_KEYS.has(key)) {
85
+ return { error: `Setting "${key}" is not editable at runtime; change it in host.config.json and restart cadre-host.` };
86
+ }
87
+ if (!WRITABLE_TOP_KEYS.has(key)) {
88
+ return { error: `Unknown setting "${key}"` };
89
+ }
90
+ }
91
+ if ('upnpEnabled' in body && typeof body.upnpEnabled !== 'boolean') {
92
+ return { error: '"upnpEnabled" must be a boolean' };
93
+ }
94
+ if ('updates' in body) {
95
+ if (!body.updates || typeof body.updates !== 'object') {
96
+ return { error: '"updates" must be an object' };
97
+ }
98
+ const updates = body.updates as Record<string, unknown>;
99
+ for (const key of Object.keys(updates)) {
100
+ if (!WRITABLE_UPDATES_KEYS.has(key)) {
101
+ return { error: `Unknown setting "updates.${key}"` };
102
+ }
103
+ }
104
+ if ('autoApply' in updates && typeof updates.autoApply !== 'boolean') {
105
+ return { error: '"updates.autoApply" must be a boolean' };
106
+ }
107
+ if (
108
+ 'manifestUrl' in updates &&
109
+ typeof updates.manifestUrl !== 'string'
110
+ ) {
111
+ return { error: '"updates.manifestUrl" must be a string' };
112
+ }
113
+ }
114
+ return {};
115
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * GET /api/status — aggregated health snapshot used by the SPA's dashboard.
3
+ *
4
+ * All inputs are in-memory subsystem state — no disk reads on each call.
5
+ * The response budget is < 5 ms; favour `getStatus()` over `testReachability()`.
6
+ */
7
+
8
+ import { readFileSync } from 'node:fs';
9
+ import { dirname, resolve } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ import type { FastifyInstance } from 'fastify';
13
+
14
+ import type { HostProcessOrchestrator } from '../../orchestrator/index.js';
15
+ import type { ManagedNodeInfo } from '../../orchestrator/types.js';
16
+ import type { TrustCircleService } from '../../auth/index.js';
17
+ import type { NatService } from '../../nat/index.js';
18
+ import type { NatStatusSnapshot } from '../../nat/types.js';
19
+ import type { UpdateService } from '../../update/index.js';
20
+
21
+ const PROCESS_STARTED_AT = process.hrtime.bigint();
22
+
23
+ export interface StatusRouteOptions {
24
+ orchestrator: HostProcessOrchestrator;
25
+ trustCircle: TrustCircleService;
26
+ nat: NatService;
27
+ update?: UpdateService;
28
+ }
29
+
30
+ export interface StatusResponse {
31
+ service: { name: 'cadre-host'; version: string; uptimeSeconds: number };
32
+ nodes: Array<{
33
+ id: string;
34
+ partyId: string;
35
+ status: ManagedNodeInfo['status'];
36
+ profile: ManagedNodeInfo['profile'];
37
+ }>;
38
+ trustCircle: { members: number; pending: number };
39
+ connectivity: NatStatusSnapshot;
40
+ update?: { available?: string; lastChecked?: string };
41
+ }
42
+
43
+ export function registerStatusRoute(app: FastifyInstance, opts: StatusRouteOptions): void {
44
+ const version = readPackageVersion();
45
+
46
+ app.get('/api/status', async (): Promise<StatusResponse> => {
47
+ const nodes = opts.orchestrator.listNodes().map((n) => ({
48
+ id: n.id,
49
+ partyId: n.partyId,
50
+ status: n.status,
51
+ profile: n.profile,
52
+ }));
53
+ const tc = await opts.trustCircle.list();
54
+ const connectivity = opts.nat.getStatus();
55
+
56
+ const response: StatusResponse = {
57
+ service: {
58
+ name: 'cadre-host',
59
+ version,
60
+ uptimeSeconds: secondsSince(PROCESS_STARTED_AT),
61
+ },
62
+ nodes,
63
+ trustCircle: { members: tc.members.length, pending: tc.pending.length },
64
+ connectivity,
65
+ };
66
+
67
+ if (opts.update) {
68
+ const state = await opts.update.getState();
69
+ const update: { available?: string; lastChecked?: string } = {};
70
+ if (state.available?.version) update.available = state.available.version;
71
+ if (state.lastChecked) update.lastChecked = state.lastChecked;
72
+ response.update = update;
73
+ }
74
+
75
+ return response;
76
+ });
77
+ }
78
+
79
+ function secondsSince(start: bigint): number {
80
+ const now = process.hrtime.bigint();
81
+ const elapsedNs = now - start;
82
+ return Number(elapsedNs / 1_000_000_000n);
83
+ }
84
+
85
+ let cachedVersion: string | null = null;
86
+ function readPackageVersion(): string {
87
+ if (cachedVersion) return cachedVersion;
88
+ try {
89
+ // dist/server/routes/status.js -> ../../../package.json
90
+ // src/server/routes/status.ts -> ../../../package.json (vitest)
91
+ const here = dirname(fileURLToPath(import.meta.url));
92
+ const candidates = [
93
+ resolve(here, '..', '..', '..', 'package.json'),
94
+ resolve(here, '..', '..', 'package.json'),
95
+ ];
96
+ for (const p of candidates) {
97
+ try {
98
+ const parsed = JSON.parse(readFileSync(p, 'utf8')) as { version?: string };
99
+ if (parsed.version) {
100
+ cachedVersion = parsed.version;
101
+ return cachedVersion;
102
+ }
103
+ } catch { /* try next */ }
104
+ }
105
+ } catch { /* ignore */ }
106
+ cachedVersion = '0.0.0-unknown';
107
+ return cachedVersion;
108
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Trust-circle HTTP routes — thin adapters around `TrustCircleHandlers`
3
+ * (`createTrustCircleHandlers(service)`). Side-effects (invite issued,
4
+ * pending revoked, member removed) emit a `trust-circle-changed` bus
5
+ * event so the SPA can refresh without polling.
6
+ *
7
+ * Mount path: `/auth/*` — the existing CLI (cadre-host invite / trust)
8
+ * already targets these URLs; do not change them.
9
+ */
10
+
11
+ import type { FastifyInstance } from 'fastify';
12
+
13
+ import type { TrustCircleHandlers } from '../../auth/types.js';
14
+ import type { EventBus } from '../events/bus.js';
15
+
16
+ export interface TrustCircleRoutesOptions {
17
+ handlers: TrustCircleHandlers;
18
+ events: EventBus;
19
+ }
20
+
21
+ export function registerTrustCircleRoutes(app: FastifyInstance, opts: TrustCircleRoutesOptions): void {
22
+ const { handlers, events } = opts;
23
+
24
+ app.get('/auth/trust-circle', async () => {
25
+ return handlers.listTrustCircle();
26
+ });
27
+
28
+ app.post('/auth/invites', async (request) => {
29
+ const body = (request.body ?? {}) as { label?: unknown; ttlMs?: unknown };
30
+ const label = typeof body.label === 'string' ? body.label : '';
31
+ const ttlMs = typeof body.ttlMs === 'number' ? body.ttlMs : undefined;
32
+ const args = ttlMs !== undefined ? { label, ttlMs } : { label };
33
+ const result = await handlers.postInvite(args);
34
+ events.publish({ type: 'trust-circle-changed', kind: 'invited' });
35
+ return result;
36
+ });
37
+
38
+ app.delete<{ Params: { token: string } }>('/auth/invites/:token', async (request) => {
39
+ await handlers.deleteInvite(request.params.token);
40
+ events.publish({ type: 'trust-circle-changed', kind: 'revoked' });
41
+ return { ok: true };
42
+ });
43
+
44
+ app.delete<{ Params: { peerId: string } }>('/auth/members/:peerId', async (request) => {
45
+ await handlers.deleteMember(request.params.peerId);
46
+ events.publish({ type: 'trust-circle-changed', kind: 'revoked' });
47
+ return { ok: true };
48
+ });
49
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Update-flow HTTP routes — thin adapters around `UpdateHandlers`
3
+ * (`createUpdateHandlers(service)`). The /update/apply route publishes
4
+ * `update-available` for the post-apply state when the new version
5
+ * differs from the running one.
6
+ *
7
+ * Mount path: `/update/*` (matches docs/cadre-host.md).
8
+ */
9
+
10
+ import type { FastifyInstance } from 'fastify';
11
+
12
+ import type { UpdateHandlers } from '../../update/index.js';
13
+ import type { EventBus } from '../events/bus.js';
14
+
15
+ export interface UpdateRoutesOptions {
16
+ handlers: UpdateHandlers;
17
+ events: EventBus;
18
+ }
19
+
20
+ export function registerUpdateRoutes(app: FastifyInstance, opts: UpdateRoutesOptions): void {
21
+ const { handlers, events } = opts;
22
+
23
+ app.get('/update', async () => handlers.getState());
24
+
25
+ app.post('/update/apply', async () => {
26
+ const result = await handlers.postApply();
27
+ // Apply succeeded — the still-running binary is the *old* one; the new
28
+ // one becomes live after the service-host restarts. Surface the version
29
+ // we just applied so the UI can prompt the user to refresh.
30
+ events.publish({
31
+ type: 'update-available',
32
+ version: result.toVersion,
33
+ });
34
+ return result;
35
+ });
36
+
37
+ app.get('/update/settings', async () => handlers.getSettings());
38
+
39
+ app.put('/update/settings', async (request) => {
40
+ const body = (request.body ?? {}) as { autoApply?: boolean; manifestUrl?: string };
41
+ return handlers.putSettings(body);
42
+ });
43
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Fastify lifecycle for the cadre-host local UI.
3
+ *
4
+ * Bound to `127.0.0.1:<uiPort>` only — never `0.0.0.0`. On `EADDRINUSE`
5
+ * tries the configured port and then nine consecutive ports. On total
6
+ * failure the start() promise rejects with a clear message naming every
7
+ * port that was attempted.
8
+ *
9
+ * Shutdown drains with a 5 s timeout, mirroring cadre-provider's pattern.
10
+ */
11
+
12
+ import Fastify, { type FastifyInstance } from 'fastify';
13
+ import debug from 'debug';
14
+
15
+ const log = debug('cadre:host:server');
16
+
17
+ /** Hard cap on Fastify's drain phase during shutdown. */
18
+ const SHUTDOWN_DRAIN_TIMEOUT_MS = 5000;
19
+ /** Inclusive count of ports to try (config port + 9 alternates). */
20
+ const PORT_FALLBACK_TRIES = 10;
21
+ /** Loopback interface; never serve from 0.0.0.0. */
22
+ const LOOPBACK_HOST = '127.0.0.1';
23
+
24
+ export interface StartServerOptions {
25
+ /** Preferred port. The server tries this then the next 9 on EADDRINUSE. */
26
+ preferredPort: number;
27
+ /**
28
+ * Test override: when 0, lets the OS pick any free port (skips the
29
+ * fallback loop). The returned `port` reflects the actual bound port.
30
+ */
31
+ forcePort?: 0;
32
+ }
33
+
34
+ export interface StartedServer {
35
+ port: number;
36
+ url: string;
37
+ }
38
+
39
+ export interface ShutdownOptions {
40
+ /** Override the 5 s drain timeout (tests). */
41
+ drainTimeoutMs?: number;
42
+ }
43
+
44
+ /**
45
+ * Bind `app` to the loopback interface with port-fallback. Returns the
46
+ * actually-bound port — the configured port may be in use.
47
+ */
48
+ export async function startListening(
49
+ app: FastifyInstance,
50
+ opts: StartServerOptions,
51
+ ): Promise<StartedServer> {
52
+ if (opts.forcePort === 0) {
53
+ const address = await app.listen({ host: LOOPBACK_HOST, port: 0 });
54
+ const port = parsePort(address);
55
+ return { port, url: `http://${LOOPBACK_HOST}:${port}` };
56
+ }
57
+
58
+ const tried: number[] = [];
59
+ let lastErr: Error | null = null;
60
+ for (let i = 0; i < PORT_FALLBACK_TRIES; i++) {
61
+ const port = opts.preferredPort + i;
62
+ tried.push(port);
63
+ try {
64
+ const address = await app.listen({ host: LOOPBACK_HOST, port });
65
+ const bound = parsePort(address);
66
+ if (i > 0) {
67
+ log('port %d in use; bound on fallback port %d', opts.preferredPort, bound);
68
+ }
69
+ return { port: bound, url: `http://${LOOPBACK_HOST}:${bound}` };
70
+ } catch (err) {
71
+ const code = (err as NodeJS.ErrnoException).code;
72
+ if (code !== 'EADDRINUSE') {
73
+ throw err;
74
+ }
75
+ lastErr = err as Error;
76
+ log('port %d in use, trying next', port);
77
+ }
78
+ }
79
+ throw new Error(
80
+ `cadre-host: all ${PORT_FALLBACK_TRIES} candidate ports were in use ` +
81
+ `(${tried.join(', ')}). Reconfigure uiPort in host.config.json. ` +
82
+ `Last error: ${lastErr?.message ?? 'unknown'}`,
83
+ );
84
+ }
85
+
86
+ /**
87
+ * Close the server with a hard 5 s drain timeout. Idempotent — safe to call
88
+ * from a SIGINT/SIGTERM handler that may fire twice.
89
+ */
90
+ export async function stopListening(
91
+ app: FastifyInstance,
92
+ opts: ShutdownOptions = {},
93
+ ): Promise<void> {
94
+ const drainMs = opts.drainTimeoutMs ?? SHUTDOWN_DRAIN_TIMEOUT_MS;
95
+ let timer: ReturnType<typeof setTimeout> | undefined;
96
+ const drain = app.close();
97
+ const timeout = new Promise<'timeout'>((resolve) => {
98
+ timer = setTimeout(() => resolve('timeout'), drainMs);
99
+ });
100
+ const outcome = await Promise.race([drain.then(() => 'drained' as const), timeout]);
101
+ if (timer) clearTimeout(timer);
102
+ if (outcome === 'timeout') {
103
+ log('shutdown drain timeout after %dms', drainMs);
104
+ }
105
+ }
106
+
107
+ /** Create a bare Fastify instance with the project's logging convention. */
108
+ export function buildFastify(): FastifyInstance {
109
+ return Fastify({
110
+ // Stay quiet by default — request-level logs are noisy on a local UI.
111
+ // Operators can opt in via the `debug` env var (cadre:host:*).
112
+ logger: false,
113
+ disableRequestLogging: true,
114
+ });
115
+ }
116
+
117
+ function parsePort(address: string): number {
118
+ // address is e.g. http://127.0.0.1:8765
119
+ const m = address.match(/:(\d+)\/?$/);
120
+ if (!m) throw new Error(`cadre-host: unable to parse port from listen address: ${address}`);
121
+ return Number(m[1]);
122
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Settings store — read-write facade around `host.config.json` for the
3
+ * /api/settings route.
4
+ *
5
+ * The installer (`6.4.1`) owns the schema (`HostConfigFile` v2). This module
6
+ * just delegates load/persist to that file's helpers; it exists so route
7
+ * handlers can read/write without each one re-resolving `configPath(dataDir)`.
8
+ *
9
+ * PUT semantics live in routes/settings.ts; this class is purely an I/O
10
+ * boundary so tests can swap it for an in-memory fake.
11
+ */
12
+
13
+ import { configPath } from '../installer/paths.js';
14
+ import { readHostConfig, updateHostConfig, type HostConfigFile } from '../installer/config.js';
15
+
16
+ export interface HostSettingsStoreOptions {
17
+ /** Resolved cadre-host data dir. */
18
+ dataDir: string;
19
+ }
20
+
21
+ export class HostSettingsStore {
22
+ private readonly configPath: string;
23
+
24
+ constructor(opts: HostSettingsStoreOptions) {
25
+ this.configPath = configPath(opts.dataDir);
26
+ }
27
+
28
+ /** Read the current host.config.json (runs v1→v2 migration on first read). */
29
+ read(): HostConfigFile {
30
+ return readHostConfig(this.configPath);
31
+ }
32
+
33
+ /** Patch and persist. */
34
+ update(patch: Partial<Omit<HostConfigFile, 'version'>>): HostConfigFile {
35
+ return updateHostConfig(this.configPath, patch);
36
+ }
37
+ }