@serfab/cadre-host 0.8.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 (136) hide show
  1. package/dist/auth/trust-circle.d.ts +5 -5
  2. package/dist/auth/trust-circle.js +10 -10
  3. package/dist/auth/trust-circle.js.map +1 -1
  4. package/dist/auth/types.d.ts +2 -2
  5. package/dist/bin/host.js +255 -54
  6. package/dist/bin/host.js.map +1 -1
  7. package/dist/donation/donation-service.d.ts +136 -0
  8. package/dist/donation/donation-service.js +306 -0
  9. package/dist/donation/donation-service.js.map +1 -0
  10. package/dist/donation/donation-store.d.ts +42 -0
  11. package/dist/donation/donation-store.js +141 -0
  12. package/dist/donation/donation-store.js.map +1 -0
  13. package/dist/donation/grant-service.d.ts +71 -0
  14. package/dist/donation/grant-service.js +163 -0
  15. package/dist/donation/grant-service.js.map +1 -0
  16. package/dist/donation/grant-store.d.ts +38 -0
  17. package/dist/donation/grant-store.js +135 -0
  18. package/dist/donation/grant-store.js.map +1 -0
  19. package/dist/donation/index.d.ts +18 -0
  20. package/dist/donation/index.js +16 -0
  21. package/dist/donation/index.js.map +1 -0
  22. package/dist/donation/types.d.ts +175 -0
  23. package/dist/donation/types.js +40 -0
  24. package/dist/donation/types.js.map +1 -0
  25. package/dist/index.d.ts +6 -4
  26. package/dist/index.js +4 -2
  27. package/dist/index.js.map +1 -1
  28. package/dist/installer/config.d.ts +26 -0
  29. package/dist/installer/config.js +13 -0
  30. package/dist/installer/config.js.map +1 -1
  31. package/dist/installer/index.d.ts +2 -0
  32. package/dist/installer/index.js +3 -0
  33. package/dist/installer/index.js.map +1 -1
  34. package/dist/installer/wizard.d.ts +4 -0
  35. package/dist/installer/wizard.js +5 -1
  36. package/dist/installer/wizard.js.map +1 -1
  37. package/dist/nat/nat-service.d.ts +5 -5
  38. package/dist/nat/nat-service.js +9 -9
  39. package/dist/nat/nat-service.js.map +1 -1
  40. package/dist/nat/types.d.ts +1 -1
  41. package/dist/orchestrator/host-process-orchestrator.d.ts +35 -27
  42. package/dist/orchestrator/host-process-orchestrator.js +79 -53
  43. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  44. package/dist/orchestrator/index.d.ts +3 -3
  45. package/dist/orchestrator/index.js +1 -1
  46. package/dist/orchestrator/index.js.map +1 -1
  47. package/dist/orchestrator/state-store.d.ts +5 -5
  48. package/dist/orchestrator/state-store.js +2 -2
  49. package/dist/orchestrator/state-store.js.map +1 -1
  50. package/dist/orchestrator/types.d.ts +8 -8
  51. package/dist/owner/index.d.ts +11 -0
  52. package/dist/owner/index.js +11 -0
  53. package/dist/owner/index.js.map +1 -0
  54. package/dist/{authority/authority-node-client.d.ts → owner/owner-node-client.d.ts} +32 -14
  55. package/dist/{authority/authority-node-client.js → owner/owner-node-client.js} +39 -15
  56. package/dist/owner/owner-node-client.js.map +1 -0
  57. package/dist/server/error-handler.d.ts +15 -0
  58. package/dist/server/error-handler.js +41 -0
  59. package/dist/server/error-handler.js.map +1 -1
  60. package/dist/server/index.d.ts +26 -2
  61. package/dist/server/index.js +37 -16
  62. package/dist/server/index.js.map +1 -1
  63. package/dist/server/routes/grants-admin.d.ts +17 -0
  64. package/dist/server/routes/grants-admin.js +32 -0
  65. package/dist/server/routes/grants-admin.js.map +1 -0
  66. package/dist/server/routes/grants.d.ts +28 -0
  67. package/dist/server/routes/grants.js +163 -0
  68. package/dist/server/routes/grants.js.map +1 -0
  69. package/dist/server/routes/nodes.d.ts +5 -4
  70. package/dist/server/routes/nodes.js +19 -18
  71. package/dist/server/routes/nodes.js.map +1 -1
  72. package/dist/server/routes/settings.d.ts +6 -1
  73. package/dist/server/routes/settings.js +5 -2
  74. package/dist/server/routes/settings.js.map +1 -1
  75. package/dist/server/routes/status.d.ts +8 -4
  76. package/dist/server/routes/status.js +9 -4
  77. package/dist/server/routes/status.js.map +1 -1
  78. package/dist/update/release-key.js +7 -5
  79. package/dist/update/release-key.js.map +1 -1
  80. package/package.json +4 -4
  81. package/src/__tests__/{host-authority.smoke.test.ts → host-owner.smoke.test.ts} +7 -7
  82. package/src/__tests__/{orchestrator-authority.test.ts → orchestrator-owner.test.ts} +33 -33
  83. package/src/__tests__/orchestrator-pin-keys.test.ts +153 -0
  84. package/src/__tests__/orchestrator-push.test.ts +10 -10
  85. package/src/auth/__tests__/trust-circle-integration.test.ts +11 -11
  86. package/src/auth/__tests__/trust-circle.test.ts +10 -10
  87. package/src/auth/trust-circle.ts +10 -10
  88. package/src/auth/types.ts +2 -2
  89. package/src/bin/host.ts +273 -53
  90. package/src/donation/__tests__/donation-service.test.ts +290 -0
  91. package/src/donation/__tests__/grant-service.test.ts +213 -0
  92. package/src/donation/__tests__/grant-store.test.ts +113 -0
  93. package/src/donation/donation-service.ts +392 -0
  94. package/src/donation/donation-store.ts +164 -0
  95. package/src/donation/grant-service.ts +200 -0
  96. package/src/donation/grant-store.ts +152 -0
  97. package/src/donation/index.ts +49 -0
  98. package/src/donation/types.ts +220 -0
  99. package/src/index.ts +37 -5
  100. package/src/installer/__tests__/config.test.ts +27 -1
  101. package/src/installer/__tests__/installer.smoke.test.ts +31 -1
  102. package/src/installer/__tests__/wizard.test.ts +5 -1
  103. package/src/installer/config.ts +34 -0
  104. package/src/installer/index.ts +5 -0
  105. package/src/installer/wizard.ts +12 -1
  106. package/src/nat/__tests__/nat-service.test.ts +5 -5
  107. package/src/nat/nat-service.ts +10 -10
  108. package/src/nat/types.ts +1 -1
  109. package/src/orchestrator/host-process-orchestrator.ts +88 -60
  110. package/src/orchestrator/index.ts +3 -3
  111. package/src/orchestrator/state-store.ts +7 -7
  112. package/src/orchestrator/types.ts +8 -8
  113. package/src/{authority/__tests__/authority-node-client.test.ts → owner/__tests__/owner-node-client.test.ts} +45 -17
  114. package/src/owner/index.ts +12 -0
  115. package/src/{authority/authority-node-client.ts → owner/owner-node-client.ts} +55 -21
  116. package/src/server/__tests__/error-handler.test.ts +22 -0
  117. package/src/server/__tests__/grants-admin-route.test.ts +73 -0
  118. package/src/server/__tests__/grants-route.test.ts +189 -0
  119. package/src/server/__tests__/nodes-route.test.ts +25 -25
  120. package/src/server/__tests__/server.smoke.test.ts +75 -0
  121. package/src/server/__tests__/settings-route.test.ts +17 -0
  122. package/src/server/__tests__/status-route.test.ts +21 -0
  123. package/src/server/error-handler.ts +43 -0
  124. package/src/server/index.ts +62 -17
  125. package/src/server/routes/grants-admin.ts +41 -0
  126. package/src/server/routes/grants.ts +186 -0
  127. package/src/server/routes/nodes.ts +19 -18
  128. package/src/server/routes/settings.ts +11 -3
  129. package/src/server/routes/status.ts +18 -8
  130. package/src/update/__tests__/release-key.test.ts +16 -3
  131. package/src/update/release-key.ts +7 -5
  132. package/dist/authority/authority-node-client.js.map +0 -1
  133. package/dist/authority/index.d.ts +0 -11
  134. package/dist/authority/index.js +0 -11
  135. package/dist/authority/index.js.map +0 -1
  136. package/src/authority/index.ts +0 -12
@@ -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);
@@ -22,6 +22,21 @@
22
22
  * apply_in_progress → 409
23
23
  * signature_invalid, unsupported_state_version,
24
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
25
40
  * - everything else → 500 (code "internal")
26
41
  */
27
42
 
@@ -31,6 +46,7 @@ import debug from 'debug';
31
46
  import { TrustCircleError, type TrustCircleErrorCode } from '../auth/types.js';
32
47
  import { NatError, type NatErrorCode } from '../nat/types.js';
33
48
  import { UpdateErrorException, type UpdateErrorCode } from '../update/types.js';
49
+ import { GrantError, type GrantErrorCode, DonationError, type DonationErrorCode } from '../donation/types.js';
34
50
 
35
51
  const log = debug('cadre:host:error-handler');
36
52
 
@@ -57,6 +73,27 @@ const NAT_STATUS: Record<NatErrorCode, number> = {
57
73
  node_unavailable: 503,
58
74
  };
59
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
+
60
97
  const UPDATE_STATUS: Record<UpdateErrorCode, number> = {
61
98
  manifest_invalid: 400,
62
99
  no_update_available: 400,
@@ -100,6 +137,12 @@ function classify(err: FastifyError): Classified {
100
137
  if (err instanceof UpdateErrorException) {
101
138
  return { status: UPDATE_STATUS[err.code] ?? 500, code: err.code, message: err.message };
102
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
+ }
103
146
  // Fastify body-parsing / validation errors have a statusCode we should honour.
104
147
  if (typeof err.statusCode === 'number' && err.statusCode >= 400 && err.statusCode < 600) {
105
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
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Grantee-facing donation routes — the HTTP surface an external cadre authority
3
+ * (a friend's phone) drives to have this host donate a node into *its* cadre.
4
+ *
5
+ * Mount path: `/grants`. Distinct from the loopback-only `/grants-admin` surface
6
+ * (issue/list/revoke, no bearer): this one is **bearer-gated** — every request
7
+ * carries `Authorization: Bearer <grant-token>`, and a grantee sees only the
8
+ * donations its own grant authorized (the single cross-grantee boundary,
9
+ * analogous to cadre-provider's per-customer ownership check).
10
+ *
11
+ * Reachability is out of scope here: in v1 this mounts on the loopback
12
+ * management server, same as the trust-circle / NAT surfaces. Physically exposing
13
+ * it to a remote phone is `backlog/feat-cadre-host-wan-grant-reachability`.
14
+ *
15
+ * Errors: handlers either send a `{ ok:false, error:{code,message} }` envelope
16
+ * inline (auth / body validation) or let the `DonationService` throw a
17
+ * `DonationError`, which `server/error-handler.ts` maps to the matching HTTP
18
+ * status and the same envelope.
19
+ */
20
+
21
+ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
22
+
23
+ import type { DonationService, DonationProvisionRequest } from '../../donation/donation-service.js';
24
+ import type { DonationView, GrantValidator } from '../../donation/types.js';
25
+
26
+ export interface GrantsRoutesOptions {
27
+ donations: DonationService;
28
+ /** Validates the presented bearer for non-provisioning ops (provision revalidates internally). */
29
+ grants: GrantValidator;
30
+ }
31
+
32
+ /** Uniform error envelope (matches `error-handler.ts` and the provider API). */
33
+ function errorResponse(reply: FastifyReply, code: string, message: string, status: number) {
34
+ return reply.status(status).send({ ok: false, error: { code, message } });
35
+ }
36
+
37
+ /** Pull the `Bearer <token>` value out of the Authorization header, or undefined. */
38
+ function parseBearer(header: string | undefined): string | undefined {
39
+ if (!header) return undefined;
40
+ const match = /^Bearer\s+(.+)$/i.exec(header.trim());
41
+ return match ? match[1].trim() : undefined;
42
+ }
43
+
44
+ /** Map a grant denial reason to an HTTP status for the bearer gate. */
45
+ function denyStatus(reason: string | undefined): number {
46
+ if (reason === 'expired' || reason === 'revoked') return 403;
47
+ return 401; // unknown_token / missing
48
+ }
49
+
50
+ /**
51
+ * Authenticate the grant bearer for a non-provisioning op (identity + not
52
+ * expired/revoked; no quota). Returns the token, or null after sending the
53
+ * failure response. Provision does its own quota-aware validation inside
54
+ * `DonationService.provision`.
55
+ */
56
+ function authenticate(
57
+ request: FastifyRequest,
58
+ reply: FastifyReply,
59
+ grants: GrantValidator,
60
+ ): string | null {
61
+ const token = parseBearer(request.headers.authorization);
62
+ if (!token) {
63
+ errorResponse(reply, 'unauthorized', 'Missing bearer grant token', 401);
64
+ return null;
65
+ }
66
+ const validation = grants.validate(token);
67
+ if (!validation.ok) {
68
+ const status = denyStatus(validation.reason);
69
+ // Keep the envelope code aligned with the status (and with the POST path,
70
+ // which surfaces an unknown token as `unauthorized`): 401 → unauthorized,
71
+ // 403 → forbidden. A 401 labelled `forbidden` is self-contradictory.
72
+ const code = status === 403 ? 'forbidden' : 'unauthorized';
73
+ errorResponse(reply, code, `Grant is ${validation.reason ?? 'invalid'}`, status);
74
+ return null;
75
+ }
76
+ return token;
77
+ }
78
+
79
+ /**
80
+ * Resolve a donation the presented grant owns. Returns the redacted view, or
81
+ * null after sending a 404 — an unknown id and one owned by a *different* grant
82
+ * are deliberately indistinguishable (a grantee never learns another grantee's
83
+ * donations exist).
84
+ */
85
+ function requireOwned(
86
+ donations: DonationService,
87
+ id: string,
88
+ token: string,
89
+ reply: FastifyReply,
90
+ ): DonationView | null {
91
+ const donation = donations.get(id);
92
+ if (!donation || donation.grantToken !== token) {
93
+ errorResponse(reply, 'not_found', `No such donation: ${id}`, 404);
94
+ return null;
95
+ }
96
+ return donation;
97
+ }
98
+
99
+ export function registerGrantsRoutes(app: FastifyInstance, opts: GrantsRoutesOptions): void {
100
+ const { donations, grants } = opts;
101
+
102
+ // POST /grants — provision a donated node. The bearer's identity/expiry/
103
+ // revocation AND quota are validated inside provision(), which throws a
104
+ // DonationError (unauthorized / forbidden / quota_exceeded) the error handler
105
+ // maps. We only parse the bearer and validate the request body here.
106
+ app.post('/grants', async (request, reply) => {
107
+ const token = parseBearer(request.headers.authorization);
108
+ if (!token) {
109
+ return errorResponse(reply, 'unauthorized', 'Missing bearer grant token', 401);
110
+ }
111
+
112
+ const body = (request.body ?? {}) as Partial<DonationProvisionRequest>;
113
+ if (typeof body.partyId !== 'string' || body.partyId.length === 0) {
114
+ return errorResponse(reply, 'invalid_request', 'partyId is required', 400);
115
+ }
116
+ if (!Array.isArray(body.bootstrapNodes) || body.bootstrapNodes.length === 0) {
117
+ return errorResponse(reply, 'invalid_request', 'bootstrapNodes is required', 400);
118
+ }
119
+ if (!Array.isArray(body.ownerKeys) || body.ownerKeys.length === 0) {
120
+ return errorResponse(reply, 'invalid_request', 'ownerKeys is required', 400);
121
+ }
122
+ if (body.profile !== undefined && body.profile !== 'storage' && body.profile !== 'transaction') {
123
+ return errorResponse(reply, 'invalid_request', 'profile must be "storage" or "transaction"', 400);
124
+ }
125
+
126
+ const donation = await donations.provision({
127
+ grantToken: token,
128
+ partyId: body.partyId,
129
+ bootstrapNodes: body.bootstrapNodes,
130
+ ownerKeys: body.ownerKeys,
131
+ ...(body.profile ? { profile: body.profile } : {}),
132
+ });
133
+ return reply.status(201).send({ ok: true, data: { donation } });
134
+ });
135
+
136
+ // GET /grants — list only the donations this grant authorized.
137
+ app.get('/grants', async (request, reply) => {
138
+ const token = authenticate(request, reply, grants);
139
+ if (!token) return reply;
140
+ return reply.send({ ok: true, data: { donations: donations.list(token) } });
141
+ });
142
+
143
+ // GET /grants/:id — one donation (redacted), owned by this grant.
144
+ app.get<{ Params: { id: string } }>('/grants/:id', async (request, reply) => {
145
+ const token = authenticate(request, reply, grants);
146
+ if (!token) return reply;
147
+ const donation = requireOwned(donations, request.params.id, token, reply);
148
+ if (!donation) return reply;
149
+ return reply.send({ ok: true, data: { donation } });
150
+ });
151
+
152
+ // GET /grants/:id/peer — live { peerId, multiaddrs } for the requester's addDrone.
153
+ app.get<{ Params: { id: string } }>('/grants/:id/peer', async (request, reply) => {
154
+ const token = authenticate(request, reply, grants);
155
+ if (!token) return reply;
156
+ if (!requireOwned(donations, request.params.id, token, reply)) return reply;
157
+ const peer = await donations.getPeer(request.params.id);
158
+ return reply.send({ ok: true, data: peer });
159
+ });
160
+
161
+ // PUT /grants/:id/seed — apply the requester's phone-signed seed.
162
+ app.put<{ Params: { id: string } }>('/grants/:id/seed', async (request, reply) => {
163
+ const token = authenticate(request, reply, grants);
164
+ if (!token) return reply;
165
+ if (!requireOwned(donations, request.params.id, token, reply)) return reply;
166
+
167
+ const body = (request.body ?? {}) as { seed?: unknown };
168
+ if (typeof body.seed !== 'string' || body.seed.length === 0) {
169
+ return errorResponse(reply, 'invalid_request', 'seed is required', 400);
170
+ }
171
+ const result = await donations.applySeed(request.params.id, body.seed);
172
+ if (!result.success) {
173
+ return errorResponse(reply, 'seed_failed', result.error ?? 'Node rejected the seed', 502);
174
+ }
175
+ return reply.send({ ok: true, data: { peersAdded: result.peersAdded ?? 0 } });
176
+ });
177
+
178
+ // DELETE /grants/:id — terminate the donated node.
179
+ app.delete<{ Params: { id: string } }>('/grants/:id', async (request, reply) => {
180
+ const token = authenticate(request, reply, grants);
181
+ if (!token) return reply;
182
+ if (!requireOwned(donations, request.params.id, token, reply)) return reply;
183
+ await donations.terminate(request.params.id);
184
+ return reply.send({ ok: true });
185
+ });
186
+ }
@@ -6,10 +6,11 @@
6
6
  * `onStateChange` (forwarded by `createLocalUiServer.start`) — the route
7
7
  * handlers don't re-publish.
8
8
  *
9
- * start/restart are real for the admin's authority node (re-spawned from the
10
- * persisted `AuthoritySpawnConfig`). Generic per-member node spawn from saved
11
- * config is out of scope and returns `not_implemented`. Stop works for any
12
- * running node.
9
+ * start/restart are real for the admin's owner node (re-spawned from the
10
+ * persisted `OwnerSpawnConfig`). Generic node lifecycle is owned by the
11
+ * grantee-facing donation surface (`/grants`) — this route no longer spawns
12
+ * generic nodes; start/restart of a known non-owner node points the caller
13
+ * there. Stop works for any running node.
13
14
  */
14
15
 
15
16
  import { existsSync, openSync, readSync, closeSync, statSync } from 'node:fs';
@@ -83,17 +84,17 @@ export function registerNodesRoutes(app: FastifyInstance, opts: NodesRoutesOptio
83
84
  return { ok: true };
84
85
  });
85
86
 
86
- // start / restart: real for the admin's authority node (spawned from the
87
- // persisted AuthoritySpawnConfig). Generic per-member node spawn from saved
88
- // config is out of scope — those ids return a clear not_implemented; unknown
89
- // ids 404.
87
+ // start / restart: real for the admin's owner node (spawned from the
88
+ // persisted OwnerSpawnConfig). Generic node lifecycle now belongs to the
89
+ // donation surface (/grants) — those ids return a clear not_implemented that
90
+ // points there; unknown ids 404.
90
91
  app.post<{ Params: { id: string } }>('/api/nodes/:id/start', async (request, reply) => {
91
92
  const { id } = request.params;
92
- if (orchestrator.isAuthorityNode(id)) {
93
- if (!orchestrator.hasAuthorityConfig()) {
94
- return notImplemented(reply, `start ${id}: authority node has no saved spawn config.`);
93
+ if (orchestrator.isOwnerNode(id)) {
94
+ if (!orchestrator.hasOwnerConfig()) {
95
+ return notImplemented(reply, `start ${id}: owner node has no saved spawn config.`);
95
96
  }
96
- const node = await orchestrator.ensureAuthorityNode();
97
+ const node = await orchestrator.ensureOwnerNode();
97
98
  return { ok: true, data: { node } };
98
99
  }
99
100
  return startRestartFallback(reply, orchestrator, id, 'start');
@@ -101,18 +102,18 @@ export function registerNodesRoutes(app: FastifyInstance, opts: NodesRoutesOptio
101
102
 
102
103
  app.post<{ Params: { id: string } }>('/api/nodes/:id/restart', async (request, reply) => {
103
104
  const { id } = request.params;
104
- if (orchestrator.isAuthorityNode(id)) {
105
- if (!orchestrator.hasAuthorityConfig()) {
106
- return notImplemented(reply, `restart ${id}: authority node has no saved spawn config.`);
105
+ if (orchestrator.isOwnerNode(id)) {
106
+ if (!orchestrator.hasOwnerConfig()) {
107
+ return notImplemented(reply, `restart ${id}: owner node has no saved spawn config.`);
107
108
  }
108
- const node = await orchestrator.restartAuthorityNode();
109
+ const node = await orchestrator.restartOwnerNode();
109
110
  return { ok: true, data: { node } };
110
111
  }
111
112
  return startRestartFallback(reply, orchestrator, id, 'restart');
112
113
  });
113
114
  }
114
115
 
115
- /** 404 for unknown ids; 501 not_implemented for known non-authority nodes. */
116
+ /** 404 for unknown ids; 501 not_implemented for known non-owner nodes. */
116
117
  function startRestartFallback(
117
118
  reply: import('fastify').FastifyReply,
118
119
  orchestrator: HostProcessOrchestrator,
@@ -127,7 +128,7 @@ function startRestartFallback(
127
128
  }
128
129
  return notImplemented(
129
130
  reply,
130
- `${verb} ${id}: only the authority node is start/restart-able; generic node spawn is out of scope.`,
131
+ `${verb} ${id}: only the owner node is start/restart-able here; generic donated-node lifecycle is owned by the donation surface — use POST /grants to provision and DELETE /grants/:id to terminate.`,
131
132
  );
132
133
  }
133
134
 
@@ -21,14 +21,19 @@ import type { NatService } from '../../nat/index.js';
21
21
  import type { UpdateService } from '../../update/index.js';
22
22
  import type { HostSettingsStore } from '../settings-store.js';
23
23
 
24
- const FORBIDDEN_KEYS = new Set<string>(['uiPort', 'libp2pPort', 'dataDir', 'identityPath', 'installId', 'installedAt', 'installerVersion', 'version']);
24
+ const FORBIDDEN_KEYS = new Set<string>(['uiPort', 'libp2pPort', 'dataDir', 'identityPath', 'installId', 'installedAt', 'installerVersion', 'version', 'ownCadre']);
25
25
 
26
26
  const WRITABLE_TOP_KEYS = new Set<string>(['upnpEnabled', 'updates']);
27
27
  const WRITABLE_UPDATES_KEYS = new Set<string>(['autoApply', 'manifestUrl']);
28
28
 
29
29
  export interface SettingsRoutesOptions {
30
30
  settingsStore: HostSettingsStore;
31
- nat: NatService;
31
+ /**
32
+ * Present only when the host runs its own personal cadre. In donor-only mode
33
+ * an `upnpEnabled` write still persists to host.config.json but has no running
34
+ * NatService to propagate into.
35
+ */
36
+ nat?: NatService;
32
37
  update?: UpdateService;
33
38
  }
34
39
 
@@ -58,7 +63,10 @@ export function registerSettingsRoutes(app: FastifyInstance, opts: SettingsRoute
58
63
  // takes effect immediately. NatService persists into nat.json — that's
59
64
  // independent of host.config.json's `upnpEnabled` (which the installer
60
65
  // uses on first run to seed nat.json). Update both for consistency.
61
- await nat.putSettings({ upnpEnabled: body.upnpEnabled as boolean });
66
+ // In donor-only mode there is no NatService — the write still persists.
67
+ if (nat) {
68
+ await nat.putSettings({ upnpEnabled: body.upnpEnabled as boolean });
69
+ }
62
70
  }
63
71
  if ('updates' in body) {
64
72
  const updates = body.updates as { autoApply?: boolean; manifestUrl?: string };