@camstack/server 1.2.118 → 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
|
}
|