@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,210 @@
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
+ // Element types too, not just the array: `provision`'s shape check trims each
123
+ // entry, so a non-string reaches the caller as an internal `.trim is not a
124
+ // function` rather than as a message naming what is wrong with the request.
125
+ if (!body.ownerKeys.every(key => typeof key === 'string')) {
126
+ return errorResponse(reply, 'invalid_request', 'ownerKeys must be an array of strings', 400);
127
+ }
128
+ if (body.profile !== undefined && body.profile !== 'storage' && body.profile !== 'transaction') {
129
+ return errorResponse(reply, 'invalid_request', 'profile must be "storage" or "transaction"', 400);
130
+ }
131
+
132
+ const donation = await donations.provision({
133
+ grantToken: token,
134
+ partyId: body.partyId,
135
+ bootstrapNodes: body.bootstrapNodes,
136
+ ownerKeys: body.ownerKeys,
137
+ ...(body.profile ? { profile: body.profile } : {}),
138
+ });
139
+ return reply.status(201).send({ ok: true, data: { donation } });
140
+ });
141
+
142
+ // GET /grants — list only the donations this grant authorized.
143
+ app.get('/grants', async (request, reply) => {
144
+ const token = authenticate(request, reply, grants);
145
+ if (!token) return reply;
146
+ return reply.send({ ok: true, data: { donations: donations.list(token) } });
147
+ });
148
+
149
+ // GET /grants/:id — one donation (redacted), owned by this grant.
150
+ app.get<{ Params: { id: string } }>('/grants/:id', async (request, reply) => {
151
+ const token = authenticate(request, reply, grants);
152
+ if (!token) return reply;
153
+ const donation = requireOwned(donations, request.params.id, token, reply);
154
+ if (!donation) return reply;
155
+ return reply.send({ ok: true, data: { donation } });
156
+ });
157
+
158
+ // GET /grants/:id/peer — live { peerId, multiaddrs } for the requester's addDrone.
159
+ app.get<{ Params: { id: string } }>('/grants/:id/peer', async (request, reply) => {
160
+ const token = authenticate(request, reply, grants);
161
+ if (!token) return reply;
162
+ if (!requireOwned(donations, request.params.id, token, reply)) return reply;
163
+ const peer = await donations.getPeer(request.params.id);
164
+ return reply.send({ ok: true, data: peer });
165
+ });
166
+
167
+ // PUT /grants/:id/seed — apply the requester's phone-signed seed.
168
+ app.put<{ Params: { id: string } }>('/grants/:id/seed', async (request, reply) => {
169
+ const token = authenticate(request, reply, grants);
170
+ if (!token) return reply;
171
+ if (!requireOwned(donations, request.params.id, token, reply)) return reply;
172
+
173
+ const body = (request.body ?? {}) as { seed?: unknown };
174
+ if (typeof body.seed !== 'string' || body.seed.length === 0) {
175
+ return errorResponse(reply, 'invalid_request', 'seed is required', 400);
176
+ }
177
+ const result = await donations.applySeed(request.params.id, body.seed);
178
+ // NOTE: exhaustive over today's three outcomes, and nothing forces it to stay
179
+ // that way — an unhandled fourth would fall out of the handler as `undefined`
180
+ // and hang the request rather than fail to compile. If `DonationSeedResult`
181
+ // ever grows a variant, add a `default:` that 500s here.
182
+ switch (result.outcome) {
183
+ case 'rejected':
184
+ return errorResponse(reply, 'seed_failed', result.error ?? 'Node rejected the seed', 502);
185
+ // The loan ended while the seed was in flight. The ending won, so the
186
+ // caller is told the record it seeded is no longer seedable (409) — or is
187
+ // gone entirely (404) — rather than a 200 that implies a live node.
188
+ case 'abandoned':
189
+ return result.status
190
+ ? errorResponse(
191
+ reply,
192
+ 'invalid_state',
193
+ `Donation ${request.params.id} ended (${result.status}) while the seed was in flight`,
194
+ 409,
195
+ )
196
+ : errorResponse(reply, 'not_found', `No such donation: ${request.params.id}`, 404);
197
+ case 'seeded':
198
+ return reply.send({ ok: true, data: { peersAdded: result.peersAdded } });
199
+ }
200
+ });
201
+
202
+ // DELETE /grants/:id — terminate the donated node.
203
+ app.delete<{ Params: { id: string } }>('/grants/:id', async (request, reply) => {
204
+ const token = authenticate(request, reply, grants);
205
+ if (!token) return reply;
206
+ if (!requireOwned(donations, request.params.id, token, reply)) return reply;
207
+ await donations.terminate(request.params.id);
208
+ return reply.send({ ok: true });
209
+ });
210
+ }
@@ -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 };
@@ -22,8 +22,10 @@ const PROCESS_STARTED_AT = process.hrtime.bigint();
22
22
 
23
23
  export interface StatusRouteOptions {
24
24
  orchestrator: HostProcessOrchestrator;
25
- trustCircle: TrustCircleService;
26
- nat: NatService;
25
+ /** Present only when the host runs its own personal cadre (`ownCadre.enabled`). */
26
+ trustCircle?: TrustCircleService;
27
+ /** Present only when the host runs its own personal cadre (`ownCadre.enabled`). */
28
+ nat?: NatService;
27
29
  update?: UpdateService;
28
30
  }
29
31
 
@@ -35,8 +37,10 @@ export interface StatusResponse {
35
37
  status: ManagedNodeInfo['status'];
36
38
  profile: ManagedNodeInfo['profile'];
37
39
  }>;
38
- trustCircle: { members: number; pending: number };
39
- connectivity: NatStatusSnapshot;
40
+ /** Omitted in donor-only mode (no host-own trust circle). */
41
+ trustCircle?: { members: number; pending: number };
42
+ /** Omitted in donor-only mode (no NAT service). */
43
+ connectivity?: NatStatusSnapshot;
40
44
  update?: { available?: string; lastChecked?: string };
41
45
  }
42
46
 
@@ -50,8 +54,6 @@ export function registerStatusRoute(app: FastifyInstance, opts: StatusRouteOptio
50
54
  status: n.status,
51
55
  profile: n.profile,
52
56
  }));
53
- const tc = await opts.trustCircle.list();
54
- const connectivity = opts.nat.getStatus();
55
57
 
56
58
  const response: StatusResponse = {
57
59
  service: {
@@ -60,10 +62,18 @@ export function registerStatusRoute(app: FastifyInstance, opts: StatusRouteOptio
60
62
  uptimeSeconds: secondsSince(PROCESS_STARTED_AT),
61
63
  },
62
64
  nodes,
63
- trustCircle: { members: tc.members.length, pending: tc.pending.length },
64
- connectivity,
65
65
  };
66
66
 
67
+ // Trust-circle + connectivity exist only when the host runs its own
68
+ // personal cadre; donor-only mode omits both.
69
+ if (opts.trustCircle) {
70
+ const tc = await opts.trustCircle.list();
71
+ response.trustCircle = { members: tc.members.length, pending: tc.pending.length };
72
+ }
73
+ if (opts.nat) {
74
+ response.connectivity = opts.nat.getStatus();
75
+ }
76
+
67
77
  if (opts.update) {
68
78
  const state = await opts.update.getState();
69
79
  const update: { available?: string; lastChecked?: string } = {};
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Strand HTTP routes — thin adapters around `StrandHandlers`
3
+ * (`createStrandHandlers(service)`).
4
+ *
5
+ * Mount path: `/api/strands` — a UI-only surface with no CLI contract to
6
+ * preserve. (The `/auth/*` and `/nat/*` mounts sit outside `/api` only because
7
+ * the existing CLI already targets those URLs.) Envelope form therefore follows
8
+ * the other `/api/*` routes: `{ ok: true, data }`.
9
+ *
10
+ * A removal that actually issued a delete publishes `strands-changed` so other
11
+ * open tabs refresh. A call that found no row publishes nothing — nothing changed.
12
+ */
13
+
14
+ import type { FastifyInstance } from 'fastify';
15
+
16
+ import type { StrandHandlers } from '../../strands/types.js';
17
+ import type { EventBus } from '../events/bus.js';
18
+
19
+ export interface StrandRoutesOptions {
20
+ handlers: StrandHandlers;
21
+ events: EventBus;
22
+ }
23
+
24
+ /**
25
+ * The only accepted `confirm` values — the same strict pair the admin channel
26
+ * accepts. Not a general truthiness parser: `yes`, `on` and an empty value all
27
+ * count as NOT confirmed, because a guessy parser here would turn a typo into a
28
+ * destroyed membership key.
29
+ *
30
+ * NOTE: deliberately duplicated from cadre-cli's `admin-server.ts` rather than
31
+ * imported — cadre-host depends on cadre-cli for its bin path only, never its
32
+ * source. Keep the two in step by hand; do not DRY them into an import.
33
+ */
34
+ const CONFIRM_VALUES = new Set(['1', 'true']);
35
+
36
+ export function registerStrandRoutes(app: FastifyInstance, opts: StrandRoutesOptions): void {
37
+ const { handlers, events } = opts;
38
+
39
+ app.get('/api/strands', async () => {
40
+ return { ok: true, data: await handlers.listStrands() };
41
+ });
42
+
43
+ app.delete<{ Params: { id: string }; Querystring: { confirm?: string } }>(
44
+ '/api/strands/:id',
45
+ async (request) => {
46
+ const confirm = typeof request.query.confirm === 'string' && CONFIRM_VALUES.has(request.query.confirm);
47
+ const result = await handlers.removeStrand(request.params.id, { confirm });
48
+ if (result.removed) {
49
+ events.publish({ type: 'strands-changed', kind: 'removed' });
50
+ }
51
+ return { ok: true, data: result };
52
+ },
53
+ );
54
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * StrandService unit tests — a fake node, no HTTP.
3
+ *
4
+ * The two things this layer actually owns: id validation (which must never reach
5
+ * the node) and the translation of the admin channel's single
6
+ * `OwnerNodeUnavailableError` into a vocabulary the HTTP layer can map. The
7
+ * confirmation *policy* is the node's and is not re-tested here — only that the
8
+ * flag is forwarded verbatim.
9
+ */
10
+
11
+ import { describe, it, expect, beforeEach } from 'vitest';
12
+
13
+ import { OwnerNodeUnavailableError } from '../../owner/owner-node-client.js';
14
+ import { StrandService } from '../strand-service.js';
15
+ import { StrandError } from '../types.js';
16
+ import type { CadreNodeLike } from '../strand-service.js';
17
+ import type { StrandListSnapshot, StrandRemovalResult } from '../types.js';
18
+
19
+ interface FakeNode extends CadreNodeLike {
20
+ calls: Array<{ strandId: string; confirm: boolean }>;
21
+ listCalls: number;
22
+ /** Thrown by both methods when set. */
23
+ failWith?: unknown;
24
+ }
25
+
26
+ const SNAPSHOT: StrandListSnapshot = {
27
+ strands: [
28
+ { id: 'open-one', type: 'o', running: true, status: 'active' },
29
+ { id: 'closed-one', type: 'c', running: false, status: null },
30
+ ],
31
+ controlConnections: 2,
32
+ };
33
+
34
+ function fakeNode(): FakeNode {
35
+ const node: FakeNode = {
36
+ calls: [],
37
+ listCalls: 0,
38
+ async listStrands() {
39
+ node.listCalls++;
40
+ if (node.failWith) throw node.failWith;
41
+ return SNAPSHOT;
42
+ },
43
+ async removeStrand(strandId, opts) {
44
+ node.calls.push({ strandId, confirm: opts.confirm });
45
+ if (node.failWith) throw node.failWith;
46
+ const result: StrandRemovalResult = {
47
+ strandId,
48
+ published: true,
49
+ type: 'o',
50
+ removed: true,
51
+ alone: false,
52
+ };
53
+ return result;
54
+ },
55
+ };
56
+ return node;
57
+ }
58
+
59
+ /** Await a call expected to reject, assert it is a StrandError, and hand it back. */
60
+ async function catchStrandError(p: Promise<unknown>): Promise<StrandError> {
61
+ const err = await p.then(() => undefined, (e: unknown) => e);
62
+ expect(err).toBeInstanceOf(StrandError);
63
+ return err as StrandError;
64
+ }
65
+
66
+ let node: FakeNode;
67
+ let service: StrandService;
68
+
69
+ beforeEach(() => {
70
+ node = fakeNode();
71
+ service = new StrandService({ cadreNode: node });
72
+ });
73
+
74
+ describe('StrandService.list', () => {
75
+ it('passes the node snapshot through unchanged', async () => {
76
+ await expect(service.list()).resolves.toEqual(SNAPSHOT);
77
+ expect(node.listCalls).toBe(1);
78
+ });
79
+ });
80
+
81
+ describe('StrandService.remove — id validation', () => {
82
+ it.each([
83
+ ['blank', ''],
84
+ ['whitespace-only', ' '],
85
+ ['tab/newline only', '\t\n'],
86
+ ])('rejects a %s id with invalid_id and never calls the node', async (_label, id) => {
87
+ await expect(service.remove(id, { confirm: false })).rejects.toMatchObject({
88
+ name: 'StrandError',
89
+ code: 'invalid_id',
90
+ });
91
+ expect(node.calls).toEqual([]);
92
+ });
93
+
94
+ it('forwards an id containing "/" rather than refusing it', async () => {
95
+ // The whole chain carries it: Fastify decodes `%2F` into `params.id`,
96
+ // `OwnerNodeClient` re-encodes it, and the admin channel splits on the still
97
+ // -encoded segment and decodes it back. Refusing here would break the only
98
+ // form that works. `strands-route.test.ts` pins the Fastify half.
99
+ await service.remove('ns/strand', { confirm: true });
100
+ expect(node.calls).toEqual([{ strandId: 'ns/strand', confirm: true }]);
101
+ });
102
+
103
+ it('trims a padded id before forwarding it', async () => {
104
+ await service.remove(' spaced ', { confirm: false });
105
+ expect(node.calls).toEqual([{ strandId: 'spaced', confirm: false }]);
106
+ });
107
+ });
108
+
109
+ describe('StrandService.remove — confirmation forwarding', () => {
110
+ it('forwards confirm: false as false', async () => {
111
+ await service.remove('open-one', { confirm: false });
112
+ expect(node.calls).toEqual([{ strandId: 'open-one', confirm: false }]);
113
+ });
114
+
115
+ it('forwards confirm: true as true', async () => {
116
+ await service.remove('closed-one', { confirm: true });
117
+ expect(node.calls).toEqual([{ strandId: 'closed-one', confirm: true }]);
118
+ });
119
+
120
+ it('does not retry a refused removal with confirmation added', async () => {
121
+ node.failWith = new OwnerNodeUnavailableError('needs confirmation', 'confirmation_required');
122
+ await expect(service.remove('closed-one', { confirm: false })).rejects.toBeInstanceOf(StrandError);
123
+ // Exactly one attempt, and it carried the caller's flag.
124
+ expect(node.calls).toEqual([{ strandId: 'closed-one', confirm: false }]);
125
+ });
126
+ });
127
+
128
+ describe('StrandService — error translation', () => {
129
+ const cases: Array<{ nodeCode: string | undefined; code: string; keepsMessage: boolean }> = [
130
+ { nodeCode: 'confirmation_required', code: 'confirmation_required', keepsMessage: true },
131
+ { nodeCode: 'bad_request', code: 'invalid_id', keepsMessage: true },
132
+ { nodeCode: 'not_ready', code: 'node_unavailable', keepsMessage: false },
133
+ { nodeCode: 'not_authorized', code: 'node_unavailable', keepsMessage: false },
134
+ { nodeCode: undefined, code: 'node_unavailable', keepsMessage: false },
135
+ { nodeCode: 'internal', code: 'internal', keepsMessage: true },
136
+ { nodeCode: 'something_new', code: 'internal', keepsMessage: true },
137
+ ];
138
+
139
+ for (const { nodeCode, code, keepsMessage } of cases) {
140
+ it(`maps nodeCode ${nodeCode ?? '(absent)'} → ${code}`, async () => {
141
+ const message = `node said ${nodeCode ?? 'nothing'}`;
142
+ node.failWith = new OwnerNodeUnavailableError(message, nodeCode);
143
+ const err = await catchStrandError(service.remove('x', { confirm: false }));
144
+ expect(err.code).toBe(code);
145
+ if (keepsMessage) {
146
+ expect(err.message).toBe(message);
147
+ } else {
148
+ // Wrapped, but the node's words survive inside.
149
+ expect(err.message).toContain(message);
150
+ }
151
+ });
152
+ }
153
+
154
+ it('translates a transport failure (no nodeCode) on list() too', async () => {
155
+ node.failWith = new OwnerNodeUnavailableError('Owner node unreachable: ECONNREFUSED');
156
+ const err = await catchStrandError(service.list());
157
+ expect(err.code).toBe('node_unavailable');
158
+ });
159
+
160
+ it('rethrows a non-owner-node error unchanged rather than dressing it as a strand error', async () => {
161
+ const boom = new TypeError('something else broke');
162
+ node.failWith = boom;
163
+ await expect(service.list()).rejects.toBe(boom);
164
+ });
165
+ });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Strand management for cadre-host. See ./strand-service.ts and ./types.ts.
3
+ */
4
+
5
+ export { StrandService, createStrandHandlers } from './strand-service.js';
6
+ export type { StrandServiceOptions, CadreNodeLike } from './strand-service.js';
7
+ export { StrandError } from './types.js';
8
+ export type {
9
+ StrandSummary,
10
+ StrandListSnapshot,
11
+ StrandRemovalResult,
12
+ StrandHandlers,
13
+ StrandErrorCode,
14
+ } from './types.js';