@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
@@ -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 { AuthorityNodeUnavailableError } from '../authority/authority-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 authority
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 authority 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 authority 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 AuthorityNodeUnavailableError) {
251
+ throw new NatError('node_unavailable', `Authority 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', 'Authority 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 `AuthorityNodeClient.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
+ * authority 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 authority 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 authority 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 {