@serfab/cadre-host 0.10.0 → 0.11.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 (138) hide show
  1. package/dist/server/static.js +38 -38
  2. package/dist/ui/assets/index-C-iks41M.js.map +1 -1
  3. package/dist/ui/index.html +12 -12
  4. package/package.json +4 -4
  5. package/service/cadre-host.service.tmpl +16 -16
  6. package/service/com.serfab.cadre-host.plist.tmpl +35 -35
  7. package/service/install-service.ps1 +98 -98
  8. package/service/uninstall-service.ps1 +19 -19
  9. package/src/__tests__/build-targets.test.ts +26 -26
  10. package/src/__tests__/cli-invite.smoke.test.ts +192 -192
  11. package/src/__tests__/cli-nat.smoke.test.ts +212 -212
  12. package/src/__tests__/cli.smoke.test.ts +46 -46
  13. package/src/__tests__/global-setup.ts +51 -51
  14. package/src/__tests__/host-owner.smoke.test.ts +123 -123
  15. package/src/__tests__/orchestrator-env-scrub.test.ts +140 -140
  16. package/src/__tests__/orchestrator-node-identity.test.ts +204 -204
  17. package/src/__tests__/orchestrator-owner.test.ts +198 -198
  18. package/src/__tests__/orchestrator-pin-keys.test.ts +153 -153
  19. package/src/__tests__/orchestrator-push.test.ts +145 -145
  20. package/src/__tests__/orchestrator.test.ts +951 -951
  21. package/src/auth/__tests__/duration.test.ts +28 -28
  22. package/src/auth/__tests__/self-label.test.ts +85 -85
  23. package/src/auth/__tests__/trust-circle-store.test.ts +115 -115
  24. package/src/auth/__tests__/trust-circle.test.ts +445 -445
  25. package/src/auth/duration.ts +33 -33
  26. package/src/auth/index.ts +21 -21
  27. package/src/auth/self-label.ts +74 -74
  28. package/src/auth/trust-circle-store.ts +155 -155
  29. package/src/auth/types.ts +99 -99
  30. package/src/donation/__tests__/donation-service.test.ts +1068 -1068
  31. package/src/donation/__tests__/donation-supervisor.test.ts +510 -510
  32. package/src/donation/__tests__/fake-orchestrator.test.ts +168 -168
  33. package/src/donation/__tests__/fake-orchestrator.ts +225 -225
  34. package/src/donation/__tests__/grant-service.test.ts +213 -213
  35. package/src/donation/__tests__/grant-store.test.ts +113 -113
  36. package/src/donation/donation-service.ts +962 -962
  37. package/src/donation/donation-store.ts +171 -171
  38. package/src/donation/donation-supervisor.ts +388 -388
  39. package/src/donation/grant-service.ts +200 -200
  40. package/src/donation/grant-store.ts +152 -152
  41. package/src/donation/index.ts +64 -64
  42. package/src/donation/types.ts +243 -243
  43. package/src/installer/__tests__/identity.test.ts +34 -34
  44. package/src/installer/__tests__/paths.test.ts +47 -47
  45. package/src/installer/__tests__/service-host.test.ts +62 -62
  46. package/src/installer/__tests__/wizard.test.ts +64 -64
  47. package/src/installer/identity.ts +49 -49
  48. package/src/installer/paths.ts +109 -109
  49. package/src/installer/platform.ts +27 -27
  50. package/src/installer/service-host/index.ts +21 -21
  51. package/src/installer/service-host/template.ts +21 -21
  52. package/src/installer/wizard.ts +103 -103
  53. package/src/nat/__tests__/address-resolver.test.ts +82 -82
  54. package/src/nat/__tests__/ddns-duckdns.test.ts +83 -83
  55. package/src/nat/__tests__/ddns-updater.test.ts +253 -253
  56. package/src/nat/__tests__/external-ip.test.ts +124 -124
  57. package/src/nat/__tests__/nat-service.test.ts +497 -497
  58. package/src/nat/__tests__/nat-store.test.ts +93 -93
  59. package/src/nat/__tests__/port-mapper.test.ts +191 -191
  60. package/src/nat/__tests__/reachability.test.ts +24 -24
  61. package/src/nat/__tests__/secrets.test.ts +178 -178
  62. package/src/nat/address-resolver.ts +51 -51
  63. package/src/nat/ddns/duckdns.ts +76 -76
  64. package/src/nat/ddns/index.ts +55 -55
  65. package/src/nat/ddns/updater.ts +243 -243
  66. package/src/nat/external-ip.ts +154 -154
  67. package/src/nat/index.ts +71 -71
  68. package/src/nat/nat-service.ts +501 -501
  69. package/src/nat/nat-store.ts +154 -154
  70. package/src/nat/port-mapper.ts +329 -329
  71. package/src/nat/reachability.ts +34 -34
  72. package/src/nat/secrets/file-store.ts +130 -130
  73. package/src/nat/secrets/index.ts +112 -112
  74. package/src/nat/secrets/keytar-store.ts +67 -67
  75. package/src/nat/types.ts +159 -159
  76. package/src/orchestrator/__tests__/node-identity.test.ts +62 -62
  77. package/src/orchestrator/host-process-orchestrator.ts +1133 -1133
  78. package/src/orchestrator/index.ts +10 -10
  79. package/src/orchestrator/log-rotator.ts +53 -53
  80. package/src/orchestrator/node-identity.ts +40 -40
  81. package/src/orchestrator/pid-liveness.ts +24 -24
  82. package/src/orchestrator/port-allocator.ts +91 -91
  83. package/src/orchestrator/state-store.ts +90 -90
  84. package/src/orchestrator/types.ts +141 -141
  85. package/src/owner/__tests__/owner-node-client.test.ts +194 -194
  86. package/src/owner/index.ts +12 -12
  87. package/src/owner/owner-node-client.ts +259 -259
  88. package/src/push/__tests__/push.test.ts +90 -90
  89. package/src/push/index.ts +146 -146
  90. package/src/server/__tests__/bus.test.ts +38 -38
  91. package/src/server/__tests__/error-handler.test.ts +118 -118
  92. package/src/server/__tests__/grants-admin-route.test.ts +73 -73
  93. package/src/server/__tests__/grants-route.test.ts +263 -263
  94. package/src/server/__tests__/nodes-route.test.ts +259 -259
  95. package/src/server/__tests__/origin-guard.test.ts +72 -72
  96. package/src/server/__tests__/publishers.test.ts +216 -216
  97. package/src/server/__tests__/server.smoke.test.ts +253 -253
  98. package/src/server/__tests__/settings-route.test.ts +194 -194
  99. package/src/server/__tests__/sse-route.test.ts +119 -119
  100. package/src/server/__tests__/static.test.ts +145 -145
  101. package/src/server/__tests__/status-route.test.ts +134 -134
  102. package/src/server/__tests__/strands-route.test.ts +233 -233
  103. package/src/server/error-handler.ts +178 -178
  104. package/src/server/events/bus.ts +42 -42
  105. package/src/server/events/sse-route.ts +81 -81
  106. package/src/server/events/types.ts +23 -23
  107. package/src/server/index.ts +267 -267
  108. package/src/server/origin-guard.ts +84 -84
  109. package/src/server/routes/grants-admin.ts +41 -41
  110. package/src/server/routes/grants.ts +210 -210
  111. package/src/server/routes/nat.ts +63 -63
  112. package/src/server/routes/nodes.ts +170 -170
  113. package/src/server/routes/settings.ts +123 -123
  114. package/src/server/routes/status.ts +118 -118
  115. package/src/server/routes/strands.ts +54 -54
  116. package/src/server/routes/trust-circle.ts +49 -49
  117. package/src/server/routes/update.ts +43 -43
  118. package/src/server/server.ts +122 -122
  119. package/src/server/settings-store.ts +37 -37
  120. package/src/server/static.ts +207 -207
  121. package/src/strands/__tests__/strand-service.test.ts +165 -165
  122. package/src/strands/index.ts +14 -14
  123. package/src/strands/strand-service.ts +135 -135
  124. package/src/strands/types.ts +102 -102
  125. package/src/update/__tests__/apply.test.ts +158 -158
  126. package/src/update/__tests__/manifest.test.ts +128 -128
  127. package/src/update/__tests__/release-key.test.ts +42 -42
  128. package/src/update/__tests__/sign.test.ts +78 -78
  129. package/src/update/__tests__/store.test.ts +81 -81
  130. package/src/update/__tests__/update-service.integration.test.ts +256 -256
  131. package/src/update/__tests__/version.test.ts +31 -31
  132. package/src/update/apply.ts +190 -190
  133. package/src/update/index.ts +341 -341
  134. package/src/update/release-key.ts +71 -71
  135. package/src/update/sign.ts +74 -74
  136. package/src/update/store.ts +101 -101
  137. package/src/update/types.ts +109 -109
  138. package/src/update/version.ts +80 -80
@@ -1,259 +1,259 @@
1
- /**
2
- * HTTP client for the owner node's loopback admin channel (the 6.6
3
- * `cadre-node-admin-channel` contract).
4
- *
5
- * cadre-host's manager process holds no in-process `CadreNode`; it spawns the
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).
12
- *
13
- * Transport: `Authorization: Bearer <token>` against
14
- * `http://127.0.0.1:<adminPort>`. Every response uses the cadre-provider
15
- * envelope `{ ok: true, data }` / `{ ok: false, error: { code, message } }`.
16
- * A refused connection, a transport error, or any non-2xx response surfaces
17
- * as {@link OwnerNodeUnavailableError}; the trust-circle / NAT services
18
- * translate that into a `node_unavailable` domain error so the management API
19
- * returns a clear 503 rather than a raw 500.
20
- */
21
-
22
- import debug from 'debug';
23
- import type { CadreInvite, DroneInitResult } from '@serfab/cadre-core';
24
-
25
- import type { OwnerAdminEndpoint } from '../orchestrator/index.js';
26
- import type { CadreNodeLike as TrustCircleCadreNodeLike } from '../auth/trust-circle.js';
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';
30
-
31
- const log = debug('cadre:host:owner-client');
32
-
33
- /**
34
- * Raised when the owner node's admin channel can't be reached or returns
35
- * a non-success response. Carries the node's stable error `code` when one was
36
- * present in the envelope (e.g. `not_ready`, `not_authorized`).
37
- */
38
- export class OwnerNodeUnavailableError extends Error {
39
- /** The admin-channel error code, when the node answered with an envelope. */
40
- readonly nodeCode?: string;
41
-
42
- constructor(message: string, nodeCode?: string) {
43
- super(message);
44
- this.name = 'OwnerNodeUnavailableError';
45
- if (nodeCode !== undefined) this.nodeCode = nodeCode;
46
- }
47
- }
48
-
49
- /** Endpoint provided directly or via a getter (so it tracks re-spawns). */
50
- type EndpointSource =
51
- | OwnerAdminEndpoint
52
- | (() => OwnerAdminEndpoint | undefined)
53
- | undefined;
54
-
55
- export interface OwnerNodeClientOptions {
56
- /** Fetch override for tests. Defaults to the global `fetch`. */
57
- fetch?: typeof fetch;
58
- }
59
-
60
- export class OwnerNodeClient
61
- implements TrustCircleCadreNodeLike, NatCadreNodeLike, StrandCadreNodeLike {
62
- private readonly endpointSource: EndpointSource;
63
- private readonly fetchImpl: typeof fetch;
64
-
65
- /**
66
- * @param endpoint Either a fixed `{ baseUrl, token }` or a getter returning
67
- * the current endpoint (pass `orchestrator.getOwnerAdminEndpoint`
68
- * bound, so a restart's new bearer token is picked up automatically).
69
- */
70
- constructor(endpoint: EndpointSource, opts: OwnerNodeClientOptions = {}) {
71
- this.endpointSource = endpoint;
72
- this.fetchImpl = opts.fetch ?? fetch;
73
- }
74
-
75
- // --- trust-circle CadreNodeLike ---
76
-
77
- async createInvite(
78
- token?: string,
79
- expiresInMs?: number,
80
- ): Promise<{ invite: CadreInvite; encodedInvite: string }> {
81
- const body: Record<string, unknown> = {};
82
- if (token !== undefined) body.token = token;
83
- if (expiresInMs !== undefined) body.expiresInMs = expiresInMs;
84
- return await this.request<{ invite: CadreInvite; encodedInvite: string }>(
85
- 'POST',
86
- '/admin/invites',
87
- body,
88
- );
89
- }
90
-
91
- async acceptPhone(
92
- options: { phonePeerId: string; token?: string },
93
- issuedInvite?: CadreInvite,
94
- ): Promise<void> {
95
- const body: Record<string, unknown> = { phonePeerId: options.phonePeerId };
96
- if (options.token !== undefined) body.token = options.token;
97
- if (issuedInvite !== undefined) body.issuedInvite = issuedInvite;
98
- await this.request('POST', '/admin/accept-phone', body);
99
- }
100
-
101
- async removePeer(peerId: string): Promise<void> {
102
- await this.request('DELETE', `/admin/members/${encodeURIComponent(peerId)}`);
103
- }
104
-
105
- /**
106
- * Encode an invite for out-of-band delivery. The admin contract has no
107
- * encode route (the mint route already returns `encodedInvite`), so this
108
- * mirrors cadre-core's encoding (`base64url(JSON.stringify(invite))`).
109
- */
110
- encodeInvite(invite: CadreInvite): string {
111
- return Buffer.from(JSON.stringify(invite), 'utf8').toString('base64url');
112
- }
113
-
114
- async listMembers(): Promise<Array<{ peerId: string; multiaddr: string | null }>> {
115
- const data = await this.request<{ members: Array<{ peerId: string; multiaddr: string | null }> }>(
116
- 'GET',
117
- '/admin/members',
118
- );
119
- return data.members;
120
- }
121
-
122
- async isMember(peerId: string): Promise<boolean> {
123
- const data = await this.request<{ member: boolean }>(
124
- 'GET',
125
- `/admin/members/${encodeURIComponent(peerId)}`,
126
- );
127
- return data.member;
128
- }
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
-
147
- // --- NAT CadreNodeLike ---
148
-
149
- async getPeerId(): Promise<string> {
150
- const data = await this.request<{ peerId: string | null; partyId: string }>('GET', '/admin/identity');
151
- return data.peerId ?? '';
152
- }
153
-
154
- async getMultiaddrs(): Promise<string[]> {
155
- const data = await this.request<{ multiaddrs: string[] }>('GET', '/admin/multiaddrs');
156
- return data.multiaddrs;
157
- }
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
-
198
- // --- push-model invite addresses ---
199
-
200
- /** Push the NAT-resolved addresses the node should embed in future invites. */
201
- async pushInviteAddresses(addresses: string[]): Promise<void> {
202
- await this.request('PUT', '/admin/invite-addresses', { addresses });
203
- }
204
-
205
- // --- internals ---
206
-
207
- private resolveEndpoint(): OwnerAdminEndpoint {
208
- const ep = typeof this.endpointSource === 'function' ? this.endpointSource() : this.endpointSource;
209
- if (!ep) {
210
- throw new OwnerNodeUnavailableError('Owner node admin endpoint is not available');
211
- }
212
- return ep;
213
- }
214
-
215
- private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
216
- const ep = this.resolveEndpoint();
217
- const headers: Record<string, string> = { authorization: `Bearer ${ep.token}` };
218
- const init: RequestInit = { method, headers };
219
- if (body !== undefined) {
220
- headers['content-type'] = 'application/json';
221
- init.body = JSON.stringify(body);
222
- }
223
-
224
- let res: Response;
225
- try {
226
- res = await this.fetchImpl(`${ep.baseUrl}${path}`, init);
227
- } catch (err) {
228
- const message = err instanceof Error ? err.message : String(err);
229
- log('admin %s %s transport error: %s', method, path, message);
230
- throw new OwnerNodeUnavailableError(`Owner node unreachable: ${message}`);
231
- }
232
-
233
- const envelope = await this.parseEnvelope(res);
234
- if (!res.ok || !envelope.ok) {
235
- const code = envelope.error?.code;
236
- const message = envelope.error?.message ?? `admin ${method} ${path} → HTTP ${res.status}`;
237
- log('admin %s %s failed: [%s] %s', method, path, code ?? res.status, message);
238
- throw new OwnerNodeUnavailableError(message, code);
239
- }
240
- return envelope.data as T;
241
- }
242
-
243
- private async parseEnvelope(res: Response): Promise<{
244
- ok?: boolean;
245
- data?: unknown;
246
- error?: { code?: string; message?: string };
247
- }> {
248
- try {
249
- const parsed = await res.json();
250
- if (parsed && typeof parsed === 'object') {
251
- return parsed as { ok?: boolean; data?: unknown; error?: { code?: string; message?: string } };
252
- }
253
- } catch {
254
- // Non-JSON body — fall through to an empty envelope; status drives the
255
- // ok/non-ok decision in the caller.
256
- }
257
- return {};
258
- }
259
- }
1
+ /**
2
+ * HTTP client for the owner node's loopback admin channel (the 6.6
3
+ * `cadre-node-admin-channel` contract).
4
+ *
5
+ * cadre-host's manager process holds no in-process `CadreNode`; it spawns the
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).
12
+ *
13
+ * Transport: `Authorization: Bearer <token>` against
14
+ * `http://127.0.0.1:<adminPort>`. Every response uses the cadre-provider
15
+ * envelope `{ ok: true, data }` / `{ ok: false, error: { code, message } }`.
16
+ * A refused connection, a transport error, or any non-2xx response surfaces
17
+ * as {@link OwnerNodeUnavailableError}; the trust-circle / NAT services
18
+ * translate that into a `node_unavailable` domain error so the management API
19
+ * returns a clear 503 rather than a raw 500.
20
+ */
21
+
22
+ import debug from 'debug';
23
+ import type { CadreInvite, DroneInitResult } from '@serfab/cadre-core';
24
+
25
+ import type { OwnerAdminEndpoint } from '../orchestrator/index.js';
26
+ import type { CadreNodeLike as TrustCircleCadreNodeLike } from '../auth/trust-circle.js';
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';
30
+
31
+ const log = debug('cadre:host:owner-client');
32
+
33
+ /**
34
+ * Raised when the owner node's admin channel can't be reached or returns
35
+ * a non-success response. Carries the node's stable error `code` when one was
36
+ * present in the envelope (e.g. `not_ready`, `not_authorized`).
37
+ */
38
+ export class OwnerNodeUnavailableError extends Error {
39
+ /** The admin-channel error code, when the node answered with an envelope. */
40
+ readonly nodeCode?: string;
41
+
42
+ constructor(message: string, nodeCode?: string) {
43
+ super(message);
44
+ this.name = 'OwnerNodeUnavailableError';
45
+ if (nodeCode !== undefined) this.nodeCode = nodeCode;
46
+ }
47
+ }
48
+
49
+ /** Endpoint provided directly or via a getter (so it tracks re-spawns). */
50
+ type EndpointSource =
51
+ | OwnerAdminEndpoint
52
+ | (() => OwnerAdminEndpoint | undefined)
53
+ | undefined;
54
+
55
+ export interface OwnerNodeClientOptions {
56
+ /** Fetch override for tests. Defaults to the global `fetch`. */
57
+ fetch?: typeof fetch;
58
+ }
59
+
60
+ export class OwnerNodeClient
61
+ implements TrustCircleCadreNodeLike, NatCadreNodeLike, StrandCadreNodeLike {
62
+ private readonly endpointSource: EndpointSource;
63
+ private readonly fetchImpl: typeof fetch;
64
+
65
+ /**
66
+ * @param endpoint Either a fixed `{ baseUrl, token }` or a getter returning
67
+ * the current endpoint (pass `orchestrator.getOwnerAdminEndpoint`
68
+ * bound, so a restart's new bearer token is picked up automatically).
69
+ */
70
+ constructor(endpoint: EndpointSource, opts: OwnerNodeClientOptions = {}) {
71
+ this.endpointSource = endpoint;
72
+ this.fetchImpl = opts.fetch ?? fetch;
73
+ }
74
+
75
+ // --- trust-circle CadreNodeLike ---
76
+
77
+ async createInvite(
78
+ token?: string,
79
+ expiresInMs?: number,
80
+ ): Promise<{ invite: CadreInvite; encodedInvite: string }> {
81
+ const body: Record<string, unknown> = {};
82
+ if (token !== undefined) body.token = token;
83
+ if (expiresInMs !== undefined) body.expiresInMs = expiresInMs;
84
+ return await this.request<{ invite: CadreInvite; encodedInvite: string }>(
85
+ 'POST',
86
+ '/admin/invites',
87
+ body,
88
+ );
89
+ }
90
+
91
+ async acceptPhone(
92
+ options: { phonePeerId: string; token?: string },
93
+ issuedInvite?: CadreInvite,
94
+ ): Promise<void> {
95
+ const body: Record<string, unknown> = { phonePeerId: options.phonePeerId };
96
+ if (options.token !== undefined) body.token = options.token;
97
+ if (issuedInvite !== undefined) body.issuedInvite = issuedInvite;
98
+ await this.request('POST', '/admin/accept-phone', body);
99
+ }
100
+
101
+ async removePeer(peerId: string): Promise<void> {
102
+ await this.request('DELETE', `/admin/members/${encodeURIComponent(peerId)}`);
103
+ }
104
+
105
+ /**
106
+ * Encode an invite for out-of-band delivery. The admin contract has no
107
+ * encode route (the mint route already returns `encodedInvite`), so this
108
+ * mirrors cadre-core's encoding (`base64url(JSON.stringify(invite))`).
109
+ */
110
+ encodeInvite(invite: CadreInvite): string {
111
+ return Buffer.from(JSON.stringify(invite), 'utf8').toString('base64url');
112
+ }
113
+
114
+ async listMembers(): Promise<Array<{ peerId: string; multiaddr: string | null }>> {
115
+ const data = await this.request<{ members: Array<{ peerId: string; multiaddr: string | null }> }>(
116
+ 'GET',
117
+ '/admin/members',
118
+ );
119
+ return data.members;
120
+ }
121
+
122
+ async isMember(peerId: string): Promise<boolean> {
123
+ const data = await this.request<{ member: boolean }>(
124
+ 'GET',
125
+ `/admin/members/${encodeURIComponent(peerId)}`,
126
+ );
127
+ return data.member;
128
+ }
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
+
147
+ // --- NAT CadreNodeLike ---
148
+
149
+ async getPeerId(): Promise<string> {
150
+ const data = await this.request<{ peerId: string | null; partyId: string }>('GET', '/admin/identity');
151
+ return data.peerId ?? '';
152
+ }
153
+
154
+ async getMultiaddrs(): Promise<string[]> {
155
+ const data = await this.request<{ multiaddrs: string[] }>('GET', '/admin/multiaddrs');
156
+ return data.multiaddrs;
157
+ }
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
+
198
+ // --- push-model invite addresses ---
199
+
200
+ /** Push the NAT-resolved addresses the node should embed in future invites. */
201
+ async pushInviteAddresses(addresses: string[]): Promise<void> {
202
+ await this.request('PUT', '/admin/invite-addresses', { addresses });
203
+ }
204
+
205
+ // --- internals ---
206
+
207
+ private resolveEndpoint(): OwnerAdminEndpoint {
208
+ const ep = typeof this.endpointSource === 'function' ? this.endpointSource() : this.endpointSource;
209
+ if (!ep) {
210
+ throw new OwnerNodeUnavailableError('Owner node admin endpoint is not available');
211
+ }
212
+ return ep;
213
+ }
214
+
215
+ private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
216
+ const ep = this.resolveEndpoint();
217
+ const headers: Record<string, string> = { authorization: `Bearer ${ep.token}` };
218
+ const init: RequestInit = { method, headers };
219
+ if (body !== undefined) {
220
+ headers['content-type'] = 'application/json';
221
+ init.body = JSON.stringify(body);
222
+ }
223
+
224
+ let res: Response;
225
+ try {
226
+ res = await this.fetchImpl(`${ep.baseUrl}${path}`, init);
227
+ } catch (err) {
228
+ const message = err instanceof Error ? err.message : String(err);
229
+ log('admin %s %s transport error: %s', method, path, message);
230
+ throw new OwnerNodeUnavailableError(`Owner node unreachable: ${message}`);
231
+ }
232
+
233
+ const envelope = await this.parseEnvelope(res);
234
+ if (!res.ok || !envelope.ok) {
235
+ const code = envelope.error?.code;
236
+ const message = envelope.error?.message ?? `admin ${method} ${path} → HTTP ${res.status}`;
237
+ log('admin %s %s failed: [%s] %s', method, path, code ?? res.status, message);
238
+ throw new OwnerNodeUnavailableError(message, code);
239
+ }
240
+ return envelope.data as T;
241
+ }
242
+
243
+ private async parseEnvelope(res: Response): Promise<{
244
+ ok?: boolean;
245
+ data?: unknown;
246
+ error?: { code?: string; message?: string };
247
+ }> {
248
+ try {
249
+ const parsed = await res.json();
250
+ if (parsed && typeof parsed === 'object') {
251
+ return parsed as { ok?: boolean; data?: unknown; error?: { code?: string; message?: string } };
252
+ }
253
+ } catch {
254
+ // Non-JSON body — fall through to an empty envelope; status drives the
255
+ // ok/non-ok decision in the caller.
256
+ }
257
+ return {};
258
+ }
259
+ }