@serfab/cadre-host 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +32 -5
- package/dist/auth/trust-circle.js +31 -11
- package/dist/auth/trust-circle.js.map +1 -1
- package/dist/bin/host.js +41 -5
- 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 +237 -12
- package/dist/donation/donation-service.js +491 -34
- package/dist/donation/donation-service.js.map +1 -1
- package/dist/donation/donation-store.d.ts +7 -0
- package/dist/donation/donation-store.js +7 -0
- package/dist/donation/donation-store.js.map +1 -1
- 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/index.d.ts +4 -2
- package/dist/donation/index.js +2 -1
- package/dist/donation/index.js.map +1 -1
- package/dist/donation/types.d.ts +26 -0
- package/dist/donation/types.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/orchestrator/host-process-orchestrator.d.ts +109 -5
- package/dist/orchestrator/host-process-orchestrator.js +341 -98
- package/dist/orchestrator/host-process-orchestrator.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/owner/owner-node-client.d.ts +20 -5
- package/dist/owner/owner-node-client.js +21 -4
- package/dist/owner/owner-node-client.js.map +1 -1
- package/dist/server/error-handler.d.ts +7 -1
- package/dist/server/error-handler.js +23 -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 +7 -0
- package/dist/server/index.js +9 -3
- package/dist/server/index.js.map +1 -1
- package/dist/server/routes/grants.js +22 -3
- package/dist/server/routes/grants.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 +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__/orchestrator-env-scrub.test.ts +140 -0
- package/src/__tests__/orchestrator-node-identity.test.ts +204 -0
- 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 +45 -1
- package/src/auth/index.ts +2 -0
- package/src/auth/self-label.ts +74 -0
- package/src/auth/trust-circle.ts +45 -13
- package/src/bin/host.ts +1299 -1258
- package/src/donation/__tests__/donation-service.test.ts +836 -58
- 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/donation-service.ts +611 -41
- package/src/donation/donation-store.ts +7 -0
- package/src/donation/donation-supervisor.ts +388 -0
- package/src/donation/index.ts +15 -0
- package/src/donation/types.ts +23 -0
- package/src/index.ts +14 -0
- package/src/orchestrator/__tests__/node-identity.test.ts +62 -0
- package/src/orchestrator/host-process-orchestrator.ts +336 -97
- package/src/orchestrator/node-identity.ts +40 -0
- package/src/orchestrator/port-allocator.ts +48 -0
- package/src/owner/owner-node-client.ts +31 -5
- package/src/server/__tests__/grants-route.test.ts +80 -6
- package/src/server/__tests__/strands-route.test.ts +233 -0
- package/src/server/error-handler.ts +24 -1
- package/src/server/events/types.ts +1 -0
- package/src/server/index.ts +16 -3
- package/src/server/routes/grants.ts +27 -3
- 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/release-key.ts +1 -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
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
* cadre-host's manager process holds no in-process `CadreNode`; it spawns the
|
|
6
6
|
* admin's owner node as a managed child (see `HostProcessOrchestrator`)
|
|
7
7
|
* and delegates all owner/membership/identity operations to it over this
|
|
8
|
-
* client. The client implements **
|
|
9
|
-
* the trust-circle one (`auth/trust-circle.ts`)
|
|
10
|
-
* (`nat/nat-service.ts`)
|
|
11
|
-
* node-donation requester side).
|
|
8
|
+
* client. The client implements **all three** trimmed `CadreNodeLike`
|
|
9
|
+
* interfaces — the trust-circle one (`auth/trust-circle.ts`), the NAT one
|
|
10
|
+
* (`nat/nat-service.ts`), and the strand one (`strands/strand-service.ts`) —
|
|
11
|
+
* plus `pushInviteAddresses` and `addDrone` (the node-donation requester side).
|
|
12
12
|
*
|
|
13
13
|
* Transport: `Authorization: Bearer <token>` against
|
|
14
14
|
* `http://127.0.0.1:<adminPort>`. Every response uses the cadre-provider
|
|
@@ -25,6 +25,8 @@ import type { CadreInvite, DroneInitResult } from '@serfab/cadre-core';
|
|
|
25
25
|
import type { OwnerAdminEndpoint } from '../orchestrator/index.js';
|
|
26
26
|
import type { CadreNodeLike as TrustCircleCadreNodeLike } from '../auth/trust-circle.js';
|
|
27
27
|
import type { CadreNodeLike as NatCadreNodeLike } from '../nat/nat-service.js';
|
|
28
|
+
import type { CadreNodeLike as StrandCadreNodeLike } from '../strands/strand-service.js';
|
|
29
|
+
import type { StrandListSnapshot, StrandRemovalResult } from '../strands/types.js';
|
|
28
30
|
|
|
29
31
|
const log = debug('cadre:host:owner-client');
|
|
30
32
|
|
|
@@ -55,7 +57,8 @@ export interface OwnerNodeClientOptions {
|
|
|
55
57
|
fetch?: typeof fetch;
|
|
56
58
|
}
|
|
57
59
|
|
|
58
|
-
export class OwnerNodeClient
|
|
60
|
+
export class OwnerNodeClient
|
|
61
|
+
implements TrustCircleCadreNodeLike, NatCadreNodeLike, StrandCadreNodeLike {
|
|
59
62
|
private readonly endpointSource: EndpointSource;
|
|
60
63
|
private readonly fetchImpl: typeof fetch;
|
|
61
64
|
|
|
@@ -153,6 +156,29 @@ export class OwnerNodeClient implements TrustCircleCadreNodeLike, NatCadreNodeLi
|
|
|
153
156
|
return data.multiaddrs;
|
|
154
157
|
}
|
|
155
158
|
|
|
159
|
+
// --- strand CadreNodeLike ---
|
|
160
|
+
|
|
161
|
+
/** This party's strands plus the node's current control-connection count. */
|
|
162
|
+
async listStrands(): Promise<StrandListSnapshot> {
|
|
163
|
+
return await this.request<StrandListSnapshot>('GET', '/admin/strands');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Remove this party's participation in one strand.
|
|
168
|
+
*
|
|
169
|
+
* `?confirm=1` is appended **only** when the caller asked for it. The node
|
|
170
|
+
* refuses an unconfirmed closed-strand removal with `confirmation_required`;
|
|
171
|
+
* that refusal is surfaced, never retried with the flag added — the row holds
|
|
172
|
+
* this party's membership key for the strand and nothing else does.
|
|
173
|
+
*/
|
|
174
|
+
async removeStrand(strandId: string, opts: { confirm: boolean }): Promise<StrandRemovalResult> {
|
|
175
|
+
const query = opts.confirm ? '?confirm=1' : '';
|
|
176
|
+
return await this.request<StrandRemovalResult>(
|
|
177
|
+
'DELETE',
|
|
178
|
+
`/admin/strands/${encodeURIComponent(strandId)}${query}`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
156
182
|
// --- drone seeding (node-donation requester side) ---
|
|
157
183
|
|
|
158
184
|
/**
|
|
@@ -54,12 +54,20 @@ class FakeOrchestrator implements Orchestrator {
|
|
|
54
54
|
let tmpRoot: string;
|
|
55
55
|
let app: ReturnType<typeof Fastify>;
|
|
56
56
|
let grants: GrantService;
|
|
57
|
+
let store: DonationStore;
|
|
57
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');
|
|
58
66
|
|
|
59
67
|
const body = {
|
|
60
68
|
partyId: 'party-P',
|
|
61
69
|
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooReq'],
|
|
62
|
-
ownerKeys: [
|
|
70
|
+
ownerKeys: [OWNER_KEY],
|
|
63
71
|
};
|
|
64
72
|
|
|
65
73
|
const bearer = (t: string) => ({ authorization: `Bearer ${t}` });
|
|
@@ -68,17 +76,15 @@ beforeEach(() => {
|
|
|
68
76
|
tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-grants-svc-route-'));
|
|
69
77
|
grants = new GrantService({ store: new GrantStore(join(tmpRoot, 'grants')) });
|
|
70
78
|
token = grants.issue({ label: 'Alice', maxNodes: 2 }).token;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
grants,
|
|
74
|
-
store: new DonationStore(join(tmpRoot, 'donations')),
|
|
75
|
-
});
|
|
79
|
+
store = new DonationStore(join(tmpRoot, 'donations'));
|
|
80
|
+
const donations = new DonationService({ orchestrator: new FakeOrchestrator(), grants, store });
|
|
76
81
|
app = Fastify();
|
|
77
82
|
registerErrorHandler(app);
|
|
78
83
|
registerGrantsRoutes(app, { donations, grants });
|
|
79
84
|
});
|
|
80
85
|
|
|
81
86
|
afterEach(async () => {
|
|
87
|
+
globalThis.fetch = originalFetch;
|
|
82
88
|
await app.close();
|
|
83
89
|
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
84
90
|
});
|
|
@@ -122,6 +128,32 @@ describe('POST /grants', () => {
|
|
|
122
128
|
expect(res.statusCode).toBe(400);
|
|
123
129
|
});
|
|
124
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
|
+
|
|
125
157
|
it('enforces the grant quota → 429 quota_exceeded once maxNodes is reached', async () => {
|
|
126
158
|
const g = grants.issue({ label: 'Bob', maxNodes: 1 }).token;
|
|
127
159
|
const first = await app.inject({ method: 'POST', url: '/grants', headers: bearer(g), payload: body });
|
|
@@ -173,6 +205,48 @@ describe('PUT /grants/:id/seed validation', () => {
|
|
|
173
205
|
expect(res.statusCode).toBe(400);
|
|
174
206
|
expect((res.json() as { error: { code: string } }).error.code).toBe('invalid_request');
|
|
175
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
|
+
});
|
|
176
250
|
});
|
|
177
251
|
|
|
178
252
|
describe('DELETE /grants/:id', () => {
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/api/strands` route tests — a real Fastify built by `createLocalUiServer`
|
|
3
|
+
* (so the error handler, the mount gate, and the static not-found fallback are
|
|
4
|
+
* all the production ones) over a fake StrandService.
|
|
5
|
+
*
|
|
6
|
+
* What the service does is covered by `strands/__tests__/strand-service.test.ts`;
|
|
7
|
+
* this file is about the HTTP contract: envelope shape, status mapping, the
|
|
8
|
+
* `confirm` query param, and exactly-once bus publication.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
16
|
+
|
|
17
|
+
import { createLocalUiServer } from '../index.js';
|
|
18
|
+
import type { LocalUiEvent } from '../events/types.js';
|
|
19
|
+
import type { HostProcessOrchestrator } from '../../orchestrator/index.js';
|
|
20
|
+
import type { StrandService } from '../../strands/index.js';
|
|
21
|
+
import { StrandError } from '../../strands/index.js';
|
|
22
|
+
import type { StrandListSnapshot, StrandRemovalResult } from '../../strands/index.js';
|
|
23
|
+
|
|
24
|
+
function fakeOrchestrator(): HostProcessOrchestrator {
|
|
25
|
+
return {
|
|
26
|
+
listNodes: () => [],
|
|
27
|
+
getNode: () => undefined,
|
|
28
|
+
resolveDockerId: () => undefined,
|
|
29
|
+
onStateChange: () => () => undefined,
|
|
30
|
+
} as unknown as HostProcessOrchestrator;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const SNAPSHOT: StrandListSnapshot = {
|
|
34
|
+
strands: [
|
|
35
|
+
{ id: 'open-one', type: 'o', running: true, status: 'active' },
|
|
36
|
+
{ id: 'closed-one', type: 'c', running: false, status: null },
|
|
37
|
+
],
|
|
38
|
+
controlConnections: 3,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
interface FakeStrands {
|
|
42
|
+
service: StrandService;
|
|
43
|
+
/** Every (id, confirm) the route forwarded. */
|
|
44
|
+
calls: Array<{ strandId: string; confirm: boolean }>;
|
|
45
|
+
/** Set to make `list` and `remove` throw. */
|
|
46
|
+
failWith?: unknown;
|
|
47
|
+
/** Set to make `remove` report a strand that was never published. */
|
|
48
|
+
absent?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fakeStrandService(): FakeStrands {
|
|
52
|
+
const state: FakeStrands = {
|
|
53
|
+
calls: [],
|
|
54
|
+
service: undefined as unknown as StrandService,
|
|
55
|
+
};
|
|
56
|
+
state.service = {
|
|
57
|
+
async list(): Promise<StrandListSnapshot> {
|
|
58
|
+
if (state.failWith) throw state.failWith;
|
|
59
|
+
return SNAPSHOT;
|
|
60
|
+
},
|
|
61
|
+
async remove(strandId: string, opts: { confirm: boolean }): Promise<StrandRemovalResult> {
|
|
62
|
+
state.calls.push({ strandId, confirm: opts.confirm });
|
|
63
|
+
if (state.failWith) throw state.failWith;
|
|
64
|
+
return state.absent
|
|
65
|
+
? { strandId, published: false, type: null, removed: false, alone: false }
|
|
66
|
+
: { strandId, published: true, type: 'o', removed: true, alone: false };
|
|
67
|
+
},
|
|
68
|
+
} as unknown as StrandService;
|
|
69
|
+
return state;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let dataDir: string;
|
|
73
|
+
let server: ReturnType<typeof createLocalUiServer>;
|
|
74
|
+
let strands: FakeStrands;
|
|
75
|
+
let events: LocalUiEvent[];
|
|
76
|
+
|
|
77
|
+
function buildServer(opts: { withStrands: boolean }): void {
|
|
78
|
+
server = createLocalUiServer({
|
|
79
|
+
uiPort: 8765,
|
|
80
|
+
dataDir,
|
|
81
|
+
orchestrator: fakeOrchestrator(),
|
|
82
|
+
...(opts.withStrands ? { strands: strands.service } : {}),
|
|
83
|
+
forcePort: 0,
|
|
84
|
+
});
|
|
85
|
+
events = [];
|
|
86
|
+
server.events.subscribe((e) => events.push(e));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
dataDir = mkdtempSync(join(tmpdir(), 'cadre-host-strands-route-'));
|
|
91
|
+
strands = fakeStrandService();
|
|
92
|
+
buildServer({ withStrands: true });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterEach(async () => {
|
|
96
|
+
await server.app.close();
|
|
97
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('GET /api/strands', () => {
|
|
101
|
+
it('returns the snapshot in the /api/* envelope', async () => {
|
|
102
|
+
const res = await server.app.inject({ method: 'GET', url: '/api/strands' });
|
|
103
|
+
expect(res.statusCode).toBe(200);
|
|
104
|
+
const body = res.json() as { ok: boolean; data: StrandListSnapshot };
|
|
105
|
+
expect(body.ok).toBe(true);
|
|
106
|
+
expect(body.data.strands).toHaveLength(2);
|
|
107
|
+
expect(body.data.strands[0]?.id).toBe('open-one');
|
|
108
|
+
expect(body.data.controlConnections).toBe(3);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('maps a service failure through the same error handler as the DELETE arm', async () => {
|
|
112
|
+
strands.failWith = new StrandError('node_unavailable', 'Owner node unavailable: refused');
|
|
113
|
+
const res = await server.app.inject({ method: 'GET', url: '/api/strands' });
|
|
114
|
+
expect(res.statusCode).toBe(503);
|
|
115
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('node_unavailable');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('DELETE /api/strands/:id', () => {
|
|
120
|
+
it('removes a published strand → 200 and exactly one strands-changed event', async () => {
|
|
121
|
+
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/open-one' });
|
|
122
|
+
expect(res.statusCode).toBe(200);
|
|
123
|
+
const body = res.json() as { ok: boolean; data: StrandRemovalResult };
|
|
124
|
+
expect(body.ok).toBe(true);
|
|
125
|
+
expect(body.data).toMatchObject({ strandId: 'open-one', published: true, removed: true });
|
|
126
|
+
expect(events).toEqual([{ type: 'strands-changed', kind: 'removed' }]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('an absent strand → 200, published: false, and NO event', async () => {
|
|
130
|
+
strands.absent = true;
|
|
131
|
+
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/never-published' });
|
|
132
|
+
expect(res.statusCode).toBe(200);
|
|
133
|
+
const body = res.json() as { data: StrandRemovalResult };
|
|
134
|
+
expect(body.data.published).toBe(false);
|
|
135
|
+
expect(body.data.removed).toBe(false);
|
|
136
|
+
expect(events).toEqual([]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('forwards confirm=1 as true and its absence as false', async () => {
|
|
140
|
+
await server.app.inject({ method: 'DELETE', url: '/api/strands/a' });
|
|
141
|
+
await server.app.inject({ method: 'DELETE', url: '/api/strands/b?confirm=1' });
|
|
142
|
+
await server.app.inject({ method: 'DELETE', url: '/api/strands/c?confirm=true' });
|
|
143
|
+
expect(strands.calls).toEqual([
|
|
144
|
+
{ strandId: 'a', confirm: false },
|
|
145
|
+
{ strandId: 'b', confirm: true },
|
|
146
|
+
{ strandId: 'c', confirm: true },
|
|
147
|
+
]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('treats a guessy confirm value as NOT confirmed', async () => {
|
|
151
|
+
// `yes`/`on`/empty are refused deliberately — a typo must not destroy a
|
|
152
|
+
// closed strand's membership key.
|
|
153
|
+
await server.app.inject({ method: 'DELETE', url: '/api/strands/a?confirm=yes' });
|
|
154
|
+
await server.app.inject({ method: 'DELETE', url: '/api/strands/b?confirm=' });
|
|
155
|
+
expect(strands.calls).toEqual([
|
|
156
|
+
{ strandId: 'a', confirm: false },
|
|
157
|
+
{ strandId: 'b', confirm: false },
|
|
158
|
+
]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('ignores query params other than confirm rather than proxying them', async () => {
|
|
162
|
+
await server.app.inject({ method: 'DELETE', url: '/api/strands/a?force=1&confirm=1' });
|
|
163
|
+
expect(strands.calls).toEqual([{ strandId: 'a', confirm: true }]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// The route is one path segment, so an id holding a `/` has to arrive
|
|
167
|
+
// percent-encoded. Fastify decodes `params`, so the service sees the literal id
|
|
168
|
+
// and forwards it — nothing in this chain needs to refuse such an id.
|
|
169
|
+
it('carries a percent-encoded "/" through to the service as a literal', async () => {
|
|
170
|
+
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/ns%2Fstrand' });
|
|
171
|
+
expect(res.statusCode).toBe(200);
|
|
172
|
+
expect(strands.calls).toEqual([{ strandId: 'ns/strand', confirm: false }]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('404s an UNencoded "/" at the router, before any handler runs', async () => {
|
|
176
|
+
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/ns/strand' });
|
|
177
|
+
expect(res.statusCode).toBe(404);
|
|
178
|
+
expect(strands.calls).toEqual([]);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('DELETE /api/strands/:id — error mapping', () => {
|
|
183
|
+
it('confirmation_required → 428 with the node message intact', async () => {
|
|
184
|
+
const message = 'Refusing to remove closed strand closed-one without confirmation: …';
|
|
185
|
+
strands.failWith = new StrandError('confirmation_required', message);
|
|
186
|
+
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/closed-one' });
|
|
187
|
+
expect(res.statusCode).toBe(428);
|
|
188
|
+
const body = res.json() as { ok: boolean; error: { code: string; message: string } };
|
|
189
|
+
expect(body.ok).toBe(false);
|
|
190
|
+
expect(body.error.code).toBe('confirmation_required');
|
|
191
|
+
expect(body.error.message).toBe(message);
|
|
192
|
+
expect(events).toEqual([]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('node_unavailable → 503', async () => {
|
|
196
|
+
strands.failWith = new StrandError('node_unavailable', 'Owner node unavailable: refused');
|
|
197
|
+
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/open-one' });
|
|
198
|
+
expect(res.statusCode).toBe(503);
|
|
199
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('node_unavailable');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('invalid_id → 400', async () => {
|
|
203
|
+
strands.failWith = new StrandError('invalid_id', 'A strand id is required');
|
|
204
|
+
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/%20' });
|
|
205
|
+
expect(res.statusCode).toBe(400);
|
|
206
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('invalid_id');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('internal → 500', async () => {
|
|
210
|
+
strands.failWith = new StrandError('internal', 'node said something new');
|
|
211
|
+
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/open-one' });
|
|
212
|
+
expect(res.statusCode).toBe(500);
|
|
213
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('internal');
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('donor-only mode (no strands option)', () => {
|
|
218
|
+
beforeEach(async () => {
|
|
219
|
+
await server.app.close();
|
|
220
|
+
buildServer({ withStrands: false });
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('404s GET /api/strands through the static not-found handler', async () => {
|
|
224
|
+
const res = await server.app.inject({ method: 'GET', url: '/api/strands' });
|
|
225
|
+
expect(res.statusCode).toBe(404);
|
|
226
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('not_found');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('404s DELETE /api/strands/:id', async () => {
|
|
230
|
+
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/open-one' });
|
|
231
|
+
expect(res.statusCode).toBe(404);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Fastify error handler — translates known typed errors to stable HTTP status
|
|
3
3
|
* codes and uniform `{ ok: false, error: { code, message } }` payloads.
|
|
4
4
|
*
|
|
5
|
-
* Codes (cross-referenced with `auth/types.ts`, `nat/types.ts`, `
|
|
5
|
+
* Codes (cross-referenced with `auth/types.ts`, `nat/types.ts`, `strands/types.ts`,
|
|
6
|
+
* `update/types.ts`):
|
|
6
7
|
* - TrustCircleError
|
|
7
8
|
* invalid_label, invalid_token → 400
|
|
8
9
|
* not_found, already_redeemed → 404
|
|
@@ -16,6 +17,11 @@
|
|
|
16
17
|
* mapping_failed, router_unreachable,
|
|
17
18
|
* ip_detection_failed, ddns_update_failed → 500
|
|
18
19
|
* node_unavailable → 503
|
|
20
|
+
* - StrandError
|
|
21
|
+
* invalid_id → 400
|
|
22
|
+
* confirmation_required → 428
|
|
23
|
+
* node_unavailable → 503
|
|
24
|
+
* internal → 500
|
|
19
25
|
* - UpdateErrorException
|
|
20
26
|
* manifest_invalid, no_update_available,
|
|
21
27
|
* min_previous_version → 400
|
|
@@ -45,6 +51,7 @@ import debug from 'debug';
|
|
|
45
51
|
|
|
46
52
|
import { TrustCircleError, type TrustCircleErrorCode } from '../auth/types.js';
|
|
47
53
|
import { NatError, type NatErrorCode } from '../nat/types.js';
|
|
54
|
+
import { StrandError, type StrandErrorCode } from '../strands/types.js';
|
|
48
55
|
import { UpdateErrorException, type UpdateErrorCode } from '../update/types.js';
|
|
49
56
|
import { GrantError, type GrantErrorCode, DonationError, type DonationErrorCode } from '../donation/types.js';
|
|
50
57
|
|
|
@@ -73,6 +80,19 @@ const NAT_STATUS: Record<NatErrorCode, number> = {
|
|
|
73
80
|
node_unavailable: 503,
|
|
74
81
|
};
|
|
75
82
|
|
|
83
|
+
/**
|
|
84
|
+
* 428 (Precondition Required) for `confirmation_required`: the node refused a
|
|
85
|
+
* closed-strand removal because the caller had not confirmed. It is not a 400
|
|
86
|
+
* (the request was well-formed) and not a 409 (nothing conflicts) — the caller
|
|
87
|
+
* must re-send with the precondition satisfied.
|
|
88
|
+
*/
|
|
89
|
+
const STRAND_STATUS: Record<StrandErrorCode, number> = {
|
|
90
|
+
invalid_id: 400,
|
|
91
|
+
confirmation_required: 428,
|
|
92
|
+
node_unavailable: 503,
|
|
93
|
+
internal: 500,
|
|
94
|
+
};
|
|
95
|
+
|
|
76
96
|
const GRANT_STATUS: Record<GrantErrorCode, number> = {
|
|
77
97
|
invalid_label: 400,
|
|
78
98
|
invalid_max_nodes: 400,
|
|
@@ -134,6 +154,9 @@ function classify(err: FastifyError): Classified {
|
|
|
134
154
|
if (err instanceof NatError) {
|
|
135
155
|
return { status: NAT_STATUS[err.code] ?? 500, code: err.code, message: err.message };
|
|
136
156
|
}
|
|
157
|
+
if (err instanceof StrandError) {
|
|
158
|
+
return { status: STRAND_STATUS[err.code] ?? 500, code: err.code, message: err.message };
|
|
159
|
+
}
|
|
137
160
|
if (err instanceof UpdateErrorException) {
|
|
138
161
|
return { status: UPDATE_STATUS[err.code] ?? 500, code: err.code, message: err.message };
|
|
139
162
|
}
|
|
@@ -12,6 +12,7 @@ import type { DirectReachability, PortForwardMode } from '../../nat/index.js';
|
|
|
12
12
|
export type LocalUiEvent =
|
|
13
13
|
| { type: 'node-state-changed'; nodeId: string; status: ContainerStatus }
|
|
14
14
|
| { type: 'trust-circle-changed'; kind: 'invited' | 'redeemed' | 'revoked' }
|
|
15
|
+
| { type: 'strands-changed'; kind: 'removed' }
|
|
15
16
|
| {
|
|
16
17
|
type: 'connectivity-changed';
|
|
17
18
|
portMode: PortForwardMode;
|
package/src/server/index.ts
CHANGED
|
@@ -20,6 +20,8 @@ import type { TrustCircleService } from '../auth/index.js';
|
|
|
20
20
|
import { createTrustCircleHandlers } from '../auth/index.js';
|
|
21
21
|
import type { NatService } from '../nat/index.js';
|
|
22
22
|
import { createNatHandlers } from '../nat/index.js';
|
|
23
|
+
import type { StrandService } from '../strands/index.js';
|
|
24
|
+
import { createStrandHandlers } from '../strands/index.js';
|
|
23
25
|
import type { UpdateService } from '../update/index.js';
|
|
24
26
|
import { createUpdateHandlers } from '../update/index.js';
|
|
25
27
|
import type { GrantService, DonationService } from '../donation/index.js';
|
|
@@ -33,6 +35,7 @@ import { registerStaticMount } from './static.js';
|
|
|
33
35
|
import { buildFastify, startListening, stopListening } from './server.js';
|
|
34
36
|
import { registerNatRoutes } from './routes/nat.js';
|
|
35
37
|
import { registerTrustCircleRoutes } from './routes/trust-circle.js';
|
|
38
|
+
import { registerStrandRoutes } from './routes/strands.js';
|
|
36
39
|
import { registerUpdateRoutes } from './routes/update.js';
|
|
37
40
|
import { registerStatusRoute } from './routes/status.js';
|
|
38
41
|
import { registerNodesRoutes } from './routes/nodes.js';
|
|
@@ -60,6 +63,12 @@ export interface LocalUiServerOptions {
|
|
|
60
63
|
* unmounted and 404s.
|
|
61
64
|
*/
|
|
62
65
|
nat?: NatService;
|
|
66
|
+
/**
|
|
67
|
+
* Strand service — present only when the host runs its own personal cadre.
|
|
68
|
+
* Absent in donor-only mode, where there is no owner node to ask, so
|
|
69
|
+
* `/api/strands` stays unmounted and 404s through the static handler.
|
|
70
|
+
*/
|
|
71
|
+
strands?: StrandService;
|
|
63
72
|
/** Optional — 6.4.2 lands this; nullable while still iterating. */
|
|
64
73
|
update?: UpdateService;
|
|
65
74
|
/**
|
|
@@ -133,15 +142,19 @@ export function createLocalUiServer(opts: LocalUiServerOptions): LocalUiServer {
|
|
|
133
142
|
registerNodesRoutes(app, { orchestrator: opts.orchestrator });
|
|
134
143
|
registerSettingsRoutes(app, { settingsStore, ...(opts.nat ? { nat: opts.nat } : {}), ...(opts.update ? { update: opts.update } : {}) });
|
|
135
144
|
|
|
136
|
-
// Trust-circle + NAT surfaces exist only when the host runs its own
|
|
137
|
-
// cadre. In donor-only mode they're left unmounted, so `/auth
|
|
138
|
-
// fall through to the static not-found handler and
|
|
145
|
+
// Trust-circle + NAT + strand surfaces exist only when the host runs its own
|
|
146
|
+
// personal cadre. In donor-only mode they're left unmounted, so `/auth/*`,
|
|
147
|
+
// `/nat/*` and `/api/strands` fall through to the static not-found handler and
|
|
148
|
+
// 404 (see static.ts).
|
|
139
149
|
if (opts.trustCircle) {
|
|
140
150
|
registerTrustCircleRoutes(app, { handlers: createTrustCircleHandlers(opts.trustCircle), events });
|
|
141
151
|
}
|
|
142
152
|
if (opts.nat) {
|
|
143
153
|
registerNatRoutes(app, { handlers: createNatHandlers(opts.nat), events });
|
|
144
154
|
}
|
|
155
|
+
if (opts.strands) {
|
|
156
|
+
registerStrandRoutes(app, { handlers: createStrandHandlers(opts.strands), events });
|
|
157
|
+
}
|
|
145
158
|
if (opts.update) {
|
|
146
159
|
registerUpdateRoutes(app, { handlers: createUpdateHandlers(opts.update), events });
|
|
147
160
|
}
|
|
@@ -119,6 +119,12 @@ export function registerGrantsRoutes(app: FastifyInstance, opts: GrantsRoutesOpt
|
|
|
119
119
|
if (!Array.isArray(body.ownerKeys) || body.ownerKeys.length === 0) {
|
|
120
120
|
return errorResponse(reply, 'invalid_request', 'ownerKeys is required', 400);
|
|
121
121
|
}
|
|
122
|
+
// Element types too, not just the array: `provision`'s shape check trims each
|
|
123
|
+
// entry, so a non-string reaches the caller as an internal `.trim is not a
|
|
124
|
+
// function` rather than as a message naming what is wrong with the request.
|
|
125
|
+
if (!body.ownerKeys.every(key => typeof key === 'string')) {
|
|
126
|
+
return errorResponse(reply, 'invalid_request', 'ownerKeys must be an array of strings', 400);
|
|
127
|
+
}
|
|
122
128
|
if (body.profile !== undefined && body.profile !== 'storage' && body.profile !== 'transaction') {
|
|
123
129
|
return errorResponse(reply, 'invalid_request', 'profile must be "storage" or "transaction"', 400);
|
|
124
130
|
}
|
|
@@ -169,10 +175,28 @@ export function registerGrantsRoutes(app: FastifyInstance, opts: GrantsRoutesOpt
|
|
|
169
175
|
return errorResponse(reply, 'invalid_request', 'seed is required', 400);
|
|
170
176
|
}
|
|
171
177
|
const result = await donations.applySeed(request.params.id, body.seed);
|
|
172
|
-
|
|
173
|
-
|
|
178
|
+
// NOTE: exhaustive over today's three outcomes, and nothing forces it to stay
|
|
179
|
+
// that way — an unhandled fourth would fall out of the handler as `undefined`
|
|
180
|
+
// and hang the request rather than fail to compile. If `DonationSeedResult`
|
|
181
|
+
// ever grows a variant, add a `default:` that 500s here.
|
|
182
|
+
switch (result.outcome) {
|
|
183
|
+
case 'rejected':
|
|
184
|
+
return errorResponse(reply, 'seed_failed', result.error ?? 'Node rejected the seed', 502);
|
|
185
|
+
// The loan ended while the seed was in flight. The ending won, so the
|
|
186
|
+
// caller is told the record it seeded is no longer seedable (409) — or is
|
|
187
|
+
// gone entirely (404) — rather than a 200 that implies a live node.
|
|
188
|
+
case 'abandoned':
|
|
189
|
+
return result.status
|
|
190
|
+
? errorResponse(
|
|
191
|
+
reply,
|
|
192
|
+
'invalid_state',
|
|
193
|
+
`Donation ${request.params.id} ended (${result.status}) while the seed was in flight`,
|
|
194
|
+
409,
|
|
195
|
+
)
|
|
196
|
+
: errorResponse(reply, 'not_found', `No such donation: ${request.params.id}`, 404);
|
|
197
|
+
case 'seeded':
|
|
198
|
+
return reply.send({ ok: true, data: { peersAdded: result.peersAdded } });
|
|
174
199
|
}
|
|
175
|
-
return reply.send({ ok: true, data: { peersAdded: result.peersAdded ?? 0 } });
|
|
176
200
|
});
|
|
177
201
|
|
|
178
202
|
// DELETE /grants/:id — terminate the donated node.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strand HTTP routes — thin adapters around `StrandHandlers`
|
|
3
|
+
* (`createStrandHandlers(service)`).
|
|
4
|
+
*
|
|
5
|
+
* Mount path: `/api/strands` — a UI-only surface with no CLI contract to
|
|
6
|
+
* preserve. (The `/auth/*` and `/nat/*` mounts sit outside `/api` only because
|
|
7
|
+
* the existing CLI already targets those URLs.) Envelope form therefore follows
|
|
8
|
+
* the other `/api/*` routes: `{ ok: true, data }`.
|
|
9
|
+
*
|
|
10
|
+
* A removal that actually issued a delete publishes `strands-changed` so other
|
|
11
|
+
* open tabs refresh. A call that found no row publishes nothing — nothing changed.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { FastifyInstance } from 'fastify';
|
|
15
|
+
|
|
16
|
+
import type { StrandHandlers } from '../../strands/types.js';
|
|
17
|
+
import type { EventBus } from '../events/bus.js';
|
|
18
|
+
|
|
19
|
+
export interface StrandRoutesOptions {
|
|
20
|
+
handlers: StrandHandlers;
|
|
21
|
+
events: EventBus;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The only accepted `confirm` values — the same strict pair the admin channel
|
|
26
|
+
* accepts. Not a general truthiness parser: `yes`, `on` and an empty value all
|
|
27
|
+
* count as NOT confirmed, because a guessy parser here would turn a typo into a
|
|
28
|
+
* destroyed membership key.
|
|
29
|
+
*
|
|
30
|
+
* NOTE: deliberately duplicated from cadre-cli's `admin-server.ts` rather than
|
|
31
|
+
* imported — cadre-host depends on cadre-cli for its bin path only, never its
|
|
32
|
+
* source. Keep the two in step by hand; do not DRY them into an import.
|
|
33
|
+
*/
|
|
34
|
+
const CONFIRM_VALUES = new Set(['1', 'true']);
|
|
35
|
+
|
|
36
|
+
export function registerStrandRoutes(app: FastifyInstance, opts: StrandRoutesOptions): void {
|
|
37
|
+
const { handlers, events } = opts;
|
|
38
|
+
|
|
39
|
+
app.get('/api/strands', async () => {
|
|
40
|
+
return { ok: true, data: await handlers.listStrands() };
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
app.delete<{ Params: { id: string }; Querystring: { confirm?: string } }>(
|
|
44
|
+
'/api/strands/:id',
|
|
45
|
+
async (request) => {
|
|
46
|
+
const confirm = typeof request.query.confirm === 'string' && CONFIRM_VALUES.has(request.query.confirm);
|
|
47
|
+
const result = await handlers.removeStrand(request.params.id, { confirm });
|
|
48
|
+
if (result.removed) {
|
|
49
|
+
events.publish({ type: 'strands-changed', kind: 'removed' });
|
|
50
|
+
}
|
|
51
|
+
return { ok: true, data: result };
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
}
|