@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
|
@@ -9,6 +9,7 @@ import type { HostProcessOrchestrator } from '../../orchestrator/index.js';
|
|
|
9
9
|
import type { TrustCircleService } from '../../auth/index.js';
|
|
10
10
|
import type { NatService } from '../../nat/index.js';
|
|
11
11
|
import type { NatStatusSnapshot } from '../../nat/types.js';
|
|
12
|
+
import { GrantService, GrantStore } from '../../donation/index.js';
|
|
12
13
|
|
|
13
14
|
function fakeOrchestrator(): HostProcessOrchestrator {
|
|
14
15
|
return {
|
|
@@ -176,3 +177,77 @@ describe('createLocalUiServer smoke', () => {
|
|
|
176
177
|
expect(body.error.code).toBe('not_found');
|
|
177
178
|
});
|
|
178
179
|
});
|
|
180
|
+
|
|
181
|
+
// Donor-only mode: the common case (ownCadre.enabled=false). No owner node, so
|
|
182
|
+
// no trustCircle / NAT are wired. The donor surface (/grants-admin) is up, and
|
|
183
|
+
// the founder-only surfaces (/auth, /nat) 404 to keep the surface honest.
|
|
184
|
+
describe('createLocalUiServer smoke — donor-only (no owner node)', () => {
|
|
185
|
+
let dataDir: string;
|
|
186
|
+
let server: ReturnType<typeof createLocalUiServer>;
|
|
187
|
+
let baseUrl: string;
|
|
188
|
+
|
|
189
|
+
beforeEach(async () => {
|
|
190
|
+
dataDir = mkdtempSync(join(tmpdir(), 'cadre-host-donor-'));
|
|
191
|
+
writeConfig(dataDir);
|
|
192
|
+
server = createLocalUiServer({
|
|
193
|
+
uiPort: 8765,
|
|
194
|
+
dataDir,
|
|
195
|
+
orchestrator: fakeOrchestrator(),
|
|
196
|
+
grants: new GrantService({ store: new GrantStore(dataDir) }),
|
|
197
|
+
forcePort: 0,
|
|
198
|
+
});
|
|
199
|
+
const { url } = await server.start();
|
|
200
|
+
baseUrl = url;
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
afterEach(async () => {
|
|
204
|
+
await server.stop();
|
|
205
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('serves /api/status with no trustCircle / connectivity and no nodes', async () => {
|
|
209
|
+
const res = await fetch(`${baseUrl}/api/status`);
|
|
210
|
+
expect(res.status).toBe(200);
|
|
211
|
+
const body = await res.json() as {
|
|
212
|
+
service: { name: string };
|
|
213
|
+
nodes: unknown[];
|
|
214
|
+
trustCircle?: unknown;
|
|
215
|
+
connectivity?: unknown;
|
|
216
|
+
};
|
|
217
|
+
expect(body.service.name).toBe('cadre-host');
|
|
218
|
+
expect(body.nodes).toEqual([]);
|
|
219
|
+
expect(body.trustCircle).toBeUndefined();
|
|
220
|
+
expect(body.connectivity).toBeUndefined();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('serves the donor surface: POST /grants-admin issues a grant', async () => {
|
|
224
|
+
const res = await fetch(`${baseUrl}/grants-admin`, {
|
|
225
|
+
method: 'POST',
|
|
226
|
+
headers: { 'content-type': 'application/json' },
|
|
227
|
+
body: JSON.stringify({ label: "Alice's cadre", maxNodes: 2 }),
|
|
228
|
+
});
|
|
229
|
+
expect(res.status).toBe(200);
|
|
230
|
+
const body = await res.json() as { grant: { token: string; maxNodes: number } };
|
|
231
|
+
expect(body.grant.token).toBeTruthy();
|
|
232
|
+
expect(body.grant.maxNodes).toBe(2);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('404s the founder-only NAT surface (GET /nat/status)', async () => {
|
|
236
|
+
const res = await fetch(`${baseUrl}/nat/status`);
|
|
237
|
+
expect(res.status).toBe(404);
|
|
238
|
+
const body = await res.json() as { error: { code: string } };
|
|
239
|
+
expect(body.error.code).toBe('not_found');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('404s the founder-only trust-circle surface (/auth/*)', async () => {
|
|
243
|
+
const list = await fetch(`${baseUrl}/auth/trust-circle`);
|
|
244
|
+
expect(list.status).toBe(404);
|
|
245
|
+
|
|
246
|
+
const invite = await fetch(`${baseUrl}/auth/invites`, {
|
|
247
|
+
method: 'POST',
|
|
248
|
+
headers: { 'content-type': 'application/json' },
|
|
249
|
+
body: JSON.stringify({ label: 'Mom' }),
|
|
250
|
+
});
|
|
251
|
+
expect(invite.status).toBe(404);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
@@ -149,6 +149,23 @@ describe('/api/settings routes', () => {
|
|
|
149
149
|
expect(body.error.message).toMatch(/uiPort/);
|
|
150
150
|
});
|
|
151
151
|
|
|
152
|
+
it('PUT ownCadre returns 400 invalid_setting (install-time only)', async () => {
|
|
153
|
+
writeV2Config(dataDir);
|
|
154
|
+
app = Fastify();
|
|
155
|
+
registerErrorHandler(app);
|
|
156
|
+
registerSettingsRoutes(app, { settingsStore: new HostSettingsStore({ dataDir }), nat: fakeNat().svc });
|
|
157
|
+
const res = await app.inject({
|
|
158
|
+
method: 'PUT',
|
|
159
|
+
url: '/api/settings',
|
|
160
|
+
headers: { 'content-type': 'application/json' },
|
|
161
|
+
payload: JSON.stringify({ ownCadre: { enabled: true } }),
|
|
162
|
+
});
|
|
163
|
+
expect(res.statusCode).toBe(400);
|
|
164
|
+
const body = res.json() as { error: { code: string; message: string } };
|
|
165
|
+
expect(body.error.code).toBe('invalid_setting');
|
|
166
|
+
expect(body.error.message).toMatch(/ownCadre/);
|
|
167
|
+
});
|
|
168
|
+
|
|
152
169
|
it('PUT unknown setting returns 400', async () => {
|
|
153
170
|
writeV2Config(dataDir);
|
|
154
171
|
app = Fastify();
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join, normalize, relative, sep } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import Fastify from 'fastify';
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { registerStaticMount, resolveSafe } from '../static.js';
|
|
9
|
+
|
|
10
|
+
describe('resolveSafe', () => {
|
|
11
|
+
// POSIX-style fixtures; node:path normalizes separators per-platform, so the
|
|
12
|
+
// containment logic is exercised identically on Windows and POSIX.
|
|
13
|
+
const root = '/srv/app/dist/ui';
|
|
14
|
+
|
|
15
|
+
it('resolves a normal in-root asset', () => {
|
|
16
|
+
expect(resolveSafe(root, '/assets/app.js')).toBe(normalize(join(root, 'assets/app.js')));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('resolves a bare / to the root directory itself', () => {
|
|
20
|
+
expect(resolveSafe(root, '/')).toBe(normalize(root));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('resolves /index.html to the root index', () => {
|
|
24
|
+
expect(resolveSafe(root, '/index.html')).toBe(normalize(join(root, 'index.html')));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('rejects the sibling-directory prefix bypass', () => {
|
|
28
|
+
// `ui-private` shares `ui`'s basename as a prefix — the bare startsWith
|
|
29
|
+
// guard accepted this; the relative-based guard must reject it.
|
|
30
|
+
expect(resolveSafe(root, '/../ui-private/secret')).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('accepts an in-root asset whose name merely begins with ".."', () => {
|
|
34
|
+
// `..foo` is a legitimate descendant — the containment guard must not reject
|
|
35
|
+
// it via a loose '..' prefix match (the precise check keys on '..' + sep).
|
|
36
|
+
expect(resolveSafe(root, '/..foo.js')).toBe(normalize(join(root, '..foo.js')));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('rejects ordinary out-of-tree traversal', () => {
|
|
40
|
+
expect(resolveSafe(root, '/../../etc/passwd')).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('rejects an encoded out-of-tree traversal', () => {
|
|
44
|
+
expect(resolveSafe(root, '/%2e%2e/%2e%2e/etc/passwd')).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('rejects a NUL-byte path', () => {
|
|
48
|
+
expect(resolveSafe(root, '/app%00.js')).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('rejects malformed percent-encoding', () => {
|
|
52
|
+
expect(resolveSafe(root, '/%')).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('never returns a path that escapes the root', () => {
|
|
56
|
+
const escapes = [
|
|
57
|
+
'/../ui-private/secret',
|
|
58
|
+
'/../../etc/passwd',
|
|
59
|
+
'/../ui/../ui-private/x',
|
|
60
|
+
'/..',
|
|
61
|
+
];
|
|
62
|
+
for (const p of escapes) {
|
|
63
|
+
const resolved = resolveSafe(root, p);
|
|
64
|
+
if (resolved !== null) {
|
|
65
|
+
const rel = relative(normalize(root), resolved);
|
|
66
|
+
expect(rel === '..' || rel.startsWith('..' + sep)).toBe(false);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('serveStatic (via registerStaticMount)', () => {
|
|
73
|
+
let app: ReturnType<typeof Fastify>;
|
|
74
|
+
let tmpRoot: string;
|
|
75
|
+
let rootDir: string;
|
|
76
|
+
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-static-'));
|
|
79
|
+
rootDir = join(tmpRoot, 'ui');
|
|
80
|
+
mkdirSync(join(rootDir, 'assets'), { recursive: true });
|
|
81
|
+
writeFileSync(join(rootDir, 'index.html'), '<!doctype html><title>spa</title>');
|
|
82
|
+
writeFileSync(join(rootDir, 'assets', 'app.js'), 'console.log("hi");');
|
|
83
|
+
|
|
84
|
+
// Sibling directory whose name is a prefix superset of the root basename.
|
|
85
|
+
const sibling = join(tmpRoot, 'ui-private');
|
|
86
|
+
mkdirSync(sibling, { recursive: true });
|
|
87
|
+
writeFileSync(join(sibling, 'secret.txt'), 'top secret');
|
|
88
|
+
|
|
89
|
+
app = Fastify();
|
|
90
|
+
registerStaticMount(app, { rootDir });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
afterEach(async () => {
|
|
94
|
+
await app.close();
|
|
95
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('serves an in-root asset', async () => {
|
|
99
|
+
const res = await app.inject({ method: 'GET', url: '/assets/app.js' });
|
|
100
|
+
expect(res.statusCode).toBe(200);
|
|
101
|
+
expect(res.headers['content-type']).toContain('application/javascript');
|
|
102
|
+
expect(res.body).toBe('console.log("hi");');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('serves index.html for the bare root', async () => {
|
|
106
|
+
const res = await app.inject({ method: 'GET', url: '/' });
|
|
107
|
+
expect(res.statusCode).toBe(200);
|
|
108
|
+
expect(res.headers['content-type']).toContain('text/html');
|
|
109
|
+
expect(res.body).toContain('<title>spa</title>');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('serves index.html as SPA fallback for unknown HTML routes', async () => {
|
|
113
|
+
const res = await app.inject({
|
|
114
|
+
method: 'GET',
|
|
115
|
+
url: '/some/client/route',
|
|
116
|
+
headers: { accept: 'text/html' },
|
|
117
|
+
});
|
|
118
|
+
expect(res.statusCode).toBe(200);
|
|
119
|
+
expect(res.body).toContain('<title>spa</title>');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// HTTP-level regression for the sibling-prefix bypass. Two vector classes:
|
|
123
|
+
// - literal / `%2e%2e`-with-literal-slash: Fastify's router collapses the
|
|
124
|
+
// `../` to a within-root path before the handler, so these never reach
|
|
125
|
+
// `resolveSafe` in escaping form — they pass regardless of the fix.
|
|
126
|
+
// - encoded-SEPARATOR (`%2f`/`%5c`): Fastify does NOT collapse these; the
|
|
127
|
+
// handler sees the still-encoded url, `decodeSafe` then reconstitutes
|
|
128
|
+
// `../`, and the escaping path DOES reach `resolveSafe`. Under the old
|
|
129
|
+
// `startsWith` guard this served the sibling secret over HTTP — a genuine
|
|
130
|
+
// exploit. These cases fail without the `relative`-based guard.
|
|
131
|
+
// Either way the asserted invariant is the same: sibling content never leaks.
|
|
132
|
+
it('never serves sibling-prefix directory content over HTTP', async () => {
|
|
133
|
+
const vectors = [
|
|
134
|
+
'/../ui-private/secret.txt',
|
|
135
|
+
'/%2e%2e/ui-private/secret.txt',
|
|
136
|
+
'/..%2fui-private%2fsecret.txt',
|
|
137
|
+
'/%2e%2e%2fui-private%2fsecret.txt',
|
|
138
|
+
'/..%5cui-private%5csecret.txt',
|
|
139
|
+
];
|
|
140
|
+
for (const url of vectors) {
|
|
141
|
+
const res = await app.inject({ method: 'GET', url });
|
|
142
|
+
expect(res.body).not.toContain('top secret');
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import Fastify from 'fastify';
|
|
2
|
-
import { describe, it, expect,
|
|
2
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
3
3
|
|
|
4
4
|
import { registerErrorHandler } from '../error-handler.js';
|
|
5
5
|
import { registerStatusRoute } from '../routes/status.js';
|
|
@@ -64,7 +64,7 @@ describe('GET /api/status', () => {
|
|
|
64
64
|
status: 'running',
|
|
65
65
|
spawnedAt: '2025-01-01T00:00:00Z',
|
|
66
66
|
workdir: '/tmp/alice',
|
|
67
|
-
ports: { health: 1, metrics: 2, p2p: 3 },
|
|
67
|
+
ports: { health: 1, metrics: 2, p2p: 3, admin: 4 },
|
|
68
68
|
},
|
|
69
69
|
]),
|
|
70
70
|
trustCircle: fakeTrustCircle({
|
|
@@ -91,6 +91,27 @@ describe('GET /api/status', () => {
|
|
|
91
91
|
expect(body.update).toBeUndefined();
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
+
it('omits trustCircle + connectivity in donor-only mode (no trust circle / NAT wired)', async () => {
|
|
95
|
+
app = Fastify();
|
|
96
|
+
registerErrorHandler(app);
|
|
97
|
+
registerStatusRoute(app, {
|
|
98
|
+
orchestrator: fakeOrchestrator([]),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const res = await app.inject({ method: 'GET', url: '/api/status' });
|
|
102
|
+
expect(res.statusCode).toBe(200);
|
|
103
|
+
const body = res.json() as {
|
|
104
|
+
service: { name: string };
|
|
105
|
+
nodes: unknown[];
|
|
106
|
+
trustCircle?: unknown;
|
|
107
|
+
connectivity?: unknown;
|
|
108
|
+
};
|
|
109
|
+
expect(body.service.name).toBe('cadre-host');
|
|
110
|
+
expect(body.nodes).toEqual([]);
|
|
111
|
+
expect(body.trustCircle).toBeUndefined();
|
|
112
|
+
expect(body.connectivity).toBeUndefined();
|
|
113
|
+
});
|
|
114
|
+
|
|
94
115
|
it('includes update info when service is present', async () => {
|
|
95
116
|
app = Fastify();
|
|
96
117
|
registerErrorHandler(app);
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* invalid_label, invalid_token → 400
|
|
8
8
|
* not_found, already_redeemed → 404
|
|
9
9
|
* expired → 410
|
|
10
|
+
* node_unavailable → 503
|
|
10
11
|
* storage_error → 500
|
|
11
12
|
* - NatError
|
|
12
13
|
* invalid_config, ddns_provider_unknown,
|
|
@@ -14,12 +15,28 @@
|
|
|
14
15
|
* secrets_unavailable, storage_error,
|
|
15
16
|
* mapping_failed, router_unreachable,
|
|
16
17
|
* ip_detection_failed, ddns_update_failed → 500
|
|
18
|
+
* node_unavailable → 503
|
|
17
19
|
* - UpdateErrorException
|
|
18
20
|
* manifest_invalid, no_update_available,
|
|
19
21
|
* min_previous_version → 400
|
|
20
22
|
* apply_in_progress → 409
|
|
21
23
|
* signature_invalid, unsupported_state_version,
|
|
22
24
|
* apply_failed, rollback_failed, storage_error → 500
|
|
25
|
+
* - GrantError
|
|
26
|
+
* invalid_label, invalid_max_nodes,
|
|
27
|
+
* invalid_ttl → 400
|
|
28
|
+
* not_found → 404
|
|
29
|
+
* storage_error → 500
|
|
30
|
+
* - DonationError
|
|
31
|
+
* invalid_request → 400
|
|
32
|
+
* unauthorized → 401
|
|
33
|
+
* forbidden → 403
|
|
34
|
+
* not_found → 404
|
|
35
|
+
* invalid_state → 409
|
|
36
|
+
* quota_exceeded → 429
|
|
37
|
+
* orchestrator_error, storage_error → 500
|
|
38
|
+
* seed_failed → 502
|
|
39
|
+
* peer_unavailable → 503
|
|
23
40
|
* - everything else → 500 (code "internal")
|
|
24
41
|
*/
|
|
25
42
|
|
|
@@ -29,6 +46,7 @@ import debug from 'debug';
|
|
|
29
46
|
import { TrustCircleError, type TrustCircleErrorCode } from '../auth/types.js';
|
|
30
47
|
import { NatError, type NatErrorCode } from '../nat/types.js';
|
|
31
48
|
import { UpdateErrorException, type UpdateErrorCode } from '../update/types.js';
|
|
49
|
+
import { GrantError, type GrantErrorCode, DonationError, type DonationErrorCode } from '../donation/types.js';
|
|
32
50
|
|
|
33
51
|
const log = debug('cadre:host:error-handler');
|
|
34
52
|
|
|
@@ -39,6 +57,7 @@ const TRUST_STATUS: Record<TrustCircleErrorCode, number> = {
|
|
|
39
57
|
already_redeemed: 404,
|
|
40
58
|
expired: 410,
|
|
41
59
|
storage_error: 500,
|
|
60
|
+
node_unavailable: 503,
|
|
42
61
|
};
|
|
43
62
|
|
|
44
63
|
const NAT_STATUS: Record<NatErrorCode, number> = {
|
|
@@ -51,6 +70,28 @@ const NAT_STATUS: Record<NatErrorCode, number> = {
|
|
|
51
70
|
router_unreachable: 500,
|
|
52
71
|
ip_detection_failed: 500,
|
|
53
72
|
ddns_update_failed: 500,
|
|
73
|
+
node_unavailable: 503,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const GRANT_STATUS: Record<GrantErrorCode, number> = {
|
|
77
|
+
invalid_label: 400,
|
|
78
|
+
invalid_max_nodes: 400,
|
|
79
|
+
invalid_ttl: 400,
|
|
80
|
+
not_found: 404,
|
|
81
|
+
storage_error: 500,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const DONATION_STATUS: Record<DonationErrorCode, number> = {
|
|
85
|
+
unauthorized: 401,
|
|
86
|
+
forbidden: 403,
|
|
87
|
+
quota_exceeded: 429,
|
|
88
|
+
invalid_request: 400,
|
|
89
|
+
not_found: 404,
|
|
90
|
+
invalid_state: 409,
|
|
91
|
+
seed_failed: 502,
|
|
92
|
+
peer_unavailable: 503,
|
|
93
|
+
orchestrator_error: 500,
|
|
94
|
+
storage_error: 500,
|
|
54
95
|
};
|
|
55
96
|
|
|
56
97
|
const UPDATE_STATUS: Record<UpdateErrorCode, number> = {
|
|
@@ -96,6 +137,12 @@ function classify(err: FastifyError): Classified {
|
|
|
96
137
|
if (err instanceof UpdateErrorException) {
|
|
97
138
|
return { status: UPDATE_STATUS[err.code] ?? 500, code: err.code, message: err.message };
|
|
98
139
|
}
|
|
140
|
+
if (err instanceof GrantError) {
|
|
141
|
+
return { status: GRANT_STATUS[err.code] ?? 500, code: err.code, message: err.message };
|
|
142
|
+
}
|
|
143
|
+
if (err instanceof DonationError) {
|
|
144
|
+
return { status: DONATION_STATUS[err.code] ?? 500, code: err.code, message: err.message };
|
|
145
|
+
}
|
|
99
146
|
// Fastify body-parsing / validation errors have a statusCode we should honour.
|
|
100
147
|
if (typeof err.statusCode === 'number' && err.statusCode >= 400 && err.statusCode < 600) {
|
|
101
148
|
return {
|
package/src/server/index.ts
CHANGED
|
@@ -22,6 +22,8 @@ import type { NatService } from '../nat/index.js';
|
|
|
22
22
|
import { createNatHandlers } from '../nat/index.js';
|
|
23
23
|
import type { UpdateService } from '../update/index.js';
|
|
24
24
|
import { createUpdateHandlers } from '../update/index.js';
|
|
25
|
+
import type { GrantService, DonationService } from '../donation/index.js';
|
|
26
|
+
import { createGrantAdminHandlers } from '../donation/index.js';
|
|
25
27
|
|
|
26
28
|
import { EventBus } from './events/bus.js';
|
|
27
29
|
import { registerSseRoute } from './events/sse-route.js';
|
|
@@ -35,6 +37,8 @@ import { registerUpdateRoutes } from './routes/update.js';
|
|
|
35
37
|
import { registerStatusRoute } from './routes/status.js';
|
|
36
38
|
import { registerNodesRoutes } from './routes/nodes.js';
|
|
37
39
|
import { registerSettingsRoutes } from './routes/settings.js';
|
|
40
|
+
import { registerGrantsAdminRoutes } from './routes/grants-admin.js';
|
|
41
|
+
import { registerGrantsRoutes } from './routes/grants.js';
|
|
38
42
|
import { HostSettingsStore } from './settings-store.js';
|
|
39
43
|
|
|
40
44
|
export interface LocalUiServerOptions {
|
|
@@ -44,10 +48,33 @@ export interface LocalUiServerOptions {
|
|
|
44
48
|
dataDir: string;
|
|
45
49
|
/** Wired dependencies — all owned by the caller. */
|
|
46
50
|
orchestrator: HostProcessOrchestrator;
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Trust-circle service — present only when the host runs its own personal
|
|
53
|
+
* cadre (`ownCadre.enabled`). Absent in donor-only mode, where `/auth/*`
|
|
54
|
+
* stays unmounted and 404s.
|
|
55
|
+
*/
|
|
56
|
+
trustCircle?: TrustCircleService;
|
|
57
|
+
/**
|
|
58
|
+
* NAT service — present only when the host runs its own personal cadre.
|
|
59
|
+
* Absent in donor-only mode (loopback-only in v1), where `/nat/*` stays
|
|
60
|
+
* unmounted and 404s.
|
|
61
|
+
*/
|
|
62
|
+
nat?: NatService;
|
|
49
63
|
/** Optional — 6.4.2 lands this; nullable while still iterating. */
|
|
50
64
|
update?: UpdateService;
|
|
65
|
+
/**
|
|
66
|
+
* Donation grant layer. When present, mounts the loopback admin surface at
|
|
67
|
+
* `/grants-admin` (issue/list/revoke). Optional so existing callers/tests
|
|
68
|
+
* that don't exercise donations need not wire it.
|
|
69
|
+
*/
|
|
70
|
+
grants?: GrantService;
|
|
71
|
+
/**
|
|
72
|
+
* Donation lifecycle service. When present (alongside `grants`), mounts the
|
|
73
|
+
* bearer-gated grantee-facing provisioning surface at `/grants`
|
|
74
|
+
* (provision / peer / seed / terminate). Optional so callers/tests that don't
|
|
75
|
+
* exercise the donor path need not wire it.
|
|
76
|
+
*/
|
|
77
|
+
donations?: DonationService;
|
|
51
78
|
/** Settings I/O facade. Defaults to a new one rooted at `dataDir`. */
|
|
52
79
|
settingsStore?: HostSettingsStore;
|
|
53
80
|
/** Bus instance — pass the same one to subsystems that publish events. */
|
|
@@ -99,18 +126,33 @@ export function createLocalUiServer(opts: LocalUiServerOptions): LocalUiServer {
|
|
|
99
126
|
|
|
100
127
|
registerStatusRoute(app, {
|
|
101
128
|
orchestrator: opts.orchestrator,
|
|
102
|
-
trustCircle: opts.trustCircle,
|
|
103
|
-
nat: opts.nat,
|
|
129
|
+
...(opts.trustCircle ? { trustCircle: opts.trustCircle } : {}),
|
|
130
|
+
...(opts.nat ? { nat: opts.nat } : {}),
|
|
104
131
|
...(opts.update ? { update: opts.update } : {}),
|
|
105
132
|
});
|
|
106
133
|
registerNodesRoutes(app, { orchestrator: opts.orchestrator });
|
|
107
|
-
registerSettingsRoutes(app, { settingsStore, nat: opts.nat, ...(opts.update ? { update: opts.update } : {}) });
|
|
134
|
+
registerSettingsRoutes(app, { settingsStore, ...(opts.nat ? { nat: opts.nat } : {}), ...(opts.update ? { update: opts.update } : {}) });
|
|
108
135
|
|
|
109
|
-
|
|
110
|
-
|
|
136
|
+
// Trust-circle + NAT surfaces exist only when the host runs its own personal
|
|
137
|
+
// cadre. In donor-only mode they're left unmounted, so `/auth/*` and `/nat/*`
|
|
138
|
+
// fall through to the static not-found handler and 404 (see static.ts).
|
|
139
|
+
if (opts.trustCircle) {
|
|
140
|
+
registerTrustCircleRoutes(app, { handlers: createTrustCircleHandlers(opts.trustCircle), events });
|
|
141
|
+
}
|
|
142
|
+
if (opts.nat) {
|
|
143
|
+
registerNatRoutes(app, { handlers: createNatHandlers(opts.nat), events });
|
|
144
|
+
}
|
|
111
145
|
if (opts.update) {
|
|
112
146
|
registerUpdateRoutes(app, { handlers: createUpdateHandlers(opts.update), events });
|
|
113
147
|
}
|
|
148
|
+
if (opts.grants) {
|
|
149
|
+
registerGrantsAdminRoutes(app, { handlers: createGrantAdminHandlers(opts.grants) });
|
|
150
|
+
// Grantee-facing provisioning surface needs both the donation service and a
|
|
151
|
+
// grant validator (the GrantService doubles as the validator).
|
|
152
|
+
if (opts.donations) {
|
|
153
|
+
registerGrantsRoutes(app, { donations: opts.donations, grants: opts.grants });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
114
156
|
|
|
115
157
|
// Static mount registers as the not-found handler — declared last so the
|
|
116
158
|
// API paths above win.
|
|
@@ -147,16 +189,19 @@ export function createLocalUiServer(opts: LocalUiServerOptions): LocalUiServer {
|
|
|
147
189
|
);
|
|
148
190
|
|
|
149
191
|
// One-shot connectivity publish — the SPA will get a sane initial
|
|
150
|
-
// signal even if no settings have changed yet this session.
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
192
|
+
// signal even if no settings have changed yet this session. Skipped in
|
|
193
|
+
// donor-only mode, where there is no NatService.
|
|
194
|
+
if (opts.nat) {
|
|
195
|
+
try {
|
|
196
|
+
const snap = opts.nat.getStatus();
|
|
197
|
+
events.publish({
|
|
198
|
+
type: 'connectivity-changed',
|
|
199
|
+
portMode: snap.portMode,
|
|
200
|
+
directReachability: snap.directReachability,
|
|
201
|
+
});
|
|
202
|
+
} catch {
|
|
203
|
+
// NatService.start may not have completed; harmless to skip.
|
|
204
|
+
}
|
|
160
205
|
}
|
|
161
206
|
|
|
162
207
|
// Update observer: poll UpdateService.getState() every 60 s and emit
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Donation grant **admin** HTTP routes — thin adapters around
|
|
3
|
+
* `GrantAdminHandlers` (`createGrantAdminHandlers(service)`).
|
|
4
|
+
*
|
|
5
|
+
* Mount path: `/grants-admin` — the admin surface the `cadre-host grant` CLI
|
|
6
|
+
* targets. It is **loopback, no bearer**: same-machine admin, matching
|
|
7
|
+
* cadre-host's local-UI "no login" posture (see docs/cadre-host.md §
|
|
8
|
+
* Security posture). This is distinct from the grantee-facing `/grants`
|
|
9
|
+
* provisioning surface that carries the bearer gate — that arrives in the
|
|
10
|
+
* `2-donation-service` ticket.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { FastifyInstance } from 'fastify';
|
|
14
|
+
|
|
15
|
+
import type { GrantAdminHandlers } from '../../donation/types.js';
|
|
16
|
+
|
|
17
|
+
export interface GrantsAdminRoutesOptions {
|
|
18
|
+
handlers: GrantAdminHandlers;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function registerGrantsAdminRoutes(app: FastifyInstance, opts: GrantsAdminRoutesOptions): void {
|
|
22
|
+
const { handlers } = opts;
|
|
23
|
+
|
|
24
|
+
app.get('/grants-admin', async () => {
|
|
25
|
+
return handlers.listGrants();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
app.post('/grants-admin', async (request) => {
|
|
29
|
+
const body = (request.body ?? {}) as { label?: unknown; maxNodes?: unknown; ttlMs?: unknown };
|
|
30
|
+
const label = typeof body.label === 'string' ? body.label : '';
|
|
31
|
+
const args: { label: string; maxNodes?: number; ttlMs?: number } = { label };
|
|
32
|
+
if (typeof body.maxNodes === 'number') args.maxNodes = body.maxNodes;
|
|
33
|
+
if (typeof body.ttlMs === 'number') args.ttlMs = body.ttlMs;
|
|
34
|
+
return handlers.postGrant(args);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
app.delete<{ Params: { token: string } }>('/grants-admin/:token', async (request) => {
|
|
38
|
+
await handlers.deleteGrant(request.params.token);
|
|
39
|
+
return { ok: true };
|
|
40
|
+
});
|
|
41
|
+
}
|