@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/dist/bin/host.js CHANGED
@@ -25,6 +25,9 @@ import { UpdateService } from '../update/index.js';
25
25
  import { HostProcessOrchestrator } from '../orchestrator/index.js';
26
26
  import { TrustCircleService, TrustCircleStore } from '../auth/index.js';
27
27
  import { NatService } from '../nat/index.js';
28
+ import { createSecretsStore } from '../nat/secrets/index.js';
29
+ import { resolvePushCredentials, setFcmSecret, setApnsSecret, clearPushSecret, pushStatus, } from '../push/index.js';
30
+ import { AuthorityNodeClient } from '../authority/index.js';
28
31
  import { createLocalUiServer, HostSettingsStore } from '../server/index.js';
29
32
  import { openBrowser } from '../installer/browser.js';
30
33
  const DEFAULT_PORT = Number(process.env.CADRE_HOST_PORT ?? '8765');
@@ -36,7 +39,6 @@ function looksLikePeerId(id) {
36
39
  function resolvePort(raw) {
37
40
  const n = Number(raw);
38
41
  if (!Number.isFinite(n) || n <= 0 || n > 65535) {
39
- // eslint-disable-next-line no-console
40
42
  console.error(`Invalid --port: ${raw}`);
41
43
  process.exit(1);
42
44
  }
@@ -76,13 +78,9 @@ program
76
78
  system: Boolean(opts.system),
77
79
  ...(opts.nodePath ? { nodePath: opts.nodePath } : {}),
78
80
  });
79
- // eslint-disable-next-line no-console
80
81
  console.log(`cadre-host installed.`);
81
- // eslint-disable-next-line no-console
82
82
  console.log(` Data dir: ${result.dataDir}`);
83
- // eslint-disable-next-line no-console
84
83
  console.log(` UI: ${result.uiUrl}`);
85
- // eslint-disable-next-line no-console
86
84
  console.log(` Service: ${result.serviceName}`);
87
85
  if (result.enrollmentInvite) {
88
86
  printEnrollmentInvite(result.enrollmentInvite);
@@ -90,7 +88,6 @@ program
90
88
  process.exit(0);
91
89
  }
92
90
  catch (err) {
93
- // eslint-disable-next-line no-console
94
91
  console.error(`install failed: ${err.message}`);
95
92
  process.exit(1);
96
93
  }
@@ -129,12 +126,10 @@ program
129
126
  removeData: Boolean(opts.removeData),
130
127
  ...(opts.dataDir ? { dataDir: opts.dataDir } : {}),
131
128
  });
132
- // eslint-disable-next-line no-console
133
129
  console.log('cadre-host uninstalled.');
134
130
  process.exit(0);
135
131
  }
136
132
  catch (err) {
137
- // eslint-disable-next-line no-console
138
133
  console.error(`uninstall failed: ${err.message}`);
139
134
  process.exit(1);
140
135
  }
@@ -149,14 +144,11 @@ program
149
144
  const installer = new Installer();
150
145
  try {
151
146
  const status = await installer.status();
152
- // eslint-disable-next-line no-console
153
147
  console.log(`Service installed: ${status.installed ? 'yes' : 'no'}`);
154
- // eslint-disable-next-line no-console
155
148
  console.log(`Service running: ${status.running ? 'yes' : 'no'}`);
156
149
  process.exit(0);
157
150
  }
158
151
  catch (err) {
159
- // eslint-disable-next-line no-console
160
152
  console.error(`status failed: ${err.message}`);
161
153
  process.exit(1);
162
154
  }
@@ -176,7 +168,6 @@ program
176
168
  const dataDir = opts.dataDir ?? process.env.CADRE_HOST_DATA_DIR ?? defaultDataDir(platform);
177
169
  const cfgPath = resolveConfigPath(dataDir);
178
170
  if (!existsSync(cfgPath)) {
179
- // eslint-disable-next-line no-console
180
171
  console.error(`cadre-host start: ${cfgPath} not found. ` +
181
172
  `Run \`cadre-host install\` first (or pass --data-dir to an existing install).`);
182
173
  process.exit(1);
@@ -185,12 +176,10 @@ program
185
176
  const cfg = readHostConfig(cfgPath);
186
177
  const idPath = resolveIdentityPath(cfg.dataDir);
187
178
  if (!existsSync(idPath)) {
188
- // eslint-disable-next-line no-console
189
179
  console.error(`cadre-host start: identity file not found at ${idPath}. Re-run \`cadre-host install\`.`);
190
180
  process.exit(1);
191
181
  return;
192
182
  }
193
- // eslint-disable-next-line no-console
194
183
  console.log(`cadre-host starting (dataDir=${cfg.dataDir}, uiPort=${cfg.uiPort})`);
195
184
  // Update flow: notify-by-default; auto-apply opt-in via host.config.json.
196
185
  // The local-UI ticket (6.5) wires the handlers into HTTP routes; here we
@@ -223,29 +212,66 @@ program
223
212
  updateHostConfig(cfgPath, { updates: next });
224
213
  }
225
214
  catch (err) {
226
- // eslint-disable-next-line no-console
227
215
  console.error(`failed to persist updated settings to ${cfgPath}: ${err.message}`);
228
216
  }
229
217
  });
230
218
  // Don't block startup on the network round-trip — call and forget.
231
219
  void updateService.check();
232
220
  updateService.start();
233
- // Wire the long-lived HTTP management server. The trust-circle and
234
- // NAT services depend on a libp2p `CadreNodeLike`; v1 has no node-
235
- // spawning path yet (that's a follow-up ticket), so we inject minimal
236
- // stubs that fail loudly if a real call is attempted. The /auth/* and
237
- // /nat/* routes are still mounted so the CLI's existing HTTP contract
238
- // is intact — operations that genuinely need the libp2p node will
239
- // surface a NatError / TrustCircleError, mapped to 500 by the server.
240
- const orchestrator = new HostProcessOrchestrator({ rootDir: join(cfg.dataDir, 'orchestrator') });
221
+ // Wire the long-lived HTTP management server. The manager holds no
222
+ // in-process cadre node: it spawns the admin's **authority node** as a
223
+ // managed child and delegates authority/membership/identity operations
224
+ // to it over the node's loopback admin channel (the 6.6 contract). The
225
+ // manager never joins the control network (see docs/cadre-host.md
226
+ // § Control-plane separation).
227
+ // Push (FCM/APNs) credentials are resolved fresh on every node spawn so a
228
+ // restart re-reads the secret store (rotated keys) and nothing raw is
229
+ // persisted in state.json. The non-secret bits (bundle id / sandbox toggle,
230
+ // cooldown/debounce) are re-read from host.config.json each call too.
231
+ const pushSecrets = await createSecretsStore(cfg.dataDir);
232
+ const orchestrator = new HostProcessOrchestrator({
233
+ rootDir: join(cfg.dataDir, 'orchestrator'),
234
+ pushResolver: () => resolvePushCredentials(pushSecrets, readHostConfig(cfgPath).push),
235
+ });
241
236
  await orchestrator.init();
237
+ // Spawn the authority node. Best-effort: a spawn failure leaves the
238
+ // management API up (trust-circle listing degrades to local labels,
239
+ // authority ops return 503) rather than taking down the whole process.
240
+ try {
241
+ await orchestrator.ensureAuthorityNode({
242
+ identityPath: idPath,
243
+ partyId: cfg.installId,
244
+ libp2pPort: cfg.libp2pPort,
245
+ });
246
+ console.log('cadre-host: authority node spawned');
247
+ }
248
+ catch (err) {
249
+ console.error(`authority node spawn failed: ${err.message}`);
250
+ }
251
+ // The client reads the admin endpoint lazily so a node restart's fresh
252
+ // bearer token is picked up automatically.
253
+ const authority = new AuthorityNodeClient(() => orchestrator.getAuthorityAdminEndpoint());
242
254
  const trustCircle = new TrustCircleService({
243
- cadreNode: missingCadreNodeStub('trust-circle'),
255
+ cadreNode: authority,
244
256
  store: new TrustCircleStore(cfg.dataDir),
245
257
  });
246
258
  const natService = new NatService({
247
259
  rootDir: cfg.dataDir,
248
- cadreNode: missingNatNodeStub(),
260
+ cadreNode: authority,
261
+ });
262
+ // Push NAT-resolved invite addresses to the node on every NAT change.
263
+ // NatService.start() also fires this once as an initial push, retried
264
+ // until the freshly spawned authority node accepts it (bounded; the
265
+ // management API that mints invites comes up only after start() resolves).
266
+ natService.onAddressesChanged(async (addresses) => {
267
+ if (addresses.length === 0)
268
+ return;
269
+ try {
270
+ await authority.pushInviteAddresses(addresses);
271
+ }
272
+ catch (err) {
273
+ console.error(`invite-address push failed: ${err.message}`);
274
+ }
249
275
  });
250
276
  // Best-effort NAT start. Failures here aren't fatal — the local UI
251
277
  // can still serve the trust circle, settings, etc.
@@ -253,7 +279,6 @@ program
253
279
  await natService.start();
254
280
  }
255
281
  catch (err) {
256
- // eslint-disable-next-line no-console
257
282
  console.error(`NAT start failed: ${err.message}`);
258
283
  }
259
284
  const settingsStore = new HostSettingsStore({ dataDir: cfg.dataDir });
@@ -268,10 +293,8 @@ program
268
293
  });
269
294
  const { url, port } = await server.start();
270
295
  if (port !== cfg.uiPort) {
271
- // eslint-disable-next-line no-console
272
296
  console.log(`(configured port ${cfg.uiPort} was in use — bound on ${port} instead)`);
273
297
  }
274
- // eslint-disable-next-line no-console
275
298
  console.log(`cadre-host local UI: ${url}`);
276
299
  await waitForTermination();
277
300
  try {
@@ -282,13 +305,15 @@ program
282
305
  await natService.stop();
283
306
  }
284
307
  catch { /* ignore */ }
308
+ try {
309
+ await orchestrator.stopAuthorityNode();
310
+ }
311
+ catch { /* ignore */ }
285
312
  updateService.stop();
286
- // eslint-disable-next-line no-console
287
313
  console.log('cadre-host stopped.');
288
314
  process.exit(0);
289
315
  }
290
316
  catch (err) {
291
- // eslint-disable-next-line no-console
292
317
  console.error(`start failed: ${err.message}`);
293
318
  process.exit(1);
294
319
  }
@@ -307,30 +332,25 @@ program
307
332
  const dataDir = opts.dataDir ?? process.env.CADRE_HOST_DATA_DIR ?? defaultDataDir(platform);
308
333
  const cfgPath = resolveConfigPath(dataDir);
309
334
  if (!existsSync(cfgPath)) {
310
- // eslint-disable-next-line no-console
311
335
  console.error(`cadre-host ui: ${cfgPath} not found. Run \`cadre-host install\` first.`);
312
336
  process.exit(1);
313
337
  return;
314
338
  }
315
339
  const cfg = readHostConfig(cfgPath);
316
340
  const url = `http://127.0.0.1:${cfg.uiPort}`;
317
- // eslint-disable-next-line no-console
318
341
  console.log(url);
319
342
  if (opts.browser !== false) {
320
343
  const res = openBrowser(url);
321
344
  if (res.spawned) {
322
- // eslint-disable-next-line no-console
323
345
  console.log('(Opening in your default browser...)');
324
346
  }
325
347
  else if (res.error) {
326
- // eslint-disable-next-line no-console
327
348
  console.error(`(unable to launch browser: ${res.error})`);
328
349
  }
329
350
  }
330
351
  process.exit(0);
331
352
  }
332
353
  catch (err) {
333
- // eslint-disable-next-line no-console
334
354
  console.error(`ui failed: ${err.message}`);
335
355
  process.exit(1);
336
356
  }
@@ -355,28 +375,6 @@ function readPackageVersionForStart() {
355
375
  return '0.0.0-unknown';
356
376
  }
357
377
  }
358
- /**
359
- * Returns a `CadreNodeLike` for TrustCircleService that fails loudly on any
360
- * libp2p call. cadre-host v1 doesn't yet spawn the host's own cadre node —
361
- * that ticket is separate. The trust-circle HTTP endpoints still load the
362
- * local labels/pending file (listing works), but issuing/redeeming an invite
363
- * requires a real node and surfaces a 500.
364
- */
365
- function missingCadreNodeStub(name) {
366
- return {
367
- async createInvite() { throw new Error(`${name}: cadre-host v1 has no inline cadre node — cannot issue invite`); },
368
- async acceptPhone() { throw new Error(`${name}: cadre-host v1 has no inline cadre node — cannot redeem`); },
369
- async removePeer() { throw new Error(`${name}: cadre-host v1 has no inline cadre node — cannot remove peer`); },
370
- encodeInvite() { throw new Error(`${name}: cadre-host v1 has no inline cadre node — cannot encode invite`); },
371
- getControlDatabase() { return null; },
372
- };
373
- }
374
- function missingNatNodeStub() {
375
- return {
376
- getPeerId() { return ''; },
377
- getMultiaddrs() { return []; },
378
- };
379
- }
380
378
  function waitForTermination() {
381
379
  return new Promise((resolveTerm) => {
382
380
  const done = () => resolveTerm();
@@ -398,23 +396,18 @@ async function confirmDestructive(message) {
398
396
  const requireForQr = createRequire(import.meta.url);
399
397
  function printEnrollmentInvite(invite) {
400
398
  // Best-effort QR render — fall back to the bare token if the lib chokes.
401
- // eslint-disable-next-line no-console
402
399
  console.log('\nEnroll your first device with this invite:');
403
400
  try {
404
401
  const qr = requireForQr('qrcode-terminal');
405
402
  qr.generate(invite.encodedInvite, { small: true }, (rendered) => {
406
- // eslint-disable-next-line no-console
407
403
  console.log(rendered);
408
404
  });
409
405
  }
410
406
  catch (err) {
411
- // eslint-disable-next-line no-console
412
407
  console.error(`(qrcode-terminal unavailable: ${err.message})`);
413
408
  }
414
- // eslint-disable-next-line no-console
415
409
  console.log(` ${invite.encodedInvite}`);
416
410
  if (invite.expiresAt) {
417
- // eslint-disable-next-line no-console
418
411
  console.log(` (expires ${invite.expiresAt})`);
419
412
  }
420
413
  }
@@ -431,7 +424,6 @@ program
431
424
  ttlMs = parseDuration(opts.ttl);
432
425
  }
433
426
  catch (err) {
434
- // eslint-disable-next-line no-console
435
427
  console.error(`Invalid --ttl: ${err.message}`);
436
428
  process.exit(1);
437
429
  return;
@@ -447,7 +439,6 @@ program
447
439
  });
448
440
  }
449
441
  catch (err) {
450
- // eslint-disable-next-line no-console
451
442
  console.error(`Failed to reach cadre-host at ${url}: ${err.message}\n` +
452
443
  `Hint: is cadre-host running? Try \`cadre-host start\`.`);
453
444
  process.exit(2);
@@ -455,22 +446,18 @@ program
455
446
  }
456
447
  if (!response.ok) {
457
448
  const text = await response.text().catch(() => '');
458
- // eslint-disable-next-line no-console
459
449
  console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
460
450
  process.exit(1);
461
451
  return;
462
452
  }
463
453
  const body = await response.json();
464
454
  if (!body.encodedInvite) {
465
- // eslint-disable-next-line no-console
466
455
  console.error('cadre-host returned malformed response (missing encodedInvite)');
467
456
  process.exit(1);
468
457
  return;
469
458
  }
470
- // eslint-disable-next-line no-console
471
459
  console.log(body.encodedInvite);
472
460
  if (body.expiresAt) {
473
- // eslint-disable-next-line no-console
474
461
  console.error(`(expires at ${body.expiresAt})`);
475
462
  }
476
463
  process.exit(0);
@@ -490,41 +477,33 @@ trust
490
477
  response = await fetch(url);
491
478
  }
492
479
  catch (err) {
493
- // eslint-disable-next-line no-console
494
480
  console.error(`Failed to reach cadre-host at ${url}: ${err.message}`);
495
481
  process.exit(2);
496
482
  return;
497
483
  }
498
484
  if (!response.ok) {
499
- // eslint-disable-next-line no-console
500
485
  console.error(`cadre-host returned ${response.status}: ${response.statusText}`);
501
486
  process.exit(1);
502
487
  return;
503
488
  }
504
489
  const body = await response.json();
505
- // eslint-disable-next-line no-console
506
490
  console.log('Members:');
507
491
  if (body.members.length === 0) {
508
- // eslint-disable-next-line no-console
509
492
  console.log(' (none)');
510
493
  }
511
494
  else {
512
495
  for (const m of body.members) {
513
496
  const self = m.self ? ' [self]' : '';
514
- // eslint-disable-next-line no-console
515
497
  console.log(` ${m.peerId} ${m.label}${self}`);
516
498
  }
517
499
  }
518
- // eslint-disable-next-line no-console
519
500
  console.log('\nPending invites:');
520
501
  if (body.pending.length === 0) {
521
- // eslint-disable-next-line no-console
522
502
  console.log(' (none)');
523
503
  }
524
504
  else {
525
505
  for (const p of body.pending) {
526
506
  const expires = p.expiresAt ? ` (expires ${p.expiresAt})` : '';
527
- // eslint-disable-next-line no-console
528
507
  console.log(` ${p.token} ${p.label}${expires}`);
529
508
  }
530
509
  }
@@ -550,19 +529,16 @@ trust
550
529
  response = await fetch(`${base}${path}`, { method: 'DELETE' });
551
530
  }
552
531
  catch (err) {
553
- // eslint-disable-next-line no-console
554
532
  console.error(`Failed to reach cadre-host at ${base}: ${err.message}`);
555
533
  process.exit(2);
556
534
  return;
557
535
  }
558
536
  if (!response.ok) {
559
537
  const text = await response.text().catch(() => '');
560
- // eslint-disable-next-line no-console
561
538
  console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
562
539
  process.exit(1);
563
540
  return;
564
541
  }
565
- // eslint-disable-next-line no-console
566
542
  console.log(`revoked ${kind}: ${id}`);
567
543
  process.exit(0);
568
544
  });
@@ -582,7 +558,6 @@ nat
582
558
  const url = `http://${opts.host}:${resolvePort(opts.port)}/nat/status`;
583
559
  const body = await getJson(url);
584
560
  if (opts.json) {
585
- // eslint-disable-next-line no-console
586
561
  console.log(JSON.stringify(body, null, 2));
587
562
  }
588
563
  else {
@@ -600,7 +575,6 @@ nat
600
575
  const url = `http://${opts.host}:${resolvePort(opts.port)}/nat/test`;
601
576
  const body = await postJson(url, {});
602
577
  if (opts.json) {
603
- // eslint-disable-next-line no-console
604
578
  console.log(JSON.stringify(body, null, 2));
605
579
  }
606
580
  else {
@@ -645,7 +619,6 @@ ddns
645
619
  .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
646
620
  .action(async (provider, opts) => {
647
621
  if (!opts.hostname) {
648
- // eslint-disable-next-line no-console
649
622
  console.error('--hostname is required');
650
623
  process.exit(1);
651
624
  return;
@@ -655,7 +628,6 @@ ddns
655
628
  config.token = opts.token;
656
629
  if (!config.token) {
657
630
  if (!process.stdin.isTTY) {
658
- // eslint-disable-next-line no-console
659
631
  console.error('Missing --token for provider "' + provider + '" and stdin is not a TTY. ' +
660
632
  'Re-run with --token <secret>.');
661
633
  process.exit(1);
@@ -665,7 +637,6 @@ ddns
665
637
  config.token = await readSecretLine('DDNS token: ');
666
638
  }
667
639
  catch (err) {
668
- // eslint-disable-next-line no-console
669
640
  console.error('Failed to read token: ' + err.message);
670
641
  process.exit(1);
671
642
  return;
@@ -699,6 +670,160 @@ ddns
699
670
  printNatStatus(body);
700
671
  process.exit(0);
701
672
  });
673
+ // ============================================================================
674
+ // push subcommands — provision FCM/APNs credentials for strand-wake delivery
675
+ // ============================================================================
676
+ //
677
+ // These operate directly on the data dir's secret store + host.config.json (they
678
+ // do NOT go through the running management API). Private keys land in the OS
679
+ // keychain (keytar) or the 0600 file-store fallback; the non-secret bits (APNs
680
+ // bundle id / sandbox toggle, cooldown/debounce) land in host.config.json. New
681
+ // credentials take effect on the next authority-node (re)spawn — run
682
+ // `cadre-host` restart (or restart the service) to apply them immediately.
683
+ const push = program
684
+ .command('push')
685
+ .description('Configure FCM/APNs push credentials for mobile strand-wake delivery');
686
+ push
687
+ .command('fcm')
688
+ .description('Store Firebase Cloud Messaging (Android) service-account credentials')
689
+ .requiredOption('--project-id <id>', 'GCP / Firebase project id')
690
+ .requiredOption('--client-email <email>', 'Service-account email')
691
+ .option('--private-key-file <path>', 'Path to the service-account private key (PEM)')
692
+ .option('--private-key <pem>', 'Inline PEM private key (prefer --private-key-file)')
693
+ .option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
694
+ .action(async (opts) => {
695
+ const { dataDir } = resolveHostPaths(opts.dataDir);
696
+ const privateKey = readPrivateKeyArg(opts.privateKeyFile, opts.privateKey, 'FCM');
697
+ const secrets = await createSecretsStore(dataDir);
698
+ await setFcmSecret(secrets, {
699
+ projectId: opts.projectId,
700
+ clientEmail: opts.clientEmail,
701
+ privateKey,
702
+ });
703
+ console.log('✓ FCM credentials stored. Restart cadre-host to apply on the authority node.');
704
+ process.exit(0);
705
+ });
706
+ push
707
+ .command('apns')
708
+ .description('Store Apple Push Notification service (iOS) auth-key credentials')
709
+ .requiredOption('--key-id <id>', 'APNs key id (the .p8 key identifier)')
710
+ .requiredOption('--team-id <id>', 'Apple developer team id')
711
+ .requiredOption('--bundle-id <id>', 'App bundle id (becomes the apns-topic)')
712
+ .option('--private-key-file <path>', 'Path to the .p8 auth key (PEM)')
713
+ .option('--private-key <pem>', 'Inline .p8 PEM key (prefer --private-key-file)')
714
+ .option('--production', 'Target the production APNs host (default: sandbox)')
715
+ .option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
716
+ .action(async (opts) => {
717
+ const { dataDir, cfgPath } = resolveHostPaths(opts.dataDir);
718
+ const privateKey = readPrivateKeyArg(opts.privateKeyFile, opts.privateKey, 'APNs');
719
+ const secrets = await createSecretsStore(dataDir);
720
+ // Secret bits → secret store; non-secret app-config → host.config.json.
721
+ await setApnsSecret(secrets, { keyId: opts.keyId, teamId: opts.teamId, privateKey });
722
+ const current = readHostConfig(cfgPath);
723
+ updateHostConfig(cfgPath, {
724
+ push: {
725
+ ...current.push,
726
+ apns: { bundleId: opts.bundleId, production: opts.production === true },
727
+ },
728
+ });
729
+ console.log(`✓ APNs credentials stored (${opts.production ? 'production' : 'sandbox'}). ` +
730
+ `Restart cadre-host to apply on the authority node.`);
731
+ process.exit(0);
732
+ });
733
+ push
734
+ .command('options')
735
+ .description('Set non-secret push tuning (anti-spam cooldown / burst-coalesce window)')
736
+ .option('--cooldown-ms <ms>', 'Per-(peer,strand) minimum gap between wakes', parseIntArg)
737
+ .option('--debounce-ms <ms>', 'Per-strand burst-coalescing window', parseIntArg)
738
+ .option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
739
+ .action((opts) => {
740
+ const { cfgPath } = resolveHostPaths(opts.dataDir);
741
+ const current = readHostConfig(cfgPath);
742
+ const nextPush = { ...current.push };
743
+ if (typeof opts.cooldownMs === 'number')
744
+ nextPush.cooldownMs = opts.cooldownMs;
745
+ if (typeof opts.debounceMs === 'number')
746
+ nextPush.debounceMs = opts.debounceMs;
747
+ updateHostConfig(cfgPath, { push: nextPush });
748
+ console.log('✓ Push options updated.');
749
+ process.exit(0);
750
+ });
751
+ push
752
+ .command('clear')
753
+ .description('Remove stored push credentials')
754
+ .argument('<target>', 'Which to clear: "fcm", "apns", or "all"')
755
+ .option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
756
+ .action(async (target, opts) => {
757
+ if (!['fcm', 'apns', 'all'].includes(target)) {
758
+ console.error(`push clear: target must be one of fcm | apns | all (got "${target}")`);
759
+ process.exit(1);
760
+ return;
761
+ }
762
+ const { dataDir, cfgPath } = resolveHostPaths(opts.dataDir);
763
+ const secrets = await createSecretsStore(dataDir);
764
+ if (target === 'fcm' || target === 'all')
765
+ await clearPushSecret(secrets, 'fcm');
766
+ if (target === 'apns' || target === 'all') {
767
+ await clearPushSecret(secrets, 'apns');
768
+ const current = readHostConfig(cfgPath);
769
+ if (current.push?.apns) {
770
+ const { apns: _drop, ...rest } = current.push;
771
+ updateHostConfig(cfgPath, { push: rest });
772
+ }
773
+ }
774
+ console.log(`✓ Cleared push credentials: ${target}. Restart cadre-host to apply.`);
775
+ process.exit(0);
776
+ });
777
+ push
778
+ .command('status')
779
+ .description('Show which push platforms are configured (no secret material)')
780
+ .option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
781
+ .action(async (opts) => {
782
+ const { dataDir, cfgPath } = resolveHostPaths(opts.dataDir);
783
+ const secrets = await createSecretsStore(dataDir);
784
+ const status = await pushStatus(secrets);
785
+ const cfg = readHostConfig(cfgPath);
786
+ console.log('Push credentials:');
787
+ console.log(` FCM: ${status.fcm ? 'configured' : 'not configured'}`);
788
+ const prod = cfg.push?.apns?.production ? 'production' : 'sandbox';
789
+ const bundle = cfg.push?.apns?.bundleId ? ` bundle=${cfg.push.apns.bundleId} (${prod})` : '';
790
+ console.log(` APNs: ${status.apns ? `configured${bundle}` : 'not configured'}`);
791
+ if (cfg.push?.cooldownMs !== undefined)
792
+ console.log(` cooldownMs: ${cfg.push.cooldownMs}`);
793
+ if (cfg.push?.debounceMs !== undefined)
794
+ console.log(` debounceMs: ${cfg.push.debounceMs}`);
795
+ process.exit(0);
796
+ });
797
+ /** Resolve the data dir + config path for a direct-on-disk push subcommand. */
798
+ function resolveHostPaths(dataDirOpt) {
799
+ const platform = detectPlatform();
800
+ const dataDir = dataDirOpt ?? process.env.CADRE_HOST_DATA_DIR ?? defaultDataDir(platform);
801
+ const cfgPath = resolveConfigPath(dataDir);
802
+ if (!existsSync(cfgPath)) {
803
+ console.error(`${cfgPath} not found. Run \`cadre-host install\` first (or pass --data-dir).`);
804
+ process.exit(1);
805
+ }
806
+ // Resolve the real data dir from the persisted config (handles default-dir installs).
807
+ const cfg = readHostConfig(cfgPath);
808
+ return { dataDir: cfg.dataDir, cfgPath };
809
+ }
810
+ /** Read a private key from a file or inline flag; exit with a clear error if neither. */
811
+ function readPrivateKeyArg(file, inline, label) {
812
+ if (file) {
813
+ try {
814
+ return readFileSync(file, 'utf8');
815
+ }
816
+ catch (err) {
817
+ console.error(`Failed to read ${label} private key file ${file}: ${err.message}`);
818
+ process.exit(1);
819
+ }
820
+ }
821
+ if (inline)
822
+ return inline;
823
+ console.error(`${label}: supply --private-key-file <path> (or --private-key <pem>).`);
824
+ process.exit(1);
825
+ throw new Error('unreachable');
826
+ }
702
827
  async function getJson(url) {
703
828
  return await callJson(url, 'GET');
704
829
  }
@@ -719,7 +844,6 @@ async function callJson(url, method, body) {
719
844
  response = await fetch(url, init);
720
845
  }
721
846
  catch (err) {
722
- // eslint-disable-next-line no-console
723
847
  console.error(`Failed to reach cadre-host at ${url}: ${err.message}\n` +
724
848
  `Hint: is cadre-host running? Try \`cadre-host start\`.`);
725
849
  process.exit(2);
@@ -727,7 +851,6 @@ async function callJson(url, method, body) {
727
851
  }
728
852
  if (!response.ok) {
729
853
  const text = await response.text().catch(() => '');
730
- // eslint-disable-next-line no-console
731
854
  console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
732
855
  process.exit(1);
733
856
  throw new Error('non-ok');
@@ -737,36 +860,26 @@ async function callJson(url, method, body) {
737
860
  return await response.json();
738
861
  }
739
862
  function printNatStatus(s) {
740
- // eslint-disable-next-line no-console
741
863
  console.log(`Port mapping: ${s.portMode ?? '?'} (external ${s.externalPort ?? '?'} → internal ${s.internalPort ?? '?'})`);
742
864
  if (s.routerExternalIp) {
743
- // eslint-disable-next-line no-console
744
865
  console.log(`Router IP: ${s.routerExternalIp}`);
745
866
  }
746
- // eslint-disable-next-line no-console
747
867
  console.log(`External IP: ${s.externalIp ?? '(unknown)'}${s.cgnatDetected ? ' [CGNAT detected]' : ''}`);
748
- // eslint-disable-next-line no-console
749
868
  console.log(`Reachability: ${s.directReachability ?? 'unknown'}${s.lastTestedAt ? ` (tested ${s.lastTestedAt})` : ''}`);
750
869
  if (s.mappingLeaseExpiresAt) {
751
- // eslint-disable-next-line no-console
752
870
  console.log(`Lease expires: ${s.mappingLeaseExpiresAt}`);
753
871
  }
754
872
  const ddns = s.ddns ?? {};
755
873
  if (ddns.providerId || ddns.hostname) {
756
- // eslint-disable-next-line no-console
757
874
  console.log(`\nDDNS:`);
758
- // eslint-disable-next-line no-console
759
875
  console.log(` Provider: ${ddns.providerId ?? '(none)'}${ddns.externallyManaged ? ' [externally managed]' : ''}`);
760
- // eslint-disable-next-line no-console
761
876
  console.log(` Hostname: ${ddns.hostname ?? '(unset)'}`);
762
877
  if (ddns.lastUpdateAt) {
763
878
  const ok = ddns.lastUpdateOk ? 'OK' : 'FAIL';
764
- // eslint-disable-next-line no-console
765
879
  console.log(` Last update: ${ok} at ${ddns.lastUpdateAt}${ddns.lastError ? ` — ${ddns.lastError}` : ''}`);
766
880
  }
767
881
  }
768
882
  else {
769
- // eslint-disable-next-line no-console
770
883
  console.log('\nDDNS: not configured');
771
884
  }
772
885
  }
@@ -822,5 +935,5 @@ function readSecretLine(prompt) {
822
935
  stdin.on('data', onData);
823
936
  });
824
937
  }
825
- program.parseAsync();
938
+ void program.parseAsync();
826
939
  //# sourceMappingURL=host.js.map