@serfab/cadre-host 0.7.1 → 0.9.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 (193) hide show
  1. package/README.md +296 -101
  2. package/dist/auth/trust-circle.d.ts +45 -7
  3. package/dist/auth/trust-circle.js +116 -59
  4. package/dist/auth/trust-circle.js.map +1 -1
  5. package/dist/auth/types.d.ts +4 -1
  6. package/dist/auth/types.js.map +1 -1
  7. package/dist/bin/host.js +426 -112
  8. package/dist/bin/host.js.map +1 -1
  9. package/dist/donation/donation-service.d.ts +136 -0
  10. package/dist/donation/donation-service.js +306 -0
  11. package/dist/donation/donation-service.js.map +1 -0
  12. package/dist/donation/donation-store.d.ts +42 -0
  13. package/dist/donation/donation-store.js +141 -0
  14. package/dist/donation/donation-store.js.map +1 -0
  15. package/dist/donation/grant-service.d.ts +71 -0
  16. package/dist/donation/grant-service.js +163 -0
  17. package/dist/donation/grant-service.js.map +1 -0
  18. package/dist/donation/grant-store.d.ts +38 -0
  19. package/dist/donation/grant-store.js +135 -0
  20. package/dist/donation/grant-store.js.map +1 -0
  21. package/dist/donation/index.d.ts +18 -0
  22. package/dist/donation/index.js +16 -0
  23. package/dist/donation/index.js.map +1 -0
  24. package/dist/donation/types.d.ts +175 -0
  25. package/dist/donation/types.js +40 -0
  26. package/dist/donation/types.js.map +1 -0
  27. package/dist/index.d.ts +12 -6
  28. package/dist/index.js +8 -3
  29. package/dist/index.js.map +1 -1
  30. package/dist/installer/browser.js +16 -17
  31. package/dist/installer/browser.js.map +1 -1
  32. package/dist/installer/config.d.ts +51 -0
  33. package/dist/installer/config.js +14 -1
  34. package/dist/installer/config.js.map +1 -1
  35. package/dist/installer/index.d.ts +7 -2
  36. package/dist/installer/index.js +17 -10
  37. package/dist/installer/index.js.map +1 -1
  38. package/dist/installer/service-host/launchd.js +1 -1
  39. package/dist/installer/service-host/launchd.js.map +1 -1
  40. package/dist/installer/service-host/systemd.js +1 -1
  41. package/dist/installer/service-host/systemd.js.map +1 -1
  42. package/dist/installer/wizard.d.ts +4 -0
  43. package/dist/installer/wizard.js +5 -1
  44. package/dist/installer/wizard.js.map +1 -1
  45. package/dist/nat/index.d.ts +1 -1
  46. package/dist/nat/nat-service.d.ts +45 -3
  47. package/dist/nat/nat-service.js +116 -3
  48. package/dist/nat/nat-service.js.map +1 -1
  49. package/dist/nat/secrets/index.d.ts +7 -0
  50. package/dist/nat/secrets/index.js +28 -2
  51. package/dist/nat/secrets/index.js.map +1 -1
  52. package/dist/nat/types.d.ts +3 -1
  53. package/dist/nat/types.js.map +1 -1
  54. package/dist/orchestrator/host-process-orchestrator.d.ts +68 -1
  55. package/dist/orchestrator/host-process-orchestrator.js +272 -39
  56. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  57. package/dist/orchestrator/index.d.ts +3 -2
  58. package/dist/orchestrator/index.js +1 -1
  59. package/dist/orchestrator/index.js.map +1 -1
  60. package/dist/orchestrator/state-store.d.ts +6 -5
  61. package/dist/orchestrator/state-store.js +10 -2
  62. package/dist/orchestrator/state-store.js.map +1 -1
  63. package/dist/orchestrator/types.d.ts +45 -10
  64. package/dist/orchestrator/types.js.map +1 -1
  65. package/dist/owner/index.d.ts +11 -0
  66. package/dist/owner/index.js +11 -0
  67. package/dist/owner/index.js.map +1 -0
  68. package/dist/owner/owner-node-client.d.ts +95 -0
  69. package/dist/owner/owner-node-client.js +167 -0
  70. package/dist/owner/owner-node-client.js.map +1 -0
  71. package/dist/push/index.d.ts +67 -0
  72. package/dist/push/index.js +101 -0
  73. package/dist/push/index.js.map +1 -0
  74. package/dist/server/error-handler.d.ts +17 -0
  75. package/dist/server/error-handler.js +45 -0
  76. package/dist/server/error-handler.js.map +1 -1
  77. package/dist/server/index.d.ts +26 -2
  78. package/dist/server/index.js +37 -16
  79. package/dist/server/index.js.map +1 -1
  80. package/dist/server/routes/grants-admin.d.ts +17 -0
  81. package/dist/server/routes/grants-admin.js +32 -0
  82. package/dist/server/routes/grants-admin.js.map +1 -0
  83. package/dist/server/routes/grants.d.ts +28 -0
  84. package/dist/server/routes/grants.js +163 -0
  85. package/dist/server/routes/grants.js.map +1 -0
  86. package/dist/server/routes/nodes.d.ts +5 -3
  87. package/dist/server/routes/nodes.js +39 -24
  88. package/dist/server/routes/nodes.js.map +1 -1
  89. package/dist/server/routes/settings.d.ts +6 -1
  90. package/dist/server/routes/settings.js +5 -2
  91. package/dist/server/routes/settings.js.map +1 -1
  92. package/dist/server/routes/status.d.ts +8 -4
  93. package/dist/server/routes/status.js +9 -4
  94. package/dist/server/routes/status.js.map +1 -1
  95. package/dist/server/static.d.ts +12 -0
  96. package/dist/server/static.js +24 -7
  97. package/dist/server/static.js.map +1 -1
  98. package/dist/ui/assets/index-BlMXQPSw.js +14 -0
  99. package/dist/ui/assets/{index-C9uChk2P.js.map → index-BlMXQPSw.js.map} +1 -1
  100. package/dist/ui/index.html +1 -1
  101. package/dist/update/apply.d.ts +23 -1
  102. package/dist/update/apply.js +46 -4
  103. package/dist/update/apply.js.map +1 -1
  104. package/dist/update/index.d.ts +14 -3
  105. package/dist/update/index.js +35 -2
  106. package/dist/update/index.js.map +1 -1
  107. package/dist/update/manifest.d.ts +28 -9
  108. package/dist/update/manifest.js +56 -29
  109. package/dist/update/manifest.js.map +1 -1
  110. package/dist/update/release-key.d.ts +18 -7
  111. package/dist/update/release-key.js +29 -14
  112. package/dist/update/release-key.js.map +1 -1
  113. package/dist/update/sign.d.ts +46 -0
  114. package/dist/update/sign.js +53 -0
  115. package/dist/update/sign.js.map +1 -0
  116. package/package.json +9 -4
  117. package/service/README.md +95 -86
  118. package/src/__tests__/cli-invite.smoke.test.ts +6 -1
  119. package/src/__tests__/cli-nat.smoke.test.ts +6 -1
  120. package/src/__tests__/cli.smoke.test.ts +6 -1
  121. package/src/__tests__/host-owner.smoke.test.ts +123 -0
  122. package/src/__tests__/orchestrator-owner.test.ts +198 -0
  123. package/src/__tests__/orchestrator-pin-keys.test.ts +153 -0
  124. package/src/__tests__/orchestrator-push.test.ts +145 -0
  125. package/src/auth/__tests__/trust-circle-integration.test.ts +44 -25
  126. package/src/auth/__tests__/trust-circle.test.ts +91 -24
  127. package/src/auth/trust-circle.ts +363 -304
  128. package/src/auth/types.ts +4 -1
  129. package/src/bin/host.ts +1258 -905
  130. package/src/donation/__tests__/donation-service.test.ts +290 -0
  131. package/src/donation/__tests__/grant-service.test.ts +213 -0
  132. package/src/donation/__tests__/grant-store.test.ts +113 -0
  133. package/src/donation/donation-service.ts +392 -0
  134. package/src/donation/donation-store.ts +164 -0
  135. package/src/donation/grant-service.ts +200 -0
  136. package/src/donation/grant-store.ts +152 -0
  137. package/src/donation/index.ts +49 -0
  138. package/src/donation/types.ts +220 -0
  139. package/src/index.ts +66 -3
  140. package/src/installer/__tests__/config.test.ts +27 -1
  141. package/src/installer/__tests__/installer.smoke.test.ts +94 -0
  142. package/src/installer/__tests__/wizard.test.ts +5 -1
  143. package/src/installer/browser.ts +40 -42
  144. package/src/installer/config.ts +61 -1
  145. package/src/installer/index.ts +23 -12
  146. package/src/installer/service-host/launchd.ts +1 -1
  147. package/src/installer/service-host/systemd.ts +1 -1
  148. package/src/installer/wizard.ts +12 -1
  149. package/src/nat/__tests__/ddns-updater.test.ts +1 -1
  150. package/src/nat/__tests__/nat-service.test.ts +192 -0
  151. package/src/nat/__tests__/port-mapper.test.ts +1 -1
  152. package/src/nat/__tests__/secrets.test.ts +23 -1
  153. package/src/nat/index.ts +1 -1
  154. package/src/nat/nat-service.ts +131 -5
  155. package/src/nat/secrets/index.ts +32 -2
  156. package/src/nat/types.ts +3 -1
  157. package/src/orchestrator/host-process-orchestrator.ts +322 -54
  158. package/src/orchestrator/index.ts +9 -2
  159. package/src/orchestrator/state-store.ts +21 -3
  160. package/src/orchestrator/types.ts +48 -2
  161. package/src/owner/__tests__/owner-node-client.test.ts +194 -0
  162. package/src/owner/index.ts +12 -0
  163. package/src/owner/owner-node-client.ts +233 -0
  164. package/src/push/__tests__/push.test.ts +90 -0
  165. package/src/push/index.ts +146 -0
  166. package/src/server/__tests__/error-handler.test.ts +26 -4
  167. package/src/server/__tests__/grants-admin-route.test.ts +73 -0
  168. package/src/server/__tests__/grants-route.test.ts +189 -0
  169. package/src/server/__tests__/nodes-route.test.ts +90 -10
  170. package/src/server/__tests__/publishers.test.ts +1 -1
  171. package/src/server/__tests__/server.smoke.test.ts +75 -0
  172. package/src/server/__tests__/settings-route.test.ts +17 -0
  173. package/src/server/__tests__/static.test.ts +145 -0
  174. package/src/server/__tests__/status-route.test.ts +23 -2
  175. package/src/server/error-handler.ts +47 -0
  176. package/src/server/index.ts +62 -17
  177. package/src/server/routes/grants-admin.ts +41 -0
  178. package/src/server/routes/grants.ts +186 -0
  179. package/src/server/routes/nodes.ts +49 -26
  180. package/src/server/routes/settings.ts +11 -3
  181. package/src/server/routes/status.ts +18 -8
  182. package/src/server/static.ts +24 -8
  183. package/src/update/__tests__/apply.test.ts +58 -1
  184. package/src/update/__tests__/manifest.test.ts +41 -6
  185. package/src/update/__tests__/release-key.test.ts +42 -0
  186. package/src/update/__tests__/sign.test.ts +78 -0
  187. package/src/update/__tests__/update-service.integration.test.ts +44 -9
  188. package/src/update/apply.ts +46 -4
  189. package/src/update/index.ts +41 -3
  190. package/src/update/manifest.ts +214 -172
  191. package/src/update/release-key.ts +31 -15
  192. package/src/update/sign.ts +74 -0
  193. package/dist/ui/assets/index-C9uChk2P.js +0 -14
@@ -9,6 +9,7 @@ import type { PortMapper, PortMappingResult } from '../port-mapper.js';
9
9
  import type { SecretsStore } from '../secrets/index.js';
10
10
  import { ddnsAccount } from '../secrets/index.js';
11
11
  import type { CadreNodeLike } from '../nat-service.js';
12
+ import { OwnerNodeUnavailableError } from '../../owner/owner-node-client.js';
12
13
 
13
14
  class StubMapper implements PortMapper {
14
15
  mapCalls = 0;
@@ -51,6 +52,29 @@ function makeNode(peerId = '12D3KooWHost', addrs: string[] = ['/ip4/192.168.1.10
51
52
  };
52
53
  }
53
54
 
55
+ /**
56
+ * A node that mimics a freshly spawned owner child: `getPeerId` throws
57
+ * `OwnerNodeUnavailableError` for the first `failTimes` calls (admin channel
58
+ * not bound yet), then returns a real peer ID. Exposes the call count so tests
59
+ * can assert how many attempts the retry loop made.
60
+ */
61
+ function makeFlakyNode(
62
+ failTimes: number,
63
+ peerId = '12D3KooWFlaky',
64
+ addrs: string[] = ['/ip4/192.168.1.10/tcp/4001'],
65
+ ): { node: CadreNodeLike; peerIdCalls: () => number } {
66
+ let calls = 0;
67
+ const node: CadreNodeLike = {
68
+ getPeerId: async () => {
69
+ calls += 1;
70
+ if (calls <= failTimes) throw new OwnerNodeUnavailableError('admin channel not bound yet');
71
+ return peerId;
72
+ },
73
+ getMultiaddrs: async () => addrs,
74
+ };
75
+ return { node, peerIdCalls: () => calls };
76
+ }
77
+
54
78
  function makeDetector(opts: { router?: string | null; pub?: string | null }): ExternalIpDetector {
55
79
  return new ExternalIpDetector({
56
80
  routerProbe: opts.router === undefined ? undefined : async () => opts.router ?? null,
@@ -273,6 +297,174 @@ describe('NatService', () => {
273
297
  });
274
298
  });
275
299
 
300
+ describe('NatService — async channel node + onAddressesChanged', () => {
301
+ it('getInviteAddresses awaits async getPeerId/getMultiaddrs from the channel', async () => {
302
+ const asyncNode: CadreNodeLike = {
303
+ getPeerId: async () => '12D3KooWAsync',
304
+ getMultiaddrs: async () => ['/ip4/10.0.0.9/tcp/4001'],
305
+ };
306
+ const svc = new NatService({
307
+ rootDir: tmpRoot,
308
+ cadreNode: asyncNode,
309
+ secretsStore: makeSecrets(),
310
+ portMapper: new StubMapper(),
311
+ externalIpDetector: makeDetector({ pub: '203.0.113.50' }),
312
+ });
313
+ await svc.start();
314
+ const addrs = await svc.getInviteAddresses();
315
+ expect(addrs).toEqual(['/ip4/203.0.113.50/tcp/4001/p2p/12D3KooWAsync']);
316
+ });
317
+
318
+ it('throws node_unavailable when the node reports an empty peer ID (not ready)', async () => {
319
+ const notReadyNode: CadreNodeLike = {
320
+ getPeerId: async () => '',
321
+ getMultiaddrs: async () => [],
322
+ };
323
+ const svc = new NatService({
324
+ rootDir: tmpRoot,
325
+ cadreNode: notReadyNode,
326
+ secretsStore: makeSecrets(),
327
+ portMapper: new StubMapper(),
328
+ externalIpDetector: makeDetector({ pub: '203.0.113.50' }),
329
+ });
330
+ await svc.start();
331
+ await expect(svc.getInviteAddresses()).rejects.toMatchObject({ code: 'node_unavailable' });
332
+ });
333
+
334
+ it('fires onAddressesChanged on putSettings and testReachability', async () => {
335
+ const svc = new NatService({
336
+ rootDir: tmpRoot,
337
+ cadreNode: makeNode('12D3KooWHook'),
338
+ secretsStore: makeSecrets(),
339
+ portMapper: new StubMapper(),
340
+ externalIpDetector: makeDetector({ pub: '203.0.113.7' }),
341
+ });
342
+ await svc.start();
343
+
344
+ const fired: string[][] = [];
345
+ svc.onAddressesChanged((addrs) => { fired.push(addrs); });
346
+
347
+ await svc.putSettings({ externalPort: 4002 });
348
+ await svc.testReachability();
349
+
350
+ expect(fired.length).toBeGreaterThanOrEqual(2);
351
+ // The pushed addresses carry the node's peer ID.
352
+ expect(fired[fired.length - 1]!.every((a: string) => a.includes('12D3KooWHook'))).toBe(true);
353
+ });
354
+
355
+ it('onAddressesChanged unsubscribe stops further notifications', async () => {
356
+ const svc = new NatService({
357
+ rootDir: tmpRoot,
358
+ cadreNode: makeNode(),
359
+ secretsStore: makeSecrets(),
360
+ portMapper: new StubMapper(),
361
+ externalIpDetector: makeDetector({ pub: '1.2.3.4' }),
362
+ });
363
+ await svc.start();
364
+ let count = 0;
365
+ const off = svc.onAddressesChanged(() => { count++; });
366
+ await svc.testReachability();
367
+ off();
368
+ await svc.testReachability();
369
+ expect(count).toBe(1);
370
+ });
371
+ });
372
+
373
+ describe('NatService — initial invite-address push retry', () => {
374
+ it('delivers the initial push exactly once when the node is ready on the first attempt', async () => {
375
+ // The common production happy path: node bound fast, listener registered
376
+ // BEFORE start(). No retry needed — but the push must still fire (once).
377
+ const { node, peerIdCalls } = makeFlakyNode(0, '12D3KooWReady');
378
+ const svc = new NatService({
379
+ rootDir: tmpRoot,
380
+ cadreNode: node,
381
+ secretsStore: makeSecrets(),
382
+ portMapper: new StubMapper(),
383
+ externalIpDetector: makeDetector({ pub: '203.0.113.42' }),
384
+ initialPushRetryMs: 1,
385
+ initialPushTimeoutMs: 2_000,
386
+ });
387
+
388
+ const fired: string[][] = [];
389
+ svc.onAddressesChanged((addrs) => { fired.push(addrs); });
390
+
391
+ await svc.start();
392
+
393
+ expect(fired).toEqual([['/ip4/203.0.113.42/tcp/4001/p2p/12D3KooWReady']]);
394
+ expect(peerIdCalls()).toBe(1); // succeeded on the first attempt, no retry
395
+ });
396
+
397
+ it('retries past a not-yet-ready node and delivers the push exactly once', async () => {
398
+ const { node, peerIdCalls } = makeFlakyNode(3, '12D3KooWFlaky');
399
+ const svc = new NatService({
400
+ rootDir: tmpRoot,
401
+ cadreNode: node,
402
+ secretsStore: makeSecrets(),
403
+ portMapper: new StubMapper(),
404
+ externalIpDetector: makeDetector({ pub: '203.0.113.42' }),
405
+ initialPushRetryMs: 1,
406
+ initialPushTimeoutMs: 2_000,
407
+ });
408
+
409
+ // Listener registered BEFORE start() — this is the race the fix closes.
410
+ const fired: string[][] = [];
411
+ svc.onAddressesChanged((addrs) => { fired.push(addrs); });
412
+
413
+ await svc.start();
414
+
415
+ // Delivered exactly once, with the NAT-resolved address carrying the peer ID.
416
+ expect(fired).toEqual([['/ip4/203.0.113.42/tcp/4001/p2p/12D3KooWFlaky']]);
417
+ // It took the failing attempts plus the successful one (proves it retried).
418
+ expect(peerIdCalls()).toBe(4);
419
+ });
420
+
421
+ it('gives up after the bounded timeout but still resolves start() (best-effort)', async () => {
422
+ // Always unavailable — the retry budget elapses without a successful push.
423
+ const node: CadreNodeLike = {
424
+ getPeerId: async () => { throw new OwnerNodeUnavailableError('never ready'); },
425
+ getMultiaddrs: async () => [],
426
+ };
427
+ const svc = new NatService({
428
+ rootDir: tmpRoot,
429
+ cadreNode: node,
430
+ secretsStore: makeSecrets(),
431
+ portMapper: new StubMapper(),
432
+ externalIpDetector: makeDetector({ pub: '203.0.113.42' }),
433
+ initialPushRetryMs: 2,
434
+ initialPushTimeoutMs: 20,
435
+ });
436
+
437
+ const fired: string[][] = [];
438
+ svc.onAddressesChanged((addrs) => { fired.push(addrs); });
439
+
440
+ await svc.start(); // resolves despite the node never coming up
441
+ expect(fired).toEqual([]); // best-effort: nothing delivered
442
+ // The process is otherwise healthy — status still builds.
443
+ expect(svc.getStatus().portMode).toBe('auto-upnp');
444
+ });
445
+
446
+ it('does not retry on a non-node_unavailable error', async () => {
447
+ let calls = 0;
448
+ const node: CadreNodeLike = {
449
+ getPeerId: async () => { calls += 1; throw new Error('boom'); },
450
+ getMultiaddrs: async () => [],
451
+ };
452
+ const svc = new NatService({
453
+ rootDir: tmpRoot,
454
+ cadreNode: node,
455
+ secretsStore: makeSecrets(),
456
+ portMapper: new StubMapper(),
457
+ externalIpDetector: makeDetector({ pub: '203.0.113.42' }),
458
+ initialPushRetryMs: 1,
459
+ initialPushTimeoutMs: 2_000,
460
+ });
461
+ svc.onAddressesChanged(() => { /* registered before start */ });
462
+
463
+ await svc.start(); // does not hang retrying a non-transient failure
464
+ expect(calls).toBe(1);
465
+ });
466
+ });
467
+
276
468
  describe('createNatHandlers', () => {
277
469
  it('every handler delegates to the service', async () => {
278
470
  const mapper = new StubMapper();
@@ -1,4 +1,4 @@
1
- import { describe, expect, it, vi } from 'vitest';
1
+ import { describe, expect, it } from 'vitest';
2
2
 
3
3
  import { NatError } from '../types.js';
4
4
  import {
@@ -5,7 +5,7 @@ import { join } from 'node:path';
5
5
 
6
6
  import { KeytarSecretsStore } from '../secrets/keytar-store.js';
7
7
  import { FileSecretsStore } from '../secrets/file-store.js';
8
- import { ddnsAccount, type KeytarLike } from '../secrets/index.js';
8
+ import { ddnsAccount, resolveKeytarLike, type KeytarLike } from '../secrets/index.js';
9
9
  import { NatError } from '../types.js';
10
10
 
11
11
  function makeFakeKeytar(): {
@@ -60,6 +60,28 @@ describe('ddnsAccount()', () => {
60
60
  });
61
61
  });
62
62
 
63
+ describe('resolveKeytarLike()', () => {
64
+ it('unwraps the CJS-via-ESM default export shape', () => {
65
+ const { keytar } = makeFakeKeytar();
66
+ expect(resolveKeytarLike({ default: keytar })).toBe(keytar);
67
+ });
68
+
69
+ it('passes through a direct named-export shape', () => {
70
+ const { keytar } = makeFakeKeytar();
71
+ expect(resolveKeytarLike(keytar)).toBe(keytar);
72
+ });
73
+
74
+ it('returns null when the shape is missing methods', () => {
75
+ expect(resolveKeytarLike({})).toBeNull();
76
+ expect(resolveKeytarLike({ default: {} })).toBeNull();
77
+ });
78
+
79
+ it('returns null for nullish input without crashing', () => {
80
+ expect(resolveKeytarLike(null)).toBeNull();
81
+ expect(resolveKeytarLike(undefined)).toBeNull();
82
+ });
83
+ });
84
+
63
85
  describe('KeytarSecretsStore', () => {
64
86
  it('round-trips a credential', async () => {
65
87
  const { keytar } = makeFakeKeytar();
package/src/nat/index.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  export { NatService, createNatHandlers } from './nat-service.js';
9
- export type { NatServiceOptions, CadreNodeLike } from './nat-service.js';
9
+ export type { NatServiceOptions, CadreNodeLike, AddressesChangedListener } from './nat-service.js';
10
10
  export { NatStore } from './nat-store.js';
11
11
  export {
12
12
  NatError,
@@ -24,21 +24,37 @@ import {
24
24
  type BuildInviteAddressesInput,
25
25
  } from './address-resolver.js';
26
26
  import { createSecretsStore, ddnsAccount, type SecretsStore } from './secrets/index.js';
27
+ import { OwnerNodeUnavailableError } from '../owner/owner-node-client.js';
27
28
 
28
29
  const log = debug('cadre:host:nat-service');
29
30
 
31
+ /** Sleep helper for the initial-push retry loop; unref'd so it never holds the process open. */
32
+ function sleep(ms: number): Promise<void> {
33
+ return new Promise((res) => {
34
+ const t = setTimeout(res, ms);
35
+ if (typeof t.unref === 'function') t.unref();
36
+ });
37
+ }
38
+
30
39
  /**
31
40
  * Minimal slice of CadreNode that NatService needs — defined as an interface
32
41
  * so tests can inject a mock without a real libp2p stack. Mirrors
33
42
  * `CadreNodeLike` in `auth/trust-circle.ts`.
43
+ *
44
+ * Both methods are async: in production they round-trip over the owner
45
+ * node's loopback admin channel (`GET /admin/identity`, `GET /admin/multiaddrs`).
46
+ * Synchronous test mocks can return a value directly (it's awaited).
34
47
  */
35
48
  export interface CadreNodeLike {
36
49
  /** Returns this node's peer ID (libp2p) as a string. */
37
- getPeerId(): string;
50
+ getPeerId(): Promise<string> | string;
38
51
  /** Returns the current observed multiaddrs from libp2p. */
39
- getMultiaddrs(): string[];
52
+ getMultiaddrs(): Promise<string[]> | string[];
40
53
  }
41
54
 
55
+ /** Listener notified when the invite addresses may have changed. */
56
+ export type AddressesChangedListener = (addresses: string[]) => void | Promise<void>;
57
+
42
58
  export interface NatServiceOptions {
43
59
  /** Cadre-host root directory (same one orchestrator + trust-circle use). */
44
60
  rootDir: string;
@@ -56,6 +72,10 @@ export interface NatServiceOptions {
56
72
  externalIpDetector?: ExternalIpDetector;
57
73
  /** Optional: re-use an existing NatStore (mostly for tests). */
58
74
  store?: NatStore;
75
+ /** Initial-push retry: poll interval while the node is not-yet-ready. Default 250ms. */
76
+ initialPushRetryMs?: number;
77
+ /** Initial-push retry: total budget before giving up (best-effort). Default 15000ms. */
78
+ initialPushTimeoutMs?: number;
59
79
  }
60
80
 
61
81
  /**
@@ -94,10 +114,16 @@ export class NatService {
94
114
  private latestIp: ExternalIpResult | null = null;
95
115
  private lastTestedAt: Date | null = null;
96
116
  private started = false;
117
+ /** Listeners notified when the invite addresses may have changed. */
118
+ private readonly addressListeners = new Set<AddressesChangedListener>();
97
119
 
98
120
  // For createSecretsStore async init.
99
121
  private readonly secretsRootDir: string;
100
122
 
123
+ // Initial invite-address push retry tunables (injectable so tests run fast).
124
+ private readonly initialPushRetryMs: number;
125
+ private readonly initialPushTimeoutMs: number;
126
+
101
127
  constructor(opts: NatServiceOptions) {
102
128
  this.cadreNode = opts.cadreNode;
103
129
  this.store = opts.store ?? new NatStore(opts.rootDir);
@@ -107,6 +133,8 @@ export class NatService {
107
133
  this.portMapper = opts.portMapper ?? null;
108
134
  this.injectedDetector = opts.externalIpDetector ?? null;
109
135
  this.secretsRootDir = opts.rootDir;
136
+ this.initialPushRetryMs = opts.initialPushRetryMs ?? 250;
137
+ this.initialPushTimeoutMs = opts.initialPushTimeoutMs ?? 15_000;
110
138
  this.currentSettings = this.store.load();
111
139
  }
112
140
 
@@ -135,6 +163,10 @@ export class NatService {
135
163
  now: this.nowFn,
136
164
  });
137
165
  await this.ddnsUpdater.start();
166
+
167
+ // Initial invite-address push. Retries on `node_unavailable` until the
168
+ // freshly spawned owner node accepts it (bounded; best-effort after).
169
+ await this.pushInitialAddresses();
138
170
  }
139
171
 
140
172
  /** Release the port mapping and clear timers. Idempotent. */
@@ -198,12 +230,34 @@ export class NatService {
198
230
  if (this.ddnsUpdater) {
199
231
  await this.ddnsUpdater.forceUpdate().catch((err) => log('ddns forceUpdate err: %s', (err as Error).message));
200
232
  }
233
+ // Reachability/IP may have changed → invite addresses may have changed.
234
+ await this.fireAddressesChanged();
201
235
  return this.getStatus();
202
236
  }
203
237
 
204
- /** Build the multiaddrs to embed in invites. */
238
+ /**
239
+ * Build the multiaddrs to embed in invites. The peer ID and libp2p
240
+ * fallback addresses are fetched over the owner node's admin channel; a
241
+ * node-unavailable failure surfaces as `NatError('node_unavailable')`.
242
+ */
205
243
  async getInviteAddresses(): Promise<string[]> {
206
- const peerId = this.cadreNode.getPeerId();
244
+ let peerId: string;
245
+ let libp2pAddrs: string[];
246
+ try {
247
+ peerId = await this.cadreNode.getPeerId();
248
+ libp2pAddrs = await this.cadreNode.getMultiaddrs();
249
+ } catch (err) {
250
+ if (err instanceof OwnerNodeUnavailableError) {
251
+ throw new NatError('node_unavailable', `Owner node unavailable: ${err.message}`);
252
+ }
253
+ throw err;
254
+ }
255
+ // A reachable node that hasn't yet established its libp2p peer ID reports
256
+ // an empty peerId. Building addresses now would mint malformed `…/p2p/`
257
+ // suffixes (and push them to the node), so treat it as not-ready.
258
+ if (!peerId) {
259
+ throw new NatError('node_unavailable', 'Owner node has no peer ID yet (not ready)');
260
+ }
207
261
  const status = this.getStatus();
208
262
  const input: BuildInviteAddressesInput = {
209
263
  peerId,
@@ -211,11 +265,79 @@ export class NatService {
211
265
  ddnsHostname: status.ddns.hostname,
212
266
  externalIp: status.externalIp,
213
267
  reachable: status.directReachability === 'reachable',
214
- libp2pAddrs: this.cadreNode.getMultiaddrs(),
268
+ libp2pAddrs,
215
269
  };
216
270
  return buildInviteAddresses(input);
217
271
  }
218
272
 
273
+ /**
274
+ * Register a listener fired when the invite addresses may have changed
275
+ * (after `start`, `putSettings`, and `testReachability`). `cadre-host start`
276
+ * wires this to `OwnerNodeClient.pushInviteAddresses`. Returns an
277
+ * unsubscribe fn.
278
+ */
279
+ onAddressesChanged(listener: AddressesChangedListener): () => void {
280
+ this.addressListeners.add(listener);
281
+ return () => { this.addressListeners.delete(listener); };
282
+ }
283
+
284
+ /**
285
+ * Recompute invite addresses and notify listeners. Best-effort: when the
286
+ * owner node is unreachable (or the build fails), the notification is
287
+ * skipped — the push is retried on the next NAT event.
288
+ */
289
+ private async fireAddressesChanged(): Promise<void> {
290
+ if (this.addressListeners.size === 0) return;
291
+ let addresses: string[];
292
+ try {
293
+ addresses = await this.getInviteAddresses();
294
+ } catch (err) {
295
+ log('onAddressesChanged skipped: %s', (err as Error).message);
296
+ return;
297
+ }
298
+ await this.notifyAddressListeners(addresses);
299
+ }
300
+
301
+ /** Deliver an address set to every registered listener (errors are logged, not thrown). */
302
+ private async notifyAddressListeners(addresses: string[]): Promise<void> {
303
+ for (const listener of this.addressListeners) {
304
+ try {
305
+ await listener(addresses);
306
+ } catch (err) {
307
+ log('address listener threw: %s', (err as Error).message);
308
+ }
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Initial invite-address push. The owner node's admin channel is a freshly
314
+ * spawned detached child that may not be bound yet (and may not have a libp2p
315
+ * peer ID yet), so a single attempt races readiness and silently drops the first
316
+ * NAT-resolved address set. Retry on `node_unavailable` until the node accepts it
317
+ * or the bounded budget elapses (best-effort thereafter). Awaited by start() so
318
+ * the management API — the invite-minting path — does not come up first.
319
+ */
320
+ private async pushInitialAddresses(): Promise<void> {
321
+ if (this.addressListeners.size === 0) return;
322
+ const deadline = this.nowFn().getTime() + this.initialPushTimeoutMs;
323
+ for (;;) {
324
+ if (!this.started) return; // stop() ran during a retry
325
+ try {
326
+ const addresses = await this.getInviteAddresses();
327
+ await this.notifyAddressListeners(addresses);
328
+ return;
329
+ } catch (err) {
330
+ const code = err instanceof NatError ? err.code : undefined;
331
+ // Only retry transient not-ready; surface anything unexpected via log + give up.
332
+ if (code !== 'node_unavailable' || this.nowFn().getTime() >= deadline) {
333
+ log('initial invite-address push not delivered: %s', (err as Error).message);
334
+ return;
335
+ }
336
+ await sleep(this.initialPushRetryMs);
337
+ }
338
+ }
339
+ }
340
+
219
341
  /** Current settings (cheap read; for handlers to surface to UI). */
220
342
  getSettings(): NatSettingsFile {
221
343
  return this.currentSettings;
@@ -244,6 +366,10 @@ export class NatService {
244
366
  if (ddnsChanged && this.ddnsUpdater) {
245
367
  await this.ddnsUpdater.updateSettings(next.ddns);
246
368
  }
369
+ if (upnpChanged || portChanged || ddnsChanged) {
370
+ // Port / DDNS changes alter the advertised invite addresses.
371
+ await this.fireAddressesChanged();
372
+ }
247
373
  }
248
374
  return this.getStatus();
249
375
  }
@@ -40,8 +40,17 @@ export async function createSecretsStore(rootDir: string): Promise<SecretsStore>
40
40
  return makeFileFallback(rootDir, `keytar not installed: ${(err as Error).message}`);
41
41
  }
42
42
 
43
+ // `keytar` is a CJS package; under Node's ESM interop the dynamic import
44
+ // yields a Module Namespace whose `default` is the real `module.exports`.
45
+ // Unwrap before handing it to KeytarSecretsStore.
46
+ const keytar = resolveKeytarLike(keytarMod);
47
+ if (!keytar) {
48
+ log('keytar module loaded but API surface missing; using file fallback');
49
+ return makeFileFallback(rootDir, 'keytar module loaded but API surface missing');
50
+ }
51
+
43
52
  // Probe with a sentinel write — some installs load but throw at runtime.
44
- const ks = new KeytarSecretsStore(KEYTAR_SERVICE, keytarMod as KeytarLike);
53
+ const ks = new KeytarSecretsStore(KEYTAR_SERVICE, keytar);
45
54
  const probeAccount = '__cadre_host_probe__';
46
55
  try {
47
56
  await ks.set(probeAccount, '1');
@@ -54,9 +63,30 @@ export async function createSecretsStore(rootDir: string): Promise<SecretsStore>
54
63
  }
55
64
  }
56
65
 
66
+ /**
67
+ * Return the first candidate in `[mod, mod.default]` that satisfies the
68
+ * `KeytarLike` shape — or `null` if neither does. Handles the CJS-via-ESM
69
+ * interop case where the dynamic import yields `{ default: keytar }` rather
70
+ * than the keytar surface directly.
71
+ */
72
+ export function resolveKeytarLike(mod: unknown): KeytarLike | null {
73
+ const candidates: unknown[] = [mod, (mod as { default?: unknown } | null)?.default];
74
+ for (const c of candidates) {
75
+ if (
76
+ c &&
77
+ typeof (c as KeytarLike).setPassword === 'function' &&
78
+ typeof (c as KeytarLike).getPassword === 'function' &&
79
+ typeof (c as KeytarLike).deletePassword === 'function' &&
80
+ typeof (c as KeytarLike).findCredentials === 'function'
81
+ ) {
82
+ return c as KeytarLike;
83
+ }
84
+ }
85
+ return null;
86
+ }
87
+
57
88
  function makeFileFallback(rootDir: string, reason: string): SecretsStore {
58
89
  const store = new FileSecretsStore(rootDir);
59
- // eslint-disable-next-line no-console
60
90
  console.warn(
61
91
  `[cadre-host] DDNS credentials will be stored UNENCRYPTED at ` +
62
92
  `${store.filePath()} (${reason}). Install keytar's native dependencies ` +
package/src/nat/types.ts CHANGED
@@ -106,7 +106,9 @@ export type NatErrorCode =
106
106
  | 'ddns_update_failed'
107
107
  | 'secrets_unavailable'
108
108
  | 'storage_error'
109
- | 'invalid_config';
109
+ | 'invalid_config'
110
+ /** The owner node's admin channel is unreachable / not ready. */
111
+ | 'node_unavailable';
110
112
 
111
113
  /** Typed error carrying a stable `code` for HTTP mapping in the local-ui. */
112
114
  export class NatError extends Error {