@serfab/cadre-host 0.7.1 → 0.8.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 (135) hide show
  1. package/README.md +296 -101
  2. package/dist/auth/trust-circle.d.ts +44 -6
  3. package/dist/auth/trust-circle.js +115 -58
  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/authority/authority-node-client.d.ts +77 -0
  8. package/dist/authority/authority-node-client.js +143 -0
  9. package/dist/authority/authority-node-client.js.map +1 -0
  10. package/dist/authority/index.d.ts +11 -0
  11. package/dist/authority/index.js +11 -0
  12. package/dist/authority/index.js.map +1 -0
  13. package/dist/bin/host.js +210 -97
  14. package/dist/bin/host.js.map +1 -1
  15. package/dist/index.d.ts +10 -6
  16. package/dist/index.js +6 -3
  17. package/dist/index.js.map +1 -1
  18. package/dist/installer/browser.js +16 -17
  19. package/dist/installer/browser.js.map +1 -1
  20. package/dist/installer/config.d.ts +25 -0
  21. package/dist/installer/config.js +1 -1
  22. package/dist/installer/config.js.map +1 -1
  23. package/dist/installer/index.d.ts +5 -2
  24. package/dist/installer/index.js +14 -10
  25. package/dist/installer/index.js.map +1 -1
  26. package/dist/installer/service-host/launchd.js +1 -1
  27. package/dist/installer/service-host/launchd.js.map +1 -1
  28. package/dist/installer/service-host/systemd.js +1 -1
  29. package/dist/installer/service-host/systemd.js.map +1 -1
  30. package/dist/nat/index.d.ts +1 -1
  31. package/dist/nat/nat-service.d.ts +45 -3
  32. package/dist/nat/nat-service.js +116 -3
  33. package/dist/nat/nat-service.js.map +1 -1
  34. package/dist/nat/secrets/index.d.ts +7 -0
  35. package/dist/nat/secrets/index.js +28 -2
  36. package/dist/nat/secrets/index.js.map +1 -1
  37. package/dist/nat/types.d.ts +3 -1
  38. package/dist/nat/types.js.map +1 -1
  39. package/dist/orchestrator/host-process-orchestrator.d.ts +60 -1
  40. package/dist/orchestrator/host-process-orchestrator.js +246 -39
  41. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  42. package/dist/orchestrator/index.d.ts +3 -2
  43. package/dist/orchestrator/index.js +1 -1
  44. package/dist/orchestrator/index.js.map +1 -1
  45. package/dist/orchestrator/state-store.d.ts +6 -5
  46. package/dist/orchestrator/state-store.js +10 -2
  47. package/dist/orchestrator/state-store.js.map +1 -1
  48. package/dist/orchestrator/types.d.ts +45 -10
  49. package/dist/orchestrator/types.js.map +1 -1
  50. package/dist/push/index.d.ts +67 -0
  51. package/dist/push/index.js +101 -0
  52. package/dist/push/index.js.map +1 -0
  53. package/dist/server/error-handler.d.ts +2 -0
  54. package/dist/server/error-handler.js +4 -0
  55. package/dist/server/error-handler.js.map +1 -1
  56. package/dist/server/routes/nodes.d.ts +4 -3
  57. package/dist/server/routes/nodes.js +38 -24
  58. package/dist/server/routes/nodes.js.map +1 -1
  59. package/dist/server/static.d.ts +12 -0
  60. package/dist/server/static.js +24 -7
  61. package/dist/server/static.js.map +1 -1
  62. package/dist/ui/assets/index-BlMXQPSw.js +14 -0
  63. package/dist/ui/assets/{index-C9uChk2P.js.map → index-BlMXQPSw.js.map} +1 -1
  64. package/dist/ui/index.html +1 -1
  65. package/dist/update/apply.d.ts +23 -1
  66. package/dist/update/apply.js +46 -4
  67. package/dist/update/apply.js.map +1 -1
  68. package/dist/update/index.d.ts +14 -3
  69. package/dist/update/index.js +35 -2
  70. package/dist/update/index.js.map +1 -1
  71. package/dist/update/manifest.d.ts +28 -9
  72. package/dist/update/manifest.js +56 -29
  73. package/dist/update/manifest.js.map +1 -1
  74. package/dist/update/release-key.d.ts +18 -7
  75. package/dist/update/release-key.js +28 -15
  76. package/dist/update/release-key.js.map +1 -1
  77. package/dist/update/sign.d.ts +46 -0
  78. package/dist/update/sign.js +53 -0
  79. package/dist/update/sign.js.map +1 -0
  80. package/package.json +9 -4
  81. package/service/README.md +95 -86
  82. package/src/__tests__/cli-invite.smoke.test.ts +6 -1
  83. package/src/__tests__/cli-nat.smoke.test.ts +6 -1
  84. package/src/__tests__/cli.smoke.test.ts +6 -1
  85. package/src/__tests__/host-authority.smoke.test.ts +123 -0
  86. package/src/__tests__/orchestrator-authority.test.ts +198 -0
  87. package/src/__tests__/orchestrator-push.test.ts +145 -0
  88. package/src/auth/__tests__/trust-circle-integration.test.ts +40 -21
  89. package/src/auth/__tests__/trust-circle.test.ts +90 -23
  90. package/src/auth/trust-circle.ts +363 -304
  91. package/src/auth/types.ts +4 -1
  92. package/src/authority/__tests__/authority-node-client.test.ts +166 -0
  93. package/src/authority/authority-node-client.ts +199 -0
  94. package/src/authority/index.ts +12 -0
  95. package/src/bin/host.ts +1038 -905
  96. package/src/index.ts +34 -3
  97. package/src/installer/__tests__/installer.smoke.test.ts +64 -0
  98. package/src/installer/browser.ts +40 -42
  99. package/src/installer/config.ts +27 -1
  100. package/src/installer/index.ts +18 -12
  101. package/src/installer/service-host/launchd.ts +1 -1
  102. package/src/installer/service-host/systemd.ts +1 -1
  103. package/src/nat/__tests__/ddns-updater.test.ts +1 -1
  104. package/src/nat/__tests__/nat-service.test.ts +192 -0
  105. package/src/nat/__tests__/port-mapper.test.ts +1 -1
  106. package/src/nat/__tests__/secrets.test.ts +23 -1
  107. package/src/nat/index.ts +1 -1
  108. package/src/nat/nat-service.ts +131 -5
  109. package/src/nat/secrets/index.ts +32 -2
  110. package/src/nat/types.ts +3 -1
  111. package/src/orchestrator/host-process-orchestrator.ts +294 -54
  112. package/src/orchestrator/index.ts +9 -2
  113. package/src/orchestrator/state-store.ts +21 -3
  114. package/src/orchestrator/types.ts +48 -2
  115. package/src/push/__tests__/push.test.ts +90 -0
  116. package/src/push/index.ts +146 -0
  117. package/src/server/__tests__/error-handler.test.ts +4 -4
  118. package/src/server/__tests__/nodes-route.test.ts +90 -10
  119. package/src/server/__tests__/publishers.test.ts +1 -1
  120. package/src/server/__tests__/static.test.ts +145 -0
  121. package/src/server/__tests__/status-route.test.ts +2 -2
  122. package/src/server/error-handler.ts +4 -0
  123. package/src/server/routes/nodes.ts +48 -26
  124. package/src/server/static.ts +24 -8
  125. package/src/update/__tests__/apply.test.ts +58 -1
  126. package/src/update/__tests__/manifest.test.ts +41 -6
  127. package/src/update/__tests__/release-key.test.ts +29 -0
  128. package/src/update/__tests__/sign.test.ts +78 -0
  129. package/src/update/__tests__/update-service.integration.test.ts +44 -9
  130. package/src/update/apply.ts +46 -4
  131. package/src/update/index.ts +41 -3
  132. package/src/update/manifest.ts +214 -172
  133. package/src/update/release-key.ts +30 -16
  134. package/src/update/sign.ts +74 -0
  135. package/dist/ui/assets/index-C9uChk2P.js +0 -14
package/src/index.ts CHANGED
@@ -27,14 +27,20 @@ export type {
27
27
  ContainerResources,
28
28
  } from '@serfab/cadre-provider';
29
29
 
30
- export { HostProcessOrchestrator } from './orchestrator/index.js';
30
+ export { HostProcessOrchestrator, AUTHORITY_CONTAINER_ID } from './orchestrator/index.js';
31
31
  export type {
32
32
  HostProcessConfig,
33
33
  PersistedHandle,
34
34
  ManagedNodeInfo,
35
35
  NodeStateListener,
36
+ NodePorts,
37
+ AuthorityAdminEndpoint,
38
+ AuthoritySpawnConfig,
36
39
  } from './orchestrator/index.js';
37
40
 
41
+ export { AuthorityNodeClient, AuthorityNodeUnavailableError } from './authority/index.js';
42
+ export type { AuthorityNodeClientOptions } from './authority/index.js';
43
+
38
44
  export {
39
45
  TrustCircleService,
40
46
  TrustCircleStore,
@@ -54,13 +60,14 @@ export type {
54
60
  TrustCircleErrorCode,
55
61
  } from './auth/index.js';
56
62
 
57
- export { Installer } from './installer/index.js';
63
+ export { Installer, readHostConfig, updateHostConfig, writeHostConfig } from './installer/index.js';
58
64
  export type {
59
65
  InstallOptions,
60
66
  InstallResult,
61
67
  UninstallOptions,
62
68
  InstallerOptions,
63
69
  HostConfigFile,
70
+ PushSettings,
64
71
  WizardAnswers,
65
72
  WizardDefaults,
66
73
  ServiceHost,
@@ -68,6 +75,23 @@ export type {
68
75
  ServiceHostStatus,
69
76
  } from './installer/index.js';
70
77
 
78
+ /* ──────────────── push credentials (FCM/APNs) ──────────────── */
79
+
80
+ export {
81
+ resolvePushCredentials,
82
+ setFcmSecret,
83
+ setApnsSecret,
84
+ clearPushSecret,
85
+ pushAccount,
86
+ pushStatus,
87
+ } from './push/index.js';
88
+ export type {
89
+ FcmSecret,
90
+ ApnsSecret,
91
+ PushSecretPlatform,
92
+ PushStatus,
93
+ } from './push/index.js';
94
+
71
95
  export {
72
96
  UpdateService,
73
97
  createUpdateHandlers,
@@ -78,21 +102,28 @@ export {
78
102
  fetchManifest,
79
103
  verifyManifest,
80
104
  canonicalJson,
81
- signManifestForTesting,
105
+ signManifest,
106
+ validateManifestFields,
107
+ buildManifest,
108
+ derivePublicKeyBase64,
109
+ signAndSelfVerify,
82
110
  compareVersions,
83
111
  parseVersion,
84
112
  getReleasePublicKey,
85
113
  getReleasePublicKeyBase64,
86
114
  ed25519FromRaw,
115
+ isPlaceholderReleaseKey,
87
116
  } from './update/index.js';
88
117
  export type {
89
118
  UpdateServiceOptions,
90
119
  UpdateHandlers,
91
120
  UpdateState,
121
+ SignedManifest,
92
122
  UpdateApplyResult,
93
123
  UpdateSettings,
94
124
  UpdateManifest,
95
125
  UpdateErrorCode,
126
+ ManifestFields,
96
127
  NpmExecutor,
97
128
  ServiceRestarter,
98
129
  } from './update/index.js';
@@ -108,6 +108,70 @@ describe('Installer smoke', () => {
108
108
  expect(fake.installCalls).toHaveLength(0);
109
109
  });
110
110
 
111
+ it('passes CLI overrides to the wizard as suggested defaults', async () => {
112
+ // Regression: the interactive wizard previously showed the platform
113
+ // default (`~/.local/share/cadre-host`) in the prompt even when the
114
+ // caller passed `--data-dir <path>`. Whatever the user typed was then
115
+ // silently discarded because the CLI flag won post-hoc. The fix: CLI
116
+ // overrides become the wizard's suggested defaults, and the wizard's
117
+ // returned answers stand on their own.
118
+ const fake = new FakeServiceHost();
119
+ const installer = new Installer({ platform: 'linux' });
120
+ let seenDefaults: { dataDir: string; uiPort: number; libp2pPort: number; upnpEnabled: boolean } | undefined;
121
+ await installer.install({
122
+ nonInteractive: false,
123
+ dataDir: tmp,
124
+ uiPort: 19997,
125
+ libp2pPort: 14003,
126
+ noUpnp: true,
127
+ openBrowser: false,
128
+ noInvite: true,
129
+ serviceHost: fake,
130
+ wizard: async (defaults) => {
131
+ seenDefaults = { ...defaults };
132
+ // Simulate the user pressing Enter at every prompt.
133
+ return {
134
+ dataDir: defaults.dataDir,
135
+ uiPort: defaults.uiPort,
136
+ libp2pPort: defaults.libp2pPort,
137
+ upnpEnabled: defaults.upnpEnabled,
138
+ configureDdns: false,
139
+ };
140
+ },
141
+ });
142
+
143
+ expect(seenDefaults).toEqual({
144
+ dataDir: tmp,
145
+ uiPort: 19997,
146
+ libp2pPort: 14003,
147
+ upnpEnabled: false,
148
+ });
149
+ expect(fake.installCalls).toHaveLength(1);
150
+ expect(fake.installCalls[0]!.dataDir).toBe(tmp);
151
+ });
152
+
153
+ it('honors wizard-typed input when no CLI override is passed', async () => {
154
+ // Mirror of the previous test: with no `--data-dir`, the wizard's
155
+ // returned value is what the installer uses.
156
+ const fake = new FakeServiceHost();
157
+ const installer = new Installer({ platform: 'linux' });
158
+ await installer.install({
159
+ nonInteractive: false,
160
+ openBrowser: false,
161
+ noInvite: true,
162
+ serviceHost: fake,
163
+ wizard: async () => ({
164
+ dataDir: tmp,
165
+ uiPort: 19996,
166
+ libp2pPort: 14004,
167
+ upnpEnabled: true,
168
+ configureDdns: false,
169
+ }),
170
+ });
171
+ expect(fake.installCalls).toHaveLength(1);
172
+ expect(fake.installCalls[0]!.dataDir).toBe(tmp);
173
+ });
174
+
111
175
  it('re-running install preserves the existing identity', async () => {
112
176
  const fake = new FakeServiceHost();
113
177
  const installer = new Installer({ platform: 'linux' });
@@ -1,42 +1,40 @@
1
- /**
2
- * Best-effort URL opener. Never throws — if the platform launcher isn't
3
- * available (headless box, missing xdg-utils, etc.) we just log the URL.
4
- *
5
- * Implemented inline rather than pulling in the `open` npm dep so the
6
- * installer keeps its dependency tree small.
7
- */
8
-
9
- import { spawn } from 'node:child_process';
10
-
11
- export interface OpenBrowserResult {
12
- attempted: boolean;
13
- spawned: boolean;
14
- /** Set when the spawn attempt errored synchronously. */
15
- error?: string;
16
- }
17
-
18
- export function openBrowser(url: string, platform: NodeJS.Platform = process.platform): OpenBrowserResult {
19
- try {
20
- if (platform === 'darwin') {
21
- const child = spawn('open', [url], { detached: true, stdio: 'ignore' });
22
- child.unref();
23
- return { attempted: true, spawned: true };
24
- }
25
- if (platform === 'win32') {
26
- // rundll32 is the most portable way to invoke the registered handler
27
- // without going through cmd.exe (which would require escaping URLs).
28
- const child = spawn('rundll32', ['url.dll,FileProtocolHandler', url], {
29
- detached: true,
30
- stdio: 'ignore',
31
- });
32
- child.unref();
33
- return { attempted: true, spawned: true };
34
- }
35
- // linux + everything else POSIX-ish
36
- const child = spawn('xdg-open', [url], { detached: true, stdio: 'ignore' });
37
- child.unref();
38
- return { attempted: true, spawned: true };
39
- } catch (err) {
40
- return { attempted: true, spawned: false, error: (err as Error).message };
41
- }
42
- }
1
+ /**
2
+ * Best-effort URL opener. Never throws — if the platform launcher isn't
3
+ * available (headless box, missing xdg-utils, etc.) we just log the URL.
4
+ *
5
+ * Implemented inline rather than pulling in the `open` npm dep so the
6
+ * installer keeps its dependency tree small.
7
+ */
8
+
9
+ import { spawn } from 'node:child_process';
10
+
11
+ export interface OpenBrowserResult {
12
+ attempted: boolean;
13
+ spawned: boolean;
14
+ /** Set when the spawn attempt errored synchronously. */
15
+ error?: string;
16
+ }
17
+
18
+ export function openBrowser(url: string, platform: NodeJS.Platform = process.platform): OpenBrowserResult {
19
+ if (platform === 'darwin') return spawnOpener('open', [url]);
20
+ if (platform === 'win32') return spawnOpener('rundll32', ['url.dll,FileProtocolHandler', url]);
21
+ // linux + everything else POSIX-ish
22
+ return spawnOpener('xdg-open', [url]);
23
+ }
24
+
25
+ function spawnOpener(cmd: string, args: string[]): OpenBrowserResult {
26
+ try {
27
+ const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
28
+ // The launcher's absence (ENOENT on a headless box without xdg-utils,
29
+ // etc.) is reported asynchronously via the child's `error` event, not
30
+ // by `spawn()` throwing. Without a listener, Node escalates to an
31
+ // unhandled-error crash and kills the install. Swallow it: this is a
32
+ // best-effort convenience, the install / UI commands have already
33
+ // printed the URL.
34
+ child.on('error', () => { /* best-effort */ });
35
+ child.unref();
36
+ return { attempted: true, spawned: true };
37
+ } catch (err) {
38
+ return { attempted: true, spawned: false, error: (err as Error).message };
39
+ }
40
+ }
@@ -21,6 +21,26 @@ export interface UpdatesConfig {
21
21
  manifestUrl?: string;
22
22
  }
23
23
 
24
+ /**
25
+ * Non-secret push (FCM/APNs) settings. The actual private keys (and the FCM
26
+ * service-account / APNs key identifiers) live in the secret store — see
27
+ * `src/push/`. Only these app-config bits, which are safe to keep in plaintext
28
+ * `host.config.json`, are stored here. Absent ⇒ push is not configured.
29
+ */
30
+ export interface PushSettings {
31
+ /** APNs app-config (the `.p8` private key + key/team ids stay in the secret store). */
32
+ apns?: {
33
+ /** App bundle id → APNs `apns-topic` header. */
34
+ bundleId: string;
35
+ /** `true` → production APNs host; false/undefined → sandbox (dev/TestFlight). */
36
+ production?: boolean;
37
+ };
38
+ /** Per-(peer,strand) anti-spam cooldown (ms) forwarded into the node's push block. */
39
+ cooldownMs?: number;
40
+ /** Per-strand burst-coalescing window (ms) forwarded into the node's push block. */
41
+ debounceMs?: number;
42
+ }
43
+
24
44
  export interface HostConfigFile {
25
45
  version: 2;
26
46
  /** A stable per-install id; useful for log correlation and the control-network partyId. */
@@ -41,6 +61,12 @@ export interface HostConfigFile {
41
61
  installerVersion: string;
42
62
  /** Update-flow settings; defaults: { autoApply: false }. */
43
63
  updates: UpdatesConfig;
64
+ /**
65
+ * Non-secret push settings (bundle id, sandbox/prod toggle, cooldown/debounce).
66
+ * Optional — absent when push is not configured. Private keys never live here;
67
+ * they are read from the secret store at node-spawn time (`src/push/`).
68
+ */
69
+ push?: PushSettings;
44
70
  }
45
71
 
46
72
  const CURRENT_VERSION = 2;
@@ -75,7 +101,7 @@ export function readHostConfig(filePath: string): HostConfigFile {
75
101
  try {
76
102
  parsed = JSON.parse(raw);
77
103
  } catch (err) {
78
- throw new Error(`Failed to parse host.config.json at ${filePath}: ${(err as Error).message}`);
104
+ throw new Error(`Failed to parse host.config.json at ${filePath}: ${(err as Error).message}`, { cause: err });
79
105
  }
80
106
  if (!parsed || typeof parsed !== 'object') {
81
107
  throw new Error(`host.config.json at ${filePath} is not a JSON object`);
@@ -11,7 +11,6 @@
11
11
 
12
12
  import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
13
13
  import { randomBytes } from 'node:crypto';
14
- import { join } from 'node:path';
15
14
  import debug from 'debug';
16
15
 
17
16
  import { NatStore } from '../nat/index.js';
@@ -38,9 +37,11 @@ import {
38
37
  DEFAULT_UI_PORT,
39
38
  DEFAULT_LIBP2P_PORT,
40
39
  type WizardAnswers,
40
+ type WizardDefaults,
41
41
  } from './wizard.js';
42
42
 
43
- export type { HostConfigFile } from './config.js';
43
+ export type { HostConfigFile, PushSettings } from './config.js';
44
+ export { readHostConfig, updateHostConfig, writeHostConfig } from './config.js';
44
45
  export type { WizardAnswers, WizardDefaults } from './wizard.js';
45
46
  export type { ServiceHost, ServiceHostContext, ServiceHostStatus } from './service-host/index.js';
46
47
 
@@ -67,6 +68,8 @@ export interface InstallOptions {
67
68
  nodePath?: string;
68
69
  /** Test-only: stub the service-host registration. */
69
70
  serviceHost?: ServiceHost;
71
+ /** Test-only: stub the interactive wizard. Production callers leave unset. */
72
+ wizard?: (defaults: WizardDefaults) => Promise<WizardAnswers>;
70
73
  }
71
74
 
72
75
  export interface InstallResult {
@@ -232,17 +235,20 @@ export class Installer {
232
235
  if (opts.nonInteractive) {
233
236
  return this.resolveAnswersFromOptions(opts);
234
237
  }
235
- const defaults = defaultsForPlatform(this.platform);
236
- const answers = await runWizard(defaults);
237
- // CLI flags still take precedence over wizard input — useful when the
238
- // user passes `--ui-port 9000` but accepts defaults at the prompts.
239
- return {
240
- dataDir: opts.dataDir ?? answers.dataDir,
241
- uiPort: opts.uiPort ?? answers.uiPort,
242
- libp2pPort: opts.libp2pPort ?? answers.libp2pPort,
243
- upnpEnabled: opts.noUpnp ? false : answers.upnpEnabled,
244
- configureDdns: answers.configureDdns,
238
+ // CLI flags overlay the platform defaults so the wizard's `[...]` hint
239
+ // shows the user's chosen value. Pressing Enter accepts it; typing a new
240
+ // value also works (and now actually has an effect — the previous
241
+ // implementation showed the platform default in the prompt and silently
242
+ // discarded user input because the CLI flag won post-hoc).
243
+ const platformDefaults = defaultsForPlatform(this.platform);
244
+ const defaults: WizardDefaults = {
245
+ dataDir: opts.dataDir ?? platformDefaults.dataDir,
246
+ uiPort: opts.uiPort ?? platformDefaults.uiPort,
247
+ libp2pPort: opts.libp2pPort ?? platformDefaults.libp2pPort,
248
+ upnpEnabled: opts.noUpnp ? false : platformDefaults.upnpEnabled,
245
249
  };
250
+ const wizard = opts.wizard ?? runWizard;
251
+ return await wizard(defaults);
246
252
  }
247
253
  }
248
254
 
@@ -85,6 +85,6 @@ function run(cmd: string, args: string[]): void {
85
85
  try {
86
86
  execFileSync(cmd, args, { stdio: 'inherit' });
87
87
  } catch (err) {
88
- throw new Error(`${cmd} ${args.join(' ')} failed: ${(err as Error).message}`);
88
+ throw new Error(`${cmd} ${args.join(' ')} failed: ${(err as Error).message}`, { cause: err });
89
89
  }
90
90
  }
@@ -83,6 +83,6 @@ function run(cmd: string, args: string[]): void {
83
83
  try {
84
84
  execFileSync(cmd, args, { stdio: 'inherit' });
85
85
  } catch (err) {
86
- throw new Error(`${cmd} ${args.join(' ')} failed: ${(err as Error).message}`);
86
+ throw new Error(`${cmd} ${args.join(' ')} failed: ${(err as Error).message}`, { cause: err });
87
87
  }
88
88
  }
@@ -67,7 +67,7 @@ describe('DdnsUpdater', () => {
67
67
  it('no-op when externallyManaged', async () => {
68
68
  const provider = makeProvider();
69
69
  const secrets = makeSecrets({ [ddnsAccount('duckdns', 'token')]: 'T' });
70
- let ip: string | null = '1.2.3.4';
70
+ const ip: string | null = '1.2.3.4';
71
71
  const u = new DdnsUpdater({
72
72
  settings: { ...baseSettings, externallyManaged: true },
73
73
  secrets,
@@ -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 { AuthorityNodeUnavailableError } from '../../authority/authority-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 authority child: `getPeerId` throws
57
+ * `AuthorityNodeUnavailableError` 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 AuthorityNodeUnavailableError('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 AuthorityNodeUnavailableError('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,