@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.
Files changed (136) hide show
  1. package/dist/auth/trust-circle.d.ts +5 -5
  2. package/dist/auth/trust-circle.js +10 -10
  3. package/dist/auth/trust-circle.js.map +1 -1
  4. package/dist/auth/types.d.ts +2 -2
  5. package/dist/bin/host.js +255 -54
  6. package/dist/bin/host.js.map +1 -1
  7. package/dist/donation/donation-service.d.ts +136 -0
  8. package/dist/donation/donation-service.js +306 -0
  9. package/dist/donation/donation-service.js.map +1 -0
  10. package/dist/donation/donation-store.d.ts +42 -0
  11. package/dist/donation/donation-store.js +141 -0
  12. package/dist/donation/donation-store.js.map +1 -0
  13. package/dist/donation/grant-service.d.ts +71 -0
  14. package/dist/donation/grant-service.js +163 -0
  15. package/dist/donation/grant-service.js.map +1 -0
  16. package/dist/donation/grant-store.d.ts +38 -0
  17. package/dist/donation/grant-store.js +135 -0
  18. package/dist/donation/grant-store.js.map +1 -0
  19. package/dist/donation/index.d.ts +18 -0
  20. package/dist/donation/index.js +16 -0
  21. package/dist/donation/index.js.map +1 -0
  22. package/dist/donation/types.d.ts +175 -0
  23. package/dist/donation/types.js +40 -0
  24. package/dist/donation/types.js.map +1 -0
  25. package/dist/index.d.ts +6 -4
  26. package/dist/index.js +4 -2
  27. package/dist/index.js.map +1 -1
  28. package/dist/installer/config.d.ts +26 -0
  29. package/dist/installer/config.js +13 -0
  30. package/dist/installer/config.js.map +1 -1
  31. package/dist/installer/index.d.ts +2 -0
  32. package/dist/installer/index.js +3 -0
  33. package/dist/installer/index.js.map +1 -1
  34. package/dist/installer/wizard.d.ts +4 -0
  35. package/dist/installer/wizard.js +5 -1
  36. package/dist/installer/wizard.js.map +1 -1
  37. package/dist/nat/nat-service.d.ts +5 -5
  38. package/dist/nat/nat-service.js +9 -9
  39. package/dist/nat/nat-service.js.map +1 -1
  40. package/dist/nat/types.d.ts +1 -1
  41. package/dist/orchestrator/host-process-orchestrator.d.ts +35 -27
  42. package/dist/orchestrator/host-process-orchestrator.js +79 -53
  43. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  44. package/dist/orchestrator/index.d.ts +3 -3
  45. package/dist/orchestrator/index.js +1 -1
  46. package/dist/orchestrator/index.js.map +1 -1
  47. package/dist/orchestrator/state-store.d.ts +5 -5
  48. package/dist/orchestrator/state-store.js +2 -2
  49. package/dist/orchestrator/state-store.js.map +1 -1
  50. package/dist/orchestrator/types.d.ts +8 -8
  51. package/dist/owner/index.d.ts +11 -0
  52. package/dist/owner/index.js +11 -0
  53. package/dist/owner/index.js.map +1 -0
  54. package/dist/{authority/authority-node-client.d.ts → owner/owner-node-client.d.ts} +32 -14
  55. package/dist/{authority/authority-node-client.js → owner/owner-node-client.js} +39 -15
  56. package/dist/owner/owner-node-client.js.map +1 -0
  57. package/dist/server/error-handler.d.ts +15 -0
  58. package/dist/server/error-handler.js +41 -0
  59. package/dist/server/error-handler.js.map +1 -1
  60. package/dist/server/index.d.ts +26 -2
  61. package/dist/server/index.js +37 -16
  62. package/dist/server/index.js.map +1 -1
  63. package/dist/server/routes/grants-admin.d.ts +17 -0
  64. package/dist/server/routes/grants-admin.js +32 -0
  65. package/dist/server/routes/grants-admin.js.map +1 -0
  66. package/dist/server/routes/grants.d.ts +28 -0
  67. package/dist/server/routes/grants.js +163 -0
  68. package/dist/server/routes/grants.js.map +1 -0
  69. package/dist/server/routes/nodes.d.ts +5 -4
  70. package/dist/server/routes/nodes.js +19 -18
  71. package/dist/server/routes/nodes.js.map +1 -1
  72. package/dist/server/routes/settings.d.ts +6 -1
  73. package/dist/server/routes/settings.js +5 -2
  74. package/dist/server/routes/settings.js.map +1 -1
  75. package/dist/server/routes/status.d.ts +8 -4
  76. package/dist/server/routes/status.js +9 -4
  77. package/dist/server/routes/status.js.map +1 -1
  78. package/dist/update/release-key.js +7 -5
  79. package/dist/update/release-key.js.map +1 -1
  80. package/package.json +4 -4
  81. package/src/__tests__/{host-authority.smoke.test.ts → host-owner.smoke.test.ts} +7 -7
  82. package/src/__tests__/{orchestrator-authority.test.ts → orchestrator-owner.test.ts} +33 -33
  83. package/src/__tests__/orchestrator-pin-keys.test.ts +153 -0
  84. package/src/__tests__/orchestrator-push.test.ts +10 -10
  85. package/src/auth/__tests__/trust-circle-integration.test.ts +11 -11
  86. package/src/auth/__tests__/trust-circle.test.ts +10 -10
  87. package/src/auth/trust-circle.ts +10 -10
  88. package/src/auth/types.ts +2 -2
  89. package/src/bin/host.ts +273 -53
  90. package/src/donation/__tests__/donation-service.test.ts +290 -0
  91. package/src/donation/__tests__/grant-service.test.ts +213 -0
  92. package/src/donation/__tests__/grant-store.test.ts +113 -0
  93. package/src/donation/donation-service.ts +392 -0
  94. package/src/donation/donation-store.ts +164 -0
  95. package/src/donation/grant-service.ts +200 -0
  96. package/src/donation/grant-store.ts +152 -0
  97. package/src/donation/index.ts +49 -0
  98. package/src/donation/types.ts +220 -0
  99. package/src/index.ts +37 -5
  100. package/src/installer/__tests__/config.test.ts +27 -1
  101. package/src/installer/__tests__/installer.smoke.test.ts +31 -1
  102. package/src/installer/__tests__/wizard.test.ts +5 -1
  103. package/src/installer/config.ts +34 -0
  104. package/src/installer/index.ts +5 -0
  105. package/src/installer/wizard.ts +12 -1
  106. package/src/nat/__tests__/nat-service.test.ts +5 -5
  107. package/src/nat/nat-service.ts +10 -10
  108. package/src/nat/types.ts +1 -1
  109. package/src/orchestrator/host-process-orchestrator.ts +88 -60
  110. package/src/orchestrator/index.ts +3 -3
  111. package/src/orchestrator/state-store.ts +7 -7
  112. package/src/orchestrator/types.ts +8 -8
  113. package/src/{authority/__tests__/authority-node-client.test.ts → owner/__tests__/owner-node-client.test.ts} +45 -17
  114. package/src/owner/index.ts +12 -0
  115. package/src/{authority/authority-node-client.ts → owner/owner-node-client.ts} +55 -21
  116. package/src/server/__tests__/error-handler.test.ts +22 -0
  117. package/src/server/__tests__/grants-admin-route.test.ts +73 -0
  118. package/src/server/__tests__/grants-route.test.ts +189 -0
  119. package/src/server/__tests__/nodes-route.test.ts +25 -25
  120. package/src/server/__tests__/server.smoke.test.ts +75 -0
  121. package/src/server/__tests__/settings-route.test.ts +17 -0
  122. package/src/server/__tests__/status-route.test.ts +21 -0
  123. package/src/server/error-handler.ts +43 -0
  124. package/src/server/index.ts +62 -17
  125. package/src/server/routes/grants-admin.ts +41 -0
  126. package/src/server/routes/grants.ts +186 -0
  127. package/src/server/routes/nodes.ts +19 -18
  128. package/src/server/routes/settings.ts +11 -3
  129. package/src/server/routes/status.ts +18 -8
  130. package/src/update/__tests__/release-key.test.ts +16 -3
  131. package/src/update/release-key.ts +7 -5
  132. package/dist/authority/authority-node-client.js.map +0 -1
  133. package/dist/authority/index.d.ts +0 -11
  134. package/dist/authority/index.js +0 -11
  135. package/dist/authority/index.js.map +0 -1
  136. package/src/authority/index.ts +0 -12
@@ -1,69 +1,70 @@
1
1
  /**
2
- * HTTP client for the authority node's loopback admin channel (the 6.6
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 authority node as a managed child (see `HostProcessOrchestrator`)
7
- * and delegates all authority/membership/identity operations to it over this
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 AuthorityNodeUnavailableError}; the trust-circle / NAT services
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 { AuthorityAdminEndpoint } from '../orchestrator/index.js';
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:authority-client');
29
+ const log = debug('cadre:host:owner-client');
29
30
 
30
31
  /**
31
- * Raised when the authority node's admin channel can't be reached or returns
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 AuthorityNodeUnavailableError extends Error {
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 = 'AuthorityNodeUnavailableError';
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
- | AuthorityAdminEndpoint
49
- | (() => AuthorityAdminEndpoint | undefined)
49
+ | OwnerAdminEndpoint
50
+ | (() => OwnerAdminEndpoint | undefined)
50
51
  | undefined;
51
52
 
52
- export interface AuthorityNodeClientOptions {
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 AuthorityNodeClient implements TrustCircleCadreNodeLike, NatCadreNodeLike {
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.getAuthorityAdminEndpoint`
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: AuthorityNodeClientOptions = {}) {
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(): AuthorityAdminEndpoint {
181
+ private resolveEndpoint(): OwnerAdminEndpoint {
148
182
  const ep = typeof this.endpointSource === 'function' ? this.endpointSource() : this.endpointSource;
149
183
  if (!ep) {
150
- throw new AuthorityNodeUnavailableError('Authority node admin endpoint is not available');
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 AuthorityNodeUnavailableError(`Authority node unreachable: ${message}`);
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 AuthorityNodeUnavailableError(message, code);
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: { authorityId?: string; hasAuthorityConfig?: boolean } = {},
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
- isAuthorityNode: (id: string) => {
45
- if (!opts.authorityId) return false;
44
+ isOwnerNode: (id: string) => {
45
+ if (!opts.ownerId) return false;
46
46
  const node = findById(id);
47
- return id === opts.authorityId || node?.id === opts.authorityId;
47
+ return id === opts.ownerId || node?.id === opts.ownerId;
48
48
  },
49
- hasAuthorityConfig: () => opts.hasAuthorityConfig ?? false,
50
- ensureAuthorityNode: async () => {
49
+ hasOwnerConfig: () => opts.hasOwnerConfig ?? false,
50
+ ensureOwnerNode: async () => {
51
51
  counters.ensured++;
52
- const node = opts.authorityId ? findById(opts.authorityId) : undefined;
53
- return node ?? { id: opts.authorityId, status: 'running' } as unknown as ManagedNodeInfo;
52
+ const node = opts.ownerId ? findById(opts.ownerId) : undefined;
53
+ return node ?? { id: opts.ownerId, status: 'running' } as unknown as ManagedNodeInfo;
54
54
  },
55
- restartAuthorityNode: async () => {
55
+ restartOwnerNode: async () => {
56
56
  counters.restarted++;
57
- const node = opts.authorityId ? findById(opts.authorityId) : undefined;
58
- return node ?? { id: opts.authorityId, status: 'running' } as unknown as ManagedNodeInfo;
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-authority node returns 501 not_implemented', async () => {
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-authority node returns 501 not_implemented', async () => {
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 — authority node start/restart', () => {
195
- const AUTHORITY_NODE: ManagedNodeInfo = {
196
- id: 'authority',
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
- authority: true,
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([AUTHORITY_NODE], { authorityId: 'authority', hasAuthorityConfig: true });
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 authority node', async () => {
220
- const res = await app.inject({ method: 'POST', url: '/api/nodes/authority/start' });
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 authority node', async () => {
226
- const res = await app.inject({ method: 'POST', url: '/api/nodes/authority/restart' });
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 authority config', async () => {
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([AUTHORITY_NODE], { authorityId: 'authority', hasAuthorityConfig: false });
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/authority/start' });
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
  });