@serfab/cadre-host 1.7.0 → 1.8.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.
Files changed (87) hide show
  1. package/README.md +31 -16
  2. package/dist/bin/host.js +41 -20
  3. package/dist/bin/host.js.map +1 -1
  4. package/dist/donation/donation-service.d.ts +27 -4
  5. package/dist/donation/donation-service.js +57 -6
  6. package/dist/donation/donation-service.js.map +1 -1
  7. package/dist/donation/donation-store.d.ts +3 -1
  8. package/dist/donation/donation-store.js +5 -1
  9. package/dist/donation/donation-store.js.map +1 -1
  10. package/dist/donation/grant-service.d.ts +16 -7
  11. package/dist/donation/grant-service.js +57 -10
  12. package/dist/donation/grant-service.js.map +1 -1
  13. package/dist/donation/index.d.ts +2 -2
  14. package/dist/donation/index.js +1 -1
  15. package/dist/donation/index.js.map +1 -1
  16. package/dist/donation/types.d.ts +29 -7
  17. package/dist/donation/types.js.map +1 -1
  18. package/dist/index.d.ts +1 -1
  19. package/dist/nat/address-resolver.d.ts +3 -2
  20. package/dist/nat/address-resolver.js.map +1 -1
  21. package/dist/orchestrator/host-process-orchestrator.d.ts +1 -1
  22. package/dist/orchestrator/host-process-orchestrator.js +21 -11
  23. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  24. package/dist/orchestrator/port-allocator.d.ts +6 -39
  25. package/dist/orchestrator/port-allocator.js +9 -89
  26. package/dist/orchestrator/port-allocator.js.map +1 -1
  27. package/dist/server/events/types.d.ts +3 -0
  28. package/dist/server/index.d.ts +19 -18
  29. package/dist/server/index.js +13 -15
  30. package/dist/server/index.js.map +1 -1
  31. package/dist/server/routes/grants-admin.d.ts +15 -3
  32. package/dist/server/routes/grants-admin.js +33 -7
  33. package/dist/server/routes/grants-admin.js.map +1 -1
  34. package/dist/server/routes/nodes.d.ts +13 -5
  35. package/dist/server/routes/nodes.js +58 -39
  36. package/dist/server/routes/nodes.js.map +1 -1
  37. package/dist/server/routes/status.d.ts +13 -0
  38. package/dist/server/routes/status.js +2 -0
  39. package/dist/server/routes/status.js.map +1 -1
  40. package/dist/ui/assets/index-C6nELNfC.js +23 -0
  41. package/dist/ui/assets/index-C6nELNfC.js.map +1 -0
  42. package/dist/ui/assets/index-TtIYSeZO.css +1 -0
  43. package/dist/ui/index.html +2 -2
  44. package/package.json +4 -4
  45. package/src/__tests__/global-setup.ts +58 -56
  46. package/src/__tests__/orchestrator-env-scrub.test.ts +12 -11
  47. package/src/__tests__/orchestrator-handle-contract.ts +134 -0
  48. package/src/__tests__/orchestrator-node-identity.test.ts +6 -7
  49. package/src/__tests__/orchestrator-owner.test.ts +6 -13
  50. package/src/__tests__/orchestrator-pin-keys.test.ts +6 -7
  51. package/src/__tests__/orchestrator-ports.test.ts +5 -34
  52. package/src/__tests__/orchestrator-push.test.ts +6 -5
  53. package/src/__tests__/orchestrator-teardown.ts +65 -0
  54. package/src/__tests__/orchestrator.test.ts +23 -22
  55. package/src/auth/__tests__/trust-circle-integration.test.ts +1 -1
  56. package/src/bin/host.ts +56 -33
  57. package/src/donation/__tests__/donation-service.test.ts +38 -0
  58. package/src/donation/__tests__/fake-orchestrator.test.ts +19 -14
  59. package/src/donation/__tests__/fake-orchestrator.ts +21 -3
  60. package/src/donation/__tests__/grant-service.test.ts +2 -2
  61. package/src/donation/donation-service.ts +58 -6
  62. package/src/donation/donation-store.ts +176 -171
  63. package/src/donation/grant-service.ts +252 -200
  64. package/src/donation/index.ts +65 -64
  65. package/src/donation/types.ts +260 -244
  66. package/src/index.ts +2 -0
  67. package/src/nat/address-resolver.ts +3 -2
  68. package/src/orchestrator/host-process-orchestrator.ts +1260 -1251
  69. package/src/orchestrator/port-allocator.ts +16 -91
  70. package/src/server/__tests__/fakes.ts +44 -0
  71. package/src/server/__tests__/grants-admin-route.test.ts +171 -73
  72. package/src/server/__tests__/nodes-route.test.ts +49 -25
  73. package/src/server/__tests__/publishers.test.ts +5 -41
  74. package/src/server/__tests__/server.smoke.test.ts +7 -37
  75. package/src/server/__tests__/status-route.test.ts +8 -21
  76. package/src/server/__tests__/strands-route.test.ts +3 -2
  77. package/src/server/events/types.ts +24 -23
  78. package/src/server/index.ts +267 -267
  79. package/src/server/routes/grants-admin.ts +74 -41
  80. package/src/server/routes/nodes.ts +61 -43
  81. package/src/server/routes/status.ts +16 -0
  82. package/dist/donation/__tests__/fake-orchestrator.d.ts +0 -103
  83. package/dist/donation/__tests__/fake-orchestrator.js +0 -180
  84. package/dist/donation/__tests__/fake-orchestrator.js.map +0 -1
  85. package/dist/ui/assets/index-BVoqLjyX.css +0 -1
  86. package/dist/ui/assets/index-CEoNRwM_.js +0 -17
  87. package/dist/ui/assets/index-CEoNRwM_.js.map +0 -1
@@ -1,52 +1,11 @@
1
- import type { NodePorts } from './types.js';
2
-
3
- /**
4
- * Range-based port tracker. Allocations are in-memory; on orchestrator
5
- * restart the caller rehydrates the used-set from persisted state via
6
- * `markUsed(port)` before serving new allocations.
7
- */
8
- export class PortAllocator {
9
- private readonly usedPorts = new Set<number>();
10
-
11
- constructor(
12
- private readonly start: number,
13
- private readonly end: number,
14
- ) {
15
- if (!Number.isInteger(start) || !Number.isInteger(end) || start <= 0 || end < start) {
16
- throw new Error(`Invalid port range: ${start}..${end}`);
17
- }
18
- }
19
-
20
- allocate(): number {
21
- for (let port = this.start; port <= this.end; port++) {
22
- if (!this.usedPorts.has(port)) {
23
- this.usedPorts.add(port);
24
- return port;
25
- }
26
- }
27
- throw new Error(`No available ports in range ${this.start}..${this.end}`);
28
- }
29
-
30
- release(port: number): void {
31
- this.usedPorts.delete(port);
32
- }
33
-
34
- /**
35
- * Used during restart rehydration to reserve ports already in use. A no-op for a
36
- * port outside the range, and for a non-integer: a handle persisted by an older
37
- * build lacks the ports added since, and `undefined` must not enter the used-set.
38
- */
39
- markUsed(port: number): void {
40
- if (!Number.isInteger(port) || port < this.start || port > this.end) {
41
- return;
42
- }
43
- this.usedPorts.add(port);
44
- }
1
+ import {
2
+ allocatePortSet,
3
+ releasePortSet,
4
+ reservePortSet,
5
+ type PortAllocator,
6
+ } from '@serfab/cadre-provider';
45
7
 
46
- has(port: number): boolean {
47
- return this.usedPorts.has(port);
48
- }
49
- }
8
+ import type { NodePorts } from './types.js';
50
9
 
51
10
  /**
52
11
  * Allocation order for a node's port set. Fixed so that swapping a caller onto
@@ -54,54 +13,20 @@ export class PortAllocator {
54
13
  * already handed out — which is also why a key added later goes on the end
55
14
  * (`ws` came after the first four).
56
15
  */
57
- const NODE_PORT_KEYS = ['health', 'metrics', 'p2p', 'admin', 'ws'] as const;
16
+ const NODE_PORT_KEYS = ['health', 'metrics', 'p2p', 'admin', 'ws'] as const satisfies readonly (keyof NodePorts)[];
58
17
 
59
18
  /**
60
- * Allocate a full NodePorts set atomically — a mid-way failure (an exhausted
61
- * range) releases everything already taken rather than stranding the ports it
62
- * got before the throw. `overrides` supply a port instead of allocating one
63
- * (the owner node's p2p port, which is fixed by NAT config; a re-spawn's
64
- * previous ports, see {@link reusedNodePorts}).
65
- *
66
- * Overrides are reserved FIRST, before any allocation, so an override that
67
- * happens to sit inside the managed range cannot also be handed out to another
68
- * key. Reserving is `markUsed`, a documented no-op outside the range — which is
69
- * the production case for the owner node's libp2p port, and why reserving first
70
- * leaves every real port assignment unchanged.
71
- *
72
- * NOTE: an override is trusted, not checked — `markUsed` does not refuse a port
73
- * another handle already holds, nor two keys naming the same port. Safe for
74
- * today's callers: a re-spawn's overrides were released by its own handle drop
75
- * with no `await` in between, and the owner's `libp2pPort` can only collide if an
76
- * operator configures it inside the range onto a port a node already holds. If a
77
- * caller ever passes ports it did not just release, refuse an override that
78
- * `allocator.has()` or that repeats another key's port.
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`.
79
24
  */
80
25
  export function allocateNodePorts(
81
26
  allocator: PortAllocator,
82
27
  overrides?: Partial<NodePorts>,
83
28
  ): NodePorts {
84
- const reserved: number[] = [];
85
- const ports: Partial<NodePorts> = {};
86
- try {
87
- for (const key of NODE_PORT_KEYS) {
88
- const override = overrides?.[key];
89
- if (override === undefined) continue;
90
- allocator.markUsed(override);
91
- reserved.push(override);
92
- ports[key] = override;
93
- }
94
- for (const key of NODE_PORT_KEYS) {
95
- if (ports[key] !== undefined) continue;
96
- const port = allocator.allocate();
97
- reserved.push(port);
98
- ports[key] = port;
99
- }
100
- } catch (err) {
101
- for (const port of reserved) allocator.release(port);
102
- throw err;
103
- }
104
- return ports as NodePorts;
29
+ return allocatePortSet(allocator, NODE_PORT_KEYS, overrides);
105
30
  }
106
31
 
107
32
  /**
@@ -109,7 +34,7 @@ export function allocateNodePorts(
109
34
  * {@link releaseNodePorts}. A key the set lacks is skipped (see `markUsed`).
110
35
  */
111
36
  export function reserveNodePorts(allocator: PortAllocator, ports: NodePorts): void {
112
- for (const key of NODE_PORT_KEYS) allocator.markUsed(ports[key]);
37
+ reservePortSet(allocator, NODE_PORT_KEYS, ports);
113
38
  }
114
39
 
115
40
  /**
@@ -117,7 +42,7 @@ export function reserveNodePorts(allocator: PortAllocator, ports: NodePorts): vo
117
42
  * nothing: deleting a value the used-set never held is a no-op.
118
43
  */
119
44
  export function releaseNodePorts(allocator: PortAllocator, ports: NodePorts): void {
120
- for (const key of NODE_PORT_KEYS) allocator.release(ports[key]);
45
+ releasePortSet(allocator, NODE_PORT_KEYS, ports);
121
46
  }
122
47
 
123
48
  /**
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Shared fakes for the local-UI server tests. Holds no tests of its own.
3
+ */
4
+
5
+ import type { FounderServices } from '../index.js';
6
+ import type { TrustCircleService } from '../../auth/index.js';
7
+ import type { NatService } from '../../nat/index.js';
8
+ import type { NatStatusSnapshot } from '../../nat/types.js';
9
+ import type { StrandService } from '../../strands/index.js';
10
+
11
+ export const SAMPLE_CONNECTIVITY: NatStatusSnapshot = {
12
+ portMode: 'auto-upnp',
13
+ externalPort: 4001,
14
+ internalPort: 4001,
15
+ routerExternalIp: '203.0.113.5',
16
+ mappingLeaseExpiresAt: null,
17
+ externalIp: '203.0.113.5',
18
+ externalIpDetectedAt: null,
19
+ cgnatDetected: false,
20
+ directReachability: 'reachable',
21
+ lastTestedAt: null,
22
+ ddns: {
23
+ providerId: null,
24
+ hostname: null,
25
+ externallyManaged: false,
26
+ lastUpdateAt: null,
27
+ lastUpdateOk: null,
28
+ lastError: null,
29
+ },
30
+ };
31
+
32
+ /**
33
+ * A founder-role service set whose members answer only the reads the server
34
+ * makes on its own (status, boot publish): an empty trust circle, the sample
35
+ * connectivity, no strands. Pass the service a test exercises as an override.
36
+ */
37
+ export function fakeFounder(overrides: Partial<FounderServices> = {}): FounderServices {
38
+ return {
39
+ trustCircle: { list: async () => ({ members: [], pending: [] }) } as unknown as TrustCircleService,
40
+ nat: { getStatus: () => SAMPLE_CONNECTIVITY } as unknown as NatService,
41
+ strands: { list: async () => ({ strands: [], controlConnections: 0 }) } as unknown as StrandService,
42
+ ...overrides,
43
+ };
44
+ }
@@ -1,73 +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 { 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
- });
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
+ });
@@ -90,27 +90,15 @@ const SAMPLE_NODE: ManagedNodeInfo = {
90
90
 
91
91
  describe('/api/nodes routes', () => {
92
92
  let app: ReturnType<typeof Fastify>;
93
- let bus: EventBus;
94
93
  let orchestrator: FakeOrchestrator;
95
94
  let workdir: string;
96
95
 
97
96
  beforeEach(async () => {
98
97
  workdir = mkdtempSync(join(tmpdir(), 'cadre-host-nodes-'));
99
- bus = new EventBus();
100
98
  app = Fastify();
101
99
  registerErrorHandler(app);
102
100
  orchestrator = fakeOrchestrator([{ ...SAMPLE_NODE, workdir }]);
103
- registerNodesRoutes(app, { orchestrator });
104
- // Mirror what createLocalUiServer.start() does — forward orchestrator
105
- // state changes onto the bus. The route itself doesn't publish; the
106
- // listener is the single publication point.
107
- orchestrator.onStateChange((info) => {
108
- bus.publish({
109
- type: 'node-state-changed',
110
- nodeId: info.id,
111
- status: info.status === 'running' ? 'running' : 'stopped',
112
- });
113
- });
101
+ registerNodesRoutes(app, { orchestrator, role: 'founder' });
114
102
  });
115
103
 
116
104
  afterEach(async () => {
@@ -158,17 +146,15 @@ describe('/api/nodes routes', () => {
158
146
  expect(body.data.lines).toEqual([]);
159
147
  });
160
148
 
161
- it('POST /api/nodes/:id/stop calls orchestrator and publishes exactly one event via the listener', async () => {
162
- const events: unknown[] = [];
163
- bus.subscribe((e) => events.push(e));
149
+ it('POST /api/nodes/:id/stop on a non-owner (donated) node returns 501 and stops nothing', async () => {
150
+ // The donation supervisor would respawn it at once — the stop must not look
151
+ // like it worked.
164
152
  const res = await app.inject({ method: 'POST', url: '/api/nodes/alice/stop' });
165
- expect(res.statusCode).toBe(200);
166
- expect(orchestrator.__stoppedDockerIds).toContain(SAMPLE_NODE.dockerId);
167
- // Exactly one event — the orchestrator listener publishes; the route
168
- // does not re-publish (regression guard).
169
- expect(events).toEqual([
170
- { type: 'node-state-changed', nodeId: 'alice', status: 'stopped' },
171
- ]);
153
+ expect(res.statusCode).toBe(501);
154
+ const body = res.json() as { error: { code: string; message: string } };
155
+ expect(body.error.code).toBe('not_implemented');
156
+ expect(body.error.message).toContain('cadre-host grant terminate alice');
157
+ expect(orchestrator.__stoppedDockerIds).toEqual([]);
172
158
  });
173
159
 
174
160
  it('POST /api/nodes/:id/start on a non-owner node returns 501 not_implemented', async () => {
@@ -211,11 +197,35 @@ describe('/api/nodes — owner node start/restart', () => {
211
197
  app = Fastify();
212
198
  registerErrorHandler(app);
213
199
  orchestrator = fakeOrchestrator([OWNER_NODE], { ownerId: 'owner', hasOwnerConfig: true });
214
- registerNodesRoutes(app, { orchestrator });
200
+ registerNodesRoutes(app, { orchestrator, role: 'founder' });
215
201
  });
216
202
 
217
203
  afterEach(async () => { await app.close(); });
218
204
 
205
+ it('stop calls the orchestrator and publishes exactly one event via the listener', async () => {
206
+ // Mirror what createLocalUiServer.start() does — the orchestrator listener is
207
+ // the single publication point.
208
+ const bus = new EventBus();
209
+ orchestrator.onStateChange((info) => {
210
+ bus.publish({
211
+ type: 'node-state-changed',
212
+ nodeId: info.id,
213
+ status: info.status === 'running' ? 'running' : 'stopped',
214
+ });
215
+ });
216
+ const events: unknown[] = [];
217
+ bus.subscribe((e) => events.push(e));
218
+
219
+ const res = await app.inject({ method: 'POST', url: '/api/nodes/owner/stop' });
220
+
221
+ expect(res.statusCode).toBe(200);
222
+ expect(orchestrator.__stoppedDockerIds).toEqual([OWNER_NODE.dockerId]);
223
+ // Exactly one event — the route does not re-publish (regression guard).
224
+ expect(events).toEqual([
225
+ { type: 'node-state-changed', nodeId: 'owner', status: 'stopped' },
226
+ ]);
227
+ });
228
+
219
229
  it('start ensures the owner node', async () => {
220
230
  const res = await app.inject({ method: 'POST', url: '/api/nodes/owner/start' });
221
231
  expect(res.statusCode).toBe(200);
@@ -232,11 +242,25 @@ describe('/api/nodes — owner node start/restart', () => {
232
242
  const app2 = Fastify();
233
243
  registerErrorHandler(app2);
234
244
  const orch2 = fakeOrchestrator([OWNER_NODE], { ownerId: 'owner', hasOwnerConfig: false });
235
- registerNodesRoutes(app2, { orchestrator: orch2 });
245
+ registerNodesRoutes(app2, { orchestrator: orch2, role: 'founder' });
236
246
  const res = await app2.inject({ method: 'POST', url: '/api/nodes/owner/start' });
237
247
  expect(res.statusCode).toBe(501);
238
248
  await app2.close();
239
249
  });
250
+ it('start and restart answer 409 own_cadre_disabled on a donor-only host that kept a saved owner config', async () => {
251
+ const donorApp = Fastify();
252
+ registerErrorHandler(donorApp);
253
+ const donorOrch = fakeOrchestrator([OWNER_NODE], { ownerId: 'owner', hasOwnerConfig: true });
254
+ registerNodesRoutes(donorApp, { orchestrator: donorOrch, role: 'donor' });
255
+ for (const verb of ['start', 'restart']) {
256
+ const res = await donorApp.inject({ method: 'POST', url: `/api/nodes/owner/${verb}` });
257
+ expect(res.statusCode).toBe(409);
258
+ expect(res.json().error.code).toBe('own_cadre_disabled');
259
+ }
260
+ expect(donorOrch.__ensured).toBe(0);
261
+ expect(donorOrch.__restarted).toBe(0);
262
+ await donorApp.close();
263
+ });
240
264
  });
241
265
 
242
266
  describe('tailLogFile', () => {
@@ -14,44 +14,12 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
14
14
  import { createLocalUiServer } from '../index.js';
15
15
  import { EventBus } from '../events/bus.js';
16
16
  import type { LocalUiEvent } from '../events/types.js';
17
+ import { fakeFounder } from './fakes.js';
17
18
  import type { HostProcessOrchestrator } from '../../orchestrator/index.js';
18
19
  import type { ManagedNodeInfo, NodeStateListener } from '../../orchestrator/types.js';
19
- import type { TrustCircleService } from '../../auth/index.js';
20
- import type { NatService } from '../../nat/index.js';
21
- import type { NatStatusSnapshot } from '../../nat/types.js';
22
20
  import type { UpdateService } from '../../update/index.js';
23
21
  import type { UpdateState } from '../../update/types.js';
24
22
 
25
- const CONNECTIVITY: NatStatusSnapshot = {
26
- portMode: 'auto-upnp',
27
- externalPort: 4001,
28
- internalPort: 4001,
29
- routerExternalIp: '203.0.113.1',
30
- mappingLeaseExpiresAt: null,
31
- externalIp: '203.0.113.1',
32
- externalIpDetectedAt: null,
33
- cgnatDetected: false,
34
- directReachability: 'reachable',
35
- lastTestedAt: null,
36
- ddns: {
37
- providerId: null,
38
- hostname: null,
39
- externallyManaged: false,
40
- lastUpdateAt: null,
41
- lastUpdateOk: null,
42
- lastError: null,
43
- },
44
- };
45
-
46
- function fakeTrust(): TrustCircleService {
47
- return {
48
- list: async () => ({ members: [], pending: [] }),
49
- } as unknown as TrustCircleService;
50
- }
51
- function fakeNat(): NatService {
52
- return { getStatus: () => CONNECTIVITY, putSettings: async () => CONNECTIVITY } as unknown as NatService;
53
- }
54
-
55
23
  interface ManualOrchestrator extends HostProcessOrchestrator {
56
24
  __emit: (info: ManagedNodeInfo) => void;
57
25
  }
@@ -117,8 +85,7 @@ describe('publisher wiring', () => {
117
85
  uiPort: 8765,
118
86
  dataDir,
119
87
  orchestrator,
120
- trustCircle: fakeTrust(),
121
- nat: fakeNat(),
88
+ founder: fakeFounder(),
122
89
  events: bus,
123
90
  forcePort: 0,
124
91
  });
@@ -146,8 +113,7 @@ describe('publisher wiring', () => {
146
113
  uiPort: 8765,
147
114
  dataDir,
148
115
  orchestrator,
149
- trustCircle: fakeTrust(),
150
- nat: fakeNat(),
116
+ founder: fakeFounder(),
151
117
  events: bus,
152
118
  forcePort: 0,
153
119
  });
@@ -168,8 +134,7 @@ describe('publisher wiring', () => {
168
134
  uiPort: 8765,
169
135
  dataDir,
170
136
  orchestrator,
171
- trustCircle: fakeTrust(),
172
- nat: fakeNat(),
137
+ founder: fakeFounder(),
173
138
  events: bus,
174
139
  update: update as unknown as UpdateService,
175
140
  forcePort: 0,
@@ -194,8 +159,7 @@ describe('publisher wiring', () => {
194
159
  uiPort: 8765,
195
160
  dataDir,
196
161
  orchestrator,
197
- trustCircle: fakeTrust(),
198
- nat: fakeNat(),
162
+ founder: fakeFounder(),
199
163
  events: bus,
200
164
  update: update as unknown as UpdateService,
201
165
  forcePort: 0,