@camstack/server 1.2.128 → 1.2.130

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.
@@ -28,11 +28,24 @@ exports.createAddonSettingsRouter = createAddonSettingsRouter;
28
28
  * the whole per-key set, so a write here deletes every other row of that addon
29
29
  * in this table.
30
30
  *
31
- * Exposes four protected procedures consumed by:
32
- * 1. Forked addons (via the tRPC WSS client in `WorkerBootstrapService`)
33
- * to read/write their 3-level settings chain from the worker process.
34
- * 2. Future UI flows that want to inspect/mutate addon settings through
35
- * a single well-typed endpoint.
31
+ * ## ⚠️ ADMIN-ONLY every method here is `adminProcedure`
32
+ *
33
+ * Reads (`getGlobal` / `getDeviceOverrides`) return RAW, un-redacted addon
34
+ * config the same blob that holds notifier tokens, webhook secrets and API
35
+ * keys; writes (`updateGlobal` / `updateDevice` / `replaceGlobal`) overwrite it
36
+ * with no schema gate. Before 2026-08-13 these were `protectedProcedure`, so
37
+ * any authenticated non-admin token could read and rewrite every addon's
38
+ * config. The `:139` comment claimed "only workers with valid hub tokens can
39
+ * call this" but nothing enforced it.
40
+ *
41
+ * Gating with `adminProcedure` costs the legitimate callers NOTHING: this
42
+ * router is in `CORE_NAMESPACES` (`core-cap-bridge.ts`), so a forked addon's
43
+ * `ctx.api.addonSettingsRaw.*` arrives over the trusted core-cap/UDS mesh whose
44
+ * context is `createMeshTrpcContext` (`isAdmin: true`), and the admin UI is an
45
+ * admin session. The two consumers:
46
+ * 1. Forked addons (via `ctx.api` over the mesh) reading/writing their
47
+ * 3-level settings chain from the worker process.
48
+ * 2. Admin UI flows inspecting/mutating addon settings.
36
49
  *
37
50
  * The router is deliberately thin — it does NOT perform schema-based
38
51
  * resolver merging (defaults → global → per-device). That happens on the
@@ -89,6 +89,12 @@ function serializeRecentEvent(e) {
89
89
  }
90
90
  function createEventBusProxyRouter(eventBus) {
91
91
  return (0, trpc_middleware_js_1.trpcRouter)({
92
+ // ADMIN-ONLY. `emit` injects a fully-attributed SystemEvent onto the hub's
93
+ // real EventBus — including a spoofable `source` (a fake `device`/`addon`
94
+ // origin). Forked addons that legitimately emit reach this over the
95
+ // trusted core-cap/UDS mesh, whose context is `createMeshTrpcContext`
96
+ // (`isAdmin: true`), so gating it here withholds the surface from
97
+ // non-admin HTTP/WS principals without blinding a worker.
92
98
  emit: trpc_middleware_js_1.adminProcedure
93
99
  .input(SystemEventInputSchema)
94
100
  .output(zod_1.z.object({ ok: zod_1.z.literal(true) }))
@@ -1195,7 +1195,15 @@ class AddonPackageService {
1195
1195
  meta: { packageName, error: (0, types_1.errMsg)(err) },
1196
1196
  });
1197
1197
  }
1198
- const hasUpdate = latestVersion !== null && currentVersion !== 'unknown' && latestVersion !== currentVersion;
1198
+ // STRICTLY newer, never merely different. The hub runs
1199
+ // `1.2.x-dev.<timestamp>` builds that are AHEAD of the npm tag, and
1200
+ // `latest !== current` lit an "update available" badge whose only
1201
+ // possible outcome was a downgrade. `publishFrameworkAvailability`
1202
+ // already gated on this; the row the UI reads did not, so the same
1203
+ // page disagreed with itself.
1204
+ const hasUpdate = latestVersion !== null &&
1205
+ currentVersion !== 'unknown' &&
1206
+ isVersionNewer(latestVersion, currentVersion);
1199
1207
  return {
1200
1208
  packageName,
1201
1209
  currentVersion,
@@ -28,11 +28,14 @@ class LoggingService extends system_1.LogManager {
28
28
  // keep their sparse history. `eventBus.ringBufferSize` still sizes the
29
29
  // separate system-event ring; logs get their own per-addon cap.
30
30
  const perAddonCapacity = configService.get('eventBus.perAddonLogBufferSize') ?? 5000;
31
- // Soft total ceiling across all buckets, null (unbounded) by default so this
32
- // changes nothing until an operator opts in. `?? null` rather than a numeric
33
- // default on purpose: the per-addon rings are already a hard bound, and picking
34
- // a total for someone would silently start discarding their debug history.
35
- const maxTotalEntries = configService.get('eventBus.maxTotalLogBufferSize') ?? null;
31
+ // Total ceiling across all buckets. Left UNSET here on purpose so the buffer
32
+ // applies its own default (`DEFAULT_MAX_TOTAL_LOG_ENTRIES`): the per-addon
33
+ // rings are a hard bound only per bucket, and their product grows with the
34
+ // roster hub-main ingests from every local runner AND every agent, so
35
+ // "unbounded by default" meant ~300k retained entries (~92MB) on the one
36
+ // process that must fit inside the cgroup. An operator who genuinely wants
37
+ // no aggregate bound writes an explicit `null`, which is preserved.
38
+ const maxTotalEntries = configService.get('eventBus.maxTotalLogBufferSize');
36
39
  // Only entries at or below this level are ever discarded to meet the total.
37
40
  const pruneLevel = configService.get('eventBus.logBufferPruneLevel') ?? 'debug';
38
41
  super(perAddonCapacity, { maxTotalEntries, pruneLevel });
@@ -3,17 +3,38 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.StreamProbeService = void 0;
4
4
  const child_process_1 = require("child_process");
5
5
  const util_1 = require("util");
6
+ const ttl_cache_1 = require("./ttl-cache");
6
7
  const types_1 = require("@camstack/types");
7
8
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
8
9
  const CACHE_TTL_MS = 3_600_000; // 1 hour
9
10
  const PROBE_TIMEOUT_MS = 5_000;
11
+ /**
12
+ * Ceiling on cached probes.
13
+ *
14
+ * The cache is keyed by stream URL, and a URL is not a bounded quantity: every
15
+ * credential rotation, every edited field, every per-camera probe of a value
16
+ * that was later changed mints a new key. 512 is far above any real camera
17
+ * roster (the live cluster runs 27), so a working deployment never evicts;
18
+ * the ceiling exists so the key space cannot be walked into the OOM.
19
+ */
20
+ const CACHE_MAX_ENTRIES = 512;
10
21
  /** Codec aliases normalised to canonical names. */
11
22
  const CODEC_ALIASES = {
12
23
  hevc: 'h265',
13
24
  };
14
25
  class StreamProbeService {
15
26
  logger;
16
- cache = new Map();
27
+ /**
28
+ * Probe results, bounded by BOTH a swept TTL and an entry ceiling.
29
+ *
30
+ * It used to be a plain `Map` whose age was consulted only when the same URL
31
+ * was probed again — so an entry nobody asked about a second time was never
32
+ * found expired and never removed. See {@link TtlCache}.
33
+ */
34
+ cache = new ttl_cache_1.TtlCache({
35
+ ttlMs: CACHE_TTL_MS,
36
+ maxEntries: CACHE_MAX_ENTRIES,
37
+ });
17
38
  constructor(loggingService) {
18
39
  this.logger = loggingService.createLogger('StreamProbeService');
19
40
  }
@@ -25,12 +46,11 @@ class StreamProbeService {
25
46
  const force = options?.force ?? false;
26
47
  if (!force) {
27
48
  const cached = this.cache.get(url);
28
- if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
29
- return cached.metadata;
30
- }
49
+ if (cached)
50
+ return cached;
31
51
  }
32
52
  const metadata = await this.runProbe(url);
33
- this.cache.set(url, { metadata, timestamp: Date.now() });
53
+ this.cache.set(url, metadata);
34
54
  return metadata;
35
55
  }
36
56
  /**
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TtlCache = void 0;
4
+ class TtlCache {
5
+ entries = new Map();
6
+ ttlMs;
7
+ maxEntries;
8
+ now;
9
+ constructor(options) {
10
+ this.ttlMs = options.ttlMs;
11
+ this.maxEntries = options.maxEntries;
12
+ this.now = options.now ?? (() => Date.now());
13
+ }
14
+ /** The live value, or undefined when absent or expired. An expired entry is
15
+ * DELETED here, not merely reported missing. */
16
+ get(key) {
17
+ const slot = this.entries.get(key);
18
+ if (slot === undefined)
19
+ return undefined;
20
+ const at = this.now();
21
+ if (at - slot.storedAt >= this.ttlMs) {
22
+ this.entries.delete(key);
23
+ return undefined;
24
+ }
25
+ slot.lastAccessAt = at;
26
+ return slot.value;
27
+ }
28
+ /**
29
+ * Store a value, then bring the map back inside both bounds.
30
+ *
31
+ * The sweep runs on WRITE and not on a timer on purpose: a timer would have
32
+ * to be owned, unref'd and stopped by every holder of a cache, and a cache
33
+ * that is never written to is a cache that is not growing.
34
+ */
35
+ set(key, value) {
36
+ const at = this.now();
37
+ this.entries.set(key, { value, storedAt: at, lastAccessAt: at });
38
+ this.sweepExpired(at);
39
+ this.enforceMaxEntries(key);
40
+ }
41
+ delete(key) {
42
+ this.entries.delete(key);
43
+ }
44
+ clear() {
45
+ this.entries.clear();
46
+ }
47
+ /** Entries currently retained. The number that used to only go up. */
48
+ size() {
49
+ return this.entries.size;
50
+ }
51
+ sweepExpired(at) {
52
+ for (const [key, slot] of this.entries) {
53
+ if (at - slot.storedAt >= this.ttlMs)
54
+ this.entries.delete(key);
55
+ }
56
+ }
57
+ /** Evict least-recently-USED first. `protectedKey` is the entry just written —
58
+ * evicting it would make `set` a no-op. */
59
+ enforceMaxEntries(protectedKey) {
60
+ if (this.entries.size <= this.maxEntries)
61
+ return;
62
+ const coldestFirst = [...this.entries.entries()]
63
+ .filter(([key]) => key !== protectedKey)
64
+ .toSorted((a, b) => a[1].lastAccessAt - b[1].lastAccessAt);
65
+ for (const [key] of coldestFirst) {
66
+ if (this.entries.size <= this.maxEntries)
67
+ return;
68
+ this.entries.delete(key);
69
+ }
70
+ }
71
+ }
72
+ exports.TtlCache = TtlCache;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.128",
3
+ "version": "1.2.130",
4
4
  "private": false,
5
5
  "files": [
6
6
  "dist",
@@ -33,19 +33,19 @@
33
33
  ]
34
34
  },
35
35
  "dependencies": {
36
- "@camstack/addon-admin-ui": "1.2.77",
37
- "@camstack/addon-agent-ui": "1.2.20",
38
- "@camstack/addon-auth": "1.2.22",
39
- "@camstack/addon-decoder-nodeav": "1.2.19",
40
- "@camstack/addon-notifiers": "1.2.24",
41
- "@camstack/addon-pipeline": "1.2.93",
42
- "@camstack/addon-pipeline-orchestrator": "1.2.76",
43
- "@camstack/addon-post-analysis": "1.2.91",
44
- "@camstack/sdk": "1.2.22",
45
- "@camstack/shm-ring": "1.1.19",
46
- "@camstack/system": "1.2.102",
47
- "@camstack/types": "1.2.85",
48
- "@camstack/ui-library": "1.2.58",
36
+ "@camstack/addon-admin-ui": "1.2.78",
37
+ "@camstack/addon-agent-ui": "1.2.21",
38
+ "@camstack/addon-auth": "1.2.23",
39
+ "@camstack/addon-decoder-nodeav": "1.2.20",
40
+ "@camstack/addon-notifiers": "1.2.25",
41
+ "@camstack/addon-pipeline": "1.2.95",
42
+ "@camstack/addon-pipeline-orchestrator": "1.2.78",
43
+ "@camstack/addon-post-analysis": "1.2.93",
44
+ "@camstack/sdk": "1.2.23",
45
+ "@camstack/shm-ring": "1.1.20",
46
+ "@camstack/system": "1.2.104",
47
+ "@camstack/types": "1.2.87",
48
+ "@camstack/ui-library": "1.2.59",
49
49
  "@fastify/compress": "^9.0.0",
50
50
  "@fastify/cookie": "^11.0.2",
51
51
  "@fastify/cors": "^11.2.0",