@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
@@ -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,12 +17,32 @@
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
22
28
  * apply_in_progress → 409
23
29
  * signature_invalid, unsupported_state_version,
24
30
  * apply_failed, rollback_failed, storage_error → 500
31
+ * - GrantError
32
+ * invalid_label, invalid_max_nodes,
33
+ * invalid_ttl → 400
34
+ * not_found → 404
35
+ * storage_error → 500
36
+ * - DonationError
37
+ * invalid_request → 400
38
+ * unauthorized → 401
39
+ * forbidden → 403
40
+ * not_found → 404
41
+ * invalid_state → 409
42
+ * quota_exceeded → 429
43
+ * orchestrator_error, storage_error → 500
44
+ * seed_failed → 502
45
+ * peer_unavailable → 503
25
46
  * - everything else → 500 (code "internal")
26
47
  */
27
48
 
@@ -30,7 +51,9 @@ import debug from 'debug';
30
51
 
31
52
  import { TrustCircleError, type TrustCircleErrorCode } from '../auth/types.js';
32
53
  import { NatError, type NatErrorCode } from '../nat/types.js';
54
+ import { StrandError, type StrandErrorCode } from '../strands/types.js';
33
55
  import { UpdateErrorException, type UpdateErrorCode } from '../update/types.js';
56
+ import { GrantError, type GrantErrorCode, DonationError, type DonationErrorCode } from '../donation/types.js';
34
57
 
35
58
  const log = debug('cadre:host:error-handler');
36
59
 
@@ -57,6 +80,40 @@ const NAT_STATUS: Record<NatErrorCode, number> = {
57
80
  node_unavailable: 503,
58
81
  };
59
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
+
96
+ const GRANT_STATUS: Record<GrantErrorCode, number> = {
97
+ invalid_label: 400,
98
+ invalid_max_nodes: 400,
99
+ invalid_ttl: 400,
100
+ not_found: 404,
101
+ storage_error: 500,
102
+ };
103
+
104
+ const DONATION_STATUS: Record<DonationErrorCode, number> = {
105
+ unauthorized: 401,
106
+ forbidden: 403,
107
+ quota_exceeded: 429,
108
+ invalid_request: 400,
109
+ not_found: 404,
110
+ invalid_state: 409,
111
+ seed_failed: 502,
112
+ peer_unavailable: 503,
113
+ orchestrator_error: 500,
114
+ storage_error: 500,
115
+ };
116
+
60
117
  const UPDATE_STATUS: Record<UpdateErrorCode, number> = {
61
118
  manifest_invalid: 400,
62
119
  no_update_available: 400,
@@ -97,9 +154,18 @@ function classify(err: FastifyError): Classified {
97
154
  if (err instanceof NatError) {
98
155
  return { status: NAT_STATUS[err.code] ?? 500, code: err.code, message: err.message };
99
156
  }
157
+ if (err instanceof StrandError) {
158
+ return { status: STRAND_STATUS[err.code] ?? 500, code: err.code, message: err.message };
159
+ }
100
160
  if (err instanceof UpdateErrorException) {
101
161
  return { status: UPDATE_STATUS[err.code] ?? 500, code: err.code, message: err.message };
102
162
  }
163
+ if (err instanceof GrantError) {
164
+ return { status: GRANT_STATUS[err.code] ?? 500, code: err.code, message: err.message };
165
+ }
166
+ if (err instanceof DonationError) {
167
+ return { status: DONATION_STATUS[err.code] ?? 500, code: err.code, message: err.message };
168
+ }
103
169
  // Fastify body-parsing / validation errors have a statusCode we should honour.
104
170
  if (typeof err.statusCode === 'number' && err.statusCode >= 400 && err.statusCode < 600) {
105
171
  return {
@@ -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,8 +20,12 @@ 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';
27
+ import type { GrantService, DonationService } from '../donation/index.js';
28
+ import { createGrantAdminHandlers } from '../donation/index.js';
25
29
 
26
30
  import { EventBus } from './events/bus.js';
27
31
  import { registerSseRoute } from './events/sse-route.js';
@@ -31,10 +35,13 @@ import { registerStaticMount } from './static.js';
31
35
  import { buildFastify, startListening, stopListening } from './server.js';
32
36
  import { registerNatRoutes } from './routes/nat.js';
33
37
  import { registerTrustCircleRoutes } from './routes/trust-circle.js';
38
+ import { registerStrandRoutes } from './routes/strands.js';
34
39
  import { registerUpdateRoutes } from './routes/update.js';
35
40
  import { registerStatusRoute } from './routes/status.js';
36
41
  import { registerNodesRoutes } from './routes/nodes.js';
37
42
  import { registerSettingsRoutes } from './routes/settings.js';
43
+ import { registerGrantsAdminRoutes } from './routes/grants-admin.js';
44
+ import { registerGrantsRoutes } from './routes/grants.js';
38
45
  import { HostSettingsStore } from './settings-store.js';
39
46
 
40
47
  export interface LocalUiServerOptions {
@@ -44,10 +51,39 @@ export interface LocalUiServerOptions {
44
51
  dataDir: string;
45
52
  /** Wired dependencies — all owned by the caller. */
46
53
  orchestrator: HostProcessOrchestrator;
47
- trustCircle: TrustCircleService;
48
- nat: NatService;
54
+ /**
55
+ * Trust-circle service — present only when the host runs its own personal
56
+ * cadre (`ownCadre.enabled`). Absent in donor-only mode, where `/auth/*`
57
+ * stays unmounted and 404s.
58
+ */
59
+ trustCircle?: TrustCircleService;
60
+ /**
61
+ * NAT service — present only when the host runs its own personal cadre.
62
+ * Absent in donor-only mode (loopback-only in v1), where `/nat/*` stays
63
+ * unmounted and 404s.
64
+ */
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;
49
72
  /** Optional — 6.4.2 lands this; nullable while still iterating. */
50
73
  update?: UpdateService;
74
+ /**
75
+ * Donation grant layer. When present, mounts the loopback admin surface at
76
+ * `/grants-admin` (issue/list/revoke). Optional so existing callers/tests
77
+ * that don't exercise donations need not wire it.
78
+ */
79
+ grants?: GrantService;
80
+ /**
81
+ * Donation lifecycle service. When present (alongside `grants`), mounts the
82
+ * bearer-gated grantee-facing provisioning surface at `/grants`
83
+ * (provision / peer / seed / terminate). Optional so callers/tests that don't
84
+ * exercise the donor path need not wire it.
85
+ */
86
+ donations?: DonationService;
51
87
  /** Settings I/O facade. Defaults to a new one rooted at `dataDir`. */
52
88
  settingsStore?: HostSettingsStore;
53
89
  /** Bus instance — pass the same one to subsystems that publish events. */
@@ -99,18 +135,37 @@ export function createLocalUiServer(opts: LocalUiServerOptions): LocalUiServer {
99
135
 
100
136
  registerStatusRoute(app, {
101
137
  orchestrator: opts.orchestrator,
102
- trustCircle: opts.trustCircle,
103
- nat: opts.nat,
138
+ ...(opts.trustCircle ? { trustCircle: opts.trustCircle } : {}),
139
+ ...(opts.nat ? { nat: opts.nat } : {}),
104
140
  ...(opts.update ? { update: opts.update } : {}),
105
141
  });
106
142
  registerNodesRoutes(app, { orchestrator: opts.orchestrator });
107
- registerSettingsRoutes(app, { settingsStore, nat: opts.nat, ...(opts.update ? { update: opts.update } : {}) });
143
+ registerSettingsRoutes(app, { settingsStore, ...(opts.nat ? { nat: opts.nat } : {}), ...(opts.update ? { update: opts.update } : {}) });
108
144
 
109
- registerTrustCircleRoutes(app, { handlers: createTrustCircleHandlers(opts.trustCircle), events });
110
- registerNatRoutes(app, { handlers: createNatHandlers(opts.nat), events });
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).
149
+ if (opts.trustCircle) {
150
+ registerTrustCircleRoutes(app, { handlers: createTrustCircleHandlers(opts.trustCircle), events });
151
+ }
152
+ if (opts.nat) {
153
+ registerNatRoutes(app, { handlers: createNatHandlers(opts.nat), events });
154
+ }
155
+ if (opts.strands) {
156
+ registerStrandRoutes(app, { handlers: createStrandHandlers(opts.strands), events });
157
+ }
111
158
  if (opts.update) {
112
159
  registerUpdateRoutes(app, { handlers: createUpdateHandlers(opts.update), events });
113
160
  }
161
+ if (opts.grants) {
162
+ registerGrantsAdminRoutes(app, { handlers: createGrantAdminHandlers(opts.grants) });
163
+ // Grantee-facing provisioning surface needs both the donation service and a
164
+ // grant validator (the GrantService doubles as the validator).
165
+ if (opts.donations) {
166
+ registerGrantsRoutes(app, { donations: opts.donations, grants: opts.grants });
167
+ }
168
+ }
114
169
 
115
170
  // Static mount registers as the not-found handler — declared last so the
116
171
  // API paths above win.
@@ -147,16 +202,19 @@ export function createLocalUiServer(opts: LocalUiServerOptions): LocalUiServer {
147
202
  );
148
203
 
149
204
  // One-shot connectivity publish — the SPA will get a sane initial
150
- // signal even if no settings have changed yet this session.
151
- try {
152
- const snap = opts.nat.getStatus();
153
- events.publish({
154
- type: 'connectivity-changed',
155
- portMode: snap.portMode,
156
- directReachability: snap.directReachability,
157
- });
158
- } catch {
159
- // NatService.start may not have completed; harmless to skip.
205
+ // signal even if no settings have changed yet this session. Skipped in
206
+ // donor-only mode, where there is no NatService.
207
+ if (opts.nat) {
208
+ try {
209
+ const snap = opts.nat.getStatus();
210
+ events.publish({
211
+ type: 'connectivity-changed',
212
+ portMode: snap.portMode,
213
+ directReachability: snap.directReachability,
214
+ });
215
+ } catch {
216
+ // NatService.start may not have completed; harmless to skip.
217
+ }
160
218
  }
161
219
 
162
220
  // Update observer: poll UpdateService.getState() every 60 s and emit
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Donation grant **admin** HTTP routes — thin adapters around
3
+ * `GrantAdminHandlers` (`createGrantAdminHandlers(service)`).
4
+ *
5
+ * Mount path: `/grants-admin` — the admin surface the `cadre-host grant` CLI
6
+ * targets. It is **loopback, no bearer**: same-machine admin, matching
7
+ * cadre-host's local-UI "no login" posture (see docs/cadre-host.md §
8
+ * Security posture). This is distinct from the grantee-facing `/grants`
9
+ * provisioning surface that carries the bearer gate — that arrives in the
10
+ * `2-donation-service` ticket.
11
+ */
12
+
13
+ import type { FastifyInstance } from 'fastify';
14
+
15
+ import type { GrantAdminHandlers } from '../../donation/types.js';
16
+
17
+ export interface GrantsAdminRoutesOptions {
18
+ handlers: GrantAdminHandlers;
19
+ }
20
+
21
+ export function registerGrantsAdminRoutes(app: FastifyInstance, opts: GrantsAdminRoutesOptions): void {
22
+ const { handlers } = opts;
23
+
24
+ app.get('/grants-admin', async () => {
25
+ return handlers.listGrants();
26
+ });
27
+
28
+ app.post('/grants-admin', async (request) => {
29
+ const body = (request.body ?? {}) as { label?: unknown; maxNodes?: unknown; ttlMs?: unknown };
30
+ const label = typeof body.label === 'string' ? body.label : '';
31
+ const args: { label: string; maxNodes?: number; ttlMs?: number } = { label };
32
+ if (typeof body.maxNodes === 'number') args.maxNodes = body.maxNodes;
33
+ if (typeof body.ttlMs === 'number') args.ttlMs = body.ttlMs;
34
+ return handlers.postGrant(args);
35
+ });
36
+
37
+ app.delete<{ Params: { token: string } }>('/grants-admin/:token', async (request) => {
38
+ await handlers.deleteGrant(request.params.token);
39
+ return { ok: true };
40
+ });
41
+ }