@serfab/cadre-host 1.10.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +423 -423
- package/dist/ui/assets/index-C6nELNfC.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/global-setup.ts +58 -58
- package/src/__tests__/orchestrator-handle-contract.ts +134 -134
- package/src/__tests__/orchestrator-node-identity.test.ts +203 -203
- package/src/__tests__/orchestrator-ports.test.ts +143 -143
- package/src/__tests__/orchestrator-teardown.ts +65 -65
- package/src/__tests__/orchestrator.test.ts +1063 -1063
- package/src/bin/host.ts +1357 -1357
- package/src/donation/__tests__/fake-orchestrator.test.ts +173 -173
- package/src/donation/donation-store.ts +176 -176
- package/src/donation/grant-service.ts +252 -252
- package/src/donation/index.ts +65 -65
- package/src/donation/types.ts +260 -260
- package/src/orchestrator/host-process-orchestrator.ts +1260 -1260
- package/src/orchestrator/port-allocator.ts +73 -73
- package/src/server/__tests__/grants-admin-route.test.ts +171 -171
- package/src/server/events/types.ts +24 -24
- package/src/server/index.ts +267 -267
- package/src/server/routes/grants-admin.ts +74 -74
|
@@ -1,73 +1,73 @@
|
|
|
1
|
-
import {
|
|
2
|
-
allocatePortSet,
|
|
3
|
-
releasePortSet,
|
|
4
|
-
reservePortSet,
|
|
5
|
-
type PortAllocator,
|
|
6
|
-
} from '@serfab/cadre-provider';
|
|
7
|
-
|
|
8
|
-
import type { NodePorts } from './types.js';
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Allocation order for a node's port set. Fixed so that swapping a caller onto
|
|
12
|
-
* {@link allocateNodePorts} does not shift the ports an existing deployment
|
|
13
|
-
* already handed out — which is also why a key added later goes on the end
|
|
14
|
-
* (`ws` came after the first four).
|
|
15
|
-
*/
|
|
16
|
-
const NODE_PORT_KEYS = ['health', 'metrics', 'p2p', 'admin', 'ws'] as const satisfies readonly (keyof NodePorts)[];
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Allocate a full NodePorts set atomically, in {@link NODE_PORT_KEYS} order.
|
|
20
|
-
* `overrides` supply a port instead of allocating one (the owner node's p2p
|
|
21
|
-
* port, which is fixed by NAT config; a re-spawn's previous ports, see
|
|
22
|
-
* {@link reusedNodePorts}). Overrides are reserved before anything is allocated
|
|
23
|
-
* and are trusted, not checked — see `allocatePortSet`.
|
|
24
|
-
*/
|
|
25
|
-
export function allocateNodePorts(
|
|
26
|
-
allocator: PortAllocator,
|
|
27
|
-
overrides?: Partial<NodePorts>,
|
|
28
|
-
): NodePorts {
|
|
29
|
-
return allocatePortSet(allocator, NODE_PORT_KEYS, overrides);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Hold a node's whole port set against `allocator` — the inverse of
|
|
34
|
-
* {@link releaseNodePorts}. A key the set lacks is skipped (see `markUsed`).
|
|
35
|
-
*/
|
|
36
|
-
export function reserveNodePorts(allocator: PortAllocator, ports: NodePorts): void {
|
|
37
|
-
reservePortSet(allocator, NODE_PORT_KEYS, ports);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Give a node's whole port set back to `allocator`. A key the set lacks releases
|
|
42
|
-
* nothing: deleting a value the used-set never held is a no-op.
|
|
43
|
-
*/
|
|
44
|
-
export function releaseNodePorts(allocator: PortAllocator, ports: NodePorts): void {
|
|
45
|
-
releasePortSet(allocator, NODE_PORT_KEYS, ports);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* The ports a re-spawn comes back on: the set the dropped handle held, as
|
|
50
|
-
* {@link allocateNodePorts} overrides. `{}` when nothing was dropped (a first
|
|
51
|
-
* spawn, or a handle lost along with `state.json`), so every port is allocated
|
|
52
|
-
* fresh.
|
|
53
|
-
*
|
|
54
|
-
* Why a re-spawn must not simply take the lowest free ports: a requester with no
|
|
55
|
-
* address of its own (a phone) reaches a lent node only by dialing the address it
|
|
56
|
-
* was given, so a node that comes back on a different port is one that requester
|
|
57
|
-
* can no longer find.
|
|
58
|
-
*
|
|
59
|
-
* A key the dropped handle lacks — `ws` on a handle persisted by an older build —
|
|
60
|
-
* is left out, and so allocated fresh. When more than one handle was dropped (only
|
|
61
|
-
* possible from a `state.json` that already held duplicates), the last one stored
|
|
62
|
-
* wins and the others' ports stay released.
|
|
63
|
-
*/
|
|
64
|
-
export function reusedNodePorts(dropped: readonly { ports: NodePorts }[]): Partial<NodePorts> {
|
|
65
|
-
const reused: Partial<NodePorts> = {};
|
|
66
|
-
const previous = dropped[dropped.length - 1]?.ports;
|
|
67
|
-
if (!previous) return reused;
|
|
68
|
-
for (const key of NODE_PORT_KEYS) {
|
|
69
|
-
const port = previous[key];
|
|
70
|
-
if (Number.isInteger(port)) reused[key] = port;
|
|
71
|
-
}
|
|
72
|
-
return reused;
|
|
73
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
allocatePortSet,
|
|
3
|
+
releasePortSet,
|
|
4
|
+
reservePortSet,
|
|
5
|
+
type PortAllocator,
|
|
6
|
+
} from '@serfab/cadre-provider';
|
|
7
|
+
|
|
8
|
+
import type { NodePorts } from './types.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Allocation order for a node's port set. Fixed so that swapping a caller onto
|
|
12
|
+
* {@link allocateNodePorts} does not shift the ports an existing deployment
|
|
13
|
+
* already handed out — which is also why a key added later goes on the end
|
|
14
|
+
* (`ws` came after the first four).
|
|
15
|
+
*/
|
|
16
|
+
const NODE_PORT_KEYS = ['health', 'metrics', 'p2p', 'admin', 'ws'] as const satisfies readonly (keyof NodePorts)[];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Allocate a full NodePorts set atomically, in {@link NODE_PORT_KEYS} order.
|
|
20
|
+
* `overrides` supply a port instead of allocating one (the owner node's p2p
|
|
21
|
+
* port, which is fixed by NAT config; a re-spawn's previous ports, see
|
|
22
|
+
* {@link reusedNodePorts}). Overrides are reserved before anything is allocated
|
|
23
|
+
* and are trusted, not checked — see `allocatePortSet`.
|
|
24
|
+
*/
|
|
25
|
+
export function allocateNodePorts(
|
|
26
|
+
allocator: PortAllocator,
|
|
27
|
+
overrides?: Partial<NodePorts>,
|
|
28
|
+
): NodePorts {
|
|
29
|
+
return allocatePortSet(allocator, NODE_PORT_KEYS, overrides);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Hold a node's whole port set against `allocator` — the inverse of
|
|
34
|
+
* {@link releaseNodePorts}. A key the set lacks is skipped (see `markUsed`).
|
|
35
|
+
*/
|
|
36
|
+
export function reserveNodePorts(allocator: PortAllocator, ports: NodePorts): void {
|
|
37
|
+
reservePortSet(allocator, NODE_PORT_KEYS, ports);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Give a node's whole port set back to `allocator`. A key the set lacks releases
|
|
42
|
+
* nothing: deleting a value the used-set never held is a no-op.
|
|
43
|
+
*/
|
|
44
|
+
export function releaseNodePorts(allocator: PortAllocator, ports: NodePorts): void {
|
|
45
|
+
releasePortSet(allocator, NODE_PORT_KEYS, ports);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The ports a re-spawn comes back on: the set the dropped handle held, as
|
|
50
|
+
* {@link allocateNodePorts} overrides. `{}` when nothing was dropped (a first
|
|
51
|
+
* spawn, or a handle lost along with `state.json`), so every port is allocated
|
|
52
|
+
* fresh.
|
|
53
|
+
*
|
|
54
|
+
* Why a re-spawn must not simply take the lowest free ports: a requester with no
|
|
55
|
+
* address of its own (a phone) reaches a lent node only by dialing the address it
|
|
56
|
+
* was given, so a node that comes back on a different port is one that requester
|
|
57
|
+
* can no longer find.
|
|
58
|
+
*
|
|
59
|
+
* A key the dropped handle lacks — `ws` on a handle persisted by an older build —
|
|
60
|
+
* is left out, and so allocated fresh. When more than one handle was dropped (only
|
|
61
|
+
* possible from a `state.json` that already held duplicates), the last one stored
|
|
62
|
+
* wins and the others' ports stay released.
|
|
63
|
+
*/
|
|
64
|
+
export function reusedNodePorts(dropped: readonly { ports: NodePorts }[]): Partial<NodePorts> {
|
|
65
|
+
const reused: Partial<NodePorts> = {};
|
|
66
|
+
const previous = dropped[dropped.length - 1]?.ports;
|
|
67
|
+
if (!previous) return reused;
|
|
68
|
+
for (const key of NODE_PORT_KEYS) {
|
|
69
|
+
const port = previous[key];
|
|
70
|
+
if (Number.isInteger(port)) reused[key] = port;
|
|
71
|
+
}
|
|
72
|
+
return reused;
|
|
73
|
+
}
|
|
@@ -1,171 +1,171 @@
|
|
|
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 { EventBus } from '../events/bus.js';
|
|
9
|
-
import { registerGrantsAdminRoutes } from '../routes/grants-admin.js';
|
|
10
|
-
import {
|
|
11
|
-
DonationService,
|
|
12
|
-
DonationStore,
|
|
13
|
-
GrantService,
|
|
14
|
-
GrantStore,
|
|
15
|
-
createGrantAdminHandlers,
|
|
16
|
-
} from '../../donation/index.js';
|
|
17
|
-
import type { DonationView, Grant, GrantListing } from '../../donation/index.js';
|
|
18
|
-
import { FakeOrchestrator } from '../../donation/__tests__/fake-orchestrator.js';
|
|
19
|
-
|
|
20
|
-
/** A well-formed 32-byte base64url owner key, which `provision` requires. */
|
|
21
|
-
const OWNER_KEY = Buffer.alloc(32, 7).toString('base64url');
|
|
22
|
-
|
|
23
|
-
let tmpRoot: string;
|
|
24
|
-
let app: ReturnType<typeof Fastify>;
|
|
25
|
-
let grants: GrantService;
|
|
26
|
-
let donations: DonationService;
|
|
27
|
-
let donationStore: DonationStore;
|
|
28
|
-
let orch: FakeOrchestrator;
|
|
29
|
-
|
|
30
|
-
beforeEach(() => {
|
|
31
|
-
tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-grants-route-'));
|
|
32
|
-
grants = new GrantService({ store: new GrantStore(tmpRoot) });
|
|
33
|
-
orch = new FakeOrchestrator();
|
|
34
|
-
donationStore = new DonationStore(tmpRoot);
|
|
35
|
-
donations = new DonationService({ orchestrator: orch, grants, store: donationStore });
|
|
36
|
-
app = Fastify();
|
|
37
|
-
registerErrorHandler(app);
|
|
38
|
-
registerGrantsAdminRoutes(app, { handlers: createGrantAdminHandlers(grants, donations), events: new EventBus() });
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
afterEach(async () => {
|
|
42
|
-
await app.close();
|
|
43
|
-
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
describe('/grants-admin routes', () => {
|
|
47
|
-
it('POST issues a grant, GET lists it, DELETE revokes it', async () => {
|
|
48
|
-
const post = await app.inject({
|
|
49
|
-
method: 'POST',
|
|
50
|
-
url: '/grants-admin',
|
|
51
|
-
payload: { label: "Alice's cadre", maxNodes: 2 },
|
|
52
|
-
});
|
|
53
|
-
expect(post.statusCode).toBe(200);
|
|
54
|
-
const created = (post.json() as { grant: Grant }).grant;
|
|
55
|
-
expect(created.token).toBeTruthy();
|
|
56
|
-
expect(created.maxNodes).toBe(2);
|
|
57
|
-
|
|
58
|
-
const list = await app.inject({ method: 'GET', url: '/grants-admin' });
|
|
59
|
-
expect(list.statusCode).toBe(200);
|
|
60
|
-
expect((list.json() as { grants: Grant[] }).grants.map(g => g.token)).toContain(created.token);
|
|
61
|
-
|
|
62
|
-
const del = await app.inject({
|
|
63
|
-
method: 'DELETE',
|
|
64
|
-
url: `/grants-admin/${encodeURIComponent(created.token)}`,
|
|
65
|
-
});
|
|
66
|
-
expect(del.statusCode).toBe(200);
|
|
67
|
-
|
|
68
|
-
// Revoked → still listed, now carries revokedAt.
|
|
69
|
-
const after = await app.inject({ method: 'GET', url: '/grants-admin' });
|
|
70
|
-
const row = (after.json() as { grants: Grant[] }).grants.find(g => g.token === created.token);
|
|
71
|
-
expect(row?.revokedAt).toBeTruthy();
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it('POST with an empty label → 400 invalid_label', async () => {
|
|
75
|
-
const res = await app.inject({ method: 'POST', url: '/grants-admin', payload: { label: ' ' } });
|
|
76
|
-
expect(res.statusCode).toBe(400);
|
|
77
|
-
expect((res.json() as { error: { code: string } }).error.code).toBe('invalid_label');
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('POST with a bad maxNodes → 400 invalid_max_nodes', async () => {
|
|
81
|
-
const res = await app.inject({ method: 'POST', url: '/grants-admin', payload: { label: 'X', maxNodes: 0 } });
|
|
82
|
-
expect(res.statusCode).toBe(400);
|
|
83
|
-
expect((res.json() as { error: { code: string } }).error.code).toBe('invalid_max_nodes');
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('DELETE of an unknown token → 404 not_found', async () => {
|
|
87
|
-
const res = await app.inject({ method: 'DELETE', url: '/grants-admin/nope' });
|
|
88
|
-
expect(res.statusCode).toBe(404);
|
|
89
|
-
expect((res.json() as { error: { code: string } }).error.code).toBe('not_found');
|
|
90
|
-
});
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
function provisionUnder(token: string): Promise<DonationView> {
|
|
94
|
-
return donations.provision({ grantToken: token, partyId: 'party-P', bootstrapNodes: [], ownerKeys: [OWNER_KEY] });
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
describe('/grants-admin listing', () => {
|
|
98
|
-
it("GET reports each grant's live nodes and the donations a revoke would end", async () => {
|
|
99
|
-
const a = grants.issue({ label: 'A', maxNodes: 3 });
|
|
100
|
-
const b = grants.issue({ label: 'B' });
|
|
101
|
-
const seeded = await provisionUnder(a.token);
|
|
102
|
-
const failed = await provisionUnder(a.token);
|
|
103
|
-
const ended = await provisionUnder(a.token);
|
|
104
|
-
const other = await provisionUnder(b.token);
|
|
105
|
-
donationStore.put({ ...donationStore.get(seeded.id)!, status: 'seeded' });
|
|
106
|
-
donationStore.put({ ...donationStore.get(failed.id)!, status: 'error', error: 'respawn gave up' });
|
|
107
|
-
await donations.terminate(ended.id);
|
|
108
|
-
|
|
109
|
-
const res = await app.inject({ method: 'GET', url: '/grants-admin' });
|
|
110
|
-
|
|
111
|
-
const listed = (res.json() as { grants: GrantListing[] }).grants;
|
|
112
|
-
const rowA = listed.find(g => g.token === a.token);
|
|
113
|
-
const rowB = listed.find(g => g.token === b.token);
|
|
114
|
-
// `error` is not live, but a revoke still tears it down; `terminated` is neither.
|
|
115
|
-
expect(rowA?.liveNodes).toBe(1);
|
|
116
|
-
expect(rowA?.donations).toEqual(expect.arrayContaining([
|
|
117
|
-
{ id: seeded.id, status: 'seeded' },
|
|
118
|
-
{ id: failed.id, status: 'error' },
|
|
119
|
-
]));
|
|
120
|
-
expect(rowA?.donations).toHaveLength(2);
|
|
121
|
-
expect(rowB?.liveNodes).toBe(1);
|
|
122
|
-
expect(rowB?.donations).toEqual([{ id: other.id, status: 'awaiting_seed' }]);
|
|
123
|
-
});
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
describe('/grants-admin donated-node teardown', () => {
|
|
127
|
-
it('DELETE /grants-admin/:token terminates the nodes donated under the grant', async () => {
|
|
128
|
-
const { token } = grants.issue({ label: 'Alice' });
|
|
129
|
-
const donation = await provisionUnder(token);
|
|
130
|
-
|
|
131
|
-
const res = await app.inject({ method: 'DELETE', url: `/grants-admin/${encodeURIComponent(token)}` });
|
|
132
|
-
|
|
133
|
-
expect(res.statusCode).toBe(200);
|
|
134
|
-
expect(res.json()).toEqual({ ok: true, terminated: [donation.id] });
|
|
135
|
-
expect(donations.get(donation.id)?.status).toBe('terminated');
|
|
136
|
-
expect(orch.removed).toEqual(['dock_1']);
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it('?keepNodes=true revokes the grant but leaves its nodes running', async () => {
|
|
140
|
-
const { token } = grants.issue({ label: 'Alice' });
|
|
141
|
-
const donation = await provisionUnder(token);
|
|
142
|
-
|
|
143
|
-
const res = await app.inject({
|
|
144
|
-
method: 'DELETE',
|
|
145
|
-
url: `/grants-admin/${encodeURIComponent(token)}?keepNodes=true`,
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
expect(res.statusCode).toBe(200);
|
|
149
|
-
expect(res.json()).toEqual({ ok: true, terminated: [] });
|
|
150
|
-
expect(grants.validate(token).reason).toBe('revoked');
|
|
151
|
-
expect(donations.get(donation.id)?.status).toBe('awaiting_seed');
|
|
152
|
-
expect(orch.stopped).toEqual([]);
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it('DELETE /grants-admin/donations/:id ends one node under a revoked grant; an unknown id 404s', async () => {
|
|
156
|
-
const { token } = grants.issue({ label: 'Alice', maxNodes: 2 });
|
|
157
|
-
const kept = await provisionUnder(token);
|
|
158
|
-
const ended = await provisionUnder(token);
|
|
159
|
-
grants.revoke(token);
|
|
160
|
-
|
|
161
|
-
const res = await app.inject({ method: 'DELETE', url: `/grants-admin/donations/${ended.id}` });
|
|
162
|
-
|
|
163
|
-
expect(res.statusCode).toBe(200);
|
|
164
|
-
expect(donations.get(ended.id)?.status).toBe('terminated');
|
|
165
|
-
expect(donations.get(kept.id)?.status).toBe('awaiting_seed');
|
|
166
|
-
|
|
167
|
-
const unknown = await app.inject({ method: 'DELETE', url: '/grants-admin/donations/grn_nope' });
|
|
168
|
-
expect(unknown.statusCode).toBe(404);
|
|
169
|
-
expect((unknown.json() as { error: { code: string } }).error.code).toBe('not_found');
|
|
170
|
-
});
|
|
171
|
-
});
|
|
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 { EventBus } from '../events/bus.js';
|
|
9
|
+
import { registerGrantsAdminRoutes } from '../routes/grants-admin.js';
|
|
10
|
+
import {
|
|
11
|
+
DonationService,
|
|
12
|
+
DonationStore,
|
|
13
|
+
GrantService,
|
|
14
|
+
GrantStore,
|
|
15
|
+
createGrantAdminHandlers,
|
|
16
|
+
} from '../../donation/index.js';
|
|
17
|
+
import type { DonationView, Grant, GrantListing } from '../../donation/index.js';
|
|
18
|
+
import { FakeOrchestrator } from '../../donation/__tests__/fake-orchestrator.js';
|
|
19
|
+
|
|
20
|
+
/** A well-formed 32-byte base64url owner key, which `provision` requires. */
|
|
21
|
+
const OWNER_KEY = Buffer.alloc(32, 7).toString('base64url');
|
|
22
|
+
|
|
23
|
+
let tmpRoot: string;
|
|
24
|
+
let app: ReturnType<typeof Fastify>;
|
|
25
|
+
let grants: GrantService;
|
|
26
|
+
let donations: DonationService;
|
|
27
|
+
let donationStore: DonationStore;
|
|
28
|
+
let orch: FakeOrchestrator;
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-grants-route-'));
|
|
32
|
+
grants = new GrantService({ store: new GrantStore(tmpRoot) });
|
|
33
|
+
orch = new FakeOrchestrator();
|
|
34
|
+
donationStore = new DonationStore(tmpRoot);
|
|
35
|
+
donations = new DonationService({ orchestrator: orch, grants, store: donationStore });
|
|
36
|
+
app = Fastify();
|
|
37
|
+
registerErrorHandler(app);
|
|
38
|
+
registerGrantsAdminRoutes(app, { handlers: createGrantAdminHandlers(grants, donations), events: new EventBus() });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(async () => {
|
|
42
|
+
await app.close();
|
|
43
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('/grants-admin routes', () => {
|
|
47
|
+
it('POST issues a grant, GET lists it, DELETE revokes it', async () => {
|
|
48
|
+
const post = await app.inject({
|
|
49
|
+
method: 'POST',
|
|
50
|
+
url: '/grants-admin',
|
|
51
|
+
payload: { label: "Alice's cadre", maxNodes: 2 },
|
|
52
|
+
});
|
|
53
|
+
expect(post.statusCode).toBe(200);
|
|
54
|
+
const created = (post.json() as { grant: Grant }).grant;
|
|
55
|
+
expect(created.token).toBeTruthy();
|
|
56
|
+
expect(created.maxNodes).toBe(2);
|
|
57
|
+
|
|
58
|
+
const list = await app.inject({ method: 'GET', url: '/grants-admin' });
|
|
59
|
+
expect(list.statusCode).toBe(200);
|
|
60
|
+
expect((list.json() as { grants: Grant[] }).grants.map(g => g.token)).toContain(created.token);
|
|
61
|
+
|
|
62
|
+
const del = await app.inject({
|
|
63
|
+
method: 'DELETE',
|
|
64
|
+
url: `/grants-admin/${encodeURIComponent(created.token)}`,
|
|
65
|
+
});
|
|
66
|
+
expect(del.statusCode).toBe(200);
|
|
67
|
+
|
|
68
|
+
// Revoked → still listed, now carries revokedAt.
|
|
69
|
+
const after = await app.inject({ method: 'GET', url: '/grants-admin' });
|
|
70
|
+
const row = (after.json() as { grants: Grant[] }).grants.find(g => g.token === created.token);
|
|
71
|
+
expect(row?.revokedAt).toBeTruthy();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('POST with an empty label → 400 invalid_label', async () => {
|
|
75
|
+
const res = await app.inject({ method: 'POST', url: '/grants-admin', payload: { label: ' ' } });
|
|
76
|
+
expect(res.statusCode).toBe(400);
|
|
77
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('invalid_label');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('POST with a bad maxNodes → 400 invalid_max_nodes', async () => {
|
|
81
|
+
const res = await app.inject({ method: 'POST', url: '/grants-admin', payload: { label: 'X', maxNodes: 0 } });
|
|
82
|
+
expect(res.statusCode).toBe(400);
|
|
83
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('invalid_max_nodes');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('DELETE of an unknown token → 404 not_found', async () => {
|
|
87
|
+
const res = await app.inject({ method: 'DELETE', url: '/grants-admin/nope' });
|
|
88
|
+
expect(res.statusCode).toBe(404);
|
|
89
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('not_found');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
function provisionUnder(token: string): Promise<DonationView> {
|
|
94
|
+
return donations.provision({ grantToken: token, partyId: 'party-P', bootstrapNodes: [], ownerKeys: [OWNER_KEY] });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
describe('/grants-admin listing', () => {
|
|
98
|
+
it("GET reports each grant's live nodes and the donations a revoke would end", async () => {
|
|
99
|
+
const a = grants.issue({ label: 'A', maxNodes: 3 });
|
|
100
|
+
const b = grants.issue({ label: 'B' });
|
|
101
|
+
const seeded = await provisionUnder(a.token);
|
|
102
|
+
const failed = await provisionUnder(a.token);
|
|
103
|
+
const ended = await provisionUnder(a.token);
|
|
104
|
+
const other = await provisionUnder(b.token);
|
|
105
|
+
donationStore.put({ ...donationStore.get(seeded.id)!, status: 'seeded' });
|
|
106
|
+
donationStore.put({ ...donationStore.get(failed.id)!, status: 'error', error: 'respawn gave up' });
|
|
107
|
+
await donations.terminate(ended.id);
|
|
108
|
+
|
|
109
|
+
const res = await app.inject({ method: 'GET', url: '/grants-admin' });
|
|
110
|
+
|
|
111
|
+
const listed = (res.json() as { grants: GrantListing[] }).grants;
|
|
112
|
+
const rowA = listed.find(g => g.token === a.token);
|
|
113
|
+
const rowB = listed.find(g => g.token === b.token);
|
|
114
|
+
// `error` is not live, but a revoke still tears it down; `terminated` is neither.
|
|
115
|
+
expect(rowA?.liveNodes).toBe(1);
|
|
116
|
+
expect(rowA?.donations).toEqual(expect.arrayContaining([
|
|
117
|
+
{ id: seeded.id, status: 'seeded' },
|
|
118
|
+
{ id: failed.id, status: 'error' },
|
|
119
|
+
]));
|
|
120
|
+
expect(rowA?.donations).toHaveLength(2);
|
|
121
|
+
expect(rowB?.liveNodes).toBe(1);
|
|
122
|
+
expect(rowB?.donations).toEqual([{ id: other.id, status: 'awaiting_seed' }]);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('/grants-admin donated-node teardown', () => {
|
|
127
|
+
it('DELETE /grants-admin/:token terminates the nodes donated under the grant', async () => {
|
|
128
|
+
const { token } = grants.issue({ label: 'Alice' });
|
|
129
|
+
const donation = await provisionUnder(token);
|
|
130
|
+
|
|
131
|
+
const res = await app.inject({ method: 'DELETE', url: `/grants-admin/${encodeURIComponent(token)}` });
|
|
132
|
+
|
|
133
|
+
expect(res.statusCode).toBe(200);
|
|
134
|
+
expect(res.json()).toEqual({ ok: true, terminated: [donation.id] });
|
|
135
|
+
expect(donations.get(donation.id)?.status).toBe('terminated');
|
|
136
|
+
expect(orch.removed).toEqual(['dock_1']);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('?keepNodes=true revokes the grant but leaves its nodes running', async () => {
|
|
140
|
+
const { token } = grants.issue({ label: 'Alice' });
|
|
141
|
+
const donation = await provisionUnder(token);
|
|
142
|
+
|
|
143
|
+
const res = await app.inject({
|
|
144
|
+
method: 'DELETE',
|
|
145
|
+
url: `/grants-admin/${encodeURIComponent(token)}?keepNodes=true`,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(res.statusCode).toBe(200);
|
|
149
|
+
expect(res.json()).toEqual({ ok: true, terminated: [] });
|
|
150
|
+
expect(grants.validate(token).reason).toBe('revoked');
|
|
151
|
+
expect(donations.get(donation.id)?.status).toBe('awaiting_seed');
|
|
152
|
+
expect(orch.stopped).toEqual([]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('DELETE /grants-admin/donations/:id ends one node under a revoked grant; an unknown id 404s', async () => {
|
|
156
|
+
const { token } = grants.issue({ label: 'Alice', maxNodes: 2 });
|
|
157
|
+
const kept = await provisionUnder(token);
|
|
158
|
+
const ended = await provisionUnder(token);
|
|
159
|
+
grants.revoke(token);
|
|
160
|
+
|
|
161
|
+
const res = await app.inject({ method: 'DELETE', url: `/grants-admin/donations/${ended.id}` });
|
|
162
|
+
|
|
163
|
+
expect(res.statusCode).toBe(200);
|
|
164
|
+
expect(donations.get(ended.id)?.status).toBe('terminated');
|
|
165
|
+
expect(donations.get(kept.id)?.status).toBe('awaiting_seed');
|
|
166
|
+
|
|
167
|
+
const unknown = await app.inject({ method: 'DELETE', url: '/grants-admin/donations/grn_nope' });
|
|
168
|
+
expect(unknown.statusCode).toBe(404);
|
|
169
|
+
expect((unknown.json() as { error: { code: string } }).error.code).toBe('not_found');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Event types broadcast over the SPA's SSE channel.
|
|
3
|
-
*
|
|
4
|
-
* Publishers (orchestrator hook, route adapters, update observer) call
|
|
5
|
-
* EventBus.publish; the `/api/events` route fan-outs to connected clients.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import type { ContainerStatus } from '@serfab/cadre-provider';
|
|
9
|
-
|
|
10
|
-
import type { DirectReachability, PortForwardMode } from '../../nat/index.js';
|
|
11
|
-
|
|
12
|
-
export type LocalUiEvent =
|
|
13
|
-
| { type: 'node-state-changed'; nodeId: string; status: ContainerStatus }
|
|
14
|
-
| { type: 'trust-circle-changed'; kind: 'invited' | 'redeemed' | 'revoked' }
|
|
15
|
-
| { type: 'strands-changed'; kind: 'removed' }
|
|
16
|
-
| { type: 'grants-changed'; kind: 'issued' | 'revoked' | 'terminated' }
|
|
17
|
-
| {
|
|
18
|
-
type: 'connectivity-changed';
|
|
19
|
-
portMode: PortForwardMode;
|
|
20
|
-
directReachability: DirectReachability;
|
|
21
|
-
}
|
|
22
|
-
| { type: 'update-available'; version: string; releaseNotesUrl?: string };
|
|
23
|
-
|
|
24
|
-
export type LocalUiEventType = LocalUiEvent['type'];
|
|
1
|
+
/**
|
|
2
|
+
* Event types broadcast over the SPA's SSE channel.
|
|
3
|
+
*
|
|
4
|
+
* Publishers (orchestrator hook, route adapters, update observer) call
|
|
5
|
+
* EventBus.publish; the `/api/events` route fan-outs to connected clients.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ContainerStatus } from '@serfab/cadre-provider';
|
|
9
|
+
|
|
10
|
+
import type { DirectReachability, PortForwardMode } from '../../nat/index.js';
|
|
11
|
+
|
|
12
|
+
export type LocalUiEvent =
|
|
13
|
+
| { type: 'node-state-changed'; nodeId: string; status: ContainerStatus }
|
|
14
|
+
| { type: 'trust-circle-changed'; kind: 'invited' | 'redeemed' | 'revoked' }
|
|
15
|
+
| { type: 'strands-changed'; kind: 'removed' }
|
|
16
|
+
| { type: 'grants-changed'; kind: 'issued' | 'revoked' | 'terminated' }
|
|
17
|
+
| {
|
|
18
|
+
type: 'connectivity-changed';
|
|
19
|
+
portMode: PortForwardMode;
|
|
20
|
+
directReachability: DirectReachability;
|
|
21
|
+
}
|
|
22
|
+
| { type: 'update-available'; version: string; releaseNotesUrl?: string };
|
|
23
|
+
|
|
24
|
+
export type LocalUiEventType = LocalUiEvent['type'];
|