@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.
@@ -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 node_child_process_1 = require("node:child_process");
44
- const scope_access_js_1 = require("./trpc/scope-access.js");
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
- const authHeader = request.headers.authorization;
177
- if (!authHeader) {
178
- return reply.status(401).send({ error: 'Unauthorized' });
179
- }
180
- // Auth chain: JWT (isAdmin) OR scoped token whose scopes grant
181
- // `create` access on the `addons` capability. The scoped path is the
182
- // CLI's `camstack login` flow — fetches a long-lived upload-scoped
183
- // token so headless deploys don't need an admin password on disk.
184
- const token = authHeader.replace('Bearer ', '');
185
- let authOk = false;
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 (!isTarball(data.filename)) {
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 = validateTarball(buffer, data.filename);
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: trpc_middleware_js_1.publicProcedure
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: trpc_middleware_js_1.publicProcedure
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: trpc_middleware_js_1.publicProcedure
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: trpc_middleware_js_1.publicProcedure
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: trpc_middleware_js_1.publicProcedure
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: trpc_middleware_js_1.publicProcedure
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: trpc_middleware_js_1.publicProcedure
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: trpc_middleware_js_1.publicProcedure
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
- // Zero-config LAN discovery — unauthenticated, always 200. See DiscoveryInfo.
79
- fastify.get('/discovery', async () => {
80
- const name = process.env['CAMSTACK_HUB_NAME'] ?? 'CamStack Hub';
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',