@serfab/cadre-host 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/README.md +296 -101
  2. package/dist/auth/trust-circle.d.ts +45 -7
  3. package/dist/auth/trust-circle.js +116 -59
  4. package/dist/auth/trust-circle.js.map +1 -1
  5. package/dist/auth/types.d.ts +4 -1
  6. package/dist/auth/types.js.map +1 -1
  7. package/dist/bin/host.js +426 -112
  8. package/dist/bin/host.js.map +1 -1
  9. package/dist/donation/donation-service.d.ts +136 -0
  10. package/dist/donation/donation-service.js +306 -0
  11. package/dist/donation/donation-service.js.map +1 -0
  12. package/dist/donation/donation-store.d.ts +42 -0
  13. package/dist/donation/donation-store.js +141 -0
  14. package/dist/donation/donation-store.js.map +1 -0
  15. package/dist/donation/grant-service.d.ts +71 -0
  16. package/dist/donation/grant-service.js +163 -0
  17. package/dist/donation/grant-service.js.map +1 -0
  18. package/dist/donation/grant-store.d.ts +38 -0
  19. package/dist/donation/grant-store.js +135 -0
  20. package/dist/donation/grant-store.js.map +1 -0
  21. package/dist/donation/index.d.ts +18 -0
  22. package/dist/donation/index.js +16 -0
  23. package/dist/donation/index.js.map +1 -0
  24. package/dist/donation/types.d.ts +175 -0
  25. package/dist/donation/types.js +40 -0
  26. package/dist/donation/types.js.map +1 -0
  27. package/dist/index.d.ts +12 -6
  28. package/dist/index.js +8 -3
  29. package/dist/index.js.map +1 -1
  30. package/dist/installer/browser.js +16 -17
  31. package/dist/installer/browser.js.map +1 -1
  32. package/dist/installer/config.d.ts +51 -0
  33. package/dist/installer/config.js +14 -1
  34. package/dist/installer/config.js.map +1 -1
  35. package/dist/installer/index.d.ts +7 -2
  36. package/dist/installer/index.js +17 -10
  37. package/dist/installer/index.js.map +1 -1
  38. package/dist/installer/service-host/launchd.js +1 -1
  39. package/dist/installer/service-host/launchd.js.map +1 -1
  40. package/dist/installer/service-host/systemd.js +1 -1
  41. package/dist/installer/service-host/systemd.js.map +1 -1
  42. package/dist/installer/wizard.d.ts +4 -0
  43. package/dist/installer/wizard.js +5 -1
  44. package/dist/installer/wizard.js.map +1 -1
  45. package/dist/nat/index.d.ts +1 -1
  46. package/dist/nat/nat-service.d.ts +45 -3
  47. package/dist/nat/nat-service.js +116 -3
  48. package/dist/nat/nat-service.js.map +1 -1
  49. package/dist/nat/secrets/index.d.ts +7 -0
  50. package/dist/nat/secrets/index.js +28 -2
  51. package/dist/nat/secrets/index.js.map +1 -1
  52. package/dist/nat/types.d.ts +3 -1
  53. package/dist/nat/types.js.map +1 -1
  54. package/dist/orchestrator/host-process-orchestrator.d.ts +68 -1
  55. package/dist/orchestrator/host-process-orchestrator.js +272 -39
  56. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  57. package/dist/orchestrator/index.d.ts +3 -2
  58. package/dist/orchestrator/index.js +1 -1
  59. package/dist/orchestrator/index.js.map +1 -1
  60. package/dist/orchestrator/state-store.d.ts +6 -5
  61. package/dist/orchestrator/state-store.js +10 -2
  62. package/dist/orchestrator/state-store.js.map +1 -1
  63. package/dist/orchestrator/types.d.ts +45 -10
  64. package/dist/orchestrator/types.js.map +1 -1
  65. package/dist/owner/index.d.ts +11 -0
  66. package/dist/owner/index.js +11 -0
  67. package/dist/owner/index.js.map +1 -0
  68. package/dist/owner/owner-node-client.d.ts +95 -0
  69. package/dist/owner/owner-node-client.js +167 -0
  70. package/dist/owner/owner-node-client.js.map +1 -0
  71. package/dist/push/index.d.ts +67 -0
  72. package/dist/push/index.js +101 -0
  73. package/dist/push/index.js.map +1 -0
  74. package/dist/server/error-handler.d.ts +17 -0
  75. package/dist/server/error-handler.js +45 -0
  76. package/dist/server/error-handler.js.map +1 -1
  77. package/dist/server/index.d.ts +26 -2
  78. package/dist/server/index.js +37 -16
  79. package/dist/server/index.js.map +1 -1
  80. package/dist/server/routes/grants-admin.d.ts +17 -0
  81. package/dist/server/routes/grants-admin.js +32 -0
  82. package/dist/server/routes/grants-admin.js.map +1 -0
  83. package/dist/server/routes/grants.d.ts +28 -0
  84. package/dist/server/routes/grants.js +163 -0
  85. package/dist/server/routes/grants.js.map +1 -0
  86. package/dist/server/routes/nodes.d.ts +5 -3
  87. package/dist/server/routes/nodes.js +39 -24
  88. package/dist/server/routes/nodes.js.map +1 -1
  89. package/dist/server/routes/settings.d.ts +6 -1
  90. package/dist/server/routes/settings.js +5 -2
  91. package/dist/server/routes/settings.js.map +1 -1
  92. package/dist/server/routes/status.d.ts +8 -4
  93. package/dist/server/routes/status.js +9 -4
  94. package/dist/server/routes/status.js.map +1 -1
  95. package/dist/server/static.d.ts +12 -0
  96. package/dist/server/static.js +24 -7
  97. package/dist/server/static.js.map +1 -1
  98. package/dist/ui/assets/index-BlMXQPSw.js +14 -0
  99. package/dist/ui/assets/{index-C9uChk2P.js.map → index-BlMXQPSw.js.map} +1 -1
  100. package/dist/ui/index.html +1 -1
  101. package/dist/update/apply.d.ts +23 -1
  102. package/dist/update/apply.js +46 -4
  103. package/dist/update/apply.js.map +1 -1
  104. package/dist/update/index.d.ts +14 -3
  105. package/dist/update/index.js +35 -2
  106. package/dist/update/index.js.map +1 -1
  107. package/dist/update/manifest.d.ts +28 -9
  108. package/dist/update/manifest.js +56 -29
  109. package/dist/update/manifest.js.map +1 -1
  110. package/dist/update/release-key.d.ts +18 -7
  111. package/dist/update/release-key.js +29 -14
  112. package/dist/update/release-key.js.map +1 -1
  113. package/dist/update/sign.d.ts +46 -0
  114. package/dist/update/sign.js +53 -0
  115. package/dist/update/sign.js.map +1 -0
  116. package/package.json +9 -4
  117. package/service/README.md +95 -86
  118. package/src/__tests__/cli-invite.smoke.test.ts +6 -1
  119. package/src/__tests__/cli-nat.smoke.test.ts +6 -1
  120. package/src/__tests__/cli.smoke.test.ts +6 -1
  121. package/src/__tests__/host-owner.smoke.test.ts +123 -0
  122. package/src/__tests__/orchestrator-owner.test.ts +198 -0
  123. package/src/__tests__/orchestrator-pin-keys.test.ts +153 -0
  124. package/src/__tests__/orchestrator-push.test.ts +145 -0
  125. package/src/auth/__tests__/trust-circle-integration.test.ts +44 -25
  126. package/src/auth/__tests__/trust-circle.test.ts +91 -24
  127. package/src/auth/trust-circle.ts +363 -304
  128. package/src/auth/types.ts +4 -1
  129. package/src/bin/host.ts +1258 -905
  130. package/src/donation/__tests__/donation-service.test.ts +290 -0
  131. package/src/donation/__tests__/grant-service.test.ts +213 -0
  132. package/src/donation/__tests__/grant-store.test.ts +113 -0
  133. package/src/donation/donation-service.ts +392 -0
  134. package/src/donation/donation-store.ts +164 -0
  135. package/src/donation/grant-service.ts +200 -0
  136. package/src/donation/grant-store.ts +152 -0
  137. package/src/donation/index.ts +49 -0
  138. package/src/donation/types.ts +220 -0
  139. package/src/index.ts +66 -3
  140. package/src/installer/__tests__/config.test.ts +27 -1
  141. package/src/installer/__tests__/installer.smoke.test.ts +94 -0
  142. package/src/installer/__tests__/wizard.test.ts +5 -1
  143. package/src/installer/browser.ts +40 -42
  144. package/src/installer/config.ts +61 -1
  145. package/src/installer/index.ts +23 -12
  146. package/src/installer/service-host/launchd.ts +1 -1
  147. package/src/installer/service-host/systemd.ts +1 -1
  148. package/src/installer/wizard.ts +12 -1
  149. package/src/nat/__tests__/ddns-updater.test.ts +1 -1
  150. package/src/nat/__tests__/nat-service.test.ts +192 -0
  151. package/src/nat/__tests__/port-mapper.test.ts +1 -1
  152. package/src/nat/__tests__/secrets.test.ts +23 -1
  153. package/src/nat/index.ts +1 -1
  154. package/src/nat/nat-service.ts +131 -5
  155. package/src/nat/secrets/index.ts +32 -2
  156. package/src/nat/types.ts +3 -1
  157. package/src/orchestrator/host-process-orchestrator.ts +322 -54
  158. package/src/orchestrator/index.ts +9 -2
  159. package/src/orchestrator/state-store.ts +21 -3
  160. package/src/orchestrator/types.ts +48 -2
  161. package/src/owner/__tests__/owner-node-client.test.ts +194 -0
  162. package/src/owner/index.ts +12 -0
  163. package/src/owner/owner-node-client.ts +233 -0
  164. package/src/push/__tests__/push.test.ts +90 -0
  165. package/src/push/index.ts +146 -0
  166. package/src/server/__tests__/error-handler.test.ts +26 -4
  167. package/src/server/__tests__/grants-admin-route.test.ts +73 -0
  168. package/src/server/__tests__/grants-route.test.ts +189 -0
  169. package/src/server/__tests__/nodes-route.test.ts +90 -10
  170. package/src/server/__tests__/publishers.test.ts +1 -1
  171. package/src/server/__tests__/server.smoke.test.ts +75 -0
  172. package/src/server/__tests__/settings-route.test.ts +17 -0
  173. package/src/server/__tests__/static.test.ts +145 -0
  174. package/src/server/__tests__/status-route.test.ts +23 -2
  175. package/src/server/error-handler.ts +47 -0
  176. package/src/server/index.ts +62 -17
  177. package/src/server/routes/grants-admin.ts +41 -0
  178. package/src/server/routes/grants.ts +186 -0
  179. package/src/server/routes/nodes.ts +49 -26
  180. package/src/server/routes/settings.ts +11 -3
  181. package/src/server/routes/status.ts +18 -8
  182. package/src/server/static.ts +24 -8
  183. package/src/update/__tests__/apply.test.ts +58 -1
  184. package/src/update/__tests__/manifest.test.ts +41 -6
  185. package/src/update/__tests__/release-key.test.ts +42 -0
  186. package/src/update/__tests__/sign.test.ts +78 -0
  187. package/src/update/__tests__/update-service.integration.test.ts +44 -9
  188. package/src/update/apply.ts +46 -4
  189. package/src/update/index.ts +41 -3
  190. package/src/update/manifest.ts +214 -172
  191. package/src/update/release-key.ts +31 -15
  192. package/src/update/sign.ts +74 -0
  193. package/dist/ui/assets/index-C9uChk2P.js +0 -14
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, OWNER_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
+ OwnerAdminEndpoint,
38
+ OwnerSpawnConfig,
36
39
  } from './orchestrator/index.js';
37
40
 
41
+ export { OwnerNodeClient, OwnerNodeUnavailableError } from './owner/index.js';
42
+ export type { OwnerNodeClientOptions } from './owner/index.js';
43
+
38
44
  export {
39
45
  TrustCircleService,
40
46
  TrustCircleStore,
@@ -54,13 +60,46 @@ export type {
54
60
  TrustCircleErrorCode,
55
61
  } from './auth/index.js';
56
62
 
57
- export { Installer } from './installer/index.js';
63
+ /* ──────────────── donation grant tokens ──────────────── */
64
+
65
+ export {
66
+ GrantStore,
67
+ GrantService,
68
+ createGrantAdminHandlers,
69
+ GrantError,
70
+ DEFAULT_MAX_NODES,
71
+ DonationStore,
72
+ DonationService,
73
+ DonationError,
74
+ } from './donation/index.js';
75
+ export type {
76
+ Grant,
77
+ GrantDenyReason,
78
+ GrantValidation,
79
+ GrantValidator,
80
+ GrantFile,
81
+ GrantAdminHandlers,
82
+ GrantErrorCode,
83
+ GrantServiceOptions,
84
+ Donation,
85
+ DonationView,
86
+ DonationStatus,
87
+ DonationFile,
88
+ DonationErrorCode,
89
+ DonationProvisionRequest,
90
+ DonationSeedResult,
91
+ DonationPeerInfo,
92
+ DonationServiceOptions,
93
+ } from './donation/index.js';
94
+
95
+ export { Installer, readHostConfig, updateHostConfig, writeHostConfig } from './installer/index.js';
58
96
  export type {
59
97
  InstallOptions,
60
98
  InstallResult,
61
99
  UninstallOptions,
62
100
  InstallerOptions,
63
101
  HostConfigFile,
102
+ PushSettings,
64
103
  WizardAnswers,
65
104
  WizardDefaults,
66
105
  ServiceHost,
@@ -68,6 +107,23 @@ export type {
68
107
  ServiceHostStatus,
69
108
  } from './installer/index.js';
70
109
 
110
+ /* ──────────────── push credentials (FCM/APNs) ──────────────── */
111
+
112
+ export {
113
+ resolvePushCredentials,
114
+ setFcmSecret,
115
+ setApnsSecret,
116
+ clearPushSecret,
117
+ pushAccount,
118
+ pushStatus,
119
+ } from './push/index.js';
120
+ export type {
121
+ FcmSecret,
122
+ ApnsSecret,
123
+ PushSecretPlatform,
124
+ PushStatus,
125
+ } from './push/index.js';
126
+
71
127
  export {
72
128
  UpdateService,
73
129
  createUpdateHandlers,
@@ -78,21 +134,28 @@ export {
78
134
  fetchManifest,
79
135
  verifyManifest,
80
136
  canonicalJson,
81
- signManifestForTesting,
137
+ signManifest,
138
+ validateManifestFields,
139
+ buildManifest,
140
+ derivePublicKeyBase64,
141
+ signAndSelfVerify,
82
142
  compareVersions,
83
143
  parseVersion,
84
144
  getReleasePublicKey,
85
145
  getReleasePublicKeyBase64,
86
146
  ed25519FromRaw,
147
+ isPlaceholderReleaseKey,
87
148
  } from './update/index.js';
88
149
  export type {
89
150
  UpdateServiceOptions,
90
151
  UpdateHandlers,
91
152
  UpdateState,
153
+ SignedManifest,
92
154
  UpdateApplyResult,
93
155
  UpdateSettings,
94
156
  UpdateManifest,
95
157
  UpdateErrorCode,
158
+ ManifestFields,
96
159
  NpmExecutor,
97
160
  ServiceRestarter,
98
161
  } from './update/index.js';
@@ -3,7 +3,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
 
6
- import { readHostConfig, writeHostConfig, type HostConfigFile } from '../config.js';
6
+ import { readHostConfig, writeHostConfig, hostOwnsCadre, type HostConfigFile } from '../config.js';
7
7
 
8
8
  describe('host.config.json round-trip', () => {
9
9
  let tmp: string;
@@ -83,4 +83,30 @@ describe('host.config.json round-trip', () => {
83
83
  const path = join(tmp, 'host.config.json');
84
84
  expect(() => writeHostConfig(path, { ...makeCfg(), version: 3 as never })).toThrow(/version=3/);
85
85
  });
86
+
87
+ it('round-trips ownCadre and hostOwnsCadre reflects it', () => {
88
+ const path = join(tmp, 'host.config.json');
89
+ const cfg = makeCfg({ ownCadre: { enabled: true } });
90
+ writeHostConfig(path, cfg);
91
+ const read = readHostConfig(path);
92
+ expect(read.ownCadre).toEqual({ enabled: true });
93
+ expect(hostOwnsCadre(read)).toBe(true);
94
+ });
95
+
96
+ it('treats an absent ownCadre as donor-only (hostOwnsCadre false)', () => {
97
+ // A pre-ownCadre v2 config (field absent) must read back cleanly and be
98
+ // donor-only — no migration, field stays absent.
99
+ const path = join(tmp, 'host.config.json');
100
+ const cfg = makeCfg();
101
+ writeHostConfig(path, cfg);
102
+ const read = readHostConfig(path);
103
+ expect(read.ownCadre).toBeUndefined();
104
+ expect(hostOwnsCadre(read)).toBe(false);
105
+ });
106
+
107
+ it('rejects a malformed ownCadre shape', () => {
108
+ const path = join(tmp, 'host.config.json');
109
+ writeFileSync(path, JSON.stringify({ ...makeCfg(), ownCadre: { enabled: 'yes' } }));
110
+ expect(() => readHostConfig(path)).toThrow(/missing required fields/);
111
+ });
86
112
  });
@@ -108,6 +108,100 @@ 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; ownCadre: 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
+ ownCadre: defaults.ownCadre,
140
+ };
141
+ },
142
+ });
143
+
144
+ expect(seenDefaults).toEqual({
145
+ dataDir: tmp,
146
+ uiPort: 19997,
147
+ libp2pPort: 14003,
148
+ upnpEnabled: false,
149
+ ownCadre: false,
150
+ });
151
+ expect(fake.installCalls).toHaveLength(1);
152
+ expect(fake.installCalls[0]!.dataDir).toBe(tmp);
153
+ });
154
+
155
+ it('honors wizard-typed input when no CLI override is passed', async () => {
156
+ // Mirror of the previous test: with no `--data-dir`, the wizard's
157
+ // returned value is what the installer uses.
158
+ const fake = new FakeServiceHost();
159
+ const installer = new Installer({ platform: 'linux' });
160
+ await installer.install({
161
+ nonInteractive: false,
162
+ openBrowser: false,
163
+ noInvite: true,
164
+ serviceHost: fake,
165
+ wizard: async () => ({
166
+ dataDir: tmp,
167
+ uiPort: 19996,
168
+ libp2pPort: 14004,
169
+ upnpEnabled: true,
170
+ configureDdns: false,
171
+ ownCadre: false,
172
+ }),
173
+ });
174
+ expect(fake.installCalls).toHaveLength(1);
175
+ expect(fake.installCalls[0]!.dataDir).toBe(tmp);
176
+ });
177
+
178
+ it('writes ownCadre.enabled=false by default and true when opted in', async () => {
179
+ const { readHostConfig } = await import('../config.js');
180
+
181
+ // Default (donor-only): --own-cadre absent → ownCadre.enabled false.
182
+ const fakeA = new FakeServiceHost();
183
+ const installerA = new Installer({ platform: 'linux' });
184
+ await installerA.install({
185
+ nonInteractive: true, dataDir: tmp, openBrowser: false, noInvite: true, serviceHost: fakeA,
186
+ });
187
+ const donorCfg = readHostConfig(join(tmp, 'host.config.json'));
188
+ expect(donorCfg.ownCadre).toEqual({ enabled: false });
189
+
190
+ // Opt-in: ownCadre:true (the --own-cadre flag) → ownCadre.enabled true.
191
+ const tmp2 = mkdtempSync(join(tmpdir(), 'cadre-host-installer-owncadre-'));
192
+ try {
193
+ const fakeB = new FakeServiceHost();
194
+ const installerB = new Installer({ platform: 'linux' });
195
+ await installerB.install({
196
+ nonInteractive: true, dataDir: tmp2, ownCadre: true, openBrowser: false, noInvite: true, serviceHost: fakeB,
197
+ });
198
+ const founderCfg = readHostConfig(join(tmp2, 'host.config.json'));
199
+ expect(founderCfg.ownCadre).toEqual({ enabled: true });
200
+ } finally {
201
+ rmSync(tmp2, { recursive: true, force: true });
202
+ }
203
+ });
204
+
111
205
  it('re-running install preserves the existing identity', async () => {
112
206
  const fake = new FakeServiceHost();
113
207
  const installer = new Installer({ platform: 'linux' });
@@ -7,6 +7,7 @@ const defaults: WizardDefaults = {
7
7
  uiPort: 8765,
8
8
  libp2pPort: 4001,
9
9
  upnpEnabled: true,
10
+ ownCadre: false,
10
11
  };
11
12
 
12
13
  function scripted(answers: ReadonlyArray<string>): (label: string, fallback: string) => Promise<string> {
@@ -19,13 +20,14 @@ function scripted(answers: ReadonlyArray<string>): (label: string, fallback: str
19
20
 
20
21
  describe('runWizardWith', () => {
21
22
  it('returns the defaults when every answer is blank', async () => {
22
- const out = await runWizardWith(defaults, scripted(['', '', '', '', '']));
23
+ const out = await runWizardWith(defaults, scripted(['', '', '', '', '', '']));
23
24
  expect(out).toEqual({
24
25
  dataDir: '/data/cadre-host',
25
26
  uiPort: 8765,
26
27
  libp2pPort: 4001,
27
28
  upnpEnabled: true,
28
29
  configureDdns: false,
30
+ ownCadre: false,
29
31
  });
30
32
  });
31
33
 
@@ -36,6 +38,7 @@ describe('runWizardWith', () => {
36
38
  '5000',
37
39
  'n',
38
40
  'y',
41
+ 'y',
39
42
  ]));
40
43
  expect(out).toEqual({
41
44
  dataDir: '/srv/host',
@@ -43,6 +46,7 @@ describe('runWizardWith', () => {
43
46
  libp2pPort: 5000,
44
47
  upnpEnabled: false,
45
48
  configureDdns: true,
49
+ ownCadre: true,
46
50
  });
47
51
  });
48
52
 
@@ -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,41 @@ export interface UpdatesConfig {
21
21
  manifestUrl?: string;
22
22
  }
23
23
 
24
+ /**
25
+ * Whether this host also runs its own personal cadre (the opt-in "founder"
26
+ * persona), on top of the always-on node-donor role.
27
+ *
28
+ * Default (absent or `enabled: false`) is donor-only: `cadre-host start` brings
29
+ * up the donation surface but spawns **no** owner node, so the trust-circle and
30
+ * NAT surfaces stay inactive. Set `enabled: true` (install-time choice) to also
31
+ * spawn the host's own owner node and enable `/auth` + `/nat`. See
32
+ * docs/cadre-host.md § Two roles: donor and founder.
33
+ */
34
+ export interface OwnCadreConfig {
35
+ /** When true, `start` spawns the host-owned owner node + trust-circle + NAT. */
36
+ enabled: boolean;
37
+ }
38
+
39
+ /**
40
+ * Non-secret push (FCM/APNs) settings. The actual private keys (and the FCM
41
+ * service-account / APNs key identifiers) live in the secret store — see
42
+ * `src/push/`. Only these app-config bits, which are safe to keep in plaintext
43
+ * `host.config.json`, are stored here. Absent ⇒ push is not configured.
44
+ */
45
+ export interface PushSettings {
46
+ /** APNs app-config (the `.p8` private key + key/team ids stay in the secret store). */
47
+ apns?: {
48
+ /** App bundle id → APNs `apns-topic` header. */
49
+ bundleId: string;
50
+ /** `true` → production APNs host; false/undefined → sandbox (dev/TestFlight). */
51
+ production?: boolean;
52
+ };
53
+ /** Per-(peer,strand) anti-spam cooldown (ms) forwarded into the node's push block. */
54
+ cooldownMs?: number;
55
+ /** Per-strand burst-coalescing window (ms) forwarded into the node's push block. */
56
+ debounceMs?: number;
57
+ }
58
+
24
59
  export interface HostConfigFile {
25
60
  version: 2;
26
61
  /** A stable per-install id; useful for log correlation and the control-network partyId. */
@@ -41,6 +76,27 @@ export interface HostConfigFile {
41
76
  installerVersion: string;
42
77
  /** Update-flow settings; defaults: { autoApply: false }. */
43
78
  updates: UpdatesConfig;
79
+ /**
80
+ * Host-own-cadre (founder persona) opt-in. Optional — absent is treated as
81
+ * `{ enabled: false }` (donor-only). Written by the installer; read via
82
+ * `hostOwnsCadre()`. Not editable through /api/settings (install-time only).
83
+ */
84
+ ownCadre?: OwnCadreConfig;
85
+ /**
86
+ * Non-secret push settings (bundle id, sandbox/prod toggle, cooldown/debounce).
87
+ * Optional — absent when push is not configured. Private keys never live here;
88
+ * they are read from the secret store at node-spawn time (`src/push/`).
89
+ */
90
+ push?: PushSettings;
91
+ }
92
+
93
+ /**
94
+ * Whether this host runs its own personal cadre (founder persona). Absent
95
+ * `ownCadre` ⇒ donor-only (false). The single source of truth for the
96
+ * founder/donor gate in `bin/host.ts start`.
97
+ */
98
+ export function hostOwnsCadre(cfg: HostConfigFile): boolean {
99
+ return cfg.ownCadre?.enabled === true;
44
100
  }
45
101
 
46
102
  const CURRENT_VERSION = 2;
@@ -75,7 +131,7 @@ export function readHostConfig(filePath: string): HostConfigFile {
75
131
  try {
76
132
  parsed = JSON.parse(raw);
77
133
  } catch (err) {
78
- throw new Error(`Failed to parse host.config.json at ${filePath}: ${(err as Error).message}`);
134
+ throw new Error(`Failed to parse host.config.json at ${filePath}: ${(err as Error).message}`, { cause: err });
79
135
  }
80
136
  if (!parsed || typeof parsed !== 'object') {
81
137
  throw new Error(`host.config.json at ${filePath} is not a JSON object`);
@@ -149,5 +205,9 @@ function isHostConfigShape(v: unknown): v is HostConfigFile {
149
205
  if (!updates || typeof updates !== 'object') return false;
150
206
  if (typeof updates.autoApply !== 'boolean') return false;
151
207
  if (updates.manifestUrl !== undefined && typeof updates.manifestUrl !== 'string') return false;
208
+ if (o.ownCadre !== undefined) {
209
+ const own = o.ownCadre as Record<string, unknown>;
210
+ if (!own || typeof own !== 'object' || typeof own.enabled !== 'boolean') return false;
211
+ }
152
212
  return true;
153
213
  }
@@ -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
 
@@ -57,6 +58,8 @@ export interface InstallOptions {
57
58
  libp2pPort?: number;
58
59
  /** Disable UPnP probing on first run. */
59
60
  noUpnp?: boolean;
61
+ /** Also run the host's own personal cadre here (founder persona). Default false. */
62
+ ownCadre?: boolean;
60
63
  /** Open browser at end (default true; suppressed by --no-browser or non-TTY). */
61
64
  openBrowser?: boolean;
62
65
  /** Skip the enrollment-invite step (no QR printed). */
@@ -67,6 +70,8 @@ export interface InstallOptions {
67
70
  nodePath?: string;
68
71
  /** Test-only: stub the service-host registration. */
69
72
  serviceHost?: ServiceHost;
73
+ /** Test-only: stub the interactive wizard. Production callers leave unset. */
74
+ wizard?: (defaults: WizardDefaults) => Promise<WizardAnswers>;
70
75
  }
71
76
 
72
77
  export interface InstallResult {
@@ -143,6 +148,7 @@ export class Installer {
143
148
  installedAt: new Date().toISOString(),
144
149
  installerVersion: this.installerVersion,
145
150
  updates: { autoApply: false },
151
+ ownCadre: { enabled: answers.ownCadre },
146
152
  };
147
153
  const cfgPath = configPath(answers.dataDir);
148
154
  writeHostConfig(cfgPath, cfg);
@@ -225,6 +231,7 @@ export class Installer {
225
231
  libp2pPort: opts.libp2pPort ?? defaults.libp2pPort,
226
232
  upnpEnabled: opts.noUpnp ? false : defaults.upnpEnabled,
227
233
  configureDdns: false,
234
+ ownCadre: opts.ownCadre ?? defaults.ownCadre,
228
235
  };
229
236
  }
230
237
 
@@ -232,17 +239,21 @@ export class Installer {
232
239
  if (opts.nonInteractive) {
233
240
  return this.resolveAnswersFromOptions(opts);
234
241
  }
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,
242
+ // CLI flags overlay the platform defaults so the wizard's `[...]` hint
243
+ // shows the user's chosen value. Pressing Enter accepts it; typing a new
244
+ // value also works (and now actually has an effect — the previous
245
+ // implementation showed the platform default in the prompt and silently
246
+ // discarded user input because the CLI flag won post-hoc).
247
+ const platformDefaults = defaultsForPlatform(this.platform);
248
+ const defaults: WizardDefaults = {
249
+ dataDir: opts.dataDir ?? platformDefaults.dataDir,
250
+ uiPort: opts.uiPort ?? platformDefaults.uiPort,
251
+ libp2pPort: opts.libp2pPort ?? platformDefaults.libp2pPort,
252
+ upnpEnabled: opts.noUpnp ? false : platformDefaults.upnpEnabled,
253
+ ownCadre: opts.ownCadre ?? platformDefaults.ownCadre,
245
254
  };
255
+ const wizard = opts.wizard ?? runWizard;
256
+ return await wizard(defaults);
246
257
  }
247
258
  }
248
259
 
@@ -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
  }
@@ -16,6 +16,8 @@ export interface WizardDefaults {
16
16
  uiPort: number;
17
17
  libp2pPort: number;
18
18
  upnpEnabled: boolean;
19
+ /** Also run the host's own personal cadre here (founder persona). Default false. */
20
+ ownCadre: boolean;
19
21
  }
20
22
 
21
23
  export interface WizardAnswers {
@@ -24,6 +26,8 @@ export interface WizardAnswers {
24
26
  libp2pPort: number;
25
27
  upnpEnabled: boolean;
26
28
  configureDdns: boolean;
29
+ /** Also run the host's own personal cadre here (founder persona). */
30
+ ownCadre: boolean;
27
31
  }
28
32
 
29
33
  export const DEFAULT_UI_PORT = 8765;
@@ -35,6 +39,7 @@ export function defaultsForPlatform(platform: SupportedPlatform): WizardDefaults
35
39
  uiPort: DEFAULT_UI_PORT,
36
40
  libp2pPort: DEFAULT_LIBP2P_PORT,
37
41
  upnpEnabled: true,
42
+ ownCadre: false,
38
43
  };
39
44
  }
40
45
 
@@ -54,7 +59,13 @@ export async function runWizardWith(
54
59
  defaults.upnpEnabled,
55
60
  );
56
61
  const configureDdns = parseBool(await ask('Configure DDNS now? [y/N]', 'N'), false);
57
- return { dataDir, uiPort, libp2pPort, upnpEnabled, configureDdns };
62
+ // Founder persona (opt-in). Most installs just donate nodes to friends and
63
+ // answer no — a no leaves cadre-host donor-only (no owner node, no /auth+/nat).
64
+ const ownCadre = parseBool(
65
+ await ask('Also run your own personal cadre on this machine? (most people just donating nodes to friends say no) [y/N]', defaults.ownCadre ? 'Y' : 'N'),
66
+ defaults.ownCadre,
67
+ );
68
+ return { dataDir, uiPort, libp2pPort, upnpEnabled, configureDdns, ownCadre };
58
69
  }
59
70
 
60
71
  /** Production entrypoint — drives the wizard over stdin/stdout. */
@@ -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,