@serfab/cadre-host 0.8.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/index.d.ts +2 -0
- package/dist/auth/index.js +1 -0
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/self-label.d.ts +36 -0
- package/dist/auth/self-label.js +57 -0
- package/dist/auth/self-label.js.map +1 -0
- package/dist/auth/trust-circle.d.ts +36 -9
- package/dist/auth/trust-circle.js +40 -20
- package/dist/auth/trust-circle.js.map +1 -1
- package/dist/auth/types.d.ts +2 -2
- package/dist/bin/host.js +291 -54
- package/dist/bin/host.js.map +1 -1
- package/dist/donation/__tests__/fake-orchestrator.d.ts +103 -0
- package/dist/donation/__tests__/fake-orchestrator.js +180 -0
- package/dist/donation/__tests__/fake-orchestrator.js.map +1 -0
- package/dist/donation/donation-service.d.ts +361 -0
- package/dist/donation/donation-service.js +763 -0
- package/dist/donation/donation-service.js.map +1 -0
- package/dist/donation/donation-store.d.ts +49 -0
- package/dist/donation/donation-store.js +148 -0
- package/dist/donation/donation-store.js.map +1 -0
- package/dist/donation/donation-supervisor.d.ts +167 -0
- package/dist/donation/donation-supervisor.js +341 -0
- package/dist/donation/donation-supervisor.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 +20 -0
- package/dist/donation/index.js +17 -0
- package/dist/donation/index.js.map +1 -0
- package/dist/donation/types.d.ts +201 -0
- package/dist/donation/types.js +40 -0
- package/dist/donation/types.js.map +1 -0
- package/dist/index.d.ts +9 -5
- package/dist/index.js +7 -3
- 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 +144 -32
- package/dist/orchestrator/host-process-orchestrator.js +397 -128
- 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/node-identity.d.ts +26 -0
- package/dist/orchestrator/node-identity.js +36 -0
- package/dist/orchestrator/node-identity.js.map +1 -0
- package/dist/orchestrator/port-allocator.d.ts +14 -0
- package/dist/orchestrator/port-allocator.js +45 -0
- package/dist/orchestrator/port-allocator.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/owner/owner-node-client.d.ts +110 -0
- package/dist/{authority/authority-node-client.js → owner/owner-node-client.js} +58 -17
- package/dist/owner/owner-node-client.js.map +1 -0
- package/dist/server/error-handler.d.ts +22 -1
- package/dist/server/error-handler.js +64 -1
- package/dist/server/error-handler.js.map +1 -1
- package/dist/server/events/types.d.ts +3 -0
- package/dist/server/index.d.ts +33 -2
- package/dist/server/index.js +43 -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 +182 -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/server/routes/strands.d.ts +20 -0
- package/dist/server/routes/strands.js +38 -0
- package/dist/server/routes/strands.js.map +1 -0
- package/dist/strands/index.d.ts +7 -0
- package/dist/strands/index.js +6 -0
- package/dist/strands/index.js.map +1 -0
- package/dist/strands/strand-service.d.ts +50 -0
- package/dist/strands/strand-service.js +103 -0
- package/dist/strands/strand-service.js.map +1 -0
- package/dist/strands/types.d.ts +93 -0
- package/dist/strands/types.js +23 -0
- package/dist/strands/types.js.map +1 -0
- package/dist/ui/assets/index-BVoqLjyX.css +1 -0
- package/dist/ui/assets/index-C-iks41M.js +17 -0
- package/dist/ui/assets/index-C-iks41M.js.map +1 -0
- package/dist/ui/index.html +2 -2
- package/dist/update/release-key.d.ts +1 -1
- package/dist/update/release-key.js +8 -6
- package/dist/update/release-key.js.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/build-targets.test.ts +26 -0
- package/src/__tests__/global-setup.ts +51 -0
- package/src/__tests__/{host-authority.smoke.test.ts → host-owner.smoke.test.ts} +7 -7
- package/src/__tests__/orchestrator-env-scrub.test.ts +140 -0
- package/src/__tests__/orchestrator-node-identity.test.ts +204 -0
- 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/__tests__/orchestrator.test.ts +387 -6
- package/src/auth/__tests__/self-label.test.ts +85 -0
- package/src/auth/__tests__/trust-circle-integration.test.ts +152 -147
- package/src/auth/__tests__/trust-circle.test.ts +55 -11
- package/src/auth/index.ts +2 -0
- package/src/auth/self-label.ts +74 -0
- package/src/auth/trust-circle.ts +54 -22
- package/src/auth/types.ts +2 -2
- package/src/bin/host.ts +1299 -1038
- package/src/donation/__tests__/donation-service.test.ts +1068 -0
- package/src/donation/__tests__/donation-supervisor.test.ts +510 -0
- package/src/donation/__tests__/fake-orchestrator.test.ts +168 -0
- package/src/donation/__tests__/fake-orchestrator.ts +225 -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 +962 -0
- package/src/donation/donation-store.ts +171 -0
- package/src/donation/donation-supervisor.ts +388 -0
- package/src/donation/grant-service.ts +200 -0
- package/src/donation/grant-store.ts +152 -0
- package/src/donation/index.ts +64 -0
- package/src/donation/types.ts +243 -0
- package/src/index.ts +51 -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/__tests__/node-identity.test.ts +62 -0
- package/src/orchestrator/host-process-orchestrator.ts +401 -134
- package/src/orchestrator/index.ts +3 -3
- package/src/orchestrator/node-identity.ts +40 -0
- package/src/orchestrator/port-allocator.ts +48 -0
- 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} +83 -23
- 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 +263 -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/__tests__/strands-route.test.ts +233 -0
- package/src/server/error-handler.ts +67 -1
- package/src/server/events/types.ts +1 -0
- package/src/server/index.ts +75 -17
- package/src/server/routes/grants-admin.ts +41 -0
- package/src/server/routes/grants.ts +210 -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/server/routes/strands.ts +54 -0
- package/src/strands/__tests__/strand-service.test.ts +165 -0
- package/src/strands/index.ts +14 -0
- package/src/strands/strand-service.ts +135 -0
- package/src/strands/types.ts +102 -0
- package/src/update/__tests__/release-key.test.ts +16 -3
- package/src/update/release-key.ts +8 -6
- package/dist/authority/authority-node-client.d.ts +0 -77
- 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/dist/ui/assets/index-BlMXQPSw.js +0 -14
- package/dist/ui/assets/index-BlMXQPSw.js.map +0 -1
- package/dist/ui/assets/index-BwU5mJGF.css +0 -1
- package/src/authority/index.ts +0 -12
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grantee-facing `/grants` route tests — the bearer-gated provisioning surface.
|
|
3
|
+
*
|
|
4
|
+
* Uses a fake orchestrator (no real children) so the focus is the HTTP contract:
|
|
5
|
+
* bearer auth, request validation, per-grant ownership isolation, the quota gate,
|
|
6
|
+
* and seedToken redaction. The live-node round-trips (peer/seed) are covered by
|
|
7
|
+
* the cross-package integration scenario.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import Fastify from 'fastify';
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
12
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
Orchestrator,
|
|
18
|
+
OrchestratorCreateRequest,
|
|
19
|
+
OrchestratorCreateResult,
|
|
20
|
+
OrchestratorStats,
|
|
21
|
+
} from '@serfab/cadre-provider';
|
|
22
|
+
|
|
23
|
+
import { registerErrorHandler } from '../error-handler.js';
|
|
24
|
+
import { registerGrantsRoutes } from '../routes/grants.js';
|
|
25
|
+
import { DonationService } from '../../donation/donation-service.js';
|
|
26
|
+
import { DonationStore } from '../../donation/donation-store.js';
|
|
27
|
+
import { GrantService } from '../../donation/grant-service.js';
|
|
28
|
+
import { GrantStore } from '../../donation/grant-store.js';
|
|
29
|
+
|
|
30
|
+
class FakeOrchestrator implements Orchestrator {
|
|
31
|
+
stopped: string[] = [];
|
|
32
|
+
removed: string[] = [];
|
|
33
|
+
private counter = 0;
|
|
34
|
+
async createContainer(_request: OrchestratorCreateRequest): Promise<OrchestratorCreateResult> {
|
|
35
|
+
const n = ++this.counter;
|
|
36
|
+
return {
|
|
37
|
+
dockerId: `dock_${n}`,
|
|
38
|
+
healthEndpoint: `http://127.0.0.1:${9000 + n}/health`,
|
|
39
|
+
metricsEndpoint: `http://127.0.0.1:${9000 + n}/metrics`,
|
|
40
|
+
seedEndpoint: `http://127.0.0.1:${9000 + n}/seed`,
|
|
41
|
+
seedToken: `seed-token-${n}`,
|
|
42
|
+
p2pPort: 4000 + n,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
async stopContainer(id: string): Promise<void> { this.stopped.push(id); }
|
|
46
|
+
async removeContainer(id: string): Promise<void> { this.removed.push(id); }
|
|
47
|
+
async getStats(): Promise<OrchestratorStats> {
|
|
48
|
+
return { cpuPercent: 0, memoryBytes: 0, networkRxBytes: 0, networkTxBytes: 0 };
|
|
49
|
+
}
|
|
50
|
+
async isRunning(): Promise<boolean> { return true; }
|
|
51
|
+
async getLogs(): Promise<string> { return ''; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let tmpRoot: string;
|
|
55
|
+
let app: ReturnType<typeof Fastify>;
|
|
56
|
+
let grants: GrantService;
|
|
57
|
+
let store: DonationStore;
|
|
58
|
+
let token: string;
|
|
59
|
+
const originalFetch = globalThis.fetch;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A well-formed 32-byte base64url owner key. `DonationService.provision`
|
|
63
|
+
* shape-checks the pins, so a placeholder string is a 400 rather than a fixture.
|
|
64
|
+
*/
|
|
65
|
+
const OWNER_KEY = Buffer.alloc(32, 7).toString('base64url');
|
|
66
|
+
|
|
67
|
+
const body = {
|
|
68
|
+
partyId: 'party-P',
|
|
69
|
+
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooReq'],
|
|
70
|
+
ownerKeys: [OWNER_KEY],
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const bearer = (t: string) => ({ authorization: `Bearer ${t}` });
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-grants-svc-route-'));
|
|
77
|
+
grants = new GrantService({ store: new GrantStore(join(tmpRoot, 'grants')) });
|
|
78
|
+
token = grants.issue({ label: 'Alice', maxNodes: 2 }).token;
|
|
79
|
+
store = new DonationStore(join(tmpRoot, 'donations'));
|
|
80
|
+
const donations = new DonationService({ orchestrator: new FakeOrchestrator(), grants, store });
|
|
81
|
+
app = Fastify();
|
|
82
|
+
registerErrorHandler(app);
|
|
83
|
+
registerGrantsRoutes(app, { donations, grants });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterEach(async () => {
|
|
87
|
+
globalThis.fetch = originalFetch;
|
|
88
|
+
await app.close();
|
|
89
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('POST /grants', () => {
|
|
93
|
+
it('provisions a donated node → 201, awaiting_seed, seedToken redacted', async () => {
|
|
94
|
+
const res = await app.inject({ method: 'POST', url: '/grants', headers: bearer(token), payload: body });
|
|
95
|
+
expect(res.statusCode).toBe(201);
|
|
96
|
+
const donation = (res.json() as { data: { donation: Record<string, unknown> } }).data.donation;
|
|
97
|
+
expect(donation.status).toBe('awaiting_seed');
|
|
98
|
+
expect(donation.partyId).toBe('party-P');
|
|
99
|
+
expect(donation.seedToken).toBeUndefined();
|
|
100
|
+
expect(donation.seedEndpoint).toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('rejects a missing bearer → 401', async () => {
|
|
104
|
+
const res = await app.inject({ method: 'POST', url: '/grants', payload: body });
|
|
105
|
+
expect(res.statusCode).toBe(401);
|
|
106
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('unauthorized');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('rejects an unknown bearer → 401', async () => {
|
|
110
|
+
const res = await app.inject({ method: 'POST', url: '/grants', headers: bearer('nope'), payload: body });
|
|
111
|
+
expect(res.statusCode).toBe(401);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('rejects a missing partyId → 400 invalid_request', async () => {
|
|
115
|
+
const res = await app.inject({
|
|
116
|
+
method: 'POST', url: '/grants', headers: bearer(token),
|
|
117
|
+
payload: { bootstrapNodes: body.bootstrapNodes, ownerKeys: body.ownerKeys },
|
|
118
|
+
});
|
|
119
|
+
expect(res.statusCode).toBe(400);
|
|
120
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('invalid_request');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('rejects empty ownerKeys → 400 invalid_request', async () => {
|
|
124
|
+
const res = await app.inject({
|
|
125
|
+
method: 'POST', url: '/grants', headers: bearer(token),
|
|
126
|
+
payload: { ...body, ownerKeys: [] },
|
|
127
|
+
});
|
|
128
|
+
expect(res.statusCode).toBe(400);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// A typo'd pin used to be answered 201 and only fail when the spawned child
|
|
132
|
+
// tried to decode it at boot — by which point the caller had no way to act on it.
|
|
133
|
+
it('rejects a malformed ownerKeys entry → 400 invalid_request, naming the bad key', async () => {
|
|
134
|
+
const res = await app.inject({
|
|
135
|
+
method: 'POST', url: '/grants', headers: bearer(token),
|
|
136
|
+
payload: { ...body, ownerKeys: [OWNER_KEY, 'this-is-not-a-key'] },
|
|
137
|
+
});
|
|
138
|
+
expect(res.statusCode).toBe(400);
|
|
139
|
+
const error = (res.json() as { error: { code: string; message: string } }).error;
|
|
140
|
+
expect(error.code).toBe('invalid_request');
|
|
141
|
+
expect(error.message).toContain('this-is-not-a-key');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Element types are the route's job: the service's shape check assumes strings,
|
|
145
|
+
// so a number here would come back as an internal `.trim is not a function`.
|
|
146
|
+
it('rejects a non-string ownerKeys entry → 400 naming the field, not a decoder error', async () => {
|
|
147
|
+
const res = await app.inject({
|
|
148
|
+
method: 'POST', url: '/grants', headers: bearer(token),
|
|
149
|
+
payload: { ...body, ownerKeys: [42] },
|
|
150
|
+
});
|
|
151
|
+
expect(res.statusCode).toBe(400);
|
|
152
|
+
const error = (res.json() as { error: { code: string; message: string } }).error;
|
|
153
|
+
expect(error.code).toBe('invalid_request');
|
|
154
|
+
expect(error.message).toBe('ownerKeys must be an array of strings');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('enforces the grant quota → 429 quota_exceeded once maxNodes is reached', async () => {
|
|
158
|
+
const g = grants.issue({ label: 'Bob', maxNodes: 1 }).token;
|
|
159
|
+
const first = await app.inject({ method: 'POST', url: '/grants', headers: bearer(g), payload: body });
|
|
160
|
+
expect(first.statusCode).toBe(201);
|
|
161
|
+
const second = await app.inject({ method: 'POST', url: '/grants', headers: bearer(g), payload: body });
|
|
162
|
+
expect(second.statusCode).toBe(429);
|
|
163
|
+
expect((second.json() as { error: { code: string } }).error.code).toBe('quota_exceeded');
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('GET /grants auth', () => {
|
|
168
|
+
it('rejects an unknown bearer → 401 unauthorized (code matches the status)', async () => {
|
|
169
|
+
const res = await app.inject({ method: 'GET', url: '/grants', headers: bearer('nope') });
|
|
170
|
+
expect(res.statusCode).toBe(401);
|
|
171
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('unauthorized');
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('GET /grants (ownership isolation)', () => {
|
|
176
|
+
it('lists only the donations the presented grant authorized', async () => {
|
|
177
|
+
const other = grants.issue({ label: 'Carol' }).token;
|
|
178
|
+
await app.inject({ method: 'POST', url: '/grants', headers: bearer(token), payload: body });
|
|
179
|
+
await app.inject({ method: 'POST', url: '/grants', headers: bearer(other), payload: body });
|
|
180
|
+
|
|
181
|
+
const mine = await app.inject({ method: 'GET', url: '/grants', headers: bearer(token) });
|
|
182
|
+
const donations = (mine.json() as { data: { donations: Array<{ grantToken: string }> } }).data.donations;
|
|
183
|
+
expect(donations).toHaveLength(1);
|
|
184
|
+
expect(donations[0].grantToken).toBe(token);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('a foreign grant cannot read another grant’s donation → 404', async () => {
|
|
188
|
+
const other = grants.issue({ label: 'Carol' }).token;
|
|
189
|
+
const created = await app.inject({ method: 'POST', url: '/grants', headers: bearer(token), payload: body });
|
|
190
|
+
const id = (created.json() as { data: { donation: { id: string } } }).data.donation.id;
|
|
191
|
+
|
|
192
|
+
const mine = await app.inject({ method: 'GET', url: `/grants/${id}`, headers: bearer(token) });
|
|
193
|
+
expect(mine.statusCode).toBe(200);
|
|
194
|
+
|
|
195
|
+
const foreign = await app.inject({ method: 'GET', url: `/grants/${id}`, headers: bearer(other) });
|
|
196
|
+
expect(foreign.statusCode).toBe(404);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('PUT /grants/:id/seed validation', () => {
|
|
201
|
+
it('rejects a missing seed body → 400 invalid_request', async () => {
|
|
202
|
+
const created = await app.inject({ method: 'POST', url: '/grants', headers: bearer(token), payload: body });
|
|
203
|
+
const id = (created.json() as { data: { donation: { id: string } } }).data.donation.id;
|
|
204
|
+
const res = await app.inject({ method: 'PUT', url: `/grants/${id}/seed`, headers: bearer(token), payload: {} });
|
|
205
|
+
expect(res.statusCode).toBe(400);
|
|
206
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('invalid_request');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('answers 409 invalid_state when the loan ends while the seed is in flight', async () => {
|
|
210
|
+
const created = await app.inject({ method: 'POST', url: '/grants', headers: bearer(token), payload: body });
|
|
211
|
+
const id = (created.json() as { data: { donation: { id: string } } }).data.donation.id;
|
|
212
|
+
|
|
213
|
+
// The borrower's DELETE lands inside the seed window: the node accepts the
|
|
214
|
+
// seed, but by the time it answers the loan is over. The ending wins, so the
|
|
215
|
+
// route must NOT report a 200 that implies a live, freshly seeded node.
|
|
216
|
+
globalThis.fetch = (async () => {
|
|
217
|
+
await app.inject({ method: 'DELETE', url: `/grants/${id}`, headers: bearer(token) });
|
|
218
|
+
return { ok: true, json: async () => ({ success: true, peersAdded: 2 }) } as unknown as Response;
|
|
219
|
+
}) as typeof globalThis.fetch;
|
|
220
|
+
|
|
221
|
+
const res = await app.inject({
|
|
222
|
+
method: 'PUT', url: `/grants/${id}/seed`, headers: bearer(token), payload: { seed: 'encoded-seed' },
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
expect(res.statusCode).toBe(409);
|
|
226
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('invalid_state');
|
|
227
|
+
const after = await app.inject({ method: 'GET', url: `/grants/${id}`, headers: bearer(token) });
|
|
228
|
+
expect((after.json() as { data: { donation: { status: string } } }).data.donation.status)
|
|
229
|
+
.toBe('terminated');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('answers 404 not_found when the record vanishes while the seed is in flight', async () => {
|
|
233
|
+
const created = await app.inject({ method: 'POST', url: '/grants', headers: bearer(token), payload: body });
|
|
234
|
+
const id = (created.json() as { data: { donation: { id: string } } }).data.donation.id;
|
|
235
|
+
|
|
236
|
+
// The row is gone by the time the node answers — the other half of the
|
|
237
|
+
// abandon mapping, which has no status to report.
|
|
238
|
+
globalThis.fetch = (async () => {
|
|
239
|
+
store.remove(id);
|
|
240
|
+
return { ok: true, json: async () => ({ success: true, peersAdded: 2 }) } as unknown as Response;
|
|
241
|
+
}) as typeof globalThis.fetch;
|
|
242
|
+
|
|
243
|
+
const res = await app.inject({
|
|
244
|
+
method: 'PUT', url: `/grants/${id}/seed`, headers: bearer(token), payload: { seed: 'encoded-seed' },
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
expect(res.statusCode).toBe(404);
|
|
248
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('not_found');
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe('DELETE /grants/:id', () => {
|
|
253
|
+
it('terminates a donation and drops it from the live tally', async () => {
|
|
254
|
+
const created = await app.inject({ method: 'POST', url: '/grants', headers: bearer(token), payload: body });
|
|
255
|
+
const id = (created.json() as { data: { donation: { id: string } } }).data.donation.id;
|
|
256
|
+
|
|
257
|
+
const del = await app.inject({ method: 'DELETE', url: `/grants/${id}`, headers: bearer(token) });
|
|
258
|
+
expect(del.statusCode).toBe(200);
|
|
259
|
+
|
|
260
|
+
const one = await app.inject({ method: 'GET', url: `/grants/${id}`, headers: bearer(token) });
|
|
261
|
+
expect((one.json() as { data: { donation: { status: string } } }).data.donation.status).toBe('terminated');
|
|
262
|
+
});
|
|
263
|
+
});
|
|
@@ -21,7 +21,7 @@ interface FakeOrchestrator extends HostProcessOrchestrator {
|
|
|
21
21
|
|
|
22
22
|
function fakeOrchestrator(
|
|
23
23
|
initial: ManagedNodeInfo[] = [],
|
|
24
|
-
opts: {
|
|
24
|
+
opts: { ownerId?: string; hasOwnerConfig?: boolean } = {},
|
|
25
25
|
): FakeOrchestrator {
|
|
26
26
|
const nodes = new Map(initial.map((n) => [n.dockerId, n]));
|
|
27
27
|
const listeners = new Set<NodeStateListener>();
|
|
@@ -41,21 +41,21 @@ function fakeOrchestrator(
|
|
|
41
41
|
for (const n of nodes.values()) if (n.id === id) return n.dockerId;
|
|
42
42
|
return undefined;
|
|
43
43
|
},
|
|
44
|
-
|
|
45
|
-
if (!opts.
|
|
44
|
+
isOwnerNode: (id: string) => {
|
|
45
|
+
if (!opts.ownerId) return false;
|
|
46
46
|
const node = findById(id);
|
|
47
|
-
return id === opts.
|
|
47
|
+
return id === opts.ownerId || node?.id === opts.ownerId;
|
|
48
48
|
},
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
hasOwnerConfig: () => opts.hasOwnerConfig ?? false,
|
|
50
|
+
ensureOwnerNode: async () => {
|
|
51
51
|
counters.ensured++;
|
|
52
|
-
const node = opts.
|
|
53
|
-
return node ?? { id: opts.
|
|
52
|
+
const node = opts.ownerId ? findById(opts.ownerId) : undefined;
|
|
53
|
+
return node ?? { id: opts.ownerId, status: 'running' } as unknown as ManagedNodeInfo;
|
|
54
54
|
},
|
|
55
|
-
|
|
55
|
+
restartOwnerNode: async () => {
|
|
56
56
|
counters.restarted++;
|
|
57
|
-
const node = opts.
|
|
58
|
-
return node ?? { id: opts.
|
|
57
|
+
const node = opts.ownerId ? findById(opts.ownerId) : undefined;
|
|
58
|
+
return node ?? { id: opts.ownerId, status: 'running' } as unknown as ManagedNodeInfo;
|
|
59
59
|
},
|
|
60
60
|
onStateChange: (l: NodeStateListener) => { listeners.add(l); return () => { listeners.delete(l); }; },
|
|
61
61
|
stopContainer: async (dockerId: string) => {
|
|
@@ -171,14 +171,14 @@ describe('/api/nodes routes', () => {
|
|
|
171
171
|
]);
|
|
172
172
|
});
|
|
173
173
|
|
|
174
|
-
it('POST /api/nodes/:id/start on a non-
|
|
174
|
+
it('POST /api/nodes/:id/start on a non-owner node returns 501 not_implemented', async () => {
|
|
175
175
|
const res = await app.inject({ method: 'POST', url: '/api/nodes/alice/start' });
|
|
176
176
|
expect(res.statusCode).toBe(501);
|
|
177
177
|
const body = res.json() as { error: { code: string } };
|
|
178
178
|
expect(body.error.code).toBe('not_implemented');
|
|
179
179
|
});
|
|
180
180
|
|
|
181
|
-
it('POST /api/nodes/:id/restart on a non-
|
|
181
|
+
it('POST /api/nodes/:id/restart on a non-owner node returns 501 not_implemented', async () => {
|
|
182
182
|
const res = await app.inject({ method: 'POST', url: '/api/nodes/alice/restart' });
|
|
183
183
|
expect(res.statusCode).toBe(501);
|
|
184
184
|
});
|
|
@@ -191,9 +191,9 @@ describe('/api/nodes routes', () => {
|
|
|
191
191
|
});
|
|
192
192
|
});
|
|
193
193
|
|
|
194
|
-
describe('/api/nodes —
|
|
195
|
-
const
|
|
196
|
-
id: '
|
|
194
|
+
describe('/api/nodes — owner node start/restart', () => {
|
|
195
|
+
const OWNER_NODE: ManagedNodeInfo = {
|
|
196
|
+
id: 'owner',
|
|
197
197
|
dockerId: '999:tok',
|
|
198
198
|
partyId: 'install-id',
|
|
199
199
|
profile: 'storage',
|
|
@@ -201,7 +201,7 @@ describe('/api/nodes — authority node start/restart', () => {
|
|
|
201
201
|
spawnedAt: '2025-01-01T00:00:00Z',
|
|
202
202
|
workdir: '',
|
|
203
203
|
ports: { health: 1, metrics: 2, p2p: 4555, admin: 3 },
|
|
204
|
-
|
|
204
|
+
owner: true,
|
|
205
205
|
};
|
|
206
206
|
|
|
207
207
|
let app: ReturnType<typeof Fastify>;
|
|
@@ -210,30 +210,30 @@ describe('/api/nodes — authority node start/restart', () => {
|
|
|
210
210
|
beforeEach(() => {
|
|
211
211
|
app = Fastify();
|
|
212
212
|
registerErrorHandler(app);
|
|
213
|
-
orchestrator = fakeOrchestrator([
|
|
213
|
+
orchestrator = fakeOrchestrator([OWNER_NODE], { ownerId: 'owner', hasOwnerConfig: true });
|
|
214
214
|
registerNodesRoutes(app, { orchestrator });
|
|
215
215
|
});
|
|
216
216
|
|
|
217
217
|
afterEach(async () => { await app.close(); });
|
|
218
218
|
|
|
219
|
-
it('start ensures the
|
|
220
|
-
const res = await app.inject({ method: 'POST', url: '/api/nodes/
|
|
219
|
+
it('start ensures the owner node', async () => {
|
|
220
|
+
const res = await app.inject({ method: 'POST', url: '/api/nodes/owner/start' });
|
|
221
221
|
expect(res.statusCode).toBe(200);
|
|
222
222
|
expect(orchestrator.__ensured).toBe(1);
|
|
223
223
|
});
|
|
224
224
|
|
|
225
|
-
it('restart re-spawns the
|
|
226
|
-
const res = await app.inject({ method: 'POST', url: '/api/nodes/
|
|
225
|
+
it('restart re-spawns the owner node', async () => {
|
|
226
|
+
const res = await app.inject({ method: 'POST', url: '/api/nodes/owner/restart' });
|
|
227
227
|
expect(res.statusCode).toBe(200);
|
|
228
228
|
expect(orchestrator.__restarted).toBe(1);
|
|
229
229
|
});
|
|
230
230
|
|
|
231
|
-
it('start returns 501 when there is no saved
|
|
231
|
+
it('start returns 501 when there is no saved owner config', async () => {
|
|
232
232
|
const app2 = Fastify();
|
|
233
233
|
registerErrorHandler(app2);
|
|
234
|
-
const orch2 = fakeOrchestrator([
|
|
234
|
+
const orch2 = fakeOrchestrator([OWNER_NODE], { ownerId: 'owner', hasOwnerConfig: false });
|
|
235
235
|
registerNodesRoutes(app2, { orchestrator: orch2 });
|
|
236
|
-
const res = await app2.inject({ method: 'POST', url: '/api/nodes/
|
|
236
|
+
const res = await app2.inject({ method: 'POST', url: '/api/nodes/owner/start' });
|
|
237
237
|
expect(res.statusCode).toBe(501);
|
|
238
238
|
await app2.close();
|
|
239
239
|
});
|
|
@@ -9,6 +9,7 @@ import type { HostProcessOrchestrator } from '../../orchestrator/index.js';
|
|
|
9
9
|
import type { TrustCircleService } from '../../auth/index.js';
|
|
10
10
|
import type { NatService } from '../../nat/index.js';
|
|
11
11
|
import type { NatStatusSnapshot } from '../../nat/types.js';
|
|
12
|
+
import { GrantService, GrantStore } from '../../donation/index.js';
|
|
12
13
|
|
|
13
14
|
function fakeOrchestrator(): HostProcessOrchestrator {
|
|
14
15
|
return {
|
|
@@ -176,3 +177,77 @@ describe('createLocalUiServer smoke', () => {
|
|
|
176
177
|
expect(body.error.code).toBe('not_found');
|
|
177
178
|
});
|
|
178
179
|
});
|
|
180
|
+
|
|
181
|
+
// Donor-only mode: the common case (ownCadre.enabled=false). No owner node, so
|
|
182
|
+
// no trustCircle / NAT are wired. The donor surface (/grants-admin) is up, and
|
|
183
|
+
// the founder-only surfaces (/auth, /nat) 404 to keep the surface honest.
|
|
184
|
+
describe('createLocalUiServer smoke — donor-only (no owner node)', () => {
|
|
185
|
+
let dataDir: string;
|
|
186
|
+
let server: ReturnType<typeof createLocalUiServer>;
|
|
187
|
+
let baseUrl: string;
|
|
188
|
+
|
|
189
|
+
beforeEach(async () => {
|
|
190
|
+
dataDir = mkdtempSync(join(tmpdir(), 'cadre-host-donor-'));
|
|
191
|
+
writeConfig(dataDir);
|
|
192
|
+
server = createLocalUiServer({
|
|
193
|
+
uiPort: 8765,
|
|
194
|
+
dataDir,
|
|
195
|
+
orchestrator: fakeOrchestrator(),
|
|
196
|
+
grants: new GrantService({ store: new GrantStore(dataDir) }),
|
|
197
|
+
forcePort: 0,
|
|
198
|
+
});
|
|
199
|
+
const { url } = await server.start();
|
|
200
|
+
baseUrl = url;
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
afterEach(async () => {
|
|
204
|
+
await server.stop();
|
|
205
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('serves /api/status with no trustCircle / connectivity and no nodes', async () => {
|
|
209
|
+
const res = await fetch(`${baseUrl}/api/status`);
|
|
210
|
+
expect(res.status).toBe(200);
|
|
211
|
+
const body = await res.json() as {
|
|
212
|
+
service: { name: string };
|
|
213
|
+
nodes: unknown[];
|
|
214
|
+
trustCircle?: unknown;
|
|
215
|
+
connectivity?: unknown;
|
|
216
|
+
};
|
|
217
|
+
expect(body.service.name).toBe('cadre-host');
|
|
218
|
+
expect(body.nodes).toEqual([]);
|
|
219
|
+
expect(body.trustCircle).toBeUndefined();
|
|
220
|
+
expect(body.connectivity).toBeUndefined();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('serves the donor surface: POST /grants-admin issues a grant', async () => {
|
|
224
|
+
const res = await fetch(`${baseUrl}/grants-admin`, {
|
|
225
|
+
method: 'POST',
|
|
226
|
+
headers: { 'content-type': 'application/json' },
|
|
227
|
+
body: JSON.stringify({ label: "Alice's cadre", maxNodes: 2 }),
|
|
228
|
+
});
|
|
229
|
+
expect(res.status).toBe(200);
|
|
230
|
+
const body = await res.json() as { grant: { token: string; maxNodes: number } };
|
|
231
|
+
expect(body.grant.token).toBeTruthy();
|
|
232
|
+
expect(body.grant.maxNodes).toBe(2);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('404s the founder-only NAT surface (GET /nat/status)', async () => {
|
|
236
|
+
const res = await fetch(`${baseUrl}/nat/status`);
|
|
237
|
+
expect(res.status).toBe(404);
|
|
238
|
+
const body = await res.json() as { error: { code: string } };
|
|
239
|
+
expect(body.error.code).toBe('not_found');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('404s the founder-only trust-circle surface (/auth/*)', async () => {
|
|
243
|
+
const list = await fetch(`${baseUrl}/auth/trust-circle`);
|
|
244
|
+
expect(list.status).toBe(404);
|
|
245
|
+
|
|
246
|
+
const invite = await fetch(`${baseUrl}/auth/invites`, {
|
|
247
|
+
method: 'POST',
|
|
248
|
+
headers: { 'content-type': 'application/json' },
|
|
249
|
+
body: JSON.stringify({ label: 'Mom' }),
|
|
250
|
+
});
|
|
251
|
+
expect(invite.status).toBe(404);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
@@ -149,6 +149,23 @@ describe('/api/settings routes', () => {
|
|
|
149
149
|
expect(body.error.message).toMatch(/uiPort/);
|
|
150
150
|
});
|
|
151
151
|
|
|
152
|
+
it('PUT ownCadre returns 400 invalid_setting (install-time only)', async () => {
|
|
153
|
+
writeV2Config(dataDir);
|
|
154
|
+
app = Fastify();
|
|
155
|
+
registerErrorHandler(app);
|
|
156
|
+
registerSettingsRoutes(app, { settingsStore: new HostSettingsStore({ dataDir }), nat: fakeNat().svc });
|
|
157
|
+
const res = await app.inject({
|
|
158
|
+
method: 'PUT',
|
|
159
|
+
url: '/api/settings',
|
|
160
|
+
headers: { 'content-type': 'application/json' },
|
|
161
|
+
payload: JSON.stringify({ ownCadre: { enabled: true } }),
|
|
162
|
+
});
|
|
163
|
+
expect(res.statusCode).toBe(400);
|
|
164
|
+
const body = res.json() as { error: { code: string; message: string } };
|
|
165
|
+
expect(body.error.code).toBe('invalid_setting');
|
|
166
|
+
expect(body.error.message).toMatch(/ownCadre/);
|
|
167
|
+
});
|
|
168
|
+
|
|
152
169
|
it('PUT unknown setting returns 400', async () => {
|
|
153
170
|
writeV2Config(dataDir);
|
|
154
171
|
app = Fastify();
|
|
@@ -91,6 +91,27 @@ describe('GET /api/status', () => {
|
|
|
91
91
|
expect(body.update).toBeUndefined();
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
+
it('omits trustCircle + connectivity in donor-only mode (no trust circle / NAT wired)', async () => {
|
|
95
|
+
app = Fastify();
|
|
96
|
+
registerErrorHandler(app);
|
|
97
|
+
registerStatusRoute(app, {
|
|
98
|
+
orchestrator: fakeOrchestrator([]),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const res = await app.inject({ method: 'GET', url: '/api/status' });
|
|
102
|
+
expect(res.statusCode).toBe(200);
|
|
103
|
+
const body = res.json() as {
|
|
104
|
+
service: { name: string };
|
|
105
|
+
nodes: unknown[];
|
|
106
|
+
trustCircle?: unknown;
|
|
107
|
+
connectivity?: unknown;
|
|
108
|
+
};
|
|
109
|
+
expect(body.service.name).toBe('cadre-host');
|
|
110
|
+
expect(body.nodes).toEqual([]);
|
|
111
|
+
expect(body.trustCircle).toBeUndefined();
|
|
112
|
+
expect(body.connectivity).toBeUndefined();
|
|
113
|
+
});
|
|
114
|
+
|
|
94
115
|
it('includes update info when service is present', async () => {
|
|
95
116
|
app = Fastify();
|
|
96
117
|
registerErrorHandler(app);
|