@camstack/server 1.2.77 → 1.2.79

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.
@@ -0,0 +1,191 @@
1
+ "use strict";
2
+ /**
3
+ * Device-config secret redaction for NON-ADMIN principals.
4
+ *
5
+ * A device's `config` blob is the driver's connection record — host, port,
6
+ * username and, for every IP camera in the deployment, the camera's
7
+ * `password` in plaintext. It rides the wire on every device-manager read:
8
+ * `getDevice`, `listAll`, `getChildren`, and the three settings surfaces that
9
+ * project the same values into form fields.
10
+ *
11
+ * Those methods are declared `auth: 'protected'` (the default in
12
+ * `capability-definition.ts`), which is NOT admin — it is "authenticated, and
13
+ * scope-matched". Measured against the live hub: a `cst_*` scoped token
14
+ * carrying nothing but `category:system` + `view` — a READ-ONLY grant, the one
15
+ * the deploy runbook mints for CI — read `deviceManager.getDevice` and received
16
+ * `config.password` verbatim, and `deviceManager.listAll` answered with a
17
+ * populated password for every camera at once. Read-only access to a device
18
+ * list is not consent to hand over the credentials that open those cameras.
19
+ *
20
+ * WHY HERE, and not at the provider. `toDeviceInfo()` builds one `config` for
21
+ * everybody, and its in-process consumers need the real thing: the pipeline
22
+ * orchestrator's per-camera `getDevice`, dispatch routing, the drivers that
23
+ * actually connect. Those callers arrive through `ctx.api` / UDS and never
24
+ * touch this middleware, so redacting here withholds the secret from the WIRE
25
+ * without blinding the system to its own credentials. The transport boundary
26
+ * is also the only layer that knows the principal.
27
+ *
28
+ * WHY ADMINS ARE UNTOUCHED. The settings form renders the current value into a
29
+ * password field, and `updateConfig` is `auth: 'admin'`, so a redacted value
30
+ * can never be written back by a caller who received one. Making the field
31
+ * write-only for admins too is a bigger, UI-side change; it is not needed to
32
+ * close this hole and would regress the form.
33
+ *
34
+ * Share-view (`csv_*`) principals never reach this code — they are gated
35
+ * earlier by the fail-closed allowlist in `share-view-access.ts`, which does
36
+ * not expose `getDevice` at all and projects `listAll` down to seven presentation
37
+ * fields. This module is the gate for the tier BETWEEN a share link and an admin.
38
+ *
39
+ * Pure module — no tRPC machinery — so the spec exercises it directly (same
40
+ * pattern as `scope-access.ts` and `share-view-access.ts`).
41
+ */
42
+ Object.defineProperty(exports, "__esModule", { value: true });
43
+ exports.NON_ADMIN_CONFIG_REDACTED_METHODS = exports.REDACTED_SECRET = void 0;
44
+ exports.isSecretConfigKey = isSecretConfigKey;
45
+ exports.redactDeviceInfoSecrets = redactDeviceInfoSecrets;
46
+ exports.redactSettingsSections = redactSettingsSections;
47
+ exports.redactSettingsAggregate = redactSettingsAggregate;
48
+ exports.redactConfigEntries = redactConfigEntries;
49
+ /** What a non-admin sees instead of the value. Matches the `***` convention
50
+ * `maskUrlCredentials` already uses for credentials in log output, so one
51
+ * redaction marker means one thing across the system. */
52
+ exports.REDACTED_SECRET = '***';
53
+ /**
54
+ * Config keys whose VALUE is a credential.
55
+ *
56
+ * Deliberately the same rule the settings UI uses to decide that a field
57
+ * renders as a password input (`zodEntriesToConfigUI` in
58
+ * `packages/types/src/device/zod-to-config-ui.ts`). The two are pinned
59
+ * together by an assertion in `device-config-secret-redaction.spec.ts`: a
60
+ * keyword added to one and forgotten in the other fails the suite instead of
61
+ * leaking. It is duplicated rather than imported because `@camstack/types` is
62
+ * a framework package on the publish train, and a redaction fix must not have
63
+ * to wait for one.
64
+ */
65
+ const SECRET_KEY_SUBSTRINGS = [
66
+ 'password',
67
+ 'secret',
68
+ 'token',
69
+ 'apikey',
70
+ 'api_key',
71
+ ];
72
+ /** True when a config key names a credential. Case-insensitive substring match. */
73
+ function isSecretConfigKey(key) {
74
+ const lower = key.toLowerCase();
75
+ return SECRET_KEY_SUBSTRINGS.some((needle) => lower.includes(needle));
76
+ }
77
+ /** Non-null, non-array plain object. */
78
+ function isRecord(x) {
79
+ return x !== null && typeof x === 'object' && !Array.isArray(x);
80
+ }
81
+ /**
82
+ * Replace the value of every credential-named key with {@link REDACTED_SECRET}.
83
+ *
84
+ * An EMPTY value stays empty: "not configured" and "configured, withheld" are
85
+ * different facts, and flattening them would make a broken camera look set up.
86
+ * A non-string value is left alone — a numeric `maxTokens` is not a secret, and
87
+ * the only credentials this repo persists are strings.
88
+ */
89
+ function redactRecord(config) {
90
+ const out = {};
91
+ for (const [key, value] of Object.entries(config)) {
92
+ out[key] =
93
+ isSecretConfigKey(key) && typeof value === 'string' && value.length > 0
94
+ ? exports.REDACTED_SECRET
95
+ : value;
96
+ }
97
+ return out;
98
+ }
99
+ /**
100
+ * Project a `DeviceInfo` (or an array of them) with its `config` redacted.
101
+ * Anything that is not a device row passes through untouched — a route that
102
+ * errored upstream must not be rewritten into a plausible-looking shape.
103
+ */
104
+ function redactDeviceInfoSecrets(data) {
105
+ if (Array.isArray(data))
106
+ return data.map((row) => redactDeviceInfoSecrets(row));
107
+ if (!isRecord(data))
108
+ return data;
109
+ const config = data['config'];
110
+ if (!isRecord(config))
111
+ return data;
112
+ return { ...data, config: redactRecord(config) };
113
+ }
114
+ /** One settings field as the aggregate emits it: `{ key, value, … }`. */
115
+ function redactField(field) {
116
+ if (!isRecord(field))
117
+ return field;
118
+ const key = field['key'];
119
+ if (typeof key !== 'string' || !isSecretConfigKey(key))
120
+ return field;
121
+ const value = field['value'];
122
+ if (typeof value !== 'string' || value.length === 0)
123
+ return field;
124
+ return { ...field, value: exports.REDACTED_SECRET };
125
+ }
126
+ /**
127
+ * Project a `SettingsSchemaWithValues` (`{ sections: [{ fields: [...] }] }`).
128
+ * Used by `getDeviceSettingsAggregate` and `getSettingsSchema`.
129
+ */
130
+ function redactSettingsSections(data) {
131
+ if (!isRecord(data))
132
+ return data;
133
+ const sections = data['sections'];
134
+ if (!Array.isArray(sections))
135
+ return data;
136
+ return {
137
+ ...data,
138
+ sections: sections.map((section) => {
139
+ if (!isRecord(section))
140
+ return section;
141
+ const fields = section['fields'];
142
+ if (!Array.isArray(fields))
143
+ return section;
144
+ return { ...section, fields: fields.map(redactField) };
145
+ }),
146
+ };
147
+ }
148
+ /**
149
+ * Project the combined aggregate, whose settings live one level down under
150
+ * `settings`. The live half carries no config blob.
151
+ */
152
+ function redactSettingsAggregate(data) {
153
+ if (!isRecord(data))
154
+ return data;
155
+ const settings = data['settings'];
156
+ if (!isRecord(settings))
157
+ return data;
158
+ return { ...data, settings: redactSettingsSections(settings) };
159
+ }
160
+ /** Project a flat `ConfigEntry[]` — `getConfigSchema`'s `{ key, value }` rows. */
161
+ function redactConfigEntries(data) {
162
+ if (!Array.isArray(data))
163
+ return data;
164
+ return data.map(redactField);
165
+ }
166
+ /**
167
+ * Every device-manager read whose response carries a device `config` blob, and
168
+ * the projection that cuts it.
169
+ *
170
+ * ENUMERATED rather than applied blanket-wise on purpose: a structural
171
+ * "redact anything credential-named" pass over every non-admin response would
172
+ * also strip `turnProvider.getTurnServers().credential` (the browser needs it),
173
+ * `auth.listShareTokens().tokenPrefix`, and a notification rule's
174
+ * `conditions.eventTypeTokens` — breaking live surfaces to fix a leak that
175
+ * lives in one cap.
176
+ *
177
+ * `deviceManager.listPersistedByAddon` is absent by inspection, not omission:
178
+ * `SavedDeviceRowSchema` has no `config` field, confirmed against the live hub.
179
+ * A new device-manager method returning `DeviceInfoSchema` must be added here —
180
+ * `device-config-secret-redaction.spec.ts` pins the list so the addition is a
181
+ * failing test rather than a silent leak.
182
+ */
183
+ exports.NON_ADMIN_CONFIG_REDACTED_METHODS = new Map([
184
+ ['deviceManager.getDevice', redactDeviceInfoSecrets],
185
+ ['deviceManager.listAll', redactDeviceInfoSecrets],
186
+ ['deviceManager.getChildren', redactDeviceInfoSecrets],
187
+ ['deviceManager.getDeviceSettingsAggregate', redactSettingsSections],
188
+ ['deviceManager.getSettingsSchema', redactSettingsSections],
189
+ ['deviceManager.getDeviceAggregate', redactSettingsAggregate],
190
+ ['deviceManager.getConfigSchema', redactConfigEntries],
191
+ ]);
@@ -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,13 @@ 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 system_1 = require("@camstack/system");
12
+ const cap_route_error_formatter_js_1 = require("./cap-route-error-formatter.js");
13
+ const device_config_secret_redaction_js_1 = require("./device-config-secret-redaction.js");
12
14
  const scope_access_js_1 = require("./scope-access.js");
13
15
  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
16
  const t = server_1.initTRPC.context().create({
16
17
  transformer: superjson_1.default,
17
18
  errorFormatter: cap_route_error_formatter_js_1.formatTrpcError,
@@ -114,6 +115,20 @@ exports.protectedProcedure = t.procedure.use(async ({ ctx, next, path, getRawInp
114
115
  }
115
116
  return out;
116
117
  }
118
+ // The events surface answers with ROWS carrying a `deviceId`, and two of
119
+ // its three methods can legitimately reach devices outside the scope: a
120
+ // partially in-scope `listRecentTracks`, and a `searchObjectEvents` with
121
+ // no `deviceId` at all (which means EVERY camera). The enumeration lets
122
+ // those calls through — this is where the answer is cut. Without it the
123
+ // allowlist would be a doorman who checks the ticket and then hands over
124
+ // the whole building.
125
+ if (share_view_access_js_1.SHARE_SCOPE_PROJECTED_METHODS.has(path)) {
126
+ const out = await next({ ctx: { ...ctx, user: ctx.user } });
127
+ if (out.ok) {
128
+ return { ...out, data: (0, share_view_access_js_1.projectShareScopeRows)(out.data, ctx.user.shareView.scope) };
129
+ }
130
+ return out;
131
+ }
117
132
  return next({ ctx: { ...ctx, user: ctx.user } });
118
133
  }
119
134
  // Spread+reassign of `user` narrows downstream ctx from `User | null`
@@ -122,23 +137,37 @@ exports.protectedProcedure = t.procedure.use(async ({ ctx, next, path, getRawInp
122
137
  if (ctx.user.isAdmin) {
123
138
  return next({ ctx: { ...ctx, user: ctx.user } });
124
139
  }
125
- // Hand-written core route no cap entry. Authentication has already
126
- // passed; defer further gating to any explicit `adminProcedure`
127
- // chained on top of this one.
128
- if (!(path in system_1.METHOD_ACCESS_MAP)) {
129
- return next({ ctx: { ...ctx, user: ctx.user } });
140
+ // Everything below this line is a NON-ADMIN principal.
141
+ //
142
+ // A hand-written core route has no cap entry. Authentication has
143
+ // already passed; defer further gating to any explicit
144
+ // `adminProcedure` chained on top of this one. A cap route is
145
+ // scope-matched first.
146
+ if (path in system_1.METHOD_ACCESS_MAP) {
147
+ // Device-scope caps may be gated by a `device:N` scope. Resolve the
148
+ // raw input once so the matcher can read `input.deviceId` without
149
+ // re-doing the Zod parse (tRPC caches the parsed input downstream).
150
+ // The `getDeviceAncestors` hook lets the matcher walk parent → child
151
+ // accessory inheritance (grant on Reolink also covers its siren / PIR).
152
+ const rawInput = await getRawInput();
153
+ const result = (0, scope_access_js_1.checkScopeAccess)(ctx.user.scopes ?? [], path, rawInput, ctx.getDeviceAncestors);
154
+ if (!result.ok) {
155
+ throw new server_1.TRPCError({ code: 'FORBIDDEN', message: result.reason });
156
+ }
130
157
  }
131
- // Device-scope caps may be gated by a `device:N` scope. Resolve the
132
- // raw input once so the matcher can read `input.deviceId` without
133
- // re-doing the Zod parse (tRPC caches the parsed input downstream).
134
- // The `getDeviceAncestors` hook lets the matcher walk parent child
135
- // accessory inheritance (grant on Reolink also covers its siren / PIR).
136
- const rawInput = await getRawInput();
137
- const result = (0, scope_access_js_1.checkScopeAccess)(ctx.user.scopes ?? [], path, rawInput, ctx.getDeviceAncestors);
138
- if (!result.ok) {
139
- throw new server_1.TRPCError({ code: 'FORBIDDEN', message: result.reason });
158
+ // A non-admin who is allowed to READ a device is not thereby allowed to
159
+ // read the credential that opens it. `deviceManager.getDevice` &co. are
160
+ // `auth: 'protected'`, so a read-only `category:system` grant reaches
161
+ // them and the raw row carries `config.password` in plaintext for
162
+ // every camera in the deployment. The value is withheld HERE, at the
163
+ // transport boundary, so in-process callers (`ctx.api`, the pipeline
164
+ // orchestrator, the drivers that must actually connect) are unaffected.
165
+ const redactConfigSecrets = device_config_secret_redaction_js_1.NON_ADMIN_CONFIG_REDACTED_METHODS.get(path);
166
+ const out = await next({ ctx: { ...ctx, user: ctx.user } });
167
+ if (redactConfigSecrets && out.ok) {
168
+ return { ...out, data: redactConfigSecrets(out.data) };
140
169
  }
141
- return next({ ctx: { ...ctx, user: ctx.user } });
170
+ return out;
142
171
  });
143
172
  /**
144
173
  * Destructive-ops gate. Adds an explicit admin check on top of
@@ -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. Discriminated on `kind` so future
73
- * share surfaces (single-camera view, recording clip, …) extend the union
74
- * without touching verification plumbing.
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.literal('grid-view'),
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.77",
3
+ "version": "1.2.79",
4
4
  "private": false,
5
5
  "files": [
6
6
  "dist",
@@ -33,19 +33,19 @@
33
33
  ]
34
34
  },
35
35
  "dependencies": {
36
- "@camstack/addon-admin-ui": "1.2.37",
36
+ "@camstack/addon-admin-ui": "1.2.39",
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.48",
42
- "@camstack/addon-pipeline-orchestrator": "1.2.31",
43
- "@camstack/addon-post-analysis": "1.2.53",
41
+ "@camstack/addon-pipeline": "1.2.51",
42
+ "@camstack/addon-pipeline-orchestrator": "1.2.33",
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.63",
47
- "@camstack/types": "1.2.47",
48
- "@camstack/ui-library": "1.2.35",
46
+ "@camstack/system": "1.2.66",
47
+ "@camstack/types": "1.2.50",
48
+ "@camstack/ui-library": "1.2.36",
49
49
  "@fastify/compress": "^9.0.0",
50
50
  "@fastify/cookie": "^11.0.2",
51
51
  "@fastify/cors": "^11.2.0",