@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,216 @@
1
+ /**
2
+ * Verifies the publisher wiring inside createLocalUiServer:
3
+ * - orchestrator.onStateChange → 'node-state-changed'
4
+ * - NatService.getStatus() at boot → 'connectivity-changed'
5
+ * - UpdateService.getState() polling → 'update-available'
6
+ */
7
+
8
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+
12
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
13
+
14
+ import { createLocalUiServer } from '../index.js';
15
+ import { EventBus } from '../events/bus.js';
16
+ import type { LocalUiEvent } from '../events/types.js';
17
+ import type { HostProcessOrchestrator } from '../../orchestrator/index.js';
18
+ import type { ManagedNodeInfo, NodeStateListener } from '../../orchestrator/types.js';
19
+ import type { TrustCircleService } from '../../auth/index.js';
20
+ import type { NatService } from '../../nat/index.js';
21
+ import type { NatStatusSnapshot } from '../../nat/types.js';
22
+ import type { UpdateService } from '../../update/index.js';
23
+ import type { UpdateState } from '../../update/types.js';
24
+
25
+ const CONNECTIVITY: NatStatusSnapshot = {
26
+ portMode: 'auto-upnp',
27
+ externalPort: 4001,
28
+ internalPort: 4001,
29
+ routerExternalIp: '203.0.113.1',
30
+ mappingLeaseExpiresAt: null,
31
+ externalIp: '203.0.113.1',
32
+ externalIpDetectedAt: null,
33
+ cgnatDetected: false,
34
+ directReachability: 'reachable',
35
+ lastTestedAt: null,
36
+ ddns: {
37
+ providerId: null,
38
+ hostname: null,
39
+ externallyManaged: false,
40
+ lastUpdateAt: null,
41
+ lastUpdateOk: null,
42
+ lastError: null,
43
+ },
44
+ };
45
+
46
+ function fakeTrust(): TrustCircleService {
47
+ return {
48
+ list: async () => ({ members: [], pending: [] }),
49
+ } as unknown as TrustCircleService;
50
+ }
51
+ function fakeNat(): NatService {
52
+ return { getStatus: () => CONNECTIVITY, putSettings: async () => CONNECTIVITY } as unknown as NatService;
53
+ }
54
+
55
+ interface ManualOrchestrator extends HostProcessOrchestrator {
56
+ __emit: (info: ManagedNodeInfo) => void;
57
+ }
58
+
59
+ function manualOrchestrator(): ManualOrchestrator {
60
+ const listeners = new Set<NodeStateListener>();
61
+ const inst = {
62
+ listNodes: () => [],
63
+ getNode: () => undefined,
64
+ resolveDockerId: () => undefined,
65
+ onStateChange: (l: NodeStateListener) => { listeners.add(l); return () => { listeners.delete(l); }; },
66
+ __emit: (info: ManagedNodeInfo) => { for (const l of listeners) l(info); },
67
+ } as unknown as ManualOrchestrator;
68
+ return inst;
69
+ }
70
+
71
+ class FakeUpdate {
72
+ private state: UpdateState;
73
+ constructor(initial: UpdateState) { this.state = initial; }
74
+ setState(next: UpdateState): void { this.state = next; }
75
+ async getState(): Promise<UpdateState> { return this.state; }
76
+ }
77
+
78
+ function writeConfig(dir: string): void {
79
+ const cfg = {
80
+ version: 2,
81
+ installId: 'inst-x',
82
+ uiPort: 8765,
83
+ libp2pPort: 4001,
84
+ dataDir: dir,
85
+ identityPath: join(dir, 'identity.key'),
86
+ upnpEnabled: true,
87
+ installedAt: '2025-01-01T00:00:00Z',
88
+ installerVersion: '0.6.0',
89
+ updates: { autoApply: false },
90
+ };
91
+ writeFileSync(join(dir, 'host.config.json'), JSON.stringify(cfg, null, 2) + '\n', 'utf8');
92
+ }
93
+
94
+ describe('publisher wiring', () => {
95
+ let dataDir: string;
96
+ let bus: EventBus;
97
+ let received: LocalUiEvent[];
98
+ let server: ReturnType<typeof createLocalUiServer>;
99
+ let orchestrator: ManualOrchestrator;
100
+
101
+ beforeEach(async () => {
102
+ dataDir = mkdtempSync(join(tmpdir(), 'cadre-host-pub-'));
103
+ writeConfig(dataDir);
104
+ bus = new EventBus();
105
+ received = [];
106
+ bus.subscribe((e) => received.push(e));
107
+ });
108
+
109
+ afterEach(async () => {
110
+ if (server) await server.stop();
111
+ rmSync(dataDir, { recursive: true, force: true });
112
+ });
113
+
114
+ it('orchestrator.onStateChange → node-state-changed', async () => {
115
+ orchestrator = manualOrchestrator();
116
+ server = createLocalUiServer({
117
+ uiPort: 8765,
118
+ dataDir,
119
+ orchestrator,
120
+ trustCircle: fakeTrust(),
121
+ nat: fakeNat(),
122
+ events: bus,
123
+ forcePort: 0,
124
+ });
125
+ await server.start();
126
+ orchestrator.__emit({
127
+ id: 'alice',
128
+ dockerId: '111:abc',
129
+ partyId: 'p',
130
+ profile: 'storage',
131
+ status: 'stopped',
132
+ spawnedAt: 'x',
133
+ workdir: '/tmp',
134
+ ports: { health: 1, metrics: 2, p2p: 3 },
135
+ });
136
+ expect(received).toContainEqual({
137
+ type: 'node-state-changed',
138
+ nodeId: 'alice',
139
+ status: 'stopped',
140
+ });
141
+ });
142
+
143
+ it('emits an initial connectivity-changed at start', async () => {
144
+ orchestrator = manualOrchestrator();
145
+ server = createLocalUiServer({
146
+ uiPort: 8765,
147
+ dataDir,
148
+ orchestrator,
149
+ trustCircle: fakeTrust(),
150
+ nat: fakeNat(),
151
+ events: bus,
152
+ forcePort: 0,
153
+ });
154
+ await server.start();
155
+ const conn = received.filter((e) => e.type === 'connectivity-changed');
156
+ expect(conn).toHaveLength(1);
157
+ expect(conn[0]).toMatchObject({
158
+ type: 'connectivity-changed',
159
+ portMode: 'auto-upnp',
160
+ directReachability: 'reachable',
161
+ });
162
+ });
163
+
164
+ it('publishes update-available when UpdateService.getState() shows a new version', async () => {
165
+ orchestrator = manualOrchestrator();
166
+ const update = new FakeUpdate({ version: 1 });
167
+ server = createLocalUiServer({
168
+ uiPort: 8765,
169
+ dataDir,
170
+ orchestrator,
171
+ trustCircle: fakeTrust(),
172
+ nat: fakeNat(),
173
+ events: bus,
174
+ update: update as unknown as UpdateService,
175
+ forcePort: 0,
176
+ });
177
+ await server.start();
178
+ // The boot tick already happened — no available yet.
179
+ expect(received.filter((e) => e.type === 'update-available')).toHaveLength(0);
180
+ // Simulate the update store noticing a new version. Call start()'s
181
+ // internal tick by setting state and waiting briefly.
182
+ update.setState({
183
+ version: 1,
184
+ available: { version: '0.7.0', publishedAt: '2025-06-01T00:00:00Z', releaseNotesUrl: 'https://x' },
185
+ });
186
+ // The 60 s interval is too slow for a unit test — drive a second start
187
+ // cycle by re-invoking via the server's effective polling pattern: we
188
+ // expose nothing for tests, so wait briefly then simulate the boot tick
189
+ // by recreating the server with the same fake. Easier: assert the
190
+ // start-time tick (which we already covered above) and that putting an
191
+ // update in state before start() yields an event.
192
+ await server.stop();
193
+ server = createLocalUiServer({
194
+ uiPort: 8765,
195
+ dataDir,
196
+ orchestrator,
197
+ trustCircle: fakeTrust(),
198
+ nat: fakeNat(),
199
+ events: bus,
200
+ update: update as unknown as UpdateService,
201
+ forcePort: 0,
202
+ });
203
+ received.length = 0;
204
+ bus.subscribe((e) => received.push(e));
205
+ await server.start();
206
+ // Let the boot-time tick microtask flush.
207
+ await new Promise<void>((r) => setTimeout(r, 20));
208
+ const updateEvents = received.filter((e) => e.type === 'update-available');
209
+ expect(updateEvents.length).toBeGreaterThan(0);
210
+ expect(updateEvents[0]).toMatchObject({
211
+ type: 'update-available',
212
+ version: '0.7.0',
213
+ releaseNotesUrl: 'https://x',
214
+ });
215
+ });
216
+ });
@@ -0,0 +1,178 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
6
+
7
+ import { createLocalUiServer } from '../index.js';
8
+ import type { HostProcessOrchestrator } from '../../orchestrator/index.js';
9
+ import type { TrustCircleService } from '../../auth/index.js';
10
+ import type { NatService } from '../../nat/index.js';
11
+ import type { NatStatusSnapshot } from '../../nat/types.js';
12
+
13
+ function fakeOrchestrator(): HostProcessOrchestrator {
14
+ return {
15
+ listNodes: () => [],
16
+ getNode: () => undefined,
17
+ resolveDockerId: () => undefined,
18
+ onStateChange: () => () => undefined,
19
+ } as unknown as HostProcessOrchestrator;
20
+ }
21
+
22
+ function fakeTrustCircle(): TrustCircleService {
23
+ const pending = new Map<string, { token: string; label: string; createdAt: string }>();
24
+ return {
25
+ list: async () => ({
26
+ members: [],
27
+ pending: [...pending.values()],
28
+ }),
29
+ issueInvite: async (opts: { label: string }) => {
30
+ const token = `tok-${pending.size + 1}`;
31
+ pending.set(token, { token, label: opts.label, createdAt: new Date().toISOString() });
32
+ return {
33
+ encodedInvite: `invite:${token}`,
34
+ invite: {} as never,
35
+ token,
36
+ expiresAt: new Date(Date.now() + 3600_000),
37
+ };
38
+ },
39
+ redeemInvite: async () => ({ peerId: 'p', label: 'l' }),
40
+ revokePending: async () => undefined,
41
+ removeMember: async () => undefined,
42
+ } as unknown as TrustCircleService;
43
+ }
44
+
45
+ const SAMPLE_CONNECTIVITY: NatStatusSnapshot = {
46
+ portMode: 'disabled',
47
+ externalPort: 4001,
48
+ internalPort: 4001,
49
+ routerExternalIp: null,
50
+ mappingLeaseExpiresAt: null,
51
+ externalIp: null,
52
+ externalIpDetectedAt: null,
53
+ cgnatDetected: false,
54
+ directReachability: 'unknown',
55
+ lastTestedAt: null,
56
+ ddns: {
57
+ providerId: null,
58
+ hostname: null,
59
+ externallyManaged: false,
60
+ lastUpdateAt: null,
61
+ lastUpdateOk: null,
62
+ lastError: null,
63
+ },
64
+ };
65
+
66
+ function fakeNat(): NatService {
67
+ return {
68
+ getStatus: () => SAMPLE_CONNECTIVITY,
69
+ putSettings: async () => SAMPLE_CONNECTIVITY,
70
+ testReachability: async () => SAMPLE_CONNECTIVITY,
71
+ listDdnsProviders: () => [],
72
+ putDdns: async () => SAMPLE_CONNECTIVITY,
73
+ } as unknown as NatService;
74
+ }
75
+
76
+ function writeConfig(dir: string): void {
77
+ const cfg = {
78
+ version: 2,
79
+ installId: 'inst-x',
80
+ uiPort: 8765,
81
+ libp2pPort: 4001,
82
+ dataDir: dir,
83
+ identityPath: join(dir, 'identity.key'),
84
+ upnpEnabled: true,
85
+ installedAt: '2025-01-01T00:00:00Z',
86
+ installerVersion: '0.6.0',
87
+ updates: { autoApply: false },
88
+ };
89
+ writeFileSync(join(dir, 'host.config.json'), JSON.stringify(cfg, null, 2) + '\n', 'utf8');
90
+ }
91
+
92
+ describe('createLocalUiServer smoke', () => {
93
+ let dataDir: string;
94
+ let server: ReturnType<typeof createLocalUiServer>;
95
+ let baseUrl: string;
96
+
97
+ beforeEach(async () => {
98
+ dataDir = mkdtempSync(join(tmpdir(), 'cadre-host-smoke-'));
99
+ writeConfig(dataDir);
100
+ server = createLocalUiServer({
101
+ uiPort: 8765,
102
+ dataDir,
103
+ orchestrator: fakeOrchestrator(),
104
+ trustCircle: fakeTrustCircle(),
105
+ nat: fakeNat(),
106
+ forcePort: 0,
107
+ });
108
+ const { url } = await server.start();
109
+ baseUrl = url;
110
+ });
111
+
112
+ afterEach(async () => {
113
+ await server.stop();
114
+ rmSync(dataDir, { recursive: true, force: true });
115
+ });
116
+
117
+ it('serves /api/status and respects the origin guard', async () => {
118
+ // Without host header: undici's fetch sets it; this is the happy path.
119
+ const res = await fetch(`${baseUrl}/api/status`);
120
+ expect(res.status).toBe(200);
121
+ const body = await res.json() as { service: { name: string } };
122
+ expect(body.service.name).toBe('cadre-host');
123
+ });
124
+
125
+ it('rejects requests with a foreign Host header', async () => {
126
+ // fetch() forbids overriding the Host header — use node:http directly.
127
+ const { request: httpRequest } = await import('node:http');
128
+ const u = new URL(`${baseUrl}/api/status`);
129
+ const status = await new Promise<number>((resolve, reject) => {
130
+ const req = httpRequest(
131
+ {
132
+ hostname: u.hostname,
133
+ port: u.port,
134
+ path: u.pathname,
135
+ method: 'GET',
136
+ headers: { host: 'evil.example.com' },
137
+ },
138
+ (res) => {
139
+ res.resume();
140
+ res.on('end', () => resolve(res.statusCode ?? 0));
141
+ },
142
+ );
143
+ req.on('error', reject);
144
+ req.end();
145
+ });
146
+ expect(status).toBe(403);
147
+ });
148
+
149
+ it('round-trips an invite through /auth/* and reflects it in /auth/trust-circle', async () => {
150
+ const post = await fetch(`${baseUrl}/auth/invites`, {
151
+ method: 'POST',
152
+ headers: { 'content-type': 'application/json' },
153
+ body: JSON.stringify({ label: 'Mom\'s phone' }),
154
+ });
155
+ expect(post.status).toBe(200);
156
+
157
+ const listRes = await fetch(`${baseUrl}/auth/trust-circle`);
158
+ expect(listRes.status).toBe(200);
159
+ const body = await listRes.json() as { pending: Array<{ label: string }> };
160
+ expect(body.pending.length).toBe(1);
161
+ expect(body.pending[0]?.label).toBe('Mom\'s phone');
162
+ });
163
+
164
+ it('serves a placeholder index.html when dist/ui is missing', async () => {
165
+ const res = await fetch(`${baseUrl}/`);
166
+ expect(res.status).toBe(200);
167
+ expect(res.headers.get('content-type') ?? '').toContain('text/html');
168
+ const text = await res.text();
169
+ expect(text).toContain('cadre-host is running');
170
+ });
171
+
172
+ it('an unknown /api path returns 404 (not the static SPA)', async () => {
173
+ const res = await fetch(`${baseUrl}/api/this-does-not-exist`);
174
+ expect(res.status).toBe(404);
175
+ const body = await res.json() as { error: { code: string } };
176
+ expect(body.error.code).toBe('not_found');
177
+ });
178
+ });
@@ -0,0 +1,177 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ import Fastify from 'fastify';
6
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
7
+
8
+ import { registerErrorHandler } from '../error-handler.js';
9
+ import { registerSettingsRoutes } from '../routes/settings.js';
10
+ import { HostSettingsStore } from '../settings-store.js';
11
+ import type { NatService } from '../../nat/index.js';
12
+ import type { NatStatusSnapshot } from '../../nat/types.js';
13
+ import type { UpdateService } from '../../update/index.js';
14
+ import type { UpdateSettings } from '../../update/types.js';
15
+
16
+ function fakeNat(): { svc: NatService; calls: Array<unknown> } {
17
+ const calls: unknown[] = [];
18
+ const stub = {
19
+ putSettings: async (patch: unknown) => { calls.push(patch); return {} as NatStatusSnapshot; },
20
+ } as unknown as NatService;
21
+ return { svc: stub, calls };
22
+ }
23
+
24
+ function fakeUpdate(): { svc: UpdateService; calls: UpdateSettings[] } {
25
+ const calls: UpdateSettings[] = [];
26
+ const stub = {
27
+ putSettings: async (patch: { autoApply?: boolean; manifestUrl?: string }) => {
28
+ calls.push({ autoApply: patch.autoApply ?? false, ...(patch.manifestUrl ? { manifestUrl: patch.manifestUrl } : {}) });
29
+ return { autoApply: patch.autoApply ?? false } as UpdateSettings;
30
+ },
31
+ } as unknown as UpdateService;
32
+ return { svc: stub, calls };
33
+ }
34
+
35
+ function writeV2Config(dir: string): string {
36
+ const cfg = {
37
+ version: 2,
38
+ installId: 'inst-x',
39
+ uiPort: 8765,
40
+ libp2pPort: 4001,
41
+ dataDir: dir,
42
+ identityPath: join(dir, 'identity.key'),
43
+ upnpEnabled: true,
44
+ installedAt: '2025-01-01T00:00:00Z',
45
+ installerVersion: '0.6.0',
46
+ updates: { autoApply: false },
47
+ };
48
+ const path = join(dir, 'host.config.json');
49
+ writeFileSync(path, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
50
+ return path;
51
+ }
52
+
53
+ function writeV1Config(dir: string): string {
54
+ const cfg = {
55
+ version: 1,
56
+ installId: 'inst-x',
57
+ uiPort: 8765,
58
+ libp2pPort: 4001,
59
+ dataDir: dir,
60
+ identityPath: join(dir, 'identity.key'),
61
+ upnpEnabled: true,
62
+ installedAt: '2025-01-01T00:00:00Z',
63
+ installerVersion: '0.6.0',
64
+ };
65
+ const path = join(dir, 'host.config.json');
66
+ writeFileSync(path, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
67
+ return path;
68
+ }
69
+
70
+ describe('/api/settings routes', () => {
71
+ let app: ReturnType<typeof Fastify>;
72
+ let dataDir: string;
73
+
74
+ beforeEach(async () => {
75
+ dataDir = mkdtempSync(join(tmpdir(), 'cadre-host-settings-'));
76
+ });
77
+
78
+ afterEach(async () => {
79
+ if (app) await app.close();
80
+ rmSync(dataDir, { recursive: true, force: true });
81
+ });
82
+
83
+ it('GET returns the persisted config', async () => {
84
+ writeV2Config(dataDir);
85
+ app = Fastify();
86
+ registerErrorHandler(app);
87
+ registerSettingsRoutes(app, {
88
+ settingsStore: new HostSettingsStore({ dataDir }),
89
+ nat: fakeNat().svc,
90
+ });
91
+ const res = await app.inject({ method: 'GET', url: '/api/settings' });
92
+ expect(res.statusCode).toBe(200);
93
+ const body = res.json() as { data: { uiPort: number; updates: { autoApply: boolean } } };
94
+ expect(body.data.uiPort).toBe(8765);
95
+ expect(body.data.updates.autoApply).toBe(false);
96
+ });
97
+
98
+ it('PUT propagates upnpEnabled to nat service and host.config.json', async () => {
99
+ writeV2Config(dataDir);
100
+ const nat = fakeNat();
101
+ app = Fastify();
102
+ registerErrorHandler(app);
103
+ registerSettingsRoutes(app, { settingsStore: new HostSettingsStore({ dataDir }), nat: nat.svc });
104
+ const res = await app.inject({
105
+ method: 'PUT',
106
+ url: '/api/settings',
107
+ headers: { 'content-type': 'application/json' },
108
+ payload: JSON.stringify({ upnpEnabled: false }),
109
+ });
110
+ expect(res.statusCode).toBe(200);
111
+ expect(nat.calls).toEqual([{ upnpEnabled: false }]);
112
+ const body = res.json() as { data: { upnpEnabled: boolean } };
113
+ expect(body.data.upnpEnabled).toBe(false);
114
+ });
115
+
116
+ it('PUT updates.autoApply propagates to update service', async () => {
117
+ writeV2Config(dataDir);
118
+ const nat = fakeNat();
119
+ const update = fakeUpdate();
120
+ app = Fastify();
121
+ registerErrorHandler(app);
122
+ registerSettingsRoutes(app, { settingsStore: new HostSettingsStore({ dataDir }), nat: nat.svc, update: update.svc });
123
+ const res = await app.inject({
124
+ method: 'PUT',
125
+ url: '/api/settings',
126
+ headers: { 'content-type': 'application/json' },
127
+ payload: JSON.stringify({ updates: { autoApply: true } }),
128
+ });
129
+ expect(res.statusCode).toBe(200);
130
+ expect(update.calls).toEqual([{ autoApply: true }]);
131
+ const body = res.json() as { data: { updates: { autoApply: boolean } } };
132
+ expect(body.data.updates.autoApply).toBe(true);
133
+ });
134
+
135
+ it('PUT uiPort returns 400 invalid_setting', async () => {
136
+ writeV2Config(dataDir);
137
+ app = Fastify();
138
+ registerErrorHandler(app);
139
+ registerSettingsRoutes(app, { settingsStore: new HostSettingsStore({ dataDir }), nat: fakeNat().svc });
140
+ const res = await app.inject({
141
+ method: 'PUT',
142
+ url: '/api/settings',
143
+ headers: { 'content-type': 'application/json' },
144
+ payload: JSON.stringify({ uiPort: 9999 }),
145
+ });
146
+ expect(res.statusCode).toBe(400);
147
+ const body = res.json() as { error: { code: string; message: string } };
148
+ expect(body.error.code).toBe('invalid_setting');
149
+ expect(body.error.message).toMatch(/uiPort/);
150
+ });
151
+
152
+ it('PUT unknown setting returns 400', async () => {
153
+ writeV2Config(dataDir);
154
+ app = Fastify();
155
+ registerErrorHandler(app);
156
+ registerSettingsRoutes(app, { settingsStore: new HostSettingsStore({ dataDir }), nat: fakeNat().svc });
157
+ const res = await app.inject({
158
+ method: 'PUT',
159
+ url: '/api/settings',
160
+ headers: { 'content-type': 'application/json' },
161
+ payload: JSON.stringify({ totallyMadeUp: 'value' }),
162
+ });
163
+ expect(res.statusCode).toBe(400);
164
+ });
165
+
166
+ it('reads v1 host.config.json and migrates to v2 on first read', async () => {
167
+ writeV1Config(dataDir);
168
+ app = Fastify();
169
+ registerErrorHandler(app);
170
+ registerSettingsRoutes(app, { settingsStore: new HostSettingsStore({ dataDir }), nat: fakeNat().svc });
171
+ const res = await app.inject({ method: 'GET', url: '/api/settings' });
172
+ expect(res.statusCode).toBe(200);
173
+ const body = res.json() as { data: { version: number; updates: { autoApply: boolean } } };
174
+ expect(body.data.version).toBe(2);
175
+ expect(body.data.updates.autoApply).toBe(false);
176
+ });
177
+ });
@@ -0,0 +1,119 @@
1
+ import { createServer, type AddressInfo } from 'node:net';
2
+
3
+ import Fastify from 'fastify';
4
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
5
+
6
+ import { EventBus } from '../events/bus.js';
7
+ import { registerSseRoute } from '../events/sse-route.js';
8
+
9
+ async function pickFreePort(): Promise<number> {
10
+ return await new Promise<number>((resolve, reject) => {
11
+ const server = createServer();
12
+ server.unref();
13
+ server.on('error', reject);
14
+ server.listen({ host: '127.0.0.1', port: 0 }, () => {
15
+ const addr = server.address() as AddressInfo;
16
+ server.close(() => resolve(addr.port));
17
+ });
18
+ });
19
+ }
20
+
21
+ /** Parse SSE chunks and append decoded events to `events`. */
22
+ function parseSseChunk(buf: string, state: { current: { type: string; data: string } }, events: Array<{ type: string; data: string }>): string {
23
+ let buffered = buf;
24
+ let nl: number;
25
+ while ((nl = buffered.indexOf('\n')) !== -1) {
26
+ const line = buffered.slice(0, nl).replace(/\r$/, '');
27
+ buffered = buffered.slice(nl + 1);
28
+ if (line === '') {
29
+ if (state.current.type || state.current.data) {
30
+ events.push({ ...state.current });
31
+ state.current.type = '';
32
+ state.current.data = '';
33
+ }
34
+ continue;
35
+ }
36
+ if (line.startsWith(':')) continue;
37
+ if (line.startsWith('event:')) { state.current.type = line.slice(6).trim(); continue; }
38
+ if (line.startsWith('data:')) { state.current.data = line.slice(5).trim(); continue; }
39
+ }
40
+ return buffered;
41
+ }
42
+
43
+ describe('GET /api/events (SSE)', () => {
44
+ let app: ReturnType<typeof Fastify>;
45
+ let bus: EventBus;
46
+ let port: number;
47
+
48
+ beforeEach(async () => {
49
+ port = await pickFreePort();
50
+ bus = new EventBus();
51
+ app = Fastify();
52
+ registerSseRoute(app, { events: bus, heartbeatIntervalMs: 50 });
53
+ await app.listen({ host: '127.0.0.1', port });
54
+ });
55
+
56
+ afterEach(async () => {
57
+ await app.close();
58
+ });
59
+
60
+ it('delivers published events in order and cleans up on disconnect', async () => {
61
+ const { request: httpRequest } = await import('node:http');
62
+ const events: Array<{ type: string; data: string }> = [];
63
+ const state = { current: { type: '', data: '' } };
64
+ let leftover = '';
65
+ let done = false;
66
+
67
+ const req = httpRequest({
68
+ hostname: '127.0.0.1',
69
+ port,
70
+ path: '/api/events',
71
+ method: 'GET',
72
+ headers: { accept: 'text/event-stream' },
73
+ });
74
+
75
+ const responseReady = new Promise<void>((resolve) => {
76
+ req.on('response', (res) => {
77
+ res.setEncoding('utf8');
78
+ res.on('data', (chunk: string) => {
79
+ leftover = parseSseChunk(leftover + chunk, state, events);
80
+ });
81
+ res.on('end', () => { done = true; });
82
+ resolve();
83
+ });
84
+ });
85
+ req.end();
86
+ await responseReady;
87
+
88
+ // Give the connection a beat to settle on the server, then publish.
89
+ await new Promise<void>((r) => setTimeout(r, 30));
90
+ bus.publish({ type: 'trust-circle-changed', kind: 'invited' });
91
+ bus.publish({ type: 'trust-circle-changed', kind: 'redeemed' });
92
+ bus.publish({ type: 'connectivity-changed', portMode: 'auto-upnp', directReachability: 'reachable' });
93
+
94
+ // Wait until at least 3 events have arrived (or 1s).
95
+ const deadline = Date.now() + 1000;
96
+ while (events.length < 3 && Date.now() < deadline) {
97
+ await new Promise<void>((r) => setTimeout(r, 25));
98
+ }
99
+
100
+ expect(events.length).toBeGreaterThanOrEqual(3);
101
+ expect(events.slice(0, 3).map((e) => e.type)).toEqual([
102
+ 'trust-circle-changed',
103
+ 'trust-circle-changed',
104
+ 'connectivity-changed',
105
+ ]);
106
+ const first = JSON.parse(events[0]!.data) as { kind: string };
107
+ expect(first.kind).toBe('invited');
108
+
109
+ expect(bus.listenerCount()).toBe(1);
110
+ req.destroy();
111
+ // Give the server's 'close' handler a tick to run.
112
+ const dropDeadline = Date.now() + 1000;
113
+ while (bus.listenerCount() > 0 && Date.now() < dropDeadline) {
114
+ await new Promise<void>((r) => setTimeout(r, 25));
115
+ }
116
+ expect(bus.listenerCount()).toBe(0);
117
+ void done;
118
+ });
119
+ });