@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,43 @@
1
+ /**
2
+ * Range-based port tracker. Allocations are in-memory; on orchestrator
3
+ * restart the caller rehydrates the used-set from persisted state via
4
+ * `markUsed(port)` before serving new allocations.
5
+ */
6
+ export class PortAllocator {
7
+ private readonly usedPorts = new Set<number>();
8
+
9
+ constructor(
10
+ private readonly start: number,
11
+ private readonly end: number,
12
+ ) {
13
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start <= 0 || end < start) {
14
+ throw new Error(`Invalid port range: ${start}..${end}`);
15
+ }
16
+ }
17
+
18
+ allocate(): number {
19
+ for (let port = this.start; port <= this.end; port++) {
20
+ if (!this.usedPorts.has(port)) {
21
+ this.usedPorts.add(port);
22
+ return port;
23
+ }
24
+ }
25
+ throw new Error(`No available ports in range ${this.start}..${this.end}`);
26
+ }
27
+
28
+ release(port: number): void {
29
+ this.usedPorts.delete(port);
30
+ }
31
+
32
+ /** Used during restart rehydration to reserve ports already in use. */
33
+ markUsed(port: number): void {
34
+ if (port < this.start || port > this.end) {
35
+ return;
36
+ }
37
+ this.usedPorts.add(port);
38
+ }
39
+
40
+ has(port: number): boolean {
41
+ return this.usedPorts.has(port);
42
+ }
43
+ }
@@ -0,0 +1,72 @@
1
+ import { existsSync, readFileSync, renameSync, writeFileSync, mkdirSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import debug from 'debug';
4
+
5
+ const log = debug('cadre:host:state-store');
6
+
7
+ /**
8
+ * Persisted record for a single managed child. The orchestrator mirrors its
9
+ * in-memory map to disk so that cadre-host can re-attach to surviving
10
+ * children after a restart.
11
+ */
12
+ export interface PersistedHandle {
13
+ containerId: string; // provider-assigned ID, passed in createContainer
14
+ dockerId: string; // opaque token returned to callers (encoded "pid:token")
15
+ pid: number;
16
+ startupToken: string;
17
+ workdir: string;
18
+ ports: { health: number; metrics: number; p2p: number };
19
+ spawnedAt: string; // ISO timestamp
20
+ partyId: string;
21
+ profile: 'storage' | 'transaction';
22
+ }
23
+
24
+ export interface PersistedState {
25
+ version: 1;
26
+ handles: PersistedHandle[];
27
+ }
28
+
29
+ const STATE_VERSION = 1;
30
+
31
+ /** JSON state file at `<rootDir>/state.json`, written atomically. */
32
+ export class StateStore {
33
+ private readonly path: string;
34
+
35
+ constructor(rootDir: string) {
36
+ mkdirSync(rootDir, { recursive: true });
37
+ this.path = join(rootDir, 'state.json');
38
+ }
39
+
40
+ load(): PersistedState {
41
+ if (!existsSync(this.path)) {
42
+ return { version: STATE_VERSION, handles: [] };
43
+ }
44
+ try {
45
+ const raw = readFileSync(this.path, 'utf8');
46
+ const parsed = JSON.parse(raw) as PersistedState;
47
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.handles)) {
48
+ log('state file at %s has unexpected shape; treating as empty', this.path);
49
+ return { version: STATE_VERSION, handles: [] };
50
+ }
51
+ return { version: STATE_VERSION, handles: parsed.handles };
52
+ } catch (err) {
53
+ // A corrupt state file is operationally significant — surviving children
54
+ // may now be leaked to the OS without an in-memory handle. Make the
55
+ // failure visible rather than silently returning empty.
56
+ log('failed to load state file at %s: %o', this.path, err);
57
+ return { version: STATE_VERSION, handles: [] };
58
+ }
59
+ }
60
+
61
+ save(state: PersistedState): void {
62
+ mkdirSync(dirname(this.path), { recursive: true });
63
+ const payload = JSON.stringify({ version: STATE_VERSION, handles: state.handles }, null, 2);
64
+ const tmp = `${this.path}.tmp`;
65
+ writeFileSync(tmp, payload, { encoding: 'utf8' });
66
+ renameSync(tmp, this.path);
67
+ }
68
+
69
+ filePath(): string {
70
+ return this.path;
71
+ }
72
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Public + internal types for the HostProcessOrchestrator.
3
+ */
4
+
5
+ import type { ChildProcess } from 'node:child_process';
6
+
7
+ /** User-facing configuration for `HostProcessOrchestrator`. */
8
+ export interface HostProcessConfig {
9
+ /** Root directory under which per-container workdirs live. */
10
+ rootDir: string;
11
+ /** Port allocation range (default 10000..20000). */
12
+ portRange?: { start: number; end: number };
13
+ /** Default resource limits (memory only in v1). */
14
+ defaultResources?: { memoryLimit?: string };
15
+ /** SIGTERM → SIGKILL grace period (default 10000 ms). */
16
+ stopTimeoutMs?: number;
17
+ /** Log rotation: bytes per file (default 10 MB). */
18
+ logMaxBytes?: number;
19
+ /** Log rotation: max rotated files (default 5). */
20
+ logMaxFiles?: number;
21
+ /**
22
+ * Test-only hook: override the child entrypoint. When set, the orchestrator
23
+ * spawns `node <entrypoint> ...` instead of resolving `@serfab/cadre-cli`.
24
+ */
25
+ spawn?: { entrypoint?: string };
26
+ }
27
+
28
+ /** In-memory record per managed child. */
29
+ export interface Handle {
30
+ containerId: string;
31
+ dockerId: string;
32
+ pid: number;
33
+ startupToken: string;
34
+ workdir: string;
35
+ ports: { health: number; metrics: number; p2p: number };
36
+ spawnedAt: string;
37
+ partyId: string;
38
+ profile: 'storage' | 'transaction';
39
+ /** Live ChildProcess reference; absent after re-attach via init(). */
40
+ child?: ChildProcess;
41
+ /** Marked false when init() finds the PID dead or token mismatched. */
42
+ alive: boolean;
43
+ }
44
+
45
+ /** Public view of a managed node — surfaced to `/api/nodes` callers. */
46
+ export interface ManagedNodeInfo {
47
+ /** Friendly container id passed at create time. */
48
+ id: string;
49
+ /** Opaque dockerId (`pid:token`) — pass to stop/getStats/getLogs. */
50
+ dockerId: string;
51
+ partyId: string;
52
+ profile: 'storage' | 'transaction';
53
+ /** Runtime state derived from `Handle.alive`. */
54
+ status: 'running' | 'stopped';
55
+ spawnedAt: string;
56
+ workdir: string;
57
+ ports: { health: number; metrics: number; p2p: number };
58
+ }
59
+
60
+ /**
61
+ * Listener invoked when a managed node's lifecycle state changes
62
+ * (create, child exit, manual stop). Subscribe via
63
+ * `HostProcessOrchestrator.onStateChange`.
64
+ */
65
+ export type NodeStateListener = (info: ManagedNodeInfo) => void;
66
+
67
+ const TOKEN_SEPARATOR = ':';
68
+
69
+ /**
70
+ * Encode an opaque handle that callers treat the same way they treat a
71
+ * Docker container hash. The shape is `<pid>:<token>`; tokens are hex
72
+ * generated from `crypto.randomBytes` so they never contain `:`.
73
+ */
74
+ export function encodeDockerId(pid: number, token: string): string {
75
+ if (!Number.isInteger(pid) || pid <= 0) {
76
+ throw new Error(`Invalid pid for docker id: ${pid}`);
77
+ }
78
+ if (token.length === 0 || token.includes(TOKEN_SEPARATOR)) {
79
+ throw new Error(`Invalid token for docker id: ${JSON.stringify(token)}`);
80
+ }
81
+ return `${pid}${TOKEN_SEPARATOR}${token}`;
82
+ }
83
+
84
+ export function decodeDockerId(id: string): { pid: number; token: string } {
85
+ const idx = id.indexOf(TOKEN_SEPARATOR);
86
+ if (idx <= 0) {
87
+ throw new Error(`Malformed dockerId: ${id}`);
88
+ }
89
+ const pid = Number.parseInt(id.slice(0, idx), 10);
90
+ const token = id.slice(idx + 1);
91
+ if (!Number.isInteger(pid) || pid <= 0 || token.length === 0) {
92
+ throw new Error(`Malformed dockerId: ${id}`);
93
+ }
94
+ return { pid, token };
95
+ }
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ import { EventBus } from '../events/bus.js';
4
+ import type { LocalUiEvent } from '../events/types.js';
5
+
6
+ describe('EventBus', () => {
7
+ it('delivers events to subscribers in order', () => {
8
+ const bus = new EventBus();
9
+ const received: LocalUiEvent[] = [];
10
+ bus.subscribe((e) => received.push(e));
11
+ bus.publish({ type: 'trust-circle-changed', kind: 'invited' });
12
+ bus.publish({ type: 'trust-circle-changed', kind: 'revoked' });
13
+ expect(received).toEqual([
14
+ { type: 'trust-circle-changed', kind: 'invited' },
15
+ { type: 'trust-circle-changed', kind: 'revoked' },
16
+ ]);
17
+ });
18
+
19
+ it('unsubscribe stops further deliveries', () => {
20
+ const bus = new EventBus();
21
+ const received: LocalUiEvent[] = [];
22
+ const unsub = bus.subscribe((e) => received.push(e));
23
+ bus.publish({ type: 'trust-circle-changed', kind: 'invited' });
24
+ unsub();
25
+ bus.publish({ type: 'trust-circle-changed', kind: 'redeemed' });
26
+ expect(received).toHaveLength(1);
27
+ expect(bus.listenerCount()).toBe(0);
28
+ });
29
+
30
+ it('one listener throwing does not block others', () => {
31
+ const bus = new EventBus();
32
+ const received: LocalUiEvent[] = [];
33
+ bus.subscribe(() => { throw new Error('bad listener'); });
34
+ bus.subscribe((e) => received.push(e));
35
+ bus.publish({ type: 'trust-circle-changed', kind: 'invited' });
36
+ expect(received).toHaveLength(1);
37
+ });
38
+ });
@@ -0,0 +1,96 @@
1
+ import Fastify from 'fastify';
2
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
3
+
4
+ import { TrustCircleError } from '../../auth/types.js';
5
+ import { NatError } from '../../nat/types.js';
6
+ import { UpdateErrorException } from '../../update/types.js';
7
+ import { registerErrorHandler } from '../error-handler.js';
8
+
9
+ describe('error handler', () => {
10
+ let app: ReturnType<typeof Fastify>;
11
+
12
+ beforeEach(async () => {
13
+ app = Fastify();
14
+ registerErrorHandler(app);
15
+ app.get('/trust/:code', async (req) => {
16
+ const { code } = req.params as { code: string };
17
+ throw new TrustCircleError(code as never, `trust err: ${code}`);
18
+ });
19
+ app.get('/nat/:code', async (req) => {
20
+ const { code } = req.params as { code: string };
21
+ throw new NatError(code as never, `nat err: ${code}`);
22
+ });
23
+ app.get('/update/:code', async (req) => {
24
+ const { code } = req.params as { code: string };
25
+ throw new UpdateErrorException(code as never, `update err: ${code}`);
26
+ });
27
+ app.get('/unknown', async () => {
28
+ throw new Error('mystery');
29
+ });
30
+ });
31
+
32
+ afterEach(async () => {
33
+ await app.close();
34
+ });
35
+
36
+ it('TrustCircleError invalid_label → 400', async () => {
37
+ const res = await app.inject({ method: 'GET', url: '/trust/invalid_label' });
38
+ expect(res.statusCode).toBe(400);
39
+ const body = res.json() as { ok: boolean; error: { code: string; message: string } };
40
+ expect(body).toEqual({
41
+ ok: false,
42
+ error: { code: 'invalid_label', message: 'trust err: invalid_label' },
43
+ });
44
+ });
45
+
46
+ it('TrustCircleError not_found → 404', async () => {
47
+ const res = await app.inject({ method: 'GET', url: '/trust/not_found' });
48
+ expect(res.statusCode).toBe(404);
49
+ });
50
+
51
+ it('TrustCircleError already_redeemed → 404', async () => {
52
+ const res = await app.inject({ method: 'GET', url: '/trust/already_redeemed' });
53
+ expect(res.statusCode).toBe(404);
54
+ });
55
+
56
+ it('TrustCircleError expired → 410', async () => {
57
+ const res = await app.inject({ method: 'GET', url: '/trust/expired' });
58
+ expect(res.statusCode).toBe(410);
59
+ });
60
+
61
+ it('TrustCircleError storage_error → 500', async () => {
62
+ const res = await app.inject({ method: 'GET', url: '/trust/storage_error' });
63
+ expect(res.statusCode).toBe(500);
64
+ });
65
+
66
+ it('NatError invalid_config → 400', async () => {
67
+ const res = await app.inject({ method: 'GET', url: '/nat/invalid_config' });
68
+ expect(res.statusCode).toBe(400);
69
+ const body = res.json() as { error: { code: string } };
70
+ expect(body.error.code).toBe('invalid_config');
71
+ });
72
+
73
+ it('NatError secrets_unavailable → 500', async () => {
74
+ const res = await app.inject({ method: 'GET', url: '/nat/secrets_unavailable' });
75
+ expect(res.statusCode).toBe(500);
76
+ });
77
+
78
+ it('UpdateErrorException apply_in_progress → 409', async () => {
79
+ const res = await app.inject({ method: 'GET', url: '/update/apply_in_progress' });
80
+ expect(res.statusCode).toBe(409);
81
+ });
82
+
83
+ it('UpdateErrorException no_update_available → 400', async () => {
84
+ const res = await app.inject({ method: 'GET', url: '/update/no_update_available' });
85
+ expect(res.statusCode).toBe(400);
86
+ });
87
+
88
+ it('unknown Error → 500 internal', async () => {
89
+ const res = await app.inject({ method: 'GET', url: '/unknown' });
90
+ expect(res.statusCode).toBe(500);
91
+ const body = res.json() as { ok: boolean; error: { code: string; message: string } };
92
+ expect(body.ok).toBe(false);
93
+ expect(body.error.code).toBe('internal');
94
+ expect(body.error.message).toBe('mystery');
95
+ });
96
+ });
@@ -0,0 +1,179 @@
1
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } 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 { EventBus } from '../events/bus.js';
9
+ import { registerErrorHandler } from '../error-handler.js';
10
+ import { registerNodesRoutes, tailLogFile } from '../routes/nodes.js';
11
+ import type { HostProcessOrchestrator } from '../../orchestrator/index.js';
12
+ import type { ManagedNodeInfo, NodeStateListener } from '../../orchestrator/types.js';
13
+
14
+ interface FakeOrchestrator extends HostProcessOrchestrator {
15
+ __nodes: Map<string, ManagedNodeInfo>;
16
+ __listeners: Set<NodeStateListener>;
17
+ __stoppedDockerIds: string[];
18
+ }
19
+
20
+ function fakeOrchestrator(initial: ManagedNodeInfo[] = []): FakeOrchestrator {
21
+ const nodes = new Map(initial.map((n) => [n.dockerId, n]));
22
+ const listeners = new Set<NodeStateListener>();
23
+ const stopped: string[] = [];
24
+ const inst = {
25
+ listNodes: () => [...nodes.values()],
26
+ getNode: (id: string) => {
27
+ const direct = nodes.get(id);
28
+ if (direct) return direct;
29
+ for (const n of nodes.values()) if (n.id === id) return n;
30
+ return undefined;
31
+ },
32
+ resolveDockerId: (id: string) => {
33
+ if (nodes.has(id)) return id;
34
+ for (const n of nodes.values()) if (n.id === id) return n.dockerId;
35
+ return undefined;
36
+ },
37
+ onStateChange: (l: NodeStateListener) => { listeners.add(l); return () => { listeners.delete(l); }; },
38
+ stopContainer: async (dockerId: string) => {
39
+ stopped.push(dockerId);
40
+ const node = nodes.get(dockerId);
41
+ if (node) {
42
+ const next: ManagedNodeInfo = { ...node, status: 'stopped' };
43
+ nodes.set(dockerId, next);
44
+ for (const l of listeners) l(next);
45
+ }
46
+ },
47
+ getStats: async () => ({ cpuPercent: 1, memoryBytes: 2, networkRxBytes: 3, networkTxBytes: 4 }),
48
+ __nodes: nodes,
49
+ __listeners: listeners,
50
+ __stoppedDockerIds: stopped,
51
+ } as unknown as FakeOrchestrator;
52
+ return inst;
53
+ }
54
+
55
+ const SAMPLE_NODE: ManagedNodeInfo = {
56
+ id: 'alice',
57
+ dockerId: '12345:abcdef',
58
+ partyId: 'party-alice',
59
+ profile: 'storage',
60
+ status: 'running',
61
+ spawnedAt: '2025-01-01T00:00:00Z',
62
+ workdir: '',
63
+ ports: { health: 11, metrics: 12, p2p: 13 },
64
+ };
65
+
66
+ describe('/api/nodes routes', () => {
67
+ let app: ReturnType<typeof Fastify>;
68
+ let bus: EventBus;
69
+ let orchestrator: FakeOrchestrator;
70
+ let workdir: string;
71
+
72
+ beforeEach(async () => {
73
+ workdir = mkdtempSync(join(tmpdir(), 'cadre-host-nodes-'));
74
+ bus = new EventBus();
75
+ app = Fastify();
76
+ registerErrorHandler(app);
77
+ orchestrator = fakeOrchestrator([{ ...SAMPLE_NODE, workdir }]);
78
+ registerNodesRoutes(app, { orchestrator });
79
+ // Mirror what createLocalUiServer.start() does — forward orchestrator
80
+ // state changes onto the bus. The route itself doesn't publish; the
81
+ // listener is the single publication point.
82
+ orchestrator.onStateChange((info) => {
83
+ bus.publish({
84
+ type: 'node-state-changed',
85
+ nodeId: info.id,
86
+ status: info.status === 'running' ? 'running' : 'stopped',
87
+ });
88
+ });
89
+ });
90
+
91
+ afterEach(async () => {
92
+ await app.close();
93
+ rmSync(workdir, { recursive: true, force: true });
94
+ });
95
+
96
+ it('GET /api/nodes lists known nodes', async () => {
97
+ const res = await app.inject({ method: 'GET', url: '/api/nodes' });
98
+ expect(res.statusCode).toBe(200);
99
+ const body = res.json() as { ok: boolean; data: { nodes: Array<{ id: string }> } };
100
+ expect(body.ok).toBe(true);
101
+ expect(body.data.nodes).toHaveLength(1);
102
+ expect(body.data.nodes[0]?.id).toBe('alice');
103
+ });
104
+
105
+ it('GET /api/nodes/:id returns details with stats', async () => {
106
+ const res = await app.inject({ method: 'GET', url: '/api/nodes/alice' });
107
+ expect(res.statusCode).toBe(200);
108
+ const body = res.json() as { data: { node: { id: string }; stats: { cpuPercent: number } } };
109
+ expect(body.data.node.id).toBe('alice');
110
+ expect(body.data.stats.cpuPercent).toBe(1);
111
+ });
112
+
113
+ it('GET /api/nodes/:id 404 for unknown', async () => {
114
+ const res = await app.inject({ method: 'GET', url: '/api/nodes/nobody' });
115
+ expect(res.statusCode).toBe(404);
116
+ const body = res.json() as { ok: boolean; error: { code: string } };
117
+ expect(body.error.code).toBe('not_found');
118
+ });
119
+
120
+ it('GET /api/nodes/:id/logs returns tail of node.log', async () => {
121
+ // Mirror the orchestrator's defaultLogPath layout: <workdir>/node.log
122
+ writeFileSync(join(workdir, 'node.log'), 'one\ntwo\nthree\nfour\nfive\n', 'utf8');
123
+ const res = await app.inject({ method: 'GET', url: '/api/nodes/alice/logs?lines=2' });
124
+ expect(res.statusCode).toBe(200);
125
+ const body = res.json() as { data: { lines: string[] } };
126
+ expect(body.data.lines).toEqual(['four', 'five']);
127
+ });
128
+
129
+ it('GET /api/nodes/:id/logs returns [] when log file missing', async () => {
130
+ const res = await app.inject({ method: 'GET', url: '/api/nodes/alice/logs' });
131
+ expect(res.statusCode).toBe(200);
132
+ const body = res.json() as { data: { lines: string[] } };
133
+ expect(body.data.lines).toEqual([]);
134
+ });
135
+
136
+ it('POST /api/nodes/:id/stop calls orchestrator and publishes exactly one event via the listener', async () => {
137
+ const events: unknown[] = [];
138
+ bus.subscribe((e) => events.push(e));
139
+ const res = await app.inject({ method: 'POST', url: '/api/nodes/alice/stop' });
140
+ expect(res.statusCode).toBe(200);
141
+ expect(orchestrator.__stoppedDockerIds).toContain(SAMPLE_NODE.dockerId);
142
+ // Exactly one event — the orchestrator listener publishes; the route
143
+ // does not re-publish (regression guard).
144
+ expect(events).toEqual([
145
+ { type: 'node-state-changed', nodeId: 'alice', status: 'stopped' },
146
+ ]);
147
+ });
148
+
149
+ it('POST /api/nodes/:id/start returns 501 not_implemented', async () => {
150
+ const res = await app.inject({ method: 'POST', url: '/api/nodes/alice/start' });
151
+ expect(res.statusCode).toBe(501);
152
+ const body = res.json() as { error: { code: string } };
153
+ expect(body.error.code).toBe('not_implemented');
154
+ });
155
+
156
+ it('POST /api/nodes/:id/restart returns 501 not_implemented', async () => {
157
+ const res = await app.inject({ method: 'POST', url: '/api/nodes/alice/restart' });
158
+ expect(res.statusCode).toBe(501);
159
+ });
160
+ });
161
+
162
+ describe('tailLogFile', () => {
163
+ it('returns [] for missing file', () => {
164
+ expect(tailLogFile(join(tmpdir(), 'definitely-not-here-' + Date.now()), 10)).toEqual([]);
165
+ });
166
+
167
+ it('returns the last N lines from a multi-line file', () => {
168
+ const dir = mkdtempSync(join(tmpdir(), 'cadre-host-tail-'));
169
+ mkdirSync(dir, { recursive: true });
170
+ const path = join(dir, 'sample.log');
171
+ writeFileSync(path, Array.from({ length: 50 }, (_, i) => `line-${i + 1}`).join('\n') + '\n', 'utf8');
172
+ try {
173
+ const last3 = tailLogFile(path, 3);
174
+ expect(last3).toEqual(['line-48', 'line-49', 'line-50']);
175
+ } finally {
176
+ rmSync(dir, { recursive: true, force: true });
177
+ }
178
+ });
179
+ });
@@ -0,0 +1,72 @@
1
+ import Fastify from 'fastify';
2
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
3
+
4
+ import { registerOriginGuard } from '../origin-guard.js';
5
+
6
+ describe('origin guard', () => {
7
+ let app: ReturnType<typeof Fastify>;
8
+
9
+ beforeEach(async () => {
10
+ app = Fastify();
11
+ registerOriginGuard(app, { port: 8765 });
12
+ app.get('/ping', async () => ({ ok: true, data: { pong: true } }));
13
+ });
14
+
15
+ afterEach(async () => {
16
+ await app.close();
17
+ });
18
+
19
+ it('accepts 127.0.0.1:<port>', async () => {
20
+ const res = await app.inject({ method: 'GET', url: '/ping', headers: { host: '127.0.0.1:8765' } });
21
+ expect(res.statusCode).toBe(200);
22
+ });
23
+
24
+ it('accepts localhost:<port>', async () => {
25
+ const res = await app.inject({ method: 'GET', url: '/ping', headers: { host: 'localhost:8765' } });
26
+ expect(res.statusCode).toBe(200);
27
+ });
28
+
29
+ it('accepts case-insensitive localhost', async () => {
30
+ const res = await app.inject({ method: 'GET', url: '/ping', headers: { host: 'LOCALHOST:8765' } });
31
+ expect(res.statusCode).toBe(200);
32
+ });
33
+
34
+ it('rejects mismatched host', async () => {
35
+ const res = await app.inject({
36
+ method: 'GET',
37
+ url: '/ping',
38
+ headers: { host: 'evil.example.com' },
39
+ });
40
+ expect(res.statusCode).toBe(403);
41
+ const body = res.json() as { ok: boolean; error: { code: string } };
42
+ expect(body.ok).toBe(false);
43
+ expect(body.error.code).toBe('forbidden_origin');
44
+ });
45
+
46
+ it('rejects host with wrong port', async () => {
47
+ const res = await app.inject({
48
+ method: 'GET',
49
+ url: '/ping',
50
+ headers: { host: '127.0.0.1:9999' },
51
+ });
52
+ expect(res.statusCode).toBe(403);
53
+ });
54
+
55
+ it('accepts request with matching Origin', async () => {
56
+ const res = await app.inject({
57
+ method: 'GET',
58
+ url: '/ping',
59
+ headers: { host: '127.0.0.1:8765', origin: 'http://127.0.0.1:8765' },
60
+ });
61
+ expect(res.statusCode).toBe(200);
62
+ });
63
+
64
+ it('rejects request with mismatched Origin', async () => {
65
+ const res = await app.inject({
66
+ method: 'GET',
67
+ url: '/ping',
68
+ headers: { host: '127.0.0.1:8765', origin: 'http://evil.example.com' },
69
+ });
70
+ expect(res.statusCode).toBe(403);
71
+ });
72
+ });