@serfab/cadre-host 0.7.1 → 0.8.1

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 (135) hide show
  1. package/README.md +296 -101
  2. package/dist/auth/trust-circle.d.ts +44 -6
  3. package/dist/auth/trust-circle.js +115 -58
  4. package/dist/auth/trust-circle.js.map +1 -1
  5. package/dist/auth/types.d.ts +4 -1
  6. package/dist/auth/types.js.map +1 -1
  7. package/dist/authority/authority-node-client.d.ts +77 -0
  8. package/dist/authority/authority-node-client.js +143 -0
  9. package/dist/authority/authority-node-client.js.map +1 -0
  10. package/dist/authority/index.d.ts +11 -0
  11. package/dist/authority/index.js +11 -0
  12. package/dist/authority/index.js.map +1 -0
  13. package/dist/bin/host.js +210 -97
  14. package/dist/bin/host.js.map +1 -1
  15. package/dist/index.d.ts +10 -6
  16. package/dist/index.js +6 -3
  17. package/dist/index.js.map +1 -1
  18. package/dist/installer/browser.js +16 -17
  19. package/dist/installer/browser.js.map +1 -1
  20. package/dist/installer/config.d.ts +25 -0
  21. package/dist/installer/config.js +1 -1
  22. package/dist/installer/config.js.map +1 -1
  23. package/dist/installer/index.d.ts +5 -2
  24. package/dist/installer/index.js +14 -10
  25. package/dist/installer/index.js.map +1 -1
  26. package/dist/installer/service-host/launchd.js +1 -1
  27. package/dist/installer/service-host/launchd.js.map +1 -1
  28. package/dist/installer/service-host/systemd.js +1 -1
  29. package/dist/installer/service-host/systemd.js.map +1 -1
  30. package/dist/nat/index.d.ts +1 -1
  31. package/dist/nat/nat-service.d.ts +45 -3
  32. package/dist/nat/nat-service.js +116 -3
  33. package/dist/nat/nat-service.js.map +1 -1
  34. package/dist/nat/secrets/index.d.ts +7 -0
  35. package/dist/nat/secrets/index.js +28 -2
  36. package/dist/nat/secrets/index.js.map +1 -1
  37. package/dist/nat/types.d.ts +3 -1
  38. package/dist/nat/types.js.map +1 -1
  39. package/dist/orchestrator/host-process-orchestrator.d.ts +60 -1
  40. package/dist/orchestrator/host-process-orchestrator.js +246 -39
  41. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  42. package/dist/orchestrator/index.d.ts +3 -2
  43. package/dist/orchestrator/index.js +1 -1
  44. package/dist/orchestrator/index.js.map +1 -1
  45. package/dist/orchestrator/state-store.d.ts +6 -5
  46. package/dist/orchestrator/state-store.js +10 -2
  47. package/dist/orchestrator/state-store.js.map +1 -1
  48. package/dist/orchestrator/types.d.ts +45 -10
  49. package/dist/orchestrator/types.js.map +1 -1
  50. package/dist/push/index.d.ts +67 -0
  51. package/dist/push/index.js +101 -0
  52. package/dist/push/index.js.map +1 -0
  53. package/dist/server/error-handler.d.ts +2 -0
  54. package/dist/server/error-handler.js +4 -0
  55. package/dist/server/error-handler.js.map +1 -1
  56. package/dist/server/routes/nodes.d.ts +4 -3
  57. package/dist/server/routes/nodes.js +38 -24
  58. package/dist/server/routes/nodes.js.map +1 -1
  59. package/dist/server/static.d.ts +12 -0
  60. package/dist/server/static.js +24 -7
  61. package/dist/server/static.js.map +1 -1
  62. package/dist/ui/assets/index-BlMXQPSw.js +14 -0
  63. package/dist/ui/assets/{index-C9uChk2P.js.map → index-BlMXQPSw.js.map} +1 -1
  64. package/dist/ui/index.html +1 -1
  65. package/dist/update/apply.d.ts +23 -1
  66. package/dist/update/apply.js +46 -4
  67. package/dist/update/apply.js.map +1 -1
  68. package/dist/update/index.d.ts +14 -3
  69. package/dist/update/index.js +35 -2
  70. package/dist/update/index.js.map +1 -1
  71. package/dist/update/manifest.d.ts +28 -9
  72. package/dist/update/manifest.js +56 -29
  73. package/dist/update/manifest.js.map +1 -1
  74. package/dist/update/release-key.d.ts +18 -7
  75. package/dist/update/release-key.js +28 -15
  76. package/dist/update/release-key.js.map +1 -1
  77. package/dist/update/sign.d.ts +46 -0
  78. package/dist/update/sign.js +53 -0
  79. package/dist/update/sign.js.map +1 -0
  80. package/package.json +9 -4
  81. package/service/README.md +95 -86
  82. package/src/__tests__/cli-invite.smoke.test.ts +6 -1
  83. package/src/__tests__/cli-nat.smoke.test.ts +6 -1
  84. package/src/__tests__/cli.smoke.test.ts +6 -1
  85. package/src/__tests__/host-authority.smoke.test.ts +123 -0
  86. package/src/__tests__/orchestrator-authority.test.ts +198 -0
  87. package/src/__tests__/orchestrator-push.test.ts +145 -0
  88. package/src/auth/__tests__/trust-circle-integration.test.ts +40 -21
  89. package/src/auth/__tests__/trust-circle.test.ts +90 -23
  90. package/src/auth/trust-circle.ts +363 -304
  91. package/src/auth/types.ts +4 -1
  92. package/src/authority/__tests__/authority-node-client.test.ts +166 -0
  93. package/src/authority/authority-node-client.ts +199 -0
  94. package/src/authority/index.ts +12 -0
  95. package/src/bin/host.ts +1038 -905
  96. package/src/index.ts +34 -3
  97. package/src/installer/__tests__/installer.smoke.test.ts +64 -0
  98. package/src/installer/browser.ts +40 -42
  99. package/src/installer/config.ts +27 -1
  100. package/src/installer/index.ts +18 -12
  101. package/src/installer/service-host/launchd.ts +1 -1
  102. package/src/installer/service-host/systemd.ts +1 -1
  103. package/src/nat/__tests__/ddns-updater.test.ts +1 -1
  104. package/src/nat/__tests__/nat-service.test.ts +192 -0
  105. package/src/nat/__tests__/port-mapper.test.ts +1 -1
  106. package/src/nat/__tests__/secrets.test.ts +23 -1
  107. package/src/nat/index.ts +1 -1
  108. package/src/nat/nat-service.ts +131 -5
  109. package/src/nat/secrets/index.ts +32 -2
  110. package/src/nat/types.ts +3 -1
  111. package/src/orchestrator/host-process-orchestrator.ts +294 -54
  112. package/src/orchestrator/index.ts +9 -2
  113. package/src/orchestrator/state-store.ts +21 -3
  114. package/src/orchestrator/types.ts +48 -2
  115. package/src/push/__tests__/push.test.ts +90 -0
  116. package/src/push/index.ts +146 -0
  117. package/src/server/__tests__/error-handler.test.ts +4 -4
  118. package/src/server/__tests__/nodes-route.test.ts +90 -10
  119. package/src/server/__tests__/publishers.test.ts +1 -1
  120. package/src/server/__tests__/static.test.ts +145 -0
  121. package/src/server/__tests__/status-route.test.ts +2 -2
  122. package/src/server/error-handler.ts +4 -0
  123. package/src/server/routes/nodes.ts +48 -26
  124. package/src/server/static.ts +24 -8
  125. package/src/update/__tests__/apply.test.ts +58 -1
  126. package/src/update/__tests__/manifest.test.ts +41 -6
  127. package/src/update/__tests__/release-key.test.ts +29 -0
  128. package/src/update/__tests__/sign.test.ts +78 -0
  129. package/src/update/__tests__/update-service.integration.test.ts +44 -9
  130. package/src/update/apply.ts +46 -4
  131. package/src/update/index.ts +41 -3
  132. package/src/update/manifest.ts +214 -172
  133. package/src/update/release-key.ts +30 -16
  134. package/src/update/sign.ts +74 -0
  135. package/dist/ui/assets/index-C9uChk2P.js +0 -14
package/src/auth/types.ts CHANGED
@@ -62,6 +62,7 @@ export interface TrustCircleSnapshot {
62
62
  * - `not_found` → 404 (unknown token / unknown peer)
63
63
  * - `expired` → 410 (invite past expiresAt)
64
64
  * - `already_redeemed` → 409 (token was used and is gone)
65
+ * - `node_unavailable` → 503 (authority node admin channel down/not ready)
65
66
  * - default → 500
66
67
  */
67
68
  export interface TrustCircleHandlers {
@@ -82,7 +83,9 @@ export type TrustCircleErrorCode =
82
83
  | 'already_redeemed'
83
84
  | 'invalid_label'
84
85
  | 'invalid_token'
85
- | 'storage_error';
86
+ | 'storage_error'
87
+ /** The authority node's admin channel is unreachable / not ready. */
88
+ | 'node_unavailable';
86
89
 
87
90
  /** Typed error carrying a stable `code` for HTTP mapping. */
88
91
  export class TrustCircleError extends Error {
@@ -0,0 +1,166 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import http from 'node:http';
3
+
4
+ import { AuthorityNodeClient, AuthorityNodeUnavailableError } from '../authority-node-client.js';
5
+ import type { AuthorityAdminEndpoint } from '../../orchestrator/index.js';
6
+
7
+ /**
8
+ * Stub HTTP server matching the 6.6 admin contract. Records the last request
9
+ * so tests can assert method/path/headers/body, and lets each test choose the
10
+ * response.
11
+ */
12
+ interface Recorded {
13
+ method: string;
14
+ path: string;
15
+ auth: string | undefined;
16
+ body: string;
17
+ }
18
+
19
+ const TOKEN = 'secret-bearer-token';
20
+
21
+ let server: http.Server;
22
+ let baseUrl: string;
23
+ let last: Recorded | null;
24
+ let responder: (req: Recorded) => { status: number; payload: unknown };
25
+
26
+ beforeEach(async () => {
27
+ last = null;
28
+ responder = () => ({ status: 200, payload: { ok: true, data: { ok: true } } });
29
+ server = http.createServer((req, res) => {
30
+ const chunks: Buffer[] = [];
31
+ req.on('data', (c) => chunks.push(c as Buffer));
32
+ req.on('end', () => {
33
+ const rec: Recorded = {
34
+ method: req.method ?? 'GET',
35
+ path: req.url ?? '/',
36
+ auth: req.headers['authorization'] as string | undefined,
37
+ body: Buffer.concat(chunks).toString('utf8'),
38
+ };
39
+ last = rec;
40
+ const { status, payload } = responder(rec);
41
+ res.writeHead(status, { 'content-type': 'application/json' });
42
+ res.end(JSON.stringify(payload));
43
+ });
44
+ });
45
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
46
+ const addr = server.address();
47
+ const port = typeof addr === 'object' && addr ? addr.port : 0;
48
+ baseUrl = `http://127.0.0.1:${port}`;
49
+ });
50
+
51
+ afterEach(async () => {
52
+ await new Promise<void>((resolve) => server.close(() => resolve()));
53
+ });
54
+
55
+ function makeClient(): AuthorityNodeClient {
56
+ const endpoint: AuthorityAdminEndpoint = { baseUrl, token: TOKEN };
57
+ return new AuthorityNodeClient(endpoint);
58
+ }
59
+
60
+ describe('AuthorityNodeClient', () => {
61
+ it('createInvite POSTs /admin/invites with bearer + body and unwraps data', async () => {
62
+ responder = () => ({
63
+ status: 200,
64
+ payload: { ok: true, data: { invite: { partyId: 'p', authorityAddrs: [], token: 't', createdAt: 1 }, encodedInvite: 'enc' } },
65
+ });
66
+ const client = makeClient();
67
+ const result = await client.createInvite('t', 60_000);
68
+
69
+ expect(result.encodedInvite).toBe('enc');
70
+ expect(last?.method).toBe('POST');
71
+ expect(last?.path).toBe('/admin/invites');
72
+ expect(last?.auth).toBe(`Bearer ${TOKEN}`);
73
+ expect(JSON.parse(last!.body)).toEqual({ token: 't', expiresInMs: 60_000 });
74
+ });
75
+
76
+ it('acceptPhone POSTs /admin/accept-phone', async () => {
77
+ const client = makeClient();
78
+ await client.acceptPhone({ phonePeerId: '12D3KooWX', token: 't' }, { partyId: 'p', authorityAddrs: [], token: 't', createdAt: 1 });
79
+ expect(last?.method).toBe('POST');
80
+ expect(last?.path).toBe('/admin/accept-phone');
81
+ const body = JSON.parse(last!.body);
82
+ expect(body.phonePeerId).toBe('12D3KooWX');
83
+ expect(body.issuedInvite.token).toBe('t');
84
+ });
85
+
86
+ it('removePeer DELETEs /admin/members/:peerId (url-encoded)', async () => {
87
+ const client = makeClient();
88
+ await client.removePeer('12D3KooWMember');
89
+ expect(last?.method).toBe('DELETE');
90
+ expect(last?.path).toBe('/admin/members/12D3KooWMember');
91
+ });
92
+
93
+ it('listMembers GETs /admin/members and returns the array', async () => {
94
+ responder = () => ({ status: 200, payload: { ok: true, data: { members: [{ peerId: 'a', multiaddr: '/ip4/1.2.3.4/tcp/1' }] } } });
95
+ const client = makeClient();
96
+ const members = await client.listMembers();
97
+ expect(last?.method).toBe('GET');
98
+ expect(last?.path).toBe('/admin/members');
99
+ expect(members).toEqual([{ peerId: 'a', multiaddr: '/ip4/1.2.3.4/tcp/1' }]);
100
+ });
101
+
102
+ it('isMember GETs /admin/members/:peerId and returns the boolean', async () => {
103
+ responder = () => ({ status: 200, payload: { ok: true, data: { member: true } } });
104
+ const client = makeClient();
105
+ expect(await client.isMember('peer-1')).toBe(true);
106
+ expect(last?.path).toBe('/admin/members/peer-1');
107
+ });
108
+
109
+ it('getPeerId GETs /admin/identity', async () => {
110
+ responder = () => ({ status: 200, payload: { ok: true, data: { peerId: '12D3KooWHost', partyId: 'party' } } });
111
+ const client = makeClient();
112
+ expect(await client.getPeerId()).toBe('12D3KooWHost');
113
+ expect(last?.path).toBe('/admin/identity');
114
+ });
115
+
116
+ it('getMultiaddrs GETs /admin/multiaddrs', async () => {
117
+ responder = () => ({ status: 200, payload: { ok: true, data: { multiaddrs: ['/ip4/0.0.0.0/tcp/4001'] } } });
118
+ const client = makeClient();
119
+ expect(await client.getMultiaddrs()).toEqual(['/ip4/0.0.0.0/tcp/4001']);
120
+ expect(last?.path).toBe('/admin/multiaddrs');
121
+ });
122
+
123
+ it('pushInviteAddresses PUTs /admin/invite-addresses', async () => {
124
+ const client = makeClient();
125
+ await client.pushInviteAddresses(['/dns4/h/tcp/1/p2p/x']);
126
+ expect(last?.method).toBe('PUT');
127
+ expect(last?.path).toBe('/admin/invite-addresses');
128
+ expect(JSON.parse(last!.body)).toEqual({ addresses: ['/dns4/h/tcp/1/p2p/x'] });
129
+ });
130
+
131
+ it('encodeInvite mirrors cadre-core base64url(JSON)', () => {
132
+ const client = makeClient();
133
+ const invite = { partyId: 'p', authorityAddrs: [], token: 't', createdAt: 1 };
134
+ const encoded = client.encodeInvite(invite);
135
+ const decoded = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'));
136
+ expect(decoded).toEqual(invite);
137
+ });
138
+
139
+ it('maps a non-2xx response to AuthorityNodeUnavailableError carrying the code', async () => {
140
+ responder = () => ({ status: 503, payload: { ok: false, error: { code: 'not_ready', message: 'node booting' } } });
141
+ const client = makeClient();
142
+ await expect(client.getPeerId()).rejects.toBeInstanceOf(AuthorityNodeUnavailableError);
143
+ await expect(client.getPeerId()).rejects.toMatchObject({ nodeCode: 'not_ready' });
144
+ });
145
+
146
+ it('maps a refused connection to AuthorityNodeUnavailableError', async () => {
147
+ // Bind a throwaway server to claim a port, then close it so the address
148
+ // is guaranteed refused.
149
+ const tmp = http.createServer();
150
+ const closedPort = await new Promise<number>((resolve) => {
151
+ tmp.listen(0, '127.0.0.1', () => {
152
+ const a = tmp.address();
153
+ resolve(typeof a === 'object' && a ? a.port : 0);
154
+ });
155
+ });
156
+ await new Promise<void>((resolve) => tmp.close(() => resolve()));
157
+
158
+ const client = new AuthorityNodeClient({ baseUrl: `http://127.0.0.1:${closedPort}`, token: TOKEN });
159
+ await expect(client.getPeerId()).rejects.toBeInstanceOf(AuthorityNodeUnavailableError);
160
+ });
161
+
162
+ it('throws AuthorityNodeUnavailableError when the endpoint is undefined', async () => {
163
+ const client = new AuthorityNodeClient(() => undefined);
164
+ await expect(client.getPeerId()).rejects.toBeInstanceOf(AuthorityNodeUnavailableError);
165
+ });
166
+ });
@@ -0,0 +1,199 @@
1
+ /**
2
+ * HTTP client for the authority 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 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`.
11
+ *
12
+ * Transport: `Authorization: Bearer <token>` against
13
+ * `http://127.0.0.1:<adminPort>`. Every response uses the cadre-provider
14
+ * envelope `{ ok: true, data }` / `{ ok: false, error: { code, message } }`.
15
+ * A refused connection, a transport error, or any non-2xx response surfaces
16
+ * as {@link AuthorityNodeUnavailableError}; the trust-circle / NAT services
17
+ * translate that into a `node_unavailable` domain error so the management API
18
+ * returns a clear 503 rather than a raw 500.
19
+ */
20
+
21
+ import debug from 'debug';
22
+ import type { CadreInvite } from '@serfab/cadre-core';
23
+
24
+ import type { AuthorityAdminEndpoint } from '../orchestrator/index.js';
25
+ import type { CadreNodeLike as TrustCircleCadreNodeLike } from '../auth/trust-circle.js';
26
+ import type { CadreNodeLike as NatCadreNodeLike } from '../nat/nat-service.js';
27
+
28
+ const log = debug('cadre:host:authority-client');
29
+
30
+ /**
31
+ * Raised when the authority node's admin channel can't be reached or returns
32
+ * a non-success response. Carries the node's stable error `code` when one was
33
+ * present in the envelope (e.g. `not_ready`, `not_authorized`).
34
+ */
35
+ export class AuthorityNodeUnavailableError extends Error {
36
+ /** The admin-channel error code, when the node answered with an envelope. */
37
+ readonly nodeCode?: string;
38
+
39
+ constructor(message: string, nodeCode?: string) {
40
+ super(message);
41
+ this.name = 'AuthorityNodeUnavailableError';
42
+ if (nodeCode !== undefined) this.nodeCode = nodeCode;
43
+ }
44
+ }
45
+
46
+ /** Endpoint provided directly or via a getter (so it tracks re-spawns). */
47
+ type EndpointSource =
48
+ | AuthorityAdminEndpoint
49
+ | (() => AuthorityAdminEndpoint | undefined)
50
+ | undefined;
51
+
52
+ export interface AuthorityNodeClientOptions {
53
+ /** Fetch override for tests. Defaults to the global `fetch`. */
54
+ fetch?: typeof fetch;
55
+ }
56
+
57
+ export class AuthorityNodeClient implements TrustCircleCadreNodeLike, NatCadreNodeLike {
58
+ private readonly endpointSource: EndpointSource;
59
+ private readonly fetchImpl: typeof fetch;
60
+
61
+ /**
62
+ * @param endpoint Either a fixed `{ baseUrl, token }` or a getter returning
63
+ * the current endpoint (pass `orchestrator.getAuthorityAdminEndpoint`
64
+ * bound, so a restart's new bearer token is picked up automatically).
65
+ */
66
+ constructor(endpoint: EndpointSource, opts: AuthorityNodeClientOptions = {}) {
67
+ this.endpointSource = endpoint;
68
+ this.fetchImpl = opts.fetch ?? fetch;
69
+ }
70
+
71
+ // --- trust-circle CadreNodeLike ---
72
+
73
+ async createInvite(
74
+ token?: string,
75
+ expiresInMs?: number,
76
+ ): Promise<{ invite: CadreInvite; encodedInvite: string }> {
77
+ const body: Record<string, unknown> = {};
78
+ if (token !== undefined) body.token = token;
79
+ if (expiresInMs !== undefined) body.expiresInMs = expiresInMs;
80
+ return await this.request<{ invite: CadreInvite; encodedInvite: string }>(
81
+ 'POST',
82
+ '/admin/invites',
83
+ body,
84
+ );
85
+ }
86
+
87
+ async acceptPhone(
88
+ options: { phonePeerId: string; token?: string },
89
+ issuedInvite?: CadreInvite,
90
+ ): Promise<void> {
91
+ const body: Record<string, unknown> = { phonePeerId: options.phonePeerId };
92
+ if (options.token !== undefined) body.token = options.token;
93
+ if (issuedInvite !== undefined) body.issuedInvite = issuedInvite;
94
+ await this.request('POST', '/admin/accept-phone', body);
95
+ }
96
+
97
+ async removePeer(peerId: string): Promise<void> {
98
+ await this.request('DELETE', `/admin/members/${encodeURIComponent(peerId)}`);
99
+ }
100
+
101
+ /**
102
+ * Encode an invite for out-of-band delivery. The admin contract has no
103
+ * encode route (the mint route already returns `encodedInvite`), so this
104
+ * mirrors cadre-core's encoding (`base64url(JSON.stringify(invite))`).
105
+ */
106
+ encodeInvite(invite: CadreInvite): string {
107
+ return Buffer.from(JSON.stringify(invite), 'utf8').toString('base64url');
108
+ }
109
+
110
+ async listMembers(): Promise<Array<{ peerId: string; multiaddr: string | null }>> {
111
+ const data = await this.request<{ members: Array<{ peerId: string; multiaddr: string | null }> }>(
112
+ 'GET',
113
+ '/admin/members',
114
+ );
115
+ return data.members;
116
+ }
117
+
118
+ async isMember(peerId: string): Promise<boolean> {
119
+ const data = await this.request<{ member: boolean }>(
120
+ 'GET',
121
+ `/admin/members/${encodeURIComponent(peerId)}`,
122
+ );
123
+ return data.member;
124
+ }
125
+
126
+ // --- NAT CadreNodeLike ---
127
+
128
+ async getPeerId(): Promise<string> {
129
+ const data = await this.request<{ peerId: string | null; partyId: string }>('GET', '/admin/identity');
130
+ return data.peerId ?? '';
131
+ }
132
+
133
+ async getMultiaddrs(): Promise<string[]> {
134
+ const data = await this.request<{ multiaddrs: string[] }>('GET', '/admin/multiaddrs');
135
+ return data.multiaddrs;
136
+ }
137
+
138
+ // --- push-model invite addresses ---
139
+
140
+ /** Push the NAT-resolved addresses the node should embed in future invites. */
141
+ async pushInviteAddresses(addresses: string[]): Promise<void> {
142
+ await this.request('PUT', '/admin/invite-addresses', { addresses });
143
+ }
144
+
145
+ // --- internals ---
146
+
147
+ private resolveEndpoint(): AuthorityAdminEndpoint {
148
+ const ep = typeof this.endpointSource === 'function' ? this.endpointSource() : this.endpointSource;
149
+ if (!ep) {
150
+ throw new AuthorityNodeUnavailableError('Authority node admin endpoint is not available');
151
+ }
152
+ return ep;
153
+ }
154
+
155
+ private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
156
+ const ep = this.resolveEndpoint();
157
+ const headers: Record<string, string> = { authorization: `Bearer ${ep.token}` };
158
+ const init: RequestInit = { method, headers };
159
+ if (body !== undefined) {
160
+ headers['content-type'] = 'application/json';
161
+ init.body = JSON.stringify(body);
162
+ }
163
+
164
+ let res: Response;
165
+ try {
166
+ res = await this.fetchImpl(`${ep.baseUrl}${path}`, init);
167
+ } catch (err) {
168
+ const message = err instanceof Error ? err.message : String(err);
169
+ log('admin %s %s transport error: %s', method, path, message);
170
+ throw new AuthorityNodeUnavailableError(`Authority node unreachable: ${message}`);
171
+ }
172
+
173
+ const envelope = await this.parseEnvelope(res);
174
+ if (!res.ok || !envelope.ok) {
175
+ const code = envelope.error?.code;
176
+ const message = envelope.error?.message ?? `admin ${method} ${path} → HTTP ${res.status}`;
177
+ log('admin %s %s failed: [%s] %s', method, path, code ?? res.status, message);
178
+ throw new AuthorityNodeUnavailableError(message, code);
179
+ }
180
+ return envelope.data as T;
181
+ }
182
+
183
+ private async parseEnvelope(res: Response): Promise<{
184
+ ok?: boolean;
185
+ data?: unknown;
186
+ error?: { code?: string; message?: string };
187
+ }> {
188
+ try {
189
+ const parsed = await res.json();
190
+ if (parsed && typeof parsed === 'object') {
191
+ return parsed as { ok?: boolean; data?: unknown; error?: { code?: string; message?: string } };
192
+ }
193
+ } catch {
194
+ // Non-JSON body — fall through to an empty envelope; status drives the
195
+ // ok/non-ok decision in the caller.
196
+ }
197
+ return {};
198
+ }
199
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Authority-node delegation surface for cadre-host.
3
+ *
4
+ * The manager process spawns the admin's authority cadre node (via
5
+ * `HostProcessOrchestrator`) and delegates authority/membership/identity
6
+ * operations to it over the loopback admin channel using
7
+ * {@link AuthorityNodeClient}. See `docs/cadre-host.md` § Control-plane
8
+ * separation.
9
+ */
10
+
11
+ export { AuthorityNodeClient, AuthorityNodeUnavailableError } from './authority-node-client.js';
12
+ export type { AuthorityNodeClientOptions } from './authority-node-client.js';