@camstack/server 1.1.54 → 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.
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UPLOAD_TRPC_PATH = void 0;
4
+ exports.authorizeUploadRequest = authorizeUploadRequest;
5
+ const scope_access_js_1 = require("./trpc/scope-access.js");
6
+ exports.UPLOAD_TRPC_PATH = 'addons.installPackage';
7
+ /**
8
+ * Validate a `cst_*` scoped token via the `user-management` cap singleton.
9
+ * Returns null when the singleton isn't mounted yet (boot race) or the
10
+ * token doesn't validate. Caller treats both as auth failure.
11
+ */
12
+ async function validateScopedTokenViaCap(addonRegistry, token) {
13
+ const capRegistry = addonRegistry.getCapabilityRegistry();
14
+ // Documented type boundary: the registry returns the provider untyped; the
15
+ // `user-management` cap's `validateScopedToken` surface is asserted here
16
+ // (same shape the tRPC middleware relies on).
17
+ const userMgmt = capRegistry.getSingleton('user-management');
18
+ if (!userMgmt)
19
+ return null;
20
+ return userMgmt.validateScopedToken({ token });
21
+ }
22
+ function isUploadAllowed(scoped) {
23
+ return (0, scope_access_js_1.checkScopeAccess)(scoped.scopes, exports.UPLOAD_TRPC_PATH).ok;
24
+ }
25
+ /**
26
+ * Run the full upload auth chain. Returns `{ok: true}` on success, or the
27
+ * HTTP status + error message the route should reply with:
28
+ * - 401 `Unauthorized` when no Authorization header is present,
29
+ * - 403 `Forbidden: <reason>` for any recognised-but-insufficient token.
30
+ */
31
+ async function authorizeUploadRequest(args) {
32
+ const { authHeader, authService, addonRegistry } = args;
33
+ if (!authHeader) {
34
+ return { ok: false, status: 401, error: 'Unauthorized' };
35
+ }
36
+ const token = authHeader.replace('Bearer ', '');
37
+ let authReason;
38
+ // Try JWT first — fastest path + carries the isAdmin flag directly.
39
+ try {
40
+ const payload = authService.verifyToken(token);
41
+ if (payload.isAdmin) {
42
+ return { ok: true };
43
+ }
44
+ authReason = 'JWT is not admin';
45
+ }
46
+ catch {
47
+ // Not a JWT (or invalid signature) — fall through to scoped-token path.
48
+ }
49
+ try {
50
+ const record = await validateScopedTokenViaCap(addonRegistry, token);
51
+ if (!record) {
52
+ authReason = authReason ?? 'token not recognised';
53
+ }
54
+ else if (isUploadAllowed(record)) {
55
+ return { ok: true };
56
+ }
57
+ else {
58
+ authReason = `scoped token lacks create access on '${exports.UPLOAD_TRPC_PATH}'`;
59
+ }
60
+ }
61
+ catch (err) {
62
+ authReason = `scoped token validation failed: ${err instanceof Error ? err.message : String(err)}`;
63
+ }
64
+ return {
65
+ ok: false,
66
+ status: 403,
67
+ error: `Forbidden: ${authReason ?? 'admin or upload-scoped token required'}`,
68
+ };
69
+ }
@@ -0,0 +1,76 @@
1
+ "use strict";
2
+ /**
3
+ * Public-auth rate limiting — fixed window per (bucket, client ip).
4
+ *
5
+ * The public auth surface (login, TOTP verify, passkey ceremonies,
6
+ * handoff redeem, session exchange/upgrade) previously had NO
7
+ * throttling: unlimited online credential guessing. This module is the
8
+ * first line of defence — deliberately simple (in-memory fixed window),
9
+ * because the deployment is a single hub process and the goal is to
10
+ * blunt brute force, not to be a distributed quota system.
11
+ *
12
+ * Pure + clock-injectable so specs drive it directly. The key map is
13
+ * BOUNDED (`maxKeys`, oldest-window evicted first) so an attacker
14
+ * rotating spoofed source addresses cannot grow it without limit.
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.createRateLimiter = createRateLimiter;
18
+ exports.clientKeyFromRequest = clientKeyFromRequest;
19
+ function createRateLimiter(options) {
20
+ const maxKeys = options.maxKeys ?? 10_000;
21
+ const now = options.now ?? Date.now;
22
+ const entries = new Map();
23
+ const evictOldest = () => {
24
+ let oldestKey = null;
25
+ let oldestStart = Number.POSITIVE_INFINITY;
26
+ for (const [key, entry] of entries) {
27
+ if (entry.windowStart < oldestStart) {
28
+ oldestStart = entry.windowStart;
29
+ oldestKey = key;
30
+ }
31
+ }
32
+ if (oldestKey !== null)
33
+ entries.delete(oldestKey);
34
+ };
35
+ return {
36
+ check(key) {
37
+ const at = now();
38
+ const existing = entries.get(key);
39
+ if (!existing || at - existing.windowStart >= options.windowMs) {
40
+ if (!entries.has(key) && entries.size >= maxKeys)
41
+ evictOldest();
42
+ entries.set(key, { windowStart: at, count: 1 });
43
+ return { allowed: true, retryAfterSeconds: 0 };
44
+ }
45
+ if (existing.count < options.max) {
46
+ existing.count += 1;
47
+ return { allowed: true, retryAfterSeconds: 0 };
48
+ }
49
+ const retryAfterSeconds = Math.max(1, Math.ceil((existing.windowStart + options.windowMs - at) / 1000));
50
+ return { allowed: false, retryAfterSeconds };
51
+ },
52
+ size() {
53
+ return entries.size;
54
+ },
55
+ };
56
+ }
57
+ /**
58
+ * Client key for a tRPC/Fastify request principal: `FastifyRequest.ip`
59
+ * (proxy-aware when Fastify's `trustProxy` is on) or the raw socket
60
+ * address for WS-upgrade `IncomingMessage`s. `null` = no originating
61
+ * request (mesh-internal `createCaller` invocation) — never limited.
62
+ */
63
+ function clientKeyFromRequest(req) {
64
+ if (req === null || typeof req !== 'object' || req === undefined)
65
+ return null;
66
+ const ip = Reflect.get(req, 'ip');
67
+ if (typeof ip === 'string' && ip.length > 0)
68
+ return ip;
69
+ const socket = Reflect.get(req, 'socket');
70
+ if (socket !== null && typeof socket === 'object') {
71
+ const remote = Reflect.get(socket, 'remoteAddress');
72
+ if (typeof remote === 'string' && remote.length > 0)
73
+ return remote;
74
+ }
75
+ return null;
76
+ }
package/dist/main.js CHANGED
@@ -80,6 +80,7 @@ const trpc_router_1 = require("./api/trpc/trpc.router");
80
80
  const core_cap_bridge_1 = require("./api/trpc/core-cap-bridge");
81
81
  const trpc_context_1 = require("./api/trpc/trpc.context");
82
82
  const addon_upload_1 = require("./api/addon-upload");
83
+ const server_upload_1 = require("./api/server-upload");
83
84
  const auth_whoami_1 = require("./api/auth-whoami");
84
85
  const session_cookie_js_1 = require("./auth/session-cookie.js");
85
86
  const health_routes_1 = require("./api/health/health.routes");
@@ -231,6 +232,15 @@ async function bootstrap() {
231
232
  const uploadLogger = app.get(logging_service_1.LoggingService).createLogger('addon-upload');
232
233
  await (0, addon_upload_1.registerAddonUploadRoute)(fastify, uploadAddonBridge, uploadAuthService, uploadMoleculer, uploadAddonRegistry, uploadAddonPackage, uploadLogger);
233
234
  console.log('[bootstrap] Addon upload route registered at POST /api/addons/upload');
235
+ // Dev server-deploy channel gateway (`camstack deploy-server`). MUST be
236
+ // registered AFTER registerAddonUploadRoute — it reuses the multipart
237
+ // plugin that route installs on this instance.
238
+ (0, server_upload_1.registerServerUploadRoute)(fastify, {
239
+ authService: uploadAuthService,
240
+ addonRegistry: uploadAddonRegistry,
241
+ logger: app.get(logging_service_1.LoggingService).createLogger('server-upload'),
242
+ });
243
+ console.log('[bootstrap] Server upload route registered at POST /api/server-upload');
234
244
  // Companion endpoint: /api/auth/whoami — validates JWT or cst_*
235
245
  // scoped tokens, returns the resolved identity + scope summary.
236
246
  // Mirrors the addon-upload auth chain so the CLI can ping for
@@ -435,7 +445,7 @@ async function bootstrap() {
435
445
  : 'application/octet-stream';
436
446
  const stream = fs.createReadStream(resolved);
437
447
  return reply
438
- .header('cache-control', addonBundleCacheControl(resolved))
448
+ .header('cache-control', (0, spa_static_1.addonBundleCacheControl)(resolved))
439
449
  .type(contentType)
440
450
  .send(stream);
441
451
  });
@@ -467,7 +477,7 @@ async function bootstrap() {
467
477
  : 'application/octet-stream';
468
478
  const stream = fs.createReadStream(resolved);
469
479
  return reply
470
- .header('cache-control', addonBundleCacheControl(resolved))
480
+ .header('cache-control', (0, spa_static_1.addonBundleCacheControl)(resolved))
471
481
  .type(contentType)
472
482
  .send(stream);
473
483
  });
@@ -941,7 +951,16 @@ async function bootstrap() {
941
951
  try {
942
952
  const bootAddonRegistry = app.get(addon_registry_service_1.AddonRegistryService);
943
953
  const capRegistry = bootAddonRegistry.getCapabilityRegistry();
944
- let adminUI = capRegistry?.getSingleton('admin-ui');
954
+ // HUB-NODE-SCOPED resolution — never the cluster-global singleton.
955
+ // Agent nodes register the same `admin-ui` cap through their own
956
+ // agent-ui addon (placement: agent-only), and the cluster-elected
957
+ // active provider can land on the AGENT's registration depending on
958
+ // node boot order. Its `getStaticDir()` then answers with a path on
959
+ // the agent's filesystem — nonexistent here — and the SPA catch-all
960
+ // silently never registers (every `GET /` 404s until the next
961
+ // restart re-rolls the election). `getSingletonForNode(cap, 'hub')`
962
+ // only ever resolves providers hosted on THIS node.
963
+ let adminUI = capRegistry?.getSingletonForNode('admin-ui', 'hub');
945
964
  // CAMSTACK_SKIP_ADMIN_UI_WAIT — bypass the 60s poll. Used by the
946
965
  // e2e harness, which doesn't need the SPA served and spawns hubs
947
966
  // with strict boot timeouts. Production keeps the poll so cold
@@ -959,7 +978,7 @@ async function bootstrap() {
959
978
  const deadline = Date.now() + ADMIN_UI_WAIT_MS;
960
979
  while (!adminUI && Date.now() < deadline) {
961
980
  await new Promise((r) => setTimeout(r, POLL_MS));
962
- adminUI = capRegistry.getSingleton('admin-ui');
981
+ adminUI = capRegistry.getSingletonForNode('admin-ui', 'hub');
963
982
  }
964
983
  }
965
984
  if (adminUI) {
@@ -1055,7 +1074,10 @@ async function bootstrap() {
1055
1074
  try {
1056
1075
  const bootAddonRegistry = app.get(addon_registry_service_1.AddonRegistryService);
1057
1076
  const capRegistry = bootAddonRegistry.getCapabilityRegistry();
1058
- let viewerUI = capRegistry?.getSingleton('viewer-ui');
1077
+ // Hub-node-scoped for the same reason as admin-ui above: a remote
1078
+ // node's registration must never win the election for the provider
1079
+ // whose staticDir this process reads from local disk.
1080
+ let viewerUI = capRegistry?.getSingletonForNode('viewer-ui', 'hub');
1059
1081
  const skipViewerUIWait = process.env['CAMSTACK_SKIP_VIEWER_UI_WAIT'] === '1';
1060
1082
  if (!viewerUI && capRegistry && !skipViewerUIWait) {
1061
1083
  // The viewer-ui addon runs in its own forked runner and registers a few
@@ -1066,7 +1088,7 @@ async function bootstrap() {
1066
1088
  const deadline = Date.now() + VIEWER_UI_WAIT_MS;
1067
1089
  while (!viewerUI && Date.now() < deadline) {
1068
1090
  await new Promise((r) => setTimeout(r, POLL_MS));
1069
- viewerUI = capRegistry.getSingleton('viewer-ui');
1091
+ viewerUI = capRegistry.getSingletonForNode('viewer-ui', 'hub');
1070
1092
  }
1071
1093
  }
1072
1094
  if (viewerUI) {
@@ -1211,22 +1233,6 @@ function readHubVersion() {
1211
1233
  /**
1212
1234
  * Build an AddonHttpReply wrapper around a Fastify reply.
1213
1235
  */
1214
- /**
1215
- * Cache-Control for an addon MF bundle file. The entry point
1216
- * (`remoteEntry.js` / `widgets.mjs`) has a STABLE filename — only its
1217
- * `?v=` query changes across builds — so it MUST revalidate, otherwise a
1218
- * caching proxy (Cloudflare edge, whose default browser-cache TTL was
1219
- * serving these 4h) keeps handing out a stale entry whose export map
1220
- * lacks the current widgets ("bundle does not export this stableId",
1221
- * tunnel-only). The sibling chunks are CONTENT-HASHED in their filenames
1222
- * (`dist-<hash>.mjs`, `_virtual_mf-<hash>.mjs`), so they are safely
1223
- * immutable. The revalidation cost is a cheap 304 on a ~4KB entry.
1224
- */
1225
- function addonBundleCacheControl(filePath) {
1226
- const base = path.basename(filePath);
1227
- const isMfEntry = base === 'remoteEntry.js' || base === 'widgets.mjs';
1228
- return isMfEntry ? 'no-cache, must-revalidate' : 'public, max-age=31536000, immutable';
1229
- }
1230
1236
  function buildAddonReply(reply) {
1231
1237
  const wrapper = {
1232
1238
  status(code) {