@serfab/cadre-host 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/dist/auth/index.d.ts +2 -0
  2. package/dist/auth/index.js +1 -0
  3. package/dist/auth/index.js.map +1 -1
  4. package/dist/auth/self-label.d.ts +36 -0
  5. package/dist/auth/self-label.js +57 -0
  6. package/dist/auth/self-label.js.map +1 -0
  7. package/dist/auth/trust-circle.d.ts +32 -5
  8. package/dist/auth/trust-circle.js +31 -11
  9. package/dist/auth/trust-circle.js.map +1 -1
  10. package/dist/bin/host.js +41 -5
  11. package/dist/bin/host.js.map +1 -1
  12. package/dist/donation/__tests__/fake-orchestrator.d.ts +103 -0
  13. package/dist/donation/__tests__/fake-orchestrator.js +180 -0
  14. package/dist/donation/__tests__/fake-orchestrator.js.map +1 -0
  15. package/dist/donation/donation-service.d.ts +237 -12
  16. package/dist/donation/donation-service.js +491 -34
  17. package/dist/donation/donation-service.js.map +1 -1
  18. package/dist/donation/donation-store.d.ts +7 -0
  19. package/dist/donation/donation-store.js +7 -0
  20. package/dist/donation/donation-store.js.map +1 -1
  21. package/dist/donation/donation-supervisor.d.ts +167 -0
  22. package/dist/donation/donation-supervisor.js +341 -0
  23. package/dist/donation/donation-supervisor.js.map +1 -0
  24. package/dist/donation/index.d.ts +4 -2
  25. package/dist/donation/index.js +2 -1
  26. package/dist/donation/index.js.map +1 -1
  27. package/dist/donation/types.d.ts +26 -0
  28. package/dist/donation/types.js.map +1 -1
  29. package/dist/index.d.ts +3 -1
  30. package/dist/index.js +3 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/orchestrator/host-process-orchestrator.d.ts +109 -5
  33. package/dist/orchestrator/host-process-orchestrator.js +341 -98
  34. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  35. package/dist/orchestrator/node-identity.d.ts +26 -0
  36. package/dist/orchestrator/node-identity.js +36 -0
  37. package/dist/orchestrator/node-identity.js.map +1 -0
  38. package/dist/orchestrator/port-allocator.d.ts +14 -0
  39. package/dist/orchestrator/port-allocator.js +45 -0
  40. package/dist/orchestrator/port-allocator.js.map +1 -1
  41. package/dist/owner/owner-node-client.d.ts +20 -5
  42. package/dist/owner/owner-node-client.js +21 -4
  43. package/dist/owner/owner-node-client.js.map +1 -1
  44. package/dist/server/error-handler.d.ts +7 -1
  45. package/dist/server/error-handler.js +23 -1
  46. package/dist/server/error-handler.js.map +1 -1
  47. package/dist/server/events/types.d.ts +3 -0
  48. package/dist/server/index.d.ts +7 -0
  49. package/dist/server/index.js +9 -3
  50. package/dist/server/index.js.map +1 -1
  51. package/dist/server/routes/grants.js +22 -3
  52. package/dist/server/routes/grants.js.map +1 -1
  53. package/dist/server/routes/strands.d.ts +20 -0
  54. package/dist/server/routes/strands.js +38 -0
  55. package/dist/server/routes/strands.js.map +1 -0
  56. package/dist/server/static.js +38 -38
  57. package/dist/strands/index.d.ts +7 -0
  58. package/dist/strands/index.js +6 -0
  59. package/dist/strands/index.js.map +1 -0
  60. package/dist/strands/strand-service.d.ts +50 -0
  61. package/dist/strands/strand-service.js +103 -0
  62. package/dist/strands/strand-service.js.map +1 -0
  63. package/dist/strands/types.d.ts +93 -0
  64. package/dist/strands/types.js +23 -0
  65. package/dist/strands/types.js.map +1 -0
  66. package/dist/ui/assets/index-BVoqLjyX.css +1 -0
  67. package/dist/ui/assets/index-C-iks41M.js +17 -0
  68. package/dist/ui/assets/index-C-iks41M.js.map +1 -0
  69. package/dist/ui/index.html +14 -14
  70. package/dist/update/release-key.d.ts +1 -1
  71. package/dist/update/release-key.js +1 -1
  72. package/package.json +5 -5
  73. package/service/cadre-host.service.tmpl +16 -16
  74. package/service/com.serfab.cadre-host.plist.tmpl +35 -35
  75. package/service/install-service.ps1 +98 -98
  76. package/service/uninstall-service.ps1 +19 -19
  77. package/src/__tests__/build-targets.test.ts +26 -0
  78. package/src/__tests__/cli-invite.smoke.test.ts +192 -192
  79. package/src/__tests__/cli-nat.smoke.test.ts +212 -212
  80. package/src/__tests__/cli.smoke.test.ts +46 -46
  81. package/src/__tests__/global-setup.ts +51 -0
  82. package/src/__tests__/host-owner.smoke.test.ts +123 -123
  83. package/src/__tests__/orchestrator-env-scrub.test.ts +140 -0
  84. package/src/__tests__/orchestrator-node-identity.test.ts +204 -0
  85. package/src/__tests__/orchestrator-owner.test.ts +198 -198
  86. package/src/__tests__/orchestrator-pin-keys.test.ts +153 -153
  87. package/src/__tests__/orchestrator-push.test.ts +145 -145
  88. package/src/__tests__/orchestrator.test.ts +951 -570
  89. package/src/auth/__tests__/duration.test.ts +28 -28
  90. package/src/auth/__tests__/self-label.test.ts +85 -0
  91. package/src/auth/__tests__/trust-circle-integration.test.ts +152 -147
  92. package/src/auth/__tests__/trust-circle-store.test.ts +115 -115
  93. package/src/auth/__tests__/trust-circle.test.ts +445 -401
  94. package/src/auth/duration.ts +33 -33
  95. package/src/auth/index.ts +21 -19
  96. package/src/auth/self-label.ts +74 -0
  97. package/src/auth/trust-circle-store.ts +155 -155
  98. package/src/auth/trust-circle.ts +45 -13
  99. package/src/auth/types.ts +99 -99
  100. package/src/bin/host.ts +1299 -1258
  101. package/src/donation/__tests__/donation-service.test.ts +1068 -290
  102. package/src/donation/__tests__/donation-supervisor.test.ts +510 -0
  103. package/src/donation/__tests__/fake-orchestrator.test.ts +168 -0
  104. package/src/donation/__tests__/fake-orchestrator.ts +225 -0
  105. package/src/donation/__tests__/grant-service.test.ts +213 -213
  106. package/src/donation/__tests__/grant-store.test.ts +113 -113
  107. package/src/donation/donation-service.ts +962 -392
  108. package/src/donation/donation-store.ts +171 -164
  109. package/src/donation/donation-supervisor.ts +388 -0
  110. package/src/donation/grant-service.ts +200 -200
  111. package/src/donation/grant-store.ts +152 -152
  112. package/src/donation/index.ts +64 -49
  113. package/src/donation/types.ts +243 -220
  114. package/src/index.ts +14 -0
  115. package/src/installer/__tests__/identity.test.ts +34 -34
  116. package/src/installer/__tests__/paths.test.ts +47 -47
  117. package/src/installer/__tests__/service-host.test.ts +62 -62
  118. package/src/installer/__tests__/wizard.test.ts +64 -64
  119. package/src/installer/identity.ts +49 -49
  120. package/src/installer/paths.ts +109 -109
  121. package/src/installer/platform.ts +27 -27
  122. package/src/installer/service-host/index.ts +21 -21
  123. package/src/installer/service-host/template.ts +21 -21
  124. package/src/installer/wizard.ts +103 -103
  125. package/src/nat/__tests__/address-resolver.test.ts +82 -82
  126. package/src/nat/__tests__/ddns-duckdns.test.ts +83 -83
  127. package/src/nat/__tests__/ddns-updater.test.ts +253 -253
  128. package/src/nat/__tests__/external-ip.test.ts +124 -124
  129. package/src/nat/__tests__/nat-service.test.ts +497 -497
  130. package/src/nat/__tests__/nat-store.test.ts +93 -93
  131. package/src/nat/__tests__/port-mapper.test.ts +191 -191
  132. package/src/nat/__tests__/reachability.test.ts +24 -24
  133. package/src/nat/__tests__/secrets.test.ts +178 -178
  134. package/src/nat/address-resolver.ts +51 -51
  135. package/src/nat/ddns/duckdns.ts +76 -76
  136. package/src/nat/ddns/index.ts +55 -55
  137. package/src/nat/ddns/updater.ts +243 -243
  138. package/src/nat/external-ip.ts +154 -154
  139. package/src/nat/index.ts +71 -71
  140. package/src/nat/nat-service.ts +501 -501
  141. package/src/nat/nat-store.ts +154 -154
  142. package/src/nat/port-mapper.ts +329 -329
  143. package/src/nat/reachability.ts +34 -34
  144. package/src/nat/secrets/file-store.ts +130 -130
  145. package/src/nat/secrets/index.ts +112 -112
  146. package/src/nat/secrets/keytar-store.ts +67 -67
  147. package/src/nat/types.ts +159 -159
  148. package/src/orchestrator/__tests__/node-identity.test.ts +62 -0
  149. package/src/orchestrator/host-process-orchestrator.ts +1133 -894
  150. package/src/orchestrator/index.ts +10 -10
  151. package/src/orchestrator/log-rotator.ts +53 -53
  152. package/src/orchestrator/node-identity.ts +40 -0
  153. package/src/orchestrator/pid-liveness.ts +24 -24
  154. package/src/orchestrator/port-allocator.ts +91 -43
  155. package/src/orchestrator/state-store.ts +90 -90
  156. package/src/orchestrator/types.ts +141 -141
  157. package/src/owner/__tests__/owner-node-client.test.ts +194 -194
  158. package/src/owner/index.ts +12 -12
  159. package/src/owner/owner-node-client.ts +259 -233
  160. package/src/push/__tests__/push.test.ts +90 -90
  161. package/src/push/index.ts +146 -146
  162. package/src/server/__tests__/bus.test.ts +38 -38
  163. package/src/server/__tests__/error-handler.test.ts +118 -118
  164. package/src/server/__tests__/grants-admin-route.test.ts +73 -73
  165. package/src/server/__tests__/grants-route.test.ts +263 -189
  166. package/src/server/__tests__/nodes-route.test.ts +259 -259
  167. package/src/server/__tests__/origin-guard.test.ts +72 -72
  168. package/src/server/__tests__/publishers.test.ts +216 -216
  169. package/src/server/__tests__/server.smoke.test.ts +253 -253
  170. package/src/server/__tests__/settings-route.test.ts +194 -194
  171. package/src/server/__tests__/sse-route.test.ts +119 -119
  172. package/src/server/__tests__/static.test.ts +145 -145
  173. package/src/server/__tests__/status-route.test.ts +134 -134
  174. package/src/server/__tests__/strands-route.test.ts +233 -0
  175. package/src/server/error-handler.ts +178 -155
  176. package/src/server/events/bus.ts +42 -42
  177. package/src/server/events/sse-route.ts +81 -81
  178. package/src/server/events/types.ts +23 -22
  179. package/src/server/index.ts +267 -254
  180. package/src/server/origin-guard.ts +84 -84
  181. package/src/server/routes/grants-admin.ts +41 -41
  182. package/src/server/routes/grants.ts +210 -186
  183. package/src/server/routes/nat.ts +63 -63
  184. package/src/server/routes/nodes.ts +170 -170
  185. package/src/server/routes/settings.ts +123 -123
  186. package/src/server/routes/status.ts +118 -118
  187. package/src/server/routes/strands.ts +54 -0
  188. package/src/server/routes/trust-circle.ts +49 -49
  189. package/src/server/routes/update.ts +43 -43
  190. package/src/server/server.ts +122 -122
  191. package/src/server/settings-store.ts +37 -37
  192. package/src/server/static.ts +207 -207
  193. package/src/strands/__tests__/strand-service.test.ts +165 -0
  194. package/src/strands/index.ts +14 -0
  195. package/src/strands/strand-service.ts +135 -0
  196. package/src/strands/types.ts +102 -0
  197. package/src/update/__tests__/apply.test.ts +158 -158
  198. package/src/update/__tests__/manifest.test.ts +128 -128
  199. package/src/update/__tests__/release-key.test.ts +42 -42
  200. package/src/update/__tests__/sign.test.ts +78 -78
  201. package/src/update/__tests__/store.test.ts +81 -81
  202. package/src/update/__tests__/update-service.integration.test.ts +256 -256
  203. package/src/update/__tests__/version.test.ts +31 -31
  204. package/src/update/apply.ts +190 -190
  205. package/src/update/index.ts +341 -341
  206. package/src/update/release-key.ts +71 -71
  207. package/src/update/sign.ts +74 -74
  208. package/src/update/store.ts +101 -101
  209. package/src/update/types.ts +109 -109
  210. package/src/update/version.ts +80 -80
  211. package/dist/ui/assets/index-BlMXQPSw.js +0 -14
  212. package/dist/ui/assets/index-BlMXQPSw.js.map +0 -1
  213. package/dist/ui/assets/index-BwU5mJGF.css +0 -1
@@ -1,123 +1,123 @@
1
- /**
2
- * Server-level smoke: the /auth/* HTTP contract over the owner-node
3
- * channel adapter. Validates the two behaviours the realignment must
4
- * preserve:
5
- * 1. With the node up, POST /auth/invites returns an encodedInvite (no 500).
6
- * 2. With the node refusing connections, GET /auth/trust-circle still lists
7
- * local labels, and POST /auth/invites returns 503 (not a raw 500).
8
- *
9
- * The owner node is represented by a stub HTTP server matching the 6.6
10
- * admin contract (the spawn path itself is covered by
11
- * orchestrator-owner.test.ts).
12
- */
13
-
14
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
15
- import Fastify from 'fastify';
16
- import http from 'node:http';
17
- import { mkdtempSync, rmSync } from 'node:fs';
18
- import { tmpdir } from 'node:os';
19
- import { join } from 'node:path';
20
-
21
- import { TrustCircleService, TrustCircleStore, createTrustCircleHandlers } from '../auth/index.js';
22
- import { OwnerNodeClient } from '../owner/index.js';
23
- import { registerErrorHandler } from '../server/error-handler.js';
24
- import { registerTrustCircleRoutes } from '../server/routes/trust-circle.js';
25
- import { EventBus } from '../server/events/bus.js';
26
-
27
- const TOKEN = 'tok';
28
-
29
- let tmpRoot: string;
30
- beforeEach(() => { tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-smoke-')); });
31
- afterEach(() => { try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ } });
32
-
33
- function buildApp(service: TrustCircleService) {
34
- const app = Fastify();
35
- registerErrorHandler(app);
36
- registerTrustCircleRoutes(app, { handlers: createTrustCircleHandlers(service), events: new EventBus() });
37
- return app;
38
- }
39
-
40
- describe('host /auth delegation — node up', () => {
41
- let server: http.Server;
42
- let baseUrl: string;
43
-
44
- beforeEach(async () => {
45
- server = http.createServer((req, res) => {
46
- const chunks: Buffer[] = [];
47
- req.on('data', (c) => chunks.push(c as Buffer));
48
- req.on('end', () => {
49
- if (req.headers['authorization'] !== `Bearer ${TOKEN}`) {
50
- res.writeHead(401); res.end(JSON.stringify({ ok: false, error: { code: 'not_authorized', message: 'x' } })); return;
51
- }
52
- if (req.url === '/admin/invites' && req.method === 'POST') {
53
- res.writeHead(200, { 'content-type': 'application/json' });
54
- res.end(JSON.stringify({ ok: true, data: { invite: { partyId: 'p', ownerAddrs: [], token: 't', createdAt: 1 }, encodedInvite: 'ENC' } }));
55
- return;
56
- }
57
- if (req.url === '/admin/members' && req.method === 'GET') {
58
- res.writeHead(200, { 'content-type': 'application/json' });
59
- res.end(JSON.stringify({ ok: true, data: { members: [] } }));
60
- return;
61
- }
62
- res.writeHead(400); res.end(JSON.stringify({ ok: false, error: { code: 'bad_request', message: req.url } }));
63
- });
64
- });
65
- await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
66
- const a = server.address();
67
- baseUrl = `http://127.0.0.1:${typeof a === 'object' && a ? a.port : 0}`;
68
- });
69
-
70
- afterEach(async () => { await new Promise<void>((resolve) => server.close(() => resolve())); });
71
-
72
- it('POST /auth/invites returns an encodedInvite (no 500)', async () => {
73
- const client = new OwnerNodeClient({ baseUrl, token: TOKEN });
74
- const service = new TrustCircleService({ cadreNode: client, store: new TrustCircleStore(tmpRoot) });
75
- const app = buildApp(service);
76
- try {
77
- const res = await app.inject({ method: 'POST', url: '/auth/invites', payload: { label: 'Mom' } });
78
- expect(res.statusCode).toBe(200);
79
- const body = res.json() as { encodedInvite: string };
80
- expect(body.encodedInvite).toBe('ENC');
81
- } finally {
82
- await app.close();
83
- }
84
- });
85
- });
86
-
87
- describe('host /auth delegation — node refusing connections', () => {
88
- let closedBaseUrl: string;
89
-
90
- beforeEach(async () => {
91
- const tmp = http.createServer();
92
- const port = await new Promise<number>((resolve) => {
93
- tmp.listen(0, '127.0.0.1', () => {
94
- const a = tmp.address();
95
- resolve(typeof a === 'object' && a ? a.port : 0);
96
- });
97
- });
98
- await new Promise<void>((resolve) => tmp.close(() => resolve()));
99
- closedBaseUrl = `http://127.0.0.1:${port}`;
100
- });
101
-
102
- it('GET /auth/trust-circle lists local labels and POST /auth/invites returns 503', async () => {
103
- const store = new TrustCircleStore(tmpRoot);
104
- store.addMember({ peerId: '12D3KooWLocal', label: 'My laptop', addedAt: '2025-01-01T00:00:00Z' });
105
-
106
- const client = new OwnerNodeClient({ baseUrl: closedBaseUrl, token: TOKEN });
107
- const service = new TrustCircleService({ cadreNode: client, store });
108
- const app = buildApp(service);
109
- try {
110
- const list = await app.inject({ method: 'GET', url: '/auth/trust-circle' });
111
- expect(list.statusCode).toBe(200);
112
- const snap = list.json() as { members: Array<{ peerId: string; label: string }> };
113
- expect(snap.members.some((m) => m.peerId === '12D3KooWLocal' && m.label === 'My laptop')).toBe(true);
114
-
115
- const invite = await app.inject({ method: 'POST', url: '/auth/invites', payload: { label: 'Dad' } });
116
- expect(invite.statusCode).toBe(503);
117
- const body = invite.json() as { error: { code: string } };
118
- expect(body.error.code).toBe('node_unavailable');
119
- } finally {
120
- await app.close();
121
- }
122
- });
123
- });
1
+ /**
2
+ * Server-level smoke: the /auth/* HTTP contract over the owner-node
3
+ * channel adapter. Validates the two behaviours the realignment must
4
+ * preserve:
5
+ * 1. With the node up, POST /auth/invites returns an encodedInvite (no 500).
6
+ * 2. With the node refusing connections, GET /auth/trust-circle still lists
7
+ * local labels, and POST /auth/invites returns 503 (not a raw 500).
8
+ *
9
+ * The owner node is represented by a stub HTTP server matching the 6.6
10
+ * admin contract (the spawn path itself is covered by
11
+ * orchestrator-owner.test.ts).
12
+ */
13
+
14
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
15
+ import Fastify from 'fastify';
16
+ import http from 'node:http';
17
+ import { mkdtempSync, rmSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { join } from 'node:path';
20
+
21
+ import { TrustCircleService, TrustCircleStore, createTrustCircleHandlers } from '../auth/index.js';
22
+ import { OwnerNodeClient } from '../owner/index.js';
23
+ import { registerErrorHandler } from '../server/error-handler.js';
24
+ import { registerTrustCircleRoutes } from '../server/routes/trust-circle.js';
25
+ import { EventBus } from '../server/events/bus.js';
26
+
27
+ const TOKEN = 'tok';
28
+
29
+ let tmpRoot: string;
30
+ beforeEach(() => { tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-smoke-')); });
31
+ afterEach(() => { try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ } });
32
+
33
+ function buildApp(service: TrustCircleService) {
34
+ const app = Fastify();
35
+ registerErrorHandler(app);
36
+ registerTrustCircleRoutes(app, { handlers: createTrustCircleHandlers(service), events: new EventBus() });
37
+ return app;
38
+ }
39
+
40
+ describe('host /auth delegation — node up', () => {
41
+ let server: http.Server;
42
+ let baseUrl: string;
43
+
44
+ beforeEach(async () => {
45
+ server = http.createServer((req, res) => {
46
+ const chunks: Buffer[] = [];
47
+ req.on('data', (c) => chunks.push(c as Buffer));
48
+ req.on('end', () => {
49
+ if (req.headers['authorization'] !== `Bearer ${TOKEN}`) {
50
+ res.writeHead(401); res.end(JSON.stringify({ ok: false, error: { code: 'not_authorized', message: 'x' } })); return;
51
+ }
52
+ if (req.url === '/admin/invites' && req.method === 'POST') {
53
+ res.writeHead(200, { 'content-type': 'application/json' });
54
+ res.end(JSON.stringify({ ok: true, data: { invite: { partyId: 'p', ownerAddrs: [], token: 't', createdAt: 1 }, encodedInvite: 'ENC' } }));
55
+ return;
56
+ }
57
+ if (req.url === '/admin/members' && req.method === 'GET') {
58
+ res.writeHead(200, { 'content-type': 'application/json' });
59
+ res.end(JSON.stringify({ ok: true, data: { members: [] } }));
60
+ return;
61
+ }
62
+ res.writeHead(400); res.end(JSON.stringify({ ok: false, error: { code: 'bad_request', message: req.url } }));
63
+ });
64
+ });
65
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
66
+ const a = server.address();
67
+ baseUrl = `http://127.0.0.1:${typeof a === 'object' && a ? a.port : 0}`;
68
+ });
69
+
70
+ afterEach(async () => { await new Promise<void>((resolve) => server.close(() => resolve())); });
71
+
72
+ it('POST /auth/invites returns an encodedInvite (no 500)', async () => {
73
+ const client = new OwnerNodeClient({ baseUrl, token: TOKEN });
74
+ const service = new TrustCircleService({ cadreNode: client, store: new TrustCircleStore(tmpRoot) });
75
+ const app = buildApp(service);
76
+ try {
77
+ const res = await app.inject({ method: 'POST', url: '/auth/invites', payload: { label: 'Mom' } });
78
+ expect(res.statusCode).toBe(200);
79
+ const body = res.json() as { encodedInvite: string };
80
+ expect(body.encodedInvite).toBe('ENC');
81
+ } finally {
82
+ await app.close();
83
+ }
84
+ });
85
+ });
86
+
87
+ describe('host /auth delegation — node refusing connections', () => {
88
+ let closedBaseUrl: string;
89
+
90
+ beforeEach(async () => {
91
+ const tmp = http.createServer();
92
+ const port = await new Promise<number>((resolve) => {
93
+ tmp.listen(0, '127.0.0.1', () => {
94
+ const a = tmp.address();
95
+ resolve(typeof a === 'object' && a ? a.port : 0);
96
+ });
97
+ });
98
+ await new Promise<void>((resolve) => tmp.close(() => resolve()));
99
+ closedBaseUrl = `http://127.0.0.1:${port}`;
100
+ });
101
+
102
+ it('GET /auth/trust-circle lists local labels and POST /auth/invites returns 503', async () => {
103
+ const store = new TrustCircleStore(tmpRoot);
104
+ store.addMember({ peerId: '12D3KooWLocal', label: 'My laptop', addedAt: '2025-01-01T00:00:00Z' });
105
+
106
+ const client = new OwnerNodeClient({ baseUrl: closedBaseUrl, token: TOKEN });
107
+ const service = new TrustCircleService({ cadreNode: client, store });
108
+ const app = buildApp(service);
109
+ try {
110
+ const list = await app.inject({ method: 'GET', url: '/auth/trust-circle' });
111
+ expect(list.statusCode).toBe(200);
112
+ const snap = list.json() as { members: Array<{ peerId: string; label: string }> };
113
+ expect(snap.members.some((m) => m.peerId === '12D3KooWLocal' && m.label === 'My laptop')).toBe(true);
114
+
115
+ const invite = await app.inject({ method: 'POST', url: '/auth/invites', payload: { label: 'Dad' } });
116
+ expect(invite.statusCode).toBe(503);
117
+ const body = invite.json() as { error: { code: string } };
118
+ expect(body.error.code).toBe('node_unavailable');
119
+ } finally {
120
+ await app.close();
121
+ }
122
+ });
123
+ });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * HostProcessOrchestrator env-scrub test — the manager's own `process.env` must
3
+ * not leak `CADRE_*` config-override vars into spawned children.
4
+ *
5
+ * `launchChild` used to build each child's environment as
6
+ * `{ ...process.env, ...extraEnv, <fixed per-child vars> }`, so ANY `CADRE_*`
7
+ * var set on the manager (not just the ones the orchestrator deliberately
8
+ * sets) reached every child. Because `@serfab/cadre-cli` treats an inherited
9
+ * `CADRE_*` var as a config override that beats the per-child config file, a
10
+ * `CADRE_PARTY_ID` set on the manager could silently pull every child into
11
+ * that party.
12
+ *
13
+ * A fake CLI records the vars under test from its environment to a file so
14
+ * the scrub is observable without a real cadre node.
15
+ */
16
+
17
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
18
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
19
+ import { tmpdir } from 'node:os';
20
+ import { join } from 'node:path';
21
+
22
+ import { HostProcessOrchestrator } from '../orchestrator/host-process-orchestrator.js';
23
+
24
+ // Writes the vars under test (as seen in the child env) next to the startup token.
25
+ const FAKE_CLI = `
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+ const args = process.argv.slice(2);
29
+ const get = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : undefined; };
30
+ const tokenPath = get('--startup-token-file');
31
+ const token = process.env.CADRE_STARTUP_TOKEN ?? '';
32
+ if (tokenPath) {
33
+ const dir = path.dirname(tokenPath);
34
+ const record = {
35
+ CADRE_PARTY_ID: process.env.CADRE_PARTY_ID ?? null,
36
+ CADRE_STORAGE_PATH: process.env.CADRE_STORAGE_PATH ?? null,
37
+ CADRE_ADMIN_PORT: process.env.CADRE_ADMIN_PORT ?? null,
38
+ DEBUG: process.env.DEBUG ?? null,
39
+ };
40
+ try { fs.writeFileSync(path.join(dir, 'env-seen.json'), JSON.stringify(record), 'utf8'); } catch (e) { console.error(e); }
41
+ if (token) { try { fs.writeFileSync(tokenPath, token, 'utf8'); } catch (e) { console.error(e); } }
42
+ }
43
+ process.on('SIGTERM', () => process.exit(0));
44
+ setInterval(() => {}, 1 << 30);
45
+ `;
46
+
47
+ let tmpRoot: string;
48
+ let scriptPath: string;
49
+ const orchestrators: HostProcessOrchestrator[] = [];
50
+ let savedEnv: Record<string, string | undefined>;
51
+
52
+ beforeEach(() => {
53
+ tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-env-scrub-'));
54
+ scriptPath = join(tmpRoot, 'fake-cli.mjs');
55
+ writeFileSync(scriptPath, FAKE_CLI, 'utf8');
56
+ savedEnv = {
57
+ CADRE_PARTY_ID: process.env.CADRE_PARTY_ID,
58
+ CADRE_STORAGE_PATH: process.env.CADRE_STORAGE_PATH,
59
+ CADRE_ADMIN_PORT: process.env.CADRE_ADMIN_PORT,
60
+ DEBUG: process.env.DEBUG,
61
+ };
62
+ });
63
+
64
+ afterEach(async () => {
65
+ for (const orch of orchestrators) {
66
+ for (const n of orch.listNodes()) {
67
+ try { await orch.removeContainer(n.dockerId); } catch { /* ignore */ }
68
+ }
69
+ }
70
+ orchestrators.length = 0;
71
+ await sleep(50);
72
+ try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
73
+ for (const [key, value] of Object.entries(savedEnv)) {
74
+ if (value === undefined) delete process.env[key];
75
+ else process.env[key] = value;
76
+ }
77
+ });
78
+
79
+ function makeOrchestrator(rootDir: string): HostProcessOrchestrator {
80
+ mkdirSync(rootDir, { recursive: true });
81
+ const orch = new HostProcessOrchestrator({
82
+ rootDir,
83
+ portRange: { start: 18000, end: 18499 },
84
+ stopTimeoutMs: 1500,
85
+ spawn: { entrypoint: scriptPath },
86
+ });
87
+ orchestrators.push(orch);
88
+ return orch;
89
+ }
90
+
91
+ async function waitForFile(path: string, timeoutMs = 5000): Promise<string> {
92
+ const deadline = Date.now() + timeoutMs;
93
+ while (Date.now() < deadline) {
94
+ if (existsSync(path)) return readFileSync(path, 'utf8');
95
+ await sleep(50);
96
+ }
97
+ throw new Error(`file never appeared: ${path}`);
98
+ }
99
+
100
+ function sleep(ms: number): Promise<void> {
101
+ return new Promise((res) => setTimeout(res, ms));
102
+ }
103
+
104
+ describe('HostProcessOrchestrator child env scrub', () => {
105
+ it('does not leak manager CADRE_* config-override vars into children, but passes through non-CADRE vars', async () => {
106
+ process.env.CADRE_PARTY_ID = 'conflicting-party';
107
+ process.env.CADRE_STORAGE_PATH = '/tmp/should-not-leak';
108
+ // Not an ENV_MAPPINGS config override — read directly by the cli's `start`
109
+ // command — so it only stays scrubbed if the scrub is prefix-wide.
110
+ process.env.CADRE_ADMIN_PORT = '19999';
111
+ process.env.DEBUG = 'some:value';
112
+
113
+ const rootDir = join(tmpRoot, 'a');
114
+ const orch = makeOrchestrator(rootDir);
115
+ await orch.init();
116
+
117
+ await orch.createContainer({
118
+ containerId: 'child-1',
119
+ partyId: 'party-1',
120
+ bootstrapNodes: [],
121
+ profile: 'storage',
122
+ });
123
+ await orch.createContainer({
124
+ containerId: 'child-2',
125
+ partyId: 'party-2',
126
+ bootstrapNodes: [],
127
+ profile: 'storage',
128
+ });
129
+
130
+ const seen1 = JSON.parse(await waitForFile(join(rootDir, 'child-1', 'env-seen.json')));
131
+ const seen2 = JSON.parse(await waitForFile(join(rootDir, 'child-2', 'env-seen.json')));
132
+
133
+ for (const seen of [seen1, seen2]) {
134
+ expect(seen.CADRE_PARTY_ID).toBeNull();
135
+ expect(seen.CADRE_STORAGE_PATH).toBeNull();
136
+ expect(seen.CADRE_ADMIN_PORT).toBeNull();
137
+ expect(seen.DEBUG).toBe('some:value');
138
+ }
139
+ });
140
+ });
@@ -0,0 +1,204 @@
1
+ /**
2
+ * HostProcessOrchestrator per-node identity tests.
3
+ *
4
+ * A donated node must keep the peer id its host cadre approved across restarts,
5
+ * and needs a durable on-disk home for its node-local stores (`cadre-cli start`
6
+ * opens the file-backed bootstrap-peer / trusted-owner stores only when a
7
+ * protobuf identity key file is configured). The orchestrator therefore writes
8
+ * `<workdir>/identity.key` once per container and passes it as
9
+ * `--identity-protobuf`.
10
+ *
11
+ * A fake CLI records the `--identity-protobuf` argument it was launched with, so
12
+ * the spawn wiring is observable without a real cadre node.
13
+ */
14
+
15
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
16
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+
20
+ import { HostProcessOrchestrator, OWNER_CONTAINER_ID } from '../orchestrator/host-process-orchestrator.js';
21
+ import { loadIdentity } from '../installer/identity.js';
22
+
23
+ // Writes the --identity-protobuf argument (as seen on its own command line) next
24
+ // to the startup token, then behaves like a minimal long-lived node.
25
+ const FAKE_CLI = `
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+ const args = process.argv.slice(2);
29
+ const get = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : undefined; };
30
+ const tokenPath = get('--startup-token-file');
31
+ const token = process.env.CADRE_STARTUP_TOKEN ?? '';
32
+ if (tokenPath) {
33
+ const dir = path.dirname(tokenPath);
34
+ try { fs.writeFileSync(path.join(dir, 'identity-arg.txt'), get('--identity-protobuf') ?? '', 'utf8'); } catch (e) { console.error(e); }
35
+ if (token) { try { fs.writeFileSync(tokenPath, token, 'utf8'); } catch (e) { console.error(e); } }
36
+ }
37
+ process.on('SIGTERM', () => process.exit(0));
38
+ setInterval(() => {}, 1 << 30);
39
+ `;
40
+
41
+ let tmpRoot: string;
42
+ let scriptPath: string;
43
+ const orchestrators: HostProcessOrchestrator[] = [];
44
+
45
+ beforeEach(() => {
46
+ tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-nodeid-'));
47
+ scriptPath = join(tmpRoot, 'fake-cli.mjs');
48
+ writeFileSync(scriptPath, FAKE_CLI, 'utf8');
49
+ });
50
+
51
+ afterEach(async () => {
52
+ for (const orch of orchestrators) {
53
+ for (const n of orch.listNodes()) {
54
+ try { await orch.removeContainer(n.dockerId); } catch { /* ignore */ }
55
+ }
56
+ }
57
+ orchestrators.length = 0;
58
+ await sleep(50);
59
+ try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
60
+ });
61
+
62
+ function makeOrchestrator(
63
+ rootDir: string,
64
+ portRange: { start: number; end: number } = { start: 17500, end: 17999 },
65
+ ): HostProcessOrchestrator {
66
+ mkdirSync(rootDir, { recursive: true });
67
+ const orch = new HostProcessOrchestrator({
68
+ rootDir,
69
+ portRange,
70
+ stopTimeoutMs: 1500,
71
+ spawn: { entrypoint: scriptPath },
72
+ });
73
+ orchestrators.push(orch);
74
+ return orch;
75
+ }
76
+
77
+ async function waitForFile(path: string, timeoutMs = 5000): Promise<string> {
78
+ const deadline = Date.now() + timeoutMs;
79
+ while (Date.now() < deadline) {
80
+ if (existsSync(path)) return readFileSync(path, 'utf8');
81
+ await sleep(50);
82
+ }
83
+ throw new Error(`file never appeared: ${path}`);
84
+ }
85
+
86
+ function sleep(ms: number): Promise<void> {
87
+ return new Promise((res) => setTimeout(res, ms));
88
+ }
89
+
90
+ describe('HostProcessOrchestrator node identity', () => {
91
+ it('passes --identity-protobuf pointing at a real key inside the node workdir', async () => {
92
+ const rootDir = join(tmpRoot, 'a');
93
+ const orch = makeOrchestrator(rootDir);
94
+ await orch.init();
95
+
96
+ await orch.createContainer({
97
+ containerId: 'donated-1',
98
+ partyId: 'foreign-P',
99
+ bootstrapNodes: [],
100
+ profile: 'storage',
101
+ pinnedOwnerKeys: ['key-a'],
102
+ });
103
+
104
+ const workdir = join(rootDir, 'donated-1');
105
+ const arg = await waitForFile(join(workdir, 'identity-arg.txt'));
106
+ expect(arg).toBe(join(workdir, 'identity.key'));
107
+ // Decodes as a libp2p protobuf private key → the child gets a usable identity.
108
+ expect(loadIdentity(arg).peerId).toMatch(/^12D3Koo/);
109
+ });
110
+
111
+ it('reuses the same identity when the same containerId is spawned again', async () => {
112
+ const rootDir = join(tmpRoot, 'b');
113
+ const orch = makeOrchestrator(rootDir);
114
+ await orch.init();
115
+
116
+ const first = await orch.createContainer({
117
+ containerId: 'donated-2',
118
+ partyId: 'foreign-P',
119
+ bootstrapNodes: [],
120
+ profile: 'storage',
121
+ pinnedOwnerKeys: ['key-a'],
122
+ });
123
+ const workdir = join(rootDir, 'donated-2');
124
+ await waitForFile(join(workdir, 'identity-arg.txt'));
125
+ const before = loadIdentity(join(workdir, 'identity.key')).peerId;
126
+
127
+ // Stop (not remove — removal deletes the workdir) and re-spawn, as a restart
128
+ // of a live loan would.
129
+ await orch.stopContainer(first.dockerId);
130
+ await orch.createContainer({
131
+ containerId: 'donated-2',
132
+ partyId: 'foreign-P',
133
+ bootstrapNodes: [],
134
+ profile: 'storage',
135
+ pinnedOwnerKeys: ['key-a'],
136
+ });
137
+
138
+ const after = loadIdentity(join(workdir, 'identity.key')).peerId;
139
+ expect(after).toBe(before);
140
+ });
141
+
142
+ it('destroys the identity with the workdir when the loan is terminated', async () => {
143
+ const rootDir = join(tmpRoot, 'c');
144
+ const orch = makeOrchestrator(rootDir);
145
+ await orch.init();
146
+
147
+ const created = await orch.createContainer({
148
+ containerId: 'donated-3',
149
+ partyId: 'foreign-P',
150
+ bootstrapNodes: [],
151
+ profile: 'storage',
152
+ pinnedOwnerKeys: ['key-a'],
153
+ });
154
+ const identityPath = join(rootDir, 'donated-3', 'identity.key');
155
+ await waitForFile(identityPath);
156
+
157
+ await orch.removeContainer(created.dockerId);
158
+ expect(existsSync(identityPath)).toBe(false);
159
+ expect(existsSync(join(rootDir, 'donated-3'))).toBe(false);
160
+ });
161
+
162
+ // The failure the identity step can actually produce is a damaged key file,
163
+ // and `DonationService.provision` turns every such throw into an `error`
164
+ // record and lets the grantee retry. If the spawn path reserved its ports
165
+ // before the step that throws, each retry would burn four more ports out of a
166
+ // bounded range until provisioning stopped working altogether.
167
+ it('reserves no ports when the identity step fails', async () => {
168
+ const rootDir = join(tmpRoot, 'd');
169
+ // Exactly one node's worth of ports (health, metrics, p2p, admin).
170
+ const orch = makeOrchestrator(rootDir, { start: 18100, end: 18103 });
171
+ await orch.init();
172
+
173
+ mkdirSync(join(rootDir, 'donated-bad'), { recursive: true });
174
+ writeFileSync(join(rootDir, 'donated-bad', 'identity.key'), 'not a protobuf private key', 'utf8');
175
+
176
+ const request = {
177
+ partyId: 'foreign-P',
178
+ bootstrapNodes: [],
179
+ profile: 'storage' as const,
180
+ pinnedOwnerKeys: ['key-a'],
181
+ };
182
+ await expect(orch.createContainer({ ...request, containerId: 'donated-bad' })).rejects.toThrow();
183
+
184
+ // The whole range is still free, so a healthy container still gets its four.
185
+ const ok = await orch.createContainer({ ...request, containerId: 'donated-good' });
186
+ expect(ok.p2pPort).toBeGreaterThanOrEqual(18100);
187
+ });
188
+
189
+ // The owner node carries the HOST's installer identity from <dataDir>, not a
190
+ // per-workdir key — a second key in its workdir would mean the host's own
191
+ // cadre node quietly changed peer id.
192
+ it('leaves the owner node on its own installer identity', async () => {
193
+ const rootDir = join(tmpRoot, 'e');
194
+ const orch = makeOrchestrator(rootDir);
195
+ await orch.init();
196
+
197
+ const hostIdentity = join(tmpRoot, 'host-identity.key');
198
+ await orch.ensureOwnerNode({ identityPath: hostIdentity, partyId: 'host-P', libp2pPort: 4101 });
199
+
200
+ const ownerWorkdir = join(rootDir, OWNER_CONTAINER_ID);
201
+ expect(await waitForFile(join(ownerWorkdir, 'identity-arg.txt'))).toBe(hostIdentity);
202
+ expect(existsSync(join(ownerWorkdir, 'identity.key'))).toBe(false);
203
+ });
204
+ });