@serfab/cadre-host 0.9.0 → 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 (113) 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 +32 -5
  8. package/dist/auth/trust-circle.js +31 -11
  9. package/dist/auth/trust-circle.js.map +1 -1
  10. package/dist/bin/host.js +41 -5
  11. package/dist/bin/host.js.map +1 -1
  12. package/dist/donation/__tests__/fake-orchestrator.d.ts +103 -0
  13. package/dist/donation/__tests__/fake-orchestrator.js +180 -0
  14. package/dist/donation/__tests__/fake-orchestrator.js.map +1 -0
  15. package/dist/donation/donation-service.d.ts +237 -12
  16. package/dist/donation/donation-service.js +491 -34
  17. package/dist/donation/donation-service.js.map +1 -1
  18. package/dist/donation/donation-store.d.ts +7 -0
  19. package/dist/donation/donation-store.js +7 -0
  20. package/dist/donation/donation-store.js.map +1 -1
  21. package/dist/donation/donation-supervisor.d.ts +167 -0
  22. package/dist/donation/donation-supervisor.js +341 -0
  23. package/dist/donation/donation-supervisor.js.map +1 -0
  24. package/dist/donation/index.d.ts +4 -2
  25. package/dist/donation/index.js +2 -1
  26. package/dist/donation/index.js.map +1 -1
  27. package/dist/donation/types.d.ts +26 -0
  28. package/dist/donation/types.js.map +1 -1
  29. package/dist/index.d.ts +3 -1
  30. package/dist/index.js +3 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/orchestrator/host-process-orchestrator.d.ts +109 -5
  33. package/dist/orchestrator/host-process-orchestrator.js +341 -98
  34. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  35. package/dist/orchestrator/node-identity.d.ts +26 -0
  36. package/dist/orchestrator/node-identity.js +36 -0
  37. package/dist/orchestrator/node-identity.js.map +1 -0
  38. package/dist/orchestrator/port-allocator.d.ts +14 -0
  39. package/dist/orchestrator/port-allocator.js +45 -0
  40. package/dist/orchestrator/port-allocator.js.map +1 -1
  41. package/dist/owner/owner-node-client.d.ts +20 -5
  42. package/dist/owner/owner-node-client.js +21 -4
  43. package/dist/owner/owner-node-client.js.map +1 -1
  44. package/dist/server/error-handler.d.ts +7 -1
  45. package/dist/server/error-handler.js +23 -1
  46. package/dist/server/error-handler.js.map +1 -1
  47. package/dist/server/events/types.d.ts +3 -0
  48. package/dist/server/index.d.ts +7 -0
  49. package/dist/server/index.js +9 -3
  50. package/dist/server/index.js.map +1 -1
  51. package/dist/server/routes/grants.js +22 -3
  52. package/dist/server/routes/grants.js.map +1 -1
  53. package/dist/server/routes/strands.d.ts +20 -0
  54. package/dist/server/routes/strands.js +38 -0
  55. package/dist/server/routes/strands.js.map +1 -0
  56. package/dist/strands/index.d.ts +7 -0
  57. package/dist/strands/index.js +6 -0
  58. package/dist/strands/index.js.map +1 -0
  59. package/dist/strands/strand-service.d.ts +50 -0
  60. package/dist/strands/strand-service.js +103 -0
  61. package/dist/strands/strand-service.js.map +1 -0
  62. package/dist/strands/types.d.ts +93 -0
  63. package/dist/strands/types.js +23 -0
  64. package/dist/strands/types.js.map +1 -0
  65. package/dist/ui/assets/index-BVoqLjyX.css +1 -0
  66. package/dist/ui/assets/index-C-iks41M.js +17 -0
  67. package/dist/ui/assets/index-C-iks41M.js.map +1 -0
  68. package/dist/ui/index.html +2 -2
  69. package/dist/update/release-key.d.ts +1 -1
  70. package/dist/update/release-key.js +1 -1
  71. package/package.json +5 -5
  72. package/src/__tests__/build-targets.test.ts +26 -0
  73. package/src/__tests__/global-setup.ts +51 -0
  74. package/src/__tests__/orchestrator-env-scrub.test.ts +140 -0
  75. package/src/__tests__/orchestrator-node-identity.test.ts +204 -0
  76. package/src/__tests__/orchestrator.test.ts +387 -6
  77. package/src/auth/__tests__/self-label.test.ts +85 -0
  78. package/src/auth/__tests__/trust-circle-integration.test.ts +152 -147
  79. package/src/auth/__tests__/trust-circle.test.ts +45 -1
  80. package/src/auth/index.ts +2 -0
  81. package/src/auth/self-label.ts +74 -0
  82. package/src/auth/trust-circle.ts +45 -13
  83. package/src/bin/host.ts +1299 -1258
  84. package/src/donation/__tests__/donation-service.test.ts +836 -58
  85. package/src/donation/__tests__/donation-supervisor.test.ts +510 -0
  86. package/src/donation/__tests__/fake-orchestrator.test.ts +168 -0
  87. package/src/donation/__tests__/fake-orchestrator.ts +225 -0
  88. package/src/donation/donation-service.ts +611 -41
  89. package/src/donation/donation-store.ts +7 -0
  90. package/src/donation/donation-supervisor.ts +388 -0
  91. package/src/donation/index.ts +15 -0
  92. package/src/donation/types.ts +23 -0
  93. package/src/index.ts +14 -0
  94. package/src/orchestrator/__tests__/node-identity.test.ts +62 -0
  95. package/src/orchestrator/host-process-orchestrator.ts +336 -97
  96. package/src/orchestrator/node-identity.ts +40 -0
  97. package/src/orchestrator/port-allocator.ts +48 -0
  98. package/src/owner/owner-node-client.ts +31 -5
  99. package/src/server/__tests__/grants-route.test.ts +80 -6
  100. package/src/server/__tests__/strands-route.test.ts +233 -0
  101. package/src/server/error-handler.ts +24 -1
  102. package/src/server/events/types.ts +1 -0
  103. package/src/server/index.ts +16 -3
  104. package/src/server/routes/grants.ts +27 -3
  105. package/src/server/routes/strands.ts +54 -0
  106. package/src/strands/__tests__/strand-service.test.ts +165 -0
  107. package/src/strands/index.ts +14 -0
  108. package/src/strands/strand-service.ts +135 -0
  109. package/src/strands/types.ts +102 -0
  110. package/src/update/release-key.ts +1 -1
  111. package/dist/ui/assets/index-BlMXQPSw.js +0 -14
  112. package/dist/ui/assets/index-BlMXQPSw.js.map +0 -1
  113. package/dist/ui/assets/index-BwU5mJGF.css +0 -1
@@ -5,10 +5,10 @@
5
5
  * cadre-host's manager process holds no in-process `CadreNode`; it spawns the
6
6
  * admin's owner node as a managed child (see `HostProcessOrchestrator`)
7
7
  * and delegates all owner/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` and `addDrone` (the
11
- * node-donation requester side).
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
12
  *
13
13
  * Transport: `Authorization: Bearer <token>` against
14
14
  * `http://127.0.0.1:<adminPort>`. Every response uses the cadre-provider
@@ -25,6 +25,8 @@ import type { CadreInvite, DroneInitResult } from '@serfab/cadre-core';
25
25
  import type { OwnerAdminEndpoint } from '../orchestrator/index.js';
26
26
  import type { CadreNodeLike as TrustCircleCadreNodeLike } from '../auth/trust-circle.js';
27
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';
28
30
 
29
31
  const log = debug('cadre:host:owner-client');
30
32
 
@@ -55,7 +57,8 @@ export interface OwnerNodeClientOptions {
55
57
  fetch?: typeof fetch;
56
58
  }
57
59
 
58
- export class OwnerNodeClient implements TrustCircleCadreNodeLike, NatCadreNodeLike {
60
+ export class OwnerNodeClient
61
+ implements TrustCircleCadreNodeLike, NatCadreNodeLike, StrandCadreNodeLike {
59
62
  private readonly endpointSource: EndpointSource;
60
63
  private readonly fetchImpl: typeof fetch;
61
64
 
@@ -153,6 +156,29 @@ export class OwnerNodeClient implements TrustCircleCadreNodeLike, NatCadreNodeLi
153
156
  return data.multiaddrs;
154
157
  }
155
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
+
156
182
  // --- drone seeding (node-donation requester side) ---
157
183
 
158
184
  /**
@@ -54,12 +54,20 @@ class FakeOrchestrator implements Orchestrator {
54
54
  let tmpRoot: string;
55
55
  let app: ReturnType<typeof Fastify>;
56
56
  let grants: GrantService;
57
+ let store: DonationStore;
57
58
  let token: string;
59
+ const originalFetch = globalThis.fetch;
60
+
61
+ /**
62
+ * A well-formed 32-byte base64url owner key. `DonationService.provision`
63
+ * shape-checks the pins, so a placeholder string is a 400 rather than a fixture.
64
+ */
65
+ const OWNER_KEY = Buffer.alloc(32, 7).toString('base64url');
58
66
 
59
67
  const body = {
60
68
  partyId: 'party-P',
61
69
  bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooReq'],
62
- ownerKeys: ['owner-key-b64url'],
70
+ ownerKeys: [OWNER_KEY],
63
71
  };
64
72
 
65
73
  const bearer = (t: string) => ({ authorization: `Bearer ${t}` });
@@ -68,17 +76,15 @@ beforeEach(() => {
68
76
  tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-grants-svc-route-'));
69
77
  grants = new GrantService({ store: new GrantStore(join(tmpRoot, 'grants')) });
70
78
  token = grants.issue({ label: 'Alice', maxNodes: 2 }).token;
71
- const donations = new DonationService({
72
- orchestrator: new FakeOrchestrator(),
73
- grants,
74
- store: new DonationStore(join(tmpRoot, 'donations')),
75
- });
79
+ store = new DonationStore(join(tmpRoot, 'donations'));
80
+ const donations = new DonationService({ orchestrator: new FakeOrchestrator(), grants, store });
76
81
  app = Fastify();
77
82
  registerErrorHandler(app);
78
83
  registerGrantsRoutes(app, { donations, grants });
79
84
  });
80
85
 
81
86
  afterEach(async () => {
87
+ globalThis.fetch = originalFetch;
82
88
  await app.close();
83
89
  try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
84
90
  });
@@ -122,6 +128,32 @@ describe('POST /grants', () => {
122
128
  expect(res.statusCode).toBe(400);
123
129
  });
124
130
 
131
+ // A typo'd pin used to be answered 201 and only fail when the spawned child
132
+ // tried to decode it at boot — by which point the caller had no way to act on it.
133
+ it('rejects a malformed ownerKeys entry → 400 invalid_request, naming the bad key', async () => {
134
+ const res = await app.inject({
135
+ method: 'POST', url: '/grants', headers: bearer(token),
136
+ payload: { ...body, ownerKeys: [OWNER_KEY, 'this-is-not-a-key'] },
137
+ });
138
+ expect(res.statusCode).toBe(400);
139
+ const error = (res.json() as { error: { code: string; message: string } }).error;
140
+ expect(error.code).toBe('invalid_request');
141
+ expect(error.message).toContain('this-is-not-a-key');
142
+ });
143
+
144
+ // Element types are the route's job: the service's shape check assumes strings,
145
+ // so a number here would come back as an internal `.trim is not a function`.
146
+ it('rejects a non-string ownerKeys entry → 400 naming the field, not a decoder error', async () => {
147
+ const res = await app.inject({
148
+ method: 'POST', url: '/grants', headers: bearer(token),
149
+ payload: { ...body, ownerKeys: [42] },
150
+ });
151
+ expect(res.statusCode).toBe(400);
152
+ const error = (res.json() as { error: { code: string; message: string } }).error;
153
+ expect(error.code).toBe('invalid_request');
154
+ expect(error.message).toBe('ownerKeys must be an array of strings');
155
+ });
156
+
125
157
  it('enforces the grant quota → 429 quota_exceeded once maxNodes is reached', async () => {
126
158
  const g = grants.issue({ label: 'Bob', maxNodes: 1 }).token;
127
159
  const first = await app.inject({ method: 'POST', url: '/grants', headers: bearer(g), payload: body });
@@ -173,6 +205,48 @@ describe('PUT /grants/:id/seed validation', () => {
173
205
  expect(res.statusCode).toBe(400);
174
206
  expect((res.json() as { error: { code: string } }).error.code).toBe('invalid_request');
175
207
  });
208
+
209
+ it('answers 409 invalid_state when the loan ends while the seed is in flight', async () => {
210
+ const created = await app.inject({ method: 'POST', url: '/grants', headers: bearer(token), payload: body });
211
+ const id = (created.json() as { data: { donation: { id: string } } }).data.donation.id;
212
+
213
+ // The borrower's DELETE lands inside the seed window: the node accepts the
214
+ // seed, but by the time it answers the loan is over. The ending wins, so the
215
+ // route must NOT report a 200 that implies a live, freshly seeded node.
216
+ globalThis.fetch = (async () => {
217
+ await app.inject({ method: 'DELETE', url: `/grants/${id}`, headers: bearer(token) });
218
+ return { ok: true, json: async () => ({ success: true, peersAdded: 2 }) } as unknown as Response;
219
+ }) as typeof globalThis.fetch;
220
+
221
+ const res = await app.inject({
222
+ method: 'PUT', url: `/grants/${id}/seed`, headers: bearer(token), payload: { seed: 'encoded-seed' },
223
+ });
224
+
225
+ expect(res.statusCode).toBe(409);
226
+ expect((res.json() as { error: { code: string } }).error.code).toBe('invalid_state');
227
+ const after = await app.inject({ method: 'GET', url: `/grants/${id}`, headers: bearer(token) });
228
+ expect((after.json() as { data: { donation: { status: string } } }).data.donation.status)
229
+ .toBe('terminated');
230
+ });
231
+
232
+ it('answers 404 not_found when the record vanishes while the seed is in flight', async () => {
233
+ const created = await app.inject({ method: 'POST', url: '/grants', headers: bearer(token), payload: body });
234
+ const id = (created.json() as { data: { donation: { id: string } } }).data.donation.id;
235
+
236
+ // The row is gone by the time the node answers — the other half of the
237
+ // abandon mapping, which has no status to report.
238
+ globalThis.fetch = (async () => {
239
+ store.remove(id);
240
+ return { ok: true, json: async () => ({ success: true, peersAdded: 2 }) } as unknown as Response;
241
+ }) as typeof globalThis.fetch;
242
+
243
+ const res = await app.inject({
244
+ method: 'PUT', url: `/grants/${id}/seed`, headers: bearer(token), payload: { seed: 'encoded-seed' },
245
+ });
246
+
247
+ expect(res.statusCode).toBe(404);
248
+ expect((res.json() as { error: { code: string } }).error.code).toBe('not_found');
249
+ });
176
250
  });
177
251
 
178
252
  describe('DELETE /grants/:id', () => {
@@ -0,0 +1,233 @@
1
+ /**
2
+ * `/api/strands` route tests — a real Fastify built by `createLocalUiServer`
3
+ * (so the error handler, the mount gate, and the static not-found fallback are
4
+ * all the production ones) over a fake StrandService.
5
+ *
6
+ * What the service does is covered by `strands/__tests__/strand-service.test.ts`;
7
+ * this file is about the HTTP contract: envelope shape, status mapping, the
8
+ * `confirm` query param, and exactly-once bus publication.
9
+ */
10
+
11
+ import { mkdtempSync, rmSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+
15
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
16
+
17
+ import { createLocalUiServer } from '../index.js';
18
+ import type { LocalUiEvent } from '../events/types.js';
19
+ import type { HostProcessOrchestrator } from '../../orchestrator/index.js';
20
+ import type { StrandService } from '../../strands/index.js';
21
+ import { StrandError } from '../../strands/index.js';
22
+ import type { StrandListSnapshot, StrandRemovalResult } from '../../strands/index.js';
23
+
24
+ function fakeOrchestrator(): HostProcessOrchestrator {
25
+ return {
26
+ listNodes: () => [],
27
+ getNode: () => undefined,
28
+ resolveDockerId: () => undefined,
29
+ onStateChange: () => () => undefined,
30
+ } as unknown as HostProcessOrchestrator;
31
+ }
32
+
33
+ const SNAPSHOT: StrandListSnapshot = {
34
+ strands: [
35
+ { id: 'open-one', type: 'o', running: true, status: 'active' },
36
+ { id: 'closed-one', type: 'c', running: false, status: null },
37
+ ],
38
+ controlConnections: 3,
39
+ };
40
+
41
+ interface FakeStrands {
42
+ service: StrandService;
43
+ /** Every (id, confirm) the route forwarded. */
44
+ calls: Array<{ strandId: string; confirm: boolean }>;
45
+ /** Set to make `list` and `remove` throw. */
46
+ failWith?: unknown;
47
+ /** Set to make `remove` report a strand that was never published. */
48
+ absent?: boolean;
49
+ }
50
+
51
+ function fakeStrandService(): FakeStrands {
52
+ const state: FakeStrands = {
53
+ calls: [],
54
+ service: undefined as unknown as StrandService,
55
+ };
56
+ state.service = {
57
+ async list(): Promise<StrandListSnapshot> {
58
+ if (state.failWith) throw state.failWith;
59
+ return SNAPSHOT;
60
+ },
61
+ async remove(strandId: string, opts: { confirm: boolean }): Promise<StrandRemovalResult> {
62
+ state.calls.push({ strandId, confirm: opts.confirm });
63
+ if (state.failWith) throw state.failWith;
64
+ return state.absent
65
+ ? { strandId, published: false, type: null, removed: false, alone: false }
66
+ : { strandId, published: true, type: 'o', removed: true, alone: false };
67
+ },
68
+ } as unknown as StrandService;
69
+ return state;
70
+ }
71
+
72
+ let dataDir: string;
73
+ let server: ReturnType<typeof createLocalUiServer>;
74
+ let strands: FakeStrands;
75
+ let events: LocalUiEvent[];
76
+
77
+ function buildServer(opts: { withStrands: boolean }): void {
78
+ server = createLocalUiServer({
79
+ uiPort: 8765,
80
+ dataDir,
81
+ orchestrator: fakeOrchestrator(),
82
+ ...(opts.withStrands ? { strands: strands.service } : {}),
83
+ forcePort: 0,
84
+ });
85
+ events = [];
86
+ server.events.subscribe((e) => events.push(e));
87
+ }
88
+
89
+ beforeEach(() => {
90
+ dataDir = mkdtempSync(join(tmpdir(), 'cadre-host-strands-route-'));
91
+ strands = fakeStrandService();
92
+ buildServer({ withStrands: true });
93
+ });
94
+
95
+ afterEach(async () => {
96
+ await server.app.close();
97
+ rmSync(dataDir, { recursive: true, force: true });
98
+ });
99
+
100
+ describe('GET /api/strands', () => {
101
+ it('returns the snapshot in the /api/* envelope', async () => {
102
+ const res = await server.app.inject({ method: 'GET', url: '/api/strands' });
103
+ expect(res.statusCode).toBe(200);
104
+ const body = res.json() as { ok: boolean; data: StrandListSnapshot };
105
+ expect(body.ok).toBe(true);
106
+ expect(body.data.strands).toHaveLength(2);
107
+ expect(body.data.strands[0]?.id).toBe('open-one');
108
+ expect(body.data.controlConnections).toBe(3);
109
+ });
110
+
111
+ it('maps a service failure through the same error handler as the DELETE arm', async () => {
112
+ strands.failWith = new StrandError('node_unavailable', 'Owner node unavailable: refused');
113
+ const res = await server.app.inject({ method: 'GET', url: '/api/strands' });
114
+ expect(res.statusCode).toBe(503);
115
+ expect((res.json() as { error: { code: string } }).error.code).toBe('node_unavailable');
116
+ });
117
+ });
118
+
119
+ describe('DELETE /api/strands/:id', () => {
120
+ it('removes a published strand → 200 and exactly one strands-changed event', async () => {
121
+ const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/open-one' });
122
+ expect(res.statusCode).toBe(200);
123
+ const body = res.json() as { ok: boolean; data: StrandRemovalResult };
124
+ expect(body.ok).toBe(true);
125
+ expect(body.data).toMatchObject({ strandId: 'open-one', published: true, removed: true });
126
+ expect(events).toEqual([{ type: 'strands-changed', kind: 'removed' }]);
127
+ });
128
+
129
+ it('an absent strand → 200, published: false, and NO event', async () => {
130
+ strands.absent = true;
131
+ const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/never-published' });
132
+ expect(res.statusCode).toBe(200);
133
+ const body = res.json() as { data: StrandRemovalResult };
134
+ expect(body.data.published).toBe(false);
135
+ expect(body.data.removed).toBe(false);
136
+ expect(events).toEqual([]);
137
+ });
138
+
139
+ it('forwards confirm=1 as true and its absence as false', async () => {
140
+ await server.app.inject({ method: 'DELETE', url: '/api/strands/a' });
141
+ await server.app.inject({ method: 'DELETE', url: '/api/strands/b?confirm=1' });
142
+ await server.app.inject({ method: 'DELETE', url: '/api/strands/c?confirm=true' });
143
+ expect(strands.calls).toEqual([
144
+ { strandId: 'a', confirm: false },
145
+ { strandId: 'b', confirm: true },
146
+ { strandId: 'c', confirm: true },
147
+ ]);
148
+ });
149
+
150
+ it('treats a guessy confirm value as NOT confirmed', async () => {
151
+ // `yes`/`on`/empty are refused deliberately — a typo must not destroy a
152
+ // closed strand's membership key.
153
+ await server.app.inject({ method: 'DELETE', url: '/api/strands/a?confirm=yes' });
154
+ await server.app.inject({ method: 'DELETE', url: '/api/strands/b?confirm=' });
155
+ expect(strands.calls).toEqual([
156
+ { strandId: 'a', confirm: false },
157
+ { strandId: 'b', confirm: false },
158
+ ]);
159
+ });
160
+
161
+ it('ignores query params other than confirm rather than proxying them', async () => {
162
+ await server.app.inject({ method: 'DELETE', url: '/api/strands/a?force=1&confirm=1' });
163
+ expect(strands.calls).toEqual([{ strandId: 'a', confirm: true }]);
164
+ });
165
+
166
+ // The route is one path segment, so an id holding a `/` has to arrive
167
+ // percent-encoded. Fastify decodes `params`, so the service sees the literal id
168
+ // and forwards it — nothing in this chain needs to refuse such an id.
169
+ it('carries a percent-encoded "/" through to the service as a literal', async () => {
170
+ const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/ns%2Fstrand' });
171
+ expect(res.statusCode).toBe(200);
172
+ expect(strands.calls).toEqual([{ strandId: 'ns/strand', confirm: false }]);
173
+ });
174
+
175
+ it('404s an UNencoded "/" at the router, before any handler runs', async () => {
176
+ const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/ns/strand' });
177
+ expect(res.statusCode).toBe(404);
178
+ expect(strands.calls).toEqual([]);
179
+ });
180
+ });
181
+
182
+ describe('DELETE /api/strands/:id — error mapping', () => {
183
+ it('confirmation_required → 428 with the node message intact', async () => {
184
+ const message = 'Refusing to remove closed strand closed-one without confirmation: …';
185
+ strands.failWith = new StrandError('confirmation_required', message);
186
+ const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/closed-one' });
187
+ expect(res.statusCode).toBe(428);
188
+ const body = res.json() as { ok: boolean; error: { code: string; message: string } };
189
+ expect(body.ok).toBe(false);
190
+ expect(body.error.code).toBe('confirmation_required');
191
+ expect(body.error.message).toBe(message);
192
+ expect(events).toEqual([]);
193
+ });
194
+
195
+ it('node_unavailable → 503', async () => {
196
+ strands.failWith = new StrandError('node_unavailable', 'Owner node unavailable: refused');
197
+ const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/open-one' });
198
+ expect(res.statusCode).toBe(503);
199
+ expect((res.json() as { error: { code: string } }).error.code).toBe('node_unavailable');
200
+ });
201
+
202
+ it('invalid_id → 400', async () => {
203
+ strands.failWith = new StrandError('invalid_id', 'A strand id is required');
204
+ const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/%20' });
205
+ expect(res.statusCode).toBe(400);
206
+ expect((res.json() as { error: { code: string } }).error.code).toBe('invalid_id');
207
+ });
208
+
209
+ it('internal → 500', async () => {
210
+ strands.failWith = new StrandError('internal', 'node said something new');
211
+ const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/open-one' });
212
+ expect(res.statusCode).toBe(500);
213
+ expect((res.json() as { error: { code: string } }).error.code).toBe('internal');
214
+ });
215
+ });
216
+
217
+ describe('donor-only mode (no strands option)', () => {
218
+ beforeEach(async () => {
219
+ await server.app.close();
220
+ buildServer({ withStrands: false });
221
+ });
222
+
223
+ it('404s GET /api/strands through the static not-found handler', async () => {
224
+ const res = await server.app.inject({ method: 'GET', url: '/api/strands' });
225
+ expect(res.statusCode).toBe(404);
226
+ expect((res.json() as { error: { code: string } }).error.code).toBe('not_found');
227
+ });
228
+
229
+ it('404s DELETE /api/strands/:id', async () => {
230
+ const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/open-one' });
231
+ expect(res.statusCode).toBe(404);
232
+ });
233
+ });
@@ -2,7 +2,8 @@
2
2
  * Fastify error handler — translates known typed errors to stable HTTP status
3
3
  * codes and uniform `{ ok: false, error: { code, message } }` payloads.
4
4
  *
5
- * Codes (cross-referenced with `auth/types.ts`, `nat/types.ts`, `update/types.ts`):
5
+ * Codes (cross-referenced with `auth/types.ts`, `nat/types.ts`, `strands/types.ts`,
6
+ * `update/types.ts`):
6
7
  * - TrustCircleError
7
8
  * invalid_label, invalid_token → 400
8
9
  * not_found, already_redeemed → 404
@@ -16,6 +17,11 @@
16
17
  * mapping_failed, router_unreachable,
17
18
  * ip_detection_failed, ddns_update_failed → 500
18
19
  * node_unavailable → 503
20
+ * - StrandError
21
+ * invalid_id → 400
22
+ * confirmation_required → 428
23
+ * node_unavailable → 503
24
+ * internal → 500
19
25
  * - UpdateErrorException
20
26
  * manifest_invalid, no_update_available,
21
27
  * min_previous_version → 400
@@ -45,6 +51,7 @@ import debug from 'debug';
45
51
 
46
52
  import { TrustCircleError, type TrustCircleErrorCode } from '../auth/types.js';
47
53
  import { NatError, type NatErrorCode } from '../nat/types.js';
54
+ import { StrandError, type StrandErrorCode } from '../strands/types.js';
48
55
  import { UpdateErrorException, type UpdateErrorCode } from '../update/types.js';
49
56
  import { GrantError, type GrantErrorCode, DonationError, type DonationErrorCode } from '../donation/types.js';
50
57
 
@@ -73,6 +80,19 @@ const NAT_STATUS: Record<NatErrorCode, number> = {
73
80
  node_unavailable: 503,
74
81
  };
75
82
 
83
+ /**
84
+ * 428 (Precondition Required) for `confirmation_required`: the node refused a
85
+ * closed-strand removal because the caller had not confirmed. It is not a 400
86
+ * (the request was well-formed) and not a 409 (nothing conflicts) — the caller
87
+ * must re-send with the precondition satisfied.
88
+ */
89
+ const STRAND_STATUS: Record<StrandErrorCode, number> = {
90
+ invalid_id: 400,
91
+ confirmation_required: 428,
92
+ node_unavailable: 503,
93
+ internal: 500,
94
+ };
95
+
76
96
  const GRANT_STATUS: Record<GrantErrorCode, number> = {
77
97
  invalid_label: 400,
78
98
  invalid_max_nodes: 400,
@@ -134,6 +154,9 @@ function classify(err: FastifyError): Classified {
134
154
  if (err instanceof NatError) {
135
155
  return { status: NAT_STATUS[err.code] ?? 500, code: err.code, message: err.message };
136
156
  }
157
+ if (err instanceof StrandError) {
158
+ return { status: STRAND_STATUS[err.code] ?? 500, code: err.code, message: err.message };
159
+ }
137
160
  if (err instanceof UpdateErrorException) {
138
161
  return { status: UPDATE_STATUS[err.code] ?? 500, code: err.code, message: err.message };
139
162
  }
@@ -12,6 +12,7 @@ import type { DirectReachability, PortForwardMode } from '../../nat/index.js';
12
12
  export type LocalUiEvent =
13
13
  | { type: 'node-state-changed'; nodeId: string; status: ContainerStatus }
14
14
  | { type: 'trust-circle-changed'; kind: 'invited' | 'redeemed' | 'revoked' }
15
+ | { type: 'strands-changed'; kind: 'removed' }
15
16
  | {
16
17
  type: 'connectivity-changed';
17
18
  portMode: PortForwardMode;
@@ -20,6 +20,8 @@ import type { TrustCircleService } from '../auth/index.js';
20
20
  import { createTrustCircleHandlers } from '../auth/index.js';
21
21
  import type { NatService } from '../nat/index.js';
22
22
  import { createNatHandlers } from '../nat/index.js';
23
+ import type { StrandService } from '../strands/index.js';
24
+ import { createStrandHandlers } from '../strands/index.js';
23
25
  import type { UpdateService } from '../update/index.js';
24
26
  import { createUpdateHandlers } from '../update/index.js';
25
27
  import type { GrantService, DonationService } from '../donation/index.js';
@@ -33,6 +35,7 @@ import { registerStaticMount } from './static.js';
33
35
  import { buildFastify, startListening, stopListening } from './server.js';
34
36
  import { registerNatRoutes } from './routes/nat.js';
35
37
  import { registerTrustCircleRoutes } from './routes/trust-circle.js';
38
+ import { registerStrandRoutes } from './routes/strands.js';
36
39
  import { registerUpdateRoutes } from './routes/update.js';
37
40
  import { registerStatusRoute } from './routes/status.js';
38
41
  import { registerNodesRoutes } from './routes/nodes.js';
@@ -60,6 +63,12 @@ export interface LocalUiServerOptions {
60
63
  * unmounted and 404s.
61
64
  */
62
65
  nat?: NatService;
66
+ /**
67
+ * Strand service — present only when the host runs its own personal cadre.
68
+ * Absent in donor-only mode, where there is no owner node to ask, so
69
+ * `/api/strands` stays unmounted and 404s through the static handler.
70
+ */
71
+ strands?: StrandService;
63
72
  /** Optional — 6.4.2 lands this; nullable while still iterating. */
64
73
  update?: UpdateService;
65
74
  /**
@@ -133,15 +142,19 @@ export function createLocalUiServer(opts: LocalUiServerOptions): LocalUiServer {
133
142
  registerNodesRoutes(app, { orchestrator: opts.orchestrator });
134
143
  registerSettingsRoutes(app, { settingsStore, ...(opts.nat ? { nat: opts.nat } : {}), ...(opts.update ? { update: opts.update } : {}) });
135
144
 
136
- // Trust-circle + NAT surfaces exist only when the host runs its own personal
137
- // cadre. In donor-only mode they're left unmounted, so `/auth/*` and `/nat/*`
138
- // fall through to the static not-found handler and 404 (see static.ts).
145
+ // Trust-circle + NAT + strand surfaces exist only when the host runs its own
146
+ // personal cadre. In donor-only mode they're left unmounted, so `/auth/*`,
147
+ // `/nat/*` and `/api/strands` fall through to the static not-found handler and
148
+ // 404 (see static.ts).
139
149
  if (opts.trustCircle) {
140
150
  registerTrustCircleRoutes(app, { handlers: createTrustCircleHandlers(opts.trustCircle), events });
141
151
  }
142
152
  if (opts.nat) {
143
153
  registerNatRoutes(app, { handlers: createNatHandlers(opts.nat), events });
144
154
  }
155
+ if (opts.strands) {
156
+ registerStrandRoutes(app, { handlers: createStrandHandlers(opts.strands), events });
157
+ }
145
158
  if (opts.update) {
146
159
  registerUpdateRoutes(app, { handlers: createUpdateHandlers(opts.update), events });
147
160
  }
@@ -119,6 +119,12 @@ export function registerGrantsRoutes(app: FastifyInstance, opts: GrantsRoutesOpt
119
119
  if (!Array.isArray(body.ownerKeys) || body.ownerKeys.length === 0) {
120
120
  return errorResponse(reply, 'invalid_request', 'ownerKeys is required', 400);
121
121
  }
122
+ // Element types too, not just the array: `provision`'s shape check trims each
123
+ // entry, so a non-string reaches the caller as an internal `.trim is not a
124
+ // function` rather than as a message naming what is wrong with the request.
125
+ if (!body.ownerKeys.every(key => typeof key === 'string')) {
126
+ return errorResponse(reply, 'invalid_request', 'ownerKeys must be an array of strings', 400);
127
+ }
122
128
  if (body.profile !== undefined && body.profile !== 'storage' && body.profile !== 'transaction') {
123
129
  return errorResponse(reply, 'invalid_request', 'profile must be "storage" or "transaction"', 400);
124
130
  }
@@ -169,10 +175,28 @@ export function registerGrantsRoutes(app: FastifyInstance, opts: GrantsRoutesOpt
169
175
  return errorResponse(reply, 'invalid_request', 'seed is required', 400);
170
176
  }
171
177
  const result = await donations.applySeed(request.params.id, body.seed);
172
- if (!result.success) {
173
- return errorResponse(reply, 'seed_failed', result.error ?? 'Node rejected the seed', 502);
178
+ // NOTE: exhaustive over today's three outcomes, and nothing forces it to stay
179
+ // that way — an unhandled fourth would fall out of the handler as `undefined`
180
+ // and hang the request rather than fail to compile. If `DonationSeedResult`
181
+ // ever grows a variant, add a `default:` that 500s here.
182
+ switch (result.outcome) {
183
+ case 'rejected':
184
+ return errorResponse(reply, 'seed_failed', result.error ?? 'Node rejected the seed', 502);
185
+ // The loan ended while the seed was in flight. The ending won, so the
186
+ // caller is told the record it seeded is no longer seedable (409) — or is
187
+ // gone entirely (404) — rather than a 200 that implies a live node.
188
+ case 'abandoned':
189
+ return result.status
190
+ ? errorResponse(
191
+ reply,
192
+ 'invalid_state',
193
+ `Donation ${request.params.id} ended (${result.status}) while the seed was in flight`,
194
+ 409,
195
+ )
196
+ : errorResponse(reply, 'not_found', `No such donation: ${request.params.id}`, 404);
197
+ case 'seeded':
198
+ return reply.send({ ok: true, data: { peersAdded: result.peersAdded } });
174
199
  }
175
- return reply.send({ ok: true, data: { peersAdded: result.peersAdded ?? 0 } });
176
200
  });
177
201
 
178
202
  // DELETE /grants/:id — terminate the donated node.
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Strand HTTP routes — thin adapters around `StrandHandlers`
3
+ * (`createStrandHandlers(service)`).
4
+ *
5
+ * Mount path: `/api/strands` — a UI-only surface with no CLI contract to
6
+ * preserve. (The `/auth/*` and `/nat/*` mounts sit outside `/api` only because
7
+ * the existing CLI already targets those URLs.) Envelope form therefore follows
8
+ * the other `/api/*` routes: `{ ok: true, data }`.
9
+ *
10
+ * A removal that actually issued a delete publishes `strands-changed` so other
11
+ * open tabs refresh. A call that found no row publishes nothing — nothing changed.
12
+ */
13
+
14
+ import type { FastifyInstance } from 'fastify';
15
+
16
+ import type { StrandHandlers } from '../../strands/types.js';
17
+ import type { EventBus } from '../events/bus.js';
18
+
19
+ export interface StrandRoutesOptions {
20
+ handlers: StrandHandlers;
21
+ events: EventBus;
22
+ }
23
+
24
+ /**
25
+ * The only accepted `confirm` values — the same strict pair the admin channel
26
+ * accepts. Not a general truthiness parser: `yes`, `on` and an empty value all
27
+ * count as NOT confirmed, because a guessy parser here would turn a typo into a
28
+ * destroyed membership key.
29
+ *
30
+ * NOTE: deliberately duplicated from cadre-cli's `admin-server.ts` rather than
31
+ * imported — cadre-host depends on cadre-cli for its bin path only, never its
32
+ * source. Keep the two in step by hand; do not DRY them into an import.
33
+ */
34
+ const CONFIRM_VALUES = new Set(['1', 'true']);
35
+
36
+ export function registerStrandRoutes(app: FastifyInstance, opts: StrandRoutesOptions): void {
37
+ const { handlers, events } = opts;
38
+
39
+ app.get('/api/strands', async () => {
40
+ return { ok: true, data: await handlers.listStrands() };
41
+ });
42
+
43
+ app.delete<{ Params: { id: string }; Querystring: { confirm?: string } }>(
44
+ '/api/strands/:id',
45
+ async (request) => {
46
+ const confirm = typeof request.query.confirm === 'string' && CONFIRM_VALUES.has(request.query.confirm);
47
+ const result = await handlers.removeStrand(request.params.id, { confirm });
48
+ if (result.removed) {
49
+ events.publish({ type: 'strands-changed', kind: 'removed' });
50
+ }
51
+ return { ok: true, data: result };
52
+ },
53
+ );
54
+ }