@serfab/cadre-host 0.7.1 → 0.9.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 (193) hide show
  1. package/README.md +296 -101
  2. package/dist/auth/trust-circle.d.ts +45 -7
  3. package/dist/auth/trust-circle.js +116 -59
  4. package/dist/auth/trust-circle.js.map +1 -1
  5. package/dist/auth/types.d.ts +4 -1
  6. package/dist/auth/types.js.map +1 -1
  7. package/dist/bin/host.js +426 -112
  8. package/dist/bin/host.js.map +1 -1
  9. package/dist/donation/donation-service.d.ts +136 -0
  10. package/dist/donation/donation-service.js +306 -0
  11. package/dist/donation/donation-service.js.map +1 -0
  12. package/dist/donation/donation-store.d.ts +42 -0
  13. package/dist/donation/donation-store.js +141 -0
  14. package/dist/donation/donation-store.js.map +1 -0
  15. package/dist/donation/grant-service.d.ts +71 -0
  16. package/dist/donation/grant-service.js +163 -0
  17. package/dist/donation/grant-service.js.map +1 -0
  18. package/dist/donation/grant-store.d.ts +38 -0
  19. package/dist/donation/grant-store.js +135 -0
  20. package/dist/donation/grant-store.js.map +1 -0
  21. package/dist/donation/index.d.ts +18 -0
  22. package/dist/donation/index.js +16 -0
  23. package/dist/donation/index.js.map +1 -0
  24. package/dist/donation/types.d.ts +175 -0
  25. package/dist/donation/types.js +40 -0
  26. package/dist/donation/types.js.map +1 -0
  27. package/dist/index.d.ts +12 -6
  28. package/dist/index.js +8 -3
  29. package/dist/index.js.map +1 -1
  30. package/dist/installer/browser.js +16 -17
  31. package/dist/installer/browser.js.map +1 -1
  32. package/dist/installer/config.d.ts +51 -0
  33. package/dist/installer/config.js +14 -1
  34. package/dist/installer/config.js.map +1 -1
  35. package/dist/installer/index.d.ts +7 -2
  36. package/dist/installer/index.js +17 -10
  37. package/dist/installer/index.js.map +1 -1
  38. package/dist/installer/service-host/launchd.js +1 -1
  39. package/dist/installer/service-host/launchd.js.map +1 -1
  40. package/dist/installer/service-host/systemd.js +1 -1
  41. package/dist/installer/service-host/systemd.js.map +1 -1
  42. package/dist/installer/wizard.d.ts +4 -0
  43. package/dist/installer/wizard.js +5 -1
  44. package/dist/installer/wizard.js.map +1 -1
  45. package/dist/nat/index.d.ts +1 -1
  46. package/dist/nat/nat-service.d.ts +45 -3
  47. package/dist/nat/nat-service.js +116 -3
  48. package/dist/nat/nat-service.js.map +1 -1
  49. package/dist/nat/secrets/index.d.ts +7 -0
  50. package/dist/nat/secrets/index.js +28 -2
  51. package/dist/nat/secrets/index.js.map +1 -1
  52. package/dist/nat/types.d.ts +3 -1
  53. package/dist/nat/types.js.map +1 -1
  54. package/dist/orchestrator/host-process-orchestrator.d.ts +68 -1
  55. package/dist/orchestrator/host-process-orchestrator.js +272 -39
  56. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  57. package/dist/orchestrator/index.d.ts +3 -2
  58. package/dist/orchestrator/index.js +1 -1
  59. package/dist/orchestrator/index.js.map +1 -1
  60. package/dist/orchestrator/state-store.d.ts +6 -5
  61. package/dist/orchestrator/state-store.js +10 -2
  62. package/dist/orchestrator/state-store.js.map +1 -1
  63. package/dist/orchestrator/types.d.ts +45 -10
  64. package/dist/orchestrator/types.js.map +1 -1
  65. package/dist/owner/index.d.ts +11 -0
  66. package/dist/owner/index.js +11 -0
  67. package/dist/owner/index.js.map +1 -0
  68. package/dist/owner/owner-node-client.d.ts +95 -0
  69. package/dist/owner/owner-node-client.js +167 -0
  70. package/dist/owner/owner-node-client.js.map +1 -0
  71. package/dist/push/index.d.ts +67 -0
  72. package/dist/push/index.js +101 -0
  73. package/dist/push/index.js.map +1 -0
  74. package/dist/server/error-handler.d.ts +17 -0
  75. package/dist/server/error-handler.js +45 -0
  76. package/dist/server/error-handler.js.map +1 -1
  77. package/dist/server/index.d.ts +26 -2
  78. package/dist/server/index.js +37 -16
  79. package/dist/server/index.js.map +1 -1
  80. package/dist/server/routes/grants-admin.d.ts +17 -0
  81. package/dist/server/routes/grants-admin.js +32 -0
  82. package/dist/server/routes/grants-admin.js.map +1 -0
  83. package/dist/server/routes/grants.d.ts +28 -0
  84. package/dist/server/routes/grants.js +163 -0
  85. package/dist/server/routes/grants.js.map +1 -0
  86. package/dist/server/routes/nodes.d.ts +5 -3
  87. package/dist/server/routes/nodes.js +39 -24
  88. package/dist/server/routes/nodes.js.map +1 -1
  89. package/dist/server/routes/settings.d.ts +6 -1
  90. package/dist/server/routes/settings.js +5 -2
  91. package/dist/server/routes/settings.js.map +1 -1
  92. package/dist/server/routes/status.d.ts +8 -4
  93. package/dist/server/routes/status.js +9 -4
  94. package/dist/server/routes/status.js.map +1 -1
  95. package/dist/server/static.d.ts +12 -0
  96. package/dist/server/static.js +24 -7
  97. package/dist/server/static.js.map +1 -1
  98. package/dist/ui/assets/index-BlMXQPSw.js +14 -0
  99. package/dist/ui/assets/{index-C9uChk2P.js.map → index-BlMXQPSw.js.map} +1 -1
  100. package/dist/ui/index.html +1 -1
  101. package/dist/update/apply.d.ts +23 -1
  102. package/dist/update/apply.js +46 -4
  103. package/dist/update/apply.js.map +1 -1
  104. package/dist/update/index.d.ts +14 -3
  105. package/dist/update/index.js +35 -2
  106. package/dist/update/index.js.map +1 -1
  107. package/dist/update/manifest.d.ts +28 -9
  108. package/dist/update/manifest.js +56 -29
  109. package/dist/update/manifest.js.map +1 -1
  110. package/dist/update/release-key.d.ts +18 -7
  111. package/dist/update/release-key.js +29 -14
  112. package/dist/update/release-key.js.map +1 -1
  113. package/dist/update/sign.d.ts +46 -0
  114. package/dist/update/sign.js +53 -0
  115. package/dist/update/sign.js.map +1 -0
  116. package/package.json +9 -4
  117. package/service/README.md +95 -86
  118. package/src/__tests__/cli-invite.smoke.test.ts +6 -1
  119. package/src/__tests__/cli-nat.smoke.test.ts +6 -1
  120. package/src/__tests__/cli.smoke.test.ts +6 -1
  121. package/src/__tests__/host-owner.smoke.test.ts +123 -0
  122. package/src/__tests__/orchestrator-owner.test.ts +198 -0
  123. package/src/__tests__/orchestrator-pin-keys.test.ts +153 -0
  124. package/src/__tests__/orchestrator-push.test.ts +145 -0
  125. package/src/auth/__tests__/trust-circle-integration.test.ts +44 -25
  126. package/src/auth/__tests__/trust-circle.test.ts +91 -24
  127. package/src/auth/trust-circle.ts +363 -304
  128. package/src/auth/types.ts +4 -1
  129. package/src/bin/host.ts +1258 -905
  130. package/src/donation/__tests__/donation-service.test.ts +290 -0
  131. package/src/donation/__tests__/grant-service.test.ts +213 -0
  132. package/src/donation/__tests__/grant-store.test.ts +113 -0
  133. package/src/donation/donation-service.ts +392 -0
  134. package/src/donation/donation-store.ts +164 -0
  135. package/src/donation/grant-service.ts +200 -0
  136. package/src/donation/grant-store.ts +152 -0
  137. package/src/donation/index.ts +49 -0
  138. package/src/donation/types.ts +220 -0
  139. package/src/index.ts +66 -3
  140. package/src/installer/__tests__/config.test.ts +27 -1
  141. package/src/installer/__tests__/installer.smoke.test.ts +94 -0
  142. package/src/installer/__tests__/wizard.test.ts +5 -1
  143. package/src/installer/browser.ts +40 -42
  144. package/src/installer/config.ts +61 -1
  145. package/src/installer/index.ts +23 -12
  146. package/src/installer/service-host/launchd.ts +1 -1
  147. package/src/installer/service-host/systemd.ts +1 -1
  148. package/src/installer/wizard.ts +12 -1
  149. package/src/nat/__tests__/ddns-updater.test.ts +1 -1
  150. package/src/nat/__tests__/nat-service.test.ts +192 -0
  151. package/src/nat/__tests__/port-mapper.test.ts +1 -1
  152. package/src/nat/__tests__/secrets.test.ts +23 -1
  153. package/src/nat/index.ts +1 -1
  154. package/src/nat/nat-service.ts +131 -5
  155. package/src/nat/secrets/index.ts +32 -2
  156. package/src/nat/types.ts +3 -1
  157. package/src/orchestrator/host-process-orchestrator.ts +322 -54
  158. package/src/orchestrator/index.ts +9 -2
  159. package/src/orchestrator/state-store.ts +21 -3
  160. package/src/orchestrator/types.ts +48 -2
  161. package/src/owner/__tests__/owner-node-client.test.ts +194 -0
  162. package/src/owner/index.ts +12 -0
  163. package/src/owner/owner-node-client.ts +233 -0
  164. package/src/push/__tests__/push.test.ts +90 -0
  165. package/src/push/index.ts +146 -0
  166. package/src/server/__tests__/error-handler.test.ts +26 -4
  167. package/src/server/__tests__/grants-admin-route.test.ts +73 -0
  168. package/src/server/__tests__/grants-route.test.ts +189 -0
  169. package/src/server/__tests__/nodes-route.test.ts +90 -10
  170. package/src/server/__tests__/publishers.test.ts +1 -1
  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__/static.test.ts +145 -0
  174. package/src/server/__tests__/status-route.test.ts +23 -2
  175. package/src/server/error-handler.ts +47 -0
  176. package/src/server/index.ts +62 -17
  177. package/src/server/routes/grants-admin.ts +41 -0
  178. package/src/server/routes/grants.ts +186 -0
  179. package/src/server/routes/nodes.ts +49 -26
  180. package/src/server/routes/settings.ts +11 -3
  181. package/src/server/routes/status.ts +18 -8
  182. package/src/server/static.ts +24 -8
  183. package/src/update/__tests__/apply.test.ts +58 -1
  184. package/src/update/__tests__/manifest.test.ts +41 -6
  185. package/src/update/__tests__/release-key.test.ts +42 -0
  186. package/src/update/__tests__/sign.test.ts +78 -0
  187. package/src/update/__tests__/update-service.integration.test.ts +44 -9
  188. package/src/update/apply.ts +46 -4
  189. package/src/update/index.ts +41 -3
  190. package/src/update/manifest.ts +214 -172
  191. package/src/update/release-key.ts +31 -15
  192. package/src/update/sign.ts +74 -0
  193. package/dist/ui/assets/index-C9uChk2P.js +0 -14
@@ -9,6 +9,7 @@ import type { HostProcessOrchestrator } from '../../orchestrator/index.js';
9
9
  import type { TrustCircleService } from '../../auth/index.js';
10
10
  import type { NatService } from '../../nat/index.js';
11
11
  import type { NatStatusSnapshot } from '../../nat/types.js';
12
+ import { GrantService, GrantStore } from '../../donation/index.js';
12
13
 
13
14
  function fakeOrchestrator(): HostProcessOrchestrator {
14
15
  return {
@@ -176,3 +177,77 @@ describe('createLocalUiServer smoke', () => {
176
177
  expect(body.error.code).toBe('not_found');
177
178
  });
178
179
  });
180
+
181
+ // Donor-only mode: the common case (ownCadre.enabled=false). No owner node, so
182
+ // no trustCircle / NAT are wired. The donor surface (/grants-admin) is up, and
183
+ // the founder-only surfaces (/auth, /nat) 404 to keep the surface honest.
184
+ describe('createLocalUiServer smoke — donor-only (no owner node)', () => {
185
+ let dataDir: string;
186
+ let server: ReturnType<typeof createLocalUiServer>;
187
+ let baseUrl: string;
188
+
189
+ beforeEach(async () => {
190
+ dataDir = mkdtempSync(join(tmpdir(), 'cadre-host-donor-'));
191
+ writeConfig(dataDir);
192
+ server = createLocalUiServer({
193
+ uiPort: 8765,
194
+ dataDir,
195
+ orchestrator: fakeOrchestrator(),
196
+ grants: new GrantService({ store: new GrantStore(dataDir) }),
197
+ forcePort: 0,
198
+ });
199
+ const { url } = await server.start();
200
+ baseUrl = url;
201
+ });
202
+
203
+ afterEach(async () => {
204
+ await server.stop();
205
+ rmSync(dataDir, { recursive: true, force: true });
206
+ });
207
+
208
+ it('serves /api/status with no trustCircle / connectivity and no nodes', async () => {
209
+ const res = await fetch(`${baseUrl}/api/status`);
210
+ expect(res.status).toBe(200);
211
+ const body = await res.json() as {
212
+ service: { name: string };
213
+ nodes: unknown[];
214
+ trustCircle?: unknown;
215
+ connectivity?: unknown;
216
+ };
217
+ expect(body.service.name).toBe('cadre-host');
218
+ expect(body.nodes).toEqual([]);
219
+ expect(body.trustCircle).toBeUndefined();
220
+ expect(body.connectivity).toBeUndefined();
221
+ });
222
+
223
+ it('serves the donor surface: POST /grants-admin issues a grant', async () => {
224
+ const res = await fetch(`${baseUrl}/grants-admin`, {
225
+ method: 'POST',
226
+ headers: { 'content-type': 'application/json' },
227
+ body: JSON.stringify({ label: "Alice's cadre", maxNodes: 2 }),
228
+ });
229
+ expect(res.status).toBe(200);
230
+ const body = await res.json() as { grant: { token: string; maxNodes: number } };
231
+ expect(body.grant.token).toBeTruthy();
232
+ expect(body.grant.maxNodes).toBe(2);
233
+ });
234
+
235
+ it('404s the founder-only NAT surface (GET /nat/status)', async () => {
236
+ const res = await fetch(`${baseUrl}/nat/status`);
237
+ expect(res.status).toBe(404);
238
+ const body = await res.json() as { error: { code: string } };
239
+ expect(body.error.code).toBe('not_found');
240
+ });
241
+
242
+ it('404s the founder-only trust-circle surface (/auth/*)', async () => {
243
+ const list = await fetch(`${baseUrl}/auth/trust-circle`);
244
+ expect(list.status).toBe(404);
245
+
246
+ const invite = await fetch(`${baseUrl}/auth/invites`, {
247
+ method: 'POST',
248
+ headers: { 'content-type': 'application/json' },
249
+ body: JSON.stringify({ label: 'Mom' }),
250
+ });
251
+ expect(invite.status).toBe(404);
252
+ });
253
+ });
@@ -149,6 +149,23 @@ describe('/api/settings routes', () => {
149
149
  expect(body.error.message).toMatch(/uiPort/);
150
150
  });
151
151
 
152
+ it('PUT ownCadre returns 400 invalid_setting (install-time only)', async () => {
153
+ writeV2Config(dataDir);
154
+ app = Fastify();
155
+ registerErrorHandler(app);
156
+ registerSettingsRoutes(app, { settingsStore: new HostSettingsStore({ dataDir }), nat: fakeNat().svc });
157
+ const res = await app.inject({
158
+ method: 'PUT',
159
+ url: '/api/settings',
160
+ headers: { 'content-type': 'application/json' },
161
+ payload: JSON.stringify({ ownCadre: { enabled: true } }),
162
+ });
163
+ expect(res.statusCode).toBe(400);
164
+ const body = res.json() as { error: { code: string; message: string } };
165
+ expect(body.error.code).toBe('invalid_setting');
166
+ expect(body.error.message).toMatch(/ownCadre/);
167
+ });
168
+
152
169
  it('PUT unknown setting returns 400', async () => {
153
170
  writeV2Config(dataDir);
154
171
  app = Fastify();
@@ -0,0 +1,145 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join, normalize, relative, sep } from 'node:path';
4
+
5
+ import Fastify from 'fastify';
6
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
7
+
8
+ import { registerStaticMount, resolveSafe } from '../static.js';
9
+
10
+ describe('resolveSafe', () => {
11
+ // POSIX-style fixtures; node:path normalizes separators per-platform, so the
12
+ // containment logic is exercised identically on Windows and POSIX.
13
+ const root = '/srv/app/dist/ui';
14
+
15
+ it('resolves a normal in-root asset', () => {
16
+ expect(resolveSafe(root, '/assets/app.js')).toBe(normalize(join(root, 'assets/app.js')));
17
+ });
18
+
19
+ it('resolves a bare / to the root directory itself', () => {
20
+ expect(resolveSafe(root, '/')).toBe(normalize(root));
21
+ });
22
+
23
+ it('resolves /index.html to the root index', () => {
24
+ expect(resolveSafe(root, '/index.html')).toBe(normalize(join(root, 'index.html')));
25
+ });
26
+
27
+ it('rejects the sibling-directory prefix bypass', () => {
28
+ // `ui-private` shares `ui`'s basename as a prefix — the bare startsWith
29
+ // guard accepted this; the relative-based guard must reject it.
30
+ expect(resolveSafe(root, '/../ui-private/secret')).toBeNull();
31
+ });
32
+
33
+ it('accepts an in-root asset whose name merely begins with ".."', () => {
34
+ // `..foo` is a legitimate descendant — the containment guard must not reject
35
+ // it via a loose '..' prefix match (the precise check keys on '..' + sep).
36
+ expect(resolveSafe(root, '/..foo.js')).toBe(normalize(join(root, '..foo.js')));
37
+ });
38
+
39
+ it('rejects ordinary out-of-tree traversal', () => {
40
+ expect(resolveSafe(root, '/../../etc/passwd')).toBeNull();
41
+ });
42
+
43
+ it('rejects an encoded out-of-tree traversal', () => {
44
+ expect(resolveSafe(root, '/%2e%2e/%2e%2e/etc/passwd')).toBeNull();
45
+ });
46
+
47
+ it('rejects a NUL-byte path', () => {
48
+ expect(resolveSafe(root, '/app%00.js')).toBeNull();
49
+ });
50
+
51
+ it('rejects malformed percent-encoding', () => {
52
+ expect(resolveSafe(root, '/%')).toBeNull();
53
+ });
54
+
55
+ it('never returns a path that escapes the root', () => {
56
+ const escapes = [
57
+ '/../ui-private/secret',
58
+ '/../../etc/passwd',
59
+ '/../ui/../ui-private/x',
60
+ '/..',
61
+ ];
62
+ for (const p of escapes) {
63
+ const resolved = resolveSafe(root, p);
64
+ if (resolved !== null) {
65
+ const rel = relative(normalize(root), resolved);
66
+ expect(rel === '..' || rel.startsWith('..' + sep)).toBe(false);
67
+ }
68
+ }
69
+ });
70
+ });
71
+
72
+ describe('serveStatic (via registerStaticMount)', () => {
73
+ let app: ReturnType<typeof Fastify>;
74
+ let tmpRoot: string;
75
+ let rootDir: string;
76
+
77
+ beforeEach(() => {
78
+ tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-static-'));
79
+ rootDir = join(tmpRoot, 'ui');
80
+ mkdirSync(join(rootDir, 'assets'), { recursive: true });
81
+ writeFileSync(join(rootDir, 'index.html'), '<!doctype html><title>spa</title>');
82
+ writeFileSync(join(rootDir, 'assets', 'app.js'), 'console.log("hi");');
83
+
84
+ // Sibling directory whose name is a prefix superset of the root basename.
85
+ const sibling = join(tmpRoot, 'ui-private');
86
+ mkdirSync(sibling, { recursive: true });
87
+ writeFileSync(join(sibling, 'secret.txt'), 'top secret');
88
+
89
+ app = Fastify();
90
+ registerStaticMount(app, { rootDir });
91
+ });
92
+
93
+ afterEach(async () => {
94
+ await app.close();
95
+ try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
96
+ });
97
+
98
+ it('serves an in-root asset', async () => {
99
+ const res = await app.inject({ method: 'GET', url: '/assets/app.js' });
100
+ expect(res.statusCode).toBe(200);
101
+ expect(res.headers['content-type']).toContain('application/javascript');
102
+ expect(res.body).toBe('console.log("hi");');
103
+ });
104
+
105
+ it('serves index.html for the bare root', async () => {
106
+ const res = await app.inject({ method: 'GET', url: '/' });
107
+ expect(res.statusCode).toBe(200);
108
+ expect(res.headers['content-type']).toContain('text/html');
109
+ expect(res.body).toContain('<title>spa</title>');
110
+ });
111
+
112
+ it('serves index.html as SPA fallback for unknown HTML routes', async () => {
113
+ const res = await app.inject({
114
+ method: 'GET',
115
+ url: '/some/client/route',
116
+ headers: { accept: 'text/html' },
117
+ });
118
+ expect(res.statusCode).toBe(200);
119
+ expect(res.body).toContain('<title>spa</title>');
120
+ });
121
+
122
+ // HTTP-level regression for the sibling-prefix bypass. Two vector classes:
123
+ // - literal / `%2e%2e`-with-literal-slash: Fastify's router collapses the
124
+ // `../` to a within-root path before the handler, so these never reach
125
+ // `resolveSafe` in escaping form — they pass regardless of the fix.
126
+ // - encoded-SEPARATOR (`%2f`/`%5c`): Fastify does NOT collapse these; the
127
+ // handler sees the still-encoded url, `decodeSafe` then reconstitutes
128
+ // `../`, and the escaping path DOES reach `resolveSafe`. Under the old
129
+ // `startsWith` guard this served the sibling secret over HTTP — a genuine
130
+ // exploit. These cases fail without the `relative`-based guard.
131
+ // Either way the asserted invariant is the same: sibling content never leaks.
132
+ it('never serves sibling-prefix directory content over HTTP', async () => {
133
+ const vectors = [
134
+ '/../ui-private/secret.txt',
135
+ '/%2e%2e/ui-private/secret.txt',
136
+ '/..%2fui-private%2fsecret.txt',
137
+ '/%2e%2e%2fui-private%2fsecret.txt',
138
+ '/..%5cui-private%5csecret.txt',
139
+ ];
140
+ for (const url of vectors) {
141
+ const res = await app.inject({ method: 'GET', url });
142
+ expect(res.body).not.toContain('top secret');
143
+ }
144
+ });
145
+ });
@@ -1,5 +1,5 @@
1
1
  import Fastify from 'fastify';
2
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { describe, it, expect, afterEach } from 'vitest';
3
3
 
4
4
  import { registerErrorHandler } from '../error-handler.js';
5
5
  import { registerStatusRoute } from '../routes/status.js';
@@ -64,7 +64,7 @@ describe('GET /api/status', () => {
64
64
  status: 'running',
65
65
  spawnedAt: '2025-01-01T00:00:00Z',
66
66
  workdir: '/tmp/alice',
67
- ports: { health: 1, metrics: 2, p2p: 3 },
67
+ ports: { health: 1, metrics: 2, p2p: 3, admin: 4 },
68
68
  },
69
69
  ]),
70
70
  trustCircle: fakeTrustCircle({
@@ -91,6 +91,27 @@ describe('GET /api/status', () => {
91
91
  expect(body.update).toBeUndefined();
92
92
  });
93
93
 
94
+ it('omits trustCircle + connectivity in donor-only mode (no trust circle / NAT wired)', async () => {
95
+ app = Fastify();
96
+ registerErrorHandler(app);
97
+ registerStatusRoute(app, {
98
+ orchestrator: fakeOrchestrator([]),
99
+ });
100
+
101
+ const res = await app.inject({ method: 'GET', url: '/api/status' });
102
+ expect(res.statusCode).toBe(200);
103
+ const body = res.json() as {
104
+ service: { name: string };
105
+ nodes: unknown[];
106
+ trustCircle?: unknown;
107
+ connectivity?: unknown;
108
+ };
109
+ expect(body.service.name).toBe('cadre-host');
110
+ expect(body.nodes).toEqual([]);
111
+ expect(body.trustCircle).toBeUndefined();
112
+ expect(body.connectivity).toBeUndefined();
113
+ });
114
+
94
115
  it('includes update info when service is present', async () => {
95
116
  app = Fastify();
96
117
  registerErrorHandler(app);
@@ -7,6 +7,7 @@
7
7
  * invalid_label, invalid_token → 400
8
8
  * not_found, already_redeemed → 404
9
9
  * expired → 410
10
+ * node_unavailable → 503
10
11
  * storage_error → 500
11
12
  * - NatError
12
13
  * invalid_config, ddns_provider_unknown,
@@ -14,12 +15,28 @@
14
15
  * secrets_unavailable, storage_error,
15
16
  * mapping_failed, router_unreachable,
16
17
  * ip_detection_failed, ddns_update_failed → 500
18
+ * node_unavailable → 503
17
19
  * - UpdateErrorException
18
20
  * manifest_invalid, no_update_available,
19
21
  * min_previous_version → 400
20
22
  * apply_in_progress → 409
21
23
  * signature_invalid, unsupported_state_version,
22
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
23
40
  * - everything else → 500 (code "internal")
24
41
  */
25
42
 
@@ -29,6 +46,7 @@ import debug from 'debug';
29
46
  import { TrustCircleError, type TrustCircleErrorCode } from '../auth/types.js';
30
47
  import { NatError, type NatErrorCode } from '../nat/types.js';
31
48
  import { UpdateErrorException, type UpdateErrorCode } from '../update/types.js';
49
+ import { GrantError, type GrantErrorCode, DonationError, type DonationErrorCode } from '../donation/types.js';
32
50
 
33
51
  const log = debug('cadre:host:error-handler');
34
52
 
@@ -39,6 +57,7 @@ const TRUST_STATUS: Record<TrustCircleErrorCode, number> = {
39
57
  already_redeemed: 404,
40
58
  expired: 410,
41
59
  storage_error: 500,
60
+ node_unavailable: 503,
42
61
  };
43
62
 
44
63
  const NAT_STATUS: Record<NatErrorCode, number> = {
@@ -51,6 +70,28 @@ const NAT_STATUS: Record<NatErrorCode, number> = {
51
70
  router_unreachable: 500,
52
71
  ip_detection_failed: 500,
53
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,
54
95
  };
55
96
 
56
97
  const UPDATE_STATUS: Record<UpdateErrorCode, number> = {
@@ -96,6 +137,12 @@ function classify(err: FastifyError): Classified {
96
137
  if (err instanceof UpdateErrorException) {
97
138
  return { status: UPDATE_STATUS[err.code] ?? 500, code: err.code, message: err.message };
98
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
+ }
99
146
  // Fastify body-parsing / validation errors have a statusCode we should honour.
100
147
  if (typeof err.statusCode === 'number' && err.statusCode >= 400 && err.statusCode < 600) {
101
148
  return {
@@ -22,6 +22,8 @@ import type { NatService } from '../nat/index.js';
22
22
  import { createNatHandlers } from '../nat/index.js';
23
23
  import type { UpdateService } from '../update/index.js';
24
24
  import { createUpdateHandlers } from '../update/index.js';
25
+ import type { GrantService, DonationService } from '../donation/index.js';
26
+ import { createGrantAdminHandlers } from '../donation/index.js';
25
27
 
26
28
  import { EventBus } from './events/bus.js';
27
29
  import { registerSseRoute } from './events/sse-route.js';
@@ -35,6 +37,8 @@ import { registerUpdateRoutes } from './routes/update.js';
35
37
  import { registerStatusRoute } from './routes/status.js';
36
38
  import { registerNodesRoutes } from './routes/nodes.js';
37
39
  import { registerSettingsRoutes } from './routes/settings.js';
40
+ import { registerGrantsAdminRoutes } from './routes/grants-admin.js';
41
+ import { registerGrantsRoutes } from './routes/grants.js';
38
42
  import { HostSettingsStore } from './settings-store.js';
39
43
 
40
44
  export interface LocalUiServerOptions {
@@ -44,10 +48,33 @@ export interface LocalUiServerOptions {
44
48
  dataDir: string;
45
49
  /** Wired dependencies — all owned by the caller. */
46
50
  orchestrator: HostProcessOrchestrator;
47
- trustCircle: TrustCircleService;
48
- nat: NatService;
51
+ /**
52
+ * Trust-circle service — present only when the host runs its own personal
53
+ * cadre (`ownCadre.enabled`). Absent in donor-only mode, where `/auth/*`
54
+ * stays unmounted and 404s.
55
+ */
56
+ trustCircle?: TrustCircleService;
57
+ /**
58
+ * NAT service — present only when the host runs its own personal cadre.
59
+ * Absent in donor-only mode (loopback-only in v1), where `/nat/*` stays
60
+ * unmounted and 404s.
61
+ */
62
+ nat?: NatService;
49
63
  /** Optional — 6.4.2 lands this; nullable while still iterating. */
50
64
  update?: UpdateService;
65
+ /**
66
+ * Donation grant layer. When present, mounts the loopback admin surface at
67
+ * `/grants-admin` (issue/list/revoke). Optional so existing callers/tests
68
+ * that don't exercise donations need not wire it.
69
+ */
70
+ grants?: GrantService;
71
+ /**
72
+ * Donation lifecycle service. When present (alongside `grants`), mounts the
73
+ * bearer-gated grantee-facing provisioning surface at `/grants`
74
+ * (provision / peer / seed / terminate). Optional so callers/tests that don't
75
+ * exercise the donor path need not wire it.
76
+ */
77
+ donations?: DonationService;
51
78
  /** Settings I/O facade. Defaults to a new one rooted at `dataDir`. */
52
79
  settingsStore?: HostSettingsStore;
53
80
  /** Bus instance — pass the same one to subsystems that publish events. */
@@ -99,18 +126,33 @@ export function createLocalUiServer(opts: LocalUiServerOptions): LocalUiServer {
99
126
 
100
127
  registerStatusRoute(app, {
101
128
  orchestrator: opts.orchestrator,
102
- trustCircle: opts.trustCircle,
103
- nat: opts.nat,
129
+ ...(opts.trustCircle ? { trustCircle: opts.trustCircle } : {}),
130
+ ...(opts.nat ? { nat: opts.nat } : {}),
104
131
  ...(opts.update ? { update: opts.update } : {}),
105
132
  });
106
133
  registerNodesRoutes(app, { orchestrator: opts.orchestrator });
107
- registerSettingsRoutes(app, { settingsStore, nat: opts.nat, ...(opts.update ? { update: opts.update } : {}) });
134
+ registerSettingsRoutes(app, { settingsStore, ...(opts.nat ? { nat: opts.nat } : {}), ...(opts.update ? { update: opts.update } : {}) });
108
135
 
109
- registerTrustCircleRoutes(app, { handlers: createTrustCircleHandlers(opts.trustCircle), events });
110
- registerNatRoutes(app, { handlers: createNatHandlers(opts.nat), events });
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).
139
+ if (opts.trustCircle) {
140
+ registerTrustCircleRoutes(app, { handlers: createTrustCircleHandlers(opts.trustCircle), events });
141
+ }
142
+ if (opts.nat) {
143
+ registerNatRoutes(app, { handlers: createNatHandlers(opts.nat), events });
144
+ }
111
145
  if (opts.update) {
112
146
  registerUpdateRoutes(app, { handlers: createUpdateHandlers(opts.update), events });
113
147
  }
148
+ if (opts.grants) {
149
+ registerGrantsAdminRoutes(app, { handlers: createGrantAdminHandlers(opts.grants) });
150
+ // Grantee-facing provisioning surface needs both the donation service and a
151
+ // grant validator (the GrantService doubles as the validator).
152
+ if (opts.donations) {
153
+ registerGrantsRoutes(app, { donations: opts.donations, grants: opts.grants });
154
+ }
155
+ }
114
156
 
115
157
  // Static mount registers as the not-found handler — declared last so the
116
158
  // API paths above win.
@@ -147,16 +189,19 @@ export function createLocalUiServer(opts: LocalUiServerOptions): LocalUiServer {
147
189
  );
148
190
 
149
191
  // 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.
192
+ // signal even if no settings have changed yet this session. Skipped in
193
+ // donor-only mode, where there is no NatService.
194
+ if (opts.nat) {
195
+ try {
196
+ const snap = opts.nat.getStatus();
197
+ events.publish({
198
+ type: 'connectivity-changed',
199
+ portMode: snap.portMode,
200
+ directReachability: snap.directReachability,
201
+ });
202
+ } catch {
203
+ // NatService.start may not have completed; harmless to skip.
204
+ }
160
205
  }
161
206
 
162
207
  // 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
+ }