@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,263 @@
1
+ /**
2
+ * Grantee-facing `/grants` route tests — the bearer-gated provisioning surface.
3
+ *
4
+ * Uses a fake orchestrator (no real children) so the focus is the HTTP contract:
5
+ * bearer auth, request validation, per-grant ownership isolation, the quota gate,
6
+ * and seedToken redaction. The live-node round-trips (peer/seed) are covered by
7
+ * the cross-package integration scenario.
8
+ */
9
+
10
+ import Fastify from 'fastify';
11
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
12
+ import { mkdtempSync, rmSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+
16
+ import type {
17
+ Orchestrator,
18
+ OrchestratorCreateRequest,
19
+ OrchestratorCreateResult,
20
+ OrchestratorStats,
21
+ } from '@serfab/cadre-provider';
22
+
23
+ import { registerErrorHandler } from '../error-handler.js';
24
+ import { registerGrantsRoutes } from '../routes/grants.js';
25
+ import { DonationService } from '../../donation/donation-service.js';
26
+ import { DonationStore } from '../../donation/donation-store.js';
27
+ import { GrantService } from '../../donation/grant-service.js';
28
+ import { GrantStore } from '../../donation/grant-store.js';
29
+
30
+ class FakeOrchestrator implements Orchestrator {
31
+ stopped: string[] = [];
32
+ removed: string[] = [];
33
+ private counter = 0;
34
+ async createContainer(_request: OrchestratorCreateRequest): Promise<OrchestratorCreateResult> {
35
+ const n = ++this.counter;
36
+ return {
37
+ dockerId: `dock_${n}`,
38
+ healthEndpoint: `http://127.0.0.1:${9000 + n}/health`,
39
+ metricsEndpoint: `http://127.0.0.1:${9000 + n}/metrics`,
40
+ seedEndpoint: `http://127.0.0.1:${9000 + n}/seed`,
41
+ seedToken: `seed-token-${n}`,
42
+ p2pPort: 4000 + n,
43
+ };
44
+ }
45
+ async stopContainer(id: string): Promise<void> { this.stopped.push(id); }
46
+ async removeContainer(id: string): Promise<void> { this.removed.push(id); }
47
+ async getStats(): Promise<OrchestratorStats> {
48
+ return { cpuPercent: 0, memoryBytes: 0, networkRxBytes: 0, networkTxBytes: 0 };
49
+ }
50
+ async isRunning(): Promise<boolean> { return true; }
51
+ async getLogs(): Promise<string> { return ''; }
52
+ }
53
+
54
+ let tmpRoot: string;
55
+ let app: ReturnType<typeof Fastify>;
56
+ let grants: GrantService;
57
+ let store: DonationStore;
58
+ let token: string;
59
+ const originalFetch = globalThis.fetch;
60
+
61
+ /**
62
+ * A well-formed 32-byte base64url owner key. `DonationService.provision`
63
+ * shape-checks the pins, so a placeholder string is a 400 rather than a fixture.
64
+ */
65
+ const OWNER_KEY = Buffer.alloc(32, 7).toString('base64url');
66
+
67
+ const body = {
68
+ partyId: 'party-P',
69
+ bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooReq'],
70
+ ownerKeys: [OWNER_KEY],
71
+ };
72
+
73
+ const bearer = (t: string) => ({ authorization: `Bearer ${t}` });
74
+
75
+ beforeEach(() => {
76
+ tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-grants-svc-route-'));
77
+ grants = new GrantService({ store: new GrantStore(join(tmpRoot, 'grants')) });
78
+ token = grants.issue({ label: 'Alice', maxNodes: 2 }).token;
79
+ store = new DonationStore(join(tmpRoot, 'donations'));
80
+ const donations = new DonationService({ orchestrator: new FakeOrchestrator(), grants, store });
81
+ app = Fastify();
82
+ registerErrorHandler(app);
83
+ registerGrantsRoutes(app, { donations, grants });
84
+ });
85
+
86
+ afterEach(async () => {
87
+ globalThis.fetch = originalFetch;
88
+ await app.close();
89
+ try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
90
+ });
91
+
92
+ describe('POST /grants', () => {
93
+ it('provisions a donated node → 201, awaiting_seed, seedToken redacted', async () => {
94
+ const res = await app.inject({ method: 'POST', url: '/grants', headers: bearer(token), payload: body });
95
+ expect(res.statusCode).toBe(201);
96
+ const donation = (res.json() as { data: { donation: Record<string, unknown> } }).data.donation;
97
+ expect(donation.status).toBe('awaiting_seed');
98
+ expect(donation.partyId).toBe('party-P');
99
+ expect(donation.seedToken).toBeUndefined();
100
+ expect(donation.seedEndpoint).toBeUndefined();
101
+ });
102
+
103
+ it('rejects a missing bearer → 401', async () => {
104
+ const res = await app.inject({ method: 'POST', url: '/grants', payload: body });
105
+ expect(res.statusCode).toBe(401);
106
+ expect((res.json() as { error: { code: string } }).error.code).toBe('unauthorized');
107
+ });
108
+
109
+ it('rejects an unknown bearer → 401', async () => {
110
+ const res = await app.inject({ method: 'POST', url: '/grants', headers: bearer('nope'), payload: body });
111
+ expect(res.statusCode).toBe(401);
112
+ });
113
+
114
+ it('rejects a missing partyId → 400 invalid_request', async () => {
115
+ const res = await app.inject({
116
+ method: 'POST', url: '/grants', headers: bearer(token),
117
+ payload: { bootstrapNodes: body.bootstrapNodes, ownerKeys: body.ownerKeys },
118
+ });
119
+ expect(res.statusCode).toBe(400);
120
+ expect((res.json() as { error: { code: string } }).error.code).toBe('invalid_request');
121
+ });
122
+
123
+ it('rejects empty ownerKeys → 400 invalid_request', async () => {
124
+ const res = await app.inject({
125
+ method: 'POST', url: '/grants', headers: bearer(token),
126
+ payload: { ...body, ownerKeys: [] },
127
+ });
128
+ expect(res.statusCode).toBe(400);
129
+ });
130
+
131
+ // A typo'd pin used to be answered 201 and only fail when the spawned child
132
+ // tried to decode it at boot — by which point the caller had no way to act on it.
133
+ it('rejects a malformed ownerKeys entry → 400 invalid_request, naming the bad key', async () => {
134
+ const res = await app.inject({
135
+ method: 'POST', url: '/grants', headers: bearer(token),
136
+ payload: { ...body, ownerKeys: [OWNER_KEY, 'this-is-not-a-key'] },
137
+ });
138
+ expect(res.statusCode).toBe(400);
139
+ const error = (res.json() as { error: { code: string; message: string } }).error;
140
+ expect(error.code).toBe('invalid_request');
141
+ expect(error.message).toContain('this-is-not-a-key');
142
+ });
143
+
144
+ // Element types are the route's job: the service's shape check assumes strings,
145
+ // so a number here would come back as an internal `.trim is not a function`.
146
+ it('rejects a non-string ownerKeys entry → 400 naming the field, not a decoder error', async () => {
147
+ const res = await app.inject({
148
+ method: 'POST', url: '/grants', headers: bearer(token),
149
+ payload: { ...body, ownerKeys: [42] },
150
+ });
151
+ expect(res.statusCode).toBe(400);
152
+ const error = (res.json() as { error: { code: string; message: string } }).error;
153
+ expect(error.code).toBe('invalid_request');
154
+ expect(error.message).toBe('ownerKeys must be an array of strings');
155
+ });
156
+
157
+ it('enforces the grant quota → 429 quota_exceeded once maxNodes is reached', async () => {
158
+ const g = grants.issue({ label: 'Bob', maxNodes: 1 }).token;
159
+ const first = await app.inject({ method: 'POST', url: '/grants', headers: bearer(g), payload: body });
160
+ expect(first.statusCode).toBe(201);
161
+ const second = await app.inject({ method: 'POST', url: '/grants', headers: bearer(g), payload: body });
162
+ expect(second.statusCode).toBe(429);
163
+ expect((second.json() as { error: { code: string } }).error.code).toBe('quota_exceeded');
164
+ });
165
+ });
166
+
167
+ describe('GET /grants auth', () => {
168
+ it('rejects an unknown bearer → 401 unauthorized (code matches the status)', async () => {
169
+ const res = await app.inject({ method: 'GET', url: '/grants', headers: bearer('nope') });
170
+ expect(res.statusCode).toBe(401);
171
+ expect((res.json() as { error: { code: string } }).error.code).toBe('unauthorized');
172
+ });
173
+ });
174
+
175
+ describe('GET /grants (ownership isolation)', () => {
176
+ it('lists only the donations the presented grant authorized', async () => {
177
+ const other = grants.issue({ label: 'Carol' }).token;
178
+ await app.inject({ method: 'POST', url: '/grants', headers: bearer(token), payload: body });
179
+ await app.inject({ method: 'POST', url: '/grants', headers: bearer(other), payload: body });
180
+
181
+ const mine = await app.inject({ method: 'GET', url: '/grants', headers: bearer(token) });
182
+ const donations = (mine.json() as { data: { donations: Array<{ grantToken: string }> } }).data.donations;
183
+ expect(donations).toHaveLength(1);
184
+ expect(donations[0].grantToken).toBe(token);
185
+ });
186
+
187
+ it('a foreign grant cannot read another grant’s donation → 404', async () => {
188
+ const other = grants.issue({ label: 'Carol' }).token;
189
+ const created = await app.inject({ method: 'POST', url: '/grants', headers: bearer(token), payload: body });
190
+ const id = (created.json() as { data: { donation: { id: string } } }).data.donation.id;
191
+
192
+ const mine = await app.inject({ method: 'GET', url: `/grants/${id}`, headers: bearer(token) });
193
+ expect(mine.statusCode).toBe(200);
194
+
195
+ const foreign = await app.inject({ method: 'GET', url: `/grants/${id}`, headers: bearer(other) });
196
+ expect(foreign.statusCode).toBe(404);
197
+ });
198
+ });
199
+
200
+ describe('PUT /grants/:id/seed validation', () => {
201
+ it('rejects a missing seed body → 400 invalid_request', async () => {
202
+ const created = await app.inject({ method: 'POST', url: '/grants', headers: bearer(token), payload: body });
203
+ const id = (created.json() as { data: { donation: { id: string } } }).data.donation.id;
204
+ const res = await app.inject({ method: 'PUT', url: `/grants/${id}/seed`, headers: bearer(token), payload: {} });
205
+ expect(res.statusCode).toBe(400);
206
+ expect((res.json() as { error: { code: string } }).error.code).toBe('invalid_request');
207
+ });
208
+
209
+ it('answers 409 invalid_state when the loan ends while the seed is in flight', async () => {
210
+ const created = await app.inject({ method: 'POST', url: '/grants', headers: bearer(token), payload: body });
211
+ const id = (created.json() as { data: { donation: { id: string } } }).data.donation.id;
212
+
213
+ // The borrower's DELETE lands inside the seed window: the node accepts the
214
+ // seed, but by the time it answers the loan is over. The ending wins, so the
215
+ // route must NOT report a 200 that implies a live, freshly seeded node.
216
+ globalThis.fetch = (async () => {
217
+ await app.inject({ method: 'DELETE', url: `/grants/${id}`, headers: bearer(token) });
218
+ return { ok: true, json: async () => ({ success: true, peersAdded: 2 }) } as unknown as Response;
219
+ }) as typeof globalThis.fetch;
220
+
221
+ const res = await app.inject({
222
+ method: 'PUT', url: `/grants/${id}/seed`, headers: bearer(token), payload: { seed: 'encoded-seed' },
223
+ });
224
+
225
+ expect(res.statusCode).toBe(409);
226
+ expect((res.json() as { error: { code: string } }).error.code).toBe('invalid_state');
227
+ const after = await app.inject({ method: 'GET', url: `/grants/${id}`, headers: bearer(token) });
228
+ expect((after.json() as { data: { donation: { status: string } } }).data.donation.status)
229
+ .toBe('terminated');
230
+ });
231
+
232
+ it('answers 404 not_found when the record vanishes while the seed is in flight', async () => {
233
+ const created = await app.inject({ method: 'POST', url: '/grants', headers: bearer(token), payload: body });
234
+ const id = (created.json() as { data: { donation: { id: string } } }).data.donation.id;
235
+
236
+ // The row is gone by the time the node answers — the other half of the
237
+ // abandon mapping, which has no status to report.
238
+ globalThis.fetch = (async () => {
239
+ store.remove(id);
240
+ return { ok: true, json: async () => ({ success: true, peersAdded: 2 }) } as unknown as Response;
241
+ }) as typeof globalThis.fetch;
242
+
243
+ const res = await app.inject({
244
+ method: 'PUT', url: `/grants/${id}/seed`, headers: bearer(token), payload: { seed: 'encoded-seed' },
245
+ });
246
+
247
+ expect(res.statusCode).toBe(404);
248
+ expect((res.json() as { error: { code: string } }).error.code).toBe('not_found');
249
+ });
250
+ });
251
+
252
+ describe('DELETE /grants/:id', () => {
253
+ it('terminates a donation and drops it from the live tally', async () => {
254
+ const created = await app.inject({ method: 'POST', url: '/grants', headers: bearer(token), payload: body });
255
+ const id = (created.json() as { data: { donation: { id: string } } }).data.donation.id;
256
+
257
+ const del = await app.inject({ method: 'DELETE', url: `/grants/${id}`, headers: bearer(token) });
258
+ expect(del.statusCode).toBe(200);
259
+
260
+ const one = await app.inject({ method: 'GET', url: `/grants/${id}`, headers: bearer(token) });
261
+ expect((one.json() as { data: { donation: { status: string } } }).data.donation.status).toBe('terminated');
262
+ });
263
+ });
@@ -21,7 +21,7 @@ interface FakeOrchestrator extends HostProcessOrchestrator {
21
21
 
22
22
  function fakeOrchestrator(
23
23
  initial: ManagedNodeInfo[] = [],
24
- opts: { authorityId?: string; hasAuthorityConfig?: boolean } = {},
24
+ opts: { ownerId?: string; hasOwnerConfig?: boolean } = {},
25
25
  ): FakeOrchestrator {
26
26
  const nodes = new Map(initial.map((n) => [n.dockerId, n]));
27
27
  const listeners = new Set<NodeStateListener>();
@@ -41,21 +41,21 @@ function fakeOrchestrator(
41
41
  for (const n of nodes.values()) if (n.id === id) return n.dockerId;
42
42
  return undefined;
43
43
  },
44
- isAuthorityNode: (id: string) => {
45
- if (!opts.authorityId) return false;
44
+ isOwnerNode: (id: string) => {
45
+ if (!opts.ownerId) return false;
46
46
  const node = findById(id);
47
- return id === opts.authorityId || node?.id === opts.authorityId;
47
+ return id === opts.ownerId || node?.id === opts.ownerId;
48
48
  },
49
- hasAuthorityConfig: () => opts.hasAuthorityConfig ?? false,
50
- ensureAuthorityNode: async () => {
49
+ hasOwnerConfig: () => opts.hasOwnerConfig ?? false,
50
+ ensureOwnerNode: async () => {
51
51
  counters.ensured++;
52
- const node = opts.authorityId ? findById(opts.authorityId) : undefined;
53
- return node ?? { id: opts.authorityId, status: 'running' } as unknown as ManagedNodeInfo;
52
+ const node = opts.ownerId ? findById(opts.ownerId) : undefined;
53
+ return node ?? { id: opts.ownerId, status: 'running' } as unknown as ManagedNodeInfo;
54
54
  },
55
- restartAuthorityNode: async () => {
55
+ restartOwnerNode: async () => {
56
56
  counters.restarted++;
57
- const node = opts.authorityId ? findById(opts.authorityId) : undefined;
58
- return node ?? { id: opts.authorityId, status: 'running' } as unknown as ManagedNodeInfo;
57
+ const node = opts.ownerId ? findById(opts.ownerId) : undefined;
58
+ return node ?? { id: opts.ownerId, status: 'running' } as unknown as ManagedNodeInfo;
59
59
  },
60
60
  onStateChange: (l: NodeStateListener) => { listeners.add(l); return () => { listeners.delete(l); }; },
61
61
  stopContainer: async (dockerId: string) => {
@@ -171,14 +171,14 @@ describe('/api/nodes routes', () => {
171
171
  ]);
172
172
  });
173
173
 
174
- it('POST /api/nodes/:id/start on a non-authority node returns 501 not_implemented', async () => {
174
+ it('POST /api/nodes/:id/start on a non-owner node returns 501 not_implemented', async () => {
175
175
  const res = await app.inject({ method: 'POST', url: '/api/nodes/alice/start' });
176
176
  expect(res.statusCode).toBe(501);
177
177
  const body = res.json() as { error: { code: string } };
178
178
  expect(body.error.code).toBe('not_implemented');
179
179
  });
180
180
 
181
- it('POST /api/nodes/:id/restart on a non-authority node returns 501 not_implemented', async () => {
181
+ it('POST /api/nodes/:id/restart on a non-owner node returns 501 not_implemented', async () => {
182
182
  const res = await app.inject({ method: 'POST', url: '/api/nodes/alice/restart' });
183
183
  expect(res.statusCode).toBe(501);
184
184
  });
@@ -191,9 +191,9 @@ describe('/api/nodes routes', () => {
191
191
  });
192
192
  });
193
193
 
194
- describe('/api/nodes — authority node start/restart', () => {
195
- const AUTHORITY_NODE: ManagedNodeInfo = {
196
- id: 'authority',
194
+ describe('/api/nodes — owner node start/restart', () => {
195
+ const OWNER_NODE: ManagedNodeInfo = {
196
+ id: 'owner',
197
197
  dockerId: '999:tok',
198
198
  partyId: 'install-id',
199
199
  profile: 'storage',
@@ -201,7 +201,7 @@ describe('/api/nodes — authority node start/restart', () => {
201
201
  spawnedAt: '2025-01-01T00:00:00Z',
202
202
  workdir: '',
203
203
  ports: { health: 1, metrics: 2, p2p: 4555, admin: 3 },
204
- authority: true,
204
+ owner: true,
205
205
  };
206
206
 
207
207
  let app: ReturnType<typeof Fastify>;
@@ -210,30 +210,30 @@ describe('/api/nodes — authority node start/restart', () => {
210
210
  beforeEach(() => {
211
211
  app = Fastify();
212
212
  registerErrorHandler(app);
213
- orchestrator = fakeOrchestrator([AUTHORITY_NODE], { authorityId: 'authority', hasAuthorityConfig: true });
213
+ orchestrator = fakeOrchestrator([OWNER_NODE], { ownerId: 'owner', hasOwnerConfig: true });
214
214
  registerNodesRoutes(app, { orchestrator });
215
215
  });
216
216
 
217
217
  afterEach(async () => { await app.close(); });
218
218
 
219
- it('start ensures the authority node', async () => {
220
- const res = await app.inject({ method: 'POST', url: '/api/nodes/authority/start' });
219
+ it('start ensures the owner node', async () => {
220
+ const res = await app.inject({ method: 'POST', url: '/api/nodes/owner/start' });
221
221
  expect(res.statusCode).toBe(200);
222
222
  expect(orchestrator.__ensured).toBe(1);
223
223
  });
224
224
 
225
- it('restart re-spawns the authority node', async () => {
226
- const res = await app.inject({ method: 'POST', url: '/api/nodes/authority/restart' });
225
+ it('restart re-spawns the owner node', async () => {
226
+ const res = await app.inject({ method: 'POST', url: '/api/nodes/owner/restart' });
227
227
  expect(res.statusCode).toBe(200);
228
228
  expect(orchestrator.__restarted).toBe(1);
229
229
  });
230
230
 
231
- it('start returns 501 when there is no saved authority config', async () => {
231
+ it('start returns 501 when there is no saved owner config', async () => {
232
232
  const app2 = Fastify();
233
233
  registerErrorHandler(app2);
234
- const orch2 = fakeOrchestrator([AUTHORITY_NODE], { authorityId: 'authority', hasAuthorityConfig: false });
234
+ const orch2 = fakeOrchestrator([OWNER_NODE], { ownerId: 'owner', hasOwnerConfig: false });
235
235
  registerNodesRoutes(app2, { orchestrator: orch2 });
236
- const res = await app2.inject({ method: 'POST', url: '/api/nodes/authority/start' });
236
+ const res = await app2.inject({ method: 'POST', url: '/api/nodes/owner/start' });
237
237
  expect(res.statusCode).toBe(501);
238
238
  await app2.close();
239
239
  });
@@ -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();
@@ -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);