@serfab/cadre-host 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/trust-circle.d.ts +5 -5
- package/dist/auth/trust-circle.js +10 -10
- package/dist/auth/trust-circle.js.map +1 -1
- package/dist/auth/types.d.ts +2 -2
- package/dist/bin/host.js +255 -54
- package/dist/bin/host.js.map +1 -1
- package/dist/donation/donation-service.d.ts +136 -0
- package/dist/donation/donation-service.js +306 -0
- package/dist/donation/donation-service.js.map +1 -0
- package/dist/donation/donation-store.d.ts +42 -0
- package/dist/donation/donation-store.js +141 -0
- package/dist/donation/donation-store.js.map +1 -0
- package/dist/donation/grant-service.d.ts +71 -0
- package/dist/donation/grant-service.js +163 -0
- package/dist/donation/grant-service.js.map +1 -0
- package/dist/donation/grant-store.d.ts +38 -0
- package/dist/donation/grant-store.js +135 -0
- package/dist/donation/grant-store.js.map +1 -0
- package/dist/donation/index.d.ts +18 -0
- package/dist/donation/index.js +16 -0
- package/dist/donation/index.js.map +1 -0
- package/dist/donation/types.d.ts +175 -0
- package/dist/donation/types.js +40 -0
- package/dist/donation/types.js.map +1 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/installer/config.d.ts +26 -0
- package/dist/installer/config.js +13 -0
- package/dist/installer/config.js.map +1 -1
- package/dist/installer/index.d.ts +2 -0
- package/dist/installer/index.js +3 -0
- package/dist/installer/index.js.map +1 -1
- package/dist/installer/wizard.d.ts +4 -0
- package/dist/installer/wizard.js +5 -1
- package/dist/installer/wizard.js.map +1 -1
- package/dist/nat/nat-service.d.ts +5 -5
- package/dist/nat/nat-service.js +9 -9
- package/dist/nat/nat-service.js.map +1 -1
- package/dist/nat/types.d.ts +1 -1
- package/dist/orchestrator/host-process-orchestrator.d.ts +35 -27
- package/dist/orchestrator/host-process-orchestrator.js +79 -53
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
- package/dist/orchestrator/index.d.ts +3 -3
- package/dist/orchestrator/index.js +1 -1
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/state-store.d.ts +5 -5
- package/dist/orchestrator/state-store.js +2 -2
- package/dist/orchestrator/state-store.js.map +1 -1
- package/dist/orchestrator/types.d.ts +8 -8
- package/dist/owner/index.d.ts +11 -0
- package/dist/owner/index.js +11 -0
- package/dist/owner/index.js.map +1 -0
- package/dist/{authority/authority-node-client.d.ts → owner/owner-node-client.d.ts} +32 -14
- package/dist/{authority/authority-node-client.js → owner/owner-node-client.js} +39 -15
- package/dist/owner/owner-node-client.js.map +1 -0
- package/dist/server/error-handler.d.ts +15 -0
- package/dist/server/error-handler.js +41 -0
- package/dist/server/error-handler.js.map +1 -1
- package/dist/server/index.d.ts +26 -2
- package/dist/server/index.js +37 -16
- package/dist/server/index.js.map +1 -1
- package/dist/server/routes/grants-admin.d.ts +17 -0
- package/dist/server/routes/grants-admin.js +32 -0
- package/dist/server/routes/grants-admin.js.map +1 -0
- package/dist/server/routes/grants.d.ts +28 -0
- package/dist/server/routes/grants.js +163 -0
- package/dist/server/routes/grants.js.map +1 -0
- package/dist/server/routes/nodes.d.ts +5 -4
- package/dist/server/routes/nodes.js +19 -18
- package/dist/server/routes/nodes.js.map +1 -1
- package/dist/server/routes/settings.d.ts +6 -1
- package/dist/server/routes/settings.js +5 -2
- package/dist/server/routes/settings.js.map +1 -1
- package/dist/server/routes/status.d.ts +8 -4
- package/dist/server/routes/status.js +9 -4
- package/dist/server/routes/status.js.map +1 -1
- package/dist/update/release-key.js +7 -5
- package/dist/update/release-key.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/{host-authority.smoke.test.ts → host-owner.smoke.test.ts} +7 -7
- package/src/__tests__/{orchestrator-authority.test.ts → orchestrator-owner.test.ts} +33 -33
- package/src/__tests__/orchestrator-pin-keys.test.ts +153 -0
- package/src/__tests__/orchestrator-push.test.ts +10 -10
- package/src/auth/__tests__/trust-circle-integration.test.ts +11 -11
- package/src/auth/__tests__/trust-circle.test.ts +10 -10
- package/src/auth/trust-circle.ts +10 -10
- package/src/auth/types.ts +2 -2
- package/src/bin/host.ts +273 -53
- package/src/donation/__tests__/donation-service.test.ts +290 -0
- package/src/donation/__tests__/grant-service.test.ts +213 -0
- package/src/donation/__tests__/grant-store.test.ts +113 -0
- package/src/donation/donation-service.ts +392 -0
- package/src/donation/donation-store.ts +164 -0
- package/src/donation/grant-service.ts +200 -0
- package/src/donation/grant-store.ts +152 -0
- package/src/donation/index.ts +49 -0
- package/src/donation/types.ts +220 -0
- package/src/index.ts +37 -5
- package/src/installer/__tests__/config.test.ts +27 -1
- package/src/installer/__tests__/installer.smoke.test.ts +31 -1
- package/src/installer/__tests__/wizard.test.ts +5 -1
- package/src/installer/config.ts +34 -0
- package/src/installer/index.ts +5 -0
- package/src/installer/wizard.ts +12 -1
- package/src/nat/__tests__/nat-service.test.ts +5 -5
- package/src/nat/nat-service.ts +10 -10
- package/src/nat/types.ts +1 -1
- package/src/orchestrator/host-process-orchestrator.ts +88 -60
- package/src/orchestrator/index.ts +3 -3
- package/src/orchestrator/state-store.ts +7 -7
- package/src/orchestrator/types.ts +8 -8
- package/src/{authority/__tests__/authority-node-client.test.ts → owner/__tests__/owner-node-client.test.ts} +45 -17
- package/src/owner/index.ts +12 -0
- package/src/{authority/authority-node-client.ts → owner/owner-node-client.ts} +55 -21
- package/src/server/__tests__/error-handler.test.ts +22 -0
- package/src/server/__tests__/grants-admin-route.test.ts +73 -0
- package/src/server/__tests__/grants-route.test.ts +189 -0
- package/src/server/__tests__/nodes-route.test.ts +25 -25
- package/src/server/__tests__/server.smoke.test.ts +75 -0
- package/src/server/__tests__/settings-route.test.ts +17 -0
- package/src/server/__tests__/status-route.test.ts +21 -0
- package/src/server/error-handler.ts +43 -0
- package/src/server/index.ts +62 -17
- package/src/server/routes/grants-admin.ts +41 -0
- package/src/server/routes/grants.ts +186 -0
- package/src/server/routes/nodes.ts +19 -18
- package/src/server/routes/settings.ts +11 -3
- package/src/server/routes/status.ts +18 -8
- package/src/update/__tests__/release-key.test.ts +16 -3
- package/src/update/release-key.ts +7 -5
- package/dist/authority/authority-node-client.js.map +0 -1
- package/dist/authority/index.d.ts +0 -11
- package/dist/authority/index.js +0 -11
- package/dist/authority/index.js.map +0 -1
- package/src/authority/index.ts +0 -12
|
@@ -1,69 +1,70 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* HTTP client for the
|
|
2
|
+
* HTTP client for the owner node's loopback admin channel (the 6.6
|
|
3
3
|
* `cadre-node-admin-channel` contract).
|
|
4
4
|
*
|
|
5
5
|
* cadre-host's manager process holds no in-process `CadreNode`; it spawns the
|
|
6
|
-
* admin's
|
|
7
|
-
* and delegates all
|
|
6
|
+
* admin's owner node as a managed child (see `HostProcessOrchestrator`)
|
|
7
|
+
* and delegates all owner/membership/identity operations to it over this
|
|
8
8
|
* client. The client implements **both** trimmed `CadreNodeLike` interfaces —
|
|
9
9
|
* the trust-circle one (`auth/trust-circle.ts`) and the NAT one
|
|
10
|
-
* (`nat/nat-service.ts`) — plus `pushInviteAddresses
|
|
10
|
+
* (`nat/nat-service.ts`) — plus `pushInviteAddresses` and `addDrone` (the
|
|
11
|
+
* node-donation requester side).
|
|
11
12
|
*
|
|
12
13
|
* Transport: `Authorization: Bearer <token>` against
|
|
13
14
|
* `http://127.0.0.1:<adminPort>`. Every response uses the cadre-provider
|
|
14
15
|
* envelope `{ ok: true, data }` / `{ ok: false, error: { code, message } }`.
|
|
15
16
|
* A refused connection, a transport error, or any non-2xx response surfaces
|
|
16
|
-
* as {@link
|
|
17
|
+
* as {@link OwnerNodeUnavailableError}; the trust-circle / NAT services
|
|
17
18
|
* translate that into a `node_unavailable` domain error so the management API
|
|
18
19
|
* returns a clear 503 rather than a raw 500.
|
|
19
20
|
*/
|
|
20
21
|
|
|
21
22
|
import debug from 'debug';
|
|
22
|
-
import type { CadreInvite } from '@serfab/cadre-core';
|
|
23
|
+
import type { CadreInvite, DroneInitResult } from '@serfab/cadre-core';
|
|
23
24
|
|
|
24
|
-
import type {
|
|
25
|
+
import type { OwnerAdminEndpoint } from '../orchestrator/index.js';
|
|
25
26
|
import type { CadreNodeLike as TrustCircleCadreNodeLike } from '../auth/trust-circle.js';
|
|
26
27
|
import type { CadreNodeLike as NatCadreNodeLike } from '../nat/nat-service.js';
|
|
27
28
|
|
|
28
|
-
const log = debug('cadre:host:
|
|
29
|
+
const log = debug('cadre:host:owner-client');
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
|
-
* Raised when the
|
|
32
|
+
* Raised when the owner node's admin channel can't be reached or returns
|
|
32
33
|
* a non-success response. Carries the node's stable error `code` when one was
|
|
33
34
|
* present in the envelope (e.g. `not_ready`, `not_authorized`).
|
|
34
35
|
*/
|
|
35
|
-
export class
|
|
36
|
+
export class OwnerNodeUnavailableError extends Error {
|
|
36
37
|
/** The admin-channel error code, when the node answered with an envelope. */
|
|
37
38
|
readonly nodeCode?: string;
|
|
38
39
|
|
|
39
40
|
constructor(message: string, nodeCode?: string) {
|
|
40
41
|
super(message);
|
|
41
|
-
this.name = '
|
|
42
|
+
this.name = 'OwnerNodeUnavailableError';
|
|
42
43
|
if (nodeCode !== undefined) this.nodeCode = nodeCode;
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
/** Endpoint provided directly or via a getter (so it tracks re-spawns). */
|
|
47
48
|
type EndpointSource =
|
|
48
|
-
|
|
|
49
|
-
| (() =>
|
|
49
|
+
| OwnerAdminEndpoint
|
|
50
|
+
| (() => OwnerAdminEndpoint | undefined)
|
|
50
51
|
| undefined;
|
|
51
52
|
|
|
52
|
-
export interface
|
|
53
|
+
export interface OwnerNodeClientOptions {
|
|
53
54
|
/** Fetch override for tests. Defaults to the global `fetch`. */
|
|
54
55
|
fetch?: typeof fetch;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
export class
|
|
58
|
+
export class OwnerNodeClient implements TrustCircleCadreNodeLike, NatCadreNodeLike {
|
|
58
59
|
private readonly endpointSource: EndpointSource;
|
|
59
60
|
private readonly fetchImpl: typeof fetch;
|
|
60
61
|
|
|
61
62
|
/**
|
|
62
63
|
* @param endpoint Either a fixed `{ baseUrl, token }` or a getter returning
|
|
63
|
-
* the current endpoint (pass `orchestrator.
|
|
64
|
+
* the current endpoint (pass `orchestrator.getOwnerAdminEndpoint`
|
|
64
65
|
* bound, so a restart's new bearer token is picked up automatically).
|
|
65
66
|
*/
|
|
66
|
-
constructor(endpoint: EndpointSource, opts:
|
|
67
|
+
constructor(endpoint: EndpointSource, opts: OwnerNodeClientOptions = {}) {
|
|
67
68
|
this.endpointSource = endpoint;
|
|
68
69
|
this.fetchImpl = opts.fetch ?? fetch;
|
|
69
70
|
}
|
|
@@ -123,6 +124,23 @@ export class AuthorityNodeClient implements TrustCircleCadreNodeLike, NatCadreNo
|
|
|
123
124
|
return data.member;
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
/** Authorized-membership surface (excludes self) — see {@link listMembers} for the addressable one. */
|
|
128
|
+
async listAuthorizedMembers(): Promise<Array<{ peerId: string; multiaddr: string | null }>> {
|
|
129
|
+
const data = await this.request<{ members: Array<{ peerId: string; multiaddr: string | null }> }>(
|
|
130
|
+
'GET',
|
|
131
|
+
'/admin/authorized-members',
|
|
132
|
+
);
|
|
133
|
+
return data.members;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async isAuthorizedMember(peerId: string): Promise<boolean> {
|
|
137
|
+
const data = await this.request<{ member: boolean }>(
|
|
138
|
+
'GET',
|
|
139
|
+
`/admin/authorized-members/${encodeURIComponent(peerId)}`,
|
|
140
|
+
);
|
|
141
|
+
return data.member;
|
|
142
|
+
}
|
|
143
|
+
|
|
126
144
|
// --- NAT CadreNodeLike ---
|
|
127
145
|
|
|
128
146
|
async getPeerId(): Promise<string> {
|
|
@@ -135,6 +153,22 @@ export class AuthorityNodeClient implements TrustCircleCadreNodeLike, NatCadreNo
|
|
|
135
153
|
return data.multiaddrs;
|
|
136
154
|
}
|
|
137
155
|
|
|
156
|
+
// --- drone seeding (node-donation requester side) ---
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Mint a seed authorizing a drone — a provider-hosted / donated node — to join
|
|
160
|
+
* this node's cadre. The node signs the seed with its own authority key; only
|
|
161
|
+
* the signed, public `encodedSeed` is returned (the private key never leaves
|
|
162
|
+
* the node). This is the requester ("phone") side of the node-donation flow:
|
|
163
|
+
* the donor host presents the returned seed to the donated node's `POST /seed`.
|
|
164
|
+
*/
|
|
165
|
+
async addDrone(options: { dronePeerId: string; droneMultiaddrs: string[] }): Promise<DroneInitResult> {
|
|
166
|
+
return await this.request<DroneInitResult>('POST', '/admin/add-drone', {
|
|
167
|
+
dronePeerId: options.dronePeerId,
|
|
168
|
+
droneMultiaddrs: options.droneMultiaddrs,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
138
172
|
// --- push-model invite addresses ---
|
|
139
173
|
|
|
140
174
|
/** Push the NAT-resolved addresses the node should embed in future invites. */
|
|
@@ -144,10 +178,10 @@ export class AuthorityNodeClient implements TrustCircleCadreNodeLike, NatCadreNo
|
|
|
144
178
|
|
|
145
179
|
// --- internals ---
|
|
146
180
|
|
|
147
|
-
private resolveEndpoint():
|
|
181
|
+
private resolveEndpoint(): OwnerAdminEndpoint {
|
|
148
182
|
const ep = typeof this.endpointSource === 'function' ? this.endpointSource() : this.endpointSource;
|
|
149
183
|
if (!ep) {
|
|
150
|
-
throw new
|
|
184
|
+
throw new OwnerNodeUnavailableError('Owner node admin endpoint is not available');
|
|
151
185
|
}
|
|
152
186
|
return ep;
|
|
153
187
|
}
|
|
@@ -167,7 +201,7 @@ export class AuthorityNodeClient implements TrustCircleCadreNodeLike, NatCadreNo
|
|
|
167
201
|
} catch (err) {
|
|
168
202
|
const message = err instanceof Error ? err.message : String(err);
|
|
169
203
|
log('admin %s %s transport error: %s', method, path, message);
|
|
170
|
-
throw new
|
|
204
|
+
throw new OwnerNodeUnavailableError(`Owner node unreachable: ${message}`);
|
|
171
205
|
}
|
|
172
206
|
|
|
173
207
|
const envelope = await this.parseEnvelope(res);
|
|
@@ -175,7 +209,7 @@ export class AuthorityNodeClient implements TrustCircleCadreNodeLike, NatCadreNo
|
|
|
175
209
|
const code = envelope.error?.code;
|
|
176
210
|
const message = envelope.error?.message ?? `admin ${method} ${path} → HTTP ${res.status}`;
|
|
177
211
|
log('admin %s %s failed: [%s] %s', method, path, code ?? res.status, message);
|
|
178
|
-
throw new
|
|
212
|
+
throw new OwnerNodeUnavailableError(message, code);
|
|
179
213
|
}
|
|
180
214
|
return envelope.data as T;
|
|
181
215
|
}
|
|
@@ -4,6 +4,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
|
4
4
|
import { TrustCircleError } from '../../auth/types.js';
|
|
5
5
|
import { NatError } from '../../nat/types.js';
|
|
6
6
|
import { UpdateErrorException } from '../../update/types.js';
|
|
7
|
+
import { GrantError } from '../../donation/types.js';
|
|
7
8
|
import { registerErrorHandler } from '../error-handler.js';
|
|
8
9
|
|
|
9
10
|
describe('error handler', () => {
|
|
@@ -24,6 +25,10 @@ describe('error handler', () => {
|
|
|
24
25
|
const { code } = req.params as { code: string };
|
|
25
26
|
throw new UpdateErrorException(code as never, `update err: ${code}`);
|
|
26
27
|
});
|
|
28
|
+
app.get('/grant/:code', async (req: FastifyRequest) => {
|
|
29
|
+
const { code } = req.params as { code: string };
|
|
30
|
+
throw new GrantError(code as never, `grant err: ${code}`);
|
|
31
|
+
});
|
|
27
32
|
app.get('/unknown', async () => {
|
|
28
33
|
throw new Error('mystery');
|
|
29
34
|
});
|
|
@@ -85,6 +90,23 @@ describe('error handler', () => {
|
|
|
85
90
|
expect(res.statusCode).toBe(400);
|
|
86
91
|
});
|
|
87
92
|
|
|
93
|
+
it('GrantError invalid_max_nodes → 400', async () => {
|
|
94
|
+
const res = await app.inject({ method: 'GET', url: '/grant/invalid_max_nodes' });
|
|
95
|
+
expect(res.statusCode).toBe(400);
|
|
96
|
+
const body = res.json() as { error: { code: string } };
|
|
97
|
+
expect(body.error.code).toBe('invalid_max_nodes');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('GrantError not_found → 404', async () => {
|
|
101
|
+
const res = await app.inject({ method: 'GET', url: '/grant/not_found' });
|
|
102
|
+
expect(res.statusCode).toBe(404);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('GrantError storage_error → 500', async () => {
|
|
106
|
+
const res = await app.inject({ method: 'GET', url: '/grant/storage_error' });
|
|
107
|
+
expect(res.statusCode).toBe(500);
|
|
108
|
+
});
|
|
109
|
+
|
|
88
110
|
it('unknown Error → 500 internal', async () => {
|
|
89
111
|
const res = await app.inject({ method: 'GET', url: '/unknown' });
|
|
90
112
|
expect(res.statusCode).toBe(500);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import Fastify from 'fastify';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { registerErrorHandler } from '../error-handler.js';
|
|
8
|
+
import { registerGrantsAdminRoutes } from '../routes/grants-admin.js';
|
|
9
|
+
import { GrantService, GrantStore, createGrantAdminHandlers } from '../../donation/index.js';
|
|
10
|
+
import type { Grant } from '../../donation/index.js';
|
|
11
|
+
|
|
12
|
+
let tmpRoot: string;
|
|
13
|
+
let app: ReturnType<typeof Fastify>;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-grants-route-'));
|
|
17
|
+
const service = new GrantService({ store: new GrantStore(tmpRoot) });
|
|
18
|
+
app = Fastify();
|
|
19
|
+
registerErrorHandler(app);
|
|
20
|
+
registerGrantsAdminRoutes(app, { handlers: createGrantAdminHandlers(service) });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(async () => {
|
|
24
|
+
await app.close();
|
|
25
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('/grants-admin routes', () => {
|
|
29
|
+
it('POST issues a grant, GET lists it, DELETE revokes it', async () => {
|
|
30
|
+
const post = await app.inject({
|
|
31
|
+
method: 'POST',
|
|
32
|
+
url: '/grants-admin',
|
|
33
|
+
payload: { label: "Alice's cadre", maxNodes: 2 },
|
|
34
|
+
});
|
|
35
|
+
expect(post.statusCode).toBe(200);
|
|
36
|
+
const created = (post.json() as { grant: Grant }).grant;
|
|
37
|
+
expect(created.token).toBeTruthy();
|
|
38
|
+
expect(created.maxNodes).toBe(2);
|
|
39
|
+
|
|
40
|
+
const list = await app.inject({ method: 'GET', url: '/grants-admin' });
|
|
41
|
+
expect(list.statusCode).toBe(200);
|
|
42
|
+
expect((list.json() as { grants: Grant[] }).grants.map(g => g.token)).toContain(created.token);
|
|
43
|
+
|
|
44
|
+
const del = await app.inject({
|
|
45
|
+
method: 'DELETE',
|
|
46
|
+
url: `/grants-admin/${encodeURIComponent(created.token)}`,
|
|
47
|
+
});
|
|
48
|
+
expect(del.statusCode).toBe(200);
|
|
49
|
+
|
|
50
|
+
// Revoked → still listed, now carries revokedAt.
|
|
51
|
+
const after = await app.inject({ method: 'GET', url: '/grants-admin' });
|
|
52
|
+
const row = (after.json() as { grants: Grant[] }).grants.find(g => g.token === created.token);
|
|
53
|
+
expect(row?.revokedAt).toBeTruthy();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('POST with an empty label → 400 invalid_label', async () => {
|
|
57
|
+
const res = await app.inject({ method: 'POST', url: '/grants-admin', payload: { label: ' ' } });
|
|
58
|
+
expect(res.statusCode).toBe(400);
|
|
59
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('invalid_label');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('POST with a bad maxNodes → 400 invalid_max_nodes', async () => {
|
|
63
|
+
const res = await app.inject({ method: 'POST', url: '/grants-admin', payload: { label: 'X', maxNodes: 0 } });
|
|
64
|
+
expect(res.statusCode).toBe(400);
|
|
65
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('invalid_max_nodes');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('DELETE of an unknown token → 404 not_found', async () => {
|
|
69
|
+
const res = await app.inject({ method: 'DELETE', url: '/grants-admin/nope' });
|
|
70
|
+
expect(res.statusCode).toBe(404);
|
|
71
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('not_found');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
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 token: string;
|
|
58
|
+
|
|
59
|
+
const body = {
|
|
60
|
+
partyId: 'party-P',
|
|
61
|
+
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooReq'],
|
|
62
|
+
ownerKeys: ['owner-key-b64url'],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const bearer = (t: string) => ({ authorization: `Bearer ${t}` });
|
|
66
|
+
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-grants-svc-route-'));
|
|
69
|
+
grants = new GrantService({ store: new GrantStore(join(tmpRoot, 'grants')) });
|
|
70
|
+
token = grants.issue({ label: 'Alice', maxNodes: 2 }).token;
|
|
71
|
+
const donations = new DonationService({
|
|
72
|
+
orchestrator: new FakeOrchestrator(),
|
|
73
|
+
grants,
|
|
74
|
+
store: new DonationStore(join(tmpRoot, 'donations')),
|
|
75
|
+
});
|
|
76
|
+
app = Fastify();
|
|
77
|
+
registerErrorHandler(app);
|
|
78
|
+
registerGrantsRoutes(app, { donations, grants });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
afterEach(async () => {
|
|
82
|
+
await app.close();
|
|
83
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('POST /grants', () => {
|
|
87
|
+
it('provisions a donated node → 201, awaiting_seed, seedToken redacted', async () => {
|
|
88
|
+
const res = await app.inject({ method: 'POST', url: '/grants', headers: bearer(token), payload: body });
|
|
89
|
+
expect(res.statusCode).toBe(201);
|
|
90
|
+
const donation = (res.json() as { data: { donation: Record<string, unknown> } }).data.donation;
|
|
91
|
+
expect(donation.status).toBe('awaiting_seed');
|
|
92
|
+
expect(donation.partyId).toBe('party-P');
|
|
93
|
+
expect(donation.seedToken).toBeUndefined();
|
|
94
|
+
expect(donation.seedEndpoint).toBeUndefined();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('rejects a missing bearer → 401', async () => {
|
|
98
|
+
const res = await app.inject({ method: 'POST', url: '/grants', payload: body });
|
|
99
|
+
expect(res.statusCode).toBe(401);
|
|
100
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('unauthorized');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('rejects an unknown bearer → 401', async () => {
|
|
104
|
+
const res = await app.inject({ method: 'POST', url: '/grants', headers: bearer('nope'), payload: body });
|
|
105
|
+
expect(res.statusCode).toBe(401);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('rejects a missing partyId → 400 invalid_request', async () => {
|
|
109
|
+
const res = await app.inject({
|
|
110
|
+
method: 'POST', url: '/grants', headers: bearer(token),
|
|
111
|
+
payload: { bootstrapNodes: body.bootstrapNodes, ownerKeys: body.ownerKeys },
|
|
112
|
+
});
|
|
113
|
+
expect(res.statusCode).toBe(400);
|
|
114
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('invalid_request');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('rejects empty ownerKeys → 400 invalid_request', async () => {
|
|
118
|
+
const res = await app.inject({
|
|
119
|
+
method: 'POST', url: '/grants', headers: bearer(token),
|
|
120
|
+
payload: { ...body, ownerKeys: [] },
|
|
121
|
+
});
|
|
122
|
+
expect(res.statusCode).toBe(400);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('enforces the grant quota → 429 quota_exceeded once maxNodes is reached', async () => {
|
|
126
|
+
const g = grants.issue({ label: 'Bob', maxNodes: 1 }).token;
|
|
127
|
+
const first = await app.inject({ method: 'POST', url: '/grants', headers: bearer(g), payload: body });
|
|
128
|
+
expect(first.statusCode).toBe(201);
|
|
129
|
+
const second = await app.inject({ method: 'POST', url: '/grants', headers: bearer(g), payload: body });
|
|
130
|
+
expect(second.statusCode).toBe(429);
|
|
131
|
+
expect((second.json() as { error: { code: string } }).error.code).toBe('quota_exceeded');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('GET /grants auth', () => {
|
|
136
|
+
it('rejects an unknown bearer → 401 unauthorized (code matches the status)', async () => {
|
|
137
|
+
const res = await app.inject({ method: 'GET', url: '/grants', headers: bearer('nope') });
|
|
138
|
+
expect(res.statusCode).toBe(401);
|
|
139
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('unauthorized');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('GET /grants (ownership isolation)', () => {
|
|
144
|
+
it('lists only the donations the presented grant authorized', async () => {
|
|
145
|
+
const other = grants.issue({ label: 'Carol' }).token;
|
|
146
|
+
await app.inject({ method: 'POST', url: '/grants', headers: bearer(token), payload: body });
|
|
147
|
+
await app.inject({ method: 'POST', url: '/grants', headers: bearer(other), payload: body });
|
|
148
|
+
|
|
149
|
+
const mine = await app.inject({ method: 'GET', url: '/grants', headers: bearer(token) });
|
|
150
|
+
const donations = (mine.json() as { data: { donations: Array<{ grantToken: string }> } }).data.donations;
|
|
151
|
+
expect(donations).toHaveLength(1);
|
|
152
|
+
expect(donations[0].grantToken).toBe(token);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('a foreign grant cannot read another grant’s donation → 404', async () => {
|
|
156
|
+
const other = grants.issue({ label: 'Carol' }).token;
|
|
157
|
+
const created = await app.inject({ method: 'POST', url: '/grants', headers: bearer(token), payload: body });
|
|
158
|
+
const id = (created.json() as { data: { donation: { id: string } } }).data.donation.id;
|
|
159
|
+
|
|
160
|
+
const mine = await app.inject({ method: 'GET', url: `/grants/${id}`, headers: bearer(token) });
|
|
161
|
+
expect(mine.statusCode).toBe(200);
|
|
162
|
+
|
|
163
|
+
const foreign = await app.inject({ method: 'GET', url: `/grants/${id}`, headers: bearer(other) });
|
|
164
|
+
expect(foreign.statusCode).toBe(404);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('PUT /grants/:id/seed validation', () => {
|
|
169
|
+
it('rejects a missing seed body → 400 invalid_request', async () => {
|
|
170
|
+
const created = await app.inject({ method: 'POST', url: '/grants', headers: bearer(token), payload: body });
|
|
171
|
+
const id = (created.json() as { data: { donation: { id: string } } }).data.donation.id;
|
|
172
|
+
const res = await app.inject({ method: 'PUT', url: `/grants/${id}/seed`, headers: bearer(token), payload: {} });
|
|
173
|
+
expect(res.statusCode).toBe(400);
|
|
174
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('invalid_request');
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('DELETE /grants/:id', () => {
|
|
179
|
+
it('terminates a donation and drops it from the live tally', async () => {
|
|
180
|
+
const created = await app.inject({ method: 'POST', url: '/grants', headers: bearer(token), payload: body });
|
|
181
|
+
const id = (created.json() as { data: { donation: { id: string } } }).data.donation.id;
|
|
182
|
+
|
|
183
|
+
const del = await app.inject({ method: 'DELETE', url: `/grants/${id}`, headers: bearer(token) });
|
|
184
|
+
expect(del.statusCode).toBe(200);
|
|
185
|
+
|
|
186
|
+
const one = await app.inject({ method: 'GET', url: `/grants/${id}`, headers: bearer(token) });
|
|
187
|
+
expect((one.json() as { data: { donation: { status: string } } }).data.donation.status).toBe('terminated');
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -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
|
});
|