@serfab/cadre-host 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) 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/server/static.js +38 -38
  57. package/dist/strands/index.d.ts +7 -0
  58. package/dist/strands/index.js +6 -0
  59. package/dist/strands/index.js.map +1 -0
  60. package/dist/strands/strand-service.d.ts +50 -0
  61. package/dist/strands/strand-service.js +103 -0
  62. package/dist/strands/strand-service.js.map +1 -0
  63. package/dist/strands/types.d.ts +93 -0
  64. package/dist/strands/types.js +23 -0
  65. package/dist/strands/types.js.map +1 -0
  66. package/dist/ui/assets/index-BVoqLjyX.css +1 -0
  67. package/dist/ui/assets/index-C-iks41M.js +17 -0
  68. package/dist/ui/assets/index-C-iks41M.js.map +1 -0
  69. package/dist/ui/index.html +14 -14
  70. package/dist/update/release-key.d.ts +1 -1
  71. package/dist/update/release-key.js +1 -1
  72. package/package.json +5 -5
  73. package/service/cadre-host.service.tmpl +16 -16
  74. package/service/com.serfab.cadre-host.plist.tmpl +35 -35
  75. package/service/install-service.ps1 +98 -98
  76. package/service/uninstall-service.ps1 +19 -19
  77. package/src/__tests__/build-targets.test.ts +26 -0
  78. package/src/__tests__/cli-invite.smoke.test.ts +192 -192
  79. package/src/__tests__/cli-nat.smoke.test.ts +212 -212
  80. package/src/__tests__/cli.smoke.test.ts +46 -46
  81. package/src/__tests__/global-setup.ts +51 -0
  82. package/src/__tests__/host-owner.smoke.test.ts +123 -123
  83. package/src/__tests__/orchestrator-env-scrub.test.ts +140 -0
  84. package/src/__tests__/orchestrator-node-identity.test.ts +204 -0
  85. package/src/__tests__/orchestrator-owner.test.ts +198 -198
  86. package/src/__tests__/orchestrator-pin-keys.test.ts +153 -153
  87. package/src/__tests__/orchestrator-push.test.ts +145 -145
  88. package/src/__tests__/orchestrator.test.ts +951 -570
  89. package/src/auth/__tests__/duration.test.ts +28 -28
  90. package/src/auth/__tests__/self-label.test.ts +85 -0
  91. package/src/auth/__tests__/trust-circle-integration.test.ts +152 -147
  92. package/src/auth/__tests__/trust-circle-store.test.ts +115 -115
  93. package/src/auth/__tests__/trust-circle.test.ts +445 -401
  94. package/src/auth/duration.ts +33 -33
  95. package/src/auth/index.ts +21 -19
  96. package/src/auth/self-label.ts +74 -0
  97. package/src/auth/trust-circle-store.ts +155 -155
  98. package/src/auth/trust-circle.ts +45 -13
  99. package/src/auth/types.ts +99 -99
  100. package/src/bin/host.ts +1299 -1258
  101. package/src/donation/__tests__/donation-service.test.ts +1068 -290
  102. package/src/donation/__tests__/donation-supervisor.test.ts +510 -0
  103. package/src/donation/__tests__/fake-orchestrator.test.ts +168 -0
  104. package/src/donation/__tests__/fake-orchestrator.ts +225 -0
  105. package/src/donation/__tests__/grant-service.test.ts +213 -213
  106. package/src/donation/__tests__/grant-store.test.ts +113 -113
  107. package/src/donation/donation-service.ts +962 -392
  108. package/src/donation/donation-store.ts +171 -164
  109. package/src/donation/donation-supervisor.ts +388 -0
  110. package/src/donation/grant-service.ts +200 -200
  111. package/src/donation/grant-store.ts +152 -152
  112. package/src/donation/index.ts +64 -49
  113. package/src/donation/types.ts +243 -220
  114. package/src/index.ts +14 -0
  115. package/src/installer/__tests__/identity.test.ts +34 -34
  116. package/src/installer/__tests__/paths.test.ts +47 -47
  117. package/src/installer/__tests__/service-host.test.ts +62 -62
  118. package/src/installer/__tests__/wizard.test.ts +64 -64
  119. package/src/installer/identity.ts +49 -49
  120. package/src/installer/paths.ts +109 -109
  121. package/src/installer/platform.ts +27 -27
  122. package/src/installer/service-host/index.ts +21 -21
  123. package/src/installer/service-host/template.ts +21 -21
  124. package/src/installer/wizard.ts +103 -103
  125. package/src/nat/__tests__/address-resolver.test.ts +82 -82
  126. package/src/nat/__tests__/ddns-duckdns.test.ts +83 -83
  127. package/src/nat/__tests__/ddns-updater.test.ts +253 -253
  128. package/src/nat/__tests__/external-ip.test.ts +124 -124
  129. package/src/nat/__tests__/nat-service.test.ts +497 -497
  130. package/src/nat/__tests__/nat-store.test.ts +93 -93
  131. package/src/nat/__tests__/port-mapper.test.ts +191 -191
  132. package/src/nat/__tests__/reachability.test.ts +24 -24
  133. package/src/nat/__tests__/secrets.test.ts +178 -178
  134. package/src/nat/address-resolver.ts +51 -51
  135. package/src/nat/ddns/duckdns.ts +76 -76
  136. package/src/nat/ddns/index.ts +55 -55
  137. package/src/nat/ddns/updater.ts +243 -243
  138. package/src/nat/external-ip.ts +154 -154
  139. package/src/nat/index.ts +71 -71
  140. package/src/nat/nat-service.ts +501 -501
  141. package/src/nat/nat-store.ts +154 -154
  142. package/src/nat/port-mapper.ts +329 -329
  143. package/src/nat/reachability.ts +34 -34
  144. package/src/nat/secrets/file-store.ts +130 -130
  145. package/src/nat/secrets/index.ts +112 -112
  146. package/src/nat/secrets/keytar-store.ts +67 -67
  147. package/src/nat/types.ts +159 -159
  148. package/src/orchestrator/__tests__/node-identity.test.ts +62 -0
  149. package/src/orchestrator/host-process-orchestrator.ts +1133 -894
  150. package/src/orchestrator/index.ts +10 -10
  151. package/src/orchestrator/log-rotator.ts +53 -53
  152. package/src/orchestrator/node-identity.ts +40 -0
  153. package/src/orchestrator/pid-liveness.ts +24 -24
  154. package/src/orchestrator/port-allocator.ts +91 -43
  155. package/src/orchestrator/state-store.ts +90 -90
  156. package/src/orchestrator/types.ts +141 -141
  157. package/src/owner/__tests__/owner-node-client.test.ts +194 -194
  158. package/src/owner/index.ts +12 -12
  159. package/src/owner/owner-node-client.ts +259 -233
  160. package/src/push/__tests__/push.test.ts +90 -90
  161. package/src/push/index.ts +146 -146
  162. package/src/server/__tests__/bus.test.ts +38 -38
  163. package/src/server/__tests__/error-handler.test.ts +118 -118
  164. package/src/server/__tests__/grants-admin-route.test.ts +73 -73
  165. package/src/server/__tests__/grants-route.test.ts +263 -189
  166. package/src/server/__tests__/nodes-route.test.ts +259 -259
  167. package/src/server/__tests__/origin-guard.test.ts +72 -72
  168. package/src/server/__tests__/publishers.test.ts +216 -216
  169. package/src/server/__tests__/server.smoke.test.ts +253 -253
  170. package/src/server/__tests__/settings-route.test.ts +194 -194
  171. package/src/server/__tests__/sse-route.test.ts +119 -119
  172. package/src/server/__tests__/static.test.ts +145 -145
  173. package/src/server/__tests__/status-route.test.ts +134 -134
  174. package/src/server/__tests__/strands-route.test.ts +233 -0
  175. package/src/server/error-handler.ts +178 -155
  176. package/src/server/events/bus.ts +42 -42
  177. package/src/server/events/sse-route.ts +81 -81
  178. package/src/server/events/types.ts +23 -22
  179. package/src/server/index.ts +267 -254
  180. package/src/server/origin-guard.ts +84 -84
  181. package/src/server/routes/grants-admin.ts +41 -41
  182. package/src/server/routes/grants.ts +210 -186
  183. package/src/server/routes/nat.ts +63 -63
  184. package/src/server/routes/nodes.ts +170 -170
  185. package/src/server/routes/settings.ts +123 -123
  186. package/src/server/routes/status.ts +118 -118
  187. package/src/server/routes/strands.ts +54 -0
  188. package/src/server/routes/trust-circle.ts +49 -49
  189. package/src/server/routes/update.ts +43 -43
  190. package/src/server/server.ts +122 -122
  191. package/src/server/settings-store.ts +37 -37
  192. package/src/server/static.ts +207 -207
  193. package/src/strands/__tests__/strand-service.test.ts +165 -0
  194. package/src/strands/index.ts +14 -0
  195. package/src/strands/strand-service.ts +135 -0
  196. package/src/strands/types.ts +102 -0
  197. package/src/update/__tests__/apply.test.ts +158 -158
  198. package/src/update/__tests__/manifest.test.ts +128 -128
  199. package/src/update/__tests__/release-key.test.ts +42 -42
  200. package/src/update/__tests__/sign.test.ts +78 -78
  201. package/src/update/__tests__/store.test.ts +81 -81
  202. package/src/update/__tests__/update-service.integration.test.ts +256 -256
  203. package/src/update/__tests__/version.test.ts +31 -31
  204. package/src/update/apply.ts +190 -190
  205. package/src/update/index.ts +341 -341
  206. package/src/update/release-key.ts +71 -71
  207. package/src/update/sign.ts +74 -74
  208. package/src/update/store.ts +101 -101
  209. package/src/update/types.ts +109 -109
  210. package/src/update/version.ts +80 -80
  211. package/dist/ui/assets/index-BlMXQPSw.js +0 -14
  212. package/dist/ui/assets/index-BlMXQPSw.js.map +0 -1
  213. package/dist/ui/assets/index-BwU5mJGF.css +0 -1
@@ -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
+ });
@@ -1,155 +1,178 @@
1
- /**
2
- * Fastify error handler — translates known typed errors to stable HTTP status
3
- * codes and uniform `{ ok: false, error: { code, message } }` payloads.
4
- *
5
- * Codes (cross-referenced with `auth/types.ts`, `nat/types.ts`, `update/types.ts`):
6
- * - TrustCircleError
7
- * invalid_label, invalid_token → 400
8
- * not_found, already_redeemed → 404
9
- * expired → 410
10
- * node_unavailable → 503
11
- * storage_error → 500
12
- * - NatError
13
- * invalid_config, ddns_provider_unknown,
14
- * ddns_credentials_missing → 400
15
- * secrets_unavailable, storage_error,
16
- * mapping_failed, router_unreachable,
17
- * ip_detection_failed, ddns_update_failed → 500
18
- * node_unavailable → 503
19
- * - UpdateErrorException
20
- * manifest_invalid, no_update_available,
21
- * min_previous_version → 400
22
- * apply_in_progress → 409
23
- * signature_invalid, unsupported_state_version,
24
- * apply_failed, rollback_failed, storage_error → 500
25
- * - GrantError
26
- * invalid_label, invalid_max_nodes,
27
- * invalid_ttl → 400
28
- * not_found → 404
29
- * storage_error → 500
30
- * - DonationError
31
- * invalid_request → 400
32
- * unauthorized → 401
33
- * forbidden → 403
34
- * not_found → 404
35
- * invalid_state → 409
36
- * quota_exceeded → 429
37
- * orchestrator_error, storage_error → 500
38
- * seed_failed → 502
39
- * peer_unavailable → 503
40
- * - everything else → 500 (code "internal")
41
- */
42
-
43
- import type { FastifyError, FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
44
- import debug from 'debug';
45
-
46
- import { TrustCircleError, type TrustCircleErrorCode } from '../auth/types.js';
47
- import { NatError, type NatErrorCode } from '../nat/types.js';
48
- import { UpdateErrorException, type UpdateErrorCode } from '../update/types.js';
49
- import { GrantError, type GrantErrorCode, DonationError, type DonationErrorCode } from '../donation/types.js';
50
-
51
- const log = debug('cadre:host:error-handler');
52
-
53
- const TRUST_STATUS: Record<TrustCircleErrorCode, number> = {
54
- invalid_label: 400,
55
- invalid_token: 400,
56
- not_found: 404,
57
- already_redeemed: 404,
58
- expired: 410,
59
- storage_error: 500,
60
- node_unavailable: 503,
61
- };
62
-
63
- const NAT_STATUS: Record<NatErrorCode, number> = {
64
- invalid_config: 400,
65
- ddns_provider_unknown: 400,
66
- ddns_credentials_missing: 400,
67
- secrets_unavailable: 500,
68
- storage_error: 500,
69
- mapping_failed: 500,
70
- router_unreachable: 500,
71
- ip_detection_failed: 500,
72
- ddns_update_failed: 500,
73
- node_unavailable: 503,
74
- };
75
-
76
- const GRANT_STATUS: Record<GrantErrorCode, number> = {
77
- invalid_label: 400,
78
- invalid_max_nodes: 400,
79
- invalid_ttl: 400,
80
- not_found: 404,
81
- storage_error: 500,
82
- };
83
-
84
- const DONATION_STATUS: Record<DonationErrorCode, number> = {
85
- unauthorized: 401,
86
- forbidden: 403,
87
- quota_exceeded: 429,
88
- invalid_request: 400,
89
- not_found: 404,
90
- invalid_state: 409,
91
- seed_failed: 502,
92
- peer_unavailable: 503,
93
- orchestrator_error: 500,
94
- storage_error: 500,
95
- };
96
-
97
- const UPDATE_STATUS: Record<UpdateErrorCode, number> = {
98
- manifest_invalid: 400,
99
- no_update_available: 400,
100
- min_previous_version: 400,
101
- apply_in_progress: 409,
102
- signature_invalid: 500,
103
- unsupported_state_version: 500,
104
- apply_failed: 500,
105
- rollback_failed: 500,
106
- storage_error: 500,
107
- };
108
-
109
- export function registerErrorHandler(app: FastifyInstance): void {
110
- app.setErrorHandler((err: FastifyError, request: FastifyRequest, reply: FastifyReply) => {
111
- const { status, code, message } = classify(err);
112
- if (status >= 500) {
113
- log('5xx %s %s: [%s] %s', request.method, request.url, code, message);
114
- } else {
115
- log('%d %s %s: [%s] %s', status, request.method, request.url, code, message);
116
- }
117
- return reply.code(status).send({
118
- ok: false,
119
- error: { code, message },
120
- });
121
- });
122
- }
123
-
124
- interface Classified {
125
- status: number;
126
- code: string;
127
- message: string;
128
- }
129
-
130
- function classify(err: FastifyError): Classified {
131
- if (err instanceof TrustCircleError) {
132
- return { status: TRUST_STATUS[err.code] ?? 500, code: err.code, message: err.message };
133
- }
134
- if (err instanceof NatError) {
135
- return { status: NAT_STATUS[err.code] ?? 500, code: err.code, message: err.message };
136
- }
137
- if (err instanceof UpdateErrorException) {
138
- return { status: UPDATE_STATUS[err.code] ?? 500, code: err.code, message: err.message };
139
- }
140
- if (err instanceof GrantError) {
141
- return { status: GRANT_STATUS[err.code] ?? 500, code: err.code, message: err.message };
142
- }
143
- if (err instanceof DonationError) {
144
- return { status: DONATION_STATUS[err.code] ?? 500, code: err.code, message: err.message };
145
- }
146
- // Fastify body-parsing / validation errors have a statusCode we should honour.
147
- if (typeof err.statusCode === 'number' && err.statusCode >= 400 && err.statusCode < 600) {
148
- return {
149
- status: err.statusCode,
150
- code: err.code ?? (err.statusCode === 404 ? 'not_found' : 'bad_request'),
151
- message: err.message ?? 'request failed',
152
- };
153
- }
154
- return { status: 500, code: 'internal', message: err.message || 'internal error' };
155
- }
1
+ /**
2
+ * Fastify error handler — translates known typed errors to stable HTTP status
3
+ * codes and uniform `{ ok: false, error: { code, message } }` payloads.
4
+ *
5
+ * Codes (cross-referenced with `auth/types.ts`, `nat/types.ts`, `strands/types.ts`,
6
+ * `update/types.ts`):
7
+ * - TrustCircleError
8
+ * invalid_label, invalid_token → 400
9
+ * not_found, already_redeemed → 404
10
+ * expired → 410
11
+ * node_unavailable → 503
12
+ * storage_error → 500
13
+ * - NatError
14
+ * invalid_config, ddns_provider_unknown,
15
+ * ddns_credentials_missing → 400
16
+ * secrets_unavailable, storage_error,
17
+ * mapping_failed, router_unreachable,
18
+ * ip_detection_failed, ddns_update_failed → 500
19
+ * node_unavailable → 503
20
+ * - StrandError
21
+ * invalid_id → 400
22
+ * confirmation_required → 428
23
+ * node_unavailable → 503
24
+ * internal → 500
25
+ * - UpdateErrorException
26
+ * manifest_invalid, no_update_available,
27
+ * min_previous_version → 400
28
+ * apply_in_progress → 409
29
+ * signature_invalid, unsupported_state_version,
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
46
+ * - everything else → 500 (code "internal")
47
+ */
48
+
49
+ import type { FastifyError, FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
50
+ import debug from 'debug';
51
+
52
+ import { TrustCircleError, type TrustCircleErrorCode } from '../auth/types.js';
53
+ import { NatError, type NatErrorCode } from '../nat/types.js';
54
+ import { StrandError, type StrandErrorCode } from '../strands/types.js';
55
+ import { UpdateErrorException, type UpdateErrorCode } from '../update/types.js';
56
+ import { GrantError, type GrantErrorCode, DonationError, type DonationErrorCode } from '../donation/types.js';
57
+
58
+ const log = debug('cadre:host:error-handler');
59
+
60
+ const TRUST_STATUS: Record<TrustCircleErrorCode, number> = {
61
+ invalid_label: 400,
62
+ invalid_token: 400,
63
+ not_found: 404,
64
+ already_redeemed: 404,
65
+ expired: 410,
66
+ storage_error: 500,
67
+ node_unavailable: 503,
68
+ };
69
+
70
+ const NAT_STATUS: Record<NatErrorCode, number> = {
71
+ invalid_config: 400,
72
+ ddns_provider_unknown: 400,
73
+ ddns_credentials_missing: 400,
74
+ secrets_unavailable: 500,
75
+ storage_error: 500,
76
+ mapping_failed: 500,
77
+ router_unreachable: 500,
78
+ ip_detection_failed: 500,
79
+ ddns_update_failed: 500,
80
+ node_unavailable: 503,
81
+ };
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
+
117
+ const UPDATE_STATUS: Record<UpdateErrorCode, number> = {
118
+ manifest_invalid: 400,
119
+ no_update_available: 400,
120
+ min_previous_version: 400,
121
+ apply_in_progress: 409,
122
+ signature_invalid: 500,
123
+ unsupported_state_version: 500,
124
+ apply_failed: 500,
125
+ rollback_failed: 500,
126
+ storage_error: 500,
127
+ };
128
+
129
+ export function registerErrorHandler(app: FastifyInstance): void {
130
+ app.setErrorHandler((err: FastifyError, request: FastifyRequest, reply: FastifyReply) => {
131
+ const { status, code, message } = classify(err);
132
+ if (status >= 500) {
133
+ log('5xx %s %s: [%s] %s', request.method, request.url, code, message);
134
+ } else {
135
+ log('%d %s %s: [%s] %s', status, request.method, request.url, code, message);
136
+ }
137
+ return reply.code(status).send({
138
+ ok: false,
139
+ error: { code, message },
140
+ });
141
+ });
142
+ }
143
+
144
+ interface Classified {
145
+ status: number;
146
+ code: string;
147
+ message: string;
148
+ }
149
+
150
+ function classify(err: FastifyError): Classified {
151
+ if (err instanceof TrustCircleError) {
152
+ return { status: TRUST_STATUS[err.code] ?? 500, code: err.code, message: err.message };
153
+ }
154
+ if (err instanceof NatError) {
155
+ return { status: NAT_STATUS[err.code] ?? 500, code: err.code, message: err.message };
156
+ }
157
+ if (err instanceof StrandError) {
158
+ return { status: STRAND_STATUS[err.code] ?? 500, code: err.code, message: err.message };
159
+ }
160
+ if (err instanceof UpdateErrorException) {
161
+ return { status: UPDATE_STATUS[err.code] ?? 500, code: err.code, message: err.message };
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
+ }
169
+ // Fastify body-parsing / validation errors have a statusCode we should honour.
170
+ if (typeof err.statusCode === 'number' && err.statusCode >= 400 && err.statusCode < 600) {
171
+ return {
172
+ status: err.statusCode,
173
+ code: err.code ?? (err.statusCode === 404 ? 'not_found' : 'bad_request'),
174
+ message: err.message ?? 'request failed',
175
+ };
176
+ }
177
+ return { status: 500, code: 'internal', message: err.message || 'internal error' };
178
+ }
@@ -1,42 +1,42 @@
1
- /**
2
- * In-memory event bus for the local UI.
3
- *
4
- * Tiny synchronous fan-out: publishers (route adapters, orchestrator hook,
5
- * update observer) call `publish`; SSE connections subscribe and receive every
6
- * event until they unsubscribe. Listener errors are caught so one broken
7
- * client cannot break others.
8
- */
9
-
10
- import debug from 'debug';
11
-
12
- import type { LocalUiEvent } from './types.js';
13
-
14
- const log = debug('cadre:host:event-bus');
15
-
16
- export type LocalUiEventListener = (event: LocalUiEvent) => void;
17
-
18
- export class EventBus {
19
- private readonly listeners = new Set<LocalUiEventListener>();
20
-
21
- publish(event: LocalUiEvent): void {
22
- for (const listener of this.listeners) {
23
- try {
24
- listener(event);
25
- } catch (err) {
26
- log('listener threw for event %s: %s', event.type, (err as Error).message);
27
- }
28
- }
29
- }
30
-
31
- subscribe(listener: LocalUiEventListener): () => void {
32
- this.listeners.add(listener);
33
- return () => {
34
- this.listeners.delete(listener);
35
- };
36
- }
37
-
38
- /** Test helper. */
39
- listenerCount(): number {
40
- return this.listeners.size;
41
- }
42
- }
1
+ /**
2
+ * In-memory event bus for the local UI.
3
+ *
4
+ * Tiny synchronous fan-out: publishers (route adapters, orchestrator hook,
5
+ * update observer) call `publish`; SSE connections subscribe and receive every
6
+ * event until they unsubscribe. Listener errors are caught so one broken
7
+ * client cannot break others.
8
+ */
9
+
10
+ import debug from 'debug';
11
+
12
+ import type { LocalUiEvent } from './types.js';
13
+
14
+ const log = debug('cadre:host:event-bus');
15
+
16
+ export type LocalUiEventListener = (event: LocalUiEvent) => void;
17
+
18
+ export class EventBus {
19
+ private readonly listeners = new Set<LocalUiEventListener>();
20
+
21
+ publish(event: LocalUiEvent): void {
22
+ for (const listener of this.listeners) {
23
+ try {
24
+ listener(event);
25
+ } catch (err) {
26
+ log('listener threw for event %s: %s', event.type, (err as Error).message);
27
+ }
28
+ }
29
+ }
30
+
31
+ subscribe(listener: LocalUiEventListener): () => void {
32
+ this.listeners.add(listener);
33
+ return () => {
34
+ this.listeners.delete(listener);
35
+ };
36
+ }
37
+
38
+ /** Test helper. */
39
+ listenerCount(): number {
40
+ return this.listeners.size;
41
+ }
42
+ }