@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.
- package/README.md +296 -101
- package/dist/auth/trust-circle.d.ts +45 -7
- package/dist/auth/trust-circle.js +116 -59
- package/dist/auth/trust-circle.js.map +1 -1
- package/dist/auth/types.d.ts +4 -1
- package/dist/auth/types.js.map +1 -1
- package/dist/bin/host.js +426 -112
- package/dist/bin/host.js.map +1 -1
- package/dist/donation/donation-service.d.ts +136 -0
- package/dist/donation/donation-service.js +306 -0
- package/dist/donation/donation-service.js.map +1 -0
- package/dist/donation/donation-store.d.ts +42 -0
- package/dist/donation/donation-store.js +141 -0
- package/dist/donation/donation-store.js.map +1 -0
- package/dist/donation/grant-service.d.ts +71 -0
- package/dist/donation/grant-service.js +163 -0
- package/dist/donation/grant-service.js.map +1 -0
- package/dist/donation/grant-store.d.ts +38 -0
- package/dist/donation/grant-store.js +135 -0
- package/dist/donation/grant-store.js.map +1 -0
- package/dist/donation/index.d.ts +18 -0
- package/dist/donation/index.js +16 -0
- package/dist/donation/index.js.map +1 -0
- package/dist/donation/types.d.ts +175 -0
- package/dist/donation/types.js +40 -0
- package/dist/donation/types.js.map +1 -0
- package/dist/index.d.ts +12 -6
- package/dist/index.js +8 -3
- package/dist/index.js.map +1 -1
- package/dist/installer/browser.js +16 -17
- package/dist/installer/browser.js.map +1 -1
- package/dist/installer/config.d.ts +51 -0
- package/dist/installer/config.js +14 -1
- package/dist/installer/config.js.map +1 -1
- package/dist/installer/index.d.ts +7 -2
- package/dist/installer/index.js +17 -10
- package/dist/installer/index.js.map +1 -1
- package/dist/installer/service-host/launchd.js +1 -1
- package/dist/installer/service-host/launchd.js.map +1 -1
- package/dist/installer/service-host/systemd.js +1 -1
- package/dist/installer/service-host/systemd.js.map +1 -1
- package/dist/installer/wizard.d.ts +4 -0
- package/dist/installer/wizard.js +5 -1
- package/dist/installer/wizard.js.map +1 -1
- package/dist/nat/index.d.ts +1 -1
- package/dist/nat/nat-service.d.ts +45 -3
- package/dist/nat/nat-service.js +116 -3
- package/dist/nat/nat-service.js.map +1 -1
- package/dist/nat/secrets/index.d.ts +7 -0
- package/dist/nat/secrets/index.js +28 -2
- package/dist/nat/secrets/index.js.map +1 -1
- package/dist/nat/types.d.ts +3 -1
- package/dist/nat/types.js.map +1 -1
- package/dist/orchestrator/host-process-orchestrator.d.ts +68 -1
- package/dist/orchestrator/host-process-orchestrator.js +272 -39
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
- package/dist/orchestrator/index.d.ts +3 -2
- package/dist/orchestrator/index.js +1 -1
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/state-store.d.ts +6 -5
- package/dist/orchestrator/state-store.js +10 -2
- package/dist/orchestrator/state-store.js.map +1 -1
- package/dist/orchestrator/types.d.ts +45 -10
- package/dist/orchestrator/types.js.map +1 -1
- package/dist/owner/index.d.ts +11 -0
- package/dist/owner/index.js +11 -0
- package/dist/owner/index.js.map +1 -0
- package/dist/owner/owner-node-client.d.ts +95 -0
- package/dist/owner/owner-node-client.js +167 -0
- package/dist/owner/owner-node-client.js.map +1 -0
- package/dist/push/index.d.ts +67 -0
- package/dist/push/index.js +101 -0
- package/dist/push/index.js.map +1 -0
- package/dist/server/error-handler.d.ts +17 -0
- package/dist/server/error-handler.js +45 -0
- package/dist/server/error-handler.js.map +1 -1
- package/dist/server/index.d.ts +26 -2
- package/dist/server/index.js +37 -16
- package/dist/server/index.js.map +1 -1
- package/dist/server/routes/grants-admin.d.ts +17 -0
- package/dist/server/routes/grants-admin.js +32 -0
- package/dist/server/routes/grants-admin.js.map +1 -0
- package/dist/server/routes/grants.d.ts +28 -0
- package/dist/server/routes/grants.js +163 -0
- package/dist/server/routes/grants.js.map +1 -0
- package/dist/server/routes/nodes.d.ts +5 -3
- package/dist/server/routes/nodes.js +39 -24
- package/dist/server/routes/nodes.js.map +1 -1
- package/dist/server/routes/settings.d.ts +6 -1
- package/dist/server/routes/settings.js +5 -2
- package/dist/server/routes/settings.js.map +1 -1
- package/dist/server/routes/status.d.ts +8 -4
- package/dist/server/routes/status.js +9 -4
- package/dist/server/routes/status.js.map +1 -1
- package/dist/server/static.d.ts +12 -0
- package/dist/server/static.js +24 -7
- package/dist/server/static.js.map +1 -1
- package/dist/ui/assets/index-BlMXQPSw.js +14 -0
- package/dist/ui/assets/{index-C9uChk2P.js.map → index-BlMXQPSw.js.map} +1 -1
- package/dist/ui/index.html +1 -1
- package/dist/update/apply.d.ts +23 -1
- package/dist/update/apply.js +46 -4
- package/dist/update/apply.js.map +1 -1
- package/dist/update/index.d.ts +14 -3
- package/dist/update/index.js +35 -2
- package/dist/update/index.js.map +1 -1
- package/dist/update/manifest.d.ts +28 -9
- package/dist/update/manifest.js +56 -29
- package/dist/update/manifest.js.map +1 -1
- package/dist/update/release-key.d.ts +18 -7
- package/dist/update/release-key.js +29 -14
- package/dist/update/release-key.js.map +1 -1
- package/dist/update/sign.d.ts +46 -0
- package/dist/update/sign.js +53 -0
- package/dist/update/sign.js.map +1 -0
- package/package.json +9 -4
- package/service/README.md +95 -86
- package/src/__tests__/cli-invite.smoke.test.ts +6 -1
- package/src/__tests__/cli-nat.smoke.test.ts +6 -1
- package/src/__tests__/cli.smoke.test.ts +6 -1
- package/src/__tests__/host-owner.smoke.test.ts +123 -0
- package/src/__tests__/orchestrator-owner.test.ts +198 -0
- package/src/__tests__/orchestrator-pin-keys.test.ts +153 -0
- package/src/__tests__/orchestrator-push.test.ts +145 -0
- package/src/auth/__tests__/trust-circle-integration.test.ts +44 -25
- package/src/auth/__tests__/trust-circle.test.ts +91 -24
- package/src/auth/trust-circle.ts +363 -304
- package/src/auth/types.ts +4 -1
- package/src/bin/host.ts +1258 -905
- package/src/donation/__tests__/donation-service.test.ts +290 -0
- package/src/donation/__tests__/grant-service.test.ts +213 -0
- package/src/donation/__tests__/grant-store.test.ts +113 -0
- package/src/donation/donation-service.ts +392 -0
- package/src/donation/donation-store.ts +164 -0
- package/src/donation/grant-service.ts +200 -0
- package/src/donation/grant-store.ts +152 -0
- package/src/donation/index.ts +49 -0
- package/src/donation/types.ts +220 -0
- package/src/index.ts +66 -3
- package/src/installer/__tests__/config.test.ts +27 -1
- package/src/installer/__tests__/installer.smoke.test.ts +94 -0
- package/src/installer/__tests__/wizard.test.ts +5 -1
- package/src/installer/browser.ts +40 -42
- package/src/installer/config.ts +61 -1
- package/src/installer/index.ts +23 -12
- package/src/installer/service-host/launchd.ts +1 -1
- package/src/installer/service-host/systemd.ts +1 -1
- package/src/installer/wizard.ts +12 -1
- package/src/nat/__tests__/ddns-updater.test.ts +1 -1
- package/src/nat/__tests__/nat-service.test.ts +192 -0
- package/src/nat/__tests__/port-mapper.test.ts +1 -1
- package/src/nat/__tests__/secrets.test.ts +23 -1
- package/src/nat/index.ts +1 -1
- package/src/nat/nat-service.ts +131 -5
- package/src/nat/secrets/index.ts +32 -2
- package/src/nat/types.ts +3 -1
- package/src/orchestrator/host-process-orchestrator.ts +322 -54
- package/src/orchestrator/index.ts +9 -2
- package/src/orchestrator/state-store.ts +21 -3
- package/src/orchestrator/types.ts +48 -2
- package/src/owner/__tests__/owner-node-client.test.ts +194 -0
- package/src/owner/index.ts +12 -0
- package/src/owner/owner-node-client.ts +233 -0
- package/src/push/__tests__/push.test.ts +90 -0
- package/src/push/index.ts +146 -0
- package/src/server/__tests__/error-handler.test.ts +26 -4
- package/src/server/__tests__/grants-admin-route.test.ts +73 -0
- package/src/server/__tests__/grants-route.test.ts +189 -0
- package/src/server/__tests__/nodes-route.test.ts +90 -10
- package/src/server/__tests__/publishers.test.ts +1 -1
- package/src/server/__tests__/server.smoke.test.ts +75 -0
- package/src/server/__tests__/settings-route.test.ts +17 -0
- package/src/server/__tests__/static.test.ts +145 -0
- package/src/server/__tests__/status-route.test.ts +23 -2
- package/src/server/error-handler.ts +47 -0
- package/src/server/index.ts +62 -17
- package/src/server/routes/grants-admin.ts +41 -0
- package/src/server/routes/grants.ts +186 -0
- package/src/server/routes/nodes.ts +49 -26
- package/src/server/routes/settings.ts +11 -3
- package/src/server/routes/status.ts +18 -8
- package/src/server/static.ts +24 -8
- package/src/update/__tests__/apply.test.ts +58 -1
- package/src/update/__tests__/manifest.test.ts +41 -6
- package/src/update/__tests__/release-key.test.ts +42 -0
- package/src/update/__tests__/sign.test.ts +78 -0
- package/src/update/__tests__/update-service.integration.test.ts +44 -9
- package/src/update/apply.ts +46 -4
- package/src/update/index.ts +41 -3
- package/src/update/manifest.ts +214 -172
- package/src/update/release-key.ts +31 -15
- package/src/update/sign.ts +74 -0
- 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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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 } = {};
|
package/src/server/static.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
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&')).toBe('"with&"');
|
|
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,
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
+
});
|