@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.
- package/README.md +296 -101
- package/dist/auth/trust-circle.d.ts +45 -7
- package/dist/auth/trust-circle.js +116 -59
- package/dist/auth/trust-circle.js.map +1 -1
- package/dist/auth/types.d.ts +4 -1
- package/dist/auth/types.js.map +1 -1
- package/dist/bin/host.js +426 -112
- 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 +12 -6
- package/dist/index.js +8 -3
- package/dist/index.js.map +1 -1
- package/dist/installer/browser.js +16 -17
- package/dist/installer/browser.js.map +1 -1
- package/dist/installer/config.d.ts +51 -0
- package/dist/installer/config.js +14 -1
- package/dist/installer/config.js.map +1 -1
- package/dist/installer/index.d.ts +7 -2
- package/dist/installer/index.js +17 -10
- package/dist/installer/index.js.map +1 -1
- package/dist/installer/service-host/launchd.js +1 -1
- package/dist/installer/service-host/launchd.js.map +1 -1
- package/dist/installer/service-host/systemd.js +1 -1
- package/dist/installer/service-host/systemd.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/index.d.ts +1 -1
- package/dist/nat/nat-service.d.ts +45 -3
- package/dist/nat/nat-service.js +116 -3
- package/dist/nat/nat-service.js.map +1 -1
- package/dist/nat/secrets/index.d.ts +7 -0
- package/dist/nat/secrets/index.js +28 -2
- package/dist/nat/secrets/index.js.map +1 -1
- package/dist/nat/types.d.ts +3 -1
- package/dist/nat/types.js.map +1 -1
- package/dist/orchestrator/host-process-orchestrator.d.ts +68 -1
- package/dist/orchestrator/host-process-orchestrator.js +272 -39
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
- package/dist/orchestrator/index.d.ts +3 -2
- package/dist/orchestrator/index.js +1 -1
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/state-store.d.ts +6 -5
- package/dist/orchestrator/state-store.js +10 -2
- package/dist/orchestrator/state-store.js.map +1 -1
- package/dist/orchestrator/types.d.ts +45 -10
- package/dist/orchestrator/types.js.map +1 -1
- 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/owner/owner-node-client.d.ts +95 -0
- package/dist/owner/owner-node-client.js +167 -0
- package/dist/owner/owner-node-client.js.map +1 -0
- package/dist/push/index.d.ts +67 -0
- package/dist/push/index.js +101 -0
- package/dist/push/index.js.map +1 -0
- package/dist/server/error-handler.d.ts +17 -0
- package/dist/server/error-handler.js +45 -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 -3
- package/dist/server/routes/nodes.js +39 -24
- 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/server/static.d.ts +12 -0
- package/dist/server/static.js +24 -7
- package/dist/server/static.js.map +1 -1
- package/dist/ui/assets/index-BlMXQPSw.js +14 -0
- package/dist/ui/assets/{index-C9uChk2P.js.map → index-BlMXQPSw.js.map} +1 -1
- package/dist/ui/index.html +1 -1
- package/dist/update/apply.d.ts +23 -1
- package/dist/update/apply.js +46 -4
- package/dist/update/apply.js.map +1 -1
- package/dist/update/index.d.ts +14 -3
- package/dist/update/index.js +35 -2
- package/dist/update/index.js.map +1 -1
- package/dist/update/manifest.d.ts +28 -9
- package/dist/update/manifest.js +56 -29
- package/dist/update/manifest.js.map +1 -1
- package/dist/update/release-key.d.ts +18 -7
- package/dist/update/release-key.js +29 -14
- package/dist/update/release-key.js.map +1 -1
- package/dist/update/sign.d.ts +46 -0
- package/dist/update/sign.js +53 -0
- package/dist/update/sign.js.map +1 -0
- package/package.json +9 -4
- package/service/README.md +95 -86
- package/src/__tests__/cli-invite.smoke.test.ts +6 -1
- package/src/__tests__/cli-nat.smoke.test.ts +6 -1
- package/src/__tests__/cli.smoke.test.ts +6 -1
- package/src/__tests__/host-owner.smoke.test.ts +123 -0
- package/src/__tests__/orchestrator-owner.test.ts +198 -0
- package/src/__tests__/orchestrator-pin-keys.test.ts +153 -0
- package/src/__tests__/orchestrator-push.test.ts +145 -0
- package/src/auth/__tests__/trust-circle-integration.test.ts +44 -25
- package/src/auth/__tests__/trust-circle.test.ts +91 -24
- package/src/auth/trust-circle.ts +363 -304
- package/src/auth/types.ts +4 -1
- package/src/bin/host.ts +1258 -905
- 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 +66 -3
- package/src/installer/__tests__/config.test.ts +27 -1
- package/src/installer/__tests__/installer.smoke.test.ts +94 -0
- package/src/installer/__tests__/wizard.test.ts +5 -1
- package/src/installer/browser.ts +40 -42
- package/src/installer/config.ts +61 -1
- package/src/installer/index.ts +23 -12
- package/src/installer/service-host/launchd.ts +1 -1
- package/src/installer/service-host/systemd.ts +1 -1
- package/src/installer/wizard.ts +12 -1
- package/src/nat/__tests__/ddns-updater.test.ts +1 -1
- package/src/nat/__tests__/nat-service.test.ts +192 -0
- package/src/nat/__tests__/port-mapper.test.ts +1 -1
- package/src/nat/__tests__/secrets.test.ts +23 -1
- package/src/nat/index.ts +1 -1
- package/src/nat/nat-service.ts +131 -5
- package/src/nat/secrets/index.ts +32 -2
- package/src/nat/types.ts +3 -1
- package/src/orchestrator/host-process-orchestrator.ts +322 -54
- package/src/orchestrator/index.ts +9 -2
- package/src/orchestrator/state-store.ts +21 -3
- package/src/orchestrator/types.ts +48 -2
- package/src/owner/__tests__/owner-node-client.test.ts +194 -0
- package/src/owner/index.ts +12 -0
- package/src/owner/owner-node-client.ts +233 -0
- package/src/push/__tests__/push.test.ts +90 -0
- package/src/push/index.ts +146 -0
- package/src/server/__tests__/error-handler.test.ts +26 -4
- 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 +90 -10
- package/src/server/__tests__/publishers.test.ts +1 -1
- package/src/server/__tests__/server.smoke.test.ts +75 -0
- package/src/server/__tests__/settings-route.test.ts +17 -0
- package/src/server/__tests__/static.test.ts +145 -0
- package/src/server/__tests__/status-route.test.ts +23 -2
- package/src/server/error-handler.ts +47 -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 +49 -26
- package/src/server/routes/settings.ts +11 -3
- package/src/server/routes/status.ts +18 -8
- package/src/server/static.ts +24 -8
- package/src/update/__tests__/apply.test.ts +58 -1
- package/src/update/__tests__/manifest.test.ts +41 -6
- package/src/update/__tests__/release-key.test.ts +42 -0
- package/src/update/__tests__/sign.test.ts +78 -0
- package/src/update/__tests__/update-service.integration.test.ts +44 -9
- package/src/update/apply.ts +46 -4
- package/src/update/index.ts +41 -3
- package/src/update/manifest.ts +214 -172
- package/src/update/release-key.ts +31 -15
- package/src/update/sign.ts +74 -0
- package/dist/ui/assets/index-C9uChk2P.js +0 -14
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DonationService unit tests — the donate-a-node lifecycle on the host side,
|
|
3
|
+
* exercised against a fake orchestrator (no real child processes) and a real
|
|
4
|
+
* on-disk DonationStore + GrantService.
|
|
5
|
+
*
|
|
6
|
+
* Covers: provision → awaiting_seed (pinned keys threaded, seedToken persisted +
|
|
7
|
+
* redacted), seedToken survival across a store reconstruct, per-grant quota-race
|
|
8
|
+
* serialization, reclaim-on-post-spawn-failure, and the stale-awaiting_seed reap.
|
|
9
|
+
*
|
|
10
|
+
* getPeer / applySeed do real `fetch` to a live node, so their happy paths live
|
|
11
|
+
* in the cross-package integration scenario (`cadre-host-node-donation`), not
|
|
12
|
+
* here.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
16
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
|
|
20
|
+
import type {
|
|
21
|
+
Orchestrator,
|
|
22
|
+
OrchestratorCreateRequest,
|
|
23
|
+
OrchestratorCreateResult,
|
|
24
|
+
OrchestratorStats,
|
|
25
|
+
} from '@serfab/cadre-provider';
|
|
26
|
+
|
|
27
|
+
import { DonationService } from '../donation-service.js';
|
|
28
|
+
import { DonationStore } from '../donation-store.js';
|
|
29
|
+
import { GrantService } from '../grant-service.js';
|
|
30
|
+
import { GrantStore } from '../grant-store.js';
|
|
31
|
+
import { DonationError } from '../types.js';
|
|
32
|
+
|
|
33
|
+
function sleep(ms: number): Promise<void> {
|
|
34
|
+
return new Promise((res) => setTimeout(res, ms));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Records every create/stop/remove; returns deterministic, unique spawn results. */
|
|
38
|
+
class FakeOrchestrator implements Orchestrator {
|
|
39
|
+
createCalls: OrchestratorCreateRequest[] = [];
|
|
40
|
+
stopped: string[] = [];
|
|
41
|
+
removed: string[] = [];
|
|
42
|
+
failCreate = false;
|
|
43
|
+
createDelayMs = 0;
|
|
44
|
+
private counter = 0;
|
|
45
|
+
|
|
46
|
+
async createContainer(request: OrchestratorCreateRequest): Promise<OrchestratorCreateResult> {
|
|
47
|
+
this.createCalls.push(request);
|
|
48
|
+
if (this.createDelayMs) await sleep(this.createDelayMs);
|
|
49
|
+
if (this.failCreate) throw new Error('spawn boom');
|
|
50
|
+
const n = ++this.counter;
|
|
51
|
+
return {
|
|
52
|
+
dockerId: `dock_${n}`,
|
|
53
|
+
healthEndpoint: `http://127.0.0.1:${9000 + n}/health`,
|
|
54
|
+
metricsEndpoint: `http://127.0.0.1:${9000 + n}/metrics`,
|
|
55
|
+
seedEndpoint: `http://127.0.0.1:${9000 + n}/seed`,
|
|
56
|
+
seedToken: `seed-token-${n}`,
|
|
57
|
+
p2pPort: 4000 + n,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async stopContainer(dockerId: string): Promise<void> {
|
|
62
|
+
this.stopped.push(dockerId);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async removeContainer(dockerId: string): Promise<void> {
|
|
66
|
+
this.removed.push(dockerId);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async getStats(): Promise<OrchestratorStats> {
|
|
70
|
+
return { cpuPercent: 0, memoryBytes: 0, networkRxBytes: 0, networkTxBytes: 0 };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async isRunning(): Promise<boolean> {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async getLogs(): Promise<string> {
|
|
78
|
+
return '';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let tmpRoot: string;
|
|
83
|
+
|
|
84
|
+
beforeEach(() => {
|
|
85
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-donation-svc-'));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
afterEach(() => {
|
|
89
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
function makeGrants(opts?: { maxNodes?: number }): { grants: GrantService; token: string } {
|
|
93
|
+
const grants = new GrantService({ store: new GrantStore(join(tmpRoot, 'grants')) });
|
|
94
|
+
const token = grants.issue({ label: 'Alice', ...(opts?.maxNodes ? { maxNodes: opts.maxNodes } : {}) }).token;
|
|
95
|
+
return { grants, token };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const baseRequest = (grantToken: string) => ({
|
|
99
|
+
grantToken,
|
|
100
|
+
partyId: 'party-P',
|
|
101
|
+
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooReq'],
|
|
102
|
+
ownerKeys: ['owner-key-b64url'],
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('DonationService.provision', () => {
|
|
106
|
+
it('spawns → awaiting_seed, threads pinned owner keys, persists (redacts) the seedToken', async () => {
|
|
107
|
+
const orch = new FakeOrchestrator();
|
|
108
|
+
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
109
|
+
const { grants, token } = makeGrants();
|
|
110
|
+
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
111
|
+
|
|
112
|
+
const view = await svc.provision(baseRequest(token));
|
|
113
|
+
|
|
114
|
+
expect(view.status).toBe('awaiting_seed');
|
|
115
|
+
expect(view.partyId).toBe('party-P');
|
|
116
|
+
expect(view.profile).toBe('storage'); // default
|
|
117
|
+
expect(view.grantToken).toBe(token);
|
|
118
|
+
// Redacted: host-only secret + loopback URL never cross the boundary.
|
|
119
|
+
expect((view as Record<string, unknown>).seedToken).toBeUndefined();
|
|
120
|
+
expect((view as Record<string, unknown>).seedEndpoint).toBeUndefined();
|
|
121
|
+
|
|
122
|
+
// The orchestrator got the requester's pinned owner key (the seed-trust anchor).
|
|
123
|
+
expect(orch.createCalls).toHaveLength(1);
|
|
124
|
+
expect(orch.createCalls[0].pinnedOwnerKeys).toEqual(['owner-key-b64url']);
|
|
125
|
+
expect(orch.createCalls[0].containerId).toBe(view.id);
|
|
126
|
+
expect(orch.createCalls[0].partyId).toBe('party-P');
|
|
127
|
+
|
|
128
|
+
// But the record on disk DOES retain the seedToken (crash-recovery).
|
|
129
|
+
const stored = store.get(view.id);
|
|
130
|
+
expect(stored?.seedToken).toBe('seed-token-1');
|
|
131
|
+
expect(stored?.seedEndpoint).toBe('http://127.0.0.1:9001/seed');
|
|
132
|
+
expect(store.liveNodeCount(token)).toBe(1);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('honours an explicit transaction profile', async () => {
|
|
136
|
+
const orch = new FakeOrchestrator();
|
|
137
|
+
const { grants, token } = makeGrants();
|
|
138
|
+
const svc = new DonationService({
|
|
139
|
+
orchestrator: orch,
|
|
140
|
+
grants,
|
|
141
|
+
store: new DonationStore(join(tmpRoot, 'donations')),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const view = await svc.provision({ ...baseRequest(token), profile: 'transaction' });
|
|
145
|
+
expect(view.profile).toBe('transaction');
|
|
146
|
+
expect(orch.createCalls[0].profile).toBe('transaction');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('denies an unknown grant token before spawning (unauthorized)', async () => {
|
|
150
|
+
const orch = new FakeOrchestrator();
|
|
151
|
+
const { grants } = makeGrants();
|
|
152
|
+
const svc = new DonationService({
|
|
153
|
+
orchestrator: orch,
|
|
154
|
+
grants,
|
|
155
|
+
store: new DonationStore(join(tmpRoot, 'donations')),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await expect(svc.provision(baseRequest('not-a-real-token')))
|
|
159
|
+
.rejects.toMatchObject({ code: 'unauthorized' });
|
|
160
|
+
expect(orch.createCalls).toHaveLength(0);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('DonationService seedToken persistence', () => {
|
|
165
|
+
it('reproduces the seedToken from a store rebuilt off disk (host-restart gap)', async () => {
|
|
166
|
+
const orch = new FakeOrchestrator();
|
|
167
|
+
const dir = join(tmpRoot, 'donations');
|
|
168
|
+
const { grants, token } = makeGrants();
|
|
169
|
+
const svc = new DonationService({ orchestrator: orch, grants, store: new DonationStore(dir) });
|
|
170
|
+
|
|
171
|
+
const view = await svc.provision(baseRequest(token));
|
|
172
|
+
|
|
173
|
+
// Simulate a host restart: brand-new store instance, same directory.
|
|
174
|
+
const reloaded = new DonationStore(dir);
|
|
175
|
+
const record = reloaded.get(view.id);
|
|
176
|
+
expect(record?.status).toBe('awaiting_seed');
|
|
177
|
+
expect(record?.seedToken).toBe('seed-token-1');
|
|
178
|
+
expect(record?.seedEndpoint).toBe('http://127.0.0.1:9001/seed');
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('DonationService quota-race serialization', () => {
|
|
183
|
+
it('serializes per grant so two concurrent provisions at the boundary cannot both pass', async () => {
|
|
184
|
+
const orch = new FakeOrchestrator();
|
|
185
|
+
orch.createDelayMs = 40; // widen the check→create window so an unserialized impl would race
|
|
186
|
+
const { grants, token } = makeGrants({ maxNodes: 1 });
|
|
187
|
+
const svc = new DonationService({
|
|
188
|
+
orchestrator: orch,
|
|
189
|
+
grants,
|
|
190
|
+
store: new DonationStore(join(tmpRoot, 'donations')),
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const [a, b] = await Promise.allSettled([
|
|
194
|
+
svc.provision(baseRequest(token)),
|
|
195
|
+
svc.provision(baseRequest(token)),
|
|
196
|
+
]);
|
|
197
|
+
|
|
198
|
+
const fulfilled = [a, b].filter((r) => r.status === 'fulfilled');
|
|
199
|
+
const rejected = [a, b].filter((r) => r.status === 'rejected');
|
|
200
|
+
expect(fulfilled).toHaveLength(1);
|
|
201
|
+
expect(rejected).toHaveLength(1);
|
|
202
|
+
expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({ code: 'quota_exceeded' });
|
|
203
|
+
// Exactly one node was actually spawned.
|
|
204
|
+
expect(orch.createCalls).toHaveLength(1);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe('DonationService reclaim-on-failure', () => {
|
|
209
|
+
it('reclaims the spawned resources and marks error when persistence fails after spawn', async () => {
|
|
210
|
+
const orch = new FakeOrchestrator();
|
|
211
|
+
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
212
|
+
// Fail the SECOND put — the awaiting_seed write, after the orchestrator has
|
|
213
|
+
// already handed back a dockerId. The service must reclaim it.
|
|
214
|
+
const realPut = store.put.bind(store);
|
|
215
|
+
let puts = 0;
|
|
216
|
+
(store as unknown as { put: (d: unknown) => void }).put = (d: unknown) => {
|
|
217
|
+
puts += 1;
|
|
218
|
+
if (puts === 2) throw new DonationError('storage_error', 'disk full');
|
|
219
|
+
realPut(d as never);
|
|
220
|
+
};
|
|
221
|
+
const { grants, token } = makeGrants();
|
|
222
|
+
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
223
|
+
|
|
224
|
+
await expect(svc.provision(baseRequest(token)))
|
|
225
|
+
.rejects.toMatchObject({ code: 'orchestrator_error' });
|
|
226
|
+
|
|
227
|
+
// The spawned child was reclaimed (removeContainer called with its dockerId).
|
|
228
|
+
expect(orch.removed).toEqual(['dock_1']);
|
|
229
|
+
// The record is marked error (the 3rd put, which succeeds) → not counted live.
|
|
230
|
+
const errored = store.list().find((d) => d.grantToken === token);
|
|
231
|
+
expect(errored?.status).toBe('error');
|
|
232
|
+
expect(store.liveNodeCount(token)).toBe(0);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('does not reclaim when the spawn itself throws (nothing was allocated)', async () => {
|
|
236
|
+
const orch = new FakeOrchestrator();
|
|
237
|
+
orch.failCreate = true;
|
|
238
|
+
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
239
|
+
const { grants, token } = makeGrants();
|
|
240
|
+
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
241
|
+
|
|
242
|
+
await expect(svc.provision(baseRequest(token)))
|
|
243
|
+
.rejects.toMatchObject({ code: 'orchestrator_error' });
|
|
244
|
+
expect(orch.removed).toEqual([]);
|
|
245
|
+
expect(store.list().find((d) => d.grantToken === token)?.status).toBe('error');
|
|
246
|
+
expect(store.liveNodeCount(token)).toBe(0);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe('DonationService.reapStaleAwaitingSeed', () => {
|
|
251
|
+
it('terminates awaiting_seed donations past the TTL and leaves fresh ones alone', async () => {
|
|
252
|
+
const orch = new FakeOrchestrator();
|
|
253
|
+
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
254
|
+
const { grants, token } = makeGrants({ maxNodes: 5 });
|
|
255
|
+
let clock = new Date('2025-01-01T00:00:00.000Z');
|
|
256
|
+
const svc = new DonationService({ orchestrator: orch, grants, store, now: () => clock });
|
|
257
|
+
|
|
258
|
+
// Old donation (enters awaiting_seed at T0).
|
|
259
|
+
const old = await svc.provision(baseRequest(token));
|
|
260
|
+
|
|
261
|
+
// Advance well past the TTL, then provision a fresh one at the new "now".
|
|
262
|
+
const ttlMs = 30 * 60 * 1000;
|
|
263
|
+
clock = new Date(Date.parse('2025-01-01T00:00:00.000Z') + ttlMs + 60_000);
|
|
264
|
+
const fresh = await svc.provision(baseRequest(token));
|
|
265
|
+
|
|
266
|
+
const reaped = await svc.reapStaleAwaitingSeed(ttlMs);
|
|
267
|
+
|
|
268
|
+
expect(reaped).toEqual([old.id]);
|
|
269
|
+
expect(store.get(old.id)?.status).toBe('terminated');
|
|
270
|
+
expect(store.get(fresh.id)?.status).toBe('awaiting_seed');
|
|
271
|
+
// The stale child was stopped + removed.
|
|
272
|
+
expect(orch.stopped).toContain('dock_1');
|
|
273
|
+
expect(orch.removed).toContain('dock_1');
|
|
274
|
+
// Only the fresh donation still counts against the quota.
|
|
275
|
+
expect(store.liveNodeCount(token)).toBe(1);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('is a no-op when nothing is stale', async () => {
|
|
279
|
+
const orch = new FakeOrchestrator();
|
|
280
|
+
const { grants, token } = makeGrants();
|
|
281
|
+
const svc = new DonationService({
|
|
282
|
+
orchestrator: orch,
|
|
283
|
+
grants,
|
|
284
|
+
store: new DonationStore(join(tmpRoot, 'donations')),
|
|
285
|
+
});
|
|
286
|
+
await svc.provision(baseRequest(token));
|
|
287
|
+
expect(await svc.reapStaleAwaitingSeed(30 * 60 * 1000)).toEqual([]);
|
|
288
|
+
expect(orch.removed).toEqual([]);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { GrantService, createGrantAdminHandlers, DEFAULT_MAX_NODES } from '../grant-service.js';
|
|
7
|
+
import { GrantStore } from '../grant-store.js';
|
|
8
|
+
import { GrantError } from '../types.js';
|
|
9
|
+
|
|
10
|
+
let tmpRoot: string;
|
|
11
|
+
let store: GrantStore;
|
|
12
|
+
let service: GrantService;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-grant-svc-'));
|
|
16
|
+
store = new GrantStore(tmpRoot);
|
|
17
|
+
service = new GrantService({ store });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('GrantService.issue', () => {
|
|
25
|
+
it('persists a grant and returns a base64url token', () => {
|
|
26
|
+
const grant = service.issue({ label: "Alice's cadre", maxNodes: 2 });
|
|
27
|
+
expect(grant.token).toBeTruthy();
|
|
28
|
+
// base64url alphabet only.
|
|
29
|
+
expect(grant.token).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
30
|
+
expect(grant.label).toBe("Alice's cadre");
|
|
31
|
+
expect(grant.maxNodes).toBe(2);
|
|
32
|
+
expect(grant.createdAt).toBeTruthy();
|
|
33
|
+
expect(grant.expiresAt).toBeUndefined();
|
|
34
|
+
expect(grant.revokedAt).toBeUndefined();
|
|
35
|
+
|
|
36
|
+
expect(store.get(grant.token)).toMatchObject({ label: "Alice's cadre", maxNodes: 2 });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('defaults maxNodes when omitted', () => {
|
|
40
|
+
const grant = service.issue({ label: 'Bob' });
|
|
41
|
+
expect(grant.maxNodes).toBe(DEFAULT_MAX_NODES);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('sets expiresAt from ttlMs', () => {
|
|
45
|
+
const clock = new Date('2025-01-01T00:00:00.000Z');
|
|
46
|
+
const svc = new GrantService({ store, now: () => clock });
|
|
47
|
+
const grant = svc.issue({ label: 'Carol', ttlMs: 24 * 60 * 60 * 1000 });
|
|
48
|
+
expect(grant.expiresAt).toBe('2025-01-02T00:00:00.000Z');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('rejects empty / whitespace labels', () => {
|
|
52
|
+
expect(() => service.issue({ label: ' ' })).toThrow(GrantError);
|
|
53
|
+
expect(() => service.issue({ label: '' })).toThrow(GrantError);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('rejects labels over 200 characters', () => {
|
|
57
|
+
expect(() => service.issue({ label: 'a'.repeat(201) }))
|
|
58
|
+
.toThrow(expect.objectContaining({ code: 'invalid_label' }));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('rejects non-positive / non-integer maxNodes', () => {
|
|
62
|
+
expect(() => service.issue({ label: 'X', maxNodes: 0 }))
|
|
63
|
+
.toThrow(expect.objectContaining({ code: 'invalid_max_nodes' }));
|
|
64
|
+
expect(() => service.issue({ label: 'X', maxNodes: -1 }))
|
|
65
|
+
.toThrow(expect.objectContaining({ code: 'invalid_max_nodes' }));
|
|
66
|
+
expect(() => service.issue({ label: 'X', maxNodes: 1.5 }))
|
|
67
|
+
.toThrow(expect.objectContaining({ code: 'invalid_max_nodes' }));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('rejects non-positive ttl', () => {
|
|
71
|
+
expect(() => service.issue({ label: 'X', ttlMs: 0 }))
|
|
72
|
+
.toThrow(expect.objectContaining({ code: 'invalid_ttl' }));
|
|
73
|
+
expect(() => service.issue({ label: 'X', ttlMs: -1 }))
|
|
74
|
+
.toThrow(expect.objectContaining({ code: 'invalid_ttl' }));
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('GrantService.validate', () => {
|
|
79
|
+
it('accepts a live grant', () => {
|
|
80
|
+
const { token } = service.issue({ label: 'Alice' });
|
|
81
|
+
const v = service.validate(token);
|
|
82
|
+
expect(v.ok).toBe(true);
|
|
83
|
+
expect(v.grant?.token).toBe(token);
|
|
84
|
+
expect(v.reason).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('denies an empty bearer with unknown_token (never throws)', () => {
|
|
88
|
+
expect(service.validate('')).toEqual({ ok: false, reason: 'unknown_token' });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('denies an unknown token with unknown_token (never throws)', () => {
|
|
92
|
+
expect(service.validate('does-not-exist')).toEqual({ ok: false, reason: 'unknown_token' });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('denies a revoked grant with reason "revoked"', () => {
|
|
96
|
+
const { token } = service.issue({ label: 'Rev' });
|
|
97
|
+
service.revoke(token);
|
|
98
|
+
const v = service.validate(token);
|
|
99
|
+
expect(v.ok).toBe(false);
|
|
100
|
+
expect(v.reason).toBe('revoked');
|
|
101
|
+
expect(v.grant?.token).toBe(token);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('denies an expired grant with reason "expired" even when not revoked', () => {
|
|
105
|
+
let clock = new Date('2025-01-01T00:00:00.000Z');
|
|
106
|
+
const svc = new GrantService({ store, now: () => clock });
|
|
107
|
+
const { token } = svc.issue({ label: 'Exp', ttlMs: 60_000 });
|
|
108
|
+
|
|
109
|
+
clock = new Date('2025-01-02T00:00:00.000Z'); // past expiry
|
|
110
|
+
const v = svc.validate(token);
|
|
111
|
+
expect(v.ok).toBe(false);
|
|
112
|
+
expect(v.reason).toBe('expired');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('reports revoked over expired when both apply', () => {
|
|
116
|
+
let clock = new Date('2025-01-01T00:00:00.000Z');
|
|
117
|
+
const svc = new GrantService({ store, now: () => clock });
|
|
118
|
+
const { token } = svc.issue({ label: 'Both', ttlMs: 60_000 });
|
|
119
|
+
svc.revoke(token);
|
|
120
|
+
|
|
121
|
+
clock = new Date('2025-01-02T00:00:00.000Z'); // also past expiry now
|
|
122
|
+
expect(svc.validate(token).reason).toBe('revoked');
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('GrantService.validateForProvision', () => {
|
|
127
|
+
it('allows up to the quota, then denies with quota_exceeded', () => {
|
|
128
|
+
const { token } = service.issue({ label: 'Alice', maxNodes: 2 });
|
|
129
|
+
|
|
130
|
+
// First two provisions (live count 0, then 1) pass.
|
|
131
|
+
expect(service.validateForProvision(token, () => 0).ok).toBe(true);
|
|
132
|
+
expect(service.validateForProvision(token, () => 1).ok).toBe(true);
|
|
133
|
+
|
|
134
|
+
// Third (live count already at 2) is denied.
|
|
135
|
+
const v = service.validateForProvision(token, () => 2);
|
|
136
|
+
expect(v.ok).toBe(false);
|
|
137
|
+
expect(v.reason).toBe('quota_exceeded');
|
|
138
|
+
expect(v.grant?.token).toBe(token);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('does not consult liveNodeCount for an unknown token', () => {
|
|
142
|
+
let called = false;
|
|
143
|
+
const v = service.validateForProvision('nope', () => { called = true; return 0; });
|
|
144
|
+
expect(v).toEqual({ ok: false, reason: 'unknown_token' });
|
|
145
|
+
expect(called).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('does not consult liveNodeCount for a revoked or expired grant', () => {
|
|
149
|
+
const { token } = service.issue({ label: 'Rev', maxNodes: 5 });
|
|
150
|
+
service.revoke(token);
|
|
151
|
+
let called = false;
|
|
152
|
+
const v = service.validateForProvision(token, () => { called = true; return 0; });
|
|
153
|
+
expect(v.reason).toBe('revoked');
|
|
154
|
+
expect(called).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('GrantService.revoke', () => {
|
|
159
|
+
it('throws not_found for an unknown token', () => {
|
|
160
|
+
expect(() => service.revoke('nope'))
|
|
161
|
+
.toThrow(expect.objectContaining({ code: 'not_found' }));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('is idempotent (second revoke keeps the original timestamp)', () => {
|
|
165
|
+
let clock = new Date('2025-01-01T00:00:00.000Z');
|
|
166
|
+
const svc = new GrantService({ store, now: () => clock });
|
|
167
|
+
const { token } = svc.issue({ label: 'X' });
|
|
168
|
+
|
|
169
|
+
svc.revoke(token);
|
|
170
|
+
const first = store.get(token)?.revokedAt;
|
|
171
|
+
|
|
172
|
+
clock = new Date('2025-02-01T00:00:00.000Z');
|
|
173
|
+
svc.revoke(token); // no throw; keeps first timestamp
|
|
174
|
+
expect(store.get(token)?.revokedAt).toBe(first);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('GrantService.list', () => {
|
|
179
|
+
it('enumerates issued grants', () => {
|
|
180
|
+
service.issue({ label: 'A' });
|
|
181
|
+
service.issue({ label: 'B' });
|
|
182
|
+
expect(service.list().map(g => g.label).sort()).toEqual(['A', 'B']);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('createGrantAdminHandlers', () => {
|
|
187
|
+
it('exposes the admin HTTP-handler surface', async () => {
|
|
188
|
+
const handlers = createGrantAdminHandlers(service);
|
|
189
|
+
|
|
190
|
+
const issued = await handlers.postGrant({ label: 'Pat', maxNodes: 3 });
|
|
191
|
+
expect(issued.grant.token).toBeTruthy();
|
|
192
|
+
expect(issued.grant.maxNodes).toBe(3);
|
|
193
|
+
|
|
194
|
+
let listed = await handlers.listGrants();
|
|
195
|
+
expect(listed.grants.map(g => g.token)).toContain(issued.grant.token);
|
|
196
|
+
|
|
197
|
+
await handlers.deleteGrant(issued.grant.token);
|
|
198
|
+
listed = await handlers.listGrants();
|
|
199
|
+
// Revocation marks, not removes — the grant is still listed but revoked.
|
|
200
|
+
expect(listed.grants.find(g => g.token === issued.grant.token)?.revokedAt).toBeTruthy();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('postGrant rejects a missing label', async () => {
|
|
204
|
+
const handlers = createGrantAdminHandlers(service);
|
|
205
|
+
await expect(handlers.postGrant({ label: undefined as unknown as string }))
|
|
206
|
+
.rejects.toMatchObject({ code: 'invalid_label' });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('deleteGrant surfaces not_found for an unknown token', async () => {
|
|
210
|
+
const handlers = createGrantAdminHandlers(service);
|
|
211
|
+
await expect(handlers.deleteGrant('nope')).rejects.toMatchObject({ code: 'not_found' });
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { GrantStore } from '../grant-store.js';
|
|
7
|
+
import { GrantError } from '../types.js';
|
|
8
|
+
import type { Grant } from '../types.js';
|
|
9
|
+
|
|
10
|
+
let tmpRoot: string;
|
|
11
|
+
|
|
12
|
+
function grant(over: Partial<Grant> = {}): Grant {
|
|
13
|
+
return {
|
|
14
|
+
token: 'tok-A',
|
|
15
|
+
label: "Alice's cadre",
|
|
16
|
+
maxNodes: 2,
|
|
17
|
+
createdAt: '2025-01-01T00:00:00.000Z',
|
|
18
|
+
...over,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-grant-store-'));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('GrantStore', () => {
|
|
31
|
+
it('returns empty state when file is missing', () => {
|
|
32
|
+
const store = new GrantStore(tmpRoot);
|
|
33
|
+
expect(store.list()).toEqual([]);
|
|
34
|
+
expect(store.get('anything')).toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('round-trips grants through save/load', () => {
|
|
38
|
+
const store = new GrantStore(tmpRoot);
|
|
39
|
+
store.add(grant({ token: 'tok-A', label: 'Alice' }));
|
|
40
|
+
store.add(grant({ token: 'tok-B', label: 'Bob', maxNodes: 1, expiresAt: '2025-06-01T00:00:00.000Z' }));
|
|
41
|
+
|
|
42
|
+
const reread = new GrantStore(tmpRoot);
|
|
43
|
+
expect(reread.list()).toHaveLength(2);
|
|
44
|
+
expect(reread.get('tok-A')).toMatchObject({ token: 'tok-A', label: 'Alice', maxNodes: 2 });
|
|
45
|
+
expect(reread.get('tok-B')?.expiresAt).toBe('2025-06-01T00:00:00.000Z');
|
|
46
|
+
expect(reread.get('tok-A')?.expiresAt).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('removes grants', () => {
|
|
50
|
+
const store = new GrantStore(tmpRoot);
|
|
51
|
+
store.add(grant({ token: 'tok-A' }));
|
|
52
|
+
expect(store.remove('tok-A')).toBe(true);
|
|
53
|
+
expect(store.remove('tok-A')).toBe(false);
|
|
54
|
+
|
|
55
|
+
const reread = new GrantStore(tmpRoot);
|
|
56
|
+
expect(reread.list()).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('marks a grant revoked, idempotently', () => {
|
|
60
|
+
const store = new GrantStore(tmpRoot);
|
|
61
|
+
store.add(grant({ token: 'tok-A' }));
|
|
62
|
+
|
|
63
|
+
expect(store.markRevoked('tok-A', '2025-02-01T00:00:00.000Z')).toBe(true);
|
|
64
|
+
expect(store.get('tok-A')?.revokedAt).toBe('2025-02-01T00:00:00.000Z');
|
|
65
|
+
|
|
66
|
+
// Second revoke keeps the original timestamp.
|
|
67
|
+
expect(store.markRevoked('tok-A', '2025-03-01T00:00:00.000Z')).toBe(true);
|
|
68
|
+
expect(store.get('tok-A')?.revokedAt).toBe('2025-02-01T00:00:00.000Z');
|
|
69
|
+
|
|
70
|
+
// Unknown token.
|
|
71
|
+
expect(store.markRevoked('nope', '2025-02-01T00:00:00.000Z')).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('writes through a temp file (atomic rename, no residue)', () => {
|
|
75
|
+
const store = new GrantStore(tmpRoot);
|
|
76
|
+
store.add(grant());
|
|
77
|
+
expect(existsSync(join(tmpRoot, 'grants.json'))).toBe(true);
|
|
78
|
+
expect(existsSync(join(tmpRoot, 'grants.json.tmp'))).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('persists the version field in saved JSON', () => {
|
|
82
|
+
const store = new GrantStore(tmpRoot);
|
|
83
|
+
store.add(grant({ token: 'tok-A', label: 'one' }));
|
|
84
|
+
const raw = JSON.parse(readFileSync(join(tmpRoot, 'grants.json'), 'utf8'));
|
|
85
|
+
expect(raw.version).toBe(1);
|
|
86
|
+
expect(raw.grants['tok-A'].label).toBe('one');
|
|
87
|
+
// token is the map key, not duplicated inside the row.
|
|
88
|
+
expect(raw.grants['tok-A'].token).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('throws on malformed JSON rather than silently wiping (would revoke everyone)', () => {
|
|
92
|
+
writeFileSync(join(tmpRoot, 'grants.json'), 'not json', 'utf8');
|
|
93
|
+
const store = new GrantStore(tmpRoot);
|
|
94
|
+
expect(() => store.load()).toThrow(GrantError);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('throws on a file with the wrong shape', () => {
|
|
98
|
+
writeFileSync(join(tmpRoot, 'grants.json'), JSON.stringify({ version: 99, foo: 'bar' }), 'utf8');
|
|
99
|
+
const store = new GrantStore(tmpRoot);
|
|
100
|
+
expect(() => store.load()).toThrow(GrantError);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('wraps a write failure as storage_error (not a bare Error)', () => {
|
|
104
|
+
const store = new GrantStore(tmpRoot);
|
|
105
|
+
// load() sees no grants.json (empty), then save() writes grants.json.tmp.
|
|
106
|
+
// Make that temp path a directory so writeFileSync fails (EISDIR) — the
|
|
107
|
+
// failure must surface as GrantError storage_error, not a bare Error.
|
|
108
|
+
mkdirSync(join(tmpRoot, 'grants.json.tmp'));
|
|
109
|
+
expect(() => store.add(grant())).toThrow(
|
|
110
|
+
expect.objectContaining({ code: 'storage_error' }),
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
});
|