@serfab/cadre-host 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/README.md +296 -101
  2. package/dist/auth/trust-circle.d.ts +45 -7
  3. package/dist/auth/trust-circle.js +116 -59
  4. package/dist/auth/trust-circle.js.map +1 -1
  5. package/dist/auth/types.d.ts +4 -1
  6. package/dist/auth/types.js.map +1 -1
  7. package/dist/bin/host.js +426 -112
  8. package/dist/bin/host.js.map +1 -1
  9. package/dist/donation/donation-service.d.ts +136 -0
  10. package/dist/donation/donation-service.js +306 -0
  11. package/dist/donation/donation-service.js.map +1 -0
  12. package/dist/donation/donation-store.d.ts +42 -0
  13. package/dist/donation/donation-store.js +141 -0
  14. package/dist/donation/donation-store.js.map +1 -0
  15. package/dist/donation/grant-service.d.ts +71 -0
  16. package/dist/donation/grant-service.js +163 -0
  17. package/dist/donation/grant-service.js.map +1 -0
  18. package/dist/donation/grant-store.d.ts +38 -0
  19. package/dist/donation/grant-store.js +135 -0
  20. package/dist/donation/grant-store.js.map +1 -0
  21. package/dist/donation/index.d.ts +18 -0
  22. package/dist/donation/index.js +16 -0
  23. package/dist/donation/index.js.map +1 -0
  24. package/dist/donation/types.d.ts +175 -0
  25. package/dist/donation/types.js +40 -0
  26. package/dist/donation/types.js.map +1 -0
  27. package/dist/index.d.ts +12 -6
  28. package/dist/index.js +8 -3
  29. package/dist/index.js.map +1 -1
  30. package/dist/installer/browser.js +16 -17
  31. package/dist/installer/browser.js.map +1 -1
  32. package/dist/installer/config.d.ts +51 -0
  33. package/dist/installer/config.js +14 -1
  34. package/dist/installer/config.js.map +1 -1
  35. package/dist/installer/index.d.ts +7 -2
  36. package/dist/installer/index.js +17 -10
  37. package/dist/installer/index.js.map +1 -1
  38. package/dist/installer/service-host/launchd.js +1 -1
  39. package/dist/installer/service-host/launchd.js.map +1 -1
  40. package/dist/installer/service-host/systemd.js +1 -1
  41. package/dist/installer/service-host/systemd.js.map +1 -1
  42. package/dist/installer/wizard.d.ts +4 -0
  43. package/dist/installer/wizard.js +5 -1
  44. package/dist/installer/wizard.js.map +1 -1
  45. package/dist/nat/index.d.ts +1 -1
  46. package/dist/nat/nat-service.d.ts +45 -3
  47. package/dist/nat/nat-service.js +116 -3
  48. package/dist/nat/nat-service.js.map +1 -1
  49. package/dist/nat/secrets/index.d.ts +7 -0
  50. package/dist/nat/secrets/index.js +28 -2
  51. package/dist/nat/secrets/index.js.map +1 -1
  52. package/dist/nat/types.d.ts +3 -1
  53. package/dist/nat/types.js.map +1 -1
  54. package/dist/orchestrator/host-process-orchestrator.d.ts +68 -1
  55. package/dist/orchestrator/host-process-orchestrator.js +272 -39
  56. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  57. package/dist/orchestrator/index.d.ts +3 -2
  58. package/dist/orchestrator/index.js +1 -1
  59. package/dist/orchestrator/index.js.map +1 -1
  60. package/dist/orchestrator/state-store.d.ts +6 -5
  61. package/dist/orchestrator/state-store.js +10 -2
  62. package/dist/orchestrator/state-store.js.map +1 -1
  63. package/dist/orchestrator/types.d.ts +45 -10
  64. package/dist/orchestrator/types.js.map +1 -1
  65. package/dist/owner/index.d.ts +11 -0
  66. package/dist/owner/index.js +11 -0
  67. package/dist/owner/index.js.map +1 -0
  68. package/dist/owner/owner-node-client.d.ts +95 -0
  69. package/dist/owner/owner-node-client.js +167 -0
  70. package/dist/owner/owner-node-client.js.map +1 -0
  71. package/dist/push/index.d.ts +67 -0
  72. package/dist/push/index.js +101 -0
  73. package/dist/push/index.js.map +1 -0
  74. package/dist/server/error-handler.d.ts +17 -0
  75. package/dist/server/error-handler.js +45 -0
  76. package/dist/server/error-handler.js.map +1 -1
  77. package/dist/server/index.d.ts +26 -2
  78. package/dist/server/index.js +37 -16
  79. package/dist/server/index.js.map +1 -1
  80. package/dist/server/routes/grants-admin.d.ts +17 -0
  81. package/dist/server/routes/grants-admin.js +32 -0
  82. package/dist/server/routes/grants-admin.js.map +1 -0
  83. package/dist/server/routes/grants.d.ts +28 -0
  84. package/dist/server/routes/grants.js +163 -0
  85. package/dist/server/routes/grants.js.map +1 -0
  86. package/dist/server/routes/nodes.d.ts +5 -3
  87. package/dist/server/routes/nodes.js +39 -24
  88. package/dist/server/routes/nodes.js.map +1 -1
  89. package/dist/server/routes/settings.d.ts +6 -1
  90. package/dist/server/routes/settings.js +5 -2
  91. package/dist/server/routes/settings.js.map +1 -1
  92. package/dist/server/routes/status.d.ts +8 -4
  93. package/dist/server/routes/status.js +9 -4
  94. package/dist/server/routes/status.js.map +1 -1
  95. package/dist/server/static.d.ts +12 -0
  96. package/dist/server/static.js +24 -7
  97. package/dist/server/static.js.map +1 -1
  98. package/dist/ui/assets/index-BlMXQPSw.js +14 -0
  99. package/dist/ui/assets/{index-C9uChk2P.js.map → index-BlMXQPSw.js.map} +1 -1
  100. package/dist/ui/index.html +1 -1
  101. package/dist/update/apply.d.ts +23 -1
  102. package/dist/update/apply.js +46 -4
  103. package/dist/update/apply.js.map +1 -1
  104. package/dist/update/index.d.ts +14 -3
  105. package/dist/update/index.js +35 -2
  106. package/dist/update/index.js.map +1 -1
  107. package/dist/update/manifest.d.ts +28 -9
  108. package/dist/update/manifest.js +56 -29
  109. package/dist/update/manifest.js.map +1 -1
  110. package/dist/update/release-key.d.ts +18 -7
  111. package/dist/update/release-key.js +29 -14
  112. package/dist/update/release-key.js.map +1 -1
  113. package/dist/update/sign.d.ts +46 -0
  114. package/dist/update/sign.js +53 -0
  115. package/dist/update/sign.js.map +1 -0
  116. package/package.json +9 -4
  117. package/service/README.md +95 -86
  118. package/src/__tests__/cli-invite.smoke.test.ts +6 -1
  119. package/src/__tests__/cli-nat.smoke.test.ts +6 -1
  120. package/src/__tests__/cli.smoke.test.ts +6 -1
  121. package/src/__tests__/host-owner.smoke.test.ts +123 -0
  122. package/src/__tests__/orchestrator-owner.test.ts +198 -0
  123. package/src/__tests__/orchestrator-pin-keys.test.ts +153 -0
  124. package/src/__tests__/orchestrator-push.test.ts +145 -0
  125. package/src/auth/__tests__/trust-circle-integration.test.ts +44 -25
  126. package/src/auth/__tests__/trust-circle.test.ts +91 -24
  127. package/src/auth/trust-circle.ts +363 -304
  128. package/src/auth/types.ts +4 -1
  129. package/src/bin/host.ts +1258 -905
  130. package/src/donation/__tests__/donation-service.test.ts +290 -0
  131. package/src/donation/__tests__/grant-service.test.ts +213 -0
  132. package/src/donation/__tests__/grant-store.test.ts +113 -0
  133. package/src/donation/donation-service.ts +392 -0
  134. package/src/donation/donation-store.ts +164 -0
  135. package/src/donation/grant-service.ts +200 -0
  136. package/src/donation/grant-store.ts +152 -0
  137. package/src/donation/index.ts +49 -0
  138. package/src/donation/types.ts +220 -0
  139. package/src/index.ts +66 -3
  140. package/src/installer/__tests__/config.test.ts +27 -1
  141. package/src/installer/__tests__/installer.smoke.test.ts +94 -0
  142. package/src/installer/__tests__/wizard.test.ts +5 -1
  143. package/src/installer/browser.ts +40 -42
  144. package/src/installer/config.ts +61 -1
  145. package/src/installer/index.ts +23 -12
  146. package/src/installer/service-host/launchd.ts +1 -1
  147. package/src/installer/service-host/systemd.ts +1 -1
  148. package/src/installer/wizard.ts +12 -1
  149. package/src/nat/__tests__/ddns-updater.test.ts +1 -1
  150. package/src/nat/__tests__/nat-service.test.ts +192 -0
  151. package/src/nat/__tests__/port-mapper.test.ts +1 -1
  152. package/src/nat/__tests__/secrets.test.ts +23 -1
  153. package/src/nat/index.ts +1 -1
  154. package/src/nat/nat-service.ts +131 -5
  155. package/src/nat/secrets/index.ts +32 -2
  156. package/src/nat/types.ts +3 -1
  157. package/src/orchestrator/host-process-orchestrator.ts +322 -54
  158. package/src/orchestrator/index.ts +9 -2
  159. package/src/orchestrator/state-store.ts +21 -3
  160. package/src/orchestrator/types.ts +48 -2
  161. package/src/owner/__tests__/owner-node-client.test.ts +194 -0
  162. package/src/owner/index.ts +12 -0
  163. package/src/owner/owner-node-client.ts +233 -0
  164. package/src/push/__tests__/push.test.ts +90 -0
  165. package/src/push/index.ts +146 -0
  166. package/src/server/__tests__/error-handler.test.ts +26 -4
  167. package/src/server/__tests__/grants-admin-route.test.ts +73 -0
  168. package/src/server/__tests__/grants-route.test.ts +189 -0
  169. package/src/server/__tests__/nodes-route.test.ts +90 -10
  170. package/src/server/__tests__/publishers.test.ts +1 -1
  171. package/src/server/__tests__/server.smoke.test.ts +75 -0
  172. package/src/server/__tests__/settings-route.test.ts +17 -0
  173. package/src/server/__tests__/static.test.ts +145 -0
  174. package/src/server/__tests__/status-route.test.ts +23 -2
  175. package/src/server/error-handler.ts +47 -0
  176. package/src/server/index.ts +62 -17
  177. package/src/server/routes/grants-admin.ts +41 -0
  178. package/src/server/routes/grants.ts +186 -0
  179. package/src/server/routes/nodes.ts +49 -26
  180. package/src/server/routes/settings.ts +11 -3
  181. package/src/server/routes/status.ts +18 -8
  182. package/src/server/static.ts +24 -8
  183. package/src/update/__tests__/apply.test.ts +58 -1
  184. package/src/update/__tests__/manifest.test.ts +41 -6
  185. package/src/update/__tests__/release-key.test.ts +42 -0
  186. package/src/update/__tests__/sign.test.ts +78 -0
  187. package/src/update/__tests__/update-service.integration.test.ts +44 -9
  188. package/src/update/apply.ts +46 -4
  189. package/src/update/index.ts +41 -3
  190. package/src/update/manifest.ts +214 -172
  191. package/src/update/release-key.ts +31 -15
  192. package/src/update/sign.ts +74 -0
  193. package/dist/ui/assets/index-C9uChk2P.js +0 -14
@@ -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,9 +6,11 @@
6
6
  * `onStateChange` (forwarded by `createLocalUiServer.start`) — the route
7
7
  * handlers don't re-publish.
8
8
  *
9
- * v1 limits: cadre-host doesn't yet auto-spawn nodes (the trust-circle →
10
- * node mapping ticket is separate). Until it lands, `listNodes()` is
11
- * usually empty and start/restart are stubs. Stop on a running node works.
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.
12
14
  */
13
15
 
14
16
  import { existsSync, openSync, readSync, closeSync, statSync } from 'node:fs';
@@ -82,35 +84,56 @@ export function registerNodesRoutes(app: FastifyInstance, opts: NodesRoutesOptio
82
84
  return { ok: true };
83
85
  });
84
86
 
85
- // start / restart: the orchestrator's createContainer requires a full
86
- // OrchestratorCreateRequest (partyId, bootstrapNodes, profile, ...).
87
- // cadre-host doesn't yet own a "spawn this node from saved config" path
88
- // — that lives in the trust-circle-driven node spawner planned for a
89
- // follow-up ticket. Until then these endpoints stub out cleanly so the
90
- // SPA gets a predictable error rather than a route-not-found 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.
91
91
  app.post<{ Params: { id: string } }>('/api/nodes/:id/start', async (request, reply) => {
92
- return reply.code(501).send({
93
- ok: false,
94
- error: {
95
- code: 'not_implemented',
96
- message:
97
- `start ${request.params.id}: cadre-host v1 has no auto-spawn path. ` +
98
- `Restart the cadre-host service to bring up persisted nodes.`,
99
- },
100
- });
92
+ const { id } = request.params;
93
+ if (orchestrator.isOwnerNode(id)) {
94
+ if (!orchestrator.hasOwnerConfig()) {
95
+ return notImplemented(reply, `start ${id}: owner node has no saved spawn config.`);
96
+ }
97
+ const node = await orchestrator.ensureOwnerNode();
98
+ return { ok: true, data: { node } };
99
+ }
100
+ return startRestartFallback(reply, orchestrator, id, 'start');
101
101
  });
102
102
 
103
103
  app.post<{ Params: { id: string } }>('/api/nodes/:id/restart', async (request, reply) => {
104
- return reply.code(501).send({
104
+ const { id } = request.params;
105
+ if (orchestrator.isOwnerNode(id)) {
106
+ if (!orchestrator.hasOwnerConfig()) {
107
+ return notImplemented(reply, `restart ${id}: owner node has no saved spawn config.`);
108
+ }
109
+ const node = await orchestrator.restartOwnerNode();
110
+ return { ok: true, data: { node } };
111
+ }
112
+ return startRestartFallback(reply, orchestrator, id, 'restart');
113
+ });
114
+ }
115
+
116
+ /** 404 for unknown ids; 501 not_implemented for known non-owner nodes. */
117
+ function startRestartFallback(
118
+ reply: import('fastify').FastifyReply,
119
+ orchestrator: HostProcessOrchestrator,
120
+ id: string,
121
+ verb: string,
122
+ ) {
123
+ if (!orchestrator.getNode(id)) {
124
+ return reply.code(404).send({
105
125
  ok: false,
106
- error: {
107
- code: 'not_implemented',
108
- message:
109
- `restart ${request.params.id}: cadre-host v1 has no auto-spawn path. ` +
110
- `Restart the cadre-host service to bring up persisted nodes.`,
111
- },
126
+ error: { code: 'not_found', message: `Unknown node: ${id}` },
112
127
  });
113
- });
128
+ }
129
+ return notImplemented(
130
+ reply,
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.`,
132
+ );
133
+ }
134
+
135
+ function notImplemented(reply: import('fastify').FastifyReply, message: string) {
136
+ return reply.code(501).send({ ok: false, error: { code: 'not_implemented', message } });
114
137
  }
115
138
 
116
139
  /**
@@ -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 } = {};
@@ -13,13 +13,10 @@
13
13
  */
14
14
 
15
15
  import { readFileSync, statSync, existsSync } from 'node:fs';
16
- import { dirname, extname, join, normalize, resolve } from 'node:path';
16
+ import { dirname, extname, isAbsolute, join, normalize, relative, resolve, sep } from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
18
18
 
19
19
  import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
20
- import debug from 'debug';
21
-
22
- const log = debug('cadre:host:static');
23
20
 
24
21
  const MIME_TYPES: Record<string, string> = {
25
22
  '.html': 'text/html; charset=utf-8',
@@ -118,12 +115,31 @@ function serveStatic(rootDir: string, request: FastifyRequest, reply: FastifyRep
118
115
  });
119
116
  }
120
117
 
121
- function resolveSafe(rootDir: string, urlPath: string): string | null {
118
+ /**
119
+ * Resolve a request path to an absolute file path inside `rootDir`, or `null`
120
+ * if it would escape the root.
121
+ *
122
+ * Exported for direct unit testing. The containment check uses `path.relative`
123
+ * rather than a bare `startsWith` prefix test: the latter is the classic
124
+ * prefix-bypass anti-pattern, accepting sibling directories whose name shares
125
+ * `rootDir`'s basename as a prefix (e.g. `dist/ui-private` vs `dist/ui`).
126
+ * `relative` respects the path-separator boundary on both POSIX and Windows,
127
+ * and surfaces cross-drive escapes on Windows as an absolute result.
128
+ */
129
+ export function resolveSafe(rootDir: string, urlPath: string): string | null {
122
130
  const cleaned = decodeSafe(urlPath);
123
131
  if (cleaned === null) return null;
124
- const target = normalize(join(rootDir, cleaned === '/' ? '' : cleaned));
125
- // Disallow path traversal above rootDir.
126
- if (!target.startsWith(normalize(rootDir))) return null;
132
+ const root = normalize(rootDir);
133
+ const target = normalize(join(root, cleaned === '/' ? '' : cleaned));
134
+ // Containment: target must be rootDir itself or strictly beneath it.
135
+ // `relative(root, target)` is '' for root itself (accepted) and a descendant
136
+ // path with no leading '..' for in-root assets. Reject when it climbs out
137
+ // (exactly '..', or '..' + separator) or — cross-drive on Windows — is
138
+ // absolute. Guarding on '..' + separator rather than a bare '..' prefix avoids
139
+ // falsely rejecting legitimate in-root names that merely *begin* with '..'
140
+ // (e.g. a '..foo' asset), which would re-introduce a looser prefix check.
141
+ const rel = relative(root, target);
142
+ if (rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel)) return null;
127
143
  return target;
128
144
  }
129
145
 
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
 
3
- import { applyUpdate, type NpmExecutor, type NpmResult } from '../apply.js';
3
+ import { applyUpdate, buildNpmSpawnSpec, quoteWindowsArg, type NpmExecutor, type NpmResult } from '../apply.js';
4
4
  import { UpdateErrorException } from '../types.js';
5
5
 
6
6
  function recordingExecutor(responses: NpmResult[]): { exec: NpmExecutor; calls: string[][] } {
@@ -99,3 +99,60 @@ describe('applyUpdate', () => {
99
99
  ]);
100
100
  });
101
101
  });
102
+
103
+ describe('quoteWindowsArg', () => {
104
+ it('leaves safe args untouched', () => {
105
+ expect(quoteWindowsArg('install')).toBe('install');
106
+ expect(quoteWindowsArg('-g')).toBe('-g');
107
+ expect(quoteWindowsArg('@serfab/cadre-host@0.7.0')).toBe('@serfab/cadre-host@0.7.0');
108
+ });
109
+
110
+ it('quotes args containing whitespace or shell metachars', () => {
111
+ expect(quoteWindowsArg('has space')).toBe('"has space"');
112
+ expect(quoteWindowsArg('with&amp')).toBe('"with&amp"');
113
+ expect(quoteWindowsArg('pipe|me')).toBe('"pipe|me"');
114
+ });
115
+
116
+ it('escapes embedded quotes and trailing backslashes', () => {
117
+ expect(quoteWindowsArg('with"quote')).toBe('"with\\"quote"');
118
+ expect(quoteWindowsArg('trailing\\')).toBe('trailing\\');
119
+ expect(quoteWindowsArg('trail ing\\')).toBe('"trail ing\\\\"');
120
+ });
121
+
122
+ it('rejects control characters', () => {
123
+ expect(() => quoteWindowsArg('bad\nnewline')).toThrow(/control character/);
124
+ expect(() => quoteWindowsArg('bad\x00null')).toThrow(/control character/);
125
+ });
126
+ });
127
+
128
+ describe('buildNpmSpawnSpec', () => {
129
+ it('builds a single-string command with empty args on Windows', () => {
130
+ const original = process.platform;
131
+ Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
132
+ try {
133
+ const spec = buildNpmSpawnSpec(['install', '-g', '@serfab/cadre-host@0.7.0']);
134
+ expect(spec).toEqual({
135
+ command: 'npm install -g @serfab/cadre-host@0.7.0',
136
+ args: [],
137
+ shell: true,
138
+ });
139
+ } finally {
140
+ Object.defineProperty(process, 'platform', { value: original, configurable: true });
141
+ }
142
+ });
143
+
144
+ it('passes args through verbatim on posix (no shell)', () => {
145
+ const original = process.platform;
146
+ Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
147
+ try {
148
+ const spec = buildNpmSpawnSpec(['install', '-g', '@serfab/cadre-host@0.7.0']);
149
+ expect(spec).toEqual({
150
+ command: 'npm',
151
+ args: ['install', '-g', '@serfab/cadre-host@0.7.0'],
152
+ shell: false,
153
+ });
154
+ } finally {
155
+ Object.defineProperty(process, 'platform', { value: original, configurable: true });
156
+ }
157
+ });
158
+ });
@@ -1,7 +1,7 @@
1
1
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
2
  import { generateKeyPairSync, type KeyObject } from 'node:crypto';
3
3
 
4
- import { canonicalJson, signManifestForTesting, verifyManifest } from '../manifest.js';
4
+ import { canonicalJson, signManifest, verifyManifest } from '../manifest.js';
5
5
  import type { UpdateManifest } from '../types.js';
6
6
 
7
7
  /** Generate a fresh Ed25519 keypair and surface the raw 32-byte public key. */
@@ -47,19 +47,19 @@ describe('verifyManifest', () => {
47
47
  });
48
48
 
49
49
  it('round-trips: sign + verify', () => {
50
- const envelope = signManifestForTesting(sampleManifest, kp.privateKey);
50
+ const envelope = signManifest(sampleManifest, kp.privateKey);
51
51
  const out = verifyManifest(envelope);
52
52
  expect(out).toEqual(sampleManifest);
53
53
  });
54
54
 
55
55
  it('rejects a mutated payload (signature_invalid)', () => {
56
- const envelope = signManifestForTesting(sampleManifest, kp.privateKey);
56
+ const envelope = signManifest(sampleManifest, kp.privateKey);
57
57
  const tampered = { ...envelope, manifest: { ...envelope.manifest, version: '99.0.0' } };
58
58
  expect(() => verifyManifest(tampered)).toThrow(/signature/i);
59
59
  });
60
60
 
61
61
  it('rejects a mutated signature (signature_invalid)', () => {
62
- const envelope = signManifestForTesting(sampleManifest, kp.privateKey);
62
+ const envelope = signManifest(sampleManifest, kp.privateKey);
63
63
  const sigBytes = Buffer.from(envelope.sig.slice('ed25519:'.length), 'base64');
64
64
  sigBytes[0] ^= 0xff;
65
65
  const tampered = { ...envelope, sig: `ed25519:${sigBytes.toString('base64')}` };
@@ -67,7 +67,7 @@ describe('verifyManifest', () => {
67
67
  });
68
68
 
69
69
  it('rejects an envelope signed by a different key (signature_invalid)', () => {
70
- const envelope = signManifestForTesting(sampleManifest, kp.privateKey);
70
+ const envelope = signManifest(sampleManifest, kp.privateKey);
71
71
  // Swap the env-configured public key to a different fresh one.
72
72
  const other = freshKeypair();
73
73
  process.env.CADRE_HOST_UPDATE_DEV_KEY = other.publicRawB64;
@@ -86,8 +86,43 @@ describe('verifyManifest', () => {
86
86
  });
87
87
 
88
88
  it('rejects unsupported signature schemes', () => {
89
- const envelope = signManifestForTesting(sampleManifest, kp.privateKey);
89
+ const envelope = signManifest(sampleManifest, kp.privateKey);
90
90
  const tampered = { ...envelope, sig: envelope.sig.replace('ed25519:', 'rsa:') };
91
91
  expect(() => verifyManifest(tampered)).toThrow(/unsupported signature scheme/);
92
92
  });
93
+
94
+ it('rejects non-semver manifest.version (manifest_invalid)', () => {
95
+ const envelope = signManifest(
96
+ { ...sampleManifest, version: 'not-a-semver' },
97
+ kp.privateKey,
98
+ );
99
+ expect(() => verifyManifest(envelope)).toThrow(/not a valid semver/i);
100
+ });
101
+
102
+ it('rejects non-semver minPreviousVersion (manifest_invalid)', () => {
103
+ const envelope = signManifest(
104
+ { ...sampleManifest, minPreviousVersion: '0.6' },
105
+ kp.privateKey,
106
+ );
107
+ expect(() => verifyManifest(envelope)).toThrow(/minPreviousVersion.*not a valid semver/i);
108
+ });
109
+
110
+ it('rejects publishedAt that is not a round-trippable ISO-8601 string', () => {
111
+ const envelope = signManifest(
112
+ { ...sampleManifest, publishedAt: 'sometime last week' },
113
+ kp.privateKey,
114
+ );
115
+ expect(() => verifyManifest(envelope)).toThrow(/publishedAt.*ISO-8601/i);
116
+ });
117
+
118
+ it('rejects an npm package name that fails the naming regex', () => {
119
+ const envelope = signManifest(
120
+ {
121
+ ...sampleManifest,
122
+ channels: { npm: { package: 'BAD CHARS!', tag: 'latest' } },
123
+ },
124
+ kp.privateKey,
125
+ );
126
+ expect(() => verifyManifest(envelope)).toThrow(/not a valid npm package name/);
127
+ });
93
128
  });
@@ -0,0 +1,42 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { generateKeyPairSync } from 'node:crypto';
3
+
4
+ import { isPlaceholderReleaseKey } from '../release-key.js';
5
+
6
+ function freshPublicRawB64(): string {
7
+ const { publicKey } = generateKeyPairSync('ed25519');
8
+ const raw = publicKey.export({ format: 'der', type: 'spki' }).subarray(12);
9
+ return Buffer.from(raw).toString('base64');
10
+ }
11
+
12
+ describe('isPlaceholderReleaseKey', () => {
13
+ afterEach(() => { delete process.env.CADRE_HOST_UPDATE_DEV_KEY; });
14
+
15
+ it('is true for an explicit all-zeros placeholder key', () => {
16
+ const allZeros = Buffer.alloc(32).toString('base64');
17
+ expect(isPlaceholderReleaseKey({ CADRE_HOST_UPDATE_DEV_KEY: allZeros })).toBe(true);
18
+ });
19
+
20
+ it('is false for the shipped embedded default (real key, no override)', () => {
21
+ delete process.env.CADRE_HOST_UPDATE_DEV_KEY;
22
+ expect(isPlaceholderReleaseKey()).toBe(false);
23
+ });
24
+
25
+ it('is false when a real key overrides via CADRE_HOST_UPDATE_DEV_KEY', () => {
26
+ process.env.CADRE_HOST_UPDATE_DEV_KEY = freshPublicRawB64();
27
+ expect(isPlaceholderReleaseKey()).toBe(false);
28
+ });
29
+
30
+ it('honors an explicit env argument over process.env', () => {
31
+ expect(isPlaceholderReleaseKey({ CADRE_HOST_UPDATE_DEV_KEY: freshPublicRawB64() })).toBe(false);
32
+ // no override in the explicit arg -> falls through to the real embedded default
33
+ expect(isPlaceholderReleaseKey({})).toBe(false);
34
+ });
35
+
36
+ it('is false for a key that does not decode to 32 bytes', () => {
37
+ // Not the all-zeros placeholder even though it is a degenerate key: the
38
+ // 32-byte length gate short-circuits before the all-zeros check.
39
+ const shortZeros = Buffer.alloc(16).toString('base64');
40
+ expect(isPlaceholderReleaseKey({ CADRE_HOST_UPDATE_DEV_KEY: shortZeros })).toBe(false);
41
+ });
42
+ });