@camstack/server 1.2.77 → 1.2.78

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.
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SHARE_SCOPE_PROJECTED_METHODS = void 0;
3
4
  exports.checkShareViewAccess = checkShareViewAccess;
4
5
  exports.projectListAllForShareScope = projectListAllForShareScope;
6
+ exports.projectShareScopeRows = projectShareScopeRows;
5
7
  exports.liveEventInShareScope = liveEventInShareScope;
6
8
  /**
7
9
  * Methods callable WITHOUT a per-device check — their input carries no
@@ -37,7 +39,95 @@ function extractDeviceId(input) {
37
39
  const candidate = Reflect.get(input, 'deviceId');
38
40
  return typeof candidate === 'number' && Number.isFinite(candidate) ? candidate : null;
39
41
  }
42
+ /**
43
+ * The EVENTS embed's real call graph (`events-client.ts` in the embed):
44
+ * • `auth.me` — SDK boot/auth probe
45
+ * • `deviceManager.listAll` — camera names + the "all
46
+ * cameras" resolution (already projected to the scope)
47
+ * • `pipelineAnalytics.listRecentTracks` — the feed itself
48
+ * • `pipelineAnalytics.listEventKindsBatch` — the class→macro taxonomy
49
+ * • `pipelineAnalytics.searchObjectEvents` — CLIP text search
50
+ *
51
+ * Intentionally NOT here: everything the grid link gets (WebRTC signalling,
52
+ * snapshots, TURN, camera metrics) — a shared events page is not a camera
53
+ * wall — and every mutating analytics method (`deleteTracks`, `setTrackFlags`,
54
+ * the retrain surface, the export planes).
55
+ */
56
+ const EVENTS_VIEW_OPEN_METHODS = new Set(['auth.me', 'deviceManager.listAll']);
57
+ /**
58
+ * Methods whose input carries a `deviceIds` ARRAY. The rule is INTERSECTION,
59
+ * and the outcome is FILTER-AND-SERVE: a request that names at least one
60
+ * in-scope device passes, and the RESPONSE is stripped to the scope by
61
+ * {@link projectShareScopeRows}.
62
+ *
63
+ * Filter rather than reject, because a multi-camera link whose owner later
64
+ * narrowed the share must keep working for the cameras it still covers —
65
+ * rejecting the whole page would break the link on a change that only removed
66
+ * one camera. A FULLY disjoint request is still denied: there is nothing to
67
+ * serve, and answering `[]` would read as "these cameras had no events" rather
68
+ * than "you cannot see these cameras".
69
+ */
70
+ const EVENTS_VIEW_DEVICE_ARRAY_METHODS = new Set([
71
+ 'pipelineAnalytics.listRecentTracks',
72
+ 'pipelineAnalytics.listEventKindsBatch',
73
+ ]);
74
+ /**
75
+ * Methods whose input carries an OPTIONAL single `deviceId`, where omitting it
76
+ * means EVERY camera in the deployment.
77
+ *
78
+ * A present id must be in scope. An ABSENT id is allowed — and that is safe
79
+ * only because the response is filtered server-side
80
+ * ({@link projectShareScopeRows}); the caller is never trusted to have scoped
81
+ * its own query. Rejecting the absent case instead would leave a multi-camera
82
+ * share link unable to search at all, and would not be one bit safer.
83
+ */
84
+ const EVENTS_VIEW_OPTIONAL_DEVICE_METHODS = new Set([
85
+ 'pipelineAnalytics.searchObjectEvents',
86
+ ]);
87
+ /** Pull a `deviceIds` array off a raw tRPC input. Null when absent/malformed. */
88
+ function extractDeviceIds(input) {
89
+ if (input === null || typeof input !== 'object')
90
+ return null;
91
+ const candidate = Reflect.get(input, 'deviceIds');
92
+ if (!Array.isArray(candidate))
93
+ return null;
94
+ const ids = candidate.filter((x) => typeof x === 'number' && Number.isFinite(x));
95
+ return ids.length === candidate.length ? ids : null;
96
+ }
97
+ function checkEventsViewAccess(scope, path, input) {
98
+ if (EVENTS_VIEW_OPEN_METHODS.has(path)) {
99
+ return { ok: true };
100
+ }
101
+ if (EVENTS_VIEW_DEVICE_ARRAY_METHODS.has(path)) {
102
+ const requested = extractDeviceIds(input);
103
+ if (requested === null || requested.length === 0) {
104
+ return { ok: false, reason: `'${path}' requires a deviceIds array for share-view access` };
105
+ }
106
+ const allowed = new Set(scope.deviceIds);
107
+ if (!requested.some((id) => allowed.has(id))) {
108
+ return {
109
+ ok: false,
110
+ reason: `Devices ${requested.join(', ')} are outside this share link's scope`,
111
+ };
112
+ }
113
+ return { ok: true };
114
+ }
115
+ if (EVENTS_VIEW_OPTIONAL_DEVICE_METHODS.has(path)) {
116
+ const deviceId = extractDeviceId(input);
117
+ // Absent = every camera; the RESPONSE filter is what scopes it.
118
+ if (deviceId === null)
119
+ return { ok: true };
120
+ if (!scope.deviceIds.includes(deviceId)) {
121
+ return { ok: false, reason: `Device ${deviceId} is outside this share link's scope` };
122
+ }
123
+ return { ok: true };
124
+ }
125
+ return { ok: false, reason: `'${path}' is not available to share-view tokens` };
126
+ }
40
127
  function checkShareViewAccess(scope, path, input) {
128
+ if (scope.kind === 'events-view') {
129
+ return checkEventsViewAccess(scope, path, input);
130
+ }
41
131
  if (scope.kind !== 'grid-view') {
42
132
  return { ok: false, reason: `Unknown share scope kind '${String(scope.kind)}'` };
43
133
  }
@@ -88,6 +178,58 @@ function projectListAllForShareScope(data, scope) {
88
178
  }
89
179
  return rows;
90
180
  }
181
+ /**
182
+ * Strip every row a share token may not see, by `deviceId`.
183
+ *
184
+ * This is the half that makes the events surface safe, and it is not
185
+ * defence-in-depth — it is the ACTUAL enforcement for two of the three
186
+ * methods:
187
+ *
188
+ * - `searchObjectEvents` with no `deviceId` searches the whole deployment.
189
+ * The call is allowed (see the enumeration) precisely because the answer is
190
+ * cut here; trusting the caller to scope its own query would be trusting a
191
+ * URL a third party is holding.
192
+ * - `listRecentTracks` / `listEventKindsBatch` pass the intersection check, so
193
+ * a partially in-scope request reaches the provider — and comes back with
194
+ * rows the link may not see. Those are removed here.
195
+ *
196
+ * Two payload shapes, because the methods answer differently: a bare array of
197
+ * rows (`searchObjectEvents`, `listEventKindsBatch`) and a PAGED envelope
198
+ * (`listRecentTracks` → `{tracks, nextCursor}`). The cursor is preserved —
199
+ * dropping it would silently end a shared page's infinite scroll at page one.
200
+ *
201
+ * A row with NO numeric `deviceId` is DROPPED, never kept: "it probably
202
+ * belongs to a device in scope" is not a security argument.
203
+ *
204
+ * Anything that is not a row payload passes through untouched (the route
205
+ * errored upstream, and rewriting an error into `[]` would hide it).
206
+ */
207
+ function projectShareScopeRows(data, scope) {
208
+ const allowed = new Set(scope.deviceIds);
209
+ const keep = (row) => {
210
+ if (row === null || typeof row !== 'object')
211
+ return false;
212
+ const id = Reflect.get(row, 'deviceId');
213
+ return typeof id === 'number' && allowed.has(id);
214
+ };
215
+ if (Array.isArray(data))
216
+ return data.filter(keep);
217
+ if (data !== null && typeof data === 'object') {
218
+ const tracks = Reflect.get(data, 'tracks');
219
+ if (Array.isArray(tracks)) {
220
+ return { ...data, tracks: tracks.filter(keep) };
221
+ }
222
+ }
223
+ return data;
224
+ }
225
+ /** Paths whose RESPONSE must be cut to the share scope before it leaves the
226
+ * server. Kept beside the enumeration so a method added to one and forgotten
227
+ * in the other is visible in a single file. */
228
+ exports.SHARE_SCOPE_PROJECTED_METHODS = new Set([
229
+ 'pipelineAnalytics.listRecentTracks',
230
+ 'pipelineAnalytics.listEventKindsBatch',
231
+ 'pipelineAnalytics.searchObjectEvents',
232
+ ]);
91
233
  /**
92
234
  * Whether a live event belongs to a device inside the share scope.
93
235
  * Matches the device identity two ways (fail closed — no match, no push):
@@ -6,12 +6,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.agentProcedure = exports.adminProcedure = exports.protectedProcedure = exports.createCallerFactory = exports.publicProcedure = exports.trpcRouter = void 0;
7
7
  exports.iterableSubscription = iterableSubscription;
8
8
  exports.iterableInterval = iterableInterval;
9
+ const system_1 = require("@camstack/system");
9
10
  const server_1 = require("@trpc/server");
10
11
  const superjson_1 = __importDefault(require("superjson"));
11
- const system_1 = require("@camstack/system");
12
+ const cap_route_error_formatter_js_1 = require("./cap-route-error-formatter.js");
12
13
  const scope_access_js_1 = require("./scope-access.js");
13
14
  const share_view_access_js_1 = require("./share-view-access.js");
14
- const cap_route_error_formatter_js_1 = require("./cap-route-error-formatter.js");
15
15
  const t = server_1.initTRPC.context().create({
16
16
  transformer: superjson_1.default,
17
17
  errorFormatter: cap_route_error_formatter_js_1.formatTrpcError,
@@ -114,6 +114,20 @@ exports.protectedProcedure = t.procedure.use(async ({ ctx, next, path, getRawInp
114
114
  }
115
115
  return out;
116
116
  }
117
+ // The events surface answers with ROWS carrying a `deviceId`, and two of
118
+ // its three methods can legitimately reach devices outside the scope: a
119
+ // partially in-scope `listRecentTracks`, and a `searchObjectEvents` with
120
+ // no `deviceId` at all (which means EVERY camera). The enumeration lets
121
+ // those calls through — this is where the answer is cut. Without it the
122
+ // allowlist would be a doorman who checks the ticket and then hands over
123
+ // the whole building.
124
+ if (share_view_access_js_1.SHARE_SCOPE_PROJECTED_METHODS.has(path)) {
125
+ const out = await next({ ctx: { ...ctx, user: ctx.user } });
126
+ if (out.ok) {
127
+ return { ...out, data: (0, share_view_access_js_1.projectShareScopeRows)(out.data, ctx.user.shareView.scope) };
128
+ }
129
+ return out;
130
+ }
117
131
  return next({ ctx: { ...ctx, user: ctx.user } });
118
132
  }
119
133
  // Spread+reassign of `user` narrows downstream ctx from `User | null`
@@ -69,12 +69,25 @@ exports.SHARE_TOKEN_TTL_MAX_SEC = 30 * 24 * 60 * 60; // 30 days
69
69
  exports.SHARE_TOKEN_TTL_DEFAULT_SEC = 7 * 24 * 60 * 60; // 7 days
70
70
  exports.SHARE_TOKEN_MAX_DEVICES = 64;
71
71
  /**
72
- * What a share token is allowed to see. Discriminated on `kind` so future
73
- * share surfaces (single-camera view, recording clip, …) extend the union
74
- * without touching verification plumbing.
72
+ * What a share token is allowed to see. Keyed on `kind` so future share
73
+ * surfaces (single-camera view, recording clip, …) extend the set without
74
+ * touching verification plumbing.
75
+ */
76
+ /**
77
+ * `grid-view` — the live camera wall (WebRTC + snapshots).
78
+ * `events-view` — the events embed (track feed, kind taxonomy, CLIP search).
79
+ *
80
+ * They are SEPARATE kinds, not one kind with a bigger surface. Adding the
81
+ * track methods to `grid-view` would retroactively widen every share link an
82
+ * operator has already handed out: a link minted to show a camera wall would
83
+ * silently start serving that camera's event history. A token keeps the
84
+ * perimeter it was minted with, and a new perimeter needs a new mint.
85
+ *
86
+ * Neither kind is a superset of the other — an events link cannot open a
87
+ * WebRTC session, and a grid link cannot read a track.
75
88
  */
76
89
  exports.ShareTokenScopeSchema = zod_1.z.object({
77
- kind: zod_1.z.literal('grid-view'),
90
+ kind: zod_1.z.enum(['grid-view', 'events-view']),
78
91
  deviceIds: zod_1.z.array(zod_1.z.number().int().nonnegative()).min(1).max(exports.SHARE_TOKEN_MAX_DEVICES),
79
92
  });
80
93
  /** Persisted record — never leaves the server with `tokenHash` attached. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.77",
3
+ "version": "1.2.78",
4
4
  "private": false,
5
5
  "files": [
6
6
  "dist",
@@ -33,18 +33,18 @@
33
33
  ]
34
34
  },
35
35
  "dependencies": {
36
- "@camstack/addon-admin-ui": "1.2.37",
36
+ "@camstack/addon-admin-ui": "1.2.38",
37
37
  "@camstack/addon-agent-ui": "1.2.10",
38
38
  "@camstack/addon-auth": "1.2.11",
39
39
  "@camstack/addon-decoder-nodeav": "1.2.9",
40
40
  "@camstack/addon-notifiers": "1.2.13",
41
- "@camstack/addon-pipeline": "1.2.48",
42
- "@camstack/addon-pipeline-orchestrator": "1.2.31",
43
- "@camstack/addon-post-analysis": "1.2.53",
41
+ "@camstack/addon-pipeline": "1.2.49",
42
+ "@camstack/addon-pipeline-orchestrator": "1.2.32",
43
+ "@camstack/addon-post-analysis": "1.2.54",
44
44
  "@camstack/sdk": "1.2.10",
45
45
  "@camstack/shm-ring": "1.1.9",
46
- "@camstack/system": "1.2.63",
47
- "@camstack/types": "1.2.47",
46
+ "@camstack/system": "1.2.64",
47
+ "@camstack/types": "1.2.48",
48
48
  "@camstack/ui-library": "1.2.35",
49
49
  "@fastify/compress": "^9.0.0",
50
50
  "@fastify/cookie": "^11.0.2",