@serfab/cadre-host 0.9.0 → 0.11.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/dist/auth/index.d.ts +2 -0
- package/dist/auth/index.js +1 -0
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/self-label.d.ts +36 -0
- package/dist/auth/self-label.js +57 -0
- package/dist/auth/self-label.js.map +1 -0
- package/dist/auth/trust-circle.d.ts +32 -5
- package/dist/auth/trust-circle.js +31 -11
- package/dist/auth/trust-circle.js.map +1 -1
- package/dist/bin/host.js +41 -5
- package/dist/bin/host.js.map +1 -1
- package/dist/donation/__tests__/fake-orchestrator.d.ts +103 -0
- package/dist/donation/__tests__/fake-orchestrator.js +180 -0
- package/dist/donation/__tests__/fake-orchestrator.js.map +1 -0
- package/dist/donation/donation-service.d.ts +237 -12
- package/dist/donation/donation-service.js +491 -34
- package/dist/donation/donation-service.js.map +1 -1
- package/dist/donation/donation-store.d.ts +7 -0
- package/dist/donation/donation-store.js +7 -0
- package/dist/donation/donation-store.js.map +1 -1
- package/dist/donation/donation-supervisor.d.ts +167 -0
- package/dist/donation/donation-supervisor.js +341 -0
- package/dist/donation/donation-supervisor.js.map +1 -0
- package/dist/donation/index.d.ts +4 -2
- package/dist/donation/index.js +2 -1
- package/dist/donation/index.js.map +1 -1
- package/dist/donation/types.d.ts +26 -0
- package/dist/donation/types.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/orchestrator/host-process-orchestrator.d.ts +109 -5
- package/dist/orchestrator/host-process-orchestrator.js +341 -98
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
- package/dist/orchestrator/node-identity.d.ts +26 -0
- package/dist/orchestrator/node-identity.js +36 -0
- package/dist/orchestrator/node-identity.js.map +1 -0
- package/dist/orchestrator/port-allocator.d.ts +14 -0
- package/dist/orchestrator/port-allocator.js +45 -0
- package/dist/orchestrator/port-allocator.js.map +1 -1
- package/dist/owner/owner-node-client.d.ts +20 -5
- package/dist/owner/owner-node-client.js +21 -4
- package/dist/owner/owner-node-client.js.map +1 -1
- package/dist/server/error-handler.d.ts +7 -1
- package/dist/server/error-handler.js +23 -1
- package/dist/server/error-handler.js.map +1 -1
- package/dist/server/events/types.d.ts +3 -0
- package/dist/server/index.d.ts +7 -0
- package/dist/server/index.js +9 -3
- package/dist/server/index.js.map +1 -1
- package/dist/server/routes/grants.js +22 -3
- package/dist/server/routes/grants.js.map +1 -1
- package/dist/server/routes/strands.d.ts +20 -0
- package/dist/server/routes/strands.js +38 -0
- package/dist/server/routes/strands.js.map +1 -0
- package/dist/server/static.js +38 -38
- package/dist/strands/index.d.ts +7 -0
- package/dist/strands/index.js +6 -0
- package/dist/strands/index.js.map +1 -0
- package/dist/strands/strand-service.d.ts +50 -0
- package/dist/strands/strand-service.js +103 -0
- package/dist/strands/strand-service.js.map +1 -0
- package/dist/strands/types.d.ts +93 -0
- package/dist/strands/types.js +23 -0
- package/dist/strands/types.js.map +1 -0
- package/dist/ui/assets/index-BVoqLjyX.css +1 -0
- package/dist/ui/assets/index-C-iks41M.js +17 -0
- package/dist/ui/assets/index-C-iks41M.js.map +1 -0
- package/dist/ui/index.html +14 -14
- package/dist/update/release-key.d.ts +1 -1
- package/dist/update/release-key.js +1 -1
- package/package.json +5 -5
- package/service/cadre-host.service.tmpl +16 -16
- package/service/com.serfab.cadre-host.plist.tmpl +35 -35
- package/service/install-service.ps1 +98 -98
- package/service/uninstall-service.ps1 +19 -19
- package/src/__tests__/build-targets.test.ts +26 -0
- package/src/__tests__/cli-invite.smoke.test.ts +192 -192
- package/src/__tests__/cli-nat.smoke.test.ts +212 -212
- package/src/__tests__/cli.smoke.test.ts +46 -46
- package/src/__tests__/global-setup.ts +51 -0
- package/src/__tests__/host-owner.smoke.test.ts +123 -123
- package/src/__tests__/orchestrator-env-scrub.test.ts +140 -0
- package/src/__tests__/orchestrator-node-identity.test.ts +204 -0
- package/src/__tests__/orchestrator-owner.test.ts +198 -198
- package/src/__tests__/orchestrator-pin-keys.test.ts +153 -153
- package/src/__tests__/orchestrator-push.test.ts +145 -145
- package/src/__tests__/orchestrator.test.ts +951 -570
- package/src/auth/__tests__/duration.test.ts +28 -28
- package/src/auth/__tests__/self-label.test.ts +85 -0
- package/src/auth/__tests__/trust-circle-integration.test.ts +152 -147
- package/src/auth/__tests__/trust-circle-store.test.ts +115 -115
- package/src/auth/__tests__/trust-circle.test.ts +445 -401
- package/src/auth/duration.ts +33 -33
- package/src/auth/index.ts +21 -19
- package/src/auth/self-label.ts +74 -0
- package/src/auth/trust-circle-store.ts +155 -155
- package/src/auth/trust-circle.ts +45 -13
- package/src/auth/types.ts +99 -99
- package/src/bin/host.ts +1299 -1258
- package/src/donation/__tests__/donation-service.test.ts +1068 -290
- package/src/donation/__tests__/donation-supervisor.test.ts +510 -0
- package/src/donation/__tests__/fake-orchestrator.test.ts +168 -0
- package/src/donation/__tests__/fake-orchestrator.ts +225 -0
- package/src/donation/__tests__/grant-service.test.ts +213 -213
- package/src/donation/__tests__/grant-store.test.ts +113 -113
- package/src/donation/donation-service.ts +962 -392
- package/src/donation/donation-store.ts +171 -164
- package/src/donation/donation-supervisor.ts +388 -0
- package/src/donation/grant-service.ts +200 -200
- package/src/donation/grant-store.ts +152 -152
- package/src/donation/index.ts +64 -49
- package/src/donation/types.ts +243 -220
- package/src/index.ts +14 -0
- package/src/installer/__tests__/identity.test.ts +34 -34
- package/src/installer/__tests__/paths.test.ts +47 -47
- package/src/installer/__tests__/service-host.test.ts +62 -62
- package/src/installer/__tests__/wizard.test.ts +64 -64
- package/src/installer/identity.ts +49 -49
- package/src/installer/paths.ts +109 -109
- package/src/installer/platform.ts +27 -27
- package/src/installer/service-host/index.ts +21 -21
- package/src/installer/service-host/template.ts +21 -21
- package/src/installer/wizard.ts +103 -103
- package/src/nat/__tests__/address-resolver.test.ts +82 -82
- package/src/nat/__tests__/ddns-duckdns.test.ts +83 -83
- package/src/nat/__tests__/ddns-updater.test.ts +253 -253
- package/src/nat/__tests__/external-ip.test.ts +124 -124
- package/src/nat/__tests__/nat-service.test.ts +497 -497
- package/src/nat/__tests__/nat-store.test.ts +93 -93
- package/src/nat/__tests__/port-mapper.test.ts +191 -191
- package/src/nat/__tests__/reachability.test.ts +24 -24
- package/src/nat/__tests__/secrets.test.ts +178 -178
- package/src/nat/address-resolver.ts +51 -51
- package/src/nat/ddns/duckdns.ts +76 -76
- package/src/nat/ddns/index.ts +55 -55
- package/src/nat/ddns/updater.ts +243 -243
- package/src/nat/external-ip.ts +154 -154
- package/src/nat/index.ts +71 -71
- package/src/nat/nat-service.ts +501 -501
- package/src/nat/nat-store.ts +154 -154
- package/src/nat/port-mapper.ts +329 -329
- package/src/nat/reachability.ts +34 -34
- package/src/nat/secrets/file-store.ts +130 -130
- package/src/nat/secrets/index.ts +112 -112
- package/src/nat/secrets/keytar-store.ts +67 -67
- package/src/nat/types.ts +159 -159
- package/src/orchestrator/__tests__/node-identity.test.ts +62 -0
- package/src/orchestrator/host-process-orchestrator.ts +1133 -894
- package/src/orchestrator/index.ts +10 -10
- package/src/orchestrator/log-rotator.ts +53 -53
- package/src/orchestrator/node-identity.ts +40 -0
- package/src/orchestrator/pid-liveness.ts +24 -24
- package/src/orchestrator/port-allocator.ts +91 -43
- package/src/orchestrator/state-store.ts +90 -90
- package/src/orchestrator/types.ts +141 -141
- package/src/owner/__tests__/owner-node-client.test.ts +194 -194
- package/src/owner/index.ts +12 -12
- package/src/owner/owner-node-client.ts +259 -233
- package/src/push/__tests__/push.test.ts +90 -90
- package/src/push/index.ts +146 -146
- package/src/server/__tests__/bus.test.ts +38 -38
- package/src/server/__tests__/error-handler.test.ts +118 -118
- package/src/server/__tests__/grants-admin-route.test.ts +73 -73
- package/src/server/__tests__/grants-route.test.ts +263 -189
- package/src/server/__tests__/nodes-route.test.ts +259 -259
- package/src/server/__tests__/origin-guard.test.ts +72 -72
- package/src/server/__tests__/publishers.test.ts +216 -216
- package/src/server/__tests__/server.smoke.test.ts +253 -253
- package/src/server/__tests__/settings-route.test.ts +194 -194
- package/src/server/__tests__/sse-route.test.ts +119 -119
- package/src/server/__tests__/static.test.ts +145 -145
- package/src/server/__tests__/status-route.test.ts +134 -134
- package/src/server/__tests__/strands-route.test.ts +233 -0
- package/src/server/error-handler.ts +178 -155
- package/src/server/events/bus.ts +42 -42
- package/src/server/events/sse-route.ts +81 -81
- package/src/server/events/types.ts +23 -22
- package/src/server/index.ts +267 -254
- package/src/server/origin-guard.ts +84 -84
- package/src/server/routes/grants-admin.ts +41 -41
- package/src/server/routes/grants.ts +210 -186
- package/src/server/routes/nat.ts +63 -63
- package/src/server/routes/nodes.ts +170 -170
- package/src/server/routes/settings.ts +123 -123
- package/src/server/routes/status.ts +118 -118
- package/src/server/routes/strands.ts +54 -0
- package/src/server/routes/trust-circle.ts +49 -49
- package/src/server/routes/update.ts +43 -43
- package/src/server/server.ts +122 -122
- package/src/server/settings-store.ts +37 -37
- package/src/server/static.ts +207 -207
- package/src/strands/__tests__/strand-service.test.ts +165 -0
- package/src/strands/index.ts +14 -0
- package/src/strands/strand-service.ts +135 -0
- package/src/strands/types.ts +102 -0
- package/src/update/__tests__/apply.test.ts +158 -158
- package/src/update/__tests__/manifest.test.ts +128 -128
- package/src/update/__tests__/release-key.test.ts +42 -42
- package/src/update/__tests__/sign.test.ts +78 -78
- package/src/update/__tests__/store.test.ts +81 -81
- package/src/update/__tests__/update-service.integration.test.ts +256 -256
- package/src/update/__tests__/version.test.ts +31 -31
- package/src/update/apply.ts +190 -190
- package/src/update/index.ts +341 -341
- package/src/update/release-key.ts +71 -71
- package/src/update/sign.ts +74 -74
- package/src/update/store.ts +101 -101
- package/src/update/types.ts +109 -109
- package/src/update/version.ts +80 -80
- package/dist/ui/assets/index-BlMXQPSw.js +0 -14
- package/dist/ui/assets/index-BlMXQPSw.js.map +0 -1
- package/dist/ui/assets/index-BwU5mJGF.css +0 -1
package/src/server/static.ts
CHANGED
|
@@ -1,207 +1,207 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Static-asset mount for the SPA bundle.
|
|
3
|
-
*
|
|
4
|
-
* The SPA bundle is produced by `6.5.2-cadre-host-local-ui-spa` into
|
|
5
|
-
* `<package>/dist/ui/`. When that directory exists we serve files from it
|
|
6
|
-
* with an `index.html` SPA fallback. When it's missing (running from source
|
|
7
|
-
* before the SPA ticket lands) we serve a placeholder page that explains
|
|
8
|
-
* how to install/build the SPA.
|
|
9
|
-
*
|
|
10
|
-
* Hand-rolled rather than pulling in `@fastify/static` — the surface area we
|
|
11
|
-
* need is tiny and the dep would only matter under tests that don't exercise
|
|
12
|
-
* static serving anyway.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { readFileSync, statSync, existsSync } from 'node:fs';
|
|
16
|
-
import { dirname, extname, isAbsolute, join, normalize, relative, resolve, sep } from 'node:path';
|
|
17
|
-
import { fileURLToPath } from 'node:url';
|
|
18
|
-
|
|
19
|
-
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
|
20
|
-
|
|
21
|
-
const MIME_TYPES: Record<string, string> = {
|
|
22
|
-
'.html': 'text/html; charset=utf-8',
|
|
23
|
-
'.js': 'application/javascript; charset=utf-8',
|
|
24
|
-
'.mjs': 'application/javascript; charset=utf-8',
|
|
25
|
-
'.css': 'text/css; charset=utf-8',
|
|
26
|
-
'.json': 'application/json; charset=utf-8',
|
|
27
|
-
'.svg': 'image/svg+xml',
|
|
28
|
-
'.png': 'image/png',
|
|
29
|
-
'.jpg': 'image/jpeg',
|
|
30
|
-
'.jpeg': 'image/jpeg',
|
|
31
|
-
'.gif': 'image/gif',
|
|
32
|
-
'.ico': 'image/x-icon',
|
|
33
|
-
'.woff': 'font/woff',
|
|
34
|
-
'.woff2': 'font/woff2',
|
|
35
|
-
'.txt': 'text/plain; charset=utf-8',
|
|
36
|
-
'.map': 'application/json; charset=utf-8',
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Default location of the SPA bundle when `cadre-host` runs from its
|
|
41
|
-
* installed package.
|
|
42
|
-
* - dist/server/static.js → ../ui → packages/cadre-host/dist/ui
|
|
43
|
-
*/
|
|
44
|
-
export function defaultStaticRoot(): string {
|
|
45
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
46
|
-
return resolve(here, '..', 'ui');
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export interface StaticMountOptions {
|
|
50
|
-
/** Absolute path to the SPA build output (defaults to `defaultStaticRoot()`). */
|
|
51
|
-
rootDir?: string;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function registerStaticMount(app: FastifyInstance, opts: StaticMountOptions = {}): void {
|
|
55
|
-
const rootDir = opts.rootDir ?? defaultStaticRoot();
|
|
56
|
-
|
|
57
|
-
// Register *after* all routes so API paths win.
|
|
58
|
-
app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
|
|
59
|
-
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
60
|
-
return reply.code(404).send({
|
|
61
|
-
ok: false,
|
|
62
|
-
error: { code: 'not_found', message: `No route for ${request.method} ${request.url}` },
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
// Don't try to serve API paths as static.
|
|
66
|
-
if (
|
|
67
|
-
request.url.startsWith('/api') ||
|
|
68
|
-
request.url.startsWith('/auth') ||
|
|
69
|
-
request.url.startsWith('/nat') ||
|
|
70
|
-
request.url.startsWith('/update')
|
|
71
|
-
) {
|
|
72
|
-
return reply.code(404).send({
|
|
73
|
-
ok: false,
|
|
74
|
-
error: { code: 'not_found', message: `No route for ${request.method} ${request.url}` },
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
return serveStatic(rootDir, request, reply);
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function serveStatic(rootDir: string, request: FastifyRequest, reply: FastifyReply): FastifyReply {
|
|
82
|
-
if (!existsSync(rootDir)) {
|
|
83
|
-
if (request.url === '/' || request.url === '/index.html') {
|
|
84
|
-
return reply.code(200).type('text/html; charset=utf-8').send(placeholderHtml());
|
|
85
|
-
}
|
|
86
|
-
return reply.code(404).send({
|
|
87
|
-
ok: false,
|
|
88
|
-
error: { code: 'not_found', message: 'SPA bundle not installed' },
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const requested = stripQuery(request.url);
|
|
93
|
-
const target = resolveSafe(rootDir, requested);
|
|
94
|
-
if (!target) {
|
|
95
|
-
return reply.code(404).send({
|
|
96
|
-
ok: false,
|
|
97
|
-
error: { code: 'not_found', message: `No static asset for ${request.url}` },
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
try {
|
|
101
|
-
const stat = statSync(target);
|
|
102
|
-
if (stat.isFile()) return sendFile(reply, target);
|
|
103
|
-
} catch {
|
|
104
|
-
// fall through to SPA index
|
|
105
|
-
}
|
|
106
|
-
// SPA fallback: serve index.html for any GET that accepts HTML.
|
|
107
|
-
const accept = (request.headers.accept ?? '').toLowerCase();
|
|
108
|
-
if (accept.includes('text/html') || accept === '' || accept === '*/*') {
|
|
109
|
-
const index = join(rootDir, 'index.html');
|
|
110
|
-
if (existsSync(index)) return sendFile(reply, index);
|
|
111
|
-
}
|
|
112
|
-
return reply.code(404).send({
|
|
113
|
-
ok: false,
|
|
114
|
-
error: { code: 'not_found', message: `No static asset for ${request.url}` },
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
|
|
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 {
|
|
130
|
-
const cleaned = decodeSafe(urlPath);
|
|
131
|
-
if (cleaned === null) 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;
|
|
143
|
-
return target;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function decodeSafe(urlPath: string): string | null {
|
|
147
|
-
try {
|
|
148
|
-
const decoded = decodeURIComponent(urlPath);
|
|
149
|
-
if (decoded.includes('\0')) return null;
|
|
150
|
-
return decoded;
|
|
151
|
-
} catch {
|
|
152
|
-
return null;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function stripQuery(url: string): string {
|
|
157
|
-
const q = url.indexOf('?');
|
|
158
|
-
return q === -1 ? url : url.slice(0, q);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function sendFile(reply: FastifyReply, path: string): FastifyReply {
|
|
162
|
-
const buf = readFileSync(path);
|
|
163
|
-
const mime = MIME_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream';
|
|
164
|
-
return reply.code(200).type(mime).send(buf);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function placeholderHtml(): string {
|
|
168
|
-
return `<!doctype html>
|
|
169
|
-
<html lang="en">
|
|
170
|
-
<head>
|
|
171
|
-
<meta charset="utf-8" />
|
|
172
|
-
<title>cadre-host — UI bundle missing</title>
|
|
173
|
-
<style>
|
|
174
|
-
body { font-family: system-ui, -apple-system, sans-serif; max-width: 40rem; margin: 4rem auto; padding: 0 1rem; color: #1a1a1a; }
|
|
175
|
-
h1 { font-weight: 600; }
|
|
176
|
-
code { background: #f4f4f4; padding: 0.125rem 0.375rem; border-radius: 0.25rem; }
|
|
177
|
-
pre { background: #f4f4f4; padding: 0.75rem 1rem; border-radius: 0.5rem; overflow: auto; }
|
|
178
|
-
.endpoint { font-family: ui-monospace, monospace; }
|
|
179
|
-
</style>
|
|
180
|
-
</head>
|
|
181
|
-
<body>
|
|
182
|
-
<h1>cadre-host is running — UI bundle is missing</h1>
|
|
183
|
-
<p>
|
|
184
|
-
The HTTP management API is fully available (see endpoints below), but the
|
|
185
|
-
Svelte SPA that normally renders at this URL hasn't been built into this
|
|
186
|
-
install. This page is most likely shown because the package was started
|
|
187
|
-
from a source checkout without running the UI build step.
|
|
188
|
-
</p>
|
|
189
|
-
<p>To build it:</p>
|
|
190
|
-
<pre>yarn workspace @serfab/cadre-host build</pre>
|
|
191
|
-
<p>or just the UI:</p>
|
|
192
|
-
<pre>yarn workspace @serfab/cadre-host build:ui</pre>
|
|
193
|
-
<p>Then refresh this page.</p>
|
|
194
|
-
|
|
195
|
-
<h2 style="font-size:1.1rem;margin-top:2rem;">Available API endpoints</h2>
|
|
196
|
-
<ul>
|
|
197
|
-
<li><span class="endpoint">GET /api/status</span></li>
|
|
198
|
-
<li><span class="endpoint">GET /api/nodes</span></li>
|
|
199
|
-
<li><span class="endpoint">GET /api/settings</span></li>
|
|
200
|
-
<li><span class="endpoint">GET /api/events</span> (Server-Sent Events)</li>
|
|
201
|
-
<li><span class="endpoint">/auth/*</span>, <span class="endpoint">/nat/*</span>, <span class="endpoint">/update/*</span></li>
|
|
202
|
-
</ul>
|
|
203
|
-
<p>See <a href="https://github.com/gotchoices/sereus/blob/master/docs/cadre-host.md">docs/cadre-host.md</a>.</p>
|
|
204
|
-
</body>
|
|
205
|
-
</html>
|
|
206
|
-
`;
|
|
207
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Static-asset mount for the SPA bundle.
|
|
3
|
+
*
|
|
4
|
+
* The SPA bundle is produced by `6.5.2-cadre-host-local-ui-spa` into
|
|
5
|
+
* `<package>/dist/ui/`. When that directory exists we serve files from it
|
|
6
|
+
* with an `index.html` SPA fallback. When it's missing (running from source
|
|
7
|
+
* before the SPA ticket lands) we serve a placeholder page that explains
|
|
8
|
+
* how to install/build the SPA.
|
|
9
|
+
*
|
|
10
|
+
* Hand-rolled rather than pulling in `@fastify/static` — the surface area we
|
|
11
|
+
* need is tiny and the dep would only matter under tests that don't exercise
|
|
12
|
+
* static serving anyway.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync, statSync, existsSync } from 'node:fs';
|
|
16
|
+
import { dirname, extname, isAbsolute, join, normalize, relative, resolve, sep } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
|
20
|
+
|
|
21
|
+
const MIME_TYPES: Record<string, string> = {
|
|
22
|
+
'.html': 'text/html; charset=utf-8',
|
|
23
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
24
|
+
'.mjs': 'application/javascript; charset=utf-8',
|
|
25
|
+
'.css': 'text/css; charset=utf-8',
|
|
26
|
+
'.json': 'application/json; charset=utf-8',
|
|
27
|
+
'.svg': 'image/svg+xml',
|
|
28
|
+
'.png': 'image/png',
|
|
29
|
+
'.jpg': 'image/jpeg',
|
|
30
|
+
'.jpeg': 'image/jpeg',
|
|
31
|
+
'.gif': 'image/gif',
|
|
32
|
+
'.ico': 'image/x-icon',
|
|
33
|
+
'.woff': 'font/woff',
|
|
34
|
+
'.woff2': 'font/woff2',
|
|
35
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
36
|
+
'.map': 'application/json; charset=utf-8',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Default location of the SPA bundle when `cadre-host` runs from its
|
|
41
|
+
* installed package.
|
|
42
|
+
* - dist/server/static.js → ../ui → packages/cadre-host/dist/ui
|
|
43
|
+
*/
|
|
44
|
+
export function defaultStaticRoot(): string {
|
|
45
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
46
|
+
return resolve(here, '..', 'ui');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface StaticMountOptions {
|
|
50
|
+
/** Absolute path to the SPA build output (defaults to `defaultStaticRoot()`). */
|
|
51
|
+
rootDir?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function registerStaticMount(app: FastifyInstance, opts: StaticMountOptions = {}): void {
|
|
55
|
+
const rootDir = opts.rootDir ?? defaultStaticRoot();
|
|
56
|
+
|
|
57
|
+
// Register *after* all routes so API paths win.
|
|
58
|
+
app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
|
|
59
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
60
|
+
return reply.code(404).send({
|
|
61
|
+
ok: false,
|
|
62
|
+
error: { code: 'not_found', message: `No route for ${request.method} ${request.url}` },
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
// Don't try to serve API paths as static.
|
|
66
|
+
if (
|
|
67
|
+
request.url.startsWith('/api') ||
|
|
68
|
+
request.url.startsWith('/auth') ||
|
|
69
|
+
request.url.startsWith('/nat') ||
|
|
70
|
+
request.url.startsWith('/update')
|
|
71
|
+
) {
|
|
72
|
+
return reply.code(404).send({
|
|
73
|
+
ok: false,
|
|
74
|
+
error: { code: 'not_found', message: `No route for ${request.method} ${request.url}` },
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return serveStatic(rootDir, request, reply);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function serveStatic(rootDir: string, request: FastifyRequest, reply: FastifyReply): FastifyReply {
|
|
82
|
+
if (!existsSync(rootDir)) {
|
|
83
|
+
if (request.url === '/' || request.url === '/index.html') {
|
|
84
|
+
return reply.code(200).type('text/html; charset=utf-8').send(placeholderHtml());
|
|
85
|
+
}
|
|
86
|
+
return reply.code(404).send({
|
|
87
|
+
ok: false,
|
|
88
|
+
error: { code: 'not_found', message: 'SPA bundle not installed' },
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const requested = stripQuery(request.url);
|
|
93
|
+
const target = resolveSafe(rootDir, requested);
|
|
94
|
+
if (!target) {
|
|
95
|
+
return reply.code(404).send({
|
|
96
|
+
ok: false,
|
|
97
|
+
error: { code: 'not_found', message: `No static asset for ${request.url}` },
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
const stat = statSync(target);
|
|
102
|
+
if (stat.isFile()) return sendFile(reply, target);
|
|
103
|
+
} catch {
|
|
104
|
+
// fall through to SPA index
|
|
105
|
+
}
|
|
106
|
+
// SPA fallback: serve index.html for any GET that accepts HTML.
|
|
107
|
+
const accept = (request.headers.accept ?? '').toLowerCase();
|
|
108
|
+
if (accept.includes('text/html') || accept === '' || accept === '*/*') {
|
|
109
|
+
const index = join(rootDir, 'index.html');
|
|
110
|
+
if (existsSync(index)) return sendFile(reply, index);
|
|
111
|
+
}
|
|
112
|
+
return reply.code(404).send({
|
|
113
|
+
ok: false,
|
|
114
|
+
error: { code: 'not_found', message: `No static asset for ${request.url}` },
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
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 {
|
|
130
|
+
const cleaned = decodeSafe(urlPath);
|
|
131
|
+
if (cleaned === null) 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;
|
|
143
|
+
return target;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function decodeSafe(urlPath: string): string | null {
|
|
147
|
+
try {
|
|
148
|
+
const decoded = decodeURIComponent(urlPath);
|
|
149
|
+
if (decoded.includes('\0')) return null;
|
|
150
|
+
return decoded;
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function stripQuery(url: string): string {
|
|
157
|
+
const q = url.indexOf('?');
|
|
158
|
+
return q === -1 ? url : url.slice(0, q);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function sendFile(reply: FastifyReply, path: string): FastifyReply {
|
|
162
|
+
const buf = readFileSync(path);
|
|
163
|
+
const mime = MIME_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream';
|
|
164
|
+
return reply.code(200).type(mime).send(buf);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function placeholderHtml(): string {
|
|
168
|
+
return `<!doctype html>
|
|
169
|
+
<html lang="en">
|
|
170
|
+
<head>
|
|
171
|
+
<meta charset="utf-8" />
|
|
172
|
+
<title>cadre-host — UI bundle missing</title>
|
|
173
|
+
<style>
|
|
174
|
+
body { font-family: system-ui, -apple-system, sans-serif; max-width: 40rem; margin: 4rem auto; padding: 0 1rem; color: #1a1a1a; }
|
|
175
|
+
h1 { font-weight: 600; }
|
|
176
|
+
code { background: #f4f4f4; padding: 0.125rem 0.375rem; border-radius: 0.25rem; }
|
|
177
|
+
pre { background: #f4f4f4; padding: 0.75rem 1rem; border-radius: 0.5rem; overflow: auto; }
|
|
178
|
+
.endpoint { font-family: ui-monospace, monospace; }
|
|
179
|
+
</style>
|
|
180
|
+
</head>
|
|
181
|
+
<body>
|
|
182
|
+
<h1>cadre-host is running — UI bundle is missing</h1>
|
|
183
|
+
<p>
|
|
184
|
+
The HTTP management API is fully available (see endpoints below), but the
|
|
185
|
+
Svelte SPA that normally renders at this URL hasn't been built into this
|
|
186
|
+
install. This page is most likely shown because the package was started
|
|
187
|
+
from a source checkout without running the UI build step.
|
|
188
|
+
</p>
|
|
189
|
+
<p>To build it:</p>
|
|
190
|
+
<pre>yarn workspace @serfab/cadre-host build</pre>
|
|
191
|
+
<p>or just the UI:</p>
|
|
192
|
+
<pre>yarn workspace @serfab/cadre-host build:ui</pre>
|
|
193
|
+
<p>Then refresh this page.</p>
|
|
194
|
+
|
|
195
|
+
<h2 style="font-size:1.1rem;margin-top:2rem;">Available API endpoints</h2>
|
|
196
|
+
<ul>
|
|
197
|
+
<li><span class="endpoint">GET /api/status</span></li>
|
|
198
|
+
<li><span class="endpoint">GET /api/nodes</span></li>
|
|
199
|
+
<li><span class="endpoint">GET /api/settings</span></li>
|
|
200
|
+
<li><span class="endpoint">GET /api/events</span> (Server-Sent Events)</li>
|
|
201
|
+
<li><span class="endpoint">/auth/*</span>, <span class="endpoint">/nat/*</span>, <span class="endpoint">/update/*</span></li>
|
|
202
|
+
</ul>
|
|
203
|
+
<p>See <a href="https://github.com/gotchoices/sereus/blob/master/docs/cadre-host.md">docs/cadre-host.md</a>.</p>
|
|
204
|
+
</body>
|
|
205
|
+
</html>
|
|
206
|
+
`;
|
|
207
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StrandService unit tests — a fake node, no HTTP.
|
|
3
|
+
*
|
|
4
|
+
* The two things this layer actually owns: id validation (which must never reach
|
|
5
|
+
* the node) and the translation of the admin channel's single
|
|
6
|
+
* `OwnerNodeUnavailableError` into a vocabulary the HTTP layer can map. The
|
|
7
|
+
* confirmation *policy* is the node's and is not re-tested here — only that the
|
|
8
|
+
* flag is forwarded verbatim.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
12
|
+
|
|
13
|
+
import { OwnerNodeUnavailableError } from '../../owner/owner-node-client.js';
|
|
14
|
+
import { StrandService } from '../strand-service.js';
|
|
15
|
+
import { StrandError } from '../types.js';
|
|
16
|
+
import type { CadreNodeLike } from '../strand-service.js';
|
|
17
|
+
import type { StrandListSnapshot, StrandRemovalResult } from '../types.js';
|
|
18
|
+
|
|
19
|
+
interface FakeNode extends CadreNodeLike {
|
|
20
|
+
calls: Array<{ strandId: string; confirm: boolean }>;
|
|
21
|
+
listCalls: number;
|
|
22
|
+
/** Thrown by both methods when set. */
|
|
23
|
+
failWith?: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const SNAPSHOT: StrandListSnapshot = {
|
|
27
|
+
strands: [
|
|
28
|
+
{ id: 'open-one', type: 'o', running: true, status: 'active' },
|
|
29
|
+
{ id: 'closed-one', type: 'c', running: false, status: null },
|
|
30
|
+
],
|
|
31
|
+
controlConnections: 2,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function fakeNode(): FakeNode {
|
|
35
|
+
const node: FakeNode = {
|
|
36
|
+
calls: [],
|
|
37
|
+
listCalls: 0,
|
|
38
|
+
async listStrands() {
|
|
39
|
+
node.listCalls++;
|
|
40
|
+
if (node.failWith) throw node.failWith;
|
|
41
|
+
return SNAPSHOT;
|
|
42
|
+
},
|
|
43
|
+
async removeStrand(strandId, opts) {
|
|
44
|
+
node.calls.push({ strandId, confirm: opts.confirm });
|
|
45
|
+
if (node.failWith) throw node.failWith;
|
|
46
|
+
const result: StrandRemovalResult = {
|
|
47
|
+
strandId,
|
|
48
|
+
published: true,
|
|
49
|
+
type: 'o',
|
|
50
|
+
removed: true,
|
|
51
|
+
alone: false,
|
|
52
|
+
};
|
|
53
|
+
return result;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
return node;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Await a call expected to reject, assert it is a StrandError, and hand it back. */
|
|
60
|
+
async function catchStrandError(p: Promise<unknown>): Promise<StrandError> {
|
|
61
|
+
const err = await p.then(() => undefined, (e: unknown) => e);
|
|
62
|
+
expect(err).toBeInstanceOf(StrandError);
|
|
63
|
+
return err as StrandError;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let node: FakeNode;
|
|
67
|
+
let service: StrandService;
|
|
68
|
+
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
node = fakeNode();
|
|
71
|
+
service = new StrandService({ cadreNode: node });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('StrandService.list', () => {
|
|
75
|
+
it('passes the node snapshot through unchanged', async () => {
|
|
76
|
+
await expect(service.list()).resolves.toEqual(SNAPSHOT);
|
|
77
|
+
expect(node.listCalls).toBe(1);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('StrandService.remove — id validation', () => {
|
|
82
|
+
it.each([
|
|
83
|
+
['blank', ''],
|
|
84
|
+
['whitespace-only', ' '],
|
|
85
|
+
['tab/newline only', '\t\n'],
|
|
86
|
+
])('rejects a %s id with invalid_id and never calls the node', async (_label, id) => {
|
|
87
|
+
await expect(service.remove(id, { confirm: false })).rejects.toMatchObject({
|
|
88
|
+
name: 'StrandError',
|
|
89
|
+
code: 'invalid_id',
|
|
90
|
+
});
|
|
91
|
+
expect(node.calls).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('forwards an id containing "/" rather than refusing it', async () => {
|
|
95
|
+
// The whole chain carries it: Fastify decodes `%2F` into `params.id`,
|
|
96
|
+
// `OwnerNodeClient` re-encodes it, and the admin channel splits on the still
|
|
97
|
+
// -encoded segment and decodes it back. Refusing here would break the only
|
|
98
|
+
// form that works. `strands-route.test.ts` pins the Fastify half.
|
|
99
|
+
await service.remove('ns/strand', { confirm: true });
|
|
100
|
+
expect(node.calls).toEqual([{ strandId: 'ns/strand', confirm: true }]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('trims a padded id before forwarding it', async () => {
|
|
104
|
+
await service.remove(' spaced ', { confirm: false });
|
|
105
|
+
expect(node.calls).toEqual([{ strandId: 'spaced', confirm: false }]);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('StrandService.remove — confirmation forwarding', () => {
|
|
110
|
+
it('forwards confirm: false as false', async () => {
|
|
111
|
+
await service.remove('open-one', { confirm: false });
|
|
112
|
+
expect(node.calls).toEqual([{ strandId: 'open-one', confirm: false }]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('forwards confirm: true as true', async () => {
|
|
116
|
+
await service.remove('closed-one', { confirm: true });
|
|
117
|
+
expect(node.calls).toEqual([{ strandId: 'closed-one', confirm: true }]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('does not retry a refused removal with confirmation added', async () => {
|
|
121
|
+
node.failWith = new OwnerNodeUnavailableError('needs confirmation', 'confirmation_required');
|
|
122
|
+
await expect(service.remove('closed-one', { confirm: false })).rejects.toBeInstanceOf(StrandError);
|
|
123
|
+
// Exactly one attempt, and it carried the caller's flag.
|
|
124
|
+
expect(node.calls).toEqual([{ strandId: 'closed-one', confirm: false }]);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('StrandService — error translation', () => {
|
|
129
|
+
const cases: Array<{ nodeCode: string | undefined; code: string; keepsMessage: boolean }> = [
|
|
130
|
+
{ nodeCode: 'confirmation_required', code: 'confirmation_required', keepsMessage: true },
|
|
131
|
+
{ nodeCode: 'bad_request', code: 'invalid_id', keepsMessage: true },
|
|
132
|
+
{ nodeCode: 'not_ready', code: 'node_unavailable', keepsMessage: false },
|
|
133
|
+
{ nodeCode: 'not_authorized', code: 'node_unavailable', keepsMessage: false },
|
|
134
|
+
{ nodeCode: undefined, code: 'node_unavailable', keepsMessage: false },
|
|
135
|
+
{ nodeCode: 'internal', code: 'internal', keepsMessage: true },
|
|
136
|
+
{ nodeCode: 'something_new', code: 'internal', keepsMessage: true },
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
for (const { nodeCode, code, keepsMessage } of cases) {
|
|
140
|
+
it(`maps nodeCode ${nodeCode ?? '(absent)'} → ${code}`, async () => {
|
|
141
|
+
const message = `node said ${nodeCode ?? 'nothing'}`;
|
|
142
|
+
node.failWith = new OwnerNodeUnavailableError(message, nodeCode);
|
|
143
|
+
const err = await catchStrandError(service.remove('x', { confirm: false }));
|
|
144
|
+
expect(err.code).toBe(code);
|
|
145
|
+
if (keepsMessage) {
|
|
146
|
+
expect(err.message).toBe(message);
|
|
147
|
+
} else {
|
|
148
|
+
// Wrapped, but the node's words survive inside.
|
|
149
|
+
expect(err.message).toContain(message);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
it('translates a transport failure (no nodeCode) on list() too', async () => {
|
|
155
|
+
node.failWith = new OwnerNodeUnavailableError('Owner node unreachable: ECONNREFUSED');
|
|
156
|
+
const err = await catchStrandError(service.list());
|
|
157
|
+
expect(err.code).toBe('node_unavailable');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('rethrows a non-owner-node error unchanged rather than dressing it as a strand error', async () => {
|
|
161
|
+
const boom = new TypeError('something else broke');
|
|
162
|
+
node.failWith = boom;
|
|
163
|
+
await expect(service.list()).rejects.toBe(boom);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strand management for cadre-host. See ./strand-service.ts and ./types.ts.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { StrandService, createStrandHandlers } from './strand-service.js';
|
|
6
|
+
export type { StrandServiceOptions, CadreNodeLike } from './strand-service.js';
|
|
7
|
+
export { StrandError } from './types.js';
|
|
8
|
+
export type {
|
|
9
|
+
StrandSummary,
|
|
10
|
+
StrandListSnapshot,
|
|
11
|
+
StrandRemovalResult,
|
|
12
|
+
StrandHandlers,
|
|
13
|
+
StrandErrorCode,
|
|
14
|
+
} from './types.js';
|