@serfab/cadre-host 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/trust-circle.d.ts +5 -5
- package/dist/auth/trust-circle.js +10 -10
- package/dist/auth/trust-circle.js.map +1 -1
- package/dist/auth/types.d.ts +2 -2
- package/dist/bin/host.js +255 -54
- package/dist/bin/host.js.map +1 -1
- package/dist/donation/donation-service.d.ts +136 -0
- package/dist/donation/donation-service.js +306 -0
- package/dist/donation/donation-service.js.map +1 -0
- package/dist/donation/donation-store.d.ts +42 -0
- package/dist/donation/donation-store.js +141 -0
- package/dist/donation/donation-store.js.map +1 -0
- package/dist/donation/grant-service.d.ts +71 -0
- package/dist/donation/grant-service.js +163 -0
- package/dist/donation/grant-service.js.map +1 -0
- package/dist/donation/grant-store.d.ts +38 -0
- package/dist/donation/grant-store.js +135 -0
- package/dist/donation/grant-store.js.map +1 -0
- package/dist/donation/index.d.ts +18 -0
- package/dist/donation/index.js +16 -0
- package/dist/donation/index.js.map +1 -0
- package/dist/donation/types.d.ts +175 -0
- package/dist/donation/types.js +40 -0
- package/dist/donation/types.js.map +1 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/installer/config.d.ts +26 -0
- package/dist/installer/config.js +13 -0
- package/dist/installer/config.js.map +1 -1
- package/dist/installer/index.d.ts +2 -0
- package/dist/installer/index.js +3 -0
- package/dist/installer/index.js.map +1 -1
- package/dist/installer/wizard.d.ts +4 -0
- package/dist/installer/wizard.js +5 -1
- package/dist/installer/wizard.js.map +1 -1
- package/dist/nat/nat-service.d.ts +5 -5
- package/dist/nat/nat-service.js +9 -9
- package/dist/nat/nat-service.js.map +1 -1
- package/dist/nat/types.d.ts +1 -1
- package/dist/orchestrator/host-process-orchestrator.d.ts +35 -27
- package/dist/orchestrator/host-process-orchestrator.js +79 -53
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
- package/dist/orchestrator/index.d.ts +3 -3
- package/dist/orchestrator/index.js +1 -1
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/state-store.d.ts +5 -5
- package/dist/orchestrator/state-store.js +2 -2
- package/dist/orchestrator/state-store.js.map +1 -1
- package/dist/orchestrator/types.d.ts +8 -8
- package/dist/owner/index.d.ts +11 -0
- package/dist/owner/index.js +11 -0
- package/dist/owner/index.js.map +1 -0
- package/dist/{authority/authority-node-client.d.ts → owner/owner-node-client.d.ts} +32 -14
- package/dist/{authority/authority-node-client.js → owner/owner-node-client.js} +39 -15
- package/dist/owner/owner-node-client.js.map +1 -0
- package/dist/server/error-handler.d.ts +15 -0
- package/dist/server/error-handler.js +41 -0
- package/dist/server/error-handler.js.map +1 -1
- package/dist/server/index.d.ts +26 -2
- package/dist/server/index.js +37 -16
- package/dist/server/index.js.map +1 -1
- package/dist/server/routes/grants-admin.d.ts +17 -0
- package/dist/server/routes/grants-admin.js +32 -0
- package/dist/server/routes/grants-admin.js.map +1 -0
- package/dist/server/routes/grants.d.ts +28 -0
- package/dist/server/routes/grants.js +163 -0
- package/dist/server/routes/grants.js.map +1 -0
- package/dist/server/routes/nodes.d.ts +5 -4
- package/dist/server/routes/nodes.js +19 -18
- package/dist/server/routes/nodes.js.map +1 -1
- package/dist/server/routes/settings.d.ts +6 -1
- package/dist/server/routes/settings.js +5 -2
- package/dist/server/routes/settings.js.map +1 -1
- package/dist/server/routes/status.d.ts +8 -4
- package/dist/server/routes/status.js +9 -4
- package/dist/server/routes/status.js.map +1 -1
- package/dist/update/release-key.js +7 -5
- package/dist/update/release-key.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/{host-authority.smoke.test.ts → host-owner.smoke.test.ts} +7 -7
- package/src/__tests__/{orchestrator-authority.test.ts → orchestrator-owner.test.ts} +33 -33
- package/src/__tests__/orchestrator-pin-keys.test.ts +153 -0
- package/src/__tests__/orchestrator-push.test.ts +10 -10
- package/src/auth/__tests__/trust-circle-integration.test.ts +11 -11
- package/src/auth/__tests__/trust-circle.test.ts +10 -10
- package/src/auth/trust-circle.ts +10 -10
- package/src/auth/types.ts +2 -2
- package/src/bin/host.ts +273 -53
- package/src/donation/__tests__/donation-service.test.ts +290 -0
- package/src/donation/__tests__/grant-service.test.ts +213 -0
- package/src/donation/__tests__/grant-store.test.ts +113 -0
- package/src/donation/donation-service.ts +392 -0
- package/src/donation/donation-store.ts +164 -0
- package/src/donation/grant-service.ts +200 -0
- package/src/donation/grant-store.ts +152 -0
- package/src/donation/index.ts +49 -0
- package/src/donation/types.ts +220 -0
- package/src/index.ts +37 -5
- package/src/installer/__tests__/config.test.ts +27 -1
- package/src/installer/__tests__/installer.smoke.test.ts +31 -1
- package/src/installer/__tests__/wizard.test.ts +5 -1
- package/src/installer/config.ts +34 -0
- package/src/installer/index.ts +5 -0
- package/src/installer/wizard.ts +12 -1
- package/src/nat/__tests__/nat-service.test.ts +5 -5
- package/src/nat/nat-service.ts +10 -10
- package/src/nat/types.ts +1 -1
- package/src/orchestrator/host-process-orchestrator.ts +88 -60
- package/src/orchestrator/index.ts +3 -3
- package/src/orchestrator/state-store.ts +7 -7
- package/src/orchestrator/types.ts +8 -8
- package/src/{authority/__tests__/authority-node-client.test.ts → owner/__tests__/owner-node-client.test.ts} +45 -17
- package/src/owner/index.ts +12 -0
- package/src/{authority/authority-node-client.ts → owner/owner-node-client.ts} +55 -21
- package/src/server/__tests__/error-handler.test.ts +22 -0
- package/src/server/__tests__/grants-admin-route.test.ts +73 -0
- package/src/server/__tests__/grants-route.test.ts +189 -0
- package/src/server/__tests__/nodes-route.test.ts +25 -25
- package/src/server/__tests__/server.smoke.test.ts +75 -0
- package/src/server/__tests__/settings-route.test.ts +17 -0
- package/src/server/__tests__/status-route.test.ts +21 -0
- package/src/server/error-handler.ts +43 -0
- package/src/server/index.ts +62 -17
- package/src/server/routes/grants-admin.ts +41 -0
- package/src/server/routes/grants.ts +186 -0
- package/src/server/routes/nodes.ts +19 -18
- package/src/server/routes/settings.ts +11 -3
- package/src/server/routes/status.ts +18 -8
- package/src/update/__tests__/release-key.test.ts +16 -3
- package/src/update/release-key.ts +7 -5
- package/dist/authority/authority-node-client.js.map +0 -1
- package/dist/authority/index.d.ts +0 -11
- package/dist/authority/index.js +0 -11
- package/dist/authority/index.js.map +0 -1
- package/src/authority/index.ts +0 -12
|
@@ -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
|
+
}
|