@camstack/server 1.2.117 → 1.2.119

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.
@@ -5,6 +5,7 @@ exports.isBroadEventViewer = isBroadEventViewer;
5
5
  exports.filterViewableDeviceIds = filterViewableDeviceIds;
6
6
  exports.hasUnrestrictedDeviceView = hasUnrestrictedDeviceView;
7
7
  exports.projectListAllForPrincipal = projectListAllForPrincipal;
8
+ exports.projectBatchForPrincipal = projectBatchForPrincipal;
8
9
  exports.scopedEventVisibility = scopedEventVisibility;
9
10
  /**
10
11
  * Non-admin principal device visibility — the shared answer to "which cameras
@@ -132,6 +133,39 @@ function projectListAllForPrincipal(data, isAdmin, scopes, fleet) {
132
133
  const viewable = (0, types_1.resolveViewableDeviceIds)(scopes, fleet, 'view');
133
134
  return (0, share_view_access_js_1.projectListAllForDeviceIds)(data, viewable);
134
135
  }
136
+ /**
137
+ * Cut a DEVICE_BATCH_METHODS response to the devices the caller may view.
138
+ *
139
+ * Those methods answer "for THESE devices" where the request named the set
140
+ * in an array (`deviceIds`, `targets[].deviceId`) — a shape the per-device
141
+ * matcher cannot gate, so the answer is filtered here instead. Every one of
142
+ * their rows carries a `deviceId` (snapshot overview/links rows, batched
143
+ * event-kind rows) or nests the rows one level down (`listRecentTracks` →
144
+ * `{ tracks, nextCursor }`, tracks carry `deviceId`). A row without a
145
+ * readable `deviceId` is DROPPED — fail closed, same as the event filter.
146
+ *
147
+ * A caller with unrestricted device reach gets the payload untouched.
148
+ */
149
+ function projectBatchForPrincipal(data, isAdmin, scopes, fleet) {
150
+ if (hasUnrestrictedDeviceView(isAdmin, scopes))
151
+ return data;
152
+ const viewable = (0, types_1.resolveViewableDeviceIds)(scopes, fleet, 'view');
153
+ const rowVisible = (row) => {
154
+ if (row === null || typeof row !== 'object')
155
+ return false;
156
+ const id = row['deviceId'];
157
+ return typeof id === 'number' && viewable.has(id);
158
+ };
159
+ if (Array.isArray(data))
160
+ return data.filter(rowVisible);
161
+ if (data !== null && typeof data === 'object') {
162
+ const tracks = data['tracks'];
163
+ if (Array.isArray(tracks)) {
164
+ return { ...data, tracks: tracks.filter(rowVisible) };
165
+ }
166
+ }
167
+ return data;
168
+ }
135
169
  /**
136
170
  * Build the event-stream predicate for a NON-share principal (F0.5).
137
171
  *
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DEVICE_ENUMERATION_METHODS = void 0;
3
+ exports.DEVICE_BATCH_METHODS = exports.DEVICE_ENUMERATION_METHODS = void 0;
4
4
  exports.checkScopeAccess = checkScopeAccess;
5
5
  /**
6
6
  * Pure scope-access matcher.
@@ -102,6 +102,31 @@ function describeSelector(scope) {
102
102
  * `scoped-list-projection.spec.ts`.
103
103
  */
104
104
  exports.DEVICE_ENUMERATION_METHODS = new Set(['deviceManager.listAll']);
105
+ /**
106
+ * Device-scope methods that name their devices in BATCHES — an array of
107
+ * deviceIds, or a positional targets array — instead of the single
108
+ * top-level `deviceId` the `DeviceProxy` contract gives every other
109
+ * device-scope method. `extractDeviceId` finds nothing in their input, so
110
+ * without this set a device-granted caller could never reach them at all
111
+ * (live regression, 2026-08-15: a non-admin viewer with `device:[all]`
112
+ * received no snapshots and no events — `snapshot.getSnapshotLinks`,
113
+ * `snapshot.getSnapshotOverview`, `listRecentTracks` and
114
+ * `listEventKindsBatch` all failed closed).
115
+ *
116
+ * Same safety contract as {@link DEVICE_ENUMERATION_METHODS}, enforced by
117
+ * the same middleware block: the RESPONSE is cut to the caller's viewable
118
+ * set (`projectBatchForPrincipal`) before it leaves the server, so a
119
+ * one-camera token can ask about the whole fleet and still only receive its
120
+ * own rows. Adding a path here WITHOUT a projector in
121
+ * `projectBatchForPrincipal` hands out the unfiltered answer — the pairing
122
+ * is asserted by `scoped-list-projection.spec.ts`.
123
+ */
124
+ exports.DEVICE_BATCH_METHODS = new Set([
125
+ 'snapshot.getSnapshotOverview',
126
+ 'snapshot.getSnapshotLinks',
127
+ 'pipelineAnalytics.listRecentTracks',
128
+ 'pipelineAnalytics.listEventKindsBatch',
129
+ ]);
105
130
  /**
106
131
  * Every deviceId a request NAMES, per the codegen'd
107
132
  * {@link METHOD_DEVICE_SELECTORS}. Absent / null / malformed values are
@@ -206,7 +231,7 @@ function checkScopeAccess(scopes, path, input, lookup) {
206
231
  targetMatches =
207
232
  deviceId !== null
208
233
  ? deviceScopeCovers(s, meta.access, candidates)
209
- : exports.DEVICE_ENUMERATION_METHODS.has(path);
234
+ : exports.DEVICE_ENUMERATION_METHODS.has(path) || exports.DEVICE_BATCH_METHODS.has(path);
210
235
  break;
211
236
  }
212
237
  if (!targetMatches)
@@ -6,6 +6,49 @@ exports.createWsTrpcContext = createWsTrpcContext;
6
6
  const types_1 = require("@camstack/types");
7
7
  const share_token_service_js_1 = require("../../core/auth/share-token.service.js");
8
8
  const trpc_error_principal_js_1 = require("./trpc-error-principal.js");
9
+ /**
10
+ * Live-scope refresh memo (see the JWT branch of `resolveUser`).
11
+ *
12
+ * The JWT embeds scopes at login, so an admin's scope edit used to reach a
13
+ * live session only at the next login — a user granted cameras while logged
14
+ * in saw NONE until they signed out and back in (2026-08-15, user report).
15
+ * The store is therefore re-read per user, but memoized: a scope change
16
+ * propagates within {@link SCOPE_REFRESH_TTL_MS} and a busy session costs at
17
+ * most one store read per TTL window, not one per request.
18
+ */
19
+ const SCOPE_REFRESH_TTL_MS = 30_000;
20
+ const SCOPE_REFRESH_MISS_TTL_MS = 5_000;
21
+ const liveScopeMemo = new Map();
22
+ async function refreshScopesFromStore(userId, addonRegistry) {
23
+ const cached = liveScopeMemo.get(userId);
24
+ const now = Date.now();
25
+ if (cached &&
26
+ now - cached.at < (cached.scopes === null ? SCOPE_REFRESH_MISS_TTL_MS : SCOPE_REFRESH_TTL_MS)) {
27
+ return cached.scopes;
28
+ }
29
+ let scopes = null;
30
+ try {
31
+ const userMgmt = addonRegistry.getCapabilityRegistry().getSingleton('user-management');
32
+ const fresh = typeof userMgmt?.listUsers === 'function'
33
+ ? (await userMgmt.listUsers()).find((u) => u.id === userId)
34
+ : null;
35
+ if (fresh)
36
+ scopes = (0, types_1.normalizeTokenScopes)(fresh.scopes ?? []);
37
+ }
38
+ catch {
39
+ scopes = null;
40
+ }
41
+ liveScopeMemo.set(userId, { at: now, scopes });
42
+ // Bound the map: sessions come and go, entries are tiny, and a missed
43
+ // sweep would grow it by one row per distinct user per TTL window.
44
+ if (liveScopeMemo.size > 512) {
45
+ for (const [key, entry] of liveScopeMemo) {
46
+ if (now - entry.at > SCOPE_REFRESH_TTL_MS * 4)
47
+ liveScopeMemo.delete(key);
48
+ }
49
+ }
50
+ return scopes;
51
+ }
9
52
  /** Read `req.query` if present (Fastify-only) without losing type safety. */
10
53
  function readQuery(req) {
11
54
  if (!('query' in req))
@@ -150,7 +193,22 @@ async function resolveUser(token, authService, addonRegistry, shareTokens = null
150
193
  // them to gate every call until the user re-logs. Normalised at the
151
194
  // boundary — a v2 `device:targets` JWT (issued before the v3 model)
152
195
  // migrates to a selector here, so a pre-v3 token keeps working.
196
+ //
197
+ // …but a login-time snapshot is exactly the bug: an admin editing a
198
+ // user's scopes expects the change to APPLY, and the live session
199
+ // kept gating on the old set until re-login (2026-08-15: a user
200
+ // granted the whole fleet kept seeing zero cameras). For non-admin
201
+ // JWT principals the store's current scopes win when readable
202
+ // (memoized — see above); the JWT's copy is the fallback when the
203
+ // user-management cap is unreachable, so a store hiccup never
204
+ // locks anybody out mid-request.
153
205
  ...(payload.scopes !== undefined ? { scopes: (0, types_1.normalizeTokenScopes)(payload.scopes) } : {}),
206
+ ...(!payload.isAdmin
207
+ ? await (async () => {
208
+ const fresh = await refreshScopesFromStore(payload.userId ?? payload.keyId ?? 'unknown', addonRegistry);
209
+ return fresh !== null ? { scopes: fresh } : {};
210
+ })()
211
+ : {}),
154
212
  ...(credential.credential !== undefined ? { credential: credential.credential } : {}),
155
213
  ...(credential.sessionId !== undefined ? { oauthSessionId: credential.sessionId } : {}),
156
214
  };
@@ -178,6 +178,17 @@ exports.protectedProcedure = t.procedure.use(async ({ ctx, next, path, getRawInp
178
178
  data: redactConfigSecrets ? redactConfigSecrets(projected) : projected,
179
179
  };
180
180
  }
181
+ // Batch device methods (`deviceIds[]` / `targets[]` inputs — the snapshot
182
+ // overview/links and the merged events feed) are reachable on a device
183
+ // grant precisely because their answer is cut to the caller's viewable
184
+ // set here. Reachability without this projection would be the leak.
185
+ if (scope_access_js_1.DEVICE_BATCH_METHODS.has(path)) {
186
+ const projected = (0, principal_visibility_js_1.projectBatchForPrincipal)(out.data, ctx.user.isAdmin, ctx.user.scopes ?? [], ctx.deviceFleet?.() ?? []);
187
+ return {
188
+ ...out,
189
+ data: redactConfigSecrets ? redactConfigSecrets(projected) : projected,
190
+ };
191
+ }
181
192
  if (redactConfigSecrets) {
182
193
  return { ...out, data: redactConfigSecrets(out.data) };
183
194
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.117",
3
+ "version": "1.2.119",
4
4
  "private": false,
5
5
  "files": [
6
6
  "dist",
@@ -38,12 +38,12 @@
38
38
  "@camstack/addon-auth": "1.2.19",
39
39
  "@camstack/addon-decoder-nodeav": "1.2.16",
40
40
  "@camstack/addon-notifiers": "1.2.21",
41
- "@camstack/addon-pipeline": "1.2.82",
42
- "@camstack/addon-pipeline-orchestrator": "1.2.61",
43
- "@camstack/addon-post-analysis": "1.2.79",
41
+ "@camstack/addon-pipeline": "1.2.85",
42
+ "@camstack/addon-pipeline-orchestrator": "1.2.62",
43
+ "@camstack/addon-post-analysis": "1.2.80",
44
44
  "@camstack/sdk": "1.2.19",
45
45
  "@camstack/shm-ring": "1.1.16",
46
- "@camstack/system": "1.2.95",
46
+ "@camstack/system": "1.2.96",
47
47
  "@camstack/types": "1.2.78",
48
48
  "@camstack/ui-library": "1.2.54",
49
49
  "@fastify/compress": "^9.0.0",