@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
@@ -26,13 +26,17 @@ import { defaultLogPath, rotateOnDisk } from './log-rotator.js';
26
26
  import { PortAllocator } from './port-allocator.js';
27
27
  import { StateStore, type PersistedHandle, type PersistedState } from './state-store.js';
28
28
  import { isPidAlive } from './pid-liveness.js';
29
+ import type { PushCredentials } from '@serfab/cadre-core';
29
30
  import {
30
31
  decodeDockerId,
31
32
  encodeDockerId,
33
+ type OwnerSpawnConfig,
32
34
  type Handle,
33
35
  type HostProcessConfig,
34
36
  type ManagedNodeInfo,
37
+ type NodePorts,
35
38
  type NodeStateListener,
39
+ type PushCredentialsResolver,
36
40
  } from './types.js';
37
41
 
38
42
  const log = debug('cadre:host:orchestrator');
@@ -49,6 +53,17 @@ const STARTUP_TOKEN_FILE = '.startup-token';
49
53
  const CONFIG_FILE = 'cadre.json';
50
54
  const LIVENESS_POLL_MS = 100;
51
55
 
56
+ /** Fixed friendly id for the admin's owner node. */
57
+ export const OWNER_CONTAINER_ID = 'owner';
58
+
59
+ /** Endpoint + bearer for the owner node's loopback admin channel (6.6). */
60
+ export interface OwnerAdminEndpoint {
61
+ /** e.g. `http://127.0.0.1:<adminPort>`. */
62
+ baseUrl: string;
63
+ /** The child's `CADRE_STARTUP_TOKEN`, used as the admin bearer secret. */
64
+ token: string;
65
+ }
66
+
52
67
  /**
53
68
  * Runs cadre nodes as plain Node child processes on the host.
54
69
  *
@@ -89,8 +104,12 @@ export class HostProcessOrchestrator implements Orchestrator {
89
104
  private readonly defaultMemoryLimit?: string;
90
105
  private readonly handles = new Map<string, Handle>();
91
106
  private readonly cliEntrypoint: string;
107
+ /** Resolves push credentials fresh on every spawn (see HostProcessConfig). */
108
+ private readonly pushResolver?: PushCredentialsResolver;
92
109
  /** State-change listeners — invoked when a handle's alive state changes. */
93
110
  private readonly stateListeners = new Set<NodeStateListener>();
111
+ /** Persisted spawn config for the owner node (re-spawn on demand). */
112
+ private ownerConfig?: OwnerSpawnConfig;
94
113
 
95
114
  constructor(private readonly cfg: HostProcessConfig) {
96
115
  this.rootDir = resolvePath(cfg.rootDir);
@@ -105,6 +124,7 @@ export class HostProcessOrchestrator implements Orchestrator {
105
124
  this.logMaxFiles = cfg.logMaxFiles ?? DEFAULTS.logMaxFiles;
106
125
  this.defaultMemoryLimit = cfg.defaultResources?.memoryLimit;
107
126
  this.cliEntrypoint = cfg.spawn?.entrypoint ?? resolveCadreCliBin();
127
+ this.pushResolver = cfg.pushResolver;
108
128
  log('HostProcessOrchestrator rootDir=%s cli=%s', this.rootDir, this.cliEntrypoint);
109
129
  }
110
130
 
@@ -115,6 +135,7 @@ export class HostProcessOrchestrator implements Orchestrator {
115
135
  */
116
136
  async init(): Promise<void> {
117
137
  const state = this.stateStore.load();
138
+ this.ownerConfig = state.ownerConfig;
118
139
  for (const persisted of state.handles) {
119
140
  const alive = isPidAlive(persisted.pid) && tokenMatches(persisted.workdir, persisted.startupToken);
120
141
  const handle: Handle = {
@@ -127,6 +148,7 @@ export class HostProcessOrchestrator implements Orchestrator {
127
148
  spawnedAt: persisted.spawnedAt,
128
149
  partyId: persisted.partyId,
129
150
  profile: persisted.profile,
151
+ ...(persisted.owner ? { owner: true } : {}),
130
152
  alive,
131
153
  };
132
154
  this.handles.set(handle.dockerId, handle);
@@ -134,6 +156,10 @@ export class HostProcessOrchestrator implements Orchestrator {
134
156
  this.portAllocator.markUsed(persisted.ports.health);
135
157
  this.portAllocator.markUsed(persisted.ports.metrics);
136
158
  this.portAllocator.markUsed(persisted.ports.p2p);
159
+ // `admin` is absent on handles persisted before this build.
160
+ if (typeof persisted.ports.admin === 'number') {
161
+ this.portAllocator.markUsed(persisted.ports.admin);
162
+ }
137
163
  }
138
164
  log('init: re-attached %d handles', state.handles.length);
139
165
  }
@@ -192,15 +218,193 @@ export class HostProcessOrchestrator implements Orchestrator {
192
218
  }
193
219
 
194
220
  async createContainer(request: OrchestratorCreateRequest): Promise<OrchestratorCreateResult> {
195
- const healthPort = this.portAllocator.allocate();
196
- const metricsPort = this.portAllocator.allocate();
197
- const p2pPort = this.portAllocator.allocate();
221
+ const ports: NodePorts = {
222
+ health: this.portAllocator.allocate(),
223
+ metrics: this.portAllocator.allocate(),
224
+ p2p: this.portAllocator.allocate(),
225
+ admin: this.portAllocator.allocate(),
226
+ };
227
+ // A node started with pinned owner keys belongs to a FOREIGN cadre (the
228
+ // node-donation flow: it trusts the requester's owner key, not the host's).
229
+ // The host's FCM/APNs credentials are minted for the host owner's own app
230
+ // and are meaningless to a foreign cadre — so donated nodes get NO push
231
+ // block. (Per-grantee push creds would be a future ticket; none in v1.)
232
+ const foreignParty = (request.pinnedOwnerKeys?.length ?? 0) > 0;
233
+ // Only storage-profile nodes participate in strands and thus fan out push
234
+ // wakes — a transaction-only node need not carry credentials.
235
+ const push = request.profile === 'storage' && !foreignParty ? await this.resolvePush() : undefined;
236
+ // Pinned owner key(s) reach the child via CADRE_OWNER_KEYS (comma-separated);
237
+ // `cadre-cli start` unions it into its cold-start pinnedKeyTrustPolicy so the
238
+ // node will accept the foreign-authority-signed seed presented via POST /seed.
239
+ const extraEnv = request.pinnedOwnerKeys?.length
240
+ ? { CADRE_OWNER_KEYS: request.pinnedOwnerKeys.join(',') }
241
+ : undefined;
242
+ return this.launchChild({
243
+ containerId: request.containerId,
244
+ partyId: request.partyId,
245
+ profile: request.profile,
246
+ ports,
247
+ owner: false,
248
+ buildConfig: (workdir) => this.buildChildConfig(request, workdir, push),
249
+ extraArgs: [],
250
+ ...(extraEnv ? { extraEnv } : {}),
251
+ ...(request.resources?.memoryLimit ? { memoryLimit: request.resources.memoryLimit } : {}),
252
+ });
253
+ }
254
+
255
+ /**
256
+ * Resolve the push credentials to inject into the node about to spawn. Called
257
+ * on every spawn so a re-spawn re-reads the secret store — raw private keys are
258
+ * never persisted. A resolver failure (e.g. a partial credential set) is
259
+ * logged and degrades to "no push" rather than failing the spawn: the node
260
+ * stays up and reachable, just without platform push-wake.
261
+ */
262
+ private async resolvePush(): Promise<PushCredentials | undefined> {
263
+ if (!this.pushResolver) return undefined;
264
+ try {
265
+ const push = await this.pushResolver();
266
+ if (push) {
267
+ log('resolved push credentials: fcm=%s apns=%s cooldownMs=%s', !!push.fcm, !!push.apns, push.cooldownMs);
268
+ }
269
+ return push;
270
+ } catch (err) {
271
+ log('push resolver failed; spawning node without push: %s', (err as Error).message);
272
+ return undefined;
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Ensure the admin's owner node is running, spawning it if absent or
278
+ * dead. Idempotent: a second call with the node already alive returns the
279
+ * existing handle without launching a second child. The spawn parameters
280
+ * are persisted so a later `/api/nodes/:id/{start,restart}` (or an
281
+ * orchestrator restart) can re-spawn it without re-supplying them.
282
+ *
283
+ * The owner node is spawned as `cadre-cli start --owner
284
+ * --admin-port <p> --identity-protobuf <identityPath>` so it carries the
285
+ * host's libp2p identity, founds/joins the control network, and exposes the
286
+ * loopback admin channel the manager delegates to.
287
+ */
288
+ async ensureOwnerNode(cfg?: OwnerSpawnConfig): Promise<ManagedNodeInfo> {
289
+ const config = cfg ?? this.ownerConfig;
290
+ if (!config) {
291
+ throw new Error('ensureOwnerNode: no owner config supplied and none persisted');
292
+ }
293
+ this.ownerConfig = config;
294
+
295
+ const existing = this.findOwnerHandle();
296
+ if (existing && isPidAlive(existing.pid) && tokenMatches(existing.workdir, existing.startupToken)) {
297
+ this.persist();
298
+ return toNodeInfo(existing);
299
+ }
300
+ if (existing) {
301
+ // Drop the stale handle and release its ports — but DO NOT delete the
302
+ // workdir: the owner node's control-DB storage lives there and must
303
+ // survive a restart. launchChild reuses the same workdir.
304
+ this.releasePorts(existing.ports);
305
+ this.handles.delete(existing.dockerId);
306
+ }
307
+
308
+ const profile = config.profile ?? 'storage';
309
+ // Re-resolve push credentials from the secret store on every (re-)spawn so a
310
+ // restart picks up rotated keys and nothing raw is replayed from state.json.
311
+ const push = await this.resolvePush();
312
+ const ports: NodePorts = {
313
+ health: this.portAllocator.allocate(),
314
+ metrics: this.portAllocator.allocate(),
315
+ admin: this.portAllocator.allocate(),
316
+ // The owner node must listen on the configured libp2p port so the
317
+ // NAT mapping (external → internal) lands on it. That port is outside
318
+ // the managed range, so markUsed is a harmless no-op.
319
+ p2p: config.libp2pPort,
320
+ };
321
+ this.portAllocator.markUsed(config.libp2pPort);
322
+
323
+ const result = this.launchChild({
324
+ containerId: OWNER_CONTAINER_ID,
325
+ partyId: config.partyId,
326
+ profile,
327
+ ports,
328
+ owner: true,
329
+ buildConfig: (workdir) => this.buildOwnerChildConfig(config, profile, workdir, push),
330
+ extraArgs: ['--owner', '--admin-port', String(ports.admin), '--identity-protobuf', config.identityPath],
331
+ });
332
+ const handle = this.handles.get(result.dockerId)!;
333
+ return toNodeInfo(handle);
334
+ }
335
+
336
+ /** Endpoint + bearer for the owner node's admin channel, if spawned. */
337
+ getOwnerAdminEndpoint(): OwnerAdminEndpoint | undefined {
338
+ const handle = this.findOwnerHandle();
339
+ if (!handle) return undefined;
340
+ return {
341
+ baseUrl: `http://127.0.0.1:${handle.ports.admin}`,
342
+ token: handle.startupToken,
343
+ };
344
+ }
345
+
346
+ /** Whether spawn parameters for the owner node are known (persisted). */
347
+ hasOwnerConfig(): boolean {
348
+ return this.ownerConfig !== undefined;
349
+ }
350
+
351
+ /** Whether the given friendly/docker id refers to the owner node. */
352
+ isOwnerNode(idOrDockerId: string): boolean {
353
+ if (idOrDockerId === OWNER_CONTAINER_ID) return true;
354
+ const direct = this.handles.get(idOrDockerId);
355
+ if (direct?.owner) return true;
356
+ for (const h of this.handles.values()) {
357
+ if (h.owner && h.containerId === idOrDockerId) return true;
358
+ }
359
+ return false;
360
+ }
361
+
362
+ /** Stop the owner node (if running). Used on graceful shutdown. */
363
+ async stopOwnerNode(): Promise<void> {
364
+ const handle = this.findOwnerHandle();
365
+ if (handle && isPidAlive(handle.pid)) {
366
+ await this.stopContainer(handle.dockerId);
367
+ }
368
+ }
198
369
 
199
- const workdir = join(this.rootDir, request.containerId);
370
+ /** Stop the owner node (if running), then re-spawn it from saved config. */
371
+ async restartOwnerNode(): Promise<ManagedNodeInfo> {
372
+ const handle = this.findOwnerHandle();
373
+ if (handle && isPidAlive(handle.pid)) {
374
+ await this.stopContainer(handle.dockerId);
375
+ }
376
+ return this.ensureOwnerNode();
377
+ }
378
+
379
+ /**
380
+ * Core spawn mechanics shared by `createContainer` and `ensureOwnerNode`.
381
+ * The caller has already allocated `ports`. Releases the ports on a
382
+ * synchronous spawn failure.
383
+ */
384
+ private launchChild(opts: {
385
+ containerId: string;
386
+ partyId: string;
387
+ profile: 'storage' | 'transaction';
388
+ ports: NodePorts;
389
+ owner: boolean;
390
+ buildConfig: (workdir: string) => Record<string, unknown>;
391
+ extraArgs: string[];
392
+ /** Extra env vars merged into the child's environment (e.g. CADRE_OWNER_KEYS). */
393
+ extraEnv?: Record<string, string>;
394
+ memoryLimit?: string;
395
+ }): OrchestratorCreateResult {
396
+ const { containerId, ports } = opts;
397
+ const workdir = join(this.rootDir, containerId);
200
398
  mkdirSync(workdir, { recursive: true });
201
399
  mkdirSync(join(workdir, 'storage'), { recursive: true });
202
400
 
203
401
  const token = randomBytes(16).toString('hex');
402
+ // Per-container secret gating the node's `POST /seed` route. The node
403
+ // refuses seed delivery unless the caller presents this as a bearer token
404
+ // (and unregisters the route entirely when the env var is empty), so we
405
+ // mint a fresh high-entropy value per child and hand it back on the result
406
+ // for `ContainerService.applySeed` to send.
407
+ const seedToken = randomBytes(32).toString('base64url');
204
408
  const tokenPath = join(workdir, STARTUP_TOKEN_FILE);
205
409
  // Ensure stale token from a previous container with the same id is cleared.
206
410
  if (existsSync(tokenPath)) {
@@ -208,22 +412,22 @@ export class HostProcessOrchestrator implements Orchestrator {
208
412
  }
209
413
 
210
414
  const configPath = join(workdir, CONFIG_FILE);
211
- writeFileSync(
212
- configPath,
213
- JSON.stringify(this.buildChildConfig(request, workdir), null, 2),
214
- 'utf8',
215
- );
415
+ writeFileSync(configPath, JSON.stringify(opts.buildConfig(workdir), null, 2), 'utf8');
216
416
 
217
- const memoryLimit = request.resources?.memoryLimit ?? this.defaultMemoryLimit;
417
+ const memoryLimit = opts.memoryLimit ?? this.defaultMemoryLimit;
218
418
  const heapBytes = parseMemoryLimit(memoryLimit);
219
419
  const heapMB = heapBytes !== undefined ? Math.max(64, Math.floor(heapBytes / (1024 * 1024))) : undefined;
220
420
 
221
421
  const env: NodeJS.ProcessEnv = {
222
422
  ...process.env,
423
+ // Caller-supplied extras first — the fixed per-child vars below always win
424
+ // so extraEnv can never clobber the seed/startup tokens or port wiring.
425
+ ...opts.extraEnv,
223
426
  CADRE_STARTUP_TOKEN: token,
224
- CADRE_HEALTH_PORT: String(healthPort),
225
- CADRE_METRICS_PORT: String(metricsPort),
226
- CADRE_LISTEN_ADDRS: `/ip4/0.0.0.0/tcp/${p2pPort}`,
427
+ CADRE_HEALTH_PORT: String(ports.health),
428
+ CADRE_METRICS_PORT: String(ports.metrics),
429
+ CADRE_LISTEN_ADDRS: `/ip4/0.0.0.0/tcp/${ports.p2p}`,
430
+ CADRE_SEED_TOKEN: seedToken,
227
431
  };
228
432
  if (heapMB !== undefined) {
229
433
  env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=${heapMB}`.trim();
@@ -236,35 +440,32 @@ export class HostProcessOrchestrator implements Orchestrator {
236
440
  maybeRotateAtSpawn(logPath, this.logMaxBytes, this.logMaxFiles);
237
441
  const logFd = openSync(logPath, 'a');
238
442
 
443
+ const args = [
444
+ this.cliEntrypoint,
445
+ 'start',
446
+ '-c', configPath,
447
+ '--health-port', String(ports.health),
448
+ '--metrics-port', String(ports.metrics),
449
+ '--startup-token-file', tokenPath,
450
+ ...opts.extraArgs,
451
+ ];
452
+
239
453
  let child: ChildProcess;
240
454
  try {
241
- child = spawn(
242
- process.execPath,
243
- [
244
- this.cliEntrypoint,
245
- 'start',
246
- '-c', configPath,
247
- '--health-port', String(healthPort),
248
- '--metrics-port', String(metricsPort),
249
- '--startup-token-file', tokenPath,
250
- ],
251
- {
252
- cwd: workdir,
253
- detached: true,
254
- // Inherit the log fd directly. OS duplicates it into the child;
255
- // closing our copy below is safe and severs the parent's only
256
- // tie to the child's write path.
257
- stdio: ['ignore', logFd, logFd],
258
- env,
259
- },
260
- );
455
+ child = spawn(process.execPath, args, {
456
+ cwd: workdir,
457
+ detached: true,
458
+ // Inherit the log fd directly. OS duplicates it into the child;
459
+ // closing our copy below is safe and severs the parent's only
460
+ // tie to the child's write path.
461
+ stdio: ['ignore', logFd, logFd],
462
+ env,
463
+ });
261
464
  } catch (err) {
262
465
  // Synchronous spawn failure — release the fd and the reserved ports
263
466
  // before propagating, so a bad-args bug doesn't leak resources.
264
467
  closeSync(logFd);
265
- this.portAllocator.release(healthPort);
266
- this.portAllocator.release(metricsPort);
267
- this.portAllocator.release(p2pPort);
468
+ this.releasePorts(ports);
268
469
  throw err;
269
470
  }
270
471
  // Always release the parent's copy of the fd — the child has its own.
@@ -272,25 +473,24 @@ export class HostProcessOrchestrator implements Orchestrator {
272
473
 
273
474
  if (!child.pid) {
274
475
  // Spawn returned without a pid — release reservations and report.
275
- this.portAllocator.release(healthPort);
276
- this.portAllocator.release(metricsPort);
277
- this.portAllocator.release(p2pPort);
278
- throw new Error(`Failed to spawn child for container ${request.containerId}`);
476
+ this.releasePorts(ports);
477
+ throw new Error(`Failed to spawn child for container ${containerId}`);
279
478
  }
280
479
 
281
480
  child.unref();
282
481
 
283
482
  const dockerId = encodeDockerId(child.pid, token);
284
483
  const handle: Handle = {
285
- containerId: request.containerId,
484
+ containerId,
286
485
  dockerId,
287
486
  pid: child.pid,
288
487
  startupToken: token,
289
488
  workdir,
290
- ports: { health: healthPort, metrics: metricsPort, p2p: p2pPort },
489
+ ports,
291
490
  spawnedAt: new Date().toISOString(),
292
- partyId: request.partyId,
293
- profile: request.profile,
491
+ partyId: opts.partyId,
492
+ profile: opts.profile,
493
+ ...(opts.owner ? { owner: true } : {}),
294
494
  child,
295
495
  alive: true,
296
496
  };
@@ -307,16 +507,34 @@ export class HostProcessOrchestrator implements Orchestrator {
307
507
  this.persist();
308
508
  this.emitStateChange(handle);
309
509
 
310
- log('created container %s -> pid %d ports=%j', request.containerId, child.pid, handle.ports);
510
+ log('created container %s -> pid %d ports=%j', containerId, child.pid, handle.ports);
311
511
 
312
512
  return {
313
513
  dockerId,
314
- healthEndpoint: `http://localhost:${healthPort}/health`,
315
- metricsEndpoint: `http://localhost:${metricsPort}/metrics`,
316
- p2pPort,
514
+ healthEndpoint: `http://localhost:${ports.health}/health`,
515
+ metricsEndpoint: `http://localhost:${ports.metrics}/metrics`,
516
+ // The node's seed API (`POST /seed`) is bound to the same server/port as `/health`.
517
+ seedEndpoint: `http://localhost:${ports.health}/seed`,
518
+ seedToken,
519
+ p2pPort: ports.p2p,
317
520
  };
318
521
  }
319
522
 
523
+ /** Locate the owner node's handle, if one has been spawned. */
524
+ private findOwnerHandle(): Handle | undefined {
525
+ for (const h of this.handles.values()) {
526
+ if (h.owner || h.containerId === OWNER_CONTAINER_ID) return h;
527
+ }
528
+ return undefined;
529
+ }
530
+
531
+ private releasePorts(ports: NodePorts): void {
532
+ this.portAllocator.release(ports.health);
533
+ this.portAllocator.release(ports.metrics);
534
+ this.portAllocator.release(ports.p2p);
535
+ this.portAllocator.release(ports.admin);
536
+ }
537
+
320
538
  async stopContainer(dockerId: string): Promise<void> {
321
539
  const handle = this.requireHandle(dockerId);
322
540
  if (!isPidAlive(handle.pid)) {
@@ -378,9 +596,7 @@ export class HostProcessOrchestrator implements Orchestrator {
378
596
  await this.stopContainer(dockerId);
379
597
  }
380
598
 
381
- this.portAllocator.release(handle.ports.health);
382
- this.portAllocator.release(handle.ports.metrics);
383
- this.portAllocator.release(handle.ports.p2p);
599
+ this.releasePorts(handle.ports);
384
600
 
385
601
  // On Windows the OS releases the child's cwd handle asynchronously, so
386
602
  // retry rmSync to give the kernel a chance to drop it.
@@ -395,7 +611,7 @@ export class HostProcessOrchestrator implements Orchestrator {
395
611
  }
396
612
 
397
613
  async isRunning(dockerId: string): Promise<boolean> {
398
- let handle: Handle | undefined = this.handles.get(dockerId);
614
+ const handle: Handle | undefined = this.handles.get(dockerId);
399
615
  let pid: number;
400
616
  let token: string;
401
617
  let workdir: string | undefined;
@@ -459,7 +675,11 @@ export class HostProcessOrchestrator implements Orchestrator {
459
675
  return handle;
460
676
  }
461
677
 
462
- private buildChildConfig(req: OrchestratorCreateRequest, workdir: string): Record<string, unknown> {
678
+ private buildChildConfig(
679
+ req: OrchestratorCreateRequest,
680
+ workdir: string,
681
+ push?: PushCredentials,
682
+ ): Record<string, unknown> {
463
683
  const cfg: Record<string, unknown> = {
464
684
  controlNetwork: {
465
685
  partyId: req.partyId,
@@ -478,13 +698,58 @@ export class HostProcessOrchestrator implements Orchestrator {
478
698
  }
479
699
  cfg.storage = storage;
480
700
  }
701
+ // Push credentials land in the child's cadre.json on the host filesystem —
702
+ // the same trust boundary as the control-DB storage in this workdir. The
703
+ // private keys are NOT logged (the resolver redacts; we log only presence).
704
+ if (push) cfg.push = push;
481
705
  return cfg;
482
706
  }
483
707
 
708
+ /**
709
+ * Child config for the **host's own personal cadre** owner node — the opt-in
710
+ * "founder" persona spawned only when `ownCadre.enabled` (see
711
+ * docs/cadre-host.md § Two roles: donor and founder). It founds/joins the
712
+ * control network for the host's own `partyId` (no bootstrap peers — it is the
713
+ * founding node of the host's *own* cadre) and carries the host identity via
714
+ * `--identity-protobuf` (passed as a spawn arg).
715
+ *
716
+ * This is NOT the node cadre-host donates to a requester. Donated nodes are
717
+ * generic (`createContainer` / `buildChildConfig`): they join the
718
+ * **requester's** cadre via bootstrap peers and pin the *requester's* owner
719
+ * key — they never run a host genesis.
720
+ */
721
+ private buildOwnerChildConfig(
722
+ cfg: OwnerSpawnConfig,
723
+ profile: 'storage' | 'transaction',
724
+ workdir: string,
725
+ push?: PushCredentials,
726
+ ): Record<string, unknown> {
727
+ const config: Record<string, unknown> = {
728
+ controlNetwork: {
729
+ partyId: cfg.partyId,
730
+ bootstrapNodes: [],
731
+ },
732
+ profile,
733
+ strandFilter: 'all',
734
+ };
735
+ if (profile === 'storage') {
736
+ config.storage = { type: 'file', path: join(workdir, 'storage') };
737
+ }
738
+ // The host-own-cadre owner/storage node participates in strands, so it owns
739
+ // the push-wake fan-out when credentials are configured. (Donated nodes
740
+ // belong to foreign cadres and get NO host push block — see the donor path.)
741
+ // Written into cadre.json (same host trust boundary as the workdir's
742
+ // control-DB); keys are never logged — only the redacted presence line in
743
+ // resolvePush.
744
+ if (push) config.push = push;
745
+ return config;
746
+ }
747
+
484
748
  private persist(): void {
485
749
  const state: PersistedState = {
486
750
  version: 1,
487
751
  handles: [...this.handles.values()].map((h) => this.toPersisted(h)),
752
+ ...(this.ownerConfig ? { ownerConfig: this.ownerConfig } : {}),
488
753
  };
489
754
  this.stateStore.save(state);
490
755
  }
@@ -512,6 +777,7 @@ export class HostProcessOrchestrator implements Orchestrator {
512
777
  spawnedAt: h.spawnedAt,
513
778
  partyId: h.partyId,
514
779
  profile: h.profile,
780
+ ...(h.owner ? { owner: true } : {}),
515
781
  };
516
782
  }
517
783
  }
@@ -526,6 +792,7 @@ function toNodeInfo(h: Handle): ManagedNodeInfo {
526
792
  spawnedAt: h.spawnedAt,
527
793
  workdir: h.workdir,
528
794
  ports: { ...h.ports },
795
+ ...(h.owner ? { owner: true } : {}),
529
796
  };
530
797
  }
531
798
 
@@ -542,6 +809,7 @@ function resolveCadreCliBin(): string {
542
809
  `Unable to resolve @serfab/cadre-cli bin from cadre-host. ` +
543
810
  `Ensure cadre-cli is built (yarn workspace @serfab/cadre-cli build) ` +
544
811
  `and listed as a dependency. Underlying error: ${(err as Error).message}`,
812
+ { cause: err },
545
813
  );
546
814
  }
547
815
  }
@@ -564,7 +832,7 @@ function tokenMatches(workdir: string, expected: string): boolean {
564
832
  */
565
833
  function maybeRotateAtSpawn(logPath: string, maxBytes: number, maxFiles: number): void {
566
834
  if (maxBytes <= 0 || maxFiles < 1) return;
567
- let size = 0;
835
+ let size: number;
568
836
  try {
569
837
  size = statSync(logPath).size;
570
838
  } catch {
@@ -1,3 +1,10 @@
1
- export { HostProcessOrchestrator } from './host-process-orchestrator.js';
2
- export type { HostProcessConfig, ManagedNodeInfo, NodeStateListener } from './types.js';
1
+ export { HostProcessOrchestrator, OWNER_CONTAINER_ID } from './host-process-orchestrator.js';
2
+ export type { OwnerAdminEndpoint } from './host-process-orchestrator.js';
3
+ export type {
4
+ HostProcessConfig,
5
+ ManagedNodeInfo,
6
+ NodeStateListener,
7
+ NodePorts,
8
+ OwnerSpawnConfig,
9
+ } from './types.js';
3
10
  export type { PersistedHandle } from './state-store.js';
@@ -2,6 +2,8 @@ import { existsSync, readFileSync, renameSync, writeFileSync, mkdirSync } from '
2
2
  import { dirname, join } from 'node:path';
3
3
  import debug from 'debug';
4
4
 
5
+ import type { OwnerSpawnConfig, NodePorts } from './types.js';
6
+
5
7
  const log = debug('cadre:host:state-store');
6
8
 
7
9
  /**
@@ -15,15 +17,19 @@ export interface PersistedHandle {
15
17
  pid: number;
16
18
  startupToken: string;
17
19
  workdir: string;
18
- ports: { health: number; metrics: number; p2p: number };
20
+ ports: NodePorts;
19
21
  spawnedAt: string; // ISO timestamp
20
22
  partyId: string;
21
23
  profile: 'storage' | 'transaction';
24
+ /** True for the admin's owner node. */
25
+ owner?: boolean;
22
26
  }
23
27
 
24
28
  export interface PersistedState {
25
29
  version: 1;
26
30
  handles: PersistedHandle[];
31
+ /** Spawn parameters for the owner node, so it can be re-spawned on demand. */
32
+ ownerConfig?: OwnerSpawnConfig;
27
33
  }
28
34
 
29
35
  const STATE_VERSION = 1;
@@ -48,7 +54,11 @@ export class StateStore {
48
54
  log('state file at %s has unexpected shape; treating as empty', this.path);
49
55
  return { version: STATE_VERSION, handles: [] };
50
56
  }
51
- return { version: STATE_VERSION, handles: parsed.handles };
57
+ return {
58
+ version: STATE_VERSION,
59
+ handles: parsed.handles,
60
+ ...(parsed.ownerConfig ? { ownerConfig: parsed.ownerConfig } : {}),
61
+ };
52
62
  } catch (err) {
53
63
  // A corrupt state file is operationally significant — surviving children
54
64
  // may now be leaked to the OS without an in-memory handle. Make the
@@ -60,7 +70,15 @@ export class StateStore {
60
70
 
61
71
  save(state: PersistedState): void {
62
72
  mkdirSync(dirname(this.path), { recursive: true });
63
- const payload = JSON.stringify({ version: STATE_VERSION, handles: state.handles }, null, 2);
73
+ const payload = JSON.stringify(
74
+ {
75
+ version: STATE_VERSION,
76
+ handles: state.handles,
77
+ ...(state.ownerConfig ? { ownerConfig: state.ownerConfig } : {}),
78
+ },
79
+ null,
80
+ 2,
81
+ );
64
82
  const tmp = `${this.path}.tmp`;
65
83
  writeFileSync(tmp, payload, { encoding: 'utf8' });
66
84
  renameSync(tmp, this.path);