@serfab/cadre-host 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/dist/auth/index.d.ts +2 -0
  2. package/dist/auth/index.js +1 -0
  3. package/dist/auth/index.js.map +1 -1
  4. package/dist/auth/self-label.d.ts +36 -0
  5. package/dist/auth/self-label.js +57 -0
  6. package/dist/auth/self-label.js.map +1 -0
  7. package/dist/auth/trust-circle.d.ts +36 -9
  8. package/dist/auth/trust-circle.js +40 -20
  9. package/dist/auth/trust-circle.js.map +1 -1
  10. package/dist/auth/types.d.ts +2 -2
  11. package/dist/bin/host.js +291 -54
  12. package/dist/bin/host.js.map +1 -1
  13. package/dist/donation/__tests__/fake-orchestrator.d.ts +103 -0
  14. package/dist/donation/__tests__/fake-orchestrator.js +180 -0
  15. package/dist/donation/__tests__/fake-orchestrator.js.map +1 -0
  16. package/dist/donation/donation-service.d.ts +361 -0
  17. package/dist/donation/donation-service.js +763 -0
  18. package/dist/donation/donation-service.js.map +1 -0
  19. package/dist/donation/donation-store.d.ts +49 -0
  20. package/dist/donation/donation-store.js +148 -0
  21. package/dist/donation/donation-store.js.map +1 -0
  22. package/dist/donation/donation-supervisor.d.ts +167 -0
  23. package/dist/donation/donation-supervisor.js +341 -0
  24. package/dist/donation/donation-supervisor.js.map +1 -0
  25. package/dist/donation/grant-service.d.ts +71 -0
  26. package/dist/donation/grant-service.js +163 -0
  27. package/dist/donation/grant-service.js.map +1 -0
  28. package/dist/donation/grant-store.d.ts +38 -0
  29. package/dist/donation/grant-store.js +135 -0
  30. package/dist/donation/grant-store.js.map +1 -0
  31. package/dist/donation/index.d.ts +20 -0
  32. package/dist/donation/index.js +17 -0
  33. package/dist/donation/index.js.map +1 -0
  34. package/dist/donation/types.d.ts +201 -0
  35. package/dist/donation/types.js +40 -0
  36. package/dist/donation/types.js.map +1 -0
  37. package/dist/index.d.ts +9 -5
  38. package/dist/index.js +7 -3
  39. package/dist/index.js.map +1 -1
  40. package/dist/installer/config.d.ts +26 -0
  41. package/dist/installer/config.js +13 -0
  42. package/dist/installer/config.js.map +1 -1
  43. package/dist/installer/index.d.ts +2 -0
  44. package/dist/installer/index.js +3 -0
  45. package/dist/installer/index.js.map +1 -1
  46. package/dist/installer/wizard.d.ts +4 -0
  47. package/dist/installer/wizard.js +5 -1
  48. package/dist/installer/wizard.js.map +1 -1
  49. package/dist/nat/nat-service.d.ts +5 -5
  50. package/dist/nat/nat-service.js +9 -9
  51. package/dist/nat/nat-service.js.map +1 -1
  52. package/dist/nat/types.d.ts +1 -1
  53. package/dist/orchestrator/host-process-orchestrator.d.ts +144 -32
  54. package/dist/orchestrator/host-process-orchestrator.js +397 -128
  55. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  56. package/dist/orchestrator/index.d.ts +3 -3
  57. package/dist/orchestrator/index.js +1 -1
  58. package/dist/orchestrator/index.js.map +1 -1
  59. package/dist/orchestrator/node-identity.d.ts +26 -0
  60. package/dist/orchestrator/node-identity.js +36 -0
  61. package/dist/orchestrator/node-identity.js.map +1 -0
  62. package/dist/orchestrator/port-allocator.d.ts +14 -0
  63. package/dist/orchestrator/port-allocator.js +45 -0
  64. package/dist/orchestrator/port-allocator.js.map +1 -1
  65. package/dist/orchestrator/state-store.d.ts +5 -5
  66. package/dist/orchestrator/state-store.js +2 -2
  67. package/dist/orchestrator/state-store.js.map +1 -1
  68. package/dist/orchestrator/types.d.ts +8 -8
  69. package/dist/owner/index.d.ts +11 -0
  70. package/dist/owner/index.js +11 -0
  71. package/dist/owner/index.js.map +1 -0
  72. package/dist/owner/owner-node-client.d.ts +110 -0
  73. package/dist/{authority/authority-node-client.js → owner/owner-node-client.js} +58 -17
  74. package/dist/owner/owner-node-client.js.map +1 -0
  75. package/dist/server/error-handler.d.ts +22 -1
  76. package/dist/server/error-handler.js +64 -1
  77. package/dist/server/error-handler.js.map +1 -1
  78. package/dist/server/events/types.d.ts +3 -0
  79. package/dist/server/index.d.ts +33 -2
  80. package/dist/server/index.js +43 -16
  81. package/dist/server/index.js.map +1 -1
  82. package/dist/server/routes/grants-admin.d.ts +17 -0
  83. package/dist/server/routes/grants-admin.js +32 -0
  84. package/dist/server/routes/grants-admin.js.map +1 -0
  85. package/dist/server/routes/grants.d.ts +28 -0
  86. package/dist/server/routes/grants.js +182 -0
  87. package/dist/server/routes/grants.js.map +1 -0
  88. package/dist/server/routes/nodes.d.ts +5 -4
  89. package/dist/server/routes/nodes.js +19 -18
  90. package/dist/server/routes/nodes.js.map +1 -1
  91. package/dist/server/routes/settings.d.ts +6 -1
  92. package/dist/server/routes/settings.js +5 -2
  93. package/dist/server/routes/settings.js.map +1 -1
  94. package/dist/server/routes/status.d.ts +8 -4
  95. package/dist/server/routes/status.js +9 -4
  96. package/dist/server/routes/status.js.map +1 -1
  97. package/dist/server/routes/strands.d.ts +20 -0
  98. package/dist/server/routes/strands.js +38 -0
  99. package/dist/server/routes/strands.js.map +1 -0
  100. package/dist/strands/index.d.ts +7 -0
  101. package/dist/strands/index.js +6 -0
  102. package/dist/strands/index.js.map +1 -0
  103. package/dist/strands/strand-service.d.ts +50 -0
  104. package/dist/strands/strand-service.js +103 -0
  105. package/dist/strands/strand-service.js.map +1 -0
  106. package/dist/strands/types.d.ts +93 -0
  107. package/dist/strands/types.js +23 -0
  108. package/dist/strands/types.js.map +1 -0
  109. package/dist/ui/assets/index-BVoqLjyX.css +1 -0
  110. package/dist/ui/assets/index-C-iks41M.js +17 -0
  111. package/dist/ui/assets/index-C-iks41M.js.map +1 -0
  112. package/dist/ui/index.html +2 -2
  113. package/dist/update/release-key.d.ts +1 -1
  114. package/dist/update/release-key.js +8 -6
  115. package/dist/update/release-key.js.map +1 -1
  116. package/package.json +5 -5
  117. package/src/__tests__/build-targets.test.ts +26 -0
  118. package/src/__tests__/global-setup.ts +51 -0
  119. package/src/__tests__/{host-authority.smoke.test.ts → host-owner.smoke.test.ts} +7 -7
  120. package/src/__tests__/orchestrator-env-scrub.test.ts +140 -0
  121. package/src/__tests__/orchestrator-node-identity.test.ts +204 -0
  122. package/src/__tests__/{orchestrator-authority.test.ts → orchestrator-owner.test.ts} +33 -33
  123. package/src/__tests__/orchestrator-pin-keys.test.ts +153 -0
  124. package/src/__tests__/orchestrator-push.test.ts +10 -10
  125. package/src/__tests__/orchestrator.test.ts +387 -6
  126. package/src/auth/__tests__/self-label.test.ts +85 -0
  127. package/src/auth/__tests__/trust-circle-integration.test.ts +152 -147
  128. package/src/auth/__tests__/trust-circle.test.ts +55 -11
  129. package/src/auth/index.ts +2 -0
  130. package/src/auth/self-label.ts +74 -0
  131. package/src/auth/trust-circle.ts +54 -22
  132. package/src/auth/types.ts +2 -2
  133. package/src/bin/host.ts +1299 -1038
  134. package/src/donation/__tests__/donation-service.test.ts +1068 -0
  135. package/src/donation/__tests__/donation-supervisor.test.ts +510 -0
  136. package/src/donation/__tests__/fake-orchestrator.test.ts +168 -0
  137. package/src/donation/__tests__/fake-orchestrator.ts +225 -0
  138. package/src/donation/__tests__/grant-service.test.ts +213 -0
  139. package/src/donation/__tests__/grant-store.test.ts +113 -0
  140. package/src/donation/donation-service.ts +962 -0
  141. package/src/donation/donation-store.ts +171 -0
  142. package/src/donation/donation-supervisor.ts +388 -0
  143. package/src/donation/grant-service.ts +200 -0
  144. package/src/donation/grant-store.ts +152 -0
  145. package/src/donation/index.ts +64 -0
  146. package/src/donation/types.ts +243 -0
  147. package/src/index.ts +51 -5
  148. package/src/installer/__tests__/config.test.ts +27 -1
  149. package/src/installer/__tests__/installer.smoke.test.ts +31 -1
  150. package/src/installer/__tests__/wizard.test.ts +5 -1
  151. package/src/installer/config.ts +34 -0
  152. package/src/installer/index.ts +5 -0
  153. package/src/installer/wizard.ts +12 -1
  154. package/src/nat/__tests__/nat-service.test.ts +5 -5
  155. package/src/nat/nat-service.ts +10 -10
  156. package/src/nat/types.ts +1 -1
  157. package/src/orchestrator/__tests__/node-identity.test.ts +62 -0
  158. package/src/orchestrator/host-process-orchestrator.ts +401 -134
  159. package/src/orchestrator/index.ts +3 -3
  160. package/src/orchestrator/node-identity.ts +40 -0
  161. package/src/orchestrator/port-allocator.ts +48 -0
  162. package/src/orchestrator/state-store.ts +7 -7
  163. package/src/orchestrator/types.ts +8 -8
  164. package/src/{authority/__tests__/authority-node-client.test.ts → owner/__tests__/owner-node-client.test.ts} +45 -17
  165. package/src/owner/index.ts +12 -0
  166. package/src/{authority/authority-node-client.ts → owner/owner-node-client.ts} +83 -23
  167. package/src/server/__tests__/error-handler.test.ts +22 -0
  168. package/src/server/__tests__/grants-admin-route.test.ts +73 -0
  169. package/src/server/__tests__/grants-route.test.ts +263 -0
  170. package/src/server/__tests__/nodes-route.test.ts +25 -25
  171. package/src/server/__tests__/server.smoke.test.ts +75 -0
  172. package/src/server/__tests__/settings-route.test.ts +17 -0
  173. package/src/server/__tests__/status-route.test.ts +21 -0
  174. package/src/server/__tests__/strands-route.test.ts +233 -0
  175. package/src/server/error-handler.ts +67 -1
  176. package/src/server/events/types.ts +1 -0
  177. package/src/server/index.ts +75 -17
  178. package/src/server/routes/grants-admin.ts +41 -0
  179. package/src/server/routes/grants.ts +210 -0
  180. package/src/server/routes/nodes.ts +19 -18
  181. package/src/server/routes/settings.ts +11 -3
  182. package/src/server/routes/status.ts +18 -8
  183. package/src/server/routes/strands.ts +54 -0
  184. package/src/strands/__tests__/strand-service.test.ts +165 -0
  185. package/src/strands/index.ts +14 -0
  186. package/src/strands/strand-service.ts +135 -0
  187. package/src/strands/types.ts +102 -0
  188. package/src/update/__tests__/release-key.test.ts +16 -3
  189. package/src/update/release-key.ts +8 -6
  190. package/dist/authority/authority-node-client.d.ts +0 -77
  191. package/dist/authority/authority-node-client.js.map +0 -1
  192. package/dist/authority/index.d.ts +0 -11
  193. package/dist/authority/index.js +0 -11
  194. package/dist/authority/index.js.map +0 -1
  195. package/dist/ui/assets/index-BlMXQPSw.js +0 -14
  196. package/dist/ui/assets/index-BlMXQPSw.js.map +0 -1
  197. package/dist/ui/assets/index-BwU5mJGF.css +0 -1
  198. package/src/authority/index.ts +0 -12
@@ -1,10 +1,10 @@
1
- export { HostProcessOrchestrator, AUTHORITY_CONTAINER_ID } from './host-process-orchestrator.js';
2
- export type { AuthorityAdminEndpoint } from './host-process-orchestrator.js';
1
+ export { HostProcessOrchestrator, OWNER_CONTAINER_ID } from './host-process-orchestrator.js';
2
+ export type { OwnerAdminEndpoint } from './host-process-orchestrator.js';
3
3
  export type {
4
4
  HostProcessConfig,
5
5
  ManagedNodeInfo,
6
6
  NodeStateListener,
7
7
  NodePorts,
8
- AuthoritySpawnConfig,
8
+ OwnerSpawnConfig,
9
9
  } from './types.js';
10
10
  export type { PersistedHandle } from './state-store.js';
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Per-node identity keys for orchestrator-managed children.
3
+ *
4
+ * A donated node joins a *foreign* cadre: the requester approves its peer id
5
+ * and its seed nominates dial addresses keyed to that peer id. Both bind to the
6
+ * identity, so a node that generated a fresh libp2p keypair per process would
7
+ * become a stranger to the cadre that admitted it on every restart. It also
8
+ * needs a stable on-disk home for its node-local stores — `cadre-cli start`
9
+ * opens `FileBootstrapPeerStore` / `FileTrustedOwnerStore` only when a protobuf
10
+ * identity key file is configured.
11
+ *
12
+ * So every managed node gets its own `identity.key` inside its workdir, written
13
+ * once and reused thereafter. The workdir is removed when the loan is
14
+ * terminated (`removeContainer`), so nothing the node persisted outlives it.
15
+ */
16
+
17
+ import { existsSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+
20
+ import { generateIdentity, loadIdentity, type IdentityRecord } from '../installer/identity.js';
21
+
22
+ const IDENTITY_FILE = 'identity.key';
23
+
24
+ /** Path to a managed node's own protobuf identity key inside its workdir. */
25
+ export function nodeIdentityPath(workdir: string): string {
26
+ return join(workdir, IDENTITY_FILE);
27
+ }
28
+
29
+ /**
30
+ * Ensure `<workdir>/identity.key` exists, generating an Ed25519 protobuf key on
31
+ * first call and REUSING it on every later call. Reuse is the load-bearing
32
+ * property: a re-spawn of the same container must land on the same peer id.
33
+ */
34
+ export async function ensureNodeIdentity(workdir: string): Promise<{ path: string; peerId: string }> {
35
+ const path = nodeIdentityPath(workdir);
36
+ const record: IdentityRecord = existsSync(path)
37
+ ? loadIdentity(path)
38
+ : await generateIdentity(path);
39
+ return { path, peerId: record.peerId };
40
+ }
@@ -1,3 +1,5 @@
1
+ import type { NodePorts } from './types.js';
2
+
1
3
  /**
2
4
  * Range-based port tracker. Allocations are in-memory; on orchestrator
3
5
  * restart the caller rehydrates the used-set from persisted state via
@@ -41,3 +43,49 @@ export class PortAllocator {
41
43
  return this.usedPorts.has(port);
42
44
  }
43
45
  }
46
+
47
+ /**
48
+ * Allocation order for a node's port set. Fixed so that swapping a caller onto
49
+ * {@link allocateNodePorts} does not shift the ports an existing deployment
50
+ * already handed out.
51
+ */
52
+ const NODE_PORT_KEYS = ['health', 'metrics', 'p2p', 'admin'] as const;
53
+
54
+ /**
55
+ * Allocate a full NodePorts set atomically — a mid-way failure (an exhausted
56
+ * range) releases everything already taken rather than stranding the ports it
57
+ * got before the throw. `overrides` supply a port instead of allocating one
58
+ * (the owner node's p2p port, which is fixed by NAT config).
59
+ *
60
+ * Overrides are reserved FIRST, before any allocation, so an override that
61
+ * happens to sit inside the managed range cannot also be handed out to another
62
+ * key. Reserving is `markUsed`, a documented no-op outside the range — which is
63
+ * the production case for the owner node's libp2p port, and why reserving first
64
+ * leaves every real port assignment unchanged.
65
+ */
66
+ export function allocateNodePorts(
67
+ allocator: PortAllocator,
68
+ overrides?: Partial<NodePorts>,
69
+ ): NodePorts {
70
+ const reserved: number[] = [];
71
+ const ports: Partial<NodePorts> = {};
72
+ try {
73
+ for (const key of NODE_PORT_KEYS) {
74
+ const override = overrides?.[key];
75
+ if (override === undefined) continue;
76
+ allocator.markUsed(override);
77
+ reserved.push(override);
78
+ ports[key] = override;
79
+ }
80
+ for (const key of NODE_PORT_KEYS) {
81
+ if (ports[key] !== undefined) continue;
82
+ const port = allocator.allocate();
83
+ reserved.push(port);
84
+ ports[key] = port;
85
+ }
86
+ } catch (err) {
87
+ for (const port of reserved) allocator.release(port);
88
+ throw err;
89
+ }
90
+ return ports as NodePorts;
91
+ }
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, renameSync, writeFileSync, mkdirSync } from '
2
2
  import { dirname, join } from 'node:path';
3
3
  import debug from 'debug';
4
4
 
5
- import type { AuthoritySpawnConfig, NodePorts } from './types.js';
5
+ import type { OwnerSpawnConfig, NodePorts } from './types.js';
6
6
 
7
7
  const log = debug('cadre:host:state-store');
8
8
 
@@ -21,15 +21,15 @@ export interface PersistedHandle {
21
21
  spawnedAt: string; // ISO timestamp
22
22
  partyId: string;
23
23
  profile: 'storage' | 'transaction';
24
- /** True for the admin's authority node. */
25
- authority?: boolean;
24
+ /** True for the admin's owner node. */
25
+ owner?: boolean;
26
26
  }
27
27
 
28
28
  export interface PersistedState {
29
29
  version: 1;
30
30
  handles: PersistedHandle[];
31
- /** Spawn parameters for the authority node, so it can be re-spawned on demand. */
32
- authorityConfig?: AuthoritySpawnConfig;
31
+ /** Spawn parameters for the owner node, so it can be re-spawned on demand. */
32
+ ownerConfig?: OwnerSpawnConfig;
33
33
  }
34
34
 
35
35
  const STATE_VERSION = 1;
@@ -57,7 +57,7 @@ export class StateStore {
57
57
  return {
58
58
  version: STATE_VERSION,
59
59
  handles: parsed.handles,
60
- ...(parsed.authorityConfig ? { authorityConfig: parsed.authorityConfig } : {}),
60
+ ...(parsed.ownerConfig ? { ownerConfig: parsed.ownerConfig } : {}),
61
61
  };
62
62
  } catch (err) {
63
63
  // A corrupt state file is operationally significant — surviving children
@@ -74,7 +74,7 @@ export class StateStore {
74
74
  {
75
75
  version: STATE_VERSION,
76
76
  handles: state.handles,
77
- ...(state.authorityConfig ? { authorityConfig: state.authorityConfig } : {}),
77
+ ...(state.ownerConfig ? { ownerConfig: state.ownerConfig } : {}),
78
78
  },
79
79
  null,
80
80
  2,
@@ -47,7 +47,7 @@ export interface NodePorts {
47
47
  health: number;
48
48
  metrics: number;
49
49
  p2p: number;
50
- /** Loopback admin-channel port. Bound only by the authority node. */
50
+ /** Loopback admin-channel port. Bound only by the owner node. */
51
51
  admin: number;
52
52
  }
53
53
 
@@ -62,8 +62,8 @@ export interface Handle {
62
62
  spawnedAt: string;
63
63
  partyId: string;
64
64
  profile: 'storage' | 'transaction';
65
- /** True for the admin's authority node (binds the admin channel). */
66
- authority?: boolean;
65
+ /** True for the admin's owner node (binds the admin channel). */
66
+ owner?: boolean;
67
67
  /** Live ChildProcess reference; absent after re-attach via init(). */
68
68
  child?: ChildProcess;
69
69
  /** Marked false when init() finds the PID dead or token mismatched. */
@@ -83,16 +83,16 @@ export interface ManagedNodeInfo {
83
83
  spawnedAt: string;
84
84
  workdir: string;
85
85
  ports: NodePorts;
86
- /** True for the admin's authority node. */
87
- authority?: boolean;
86
+ /** True for the admin's owner node. */
87
+ owner?: boolean;
88
88
  }
89
89
 
90
90
  /**
91
- * Persisted spawn parameters for the admin's authority node. Stored alongside
92
- * the handles so an orchestrator restart can re-spawn the authority node from
91
+ * Persisted spawn parameters for the admin's owner node. Stored alongside
92
+ * the handles so an orchestrator restart can re-spawn the owner node from
93
93
  * `/api/nodes/:id/{start,restart}` without the caller re-supplying them.
94
94
  */
95
- export interface AuthoritySpawnConfig {
95
+ export interface OwnerSpawnConfig {
96
96
  /** Absolute path to the host's libp2p protobuf identity (`identity.key`). */
97
97
  identityPath: string;
98
98
  /** Control-network partyId (the host's installId). */
@@ -1,8 +1,8 @@
1
1
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
2
  import http from 'node:http';
3
3
 
4
- import { AuthorityNodeClient, AuthorityNodeUnavailableError } from '../authority-node-client.js';
5
- import type { AuthorityAdminEndpoint } from '../../orchestrator/index.js';
4
+ import { OwnerNodeClient, OwnerNodeUnavailableError } from '../owner-node-client.js';
5
+ import type { OwnerAdminEndpoint } from '../../orchestrator/index.js';
6
6
 
7
7
  /**
8
8
  * Stub HTTP server matching the 6.6 admin contract. Records the last request
@@ -52,16 +52,16 @@ afterEach(async () => {
52
52
  await new Promise<void>((resolve) => server.close(() => resolve()));
53
53
  });
54
54
 
55
- function makeClient(): AuthorityNodeClient {
56
- const endpoint: AuthorityAdminEndpoint = { baseUrl, token: TOKEN };
57
- return new AuthorityNodeClient(endpoint);
55
+ function makeClient(): OwnerNodeClient {
56
+ const endpoint: OwnerAdminEndpoint = { baseUrl, token: TOKEN };
57
+ return new OwnerNodeClient(endpoint);
58
58
  }
59
59
 
60
- describe('AuthorityNodeClient', () => {
60
+ describe('OwnerNodeClient', () => {
61
61
  it('createInvite POSTs /admin/invites with bearer + body and unwraps data', async () => {
62
62
  responder = () => ({
63
63
  status: 200,
64
- payload: { ok: true, data: { invite: { partyId: 'p', authorityAddrs: [], token: 't', createdAt: 1 }, encodedInvite: 'enc' } },
64
+ payload: { ok: true, data: { invite: { partyId: 'p', ownerAddrs: [], token: 't', createdAt: 1 }, encodedInvite: 'enc' } },
65
65
  });
66
66
  const client = makeClient();
67
67
  const result = await client.createInvite('t', 60_000);
@@ -75,7 +75,7 @@ describe('AuthorityNodeClient', () => {
75
75
 
76
76
  it('acceptPhone POSTs /admin/accept-phone', async () => {
77
77
  const client = makeClient();
78
- await client.acceptPhone({ phonePeerId: '12D3KooWX', token: 't' }, { partyId: 'p', authorityAddrs: [], token: 't', createdAt: 1 });
78
+ await client.acceptPhone({ phonePeerId: '12D3KooWX', token: 't' }, { partyId: 'p', ownerAddrs: [], token: 't', createdAt: 1 });
79
79
  expect(last?.method).toBe('POST');
80
80
  expect(last?.path).toBe('/admin/accept-phone');
81
81
  const body = JSON.parse(last!.body);
@@ -106,6 +106,22 @@ describe('AuthorityNodeClient', () => {
106
106
  expect(last?.path).toBe('/admin/members/peer-1');
107
107
  });
108
108
 
109
+ it('listAuthorizedMembers GETs /admin/authorized-members and returns the array', async () => {
110
+ responder = () => ({ status: 200, payload: { ok: true, data: { members: [{ peerId: 'b', multiaddr: null }] } } });
111
+ const client = makeClient();
112
+ const members = await client.listAuthorizedMembers();
113
+ expect(last?.method).toBe('GET');
114
+ expect(last?.path).toBe('/admin/authorized-members');
115
+ expect(members).toEqual([{ peerId: 'b', multiaddr: null }]);
116
+ });
117
+
118
+ it('isAuthorizedMember GETs /admin/authorized-members/:peerId and returns the boolean', async () => {
119
+ responder = () => ({ status: 200, payload: { ok: true, data: { member: false } } });
120
+ const client = makeClient();
121
+ expect(await client.isAuthorizedMember('peer-2')).toBe(false);
122
+ expect(last?.path).toBe('/admin/authorized-members/peer-2');
123
+ });
124
+
109
125
  it('getPeerId GETs /admin/identity', async () => {
110
126
  responder = () => ({ status: 200, payload: { ok: true, data: { peerId: '12D3KooWHost', partyId: 'party' } } });
111
127
  const client = makeClient();
@@ -128,22 +144,34 @@ describe('AuthorityNodeClient', () => {
128
144
  expect(JSON.parse(last!.body)).toEqual({ addresses: ['/dns4/h/tcp/1/p2p/x'] });
129
145
  });
130
146
 
147
+ it('addDrone POSTs /admin/add-drone with body and unwraps the seed result', async () => {
148
+ responder = () => ({ status: 200, payload: { ok: true, data: { seed: { partyId: 'p', peers: [] }, encodedSeed: 'enc-seed' } } });
149
+ const client = makeClient();
150
+ const result = await client.addDrone({ dronePeerId: '12D3KooWDrone', droneMultiaddrs: ['/ip4/10.0.0.9/tcp/4001'] });
151
+
152
+ expect(result.encodedSeed).toBe('enc-seed');
153
+ expect(last?.method).toBe('POST');
154
+ expect(last?.path).toBe('/admin/add-drone');
155
+ expect(last?.auth).toBe(`Bearer ${TOKEN}`);
156
+ expect(JSON.parse(last!.body)).toEqual({ dronePeerId: '12D3KooWDrone', droneMultiaddrs: ['/ip4/10.0.0.9/tcp/4001'] });
157
+ });
158
+
131
159
  it('encodeInvite mirrors cadre-core base64url(JSON)', () => {
132
160
  const client = makeClient();
133
- const invite = { partyId: 'p', authorityAddrs: [], token: 't', createdAt: 1 };
161
+ const invite = { partyId: 'p', ownerAddrs: [], token: 't', createdAt: 1 };
134
162
  const encoded = client.encodeInvite(invite);
135
163
  const decoded = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'));
136
164
  expect(decoded).toEqual(invite);
137
165
  });
138
166
 
139
- it('maps a non-2xx response to AuthorityNodeUnavailableError carrying the code', async () => {
167
+ it('maps a non-2xx response to OwnerNodeUnavailableError carrying the code', async () => {
140
168
  responder = () => ({ status: 503, payload: { ok: false, error: { code: 'not_ready', message: 'node booting' } } });
141
169
  const client = makeClient();
142
- await expect(client.getPeerId()).rejects.toBeInstanceOf(AuthorityNodeUnavailableError);
170
+ await expect(client.getPeerId()).rejects.toBeInstanceOf(OwnerNodeUnavailableError);
143
171
  await expect(client.getPeerId()).rejects.toMatchObject({ nodeCode: 'not_ready' });
144
172
  });
145
173
 
146
- it('maps a refused connection to AuthorityNodeUnavailableError', async () => {
174
+ it('maps a refused connection to OwnerNodeUnavailableError', async () => {
147
175
  // Bind a throwaway server to claim a port, then close it so the address
148
176
  // is guaranteed refused.
149
177
  const tmp = http.createServer();
@@ -155,12 +183,12 @@ describe('AuthorityNodeClient', () => {
155
183
  });
156
184
  await new Promise<void>((resolve) => tmp.close(() => resolve()));
157
185
 
158
- const client = new AuthorityNodeClient({ baseUrl: `http://127.0.0.1:${closedPort}`, token: TOKEN });
159
- await expect(client.getPeerId()).rejects.toBeInstanceOf(AuthorityNodeUnavailableError);
186
+ const client = new OwnerNodeClient({ baseUrl: `http://127.0.0.1:${closedPort}`, token: TOKEN });
187
+ await expect(client.getPeerId()).rejects.toBeInstanceOf(OwnerNodeUnavailableError);
160
188
  });
161
189
 
162
- it('throws AuthorityNodeUnavailableError when the endpoint is undefined', async () => {
163
- const client = new AuthorityNodeClient(() => undefined);
164
- await expect(client.getPeerId()).rejects.toBeInstanceOf(AuthorityNodeUnavailableError);
190
+ it('throws OwnerNodeUnavailableError when the endpoint is undefined', async () => {
191
+ const client = new OwnerNodeClient(() => undefined);
192
+ await expect(client.getPeerId()).rejects.toBeInstanceOf(OwnerNodeUnavailableError);
165
193
  });
166
194
  });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Owner-node delegation surface for cadre-host.
3
+ *
4
+ * The manager process spawns the admin's owner cadre node (via
5
+ * `HostProcessOrchestrator`) and delegates owner/membership/identity
6
+ * operations to it over the loopback admin channel using
7
+ * {@link OwnerNodeClient}. See `docs/cadre-host.md` § Control-plane
8
+ * separation.
9
+ */
10
+
11
+ export { OwnerNodeClient, OwnerNodeUnavailableError } from './owner-node-client.js';
12
+ export type { OwnerNodeClientOptions } from './owner-node-client.js';
@@ -1,69 +1,73 @@
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
8
- * client. The client implements **both** trimmed `CadreNodeLike` interfaces —
9
- * the trust-circle one (`auth/trust-circle.ts`) and the NAT one
10
- * (`nat/nat-service.ts`) — plus `pushInviteAddresses`.
6
+ * admin's owner node as a managed child (see `HostProcessOrchestrator`)
7
+ * and delegates all owner/membership/identity operations to it over this
8
+ * client. The client implements **all three** trimmed `CadreNodeLike`
9
+ * interfaces — the trust-circle one (`auth/trust-circle.ts`), the NAT one
10
+ * (`nat/nat-service.ts`), and the strand one (`strands/strand-service.ts`) —
11
+ * plus `pushInviteAddresses` and `addDrone` (the node-donation requester side).
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';
28
+ import type { CadreNodeLike as StrandCadreNodeLike } from '../strands/strand-service.js';
29
+ import type { StrandListSnapshot, StrandRemovalResult } from '../strands/types.js';
27
30
 
28
- const log = debug('cadre:host:authority-client');
31
+ const log = debug('cadre:host:owner-client');
29
32
 
30
33
  /**
31
- * Raised when the authority node's admin channel can't be reached or returns
34
+ * Raised when the owner node's admin channel can't be reached or returns
32
35
  * a non-success response. Carries the node's stable error `code` when one was
33
36
  * present in the envelope (e.g. `not_ready`, `not_authorized`).
34
37
  */
35
- export class AuthorityNodeUnavailableError extends Error {
38
+ export class OwnerNodeUnavailableError extends Error {
36
39
  /** The admin-channel error code, when the node answered with an envelope. */
37
40
  readonly nodeCode?: string;
38
41
 
39
42
  constructor(message: string, nodeCode?: string) {
40
43
  super(message);
41
- this.name = 'AuthorityNodeUnavailableError';
44
+ this.name = 'OwnerNodeUnavailableError';
42
45
  if (nodeCode !== undefined) this.nodeCode = nodeCode;
43
46
  }
44
47
  }
45
48
 
46
49
  /** Endpoint provided directly or via a getter (so it tracks re-spawns). */
47
50
  type EndpointSource =
48
- | AuthorityAdminEndpoint
49
- | (() => AuthorityAdminEndpoint | undefined)
51
+ | OwnerAdminEndpoint
52
+ | (() => OwnerAdminEndpoint | undefined)
50
53
  | undefined;
51
54
 
52
- export interface AuthorityNodeClientOptions {
55
+ export interface OwnerNodeClientOptions {
53
56
  /** Fetch override for tests. Defaults to the global `fetch`. */
54
57
  fetch?: typeof fetch;
55
58
  }
56
59
 
57
- export class AuthorityNodeClient implements TrustCircleCadreNodeLike, NatCadreNodeLike {
60
+ export class OwnerNodeClient
61
+ implements TrustCircleCadreNodeLike, NatCadreNodeLike, StrandCadreNodeLike {
58
62
  private readonly endpointSource: EndpointSource;
59
63
  private readonly fetchImpl: typeof fetch;
60
64
 
61
65
  /**
62
66
  * @param endpoint Either a fixed `{ baseUrl, token }` or a getter returning
63
- * the current endpoint (pass `orchestrator.getAuthorityAdminEndpoint`
67
+ * the current endpoint (pass `orchestrator.getOwnerAdminEndpoint`
64
68
  * bound, so a restart's new bearer token is picked up automatically).
65
69
  */
66
- constructor(endpoint: EndpointSource, opts: AuthorityNodeClientOptions = {}) {
70
+ constructor(endpoint: EndpointSource, opts: OwnerNodeClientOptions = {}) {
67
71
  this.endpointSource = endpoint;
68
72
  this.fetchImpl = opts.fetch ?? fetch;
69
73
  }
@@ -123,6 +127,23 @@ export class AuthorityNodeClient implements TrustCircleCadreNodeLike, NatCadreNo
123
127
  return data.member;
124
128
  }
125
129
 
130
+ /** Authorized-membership surface (excludes self) — see {@link listMembers} for the addressable one. */
131
+ async listAuthorizedMembers(): Promise<Array<{ peerId: string; multiaddr: string | null }>> {
132
+ const data = await this.request<{ members: Array<{ peerId: string; multiaddr: string | null }> }>(
133
+ 'GET',
134
+ '/admin/authorized-members',
135
+ );
136
+ return data.members;
137
+ }
138
+
139
+ async isAuthorizedMember(peerId: string): Promise<boolean> {
140
+ const data = await this.request<{ member: boolean }>(
141
+ 'GET',
142
+ `/admin/authorized-members/${encodeURIComponent(peerId)}`,
143
+ );
144
+ return data.member;
145
+ }
146
+
126
147
  // --- NAT CadreNodeLike ---
127
148
 
128
149
  async getPeerId(): Promise<string> {
@@ -135,6 +156,45 @@ export class AuthorityNodeClient implements TrustCircleCadreNodeLike, NatCadreNo
135
156
  return data.multiaddrs;
136
157
  }
137
158
 
159
+ // --- strand CadreNodeLike ---
160
+
161
+ /** This party's strands plus the node's current control-connection count. */
162
+ async listStrands(): Promise<StrandListSnapshot> {
163
+ return await this.request<StrandListSnapshot>('GET', '/admin/strands');
164
+ }
165
+
166
+ /**
167
+ * Remove this party's participation in one strand.
168
+ *
169
+ * `?confirm=1` is appended **only** when the caller asked for it. The node
170
+ * refuses an unconfirmed closed-strand removal with `confirmation_required`;
171
+ * that refusal is surfaced, never retried with the flag added — the row holds
172
+ * this party's membership key for the strand and nothing else does.
173
+ */
174
+ async removeStrand(strandId: string, opts: { confirm: boolean }): Promise<StrandRemovalResult> {
175
+ const query = opts.confirm ? '?confirm=1' : '';
176
+ return await this.request<StrandRemovalResult>(
177
+ 'DELETE',
178
+ `/admin/strands/${encodeURIComponent(strandId)}${query}`,
179
+ );
180
+ }
181
+
182
+ // --- drone seeding (node-donation requester side) ---
183
+
184
+ /**
185
+ * Mint a seed authorizing a drone — a provider-hosted / donated node — to join
186
+ * this node's cadre. The node signs the seed with its own authority key; only
187
+ * the signed, public `encodedSeed` is returned (the private key never leaves
188
+ * the node). This is the requester ("phone") side of the node-donation flow:
189
+ * the donor host presents the returned seed to the donated node's `POST /seed`.
190
+ */
191
+ async addDrone(options: { dronePeerId: string; droneMultiaddrs: string[] }): Promise<DroneInitResult> {
192
+ return await this.request<DroneInitResult>('POST', '/admin/add-drone', {
193
+ dronePeerId: options.dronePeerId,
194
+ droneMultiaddrs: options.droneMultiaddrs,
195
+ });
196
+ }
197
+
138
198
  // --- push-model invite addresses ---
139
199
 
140
200
  /** Push the NAT-resolved addresses the node should embed in future invites. */
@@ -144,10 +204,10 @@ export class AuthorityNodeClient implements TrustCircleCadreNodeLike, NatCadreNo
144
204
 
145
205
  // --- internals ---
146
206
 
147
- private resolveEndpoint(): AuthorityAdminEndpoint {
207
+ private resolveEndpoint(): OwnerAdminEndpoint {
148
208
  const ep = typeof this.endpointSource === 'function' ? this.endpointSource() : this.endpointSource;
149
209
  if (!ep) {
150
- throw new AuthorityNodeUnavailableError('Authority node admin endpoint is not available');
210
+ throw new OwnerNodeUnavailableError('Owner node admin endpoint is not available');
151
211
  }
152
212
  return ep;
153
213
  }
@@ -167,7 +227,7 @@ export class AuthorityNodeClient implements TrustCircleCadreNodeLike, NatCadreNo
167
227
  } catch (err) {
168
228
  const message = err instanceof Error ? err.message : String(err);
169
229
  log('admin %s %s transport error: %s', method, path, message);
170
- throw new AuthorityNodeUnavailableError(`Authority node unreachable: ${message}`);
230
+ throw new OwnerNodeUnavailableError(`Owner node unreachable: ${message}`);
171
231
  }
172
232
 
173
233
  const envelope = await this.parseEnvelope(res);
@@ -175,7 +235,7 @@ export class AuthorityNodeClient implements TrustCircleCadreNodeLike, NatCadreNo
175
235
  const code = envelope.error?.code;
176
236
  const message = envelope.error?.message ?? `admin ${method} ${path} → HTTP ${res.status}`;
177
237
  log('admin %s %s failed: [%s] %s', method, path, code ?? res.status, message);
178
- throw new AuthorityNodeUnavailableError(message, code);
238
+ throw new OwnerNodeUnavailableError(message, code);
179
239
  }
180
240
  return envelope.data as T;
181
241
  }
@@ -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
+ });