@serfab/cadre-host 0.8.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 (136) hide show
  1. package/dist/auth/trust-circle.d.ts +5 -5
  2. package/dist/auth/trust-circle.js +10 -10
  3. package/dist/auth/trust-circle.js.map +1 -1
  4. package/dist/auth/types.d.ts +2 -2
  5. package/dist/bin/host.js +255 -54
  6. package/dist/bin/host.js.map +1 -1
  7. package/dist/donation/donation-service.d.ts +136 -0
  8. package/dist/donation/donation-service.js +306 -0
  9. package/dist/donation/donation-service.js.map +1 -0
  10. package/dist/donation/donation-store.d.ts +42 -0
  11. package/dist/donation/donation-store.js +141 -0
  12. package/dist/donation/donation-store.js.map +1 -0
  13. package/dist/donation/grant-service.d.ts +71 -0
  14. package/dist/donation/grant-service.js +163 -0
  15. package/dist/donation/grant-service.js.map +1 -0
  16. package/dist/donation/grant-store.d.ts +38 -0
  17. package/dist/donation/grant-store.js +135 -0
  18. package/dist/donation/grant-store.js.map +1 -0
  19. package/dist/donation/index.d.ts +18 -0
  20. package/dist/donation/index.js +16 -0
  21. package/dist/donation/index.js.map +1 -0
  22. package/dist/donation/types.d.ts +175 -0
  23. package/dist/donation/types.js +40 -0
  24. package/dist/donation/types.js.map +1 -0
  25. package/dist/index.d.ts +6 -4
  26. package/dist/index.js +4 -2
  27. package/dist/index.js.map +1 -1
  28. package/dist/installer/config.d.ts +26 -0
  29. package/dist/installer/config.js +13 -0
  30. package/dist/installer/config.js.map +1 -1
  31. package/dist/installer/index.d.ts +2 -0
  32. package/dist/installer/index.js +3 -0
  33. package/dist/installer/index.js.map +1 -1
  34. package/dist/installer/wizard.d.ts +4 -0
  35. package/dist/installer/wizard.js +5 -1
  36. package/dist/installer/wizard.js.map +1 -1
  37. package/dist/nat/nat-service.d.ts +5 -5
  38. package/dist/nat/nat-service.js +9 -9
  39. package/dist/nat/nat-service.js.map +1 -1
  40. package/dist/nat/types.d.ts +1 -1
  41. package/dist/orchestrator/host-process-orchestrator.d.ts +35 -27
  42. package/dist/orchestrator/host-process-orchestrator.js +79 -53
  43. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  44. package/dist/orchestrator/index.d.ts +3 -3
  45. package/dist/orchestrator/index.js +1 -1
  46. package/dist/orchestrator/index.js.map +1 -1
  47. package/dist/orchestrator/state-store.d.ts +5 -5
  48. package/dist/orchestrator/state-store.js +2 -2
  49. package/dist/orchestrator/state-store.js.map +1 -1
  50. package/dist/orchestrator/types.d.ts +8 -8
  51. package/dist/owner/index.d.ts +11 -0
  52. package/dist/owner/index.js +11 -0
  53. package/dist/owner/index.js.map +1 -0
  54. package/dist/{authority/authority-node-client.d.ts → owner/owner-node-client.d.ts} +32 -14
  55. package/dist/{authority/authority-node-client.js → owner/owner-node-client.js} +39 -15
  56. package/dist/owner/owner-node-client.js.map +1 -0
  57. package/dist/server/error-handler.d.ts +15 -0
  58. package/dist/server/error-handler.js +41 -0
  59. package/dist/server/error-handler.js.map +1 -1
  60. package/dist/server/index.d.ts +26 -2
  61. package/dist/server/index.js +37 -16
  62. package/dist/server/index.js.map +1 -1
  63. package/dist/server/routes/grants-admin.d.ts +17 -0
  64. package/dist/server/routes/grants-admin.js +32 -0
  65. package/dist/server/routes/grants-admin.js.map +1 -0
  66. package/dist/server/routes/grants.d.ts +28 -0
  67. package/dist/server/routes/grants.js +163 -0
  68. package/dist/server/routes/grants.js.map +1 -0
  69. package/dist/server/routes/nodes.d.ts +5 -4
  70. package/dist/server/routes/nodes.js +19 -18
  71. package/dist/server/routes/nodes.js.map +1 -1
  72. package/dist/server/routes/settings.d.ts +6 -1
  73. package/dist/server/routes/settings.js +5 -2
  74. package/dist/server/routes/settings.js.map +1 -1
  75. package/dist/server/routes/status.d.ts +8 -4
  76. package/dist/server/routes/status.js +9 -4
  77. package/dist/server/routes/status.js.map +1 -1
  78. package/dist/update/release-key.js +7 -5
  79. package/dist/update/release-key.js.map +1 -1
  80. package/package.json +4 -4
  81. package/src/__tests__/{host-authority.smoke.test.ts → host-owner.smoke.test.ts} +7 -7
  82. package/src/__tests__/{orchestrator-authority.test.ts → orchestrator-owner.test.ts} +33 -33
  83. package/src/__tests__/orchestrator-pin-keys.test.ts +153 -0
  84. package/src/__tests__/orchestrator-push.test.ts +10 -10
  85. package/src/auth/__tests__/trust-circle-integration.test.ts +11 -11
  86. package/src/auth/__tests__/trust-circle.test.ts +10 -10
  87. package/src/auth/trust-circle.ts +10 -10
  88. package/src/auth/types.ts +2 -2
  89. package/src/bin/host.ts +273 -53
  90. package/src/donation/__tests__/donation-service.test.ts +290 -0
  91. package/src/donation/__tests__/grant-service.test.ts +213 -0
  92. package/src/donation/__tests__/grant-store.test.ts +113 -0
  93. package/src/donation/donation-service.ts +392 -0
  94. package/src/donation/donation-store.ts +164 -0
  95. package/src/donation/grant-service.ts +200 -0
  96. package/src/donation/grant-store.ts +152 -0
  97. package/src/donation/index.ts +49 -0
  98. package/src/donation/types.ts +220 -0
  99. package/src/index.ts +37 -5
  100. package/src/installer/__tests__/config.test.ts +27 -1
  101. package/src/installer/__tests__/installer.smoke.test.ts +31 -1
  102. package/src/installer/__tests__/wizard.test.ts +5 -1
  103. package/src/installer/config.ts +34 -0
  104. package/src/installer/index.ts +5 -0
  105. package/src/installer/wizard.ts +12 -1
  106. package/src/nat/__tests__/nat-service.test.ts +5 -5
  107. package/src/nat/nat-service.ts +10 -10
  108. package/src/nat/types.ts +1 -1
  109. package/src/orchestrator/host-process-orchestrator.ts +88 -60
  110. package/src/orchestrator/index.ts +3 -3
  111. package/src/orchestrator/state-store.ts +7 -7
  112. package/src/orchestrator/types.ts +8 -8
  113. package/src/{authority/__tests__/authority-node-client.test.ts → owner/__tests__/owner-node-client.test.ts} +45 -17
  114. package/src/owner/index.ts +12 -0
  115. package/src/{authority/authority-node-client.ts → owner/owner-node-client.ts} +55 -21
  116. package/src/server/__tests__/error-handler.test.ts +22 -0
  117. package/src/server/__tests__/grants-admin-route.test.ts +73 -0
  118. package/src/server/__tests__/grants-route.test.ts +189 -0
  119. package/src/server/__tests__/nodes-route.test.ts +25 -25
  120. package/src/server/__tests__/server.smoke.test.ts +75 -0
  121. package/src/server/__tests__/settings-route.test.ts +17 -0
  122. package/src/server/__tests__/status-route.test.ts +21 -0
  123. package/src/server/error-handler.ts +43 -0
  124. package/src/server/index.ts +62 -17
  125. package/src/server/routes/grants-admin.ts +41 -0
  126. package/src/server/routes/grants.ts +186 -0
  127. package/src/server/routes/nodes.ts +19 -18
  128. package/src/server/routes/settings.ts +11 -3
  129. package/src/server/routes/status.ts +18 -8
  130. package/src/update/__tests__/release-key.test.ts +16 -3
  131. package/src/update/release-key.ts +7 -5
  132. package/dist/authority/authority-node-client.js.map +0 -1
  133. package/dist/authority/index.d.ts +0 -11
  134. package/dist/authority/index.js +0 -11
  135. package/dist/authority/index.js.map +0 -1
  136. package/src/authority/index.ts +0 -12
package/src/bin/host.ts CHANGED
@@ -21,7 +21,7 @@ import { Command } from 'commander';
21
21
 
22
22
  import { parseDuration } from '../auth/duration.js';
23
23
  import { Installer } from '../installer/index.js';
24
- import { readHostConfig, updateHostConfig } from '../installer/config.js';
24
+ import { readHostConfig, updateHostConfig, hostOwnsCadre } from '../installer/config.js';
25
25
  import {
26
26
  configPath as resolveConfigPath,
27
27
  defaultDataDir,
@@ -34,6 +34,14 @@ import { createServiceHost } from '../installer/service-host/index.js';
34
34
  import { UpdateService } from '../update/index.js';
35
35
  import { HostProcessOrchestrator } from '../orchestrator/index.js';
36
36
  import { TrustCircleService, TrustCircleStore } from '../auth/index.js';
37
+ import {
38
+ GrantService,
39
+ GrantStore,
40
+ DonationService,
41
+ DonationStore,
42
+ DONATION_AWAITING_SEED_TTL_MS,
43
+ DONATION_REAP_SWEEP_MS,
44
+ } from '../donation/index.js';
37
45
  import { NatService } from '../nat/index.js';
38
46
  import { createSecretsStore } from '../nat/secrets/index.js';
39
47
  import {
@@ -43,7 +51,7 @@ import {
43
51
  clearPushSecret,
44
52
  pushStatus,
45
53
  } from '../push/index.js';
46
- import { AuthorityNodeClient } from '../authority/index.js';
54
+ import { OwnerNodeClient } from '../owner/index.js';
47
55
  import { createLocalUiServer, HostSettingsStore } from '../server/index.js';
48
56
  import { openBrowser } from '../installer/browser.js';
49
57
 
@@ -86,6 +94,7 @@ program
86
94
  .option('--no-upnp', 'Disable UPnP/NAT-PMP probing on first run')
87
95
  .option('--no-browser', 'Do not open a browser after install')
88
96
  .option('--no-invite', 'Skip generating the first enrollment invite')
97
+ .option('--own-cadre', 'Also run the host\'s own personal cadre here (founder persona; default: donor-only)')
89
98
  .option('--system', 'System-wide install (not yet supported in v1)')
90
99
  .option('--node-path <path>', 'Override the node binary embedded in the service unit')
91
100
  .action(async (opts: {
@@ -96,6 +105,7 @@ program
96
105
  upnp?: boolean;
97
106
  browser?: boolean;
98
107
  invite?: boolean;
108
+ ownCadre?: boolean;
99
109
  system?: boolean;
100
110
  nodePath?: string;
101
111
  }) => {
@@ -109,6 +119,7 @@ program
109
119
  noUpnp: opts.upnp === false,
110
120
  openBrowser: opts.browser !== false,
111
121
  noInvite: opts.invite === false,
122
+ ownCadre: opts.ownCadre === true,
112
123
  system: Boolean(opts.system),
113
124
  ...(opts.nodePath ? { nodePath: opts.nodePath } : {}),
114
125
  });
@@ -259,11 +270,14 @@ program
259
270
  void updateService.check();
260
271
  updateService.start();
261
272
 
262
- // Wire the long-lived HTTP management server. The manager holds no
263
- // in-process cadre node: it spawns the admin's **authority node** as a
264
- // managed child and delegates authority/membership/identity operations
265
- // to it over the node's loopback admin channel (the 6.6 contract). The
266
- // manager never joins the control network (see docs/cadre-host.md
273
+ // Wire the long-lived HTTP management server. cadre-host's primary role
274
+ // is **node donor**: it spawns generic cadre nodes for *other people's*
275
+ // cadres on request (the donation grant layer below) and never needs an
276
+ // owner node of its own for that. Running the host's **own** personal
277
+ // cadre here — the "founder" persona — is opt-in via `ownCadre.enabled`
278
+ // (see docs/cadre-host.md § Two roles: donor and founder). Only when it
279
+ // is enabled do we spawn the owner node and bring up trust-circle + NAT.
280
+ // The manager never joins the control network (docs/cadre-host.md
267
281
  // § Control-plane separation).
268
282
  // Push (FCM/APNs) credentials are resolved fresh on every node spawn so a
269
283
  // restart re-reads the secret store (rotated keys) and nothing raw is
@@ -276,52 +290,102 @@ program
276
290
  });
277
291
  await orchestrator.init();
278
292
 
279
- // Spawn the authority node. Best-effort: a spawn failure leaves the
280
- // management API up (trust-circle listing degrades to local labels,
281
- // authority ops return 503) rather than taking down the whole process.
282
- try {
283
- await orchestrator.ensureAuthorityNode({
284
- identityPath: idPath,
285
- partyId: cfg.installId,
286
- libp2pPort: cfg.libp2pPort,
287
- });
288
- console.log('cadre-host: authority node spawned');
289
- } catch (err) {
290
- console.error(`authority node spawn failed: ${(err as Error).message}`);
291
- }
292
-
293
- // The client reads the admin endpoint lazily so a node restart's fresh
294
- // bearer token is picked up automatically.
295
- const authority = new AuthorityNodeClient(() => orchestrator.getAuthorityAdminEndpoint());
293
+ // Donation grant layer — the always-on donor surface. Local-only:
294
+ // issue/validate/revoke are pure store ops (no node round-trip), so it
295
+ // needs no owner-node handle. The loopback `/grants-admin` surface is
296
+ // mounted by createLocalUiServer regardless of the founder persona.
297
+ const grantService = new GrantService({ store: new GrantStore(cfg.dataDir) });
296
298
 
297
- const trustCircle = new TrustCircleService({
298
- cadreNode: authority,
299
- store: new TrustCircleStore(cfg.dataDir),
300
- });
301
- const natService = new NatService({
302
- rootDir: cfg.dataDir,
303
- cadreNode: authority,
299
+ // Donation lifecycle service — consumes a validated grant to actually
300
+ // spawn a donated node into the requester's cadre. Drives the
301
+ // grantee-facing `/grants` surface (mounted by createLocalUiServer below).
302
+ const donationService = new DonationService({
303
+ orchestrator,
304
+ grants: grantService,
305
+ store: new DonationStore(cfg.dataDir),
304
306
  });
305
307
 
306
- // Push NAT-resolved invite addresses to the node on every NAT change.
307
- // NatService.start() also fires this once as an initial push, retried
308
- // until the freshly spawned authority node accepts it (bounded; the
309
- // management API that mints invites comes up only after start() resolves).
310
- natService.onAddressesChanged(async (addresses) => {
311
- if (addresses.length === 0) return;
308
+ // Reap orphaned donations: a requester that provisioned a node but never
309
+ // presented a seed leaves an `awaiting_seed` child holding host ports.
310
+ // Sweep once at startup (for records recovered from disk by
311
+ // orchestrator.init()), then periodically.
312
+ const reapStale = (): void => {
313
+ void donationService
314
+ .reapStaleAwaitingSeed(DONATION_AWAITING_SEED_TTL_MS)
315
+ .catch((err) => console.error(`donation reap failed: ${(err as Error).message}`));
316
+ };
317
+ reapStale();
318
+ const reapTimer = setInterval(reapStale, DONATION_REAP_SWEEP_MS);
319
+ reapTimer.unref();
320
+
321
+ // Founder stack (opt-in). Absent `ownCadre.enabled`, cadre-host is a pure
322
+ // donor: no owner node, and the /auth + /nat surfaces stay unmounted
323
+ // (they 404). Per-donated-node WAN reachability is deferred to
324
+ // backlog/feat-cadre-host-wan-grant-reachability, so v1 donor mode is
325
+ // loopback-only — nothing for NatService to map without an owner node.
326
+ let trustCircle: TrustCircleService | undefined;
327
+ let natService: NatService | undefined;
328
+ if (hostOwnsCadre(cfg)) {
329
+ // Spawn the owner node. Best-effort: a spawn failure leaves the
330
+ // management API up (trust-circle listing degrades to local labels,
331
+ // owner ops return 503) rather than taking down the whole process.
312
332
  try {
313
- await authority.pushInviteAddresses(addresses);
333
+ await orchestrator.ensureOwnerNode({
334
+ identityPath: idPath,
335
+ partyId: cfg.installId,
336
+ libp2pPort: cfg.libp2pPort,
337
+ });
338
+ console.log('cadre-host: owner node spawned (host-own-cadre enabled)');
314
339
  } catch (err) {
315
- console.error(`invite-address push failed: ${(err as Error).message}`);
340
+ console.error(`owner node spawn failed: ${(err as Error).message}`);
316
341
  }
317
- });
318
342
 
319
- // Best-effort NAT start. Failures here aren't fatal — the local UI
320
- // can still serve the trust circle, settings, etc.
321
- try {
322
- await natService.start();
323
- } catch (err) {
324
- console.error(`NAT start failed: ${(err as Error).message}`);
343
+ // The client reads the admin endpoint lazily so a node restart's fresh
344
+ // bearer token is picked up automatically.
345
+ const owner = new OwnerNodeClient(() => orchestrator.getOwnerAdminEndpoint());
346
+
347
+ trustCircle = new TrustCircleService({
348
+ cadreNode: owner,
349
+ store: new TrustCircleStore(cfg.dataDir),
350
+ });
351
+ natService = new NatService({
352
+ rootDir: cfg.dataDir,
353
+ cadreNode: owner,
354
+ });
355
+
356
+ // Push NAT-resolved invite addresses to the node on every NAT change.
357
+ // NatService.start() also fires this once as an initial push, retried
358
+ // until the freshly spawned owner node accepts it (bounded; the
359
+ // management API that mints invites comes up only after start() resolves).
360
+ natService.onAddressesChanged(async (addresses) => {
361
+ if (addresses.length === 0) return;
362
+ try {
363
+ await owner.pushInviteAddresses(addresses);
364
+ } catch (err) {
365
+ console.error(`invite-address push failed: ${(err as Error).message}`);
366
+ }
367
+ });
368
+
369
+ // Best-effort NAT start. Failures here aren't fatal — the local UI
370
+ // can still serve the trust circle, settings, etc.
371
+ try {
372
+ await natService.start();
373
+ } catch (err) {
374
+ console.error(`NAT start failed: ${(err as Error).message}`);
375
+ }
376
+ } else {
377
+ // Donor-only. If ownCadre was toggled off after a prior founder run,
378
+ // orchestrator.init() re-attaches the still-running owner child (it would
379
+ // otherwise linger in listNodes with no trustCircle/nat wired, serving
380
+ // the host's own cadre despite being disabled). Reap it now so a disabled
381
+ // own-cadre is actually stopped — its workdir + control-DB persist on
382
+ // disk, so toggling ownCadre back on re-spawns it from saved config.
383
+ try {
384
+ await orchestrator.stopOwnerNode();
385
+ } catch (err) {
386
+ console.error(`owner node reap failed: ${(err as Error).message}`);
387
+ }
388
+ console.log('cadre-host: node-donor mode (host-own-cadre disabled — no owner node; /auth + /nat inactive)');
325
389
  }
326
390
 
327
391
  const settingsStore = new HostSettingsStore({ dataDir: cfg.dataDir });
@@ -329,9 +393,11 @@ program
329
393
  uiPort: cfg.uiPort,
330
394
  dataDir: cfg.dataDir,
331
395
  orchestrator,
332
- trustCircle,
333
- nat: natService,
396
+ ...(trustCircle ? { trustCircle } : {}),
397
+ ...(natService ? { nat: natService } : {}),
334
398
  update: updateService,
399
+ grants: grantService,
400
+ donations: donationService,
335
401
  settingsStore,
336
402
  });
337
403
  const { url, port } = await server.start();
@@ -341,9 +407,10 @@ program
341
407
  console.log(`cadre-host local UI: ${url}`);
342
408
 
343
409
  await waitForTermination();
410
+ clearInterval(reapTimer);
344
411
  try { await server.stop(); } catch { /* ignore */ }
345
- try { await natService.stop(); } catch { /* ignore */ }
346
- try { await orchestrator.stopAuthorityNode(); } catch { /* ignore */ }
412
+ try { await natService?.stop(); } catch { /* ignore */ }
413
+ try { await orchestrator.stopOwnerNode(); } catch { /* ignore */ }
347
414
  updateService.stop();
348
415
  console.log('cadre-host stopped.');
349
416
  process.exit(0);
@@ -449,6 +516,22 @@ function printEnrollmentInvite(invite: { encodedInvite: string; expiresAt?: stri
449
516
  }
450
517
  }
451
518
 
519
+ function printGrantToken(token: string, withQr: boolean): void {
520
+ if (withQr) {
521
+ // Best-effort QR render — fall back to the bare token if the lib chokes.
522
+ try {
523
+ const qr = requireForQr('qrcode-terminal') as { generate: (text: string, opts?: { small?: boolean }, cb?: (s: string) => void) => void };
524
+ qr.generate(token, { small: true }, (rendered) => {
525
+ console.error(rendered);
526
+ });
527
+ } catch (err) {
528
+ console.error(`(qrcode-terminal unavailable: ${(err as Error).message})`);
529
+ }
530
+ }
531
+ // The token goes to stdout alone so it can be piped/copied; metadata to stderr.
532
+ console.log(token);
533
+ }
534
+
452
535
  program
453
536
  .command('invite')
454
537
  .description('Generate an invite to add a member to the trust circle')
@@ -589,6 +672,143 @@ trust
589
672
  process.exit(0);
590
673
  });
591
674
 
675
+ // ============================================================================
676
+ // grant subcommands — donation grant tokens (who may ask this host for a node)
677
+ // ============================================================================
678
+ //
679
+ // A grant token lets one grantee (friend/family) present a Bearer credential to
680
+ // ask this host to donate cadre nodes, up to a per-grantee cap. These commands
681
+ // are thin HTTP clients of the loopback `/grants-admin` admin surface — no
682
+ // bearer (same-machine admin), same posture as `invite` / `trust`. The
683
+ // grantee-facing provisioning surface lands in the donation-service ticket.
684
+
685
+ const grant = program
686
+ .command('grant')
687
+ .description('Manage donation grant tokens (who may ask this host to donate a node)');
688
+
689
+ grant
690
+ .command('issue')
691
+ .description('Issue a grant token for one grantee (prints the token + QR)')
692
+ .argument('<label>', 'Display label for the grantee (e.g. "Alice\'s cadre")')
693
+ .option('--max-nodes <n>', 'Max concurrently-live donated nodes this grant may hold', parseIntArg)
694
+ .option('--ttl <duration>', 'Grant lifetime (e.g. 30d, 12h); omit for no expiry')
695
+ .option('--no-qr', 'Print only the token, no QR code')
696
+ .option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
697
+ .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
698
+ .action(async (label: string, opts: {
699
+ maxNodes?: number;
700
+ ttl?: string;
701
+ qr?: boolean;
702
+ port: string;
703
+ host: string;
704
+ }) => {
705
+ const payload: { label: string; maxNodes?: number; ttlMs?: number } = { label };
706
+ if (typeof opts.maxNodes === 'number') payload.maxNodes = opts.maxNodes;
707
+ if (opts.ttl) {
708
+ try {
709
+ payload.ttlMs = parseDuration(opts.ttl);
710
+ } catch (err) {
711
+ console.error(`Invalid --ttl: ${(err as Error).message}`);
712
+ process.exit(1);
713
+ return;
714
+ }
715
+ }
716
+
717
+ const url = `http://${opts.host}:${resolvePort(opts.port)}/grants-admin`;
718
+ let response: Response;
719
+ try {
720
+ response = await fetch(url, {
721
+ method: 'POST',
722
+ headers: { 'content-type': 'application/json' },
723
+ body: JSON.stringify(payload),
724
+ });
725
+ } catch (err) {
726
+ console.error(
727
+ `Failed to reach cadre-host at ${url}: ${(err as Error).message}\n` +
728
+ `Hint: is cadre-host running? Try \`cadre-host start\`.`,
729
+ );
730
+ process.exit(2);
731
+ return;
732
+ }
733
+ if (!response.ok) {
734
+ const text = await response.text().catch(() => '');
735
+ console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
736
+ process.exit(1);
737
+ return;
738
+ }
739
+ const body = await response.json() as { grant?: { token?: string; maxNodes?: number; expiresAt?: string } };
740
+ const issued = body.grant;
741
+ if (!issued?.token) {
742
+ console.error('cadre-host returned malformed response (missing grant token)');
743
+ process.exit(1);
744
+ return;
745
+ }
746
+ printGrantToken(issued.token, opts.qr !== false);
747
+ console.error(`(maxNodes ${issued.maxNodes ?? '?'}${issued.expiresAt ? `, expires ${issued.expiresAt}` : ', no expiry'})`);
748
+ process.exit(0);
749
+ });
750
+
751
+ grant
752
+ .command('list')
753
+ .description('List issued grant tokens')
754
+ .option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
755
+ .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
756
+ .action(async (opts: { port: string; host: string }) => {
757
+ const url = `http://${opts.host}:${resolvePort(opts.port)}/grants-admin`;
758
+ let response: Response;
759
+ try {
760
+ response = await fetch(url);
761
+ } catch (err) {
762
+ console.error(`Failed to reach cadre-host at ${url}: ${(err as Error).message}`);
763
+ process.exit(2);
764
+ return;
765
+ }
766
+ if (!response.ok) {
767
+ console.error(`cadre-host returned ${response.status}: ${response.statusText}`);
768
+ process.exit(1);
769
+ return;
770
+ }
771
+ const body = await response.json() as {
772
+ grants: Array<{ token: string; label: string; maxNodes: number; expiresAt?: string; revokedAt?: string }>;
773
+ };
774
+ console.log('Grants:');
775
+ if (body.grants.length === 0) {
776
+ console.log(' (none)');
777
+ } else {
778
+ for (const g of body.grants) {
779
+ const state = g.revokedAt ? ' [revoked]' : (g.expiresAt ? ` (expires ${g.expiresAt})` : '');
780
+ console.log(` ${g.token} ${g.label} max=${g.maxNodes}${state}`);
781
+ }
782
+ }
783
+ process.exit(0);
784
+ });
785
+
786
+ grant
787
+ .command('revoke')
788
+ .description('Revoke a grant token (blocks future requests; live nodes are not torn down)')
789
+ .argument('<token>', 'Grant token to revoke')
790
+ .option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
791
+ .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
792
+ .action(async (token: string, opts: { port: string; host: string }) => {
793
+ const base = `http://${opts.host}:${resolvePort(opts.port)}`;
794
+ let response: Response;
795
+ try {
796
+ response = await fetch(`${base}/grants-admin/${encodeURIComponent(token)}`, { method: 'DELETE' });
797
+ } catch (err) {
798
+ console.error(`Failed to reach cadre-host at ${base}: ${(err as Error).message}`);
799
+ process.exit(2);
800
+ return;
801
+ }
802
+ if (!response.ok) {
803
+ const text = await response.text().catch(() => '');
804
+ console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
805
+ process.exit(1);
806
+ return;
807
+ }
808
+ console.log(`revoked grant: ${token}`);
809
+ process.exit(0);
810
+ });
811
+
592
812
  // ============================================================================
593
813
  // nat subcommands
594
814
  // ============================================================================
@@ -741,7 +961,7 @@ ddns
741
961
  // do NOT go through the running management API). Private keys land in the OS
742
962
  // keychain (keytar) or the 0600 file-store fallback; the non-secret bits (APNs
743
963
  // bundle id / sandbox toggle, cooldown/debounce) land in host.config.json. New
744
- // credentials take effect on the next authority-node (re)spawn — run
964
+ // credentials take effect on the next owner-node (re)spawn — run
745
965
  // `cadre-host` restart (or restart the service) to apply them immediately.
746
966
 
747
967
  const push = program
@@ -771,7 +991,7 @@ push
771
991
  clientEmail: opts.clientEmail,
772
992
  privateKey,
773
993
  });
774
- console.log('✓ FCM credentials stored. Restart cadre-host to apply on the authority node.');
994
+ console.log('✓ FCM credentials stored. Restart cadre-host to apply on the owner node.');
775
995
  process.exit(0);
776
996
  });
777
997
 
@@ -808,7 +1028,7 @@ push
808
1028
  });
809
1029
  console.log(
810
1030
  `✓ APNs credentials stored (${opts.production ? 'production' : 'sandbox'}). ` +
811
- `Restart cadre-host to apply on the authority node.`,
1031
+ `Restart cadre-host to apply on the owner node.`,
812
1032
  );
813
1033
  process.exit(0);
814
1034
  });