@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.
@@ -0,0 +1,86 @@
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.isTarballFilename = isTarballFilename;
37
+ exports.readTarballManifest = readTarballManifest;
38
+ /**
39
+ * npm-pack tarball manifest inspection — shared by the addon upload route
40
+ * (`/api/addons/upload`) and the dev server-deploy upload route
41
+ * (`/api/server-upload`).
42
+ */
43
+ const node_child_process_1 = require("node:child_process");
44
+ const fs = __importStar(require("node:fs"));
45
+ const os = __importStar(require("node:os"));
46
+ const path = __importStar(require("node:path"));
47
+ const TARBALL_EXTENSIONS = ['.tgz', '.tar.gz'];
48
+ function isTarballFilename(filename) {
49
+ return TARBALL_EXTENSIONS.some((ext) => filename.endsWith(ext));
50
+ }
51
+ /**
52
+ * Validate an uploaded tarball by extracting its `package.json` and
53
+ * checking it declares `name` + `version`. Runs BEFORE any install branch
54
+ * so broken archives are rejected at the gateway instead of failing
55
+ * mid-extraction downstream (which has no clean rollback).
56
+ *
57
+ * The routine writes the buffer to a scratch path and invokes `tar` with
58
+ * `-xzO` to stream-extract just `package/package.json` to stdout. No full
59
+ * unpack is needed — npm-pack layout always puts the manifest at that
60
+ * fixed inner path. Returns null (not throw) on malformed archives so
61
+ * the caller can surface a precise 400 response.
62
+ */
63
+ function readTarballManifest(buffer, filename) {
64
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'camstack-tarball-check-'));
65
+ const tgzPath = path.join(tmpDir, path.basename(filename));
66
+ try {
67
+ fs.writeFileSync(tgzPath, buffer);
68
+ const output = (0, node_child_process_1.execFileSync)('tar', ['-xzO', '-f', tgzPath, 'package/package.json'], {
69
+ timeout: 5_000,
70
+ stdio: ['ignore', 'pipe', 'ignore'],
71
+ });
72
+ const parsed = JSON.parse(output.toString('utf8'));
73
+ if (!parsed || typeof parsed !== 'object')
74
+ return null;
75
+ const pkg = parsed;
76
+ if (typeof pkg['name'] !== 'string' || typeof pkg['version'] !== 'string')
77
+ return null;
78
+ return { name: pkg['name'], version: pkg['version'] };
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ finally {
84
+ fs.rmSync(tmpDir, { recursive: true, force: true });
85
+ }
86
+ }
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.checkShareViewAccess = checkShareViewAccess;
4
+ exports.projectListAllForShareScope = projectListAllForShareScope;
4
5
  exports.liveEventInShareScope = liveEventInShareScope;
5
6
  /**
6
7
  * Methods callable WITHOUT a per-device check — their input carries no
@@ -58,6 +59,35 @@ function checkShareViewAccess(scope, path, input) {
58
59
  }
59
60
  return { ok: false, reason: `'${path}' is not available to share-view tokens` };
60
61
  }
62
+ /**
63
+ * Output filter for `deviceManager.listAll` under a share-view principal:
64
+ * drop every device outside the token's scope, and project the survivors
65
+ * onto the whitelisted row (never the raw device — fail closed on fields).
66
+ * Non-array payloads pass through untouched (the route errored upstream).
67
+ */
68
+ function projectListAllForShareScope(data, scope) {
69
+ if (!Array.isArray(data))
70
+ return data;
71
+ const allowed = new Set(scope.deviceIds);
72
+ const rows = [];
73
+ for (const entry of data) {
74
+ if (entry === null || typeof entry !== 'object')
75
+ continue;
76
+ const id = Reflect.get(entry, 'id');
77
+ if (typeof id !== 'number' || !allowed.has(id))
78
+ continue;
79
+ rows.push({
80
+ id,
81
+ name: Reflect.get(entry, 'name'),
82
+ online: Reflect.get(entry, 'online'),
83
+ isCamera: Reflect.get(entry, 'isCamera'),
84
+ type: Reflect.get(entry, 'type'),
85
+ location: Reflect.get(entry, 'location'),
86
+ disabled: Reflect.get(entry, 'disabled'),
87
+ });
88
+ }
89
+ return rows;
90
+ }
61
91
  /**
62
92
  * Whether a live event belongs to a device inside the share scope.
63
93
  * Matches the device identity two ways (fail closed — no match, no push):
@@ -104,6 +104,16 @@ exports.protectedProcedure = t.procedure.use(async ({ ctx, next, path, getRawInp
104
104
  if (!result.ok) {
105
105
  throw new server_1.TRPCError({ code: 'FORBIDDEN', message: result.reason });
106
106
  }
107
+ // `deviceManager.listAll` is allowlisted for the embed's tile names,
108
+ // but its raw rows describe EVERY device (config incl. credentials).
109
+ // Project the output down to the in-scope whitelisted rows.
110
+ if (path === 'deviceManager.listAll') {
111
+ const out = await next({ ctx: { ...ctx, user: ctx.user } });
112
+ if (out.ok) {
113
+ return { ...out, data: (0, share_view_access_js_1.projectListAllForShareScope)(out.data, ctx.user.shareView.scope) };
114
+ }
115
+ return out;
116
+ }
107
117
  return next({ ctx: { ...ctx, user: ctx.user } });
108
118
  }
109
119
  // Spread+reassign of `user` narrows downstream ctx from `User | null`
@@ -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
@@ -320,6 +330,7 @@ async function bootstrap() {
320
330
  // Read the live flag at request time — flips to true once the tRPC
321
331
  // router finishes registering (see `trpcRegistered = true` below).
322
332
  isReady: () => trpcRegistered,
333
+ verifyToken: (token) => app.get(auth_service_1.AuthService).verifyToken(token),
323
334
  });
324
335
  console.log(`[bootstrap] Health routes registered (hub v${hubVersion})`);
325
336
  }
@@ -434,7 +445,7 @@ async function bootstrap() {
434
445
  : 'application/octet-stream';
435
446
  const stream = fs.createReadStream(resolved);
436
447
  return reply
437
- .header('cache-control', addonBundleCacheControl(resolved))
448
+ .header('cache-control', (0, spa_static_1.addonBundleCacheControl)(resolved))
438
449
  .type(contentType)
439
450
  .send(stream);
440
451
  });
@@ -466,7 +477,7 @@ async function bootstrap() {
466
477
  : 'application/octet-stream';
467
478
  const stream = fs.createReadStream(resolved);
468
479
  return reply
469
- .header('cache-control', addonBundleCacheControl(resolved))
480
+ .header('cache-control', (0, spa_static_1.addonBundleCacheControl)(resolved))
470
481
  .type(contentType)
471
482
  .send(stream);
472
483
  });
@@ -940,7 +951,16 @@ async function bootstrap() {
940
951
  try {
941
952
  const bootAddonRegistry = app.get(addon_registry_service_1.AddonRegistryService);
942
953
  const capRegistry = bootAddonRegistry.getCapabilityRegistry();
943
- 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');
944
964
  // CAMSTACK_SKIP_ADMIN_UI_WAIT — bypass the 60s poll. Used by the
945
965
  // e2e harness, which doesn't need the SPA served and spawns hubs
946
966
  // with strict boot timeouts. Production keeps the poll so cold
@@ -958,7 +978,7 @@ async function bootstrap() {
958
978
  const deadline = Date.now() + ADMIN_UI_WAIT_MS;
959
979
  while (!adminUI && Date.now() < deadline) {
960
980
  await new Promise((r) => setTimeout(r, POLL_MS));
961
- adminUI = capRegistry.getSingleton('admin-ui');
981
+ adminUI = capRegistry.getSingletonForNode('admin-ui', 'hub');
962
982
  }
963
983
  }
964
984
  if (adminUI) {
@@ -997,7 +1017,6 @@ async function bootstrap() {
997
1017
  url.startsWith('/api/') ||
998
1018
  url.startsWith('/agent') ||
999
1019
  url.startsWith('/health') ||
1000
- url.startsWith('/discovery') ||
1001
1020
  // The viewer SPA owns /viewer/** via its own catch-all (registered
1002
1021
  // below). Fall through here so admin-ui never serves it — and so
1003
1022
  // /viewer 404s cleanly when the viewer-ui addon isn't registered.
@@ -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) {