@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
|
|
200
|
-
//
|
|
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
|
-
|
|
208
|
-
? deviceScopeCovers(s, meta.access,
|
|
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.
|
|
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.
|
|
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",
|