@serfab/cadre-host 0.9.0 → 0.10.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 (113) hide show
  1. package/dist/auth/index.d.ts +2 -0
  2. package/dist/auth/index.js +1 -0
  3. package/dist/auth/index.js.map +1 -1
  4. package/dist/auth/self-label.d.ts +36 -0
  5. package/dist/auth/self-label.js +57 -0
  6. package/dist/auth/self-label.js.map +1 -0
  7. package/dist/auth/trust-circle.d.ts +32 -5
  8. package/dist/auth/trust-circle.js +31 -11
  9. package/dist/auth/trust-circle.js.map +1 -1
  10. package/dist/bin/host.js +41 -5
  11. package/dist/bin/host.js.map +1 -1
  12. package/dist/donation/__tests__/fake-orchestrator.d.ts +103 -0
  13. package/dist/donation/__tests__/fake-orchestrator.js +180 -0
  14. package/dist/donation/__tests__/fake-orchestrator.js.map +1 -0
  15. package/dist/donation/donation-service.d.ts +237 -12
  16. package/dist/donation/donation-service.js +491 -34
  17. package/dist/donation/donation-service.js.map +1 -1
  18. package/dist/donation/donation-store.d.ts +7 -0
  19. package/dist/donation/donation-store.js +7 -0
  20. package/dist/donation/donation-store.js.map +1 -1
  21. package/dist/donation/donation-supervisor.d.ts +167 -0
  22. package/dist/donation/donation-supervisor.js +341 -0
  23. package/dist/donation/donation-supervisor.js.map +1 -0
  24. package/dist/donation/index.d.ts +4 -2
  25. package/dist/donation/index.js +2 -1
  26. package/dist/donation/index.js.map +1 -1
  27. package/dist/donation/types.d.ts +26 -0
  28. package/dist/donation/types.js.map +1 -1
  29. package/dist/index.d.ts +3 -1
  30. package/dist/index.js +3 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/orchestrator/host-process-orchestrator.d.ts +109 -5
  33. package/dist/orchestrator/host-process-orchestrator.js +341 -98
  34. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  35. package/dist/orchestrator/node-identity.d.ts +26 -0
  36. package/dist/orchestrator/node-identity.js +36 -0
  37. package/dist/orchestrator/node-identity.js.map +1 -0
  38. package/dist/orchestrator/port-allocator.d.ts +14 -0
  39. package/dist/orchestrator/port-allocator.js +45 -0
  40. package/dist/orchestrator/port-allocator.js.map +1 -1
  41. package/dist/owner/owner-node-client.d.ts +20 -5
  42. package/dist/owner/owner-node-client.js +21 -4
  43. package/dist/owner/owner-node-client.js.map +1 -1
  44. package/dist/server/error-handler.d.ts +7 -1
  45. package/dist/server/error-handler.js +23 -1
  46. package/dist/server/error-handler.js.map +1 -1
  47. package/dist/server/events/types.d.ts +3 -0
  48. package/dist/server/index.d.ts +7 -0
  49. package/dist/server/index.js +9 -3
  50. package/dist/server/index.js.map +1 -1
  51. package/dist/server/routes/grants.js +22 -3
  52. package/dist/server/routes/grants.js.map +1 -1
  53. package/dist/server/routes/strands.d.ts +20 -0
  54. package/dist/server/routes/strands.js +38 -0
  55. package/dist/server/routes/strands.js.map +1 -0
  56. package/dist/strands/index.d.ts +7 -0
  57. package/dist/strands/index.js +6 -0
  58. package/dist/strands/index.js.map +1 -0
  59. package/dist/strands/strand-service.d.ts +50 -0
  60. package/dist/strands/strand-service.js +103 -0
  61. package/dist/strands/strand-service.js.map +1 -0
  62. package/dist/strands/types.d.ts +93 -0
  63. package/dist/strands/types.js +23 -0
  64. package/dist/strands/types.js.map +1 -0
  65. package/dist/ui/assets/index-BVoqLjyX.css +1 -0
  66. package/dist/ui/assets/index-C-iks41M.js +17 -0
  67. package/dist/ui/assets/index-C-iks41M.js.map +1 -0
  68. package/dist/ui/index.html +2 -2
  69. package/dist/update/release-key.d.ts +1 -1
  70. package/dist/update/release-key.js +1 -1
  71. package/package.json +5 -5
  72. package/src/__tests__/build-targets.test.ts +26 -0
  73. package/src/__tests__/global-setup.ts +51 -0
  74. package/src/__tests__/orchestrator-env-scrub.test.ts +140 -0
  75. package/src/__tests__/orchestrator-node-identity.test.ts +204 -0
  76. package/src/__tests__/orchestrator.test.ts +387 -6
  77. package/src/auth/__tests__/self-label.test.ts +85 -0
  78. package/src/auth/__tests__/trust-circle-integration.test.ts +152 -147
  79. package/src/auth/__tests__/trust-circle.test.ts +45 -1
  80. package/src/auth/index.ts +2 -0
  81. package/src/auth/self-label.ts +74 -0
  82. package/src/auth/trust-circle.ts +45 -13
  83. package/src/bin/host.ts +1299 -1258
  84. package/src/donation/__tests__/donation-service.test.ts +836 -58
  85. package/src/donation/__tests__/donation-supervisor.test.ts +510 -0
  86. package/src/donation/__tests__/fake-orchestrator.test.ts +168 -0
  87. package/src/donation/__tests__/fake-orchestrator.ts +225 -0
  88. package/src/donation/donation-service.ts +611 -41
  89. package/src/donation/donation-store.ts +7 -0
  90. package/src/donation/donation-supervisor.ts +388 -0
  91. package/src/donation/index.ts +15 -0
  92. package/src/donation/types.ts +23 -0
  93. package/src/index.ts +14 -0
  94. package/src/orchestrator/__tests__/node-identity.test.ts +62 -0
  95. package/src/orchestrator/host-process-orchestrator.ts +336 -97
  96. package/src/orchestrator/node-identity.ts +40 -0
  97. package/src/orchestrator/port-allocator.ts +48 -0
  98. package/src/owner/owner-node-client.ts +31 -5
  99. package/src/server/__tests__/grants-route.test.ts +80 -6
  100. package/src/server/__tests__/strands-route.test.ts +233 -0
  101. package/src/server/error-handler.ts +24 -1
  102. package/src/server/events/types.ts +1 -0
  103. package/src/server/index.ts +16 -3
  104. package/src/server/routes/grants.ts +27 -3
  105. package/src/server/routes/strands.ts +54 -0
  106. package/src/strands/__tests__/strand-service.test.ts +165 -0
  107. package/src/strands/index.ts +14 -0
  108. package/src/strands/strand-service.ts +135 -0
  109. package/src/strands/types.ts +102 -0
  110. package/src/update/release-key.ts +1 -1
  111. package/dist/ui/assets/index-BlMXQPSw.js +0 -14
  112. package/dist/ui/assets/index-BlMXQPSw.js.map +0 -1
  113. package/dist/ui/assets/index-BwU5mJGF.css +0 -1
@@ -1,11 +1,13 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import debug from 'debug';
3
3
 
4
- import type { Orchestrator } from '@serfab/cadre-provider';
4
+ import { requireEd25519PublicKeyB64 } from '@serfab/cadre-core';
5
+ import type { Orchestrator, OrchestratorCreateResult } from '@serfab/cadre-provider';
5
6
 
6
7
  import type { DonationStore } from './donation-store.js';
7
8
  import type {
8
9
  Donation,
10
+ DonationStatus,
9
11
  DonationView,
10
12
  GrantDenyReason,
11
13
  GrantValidator,
@@ -14,6 +16,37 @@ import { DonationError } from './types.js';
14
16
 
15
17
  const log = debug('cadre:host:donation-service');
16
18
 
19
+ /**
20
+ * The only statuses a loan may come back from. An allowlist, not a terminal
21
+ * denylist: `terminated` is a loan the borrower ended and `error` one the host
22
+ * gave up on (neither may come back), while `provisioning` is a provision still
23
+ * in flight — replaying its spawn would race that provision and strand the
24
+ * record in `provisioning`, which no reap sweep collects.
25
+ *
26
+ * Checked twice in {@link DonationService.respawn} — once on entry, once after
27
+ * the spawn — so the "may this loan come back" rule is stated exactly once.
28
+ */
29
+ const RESPAWNABLE_STATUSES: ReadonlySet<DonationStatus> = new Set<DonationStatus>([
30
+ 'awaiting_seed',
31
+ 'seeded',
32
+ ]);
33
+
34
+ /**
35
+ * The only statuses a record may be marked `seeded` from. A different question
36
+ * from {@link RESPAWNABLE_STATUSES} — "may this record accept a seed result"
37
+ * rather than "may this loan come back" — so it is stated separately even though
38
+ * the two sets happen to coincide today. Re-seeding an already-`seeded` record is
39
+ * allowed (the borrower may present a fresh seed); `terminated` / `error` /
40
+ * `provisioning` may not be written over.
41
+ *
42
+ * Checked twice in {@link DonationService.applySeed} — once on entry, once after
43
+ * the node's round-trip — so the rule is stated exactly once.
44
+ */
45
+ const SEEDABLE_STATUSES: ReadonlySet<DonationStatus> = new Set<DonationStatus>([
46
+ 'awaiting_seed',
47
+ 'seeded',
48
+ ]);
49
+
17
50
  /**
18
51
  * Default age after which a donation still stuck in `awaiting_seed` is
19
52
  * auto-terminated: the requester provisioned a node but never presented a seed
@@ -25,6 +58,17 @@ const log = debug('cadre:host:donation-service');
25
58
  */
26
59
  export const DONATION_AWAITING_SEED_TTL_MS = 30 * 60 * 1000;
27
60
 
61
+ /**
62
+ * Default age after which a donation still stuck in `provisioning` is
63
+ * auto-terminated: the host wrote the row before starting the child, and a
64
+ * crash/kill in that window (or during the spawn itself) leaves nothing to
65
+ * advance it across a restart. A real spawn (identity key read/gen, port
66
+ * allocation, process spawn) completes in well under a second normally, so 5
67
+ * minutes is generously past "any plausible spawn" without risking a false
68
+ * reap of one still genuinely in flight under heavy load.
69
+ */
70
+ export const DONATION_PROVISIONING_TTL_MS = 5 * 60 * 1000;
71
+
28
72
  /** How often the reap sweep runs while cadre-host is up. 5 minutes. */
29
73
  export const DONATION_REAP_SWEEP_MS = 5 * 60 * 1000;
30
74
 
@@ -39,31 +83,113 @@ export interface DonationProvisionRequest {
39
83
  /**
40
84
  * The requester's owner public key(s), base64url. Pinned as cold-start
41
85
  * seed-trust anchors on the donated node so it accepts the requester-signed
42
- * seed presented in {@link DonationService.applySeed}. Without these the node
43
- * defaults to a db-anchored policy that rejects every seed.
86
+ * seed presented in {@link DonationService.applySeed}. Without these the node's
87
+ * node-local trusted-owner anchor holds no key for the requester's party, and
88
+ * the default anchored policy rejects every seed.
89
+ *
90
+ * Shape-validated on the way in ({@link DonationService.provision}): every entry
91
+ * must decode as a base64url 32-byte Ed25519 public key, the same rule the node
92
+ * itself applies to `CADRE_OWNER_KEYS`. So everything downstream — the persisted
93
+ * record, the child's environment, a later respawn replaying it — carries values
94
+ * the node can actually start with. Curve membership is NOT checked (see
95
+ * `requireEd25519PublicKeyB64`): a well-formed key naming the wrong signer still
96
+ * fails at seed time, as any wrong key would.
44
97
  */
45
98
  ownerKeys: string[];
46
99
  /** Node profile; defaults to `storage` so the node participates and is dialable. */
47
100
  profile?: 'storage' | 'transaction';
48
101
  }
49
102
 
50
- /** Result of presenting a seed to a donated node's `POST /seed`. */
51
- export interface DonationSeedResult {
103
+ /**
104
+ * The donated node's `POST /seed` response body. Module-private: this is the
105
+ * wire shape we parse, not the outcome we report ({@link DonationSeedResult}).
106
+ */
107
+ interface NodeSeedResponse {
52
108
  success: boolean;
53
109
  peersAdded?: number;
54
110
  error?: string;
55
111
  }
56
112
 
113
+ /**
114
+ * Outcome of {@link DonationService.applySeed}.
115
+ *
116
+ * - `seeded` — the node accepted the seed and the record now says so.
117
+ * - `rejected` — the node refused the seed (its own seed-trust policy). Nothing
118
+ * written.
119
+ * - `abandoned` — the seed reached the node, but the loan ended while the
120
+ * request was in flight. The ending wins: the record is left exactly as the
121
+ * ending wrote it. `status` is the status that won, absent when the row is
122
+ * gone entirely.
123
+ */
124
+ export type DonationSeedResult =
125
+ | { outcome: 'seeded'; peersAdded: number }
126
+ | { outcome: 'rejected'; error?: string }
127
+ | { outcome: 'abandoned'; status?: DonationStatus };
128
+
57
129
  /** Live peer identity of a donated node. */
58
130
  export interface DonationPeerInfo {
59
131
  peerId: string;
60
132
  multiaddrs: string[];
61
133
  }
62
134
 
135
+ /**
136
+ * Outcome of {@link DonationService.respawn}. Three distinct non-throwing ends,
137
+ * which a bare `DonationView | undefined` conflated:
138
+ *
139
+ * - `respawned` — a new child is up and the record now names its handles.
140
+ * - `not_respawnable` — the record predates persisted spawn inputs, so there is
141
+ * nothing to replay. Permanent; no child was spawned.
142
+ * - `abandoned` — the loan ended (or the record vanished) while the spawn was in
143
+ * flight. The ending wins: the record is left exactly as the ending wrote it
144
+ * and the new child is cleaned up. `status` is the status that won, absent
145
+ * when the row is gone entirely.
146
+ */
147
+ export type RespawnResult =
148
+ | { outcome: 'respawned'; donation: DonationView }
149
+ | { outcome: 'not_respawnable' }
150
+ | { outcome: 'abandoned'; status?: DonationStatus };
151
+
152
+ /**
153
+ * The three per-spawn fields a donation record carries for the child that
154
+ * currently exists. Whatever the record names here is what every later cleanup
155
+ * (`terminate` → stop + reclaim) acts on, so a spawn that succeeded must land
156
+ * these on the record even when the surrounding operation then fails.
157
+ */
158
+ interface SpawnedHandles {
159
+ dockerId: string;
160
+ seedEndpoint: string;
161
+ seedToken: string;
162
+ }
163
+
164
+ /** A respawn's attempt counters — the record's `respawn` block, never absent. */
165
+ type RespawnAttempt = NonNullable<Donation['respawn']>;
166
+
167
+ /**
168
+ * Orchestrator capabilities the donation cleanup paths need beyond the base
169
+ * `Orchestrator`. Both are optional — only `HostProcessOrchestrator` implements
170
+ * them; absent on a test double or a future orchestrator, the cleanup degrades
171
+ * to terminalizing the record with nothing to reclaim.
172
+ */
173
+ export interface DonationOrchestrator extends Orchestrator {
174
+ /**
175
+ * Resolve a spawn's friendly containerId (== a donation's id) back to its
176
+ * current dockerId, so a stuck-`provisioning` reap can find and reclaim a
177
+ * child that was actually spawned before the host died.
178
+ */
179
+ resolveDockerId?(containerId: string): string | undefined;
180
+ /**
181
+ * Remove the working directory a spawn created for `containerId` when no
182
+ * handle owns it — the cleanup of last resort for a record that never got a
183
+ * `dockerId`. Returns whether anything was removed. Refuses when a handle
184
+ * still resolves (that child's directory belongs to `removeContainer`).
185
+ */
186
+ reclaimWorkdir?(containerId: string): boolean;
187
+ }
188
+
63
189
  /** Constructor options. */
64
190
  export interface DonationServiceOptions {
65
191
  /** Orchestrator that spawns/stops the donated node child process. */
66
- orchestrator: Orchestrator;
192
+ orchestrator: DonationOrchestrator;
67
193
  /** Grant validator (identity/expiry/revocation + quota). */
68
194
  grants: GrantValidator;
69
195
  /** Persistent donation store (`donations.json`). */
@@ -89,7 +215,7 @@ export interface DonationServiceOptions {
89
215
  * `2-donation-service` ticket and land alongside this file.
90
216
  */
91
217
  export class DonationService {
92
- private readonly orchestrator: Orchestrator;
218
+ private readonly orchestrator: DonationOrchestrator;
93
219
  private readonly grants: GrantValidator;
94
220
  private readonly store: DonationStore;
95
221
  private readonly now: () => Date;
@@ -117,9 +243,17 @@ export class DonationService {
117
243
  * Provision a donated node for a validated grant. Serialized per grant token
118
244
  * so the quota check and record-create are atomic. On any orchestrator failure
119
245
  * the reserved resources are reclaimed and the record is marked `error`.
246
+ *
247
+ * The owner-key pins are shape-checked FIRST — ahead of the per-grant lock, the
248
+ * quota check and the record write — so a malformed request costs nothing: no
249
+ * waiting behind another provision, no grant quota slot burned, no workdir or
250
+ * port taken, and no `error` record to explain later. Without it the request is
251
+ * answered as provisioned and the child dies at boot, which is the one place the
252
+ * caller cannot act on the typo.
120
253
  */
121
254
  async provision(request: DonationProvisionRequest): Promise<DonationView> {
122
- return this.serializeByGrant(request.grantToken, () => this.provisionLocked(request));
255
+ const validated = { ...request, ownerKeys: validateOwnerKeys(request.ownerKeys) };
256
+ return this.serializeByGrant(validated.grantToken, () => this.provisionLocked(validated));
123
257
  }
124
258
 
125
259
  private async provisionLocked(request: DonationProvisionRequest): Promise<DonationView> {
@@ -138,6 +272,11 @@ export class DonationService {
138
272
  id,
139
273
  grantToken: request.grantToken,
140
274
  partyId: request.partyId,
275
+ // Persisted so `respawn` can replay the spawn from the record alone.
276
+ // Neither is secret (dialable addrs + public keys), so both ride along in
277
+ // the redacted `DonationView` too.
278
+ bootstrapNodes: request.bootstrapNodes,
279
+ ownerKeys: request.ownerKeys,
141
280
  profile,
142
281
  status: 'provisioning',
143
282
  createdAt: nowIso,
@@ -145,43 +284,97 @@ export class DonationService {
145
284
  };
146
285
  this.store.put(record);
147
286
 
148
- // Once the orchestrator returns a dockerId this service owns cleanup of
149
- // those (bounded host port) resources on every non-success exit.
150
- let dockerId: string | undefined;
287
+ // The spawn is the only step that can fail before anything is allocated, so
288
+ // it gets a `try` of its own — a wider one would swallow the abandon
289
+ // decision below and mark a borrower's own ending as a host-side `error`.
290
+ let result: OrchestratorCreateResult;
151
291
  try {
152
- const result = await this.orchestrator.createContainer({
292
+ result = await this.orchestrator.createContainer({
153
293
  containerId: id,
154
294
  partyId: request.partyId,
155
295
  bootstrapNodes: request.bootstrapNodes,
156
296
  profile,
157
297
  pinnedOwnerKeys: request.ownerKeys,
158
298
  });
159
- dockerId = result.dockerId;
299
+ } catch (err) {
300
+ const message = errorMessage(err);
301
+ log('provision of donation %s failed: %s', id, message);
302
+ this.markProvisionFailed(id, message);
303
+ throw new DonationError('orchestrator_error', `Failed to provision donated node: ${message}`);
304
+ }
160
305
 
161
- const provisioned: Donation = {
162
- ...record,
163
- dockerId: result.dockerId,
164
- seedEndpoint: result.seedEndpoint,
165
- // Persisted so a host restart in the request→seed gap can still present
166
- // the seed. NEVER returned to the grantee (stripped by `DonationView`).
167
- seedToken: result.seedToken,
168
- status: 'awaiting_seed',
169
- updatedAt: this.now().toISOString(),
170
- };
306
+ // Re-read: `record` predates the orchestrator round-trip (seconds of wall
307
+ // clock) and `store.put` replaces the whole row, so writing that copy back
308
+ // would undo a `terminate` — or a delete — that landed while the spawn was
309
+ // in flight, resurrecting a loan the borrower just ended. The store is
310
+ // synchronous, so this read-decide-write is atomic against the event loop
311
+ // only as long as no `await` sneaks in between.
312
+ const current = this.store.get(id);
313
+ if (!current || current.status !== 'provisioning') {
314
+ // The ending cleaned up nothing: the record named no `dockerId` yet, so
315
+ // `terminate`'s cleanup branch never ran and this child is the only thing
316
+ // holding the spawn's ports and workdir. Reclaim (not merely stop) — the
317
+ // workdir was created by this very spawn, so there is nothing to preserve.
318
+ log(
319
+ 'donation %s went %s during provision — abandoning new child %s',
320
+ id,
321
+ current?.status ?? 'missing',
322
+ result.dockerId,
323
+ );
324
+ await this.safeReclaim(result.dockerId);
325
+ throw current
326
+ ? new DonationError(
327
+ 'invalid_state',
328
+ `Donation ${id} was ${current.status} before it finished provisioning`,
329
+ )
330
+ : new DonationError('not_found', `No such donation: ${id}`);
331
+ }
332
+
333
+ const provisioned: Donation = {
334
+ ...current,
335
+ dockerId: result.dockerId,
336
+ seedEndpoint: result.seedEndpoint,
337
+ // Persisted so a host restart in the request→seed gap can still present
338
+ // the seed. NEVER returned to the grantee (stripped by `DonationView`).
339
+ seedToken: result.seedToken,
340
+ status: 'awaiting_seed',
341
+ updatedAt: this.now().toISOString(),
342
+ };
343
+ try {
171
344
  this.store.put(provisioned);
172
- log('provisioned donation %s (party=%s) → awaiting_seed', id, request.partyId);
173
- return redact(provisioned);
174
345
  } catch (err) {
175
346
  const message = errorMessage(err);
176
347
  log('provision of donation %s failed: %s', id, message);
177
- if (dockerId) await this.safeReclaim(dockerId);
348
+ await this.safeReclaim(result.dockerId);
349
+ this.markProvisionFailed(id, message);
350
+ throw new DonationError('orchestrator_error', `Failed to provision donated node: ${message}`);
351
+ }
352
+ log('provisioned donation %s (party=%s) → awaiting_seed', id, request.partyId);
353
+ return redact(provisioned);
354
+ }
355
+
356
+ /**
357
+ * Mark a still-`provisioning` record as failed — and ONLY such a record. A row
358
+ * that went `terminated` while the spawn was in flight is the borrower's own
359
+ * ending and must not be rewritten as a host fault; a row that vanished must
360
+ * not be recreated.
361
+ *
362
+ * Best-effort (mirroring {@link storeRespawnAttempt}): we are already unwinding a
363
+ * provision failure, and a store error here must not mask the error being
364
+ * reported to the caller.
365
+ */
366
+ private markProvisionFailed(id: string, message: string): void {
367
+ try {
368
+ const current = this.store.get(id);
369
+ if (current?.status !== 'provisioning') return;
178
370
  this.store.put({
179
- ...record,
371
+ ...current,
180
372
  status: 'error',
181
373
  error: message,
182
374
  updatedAt: this.now().toISOString(),
183
375
  });
184
- throw new DonationError('orchestrator_error', `Failed to provision donated node: ${message}`);
376
+ } catch (err) {
377
+ log('failed to mark donation %s as errored: %s', id, errorMessage(err));
185
378
  }
186
379
  }
187
380
 
@@ -218,11 +411,18 @@ export class DonationService {
218
411
  * Present the requester's phone-signed seed to the donated node's `POST /seed`,
219
412
  * authenticated with the host↔node `seedToken`. On success the donation moves
220
413
  * to `seeded`. The node still enforces its own seed-trust policy, so a seed
221
- * signed by a key the node did not pin is rejected here (`success: false`).
414
+ * signed by a key the node did not pin comes back `rejected`.
415
+ *
416
+ * **An ending that lands mid-seed wins.** The record is re-read after the node
417
+ * answers; if the loan ended (or the row vanished) while the request was in
418
+ * flight, that write stands and we report `abandoned` rather than marking a
419
+ * dead loan `seeded`. Unlike an abandoned respawn there is nothing to clean up
420
+ * — the record named its `dockerId` the whole time, so the ending's own
421
+ * `terminate` already stopped and reclaimed the child.
222
422
  */
223
423
  async applySeed(id: string, encodedSeed: string): Promise<DonationSeedResult> {
224
424
  const donation = this.requireDonation(id);
225
- if (donation.status !== 'awaiting_seed' && donation.status !== 'seeded') {
425
+ if (!SEEDABLE_STATUSES.has(donation.status)) {
226
426
  throw new DonationError(
227
427
  'invalid_state',
228
428
  `Donation ${id} cannot be seeded in status ${donation.status}`,
@@ -255,25 +455,222 @@ export class DonationService {
255
455
  throw new DonationError('seed_failed', `Donated node /seed returned ${res.status}: ${body}`);
256
456
  }
257
457
 
258
- const result = (await res.json()) as DonationSeedResult;
259
- if (result.success) {
260
- this.store.put({ ...donation, status: 'seeded', updatedAt: this.now().toISOString() });
261
- log('donation %s seeded (peersAdded=%d)', id, result.peersAdded ?? 0);
458
+ const result = (await res.json()) as NodeSeedResponse;
459
+ if (!result.success) {
460
+ log('donation %s rejected the seed: %s', id, result.error ?? 'no reason given');
461
+ return result.error === undefined
462
+ ? { outcome: 'rejected' }
463
+ : { outcome: 'rejected', error: result.error };
262
464
  }
263
- return result;
465
+
466
+ // Re-read: `donation` predates the node round-trip and `store.put` replaces
467
+ // the whole row, so writing that copy back would undo a `terminate` — or a
468
+ // reap — that landed while the seed was in flight, resurrecting an ended
469
+ // loan. The store is synchronous, so this read-decide-write is atomic
470
+ // against the event loop only as long as no `await` sneaks in between.
471
+ //
472
+ // NOTE: a concurrent `respawn` also passes this check — it leaves the status
473
+ // alone but swaps `seedEndpoint`/`seedToken`, so we would mark `seeded` a
474
+ // record whose live child is the new one while the seed went to the old
475
+ // endpoint. Harmless today (respawn only fires when the supervisor believes
476
+ // the child is down, in which case our `fetch` would have failed; and both
477
+ // spawns share one workdir, so the seed the old child persisted is on disk
478
+ // for the new one). If a second `respawn` caller appears, or respawn ever
479
+ // stops sharing the workdir, compare `current.seedEndpoint`/`seedToken`
480
+ // against the ones we actually seeded and report `abandoned` on a mismatch.
481
+ const current = this.store.get(id);
482
+ if (!current || !SEEDABLE_STATUSES.has(current.status)) {
483
+ log('donation %s went %s during seed — the ending wins', id, current?.status ?? 'missing');
484
+ return current ? { outcome: 'abandoned', status: current.status } : { outcome: 'abandoned' };
485
+ }
486
+
487
+ // Merge only the status transition onto the on-disk row.
488
+ this.store.put({ ...current, status: 'seeded', updatedAt: this.now().toISOString() });
489
+ log('donation %s seeded (peersAdded=%d)', id, result.peersAdded ?? 0);
490
+ return { outcome: 'seeded', peersAdded: result.peersAdded ?? 0 };
264
491
  }
265
492
 
266
493
  /**
267
- * Terminate a donation: stop + remove the child process, then mark the record
268
- * `terminated` (kept for audit, excluded from the live-node tally).
494
+ * Re-spawn a donated node that is no longer running, replaying the spawn
495
+ * inputs persisted on the record. The node keeps its workdir — and with it its
496
+ * identity key, trusted-owner anchor, and retained bootstrap peers — so it
497
+ * comes back as the *same* peer and rejoins from its own durable node-local
498
+ * stores. Only the host-side handles change: `dockerId`, `seedEndpoint`,
499
+ * `seedToken` are written back fresh.
500
+ *
501
+ * **Status is deliberately unchanged.** A `seeded` node stays `seeded` (it
502
+ * needs no re-seeding); an `awaiting_seed` node stays `awaiting_seed` and the
503
+ * borrower's later seed goes to the new endpoint/token.
504
+ *
505
+ * **An ending that lands mid-spawn wins.** The record is re-read after the
506
+ * orchestrator returns; if it went `terminated` (or `error`, or vanished)
507
+ * while the child was starting, that write stands and the new child is
508
+ * abandoned — see {@link abandonRespawn}. Both non-`respawned` outcomes are
509
+ * skips, not failures, so a sweep over the store keeps going. Orchestrator
510
+ * failures DO throw; the caller owns backoff/give-up.
511
+ *
512
+ * NOTE: not serialized. Two overlapping calls for the same id both spawn a
513
+ * child, and the second spawn drops the first's orchestrator handle — leaving
514
+ * an unmanaged process. A supervisor with more than one trigger (timer +
515
+ * exit event) must not let its passes overlap on one id.
516
+ */
517
+ async respawn(id: string): Promise<RespawnResult> {
518
+ const donation = this.requireDonation(id);
519
+ // Callers filter for this already; the guard is here so no future one can
520
+ // resurrect or double-spawn a loan by omission.
521
+ if (!RESPAWNABLE_STATUSES.has(donation.status)) {
522
+ throw new DonationError(
523
+ 'invalid_state',
524
+ `Donation ${id} cannot be respawned in status ${donation.status}`,
525
+ );
526
+ }
527
+ if (!donation.bootstrapNodes?.length || !donation.ownerKeys?.length) {
528
+ log('donation %s is not respawnable (record predates persisted spawn inputs)', id);
529
+ return { outcome: 'not_respawnable' };
530
+ }
531
+
532
+ // Just the counters, never a whole row copy: both exits below merge these
533
+ // onto whatever the store holds *after* the spawn, and an entry-time
534
+ // `Donation` in scope is a standing invitation to write the stale row back.
535
+ const attempt: RespawnAttempt = {
536
+ attempts: (donation.respawn?.attempts ?? 0) + 1,
537
+ lastAttemptAt: this.now().toISOString(),
538
+ };
539
+
540
+ let spawned: SpawnedHandles | undefined;
541
+ try {
542
+ const result = await this.orchestrator.createContainer({
543
+ containerId: id,
544
+ partyId: donation.partyId,
545
+ bootstrapNodes: donation.bootstrapNodes,
546
+ profile: donation.profile,
547
+ pinnedOwnerKeys: donation.ownerKeys,
548
+ });
549
+ spawned = {
550
+ dockerId: result.dockerId,
551
+ seedEndpoint: result.seedEndpoint,
552
+ seedToken: result.seedToken,
553
+ };
554
+
555
+ // Re-read: `donation` predates the orchestrator round-trip (seconds of
556
+ // wall clock) and `store.put` replaces the whole row, so writing that copy
557
+ // back would undo a `terminate` — or a reap, or a give-up — that landed
558
+ // while the spawn was in flight, resurrecting an ended loan. The store is
559
+ // synchronous, so this read-decide-write is atomic against the event loop
560
+ // only as long as no `await` sneaks in between.
561
+ const current = this.store.get(id);
562
+ if (!current || !RESPAWNABLE_STATUSES.has(current.status)) {
563
+ return this.abandonRespawn(id, result.dockerId, current);
564
+ }
565
+
566
+ const respawned: Donation = {
567
+ ...current,
568
+ // Merge only the attempt counters forward off the entry-time copy.
569
+ respawn: attempt,
570
+ dockerId: result.dockerId,
571
+ seedEndpoint: result.seedEndpoint,
572
+ seedToken: result.seedToken,
573
+ // NOTE: on an `awaiting_seed` record this is the very field the
574
+ // stale-seed reap measures age from, so each respawn defers that reap.
575
+ // Bounded today (5 attempts, ≤80s backoff ≈ 2.5min against a 30min TTL,
576
+ // then give-up moves the record to `error`) — but anyone raising
577
+ // DONATION_RESPAWN_MAX_ATTEMPTS should re-check that arithmetic.
578
+ updatedAt: this.now().toISOString(),
579
+ };
580
+ this.store.put(respawned);
581
+ log('respawned donation %s → %s (status %s)', id, result.dockerId, respawned.status);
582
+ return { outcome: 'respawned', donation: redact(respawned) };
583
+ } catch (err) {
584
+ const message = errorMessage(err);
585
+ log('respawn of donation %s failed: %s', id, message);
586
+ // Stop — never reclaim — a child we spawned but failed to record:
587
+ // `removeContainer` deletes the workdir, which holds the identity key and
588
+ // node-local stores that are the whole reason a respawn is the same node.
589
+ // The new handles go onto the record below, so it names the child that
590
+ // actually exists and a later `terminate()` reclaims that one.
591
+ if (spawned) await this.safeStop(spawned.dockerId);
592
+ this.storeRespawnAttempt(id, attempt, spawned);
593
+ throw new DonationError('orchestrator_error', `Failed to respawn donated node: ${message}`);
594
+ }
595
+ }
596
+
597
+ /**
598
+ * Give up on a respawn whose loan ended while the child was starting: stop the
599
+ * new child and, unless the record went `error`, reclaim it.
600
+ *
601
+ * Reclaim, not merely stop. The ending's own cleanup already ran and found
602
+ * nothing: `HostProcessOrchestrator.createContainer` calls `dropStaleHandle`
603
+ * *while* spawning, so by the time the concurrent `terminate` reached its
604
+ * stop/reclaim the old handle was gone and both calls were swallowed as
605
+ * best-effort no-ops. The new handle is now the only thing holding that
606
+ * spawn's ports and workdir, so stopping alone would leak both. On a
607
+ * `terminated` record the loan is over and the workdir (identity key +
608
+ * node-local stores) goes with it — which is what `terminate` intended.
609
+ *
610
+ * `error` is the deliberate exception: `removeContainer` deletes the workdir,
611
+ * and `DonationSupervisor.giveUp` keeps it on purpose so the identity key the
612
+ * borrower's cadre approved survives. Since both spawns share one workdir
613
+ * (`<rootDir>/<containerId>`), reclaiming here would delete exactly what
614
+ * `giveUp` meant to keep.
615
+ *
616
+ * NOTE: that skipped reclaim leaks the new spawn's four ports — the record
617
+ * still names the *previous* `dockerId`, so a later `terminate` cleans up the
618
+ * old handle, not this one. Unreachable today (`giveUp` is only called from
619
+ * the supervisor's serialized pass, so it cannot overlap a respawn); if a
620
+ * second `respawn` caller ever appears, write the new `dockerId` onto the
621
+ * `error` record here so that later `terminate` reclaims the right child.
622
+ */
623
+ private async abandonRespawn(
624
+ id: string,
625
+ dockerId: string,
626
+ current: Donation | undefined,
627
+ ): Promise<RespawnResult> {
628
+ const status = current?.status;
629
+ log(
630
+ 'donation %s went %s during respawn — abandoning new child %s',
631
+ id,
632
+ status ?? 'missing',
633
+ dockerId,
634
+ );
635
+ await this.safeStop(dockerId);
636
+ if (status !== 'error') await this.safeReclaim(dockerId);
637
+ return status ? { outcome: 'abandoned', status } : { outcome: 'abandoned' };
638
+ }
639
+
640
+ /**
641
+ * Terminate a donation: mark the record `terminated` FIRST, then stop + remove
642
+ * the child process (kept in the store for audit, excluded from the live-node
643
+ * tally).
644
+ *
645
+ * Order matters: stopping the child fires the orchestrator's `onStateChange`,
646
+ * and a respawn supervisor listening there must already see a terminal record
647
+ * — otherwise it observes "node gone, record still `seeded`" and resurrects a
648
+ * loan the borrower just ended. The tradeoff: a crash between the write and
649
+ * the stop leaves a `terminated` record with a live child (reaped on the next
650
+ * host start), which is strictly better than resurrecting an ended loan.
269
651
  */
270
652
  async terminate(id: string): Promise<void> {
271
653
  const donation = this.requireDonation(id);
654
+ this.store.put({ ...donation, status: 'terminated', updatedAt: this.now().toISOString() });
272
655
  if (donation.dockerId) {
273
656
  await this.safeStop(donation.dockerId);
274
657
  await this.safeReclaim(donation.dockerId);
658
+ } else {
659
+ // No `dockerId` — the spawn never got far enough to produce one, so the
660
+ // stop-and-reclaim above has nothing to aim at and the node's working
661
+ // directory would be stranded on disk with nothing able to name it.
662
+ // Without this branch the stuck-`provisioning` reap is bypassed entirely:
663
+ // terminating such a record moves it to `terminated`, which that reap no
664
+ // longer matches.
665
+ //
666
+ // NOTE: this can race an in-flight `provision` for the same record — the
667
+ // delete may land between that spawn's `mkdirSync` and its `spawn`. The
668
+ // end state is still correct (`provisionLocked`'s post-spawn re-read sees
669
+ // a non-`provisioning` record and reclaims the new child, which deletes
670
+ // the workdir anyway); the interim delete can only make that child fail
671
+ // to start noisily, which is strictly better than a permanent strand.
672
+ this.safeReclaimWorkdir(id);
275
673
  }
276
- this.store.put({ ...donation, status: 'terminated', updatedAt: this.now().toISOString() });
277
674
  log('donation %s terminated', id);
278
675
  }
279
676
 
@@ -287,12 +684,17 @@ export class DonationService {
287
684
  */
288
685
  async reapStaleAwaitingSeed(ttlMs: number = DONATION_AWAITING_SEED_TTL_MS): Promise<string[]> {
289
686
  const cutoff = this.now().getTime() - ttlMs;
290
- const stale = this.store
291
- .list()
292
- .filter((d) => d.status === 'awaiting_seed' && Date.parse(d.updatedAt) < cutoff);
687
+ const stale = this.store.list().filter((d) => isReapable(d, cutoff));
293
688
  const reaped: string[] = [];
294
689
  for (const donation of stale) {
295
690
  try {
691
+ // Re-read: every `terminate` awaits, so from the second record on the
692
+ // candidate list is a stale snapshot. A seed landing in that gap makes
693
+ // the record `seeded`, and a respawn refreshes `updatedAt` precisely to
694
+ // defer this reap — terminating the snapshot copy would kill a loan that
695
+ // just came good. `terminate` writes synchronously from here, so nothing
696
+ // can slip between this decision and the terminal write.
697
+ if (!isReapable(this.store.get(donation.id), cutoff)) continue;
296
698
  await this.terminate(donation.id);
297
699
  reaped.push(donation.id);
298
700
  log('reaped stale awaiting_seed donation %s (age > %dms)', donation.id, ttlMs);
@@ -303,6 +705,73 @@ export class DonationService {
303
705
  return reaped;
304
706
  }
305
707
 
708
+ /**
709
+ * Auto-terminate donations stuck in `provisioning` past `ttlMs` — the record
710
+ * is written before the child spawn starts, so a host crash/kill anywhere in
711
+ * that window (including mid-spawn) leaves nothing to advance it across a
712
+ * restart: there is no in-flight `provisionLocked` call left to reach its
713
+ * post-spawn re-read. Run on the same periodic sweep and startup pass as
714
+ * {@link reapStaleAwaitingSeed}. Age is measured from `updatedAt`, which for a
715
+ * genuinely-stuck row equals `createdAt` — `provisionLocked` never touches a
716
+ * `provisioning` record again except to advance it out of this predicate.
717
+ * Best-effort per record: a failed reap is logged and the sweep continues.
718
+ * Returns the reaped donation ids.
719
+ */
720
+ async reapStaleProvisioning(ttlMs: number = DONATION_PROVISIONING_TTL_MS): Promise<string[]> {
721
+ const cutoff = this.now().getTime() - ttlMs;
722
+ const stale = this.store.list().filter((d) => isStaleProvisioning(d, cutoff));
723
+ const reaped: string[] = [];
724
+ for (const donation of stale) {
725
+ try {
726
+ // Re-read: an in-flight (same-process) provisionLocked call can still
727
+ // advance this exact record between the snapshot above and now.
728
+ const current = this.store.get(donation.id);
729
+ if (!current || !isStaleProvisioning(current, cutoff)) continue;
730
+ await this.reclaimStuckProvisioning(current);
731
+ reaped.push(donation.id);
732
+ log('reaped stuck provisioning donation %s (age > %dms)', donation.id, ttlMs);
733
+ } catch (err) {
734
+ log('failed to reap stuck provisioning donation %s: %s', donation.id, errorMessage(err));
735
+ }
736
+ }
737
+ return reaped;
738
+ }
739
+
740
+ /**
741
+ * Terminalize a stuck-`provisioning` record as `error` and reclaim any child
742
+ * the orchestrator can still find for it. Status is written FIRST (same
743
+ * ordering rule as {@link terminate}): reclaiming fires `onStateChange`, and
744
+ * anything listening must already see a terminal record.
745
+ *
746
+ * NOTE: the stop only reaches a child the orchestrator has a handle for.
747
+ * `HostProcessOrchestrator.launchChild` spawns the process and persists the
748
+ * handle in the same synchronous step, so the only child this misses is one
749
+ * whose host died between the OS spawn and that write — an orphan *process*
750
+ * this reap terminalizes the record for but cannot kill (its ports stay held
751
+ * until a reboot). Its working directory is no longer out of reach, though:
752
+ * with no handle to resolve we fall back to reclaiming by container name,
753
+ * which also covers the far commoner case of a host that died before any
754
+ * child existed at all. If the spawn→persist window ever widens (an async
755
+ * step added between the two), the orchestrator still needs its own orphan
756
+ * *process* sweep — a record that names no handle cannot get one after the
757
+ * fact.
758
+ */
759
+ private async reclaimStuckProvisioning(donation: Donation): Promise<void> {
760
+ this.store.put({
761
+ ...donation,
762
+ status: 'error',
763
+ error: 'stuck in provisioning past TTL — host likely restarted mid-spawn',
764
+ updatedAt: this.now().toISOString(),
765
+ });
766
+ const dockerId = this.orchestrator.resolveDockerId?.(donation.id);
767
+ if (dockerId) {
768
+ await this.safeStop(dockerId);
769
+ await this.safeReclaim(dockerId);
770
+ } else {
771
+ this.safeReclaimWorkdir(donation.id);
772
+ }
773
+ }
774
+
306
775
  /** One donation (redacted), or undefined when unknown. */
307
776
  get(id: string): DonationView | undefined {
308
777
  const donation = this.store.get(id);
@@ -332,6 +801,44 @@ export class DonationService {
332
801
  }
333
802
  }
334
803
 
804
+ /**
805
+ * Persist a failed respawn's attempt counters — plus, when the spawn itself
806
+ * succeeded and it was the record write that failed, the new child's handles.
807
+ * Merged onto whatever is on disk now, never written wholesale: the caller's
808
+ * copy predates the orchestrator round-trip and `store.put` replaces the whole
809
+ * row, so writing it back would undo a `terminate` that landed while the spawn
810
+ * was in flight and resurrect a loan the borrower just ended.
811
+ *
812
+ * `status` and `updatedAt` are deliberately untouched — the attempt did not
813
+ * succeed, and `updatedAt` is what defers the stale-`awaiting_seed` reap.
814
+ *
815
+ * Recording `spawned` is what keeps the record pointing at the child that
816
+ * actually exists: `createContainer` already dropped the handle the record
817
+ * previously named, so leaving the old `dockerId` there would send every later
818
+ * stop/reclaim at an id the orchestrator cannot resolve — stranding the node's
819
+ * workdir on disk forever.
820
+ *
821
+ * Best-effort: we are already unwinding a respawn failure, and a store error
822
+ * here must not mask it. Losing the write costs the caller one extra attempt
823
+ * before backoff; the supervisor's next pass re-reads the record, finds the
824
+ * named child not running, and respawns again — which drops whatever handle is
825
+ * current, so the ports self-heal either way.
826
+ */
827
+ private storeRespawnAttempt(
828
+ id: string,
829
+ respawn: RespawnAttempt,
830
+ spawned?: SpawnedHandles,
831
+ ): void {
832
+ try {
833
+ // A row that vanished (terminate + delete) must not be recreated.
834
+ const current = this.store.get(id);
835
+ if (!current) return;
836
+ this.store.put({ ...current, respawn, ...spawned });
837
+ } catch (err) {
838
+ log('failed to record respawn attempt for %s: %s', id, errorMessage(err));
839
+ }
840
+ }
841
+
335
842
  /** Best-effort remove; logs but never throws (cleanup path). */
336
843
  private async safeReclaim(dockerId: string): Promise<void> {
337
844
  try {
@@ -341,6 +848,22 @@ export class DonationService {
341
848
  }
342
849
  }
343
850
 
851
+ /**
852
+ * Best-effort removal of the working directory of a donation whose spawn
853
+ * never produced a `dockerId`; logs but never throws (cleanup path). A no-op
854
+ * on an orchestrator without the capability, and on one that refuses because
855
+ * a live handle still owns the directory.
856
+ */
857
+ private safeReclaimWorkdir(id: string): void {
858
+ try {
859
+ if (this.orchestrator.reclaimWorkdir?.(id)) {
860
+ log('reclaimed orphaned workdir for donation %s', id);
861
+ }
862
+ } catch (err) {
863
+ log('failed to reclaim workdir for donation %s: %s', id, errorMessage(err));
864
+ }
865
+ }
866
+
344
867
  /**
345
868
  * Run `fn` after any in-flight provision for the same grant token, so the
346
869
  * quota check + record-create pair is atomic per grant.
@@ -366,6 +889,34 @@ function redact(donation: Donation): DonationView {
366
889
  return view;
367
890
  }
368
891
 
892
+ /**
893
+ * Shape-check the requester-supplied owner-key pins at the boundary they arrive
894
+ * on, returning them trimmed so the persisted record and the child's
895
+ * `CADRE_OWNER_KEYS` carry exactly what was validated — the same discipline
896
+ * `cadre-cli start` applies to its own `--pin-owner-key` / `CADRE_OWNER_KEYS`.
897
+ *
898
+ * `requireEd25519PublicKeyB64` throws a plain `Error`; it is re-thrown as
899
+ * `invalid_request` so `server/error-handler.ts` renders it as a 400 naming the
900
+ * bad key rather than a 500.
901
+ *
902
+ * Deliberately NOT applied in {@link DonationService.respawn}, which replays
903
+ * `donation.ownerKeys` off a record written before this check existed: validating
904
+ * there would make such a record permanently un-respawnable, and by then the pins
905
+ * are already inside the trust boundary. Validating the boundary is the point.
906
+ */
907
+ function validateOwnerKeys(keys: string[] | undefined): string[] {
908
+ // NOTE: every throw out of this `try` is reported to the caller as their fault.
909
+ // True while `requireEd25519PublicKeyB64` rejects only bad input (and while the
910
+ // route ahead of it has already established the entries are strings); if it ever
911
+ // grows a failure of its own — a key-server lookup, a curve check needing crypto
912
+ // — narrow this catch, or a host-side fault answers 400.
913
+ try {
914
+ return (keys ?? []).map((key) => requireEd25519PublicKeyB64(key, 'donation owner key (ownerKeys)'));
915
+ } catch (err) {
916
+ throw new DonationError('invalid_request', errorMessage(err));
917
+ }
918
+ }
919
+
369
920
  /** Map a grant denial to the donation error the routes surface as HTTP status. */
370
921
  function denialToError(reason: GrantDenyReason | undefined): DonationError {
371
922
  switch (reason) {
@@ -380,6 +931,25 @@ function denialToError(reason: GrantDenyReason | undefined): DonationError {
380
931
  }
381
932
  }
382
933
 
934
+ /**
935
+ * Whether a record is still a stale-`awaiting_seed` reap candidate: the requester
936
+ * provisioned a node and has not presented a seed since `cutoff`. Stated once and
937
+ * applied twice in {@link DonationService.reapStaleAwaitingSeed} — on the sweep's
938
+ * candidate list, and again per record before the terminal write.
939
+ */
940
+ function isReapable(donation: Donation | undefined, cutoff: number): boolean {
941
+ return donation?.status === 'awaiting_seed' && Date.parse(donation.updatedAt) < cutoff;
942
+ }
943
+
944
+ /**
945
+ * Whether a record is still a stuck-`provisioning` reap candidate. Stated
946
+ * once and applied twice in {@link DonationService.reapStaleProvisioning} — on
947
+ * the sweep's candidate list, and again per record before the terminal write.
948
+ */
949
+ function isStaleProvisioning(donation: Donation | undefined, cutoff: number): boolean {
950
+ return donation?.status === 'provisioning' && Date.parse(donation.updatedAt) < cutoff;
951
+ }
952
+
383
953
  /** Derive the node's `/status` URL from its `/seed` URL (same origin/port). */
384
954
  function toStatusUrl(seedEndpoint: string): string {
385
955
  const url = new URL(seedEndpoint);