@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
@@ -0,0 +1,392 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import debug from 'debug';
3
+
4
+ import type { Orchestrator } from '@serfab/cadre-provider';
5
+
6
+ import type { DonationStore } from './donation-store.js';
7
+ import type {
8
+ Donation,
9
+ DonationView,
10
+ GrantDenyReason,
11
+ GrantValidator,
12
+ } from './types.js';
13
+ import { DonationError } from './types.js';
14
+
15
+ const log = debug('cadre:host:donation-service');
16
+
17
+ /**
18
+ * Default age after which a donation still stuck in `awaiting_seed` is
19
+ * auto-terminated: the requester provisioned a node but never presented a seed
20
+ * (it vanished, or abandoned the flow), leaving an orphaned child holding host
21
+ * ports. 30 minutes.
22
+ *
23
+ * NOTE: not operator-configurable yet — a single module constant. If hosts need
24
+ * to tune the grace period, promote this to `host.config.json`.
25
+ */
26
+ export const DONATION_AWAITING_SEED_TTL_MS = 30 * 60 * 1000;
27
+
28
+ /** How often the reap sweep runs while cadre-host is up. 5 minutes. */
29
+ export const DONATION_REAP_SWEEP_MS = 5 * 60 * 1000;
30
+
31
+ /** A grantee-driven request to provision one donated node. */
32
+ export interface DonationProvisionRequest {
33
+ /** The grant token that authorizes this donation (the quota key). */
34
+ grantToken: string;
35
+ /** The REQUESTER's cadre — the party the donated node joins. */
36
+ partyId: string;
37
+ /** Requester control-network bootstrap multiaddrs (dialable, with `/p2p/`). */
38
+ bootstrapNodes: string[];
39
+ /**
40
+ * The requester's owner public key(s), base64url. Pinned as cold-start
41
+ * 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.
44
+ */
45
+ ownerKeys: string[];
46
+ /** Node profile; defaults to `storage` so the node participates and is dialable. */
47
+ profile?: 'storage' | 'transaction';
48
+ }
49
+
50
+ /** Result of presenting a seed to a donated node's `POST /seed`. */
51
+ export interface DonationSeedResult {
52
+ success: boolean;
53
+ peersAdded?: number;
54
+ error?: string;
55
+ }
56
+
57
+ /** Live peer identity of a donated node. */
58
+ export interface DonationPeerInfo {
59
+ peerId: string;
60
+ multiaddrs: string[];
61
+ }
62
+
63
+ /** Constructor options. */
64
+ export interface DonationServiceOptions {
65
+ /** Orchestrator that spawns/stops the donated node child process. */
66
+ orchestrator: Orchestrator;
67
+ /** Grant validator (identity/expiry/revocation + quota). */
68
+ grants: GrantValidator;
69
+ /** Persistent donation store (`donations.json`). */
70
+ store: DonationStore;
71
+ /** Clock override for tests. */
72
+ now?: () => Date;
73
+ }
74
+
75
+ /**
76
+ * DonationService — drives the donate-a-node lifecycle on behalf of an external
77
+ * requester (a friend's phone). It is cadre-host's analogue of cadre-provider's
78
+ * `ContainerService`, minus Docker/billing, plus the grant-token gate.
79
+ *
80
+ * The host contributes capacity only: it spawns a generic node into the
81
+ * *requester's* cadre (`partyId`), pins the requester's owner key(s) so the node
82
+ * will accept a requester-signed seed, and presents that seed to the node's
83
+ * `POST /seed` — it never holds the requester's authority private key.
84
+ *
85
+ * Scope note: this class covers the service surface the node-donation
86
+ * integration test drives directly (`provision` / `getPeer` / `applySeed` /
87
+ * `terminate` / `get` / `list`). The grantee-facing HTTP routes, the `bin/host.ts`
88
+ * wiring, and the stale-`awaiting_seed` reap sweep are owned by the
89
+ * `2-donation-service` ticket and land alongside this file.
90
+ */
91
+ export class DonationService {
92
+ private readonly orchestrator: Orchestrator;
93
+ private readonly grants: GrantValidator;
94
+ private readonly store: DonationStore;
95
+ private readonly now: () => Date;
96
+
97
+ /**
98
+ * Per-grant-token serialization tail. `provision` chains onto the prior
99
+ * provision for the same token so the quota check and record-create are
100
+ * atomic — two concurrent requests at `count = maxNodes - 1` can't both pass.
101
+ *
102
+ * NOTE: one entry per distinct token, never evicted. Fine at household scale
103
+ * (a handful of grants); if grant counts ever grow large, evict the tail once
104
+ * it resolves.
105
+ */
106
+ private readonly provisionTail = new Map<string, Promise<void>>();
107
+
108
+ constructor(opts: DonationServiceOptions) {
109
+ this.orchestrator = opts.orchestrator;
110
+ this.grants = opts.grants;
111
+ this.store = opts.store;
112
+ this.now = opts.now ?? (() => new Date());
113
+ log('DonationService initialized');
114
+ }
115
+
116
+ /**
117
+ * Provision a donated node for a validated grant. Serialized per grant token
118
+ * so the quota check and record-create are atomic. On any orchestrator failure
119
+ * the reserved resources are reclaimed and the record is marked `error`.
120
+ */
121
+ async provision(request: DonationProvisionRequest): Promise<DonationView> {
122
+ return this.serializeByGrant(request.grantToken, () => this.provisionLocked(request));
123
+ }
124
+
125
+ private async provisionLocked(request: DonationProvisionRequest): Promise<DonationView> {
126
+ const validation = this.grants.validateForProvision(
127
+ request.grantToken,
128
+ (token) => this.store.liveNodeCount(token),
129
+ );
130
+ if (!validation.ok) {
131
+ throw denialToError(validation.reason);
132
+ }
133
+
134
+ const id = generateDonationId();
135
+ const nowIso = this.now().toISOString();
136
+ const profile = request.profile ?? 'storage';
137
+ const record: Donation = {
138
+ id,
139
+ grantToken: request.grantToken,
140
+ partyId: request.partyId,
141
+ profile,
142
+ status: 'provisioning',
143
+ createdAt: nowIso,
144
+ updatedAt: nowIso,
145
+ };
146
+ this.store.put(record);
147
+
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;
151
+ try {
152
+ const result = await this.orchestrator.createContainer({
153
+ containerId: id,
154
+ partyId: request.partyId,
155
+ bootstrapNodes: request.bootstrapNodes,
156
+ profile,
157
+ pinnedOwnerKeys: request.ownerKeys,
158
+ });
159
+ dockerId = result.dockerId;
160
+
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
+ };
171
+ this.store.put(provisioned);
172
+ log('provisioned donation %s (party=%s) → awaiting_seed', id, request.partyId);
173
+ return redact(provisioned);
174
+ } catch (err) {
175
+ const message = errorMessage(err);
176
+ log('provision of donation %s failed: %s', id, message);
177
+ if (dockerId) await this.safeReclaim(dockerId);
178
+ this.store.put({
179
+ ...record,
180
+ status: 'error',
181
+ error: message,
182
+ updatedAt: this.now().toISOString(),
183
+ });
184
+ throw new DonationError('orchestrator_error', `Failed to provision donated node: ${message}`);
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Live peer identity of a donated node, read fresh from its `/status` every
190
+ * call (no cache) so a re-keyed identity / remapped multiaddr is never served
191
+ * stale. Throws `peer_unavailable` while the node has no peer identity yet.
192
+ */
193
+ async getPeer(id: string): Promise<DonationPeerInfo> {
194
+ const donation = this.requireDonation(id);
195
+ if (!donation.seedEndpoint) {
196
+ throw new DonationError('peer_unavailable', `Donation ${id} has no node endpoint`);
197
+ }
198
+
199
+ const statusUrl = toStatusUrl(donation.seedEndpoint);
200
+ let res: Response;
201
+ try {
202
+ res = await fetch(statusUrl);
203
+ } catch (err) {
204
+ throw new DonationError('peer_unavailable', `Donated node unreachable: ${errorMessage(err)}`);
205
+ }
206
+ if (!res.ok) {
207
+ throw new DonationError('peer_unavailable', `Donated node /status returned ${res.status}`);
208
+ }
209
+
210
+ const status = (await res.json()) as { peerId?: string | null; multiaddrs?: string[] };
211
+ if (!status.peerId || !status.multiaddrs?.length) {
212
+ throw new DonationError('peer_unavailable', `Donated node ${id} has no peer identity yet`);
213
+ }
214
+ return { peerId: status.peerId, multiaddrs: status.multiaddrs };
215
+ }
216
+
217
+ /**
218
+ * Present the requester's phone-signed seed to the donated node's `POST /seed`,
219
+ * authenticated with the host↔node `seedToken`. On success the donation moves
220
+ * 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`).
222
+ */
223
+ async applySeed(id: string, encodedSeed: string): Promise<DonationSeedResult> {
224
+ const donation = this.requireDonation(id);
225
+ if (donation.status !== 'awaiting_seed' && donation.status !== 'seeded') {
226
+ throw new DonationError(
227
+ 'invalid_state',
228
+ `Donation ${id} cannot be seeded in status ${donation.status}`,
229
+ );
230
+ }
231
+ if (!donation.seedEndpoint) {
232
+ throw new DonationError('peer_unavailable', `Donation ${id} has no seed endpoint`);
233
+ }
234
+ // A record with an endpoint but no token can never authenticate — fail here
235
+ // rather than let the node reject with an opaque 401.
236
+ if (!donation.seedToken) {
237
+ throw new DonationError('seed_failed', `Donation ${id} has no seed token`);
238
+ }
239
+
240
+ let res: Response;
241
+ try {
242
+ res = await fetch(donation.seedEndpoint, {
243
+ method: 'POST',
244
+ headers: {
245
+ 'content-type': 'application/json',
246
+ authorization: `Bearer ${donation.seedToken}`,
247
+ },
248
+ body: JSON.stringify({ seed: encodedSeed }),
249
+ });
250
+ } catch (err) {
251
+ throw new DonationError('seed_failed', `Donated node unreachable: ${errorMessage(err)}`);
252
+ }
253
+ if (!res.ok) {
254
+ const body = await res.text();
255
+ throw new DonationError('seed_failed', `Donated node /seed returned ${res.status}: ${body}`);
256
+ }
257
+
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);
262
+ }
263
+ return result;
264
+ }
265
+
266
+ /**
267
+ * Terminate a donation: stop + remove the child process, then mark the record
268
+ * `terminated` (kept for audit, excluded from the live-node tally).
269
+ */
270
+ async terminate(id: string): Promise<void> {
271
+ const donation = this.requireDonation(id);
272
+ if (donation.dockerId) {
273
+ await this.safeStop(donation.dockerId);
274
+ await this.safeReclaim(donation.dockerId);
275
+ }
276
+ this.store.put({ ...donation, status: 'terminated', updatedAt: this.now().toISOString() });
277
+ log('donation %s terminated', id);
278
+ }
279
+
280
+ /**
281
+ * Auto-terminate donations still `awaiting_seed` past `ttlMs` — a requester
282
+ * that provisioned a node but never presented a seed leaves an orphaned child
283
+ * holding host ports. Run on a periodic sweep and once at startup for records
284
+ * recovered from disk. Age is measured from the record's `updatedAt` (set when
285
+ * it entered `awaiting_seed`). Best-effort per record: a failed terminate is
286
+ * logged and the sweep continues. Returns the reaped donation ids.
287
+ */
288
+ async reapStaleAwaitingSeed(ttlMs: number = DONATION_AWAITING_SEED_TTL_MS): Promise<string[]> {
289
+ 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);
293
+ const reaped: string[] = [];
294
+ for (const donation of stale) {
295
+ try {
296
+ await this.terminate(donation.id);
297
+ reaped.push(donation.id);
298
+ log('reaped stale awaiting_seed donation %s (age > %dms)', donation.id, ttlMs);
299
+ } catch (err) {
300
+ log('failed to reap donation %s: %s', donation.id, errorMessage(err));
301
+ }
302
+ }
303
+ return reaped;
304
+ }
305
+
306
+ /** One donation (redacted), or undefined when unknown. */
307
+ get(id: string): DonationView | undefined {
308
+ const donation = this.store.get(id);
309
+ return donation ? redact(donation) : undefined;
310
+ }
311
+
312
+ /** All donations (redacted), optionally scoped to one grant token. */
313
+ list(grantToken?: string): DonationView[] {
314
+ const rows = grantToken ? this.store.listByGrant(grantToken) : this.store.list();
315
+ return rows.map(redact);
316
+ }
317
+
318
+ private requireDonation(id: string): Donation {
319
+ const donation = this.store.get(id);
320
+ if (!donation) {
321
+ throw new DonationError('not_found', `No such donation: ${id}`);
322
+ }
323
+ return donation;
324
+ }
325
+
326
+ /** Best-effort stop; logs but never throws (cleanup path). */
327
+ private async safeStop(dockerId: string): Promise<void> {
328
+ try {
329
+ await this.orchestrator.stopContainer(dockerId);
330
+ } catch (err) {
331
+ log('failed to stop container %s: %s', dockerId, errorMessage(err));
332
+ }
333
+ }
334
+
335
+ /** Best-effort remove; logs but never throws (cleanup path). */
336
+ private async safeReclaim(dockerId: string): Promise<void> {
337
+ try {
338
+ await this.orchestrator.removeContainer(dockerId);
339
+ } catch (err) {
340
+ log('failed to reclaim container %s: %s', dockerId, errorMessage(err));
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Run `fn` after any in-flight provision for the same grant token, so the
346
+ * quota check + record-create pair is atomic per grant.
347
+ */
348
+ private async serializeByGrant<T>(token: string, fn: () => Promise<T>): Promise<T> {
349
+ const prior = this.provisionTail.get(token) ?? Promise.resolve();
350
+ const run = prior.then(fn, fn);
351
+ // The stored tail swallows outcomes — a rejected provision must not reject
352
+ // the next caller's wait, only defer it.
353
+ this.provisionTail.set(token, run.then(() => undefined, () => undefined));
354
+ return run;
355
+ }
356
+ }
357
+
358
+ /** `grn_<high-entropy base64url>` — also the donated node's orchestrator containerId. */
359
+ function generateDonationId(): string {
360
+ return `grn_${randomBytes(12).toString('base64url')}`;
361
+ }
362
+
363
+ /** Strip the host-only secrets (`seedToken`) and internal URL (`seedEndpoint`). */
364
+ function redact(donation: Donation): DonationView {
365
+ const { seedToken: _seedToken, seedEndpoint: _seedEndpoint, ...view } = donation;
366
+ return view;
367
+ }
368
+
369
+ /** Map a grant denial to the donation error the routes surface as HTTP status. */
370
+ function denialToError(reason: GrantDenyReason | undefined): DonationError {
371
+ switch (reason) {
372
+ case 'quota_exceeded':
373
+ return new DonationError('quota_exceeded', 'Grant is already at its node cap');
374
+ case 'expired':
375
+ case 'revoked':
376
+ return new DonationError('forbidden', `Grant is ${reason}`);
377
+ case 'unknown_token':
378
+ default:
379
+ return new DonationError('unauthorized', 'Unknown or missing grant token');
380
+ }
381
+ }
382
+
383
+ /** Derive the node's `/status` URL from its `/seed` URL (same origin/port). */
384
+ function toStatusUrl(seedEndpoint: string): string {
385
+ const url = new URL(seedEndpoint);
386
+ url.pathname = '/status';
387
+ return url.toString();
388
+ }
389
+
390
+ function errorMessage(err: unknown): string {
391
+ return err instanceof Error ? err.message : String(err);
392
+ }
@@ -0,0 +1,164 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import debug from 'debug';
4
+
5
+ import type { Donation, DonationFile, DonationStatus } from './types.js';
6
+ import { DonationError } from './types.js';
7
+
8
+ const log = debug('cadre:host:donation-store');
9
+
10
+ const FILE_VERSION = 1;
11
+
12
+ /**
13
+ * Statuses that count a donation as "live" against a grant's `maxNodes` quota.
14
+ * `error` and `terminated` donations are kept for audit but do NOT count.
15
+ */
16
+ const LIVE_STATUSES: ReadonlySet<DonationStatus> = new Set<DonationStatus>([
17
+ 'provisioning',
18
+ 'awaiting_seed',
19
+ 'seeded',
20
+ ]);
21
+
22
+ /**
23
+ * Atomic JSON store for `donations.json` — one row per donated node. Modelled on
24
+ * `grant-store.ts`: a single file written to `<path>.tmp` then renamed, with an
25
+ * in-memory cache keyed by donation id.
26
+ *
27
+ * **Persists `seedToken`.** Unlike the orchestrator handle (whose `toPersisted`
28
+ * deliberately omits the freshly-minted-per-spawn seed token), this record keeps
29
+ * it: if the host restarts in the request→seed gap, only the donation row can
30
+ * reproduce the bearer needed to present the seed to the node. See
31
+ * `tickets/complete/cadre-provider-seed-endpoint-never-populated.md`.
32
+ *
33
+ * Concurrency assumption: only one cadre-host process owns a given rootDir
34
+ * (the orchestrator already enforces this). No file-locking primitives.
35
+ */
36
+ export class DonationStore {
37
+ private readonly path: string;
38
+ private cache: DonationFile | null = null;
39
+
40
+ constructor(rootDir: string) {
41
+ mkdirSync(rootDir, { recursive: true });
42
+ this.path = join(rootDir, 'donations.json');
43
+ }
44
+
45
+ filePath(): string {
46
+ return this.path;
47
+ }
48
+
49
+ /**
50
+ * Load (with caching). A missing file returns an empty store. A
51
+ * present-but-malformed file throws rather than silently wiping — losing the
52
+ * records would orphan live child processes (their seed tokens gone).
53
+ */
54
+ load(): DonationFile {
55
+ if (this.cache) return this.cache;
56
+ if (!existsSync(this.path)) {
57
+ this.cache = emptyFile();
58
+ return this.cache;
59
+ }
60
+ let raw: string;
61
+ try {
62
+ raw = readFileSync(this.path, 'utf8');
63
+ } catch (err) {
64
+ throw new DonationError(
65
+ 'storage_error',
66
+ `failed to read donations file at ${this.path}: ${(err as Error).message}`,
67
+ );
68
+ }
69
+ let parsed: unknown;
70
+ try {
71
+ parsed = JSON.parse(raw);
72
+ } catch (err) {
73
+ throw new DonationError(
74
+ 'storage_error',
75
+ `donations file at ${this.path} is not valid JSON: ${(err as Error).message}`,
76
+ );
77
+ }
78
+ if (!isDonationFile(parsed)) {
79
+ throw new DonationError(
80
+ 'storage_error',
81
+ `donations file at ${this.path} has unexpected shape`,
82
+ );
83
+ }
84
+ this.cache = parsed;
85
+ return this.cache;
86
+ }
87
+
88
+ /** Persist the current state atomically (write then rename). */
89
+ save(state: DonationFile): void {
90
+ // NOTE: rewrites the whole donations.json on every mutation. Fine at
91
+ // household scale (a handful of donated nodes); if counts ever grow large,
92
+ // switch to an append/compact log or per-id files.
93
+ mkdirSync(dirname(this.path), { recursive: true });
94
+ const payload = JSON.stringify({ ...state, version: FILE_VERSION }, null, 2);
95
+ const tmp = `${this.path}.tmp`;
96
+ try {
97
+ writeFileSync(tmp, payload, { encoding: 'utf8' });
98
+ renameSync(tmp, this.path);
99
+ } catch (err) {
100
+ throw new DonationError(
101
+ 'storage_error',
102
+ `failed to write donations file at ${this.path}: ${(err as Error).message}`,
103
+ );
104
+ }
105
+ this.cache = { ...state, version: FILE_VERSION };
106
+ log('saved donations (%d) to %s', Object.keys(state.donations).length, this.path);
107
+ }
108
+
109
+ /** Insert or replace a donation row. */
110
+ put(donation: Donation): void {
111
+ const state = this.load();
112
+ const { id, ...rest } = donation;
113
+ state.donations[id] = rest;
114
+ this.save(state);
115
+ }
116
+
117
+ get(id: string): Donation | undefined {
118
+ const state = this.load();
119
+ const row = state.donations[id];
120
+ if (!row) return undefined;
121
+ return { id, ...row };
122
+ }
123
+
124
+ list(): Donation[] {
125
+ const state = this.load();
126
+ return Object.entries(state.donations).map(([id, row]) => ({ id, ...row }));
127
+ }
128
+
129
+ /** All donations authorized by a given grant token. */
130
+ listByGrant(grantToken: string): Donation[] {
131
+ return this.list().filter((d) => d.grantToken === grantToken);
132
+ }
133
+
134
+ remove(id: string): boolean {
135
+ const state = this.load();
136
+ if (!(id in state.donations)) return false;
137
+ delete state.donations[id];
138
+ this.save(state);
139
+ return true;
140
+ }
141
+
142
+ /**
143
+ * The authoritative live-node tally the grant validator consumes: donations
144
+ * under `grantToken` in a live status (`provisioning` / `awaiting_seed` /
145
+ * `seeded`). `error` and `terminated` rows are excluded.
146
+ */
147
+ liveNodeCount(grantToken: string): number {
148
+ return this.list().filter(
149
+ (d) => d.grantToken === grantToken && LIVE_STATUSES.has(d.status),
150
+ ).length;
151
+ }
152
+ }
153
+
154
+ function emptyFile(): DonationFile {
155
+ return { version: FILE_VERSION, donations: {} };
156
+ }
157
+
158
+ function isDonationFile(v: unknown): v is DonationFile {
159
+ if (!v || typeof v !== 'object') return false;
160
+ const obj = v as Record<string, unknown>;
161
+ if (obj.version !== FILE_VERSION) return false;
162
+ if (!obj.donations || typeof obj.donations !== 'object') return false;
163
+ return true;
164
+ }