@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
|
|
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.
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
42
|
-
"@camstack/addon-pipeline-orchestrator": "1.2.
|
|
43
|
-
"@camstack/addon-post-analysis": "1.2.
|
|
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.
|
|
47
|
-
"@camstack/types": "1.2.
|
|
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",
|