@camstack/server 1.1.53 → 1.1.55
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/api/addon-upload.js +14 -124
- package/dist/api/core/auth.router.js +38 -8
- package/dist/api/health/health.routes.js +52 -7
- package/dist/api/server-upload.js +188 -0
- package/dist/api/static/spa-static.js +23 -0
- package/dist/api/tarball-manifest.js +86 -0
- package/dist/api/trpc/share-view-access.js +30 -0
- package/dist/api/trpc/trpc.middleware.js +10 -0
- package/dist/api/upload-auth.js +69 -0
- package/dist/auth/auth-rate-limit.js +76 -0
- package/dist/main.js +29 -23
- package/dist/server-root/index.js +264 -61
- package/package.json +4 -4
package/dist/api/addon-upload.js
CHANGED
|
@@ -40,8 +40,8 @@ exports.registerAddonUploadRoute = registerAddonUploadRoute;
|
|
|
40
40
|
const fs = __importStar(require("node:fs"));
|
|
41
41
|
const path = __importStar(require("node:path"));
|
|
42
42
|
const os = __importStar(require("node:os"));
|
|
43
|
-
const
|
|
44
|
-
const
|
|
43
|
+
const tarball_manifest_js_1 = require("./tarball-manifest.js");
|
|
44
|
+
const upload_auth_js_1 = require("./upload-auth.js");
|
|
45
45
|
const addon_package_service_js_1 = require("../core/addon/addon-package.service.js");
|
|
46
46
|
const index_js_1 = require("../server-root/index.js");
|
|
47
47
|
const deploy_stage_registry_js_1 = require("./deploy-stage-registry.js");
|
|
@@ -78,40 +78,6 @@ function registerDeployBundleRoute(fastify) {
|
|
|
78
78
|
reply.header('content-type', 'application/octet-stream').send(buffer);
|
|
79
79
|
});
|
|
80
80
|
}
|
|
81
|
-
/**
|
|
82
|
-
* Validate a `cst_*` scoped token via the `user-management` cap singleton.
|
|
83
|
-
*
|
|
84
|
-
* The local `AuthService.validateScopedToken` indirection is unused (the
|
|
85
|
-
* `setScopedTokenManager` wire-up was never invoked), so we go straight
|
|
86
|
-
* to the cap registry — same path the generic addon-route handler in
|
|
87
|
-
* `main.ts` uses (the one that actually works end-to-end).
|
|
88
|
-
*
|
|
89
|
-
* Returns null when the singleton isn't mounted yet (boot race) or the
|
|
90
|
-
* token doesn't validate. Caller treats both as auth failure.
|
|
91
|
-
*/
|
|
92
|
-
async function validateScopedTokenViaCap(addonRegistry, token) {
|
|
93
|
-
const capRegistry = addonRegistry.getCapabilityRegistry();
|
|
94
|
-
const userMgmt = capRegistry.getSingleton('user-management');
|
|
95
|
-
if (!userMgmt)
|
|
96
|
-
return null;
|
|
97
|
-
return userMgmt.validateScopedToken({ token });
|
|
98
|
-
}
|
|
99
|
-
/**
|
|
100
|
-
* REST endpoint `/api/addons/upload` shares the scope-gate of the
|
|
101
|
-
* `addons.installPackage` cap method (semantically equivalent — both
|
|
102
|
-
* install a tarball under the system-scope `addons` cap with `create`
|
|
103
|
-
* access). Reuse the shared scope matcher so the gate stays in sync
|
|
104
|
-
* with the tRPC middleware — no parallel REST-only ACL.
|
|
105
|
-
*
|
|
106
|
-
* Why not `addons.upload`? There is no `upload` method on the cap
|
|
107
|
-
* definition (this endpoint is Fastify-only), so `METHOD_ACCESS_MAP`
|
|
108
|
-
* has no row for it and `checkScopeAccess` falls through to deny.
|
|
109
|
-
*/
|
|
110
|
-
const UPLOAD_TRPC_PATH = 'addons.installPackage';
|
|
111
|
-
function isUploadAllowed(scoped) {
|
|
112
|
-
return (0, scope_access_js_1.checkScopeAccess)(scoped.scopes, UPLOAD_TRPC_PATH).ok;
|
|
113
|
-
}
|
|
114
|
-
const TARBALL_EXTENSIONS = ['.tgz', '.tar.gz'];
|
|
115
81
|
const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
116
82
|
const AGENT_DEPLOY_TIMEOUT_MS = 60_000;
|
|
117
83
|
const AGENT_DEPLOY_CONTROL_TIMEOUT_MS = 30_000;
|
|
@@ -128,104 +94,28 @@ function buildHubHttpSource(buffer, hubBaseUrl) {
|
|
|
128
94
|
bytes: staged.bytes,
|
|
129
95
|
};
|
|
130
96
|
}
|
|
131
|
-
function isTarball(filename) {
|
|
132
|
-
return TARBALL_EXTENSIONS.some((ext) => filename.endsWith(ext));
|
|
133
|
-
}
|
|
134
|
-
/**
|
|
135
|
-
* Validate an uploaded tarball by extracting its `package.json` and
|
|
136
|
-
* checking it declares `name` + `version`. Runs BEFORE the hub/agent
|
|
137
|
-
* branch so broken archives are rejected at the gateway instead of
|
|
138
|
-
* failing mid-extraction on the agent (which has no clean rollback).
|
|
139
|
-
*
|
|
140
|
-
* The routine writes the buffer to a scratch path and invokes `tar` with
|
|
141
|
-
* `-xzO` to stream-extract just `package/package.json` to stdout. No full
|
|
142
|
-
* unpack is needed — npm-pack layout always puts the manifest at that
|
|
143
|
-
* fixed inner path. Returns null (not throw) on malformed archives so
|
|
144
|
-
* the caller can surface a precise 400 response.
|
|
145
|
-
*/
|
|
146
|
-
function validateTarball(buffer, filename) {
|
|
147
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'camstack-tarball-check-'));
|
|
148
|
-
const tgzPath = path.join(tmpDir, filename);
|
|
149
|
-
try {
|
|
150
|
-
fs.writeFileSync(tgzPath, buffer);
|
|
151
|
-
const output = (0, node_child_process_1.execFileSync)('tar', ['-xzO', '-f', tgzPath, 'package/package.json'], {
|
|
152
|
-
timeout: 5_000,
|
|
153
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
154
|
-
});
|
|
155
|
-
const parsed = JSON.parse(output.toString('utf8'));
|
|
156
|
-
if (!parsed || typeof parsed !== 'object')
|
|
157
|
-
return null;
|
|
158
|
-
const pkg = parsed;
|
|
159
|
-
if (typeof pkg['name'] !== 'string' || typeof pkg['version'] !== 'string')
|
|
160
|
-
return null;
|
|
161
|
-
return { name: pkg['name'], version: pkg['version'] };
|
|
162
|
-
}
|
|
163
|
-
catch {
|
|
164
|
-
return null;
|
|
165
|
-
}
|
|
166
|
-
finally {
|
|
167
|
-
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
97
|
async function registerAddonUploadRoute(fastify, addonBridge, authService, moleculer, addonRegistry, addonPackageService, logger) {
|
|
171
98
|
await fastify.register(Promise.resolve().then(() => __importStar(require('@fastify/multipart'))), {
|
|
172
99
|
limits: { fileSize: MAX_UPLOAD_BYTES },
|
|
173
100
|
});
|
|
174
101
|
registerDeployBundleRoute(fastify);
|
|
175
102
|
fastify.post('/api/addons/upload', async (request, reply) => {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
let authReason;
|
|
187
|
-
// Try JWT first — fastest path + carries isAdmin flag directly.
|
|
188
|
-
try {
|
|
189
|
-
const payload = authService.verifyToken(token);
|
|
190
|
-
if (payload.isAdmin) {
|
|
191
|
-
authOk = true;
|
|
192
|
-
}
|
|
193
|
-
else {
|
|
194
|
-
authReason = 'JWT is not admin';
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
catch {
|
|
198
|
-
// Not a JWT (or invalid signature) — fall through to scoped-token path.
|
|
199
|
-
}
|
|
200
|
-
if (!authOk) {
|
|
201
|
-
// `cst_*` scoped tokens — only the cap-registry singleton actually
|
|
202
|
-
// validates; the local AuthService bridge was never wired. See main.ts:652.
|
|
203
|
-
try {
|
|
204
|
-
const record = await validateScopedTokenViaCap(addonRegistry, token);
|
|
205
|
-
if (!record) {
|
|
206
|
-
authReason = authReason ?? 'token not recognised';
|
|
207
|
-
}
|
|
208
|
-
else if (isUploadAllowed(record)) {
|
|
209
|
-
authOk = true;
|
|
210
|
-
}
|
|
211
|
-
else {
|
|
212
|
-
authReason = `scoped token lacks create access on '${UPLOAD_TRPC_PATH}'`;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
catch (err) {
|
|
216
|
-
authReason = `scoped token validation failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
if (!authOk) {
|
|
220
|
-
return reply
|
|
221
|
-
.status(403)
|
|
222
|
-
.send({ error: `Forbidden: ${authReason ?? 'admin or upload-scoped token required'}` });
|
|
103
|
+
// Auth chain (shared with /api/server-upload): JWT (isAdmin) OR scoped
|
|
104
|
+
// token whose scopes grant `create` access on the `addons` capability.
|
|
105
|
+
// See upload-auth.ts for the full rationale.
|
|
106
|
+
const auth = await (0, upload_auth_js_1.authorizeUploadRequest)({
|
|
107
|
+
authHeader: request.headers.authorization,
|
|
108
|
+
authService,
|
|
109
|
+
addonRegistry,
|
|
110
|
+
});
|
|
111
|
+
if (!auth.ok) {
|
|
112
|
+
return reply.status(auth.status).send({ error: auth.error });
|
|
223
113
|
}
|
|
224
114
|
const data = await request.file();
|
|
225
115
|
if (!data) {
|
|
226
116
|
return reply.status(400).send({ error: 'No file uploaded' });
|
|
227
117
|
}
|
|
228
|
-
if (!
|
|
118
|
+
if (!(0, tarball_manifest_js_1.isTarballFilename)(data.filename)) {
|
|
229
119
|
return reply.status(400).send({ error: 'File must be a .tgz or .tar.gz archive' });
|
|
230
120
|
}
|
|
231
121
|
// `nodeId` and `addonId` come through as multipart text fields.
|
|
@@ -249,7 +139,7 @@ async function registerAddonUploadRoute(fastify, addonBridge, authService, molec
|
|
|
249
139
|
// Gate: reject archives that don't expose a parseable package.json with
|
|
250
140
|
// name + version. The hub installer did this implicitly via npm; the
|
|
251
141
|
// agent path would otherwise fail mid-extraction with no clean rollback.
|
|
252
|
-
const manifest =
|
|
142
|
+
const manifest = (0, tarball_manifest_js_1.readTarballManifest)(buffer, data.filename);
|
|
253
143
|
if (!manifest) {
|
|
254
144
|
return reply.status(400).send({
|
|
255
145
|
error: 'Tarball missing or malformed package/package.json (name + version required)',
|
|
@@ -24,6 +24,36 @@ const share_token_service_js_1 = require("../../core/auth/share-token.service.js
|
|
|
24
24
|
const handoff_code_service_js_1 = require("../../core/auth/handoff-code.service.js");
|
|
25
25
|
const session_cookie_js_1 = require("../../auth/session-cookie.js");
|
|
26
26
|
const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
|
|
27
|
+
const auth_rate_limit_js_1 = require("../../auth/auth-rate-limit.js");
|
|
28
|
+
// ── Public-auth rate limiting ────────────────────────────────────────
|
|
29
|
+
// The public surface below (credential validation, passkey ceremonies,
|
|
30
|
+
// one-time-code redemption) had NO throttling — unlimited online
|
|
31
|
+
// guessing. Two tiers, fixed window per (procedure, client ip):
|
|
32
|
+
// • credential tier — anything that VERIFIES a secret (password, TOTP,
|
|
33
|
+
// passkey assertion, handoff code, session exchange): 10/min,
|
|
34
|
+
// • ceremony tier — begin-legs that only mint a challenge: 30/min.
|
|
35
|
+
// Mesh-internal calls (no originating request) are never limited.
|
|
36
|
+
const CREDENTIAL_ATTEMPT_LIMITER = (0, auth_rate_limit_js_1.createRateLimiter)({ windowMs: 60_000, max: 10 });
|
|
37
|
+
const CEREMONY_LIMITER = (0, auth_rate_limit_js_1.createRateLimiter)({ windowMs: 60_000, max: 30 });
|
|
38
|
+
function makeRateLimitedProcedure(limiter) {
|
|
39
|
+
return trpc_middleware_js_1.publicProcedure.use(({ ctx, path, next }) => {
|
|
40
|
+
const key = (0, auth_rate_limit_js_1.clientKeyFromRequest)(ctx.req);
|
|
41
|
+
if (key !== null) {
|
|
42
|
+
const verdict = limiter.check(`${path}:${key}`);
|
|
43
|
+
if (!verdict.allowed) {
|
|
44
|
+
throw new server_1.TRPCError({
|
|
45
|
+
code: 'TOO_MANY_REQUESTS',
|
|
46
|
+
message: `Too many attempts — retry in ${verdict.retryAfterSeconds}s`,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return next({ ctx });
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/** Secret-verifying legs (password / TOTP / passkey assertion / codes). */
|
|
54
|
+
const credentialProcedure = makeRateLimitedProcedure(CREDENTIAL_ATTEMPT_LIMITER);
|
|
55
|
+
/** Challenge-minting begin-legs — looser, they verify nothing. */
|
|
56
|
+
const ceremonyProcedure = makeRateLimitedProcedure(CEREMONY_LIMITER);
|
|
27
57
|
/**
|
|
28
58
|
* The available second-factor kinds a user may satisfy after the
|
|
29
59
|
* password leg. Capability-driven — `totp` maps to the
|
|
@@ -351,7 +381,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
|
|
|
351
381
|
return factors;
|
|
352
382
|
};
|
|
353
383
|
return (0, trpc_middleware_js_1.trpcRouter)({
|
|
354
|
-
login:
|
|
384
|
+
login: credentialProcedure
|
|
355
385
|
.input(zod_1.z.object({ username: zod_1.z.string(), password: zod_1.z.string() }))
|
|
356
386
|
.output(LoginResultSchema)
|
|
357
387
|
.mutation(async ({ input }) => {
|
|
@@ -431,7 +461,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
|
|
|
431
461
|
* • User vanished between leg 1 and leg 2 → 401 (operator was
|
|
432
462
|
* deleted concurrently — rare).
|
|
433
463
|
*/
|
|
434
|
-
loginVerifyTotp:
|
|
464
|
+
loginVerifyTotp: credentialProcedure
|
|
435
465
|
.input(zod_1.z.object({ challengeToken: zod_1.z.string(), code: zod_1.z.string() }))
|
|
436
466
|
.output(LoginResultSchema)
|
|
437
467
|
.mutation(async ({ input }) => {
|
|
@@ -458,7 +488,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
|
|
|
458
488
|
* enrolled credentials (strict-user `allowCredentials`). PUBLIC —
|
|
459
489
|
* the client is still pre-session at this point.
|
|
460
490
|
*/
|
|
461
|
-
loginBeginPasskey:
|
|
491
|
+
loginBeginPasskey: ceremonyProcedure
|
|
462
492
|
.input(zod_1.z.object({ challengeToken: zod_1.z.string() }))
|
|
463
493
|
.output(OptionsJsonSchema)
|
|
464
494
|
.mutation(async ({ input }) => {
|
|
@@ -478,7 +508,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
|
|
|
478
508
|
* real session JWT through the SAME `auth.signToken` path
|
|
479
509
|
* `loginVerifyTotp` uses (fresh user re-fetch for up-to-date scopes).
|
|
480
510
|
*/
|
|
481
|
-
loginVerifyPasskey:
|
|
511
|
+
loginVerifyPasskey: credentialProcedure
|
|
482
512
|
.input(zod_1.z.object({ challengeToken: zod_1.z.string(), response: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()) }))
|
|
483
513
|
.output(LoginResultSchema)
|
|
484
514
|
.mutation(async ({ input }) => {
|
|
@@ -510,7 +540,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
|
|
|
510
540
|
* public auth procedure today; the challenge store is bounded only
|
|
511
541
|
* by its 5-min TTL prune.
|
|
512
542
|
*/
|
|
513
|
-
passkeyLoginBegin:
|
|
543
|
+
passkeyLoginBegin: ceremonyProcedure
|
|
514
544
|
.input(zod_1.z.void())
|
|
515
545
|
.output(OptionsJsonSchema)
|
|
516
546
|
.mutation(async () => {
|
|
@@ -534,7 +564,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
|
|
|
534
564
|
* itself is skipped here — the primary factor WAS the passkey, and
|
|
535
565
|
* re-proving it adds nothing.
|
|
536
566
|
*/
|
|
537
|
-
passkeyLoginFinish:
|
|
567
|
+
passkeyLoginFinish: credentialProcedure
|
|
538
568
|
.input(zod_1.z.object({ response: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()) }))
|
|
539
569
|
.output(LoginResultSchema)
|
|
540
570
|
.mutation(async ({ input }) => {
|
|
@@ -588,7 +618,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
|
|
|
588
618
|
* `mintSessionForUserId` tail (re-fetched user → up-to-date
|
|
589
619
|
* scopes), equivalent to what `auth.login` hands out.
|
|
590
620
|
*/
|
|
591
|
-
exchangeSession:
|
|
621
|
+
exchangeSession: credentialProcedure
|
|
592
622
|
.input(zod_1.z.object({}).optional())
|
|
593
623
|
.output(LoginResultSchema)
|
|
594
624
|
.mutation(async ({ ctx }) => {
|
|
@@ -639,7 +669,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
|
|
|
639
669
|
}),
|
|
640
670
|
/** PUBLIC — redeem a one-time handoff code for a session bearer.
|
|
641
671
|
* Unknown / expired / already-used codes → UNAUTHORIZED. */
|
|
642
|
-
redeemHandoffCode:
|
|
672
|
+
redeemHandoffCode: credentialProcedure
|
|
643
673
|
.input(zod_1.z.object({ code: zod_1.z.string().min(1) }))
|
|
644
674
|
.output(LoginResultSchema)
|
|
645
675
|
.mutation(async ({ input }) => {
|
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
*/
|
|
22
22
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
23
|
exports.buildHubHealth = buildHubHealth;
|
|
24
|
+
exports.isAuthorizedHealthRequest = isAuthorizedHealthRequest;
|
|
24
25
|
exports.registerHealthRoutes = registerHealthRoutes;
|
|
26
|
+
const session_cookie_js_1 = require("../../auth/session-cookie.js");
|
|
25
27
|
const AGENT_HEALTH_TIMEOUT_MS = 3_000;
|
|
26
28
|
function nowIso() {
|
|
27
29
|
return new Date().toISOString();
|
|
@@ -68,23 +70,64 @@ async function fetchAgentHealth(deps, nodeId) {
|
|
|
68
70
|
};
|
|
69
71
|
}
|
|
70
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* True when the request carries a valid SESSION-grade credential: a Bearer
|
|
75
|
+
* JWT or the httpOnly session cookie. Health details expose topology
|
|
76
|
+
* (agent ids, versions, pids) — public probes get only `{ok}`.
|
|
77
|
+
* Pure over (headers, verifier) so the spec exercises it directly.
|
|
78
|
+
*/
|
|
79
|
+
function isAuthorizedHealthRequest(headers, verifyToken) {
|
|
80
|
+
const bearer = headers.authorization?.startsWith('Bearer ')
|
|
81
|
+
? headers.authorization.slice('Bearer '.length)
|
|
82
|
+
: null;
|
|
83
|
+
const cookieToken = (0, session_cookie_js_1.readSessionCookieFromHeader)(headers.cookie);
|
|
84
|
+
for (const token of [bearer, cookieToken]) {
|
|
85
|
+
if (!token)
|
|
86
|
+
continue;
|
|
87
|
+
try {
|
|
88
|
+
if ((0, session_cookie_js_1.isSessionGradeJwtPayload)(verifyToken(token)))
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
/* invalid/expired — try the next credential */
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
71
97
|
function registerHealthRoutes(fastify, deps) {
|
|
98
|
+
const authorized = (req) => isAuthorizedHealthRequest({
|
|
99
|
+
...(typeof req.headers.authorization === 'string'
|
|
100
|
+
? { authorization: req.headers.authorization }
|
|
101
|
+
: {}),
|
|
102
|
+
...(typeof req.headers.cookie === 'string' ? { cookie: req.headers.cookie } : {}),
|
|
103
|
+
}, deps.verifyToken);
|
|
104
|
+
// PUBLIC probe: liveness only. Everything beyond `ok` (version, uptime,
|
|
105
|
+
// pid, agent topology) moved to the AUTHENTICATED /health/details — a
|
|
106
|
+
// public endpoint must not enumerate the deployment.
|
|
72
107
|
fastify.get('/health', async (_req, reply) => {
|
|
108
|
+
const ok = deps.isReady();
|
|
109
|
+
if (!ok)
|
|
110
|
+
reply.status(503);
|
|
111
|
+
return { ok };
|
|
112
|
+
});
|
|
113
|
+
// AUTHENTICATED detailed health — the payload /health used to return.
|
|
114
|
+
fastify.get('/health/details', async (req, reply) => {
|
|
115
|
+
if (!authorized(req))
|
|
116
|
+
return reply.status(401).send({ ok: false, error: 'unauthorized' });
|
|
73
117
|
const health = await buildHubHealth(deps);
|
|
74
118
|
if (!health.ok)
|
|
75
119
|
reply.status(503);
|
|
76
120
|
return health;
|
|
77
121
|
});
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return { service: 'camstack-hub', nodeId: 'hub', version: deps.hubVersion, name };
|
|
82
|
-
});
|
|
83
|
-
fastify.get('/health/agents', async () => {
|
|
122
|
+
fastify.get('/health/agents', async (req, reply) => {
|
|
123
|
+
if (!authorized(req))
|
|
124
|
+
return reply.status(401).send({ ok: false, error: 'unauthorized' });
|
|
84
125
|
const nodes = deps.agentRegistry.listNodeLiveness();
|
|
85
126
|
return { agents: nodes.filter((n) => !n.isHub && n.isOnline).map((n) => n.id) };
|
|
86
127
|
});
|
|
87
128
|
fastify.get('/health/agents/:nodeId', async (req, reply) => {
|
|
129
|
+
if (!authorized(req))
|
|
130
|
+
return reply.status(401).send({ ok: false, error: 'unauthorized' });
|
|
88
131
|
const { nodeId } = req.params;
|
|
89
132
|
if (!nodeId) {
|
|
90
133
|
return reply.status(400).send({ ok: false, error: 'nodeId required' });
|
|
@@ -95,7 +138,9 @@ function registerHealthRoutes(fastify, deps) {
|
|
|
95
138
|
}
|
|
96
139
|
return result;
|
|
97
140
|
});
|
|
98
|
-
fastify.get('/health/cluster', async () => {
|
|
141
|
+
fastify.get('/health/cluster', async (req, reply) => {
|
|
142
|
+
if (!authorized(req))
|
|
143
|
+
return reply.status(401).send({ ok: false, error: 'unauthorized' });
|
|
99
144
|
const hub = await buildHubHealth(deps);
|
|
100
145
|
// Enumeration only (fan-out-free); the $agent.health fan-out below is intentional.
|
|
101
146
|
const nodes = deps.agentRegistry.listNodeLiveness();
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.validateTarballSet = validateTarballSet;
|
|
37
|
+
exports.registerServerUploadRoute = registerServerUploadRoute;
|
|
38
|
+
/**
|
|
39
|
+
* POST /api/server-upload — the dev server-deploy channel's upload gateway.
|
|
40
|
+
*
|
|
41
|
+
* `camstack deploy-server` packs the whole `@camstack/server` workspace
|
|
42
|
+
* closure at one uniform dev version (`<base>-dev.<epochSeconds>`) and
|
|
43
|
+
* uploads every tarball here in ONE multipart request (plus a `version`
|
|
44
|
+
* field). The route validates fail-closed and stores the set under
|
|
45
|
+
* `<dataDir>/server-root/dev-uploads/<version>/` together with a
|
|
46
|
+
* `manifest.json` (package name → tgz filename) — which
|
|
47
|
+
* `RootUpdateService.stageAndActivate` later consumes to stage from the
|
|
48
|
+
* tarballs instead of the npm registry.
|
|
49
|
+
*
|
|
50
|
+
* Guards (all fail-closed, nothing is written until EVERY check passes):
|
|
51
|
+
* - same admin/upload-scope auth chain as `/api/addons/upload`
|
|
52
|
+
* (`authorizeUploadRequest`),
|
|
53
|
+
* - the version MUST be dev-channel shaped (`-dev.<epoch>` suffix) — this
|
|
54
|
+
* route serves ONLY the dev channel,
|
|
55
|
+
* - no path traversal in the version or any filename,
|
|
56
|
+
* - every tarball must carry `package/package.json` with an
|
|
57
|
+
* `@camstack/*` name and a version equal to the declared dev version.
|
|
58
|
+
*
|
|
59
|
+
* REGISTRATION ORDER: `@fastify/multipart` must already be registered on
|
|
60
|
+
* the instance (done by `registerAddonUploadRoute`) — this route only adds
|
|
61
|
+
* the handler.
|
|
62
|
+
*/
|
|
63
|
+
const fs = __importStar(require("node:fs"));
|
|
64
|
+
const path = __importStar(require("node:path"));
|
|
65
|
+
const index_js_1 = require("../server-root/index.js");
|
|
66
|
+
const tarball_manifest_js_1 = require("./tarball-manifest.js");
|
|
67
|
+
const upload_auth_js_1 = require("./upload-auth.js");
|
|
68
|
+
/**
|
|
69
|
+
* Version chars beyond the dev-channel shape check: strictly semver-ish
|
|
70
|
+
* (alnum, dots, dashes) so the version can never traverse out of
|
|
71
|
+
* `dev-uploads/` when used as a directory name.
|
|
72
|
+
*/
|
|
73
|
+
const SAFE_VERSION_RE = /^[0-9A-Za-z][0-9A-Za-z.-]*$/;
|
|
74
|
+
function isValidationFailure(value) {
|
|
75
|
+
return 'error' in value;
|
|
76
|
+
}
|
|
77
|
+
function hasPathTraversal(name) {
|
|
78
|
+
return name.includes('/') || name.includes('\\') || name.includes('..');
|
|
79
|
+
}
|
|
80
|
+
/** `@camstack/server` → `camstack-server.tgz` — server-derived, never client-named. */
|
|
81
|
+
function flattenedTgzName(packageName) {
|
|
82
|
+
return `${packageName.replace(/^@/, '').replace(/\//g, '-')}.tgz`;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Validate the whole tarball set against the declared dev version.
|
|
86
|
+
* Fail-closed: the FIRST failure rejects the entire upload — nothing is
|
|
87
|
+
* written unless every tarball passes.
|
|
88
|
+
*
|
|
89
|
+
* The path-traversal guard is defense-in-depth: `@fastify/multipart`
|
|
90
|
+
* already normalises filenames to their basename, and the stored tgz name
|
|
91
|
+
* is derived server-side from the validated PACKAGE name — a client
|
|
92
|
+
* filename never becomes a filesystem path.
|
|
93
|
+
*/
|
|
94
|
+
function validateTarballSet(files, version) {
|
|
95
|
+
const seen = new Set();
|
|
96
|
+
const validated = [];
|
|
97
|
+
for (const file of files) {
|
|
98
|
+
if (hasPathTraversal(file.filename)) {
|
|
99
|
+
return { error: `Refused: filename contains path separators (${file.filename})` };
|
|
100
|
+
}
|
|
101
|
+
if (!(0, tarball_manifest_js_1.isTarballFilename)(file.filename)) {
|
|
102
|
+
return { error: `Refused: ${file.filename} is not a .tgz/.tar.gz archive` };
|
|
103
|
+
}
|
|
104
|
+
const manifest = (0, tarball_manifest_js_1.readTarballManifest)(file.buffer, file.filename);
|
|
105
|
+
if (manifest === null) {
|
|
106
|
+
return {
|
|
107
|
+
error: `Refused: ${file.filename} is missing or has a malformed package/package.json`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
if (!manifest.name.startsWith('@camstack/')) {
|
|
111
|
+
return {
|
|
112
|
+
error: `Refused: ${file.filename} declares non-@camstack package "${manifest.name}"`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (manifest.version !== version) {
|
|
116
|
+
return {
|
|
117
|
+
error: `Refused: ${file.filename} (${manifest.name}) declares version ` +
|
|
118
|
+
`${manifest.version}, expected the uploaded dev version ${version}`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
if (seen.has(manifest.name)) {
|
|
122
|
+
return { error: `Refused: duplicate package in upload set (${manifest.name})` };
|
|
123
|
+
}
|
|
124
|
+
seen.add(manifest.name);
|
|
125
|
+
validated.push({ name: manifest.name, buffer: file.buffer });
|
|
126
|
+
}
|
|
127
|
+
return validated;
|
|
128
|
+
}
|
|
129
|
+
function registerServerUploadRoute(fastify, options) {
|
|
130
|
+
const { authService, addonRegistry, logger } = options;
|
|
131
|
+
fastify.post('/api/server-upload', async (request, reply) => {
|
|
132
|
+
const auth = await (0, upload_auth_js_1.authorizeUploadRequest)({
|
|
133
|
+
authHeader: request.headers.authorization,
|
|
134
|
+
authService,
|
|
135
|
+
addonRegistry,
|
|
136
|
+
});
|
|
137
|
+
if (!auth.ok) {
|
|
138
|
+
return reply.status(auth.status).send({ error: auth.error });
|
|
139
|
+
}
|
|
140
|
+
// Drain the multipart stream first (files can precede or follow the
|
|
141
|
+
// version field), then validate the whole set before touching disk.
|
|
142
|
+
let version = null;
|
|
143
|
+
const files = [];
|
|
144
|
+
for await (const part of request.parts()) {
|
|
145
|
+
if (part.type === 'file') {
|
|
146
|
+
files.push({ filename: part.filename, buffer: await part.toBuffer() });
|
|
147
|
+
}
|
|
148
|
+
else if (part.fieldname === 'version' && typeof part.value === 'string') {
|
|
149
|
+
version = part.value;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (version === null || version.length === 0) {
|
|
153
|
+
return reply.status(400).send({ error: 'Missing "version" field' });
|
|
154
|
+
}
|
|
155
|
+
if (!SAFE_VERSION_RE.test(version) || !(0, index_js_1.isDevChannelVersion)(version)) {
|
|
156
|
+
return reply.status(400).send({
|
|
157
|
+
error: `Refused: version "${version}" is not dev-channel shaped — this route only ` +
|
|
158
|
+
'accepts `<base>-dev.<epochSeconds>` versions produced by `camstack deploy-server`.',
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (files.length === 0) {
|
|
162
|
+
return reply.status(400).send({ error: 'No tarballs uploaded' });
|
|
163
|
+
}
|
|
164
|
+
const validated = validateTarballSet(files, version);
|
|
165
|
+
if (isValidationFailure(validated)) {
|
|
166
|
+
logger.warn('server-upload rejected', { meta: { version, error: validated.error } });
|
|
167
|
+
return reply.status(400).send({ error: validated.error });
|
|
168
|
+
}
|
|
169
|
+
const dataDir = path.resolve(options.dataDir ?? process.env['CAMSTACK_DATA'] ?? 'camstack-data');
|
|
170
|
+
const versionDir = (0, index_js_1.devUploadVersionDir)((0, index_js_1.serverRootDir)(dataDir), version);
|
|
171
|
+
fs.mkdirSync(versionDir, { recursive: true });
|
|
172
|
+
const packages = {};
|
|
173
|
+
for (const pkg of validated) {
|
|
174
|
+
const tgzName = flattenedTgzName(pkg.name);
|
|
175
|
+
fs.writeFileSync(path.join(versionDir, tgzName), pkg.buffer);
|
|
176
|
+
packages[pkg.name] = tgzName;
|
|
177
|
+
}
|
|
178
|
+
(0, index_js_1.writeDevUploadManifest)(versionDir, { version, packages });
|
|
179
|
+
logger.info('server-upload stored dev closure', {
|
|
180
|
+
meta: { version, packages: Object.keys(packages), dir: versionDir },
|
|
181
|
+
});
|
|
182
|
+
return reply.send({
|
|
183
|
+
success: true,
|
|
184
|
+
version,
|
|
185
|
+
packages: Object.keys(packages),
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
10
|
exports.spaAssetCacheControl = spaAssetCacheControl;
|
|
11
|
+
exports.addonBundleCacheControl = addonBundleCacheControl;
|
|
11
12
|
exports.contentTypeForPath = contentTypeForPath;
|
|
12
13
|
/**
|
|
13
14
|
* Cache-Control value for a static SPA asset addressed by its dist-relative
|
|
@@ -29,6 +30,28 @@ function spaAssetCacheControl(rel) {
|
|
|
29
30
|
}
|
|
30
31
|
return 'no-cache';
|
|
31
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Cache-Control for an addon MF bundle file (`/api/addon-widgets/…`).
|
|
35
|
+
*
|
|
36
|
+
* Immutable is granted ONLY to filenames carrying a Vite/rolldown content
|
|
37
|
+
* hash (`<name>-<hash8>.mjs`) — never by exclusion. The previous policy
|
|
38
|
+
* ("everything that isn't the entry is hashed") aged out the moment the
|
|
39
|
+
* rolldown MF plugin emitted `_stub.js`: a FIXED filename with MUTABLE
|
|
40
|
+
* content (the real exposes module). Browsers kept the year-long
|
|
41
|
+
* immutable copy across addon updates, its imports pointed at hashed
|
|
42
|
+
* sibling chunks that no longer existed, and the widget failed to load
|
|
43
|
+
* until a manual hard-reload. Unknown fixed names now revalidate — the
|
|
44
|
+
* cost is a cheap 304.
|
|
45
|
+
*/
|
|
46
|
+
function addonBundleCacheControl(filePath) {
|
|
47
|
+
const base = filePath.split('/').pop() ?? filePath;
|
|
48
|
+
// Vite/rolldown content hash: exactly 8 base64url chars between a
|
|
49
|
+
// hyphen and the extension (`dist-CYZr2fwk.mjs`, `….js-CQ-aEQ9b.mjs`).
|
|
50
|
+
if (/-[A-Za-z0-9_-]{8}\.(mjs|js|css)$/.test(base)) {
|
|
51
|
+
return 'public, max-age=31536000, immutable';
|
|
52
|
+
}
|
|
53
|
+
return 'no-cache, must-revalidate';
|
|
54
|
+
}
|
|
32
55
|
const CONTENT_TYPES = {
|
|
33
56
|
html: 'text/html; charset=utf-8',
|
|
34
57
|
js: 'text/javascript; charset=utf-8',
|