@camstack/server 1.2.118 → 1.2.120

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.
@@ -125,6 +125,15 @@ function referencedDeviceIds(path, input) {
125
125
  if (!Array.isArray(raw))
126
126
  continue;
127
127
  for (const item of raw) {
128
+ if (field.form === 'object-array') {
129
+ // `targets[].deviceId` & co. — one nesting level, by design.
130
+ if (item !== null && typeof item === 'object') {
131
+ const nested = Reflect.get(item, field.itemField ?? 'deviceId');
132
+ if (typeof nested === 'number' && Number.isFinite(nested))
133
+ out.push(nested);
134
+ }
135
+ continue;
136
+ }
128
137
  if (typeof item === 'number' && Number.isFinite(item))
129
138
  out.push(item);
130
139
  }
@@ -181,7 +190,6 @@ function checkScopeAccess(scopes, path, input, lookup) {
181
190
  return { ok: false, reason: `Unknown method '${path}' — codegen drift` };
182
191
  }
183
192
  const deviceId = meta.capScope === 'device' ? extractDeviceId(input) : null;
184
- const candidates = deviceId !== null ? candidateMetas(deviceId, lookup) : [];
185
193
  for (const s of scopes) {
186
194
  let targetMatches = false;
187
195
  switch (s.type) {
@@ -194,20 +202,28 @@ function checkScopeAccess(scopes, path, input, lookup) {
194
202
  case 'addon':
195
203
  targetMatches = meta.addonId !== null && s.target === meta.addonId;
196
204
  break;
197
- case 'device':
205
+ case 'device': {
198
206
  // A device grant matches either:
199
- // • a request naming a device, when the v3 selector covers it (or an
200
- // ancestor accessory inheritance, gated by `includeLinked`); or
207
+ // • a request NAMING devices in ANY shape the selector codegen
208
+ // recorded (top-level `deviceId`, `deviceIds[]`, or one-level
209
+ // nested `targets[].deviceId`) — when the v3 selector covers
210
+ // EVERY referenced device (or an ancestor, when the grant
211
+ // inherits). A single uncovered reference fails the call closed:
212
+ // the viewer's batches are built from its already-projected
213
+ // camera list, so a legit client never trips this, and a
214
+ // one-camera token cannot launder the fleet through an array.
201
215
  // • a DEVICE ENUMERATION method, which names no device precisely
202
216
  // because it asks "which are mine". Those are safe to reach ONLY
203
217
  // because the RESPONSE is cut to the caller's set (see
204
218
  // `DEVICE_ENUMERATION_METHODS`); without that projection this
205
219
  // branch would hand the whole fleet to a one-camera token.
220
+ const refs = meta.capScope === 'device' ? referencedDeviceIds(path, input) : [];
206
221
  targetMatches =
207
- deviceId !== null
208
- ? deviceScopeCovers(s, meta.access, candidates)
222
+ refs.length > 0
223
+ ? refs.every((id) => deviceScopeCovers(s, meta.access, candidateMetas(id, lookup)))
209
224
  : exports.DEVICE_ENUMERATION_METHODS.has(path);
210
225
  break;
226
+ }
211
227
  }
212
228
  if (!targetMatches)
213
229
  continue;
@@ -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,11 @@ 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[].deviceId` inputs — the
182
+ // snapshot overview/links and the merged events feed) need no response
183
+ // projection: the matcher already required EVERY device the request names
184
+ // to be covered (see `referencedDeviceIds`), so the answer can only
185
+ // contain rows the caller is entitled to.
181
186
  if (redactConfigSecrets) {
182
187
  return { ...out, data: redactConfigSecrets(out.data) };
183
188
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.118",
3
+ "version": "1.2.120",
4
4
  "private": false,
5
5
  "files": [
6
6
  "dist",
@@ -44,7 +44,7 @@
44
44
  "@camstack/sdk": "1.2.19",
45
45
  "@camstack/shm-ring": "1.1.16",
46
46
  "@camstack/system": "1.2.96",
47
- "@camstack/types": "1.2.78",
47
+ "@camstack/types": "1.2.79",
48
48
  "@camstack/ui-library": "1.2.54",
49
49
  "@fastify/compress": "^9.0.0",
50
50
  "@fastify/cookie": "^11.0.2",