@camstack/server 1.2.104 → 1.2.106

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.
@@ -77,7 +77,7 @@ function createAddonSettingsRouter(cfg) {
77
77
  * Read the addon-global settings record for the given addon.
78
78
  * Returns the raw stored values (no defaults, no device overrides).
79
79
  */
80
- getGlobal: trpc_middleware_js_1.protectedProcedure
80
+ getGlobal: trpc_middleware_js_1.adminProcedure
81
81
  .input(AddonIdInputSchema)
82
82
  .output(AddonSettingsRecordSchema)
83
83
  .query(({ input }) => cfg.getAddonConfig(input.addonId)),
@@ -86,7 +86,7 @@ function createAddonSettingsRouter(cfg) {
86
86
  * Returns the raw stored values (schema filtering happens on the
87
87
  * consumer side at merge time).
88
88
  */
89
- getDeviceOverrides: trpc_middleware_js_1.protectedProcedure
89
+ getDeviceOverrides: trpc_middleware_js_1.adminProcedure
90
90
  .input(AddonDeviceInputSchema)
91
91
  .output(AddonSettingsRecordSchema)
92
92
  .query(({ input }) => cfg.getAddonDevice(input.addonId, input.deviceId)),
@@ -97,7 +97,7 @@ function createAddonSettingsRouter(cfg) {
97
97
  * writes from addon code; bulk updates should use a dedicated admin
98
98
  * endpoint (not exposed here).
99
99
  */
100
- updateGlobal: trpc_middleware_js_1.protectedProcedure
100
+ updateGlobal: trpc_middleware_js_1.adminProcedure
101
101
  .input(UpdateGlobalInputSchema)
102
102
  .output(SuccessSchema)
103
103
  .mutation(({ input }) => {
@@ -113,7 +113,7 @@ function createAddonSettingsRouter(cfg) {
113
113
  * responsibility — we preserve the raw shape at this layer so the
114
114
  * resolver contract remains symmetric with `getDeviceOverrides`.
115
115
  */
116
- updateDevice: trpc_middleware_js_1.protectedProcedure
116
+ updateDevice: trpc_middleware_js_1.adminProcedure
117
117
  .input(UpdateDeviceInputSchema)
118
118
  .output(SuccessSchema)
119
119
  .mutation(({ input }) => {
@@ -130,7 +130,7 @@ function createAddonSettingsRouter(cfg) {
130
130
  * `updateGlobal` (single-field merge), this overwrites the full record.
131
131
  * Admin-level write: only workers with valid hub tokens can call this.
132
132
  */
133
- replaceGlobal: trpc_middleware_js_1.protectedProcedure
133
+ replaceGlobal: trpc_middleware_js_1.adminProcedure
134
134
  .input(ReplaceGlobalInputSchema)
135
135
  .output(SuccessSchema)
136
136
  .mutation(({ input }) => {
@@ -1,7 +1,4 @@
1
1
  "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.EXCHANGE_SESSION_HEADER = void 0;
4
- exports.createAuthRouter = createAuthRouter;
5
2
  /**
6
3
  * Auth router — core API for login/logout/me.
7
4
  *
@@ -17,14 +14,18 @@ exports.createAuthRouter = createAuthRouter;
17
14
  * collection — the single, generic mechanism every auth addon contributes
18
15
  * to (superseding the removed `auth.listProviders`).
19
16
  */
20
- const zod_1 = require("zod");
21
- const server_1 = require("@trpc/server");
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.EXCHANGE_SESSION_HEADER = void 0;
19
+ exports.createAuthRouter = createAuthRouter;
22
20
  const types_1 = require("@camstack/types");
23
- const share_token_service_js_1 = require("../../core/auth/share-token.service.js");
24
- const handoff_code_service_js_1 = require("../../core/auth/handoff-code.service.js");
21
+ const server_1 = require("@trpc/server");
22
+ const zod_1 = require("zod");
23
+ const auth_rate_limit_js_1 = require("../../auth/auth-rate-limit.js");
25
24
  const session_cookie_js_1 = require("../../auth/session-cookie.js");
25
+ const handoff_code_service_js_1 = require("../../core/auth/handoff-code.service.js");
26
+ const share_token_service_js_1 = require("../../core/auth/share-token.service.js");
27
+ const principal_visibility_js_1 = require("../trpc/principal-visibility.js");
26
28
  const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
27
- const auth_rate_limit_js_1 = require("../../auth/auth-rate-limit.js");
28
29
  // ── Public-auth rate limiting ────────────────────────────────────────
29
30
  // The public surface below (credential validation, passkey ceremonies,
30
31
  // one-time-code redemption) had NO throttling — unlimited online
@@ -273,6 +274,35 @@ function readRequestHeader(req, name) {
273
274
  return value[0] ?? null;
274
275
  return null;
275
276
  }
277
+ /** Per-access device reach summary (counts, never id lists). */
278
+ const DeviceReachSummarySchema = zod_1.z.object({
279
+ all: zod_1.z.boolean(),
280
+ deviceCount: zod_1.z.number().int(),
281
+ });
282
+ /** One capability/addon grant flattened for the UI. */
283
+ const ScopeGrantSummarySchema = zod_1.z.object({
284
+ name: zod_1.z.string(),
285
+ access: zod_1.z.array(types_1.MethodAccessSchema).readonly(),
286
+ });
287
+ /**
288
+ * The caller's RESOLVED effective scope (scope model v3). Selectors are
289
+ * expanded against the live fleet to counts/flags — never raw id lists — so
290
+ * the viewer (F3/F4) can HIDE out-of-scope surfaces from a single probe. A
291
+ * non-admin with zero grants reports zero everything (born with no access).
292
+ */
293
+ const EffectiveScopeSchema = zod_1.z.object({
294
+ isAdmin: zod_1.z.boolean(),
295
+ allDevicesViewable: zod_1.z.boolean(),
296
+ viewableDeviceCount: zod_1.z.number().int(),
297
+ device: zod_1.z.object({
298
+ view: DeviceReachSummarySchema,
299
+ create: DeviceReachSummarySchema,
300
+ delete: DeviceReachSummarySchema,
301
+ }),
302
+ system: zod_1.z.array(types_1.MethodAccessSchema).readonly(),
303
+ capabilities: zod_1.z.array(ScopeGrantSummarySchema).readonly(),
304
+ addons: zod_1.z.array(ScopeGrantSummarySchema).readonly(),
305
+ });
276
306
  /** Wire shape of the authenticated user returned by `auth.me`. */
277
307
  const MeSchema = zod_1.z
278
308
  .object({
@@ -286,6 +316,11 @@ const MeSchema = zod_1.z
286
316
  }),
287
317
  isApiKey: zod_1.z.boolean(),
288
318
  agentId: zod_1.z.string().optional(),
319
+ /**
320
+ * The caller's resolved effective scope. Absent only on the (unreachable
321
+ * here — `me` is `protected`) null-principal branch.
322
+ */
323
+ scope: EffectiveScopeSchema.optional(),
289
324
  })
290
325
  .nullable();
291
326
  function createAuthRouter(auth, registry, moleculer = null, shareTokens = null, handoffCodes = new handoff_code_service_js_1.HandoffCodeService()) {
@@ -692,7 +727,44 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
692
727
  me: trpc_middleware_js_1.protectedProcedure
693
728
  .input(zod_1.z.void())
694
729
  .output(MeSchema)
695
- .query(({ ctx }) => ctx.user),
730
+ .query(({ ctx }) => {
731
+ if (!ctx.user)
732
+ return null;
733
+ // Resolve the effective scope against the live fleet so the viewer can
734
+ // hide surfaces from a single probe (F1 #5). Admins summarise to
735
+ // "everything"; a scoped principal's selectors expand to counts.
736
+ const fleet = ctx.deviceFleet?.() ?? [];
737
+ const scope = (0, types_1.summarizeEffectiveScope)(ctx.user.scopes ?? [], fleet, ctx.user.isAdmin);
738
+ return { ...ctx.user, scope };
739
+ }),
740
+ /**
741
+ * Resolve a PROPOSED grant set the same way `me` resolves the caller's
742
+ * own — so the Users page can show "12 devices viewable, 2 actionable"
743
+ * BEFORE saving, and the number it shows is the number the matcher will
744
+ * enforce.
745
+ *
746
+ * Deliberately the same two lines as `me` (`summarizeEffectiveScope`
747
+ * over `ctx.deviceFleet()`) rather than a second derivation in the admin
748
+ * UI. A preview computed client-side from the same selectors would be a
749
+ * hand-copied scope check, and those drift (D103) — a preview that
750
+ * disagrees with enforcement is worse than none, because it is trusted.
751
+ *
752
+ * Admin-gated: only an admin edits another user's scopes
753
+ * (`user-management.setUserScopes` is `auth: 'admin'`), and the fleet
754
+ * size / room labels this leaks are admin-visible already. It reads
755
+ * nothing off the caller, so it is not a self-scope oracle.
756
+ *
757
+ * `isAdmin` previews the admin BYPASS — with it set, grants are ignored
758
+ * and the answer is "everything", which is what the panel must show
759
+ * when the operator ticks the Admin box.
760
+ */
761
+ previewScope: trpc_middleware_js_1.adminProcedure
762
+ .input(zod_1.z.object({
763
+ scopes: zod_1.z.array(types_1.TokenScopeSchema),
764
+ isAdmin: zod_1.z.boolean().default(false),
765
+ }))
766
+ .output(EffectiveScopeSchema)
767
+ .query(({ ctx, input }) => (0, types_1.summarizeEffectiveScope)(input.scopes, ctx.deviceFleet?.() ?? [], input.isAdmin)),
696
768
  // ── Self-service profile operations ───────────────────────────────
697
769
  //
698
770
  // These route through the `user-management` capability provider but
@@ -881,9 +953,30 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
881
953
  .mutation(async ({ input, ctx }) => {
882
954
  assertRealUserSession(ctx.user);
883
955
  const service = requireShareTokens();
956
+ // SECURITY (F0.4): a share token is a PRIVILEGE HANDOFF — it authorises
957
+ // whoever holds the link to view its devices with no further auth. A
958
+ // scoped (non-admin) minter must therefore not be able to mint for a
959
+ // device it cannot itself see: without this, a `device:[5]` token could
960
+ // mint a `csv_*` grid link for cameras 5..99. Admins mint anything;
961
+ // every other caller's requested deviceIds are INTERSECTED with what
962
+ // they can view (device:[parent] covers child accessories via the
963
+ // ancestor walk). An empty intersection is a refusal, not a silent
964
+ // "share nothing".
965
+ const scope = ctx.user.isAdmin
966
+ ? input.scope
967
+ : {
968
+ ...input.scope,
969
+ deviceIds: (0, principal_visibility_js_1.filterViewableDeviceIds)(ctx.user.scopes ?? [], input.scope.deviceIds, ctx.deviceScopeLookup),
970
+ };
971
+ if (scope.deviceIds.length === 0) {
972
+ throw new server_1.TRPCError({
973
+ code: 'FORBIDDEN',
974
+ message: 'None of the requested devices are within your scope — you cannot mint a share token for devices you cannot see',
975
+ });
976
+ }
884
977
  const { token, record } = await service.create({
885
978
  userId: ctx.user.id,
886
- scope: input.scope,
979
+ scope,
887
980
  ...(input.ttlSec !== undefined ? { ttlSec: input.ttlSec } : {}),
888
981
  });
889
982
  return { id: record.id, token, expiresAt: record.expiresAt };
@@ -89,7 +89,7 @@ function serializeRecentEvent(e) {
89
89
  }
90
90
  function createEventBusProxyRouter(eventBus) {
91
91
  return (0, trpc_middleware_js_1.trpcRouter)({
92
- emit: trpc_middleware_js_1.protectedProcedure
92
+ emit: trpc_middleware_js_1.adminProcedure
93
93
  .input(SystemEventInputSchema)
94
94
  .output(zod_1.z.object({ ok: zod_1.z.literal(true) }))
95
95
  .mutation(({ input }) => {
@@ -10,6 +10,7 @@ exports.createLiveEventsRouter = createLiveEventsRouter;
10
10
  const zod_1 = require("zod");
11
11
  const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
12
12
  const share_view_access_js_1 = require("../trpc/share-view-access.js");
13
+ const principal_visibility_js_1 = require("../trpc/principal-visibility.js");
13
14
  // The pushed wire shape is `SystemEvent` from @camstack/types — the exact
14
15
  // type `EventBusService.subscribe` hands its handlers. It MUST be a type
15
16
  // exported from @camstack/types (not a local interface): the AppRouter
@@ -25,26 +26,46 @@ function createLiveEventsRouter(eb, ar) {
25
26
  limit: zod_1.z.number().optional(),
26
27
  })
27
28
  .optional())
28
- .query(({ input }) => eb.getRecent(input ?? {}, input?.limit ?? 50)),
29
+ .query(({ input, ctx }) => {
30
+ const recent = eb.getRecent(input ?? {}, input?.limit ?? 50);
31
+ // SECURITY (F0.5): a device-restricted scoped token must not read the
32
+ // whole deployment's event history. `scopedEventVisibility` returns null
33
+ // for admins and broad category-view operators (no filtering), or a
34
+ // per-event predicate that keeps only events for devices the caller may
35
+ // view. (Share-view tokens cannot reach this method — not allowlisted.)
36
+ const keep = (0, principal_visibility_js_1.scopedEventVisibility)(ctx.user, ctx.deviceScopeLookup);
37
+ return keep ? recent.filter((evt) => keep(evt)) : recent;
38
+ }),
29
39
  onEvent: trpc_middleware_js_1.protectedProcedure
30
40
  .input(zod_1.z.object({ category: zod_1.z.string().optional() }))
31
41
  .subscription(({ input, ctx }) => {
32
42
  // Share-view principals get a device-filtered stream: only events
33
43
  // attributable to an in-scope device are pushed (fail closed —
34
- // unattributable events are dropped). Everyone else gets the
35
- // unfiltered stream, exactly as before.
44
+ // unattributable events are dropped).
36
45
  const shareScope = ctx.user.shareView?.scope ?? null;
46
+ // Non-share scoped tokens get the SAME projection keyed to what they may
47
+ // view (F0.5); admins and broad category-view operators are unfiltered.
48
+ const scopedKeep = shareScope
49
+ ? null
50
+ : (0, principal_visibility_js_1.scopedEventVisibility)(ctx.user, ctx.deviceScopeLookup);
37
51
  return (0, trpc_middleware_js_1.iterableSubscription)((push) => {
38
52
  const filter = {};
39
53
  if (input.category)
40
54
  filter.category = input.category;
41
- if (!shareScope)
42
- return eb.subscribe(filter, push);
43
- const allowedDeviceIds = new Set(shareScope.deviceIds);
44
- return eb.subscribe(filter, (evt) => {
45
- if ((0, share_view_access_js_1.liveEventInShareScope)(evt, allowedDeviceIds))
46
- push(evt);
47
- });
55
+ if (shareScope) {
56
+ const allowedDeviceIds = new Set(shareScope.deviceIds);
57
+ return eb.subscribe(filter, (evt) => {
58
+ if ((0, share_view_access_js_1.liveEventInShareScope)(evt, allowedDeviceIds))
59
+ push(evt);
60
+ });
61
+ }
62
+ if (scopedKeep) {
63
+ return eb.subscribe(filter, (evt) => {
64
+ if (scopedKeep(evt))
65
+ push(evt);
66
+ });
67
+ }
68
+ return eb.subscribe(filter, push);
48
69
  });
49
70
  }),
50
71
  onDeviceEvent: trpc_middleware_js_1.protectedProcedure
@@ -139,7 +139,13 @@ function createLogsRouter(logging) {
139
139
  const removed = logging.clear(filter);
140
140
  return { removed };
141
141
  }),
142
- subscribe: trpc_middleware_js_1.protectedProcedure
142
+ // SECURITY (F0.5): the log stream is a system-wide firehose — it carries
143
+ // error context, file paths, config fragments and per-device diagnostics
144
+ // with no reliable per-caller projection. `query` and `clear` are already
145
+ // `adminProcedure`; `subscribe` was the one non-admin door onto the same
146
+ // data, so it is gated to match. (A scoped, device-projected log view is a
147
+ // later, larger change — not a security hotfix.)
148
+ subscribe: trpc_middleware_js_1.adminProcedure
143
149
  .input(zod_1.z.object({
144
150
  level: LogLevelSchema.optional(),
145
151
  tags: LogTagsSchema.optional(),
@@ -11,6 +11,7 @@ exports.createSystemEventsRouter = createSystemEventsRouter;
11
11
  */
12
12
  const zod_1 = require("zod");
13
13
  const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
14
+ const principal_visibility_js_1 = require("../trpc/principal-visibility.js");
14
15
  function serialize(e) {
15
16
  return {
16
17
  id: e.id,
@@ -80,18 +81,23 @@ const SubscribeInputSchema = ScopeFieldsSchema.extend({
80
81
  });
81
82
  function createSystemEventsRouter(eb) {
82
83
  return (0, trpc_middleware_js_1.trpcRouter)({
83
- getRecent: trpc_middleware_js_1.protectedProcedure.input(GetRecentInputSchema).query(({ input }) => {
84
- return eb
85
- .getRecent({
84
+ getRecent: trpc_middleware_js_1.protectedProcedure.input(GetRecentInputSchema).query(({ input, ctx }) => {
85
+ const recent = eb.getRecent({
86
86
  ...(input.source ? { source: input.source } : {}),
87
87
  ...(input.agentId ? { agentId: input.agentId } : {}),
88
88
  ...(input.addonId ? { addonId: input.addonId } : {}),
89
89
  ...(input.deviceId !== undefined ? { deviceId: input.deviceId } : {}),
90
90
  ...(input.category ? { category: input.category } : {}),
91
- }, input.limit)
92
- .map(serialize);
91
+ }, input.limit);
92
+ // SECURITY (F0.5): filter the history to what a device-restricted scoped
93
+ // token may view. null ⇒ admin / broad category-view operator ⇒ unfiltered.
94
+ const keep = (0, principal_visibility_js_1.scopedEventVisibility)(ctx.user, ctx.deviceScopeLookup);
95
+ return (keep ? recent.filter((evt) => keep(evt)) : recent).map(serialize);
93
96
  }),
94
- subscribe: trpc_middleware_js_1.protectedProcedure.input(SubscribeInputSchema).subscription(({ input }) => {
97
+ subscribe: trpc_middleware_js_1.protectedProcedure.input(SubscribeInputSchema).subscription(({ input, ctx }) => {
98
+ // SECURITY (F0.5): a device-restricted scoped token must not receive every
99
+ // camera's live events. Same projection as `getRecent`.
100
+ const keep = (0, principal_visibility_js_1.scopedEventVisibility)(ctx.user, ctx.deviceScopeLookup);
95
101
  return (0, trpc_middleware_js_1.iterableSubscription)((push) => {
96
102
  return eb.subscribe({
97
103
  ...(input.source ? { source: input.source } : {}),
@@ -99,7 +105,10 @@ function createSystemEventsRouter(eb) {
99
105
  ...(input.addonId ? { addonId: input.addonId } : {}),
100
106
  ...(input.deviceId !== undefined ? { deviceId: input.deviceId } : {}),
101
107
  ...(input.category ? { category: input.category } : {}),
102
- }, (event) => push(serialize(event)));
108
+ }, (event) => {
109
+ if (!keep || keep(event))
110
+ push(serialize(event));
111
+ });
103
112
  });
104
113
  }),
105
114
  });
@@ -46,9 +46,11 @@ exports.redactDeviceInfoSecrets = redactDeviceInfoSecrets;
46
46
  exports.redactSettingsSections = redactSettingsSections;
47
47
  exports.redactSettingsAggregate = redactSettingsAggregate;
48
48
  exports.redactConfigEntries = redactConfigEntries;
49
+ exports.redactStreamUrlSecrets = redactStreamUrlSecrets;
49
50
  /** What a non-admin sees instead of the value. Matches the `***` convention
50
51
  * `maskUrlCredentials` already uses for credentials in log output, so one
51
52
  * redaction marker means one thing across the system. */
53
+ const types_1 = require("@camstack/types");
52
54
  exports.REDACTED_SECRET = '***';
53
55
  /**
54
56
  * Config keys whose VALUE is a credential.
@@ -163,6 +165,42 @@ function redactConfigEntries(data) {
163
165
  return data;
164
166
  return data.map(redactField);
165
167
  }
168
+ /**
169
+ * URL fields whose VALUE is an RTSP/RTMP restream address and can embed
170
+ * `user:password@host` userinfo — the camera's own credentials.
171
+ *
172
+ * The stream surfaces (`cameraStreams.getRtspEntries`,
173
+ * `streamBroker.getRtspEntry`, `deviceOps.getStreamSources`, …) hand a
174
+ * non-admin caller `rtsp://admin:hunter2@192.168.1.139/…` verbatim: a
175
+ * read-only token can open every camera by copy-pasting the string. These
176
+ * are NOT config blobs, so {@link redactRecord} never sees them — the leak
177
+ * lives in a plain `url` / `mutedUrl` string field.
178
+ */
179
+ const URL_FIELD_KEYS = ['url', 'mutedUrl'];
180
+ /**
181
+ * Mask the userinfo of every URL-valued field on a stream entry (or array of
182
+ * them), reusing {@link maskUrlCredentials} so ONE masker governs both logs
183
+ * and the wire. A URL with no `user:pass@` (a broker restream whose token is
184
+ * in the path) is returned unchanged, so this is safe to apply broadly.
185
+ *
186
+ * Only the enumerated {@link URL_FIELD_KEYS} are touched; a non-URL string
187
+ * (`codec`, `label`) and a non-string value pass through. Non-row payloads
188
+ * pass through untouched — an upstream error must not be rewritten.
189
+ */
190
+ function redactStreamUrlSecrets(data) {
191
+ if (Array.isArray(data))
192
+ return data.map((row) => redactStreamUrlSecrets(row));
193
+ if (!isRecord(data))
194
+ return data;
195
+ const out = {};
196
+ for (const [key, value] of Object.entries(data)) {
197
+ out[key] =
198
+ URL_FIELD_KEYS.includes(key) && typeof value === 'string' && value.length > 0
199
+ ? (0, types_1.maskUrlCredentials)(value)
200
+ : value;
201
+ }
202
+ return out;
203
+ }
166
204
  /**
167
205
  * Every device-manager read whose response carries a device `config` blob, and
168
206
  * the projection that cuts it.
@@ -188,4 +226,14 @@ exports.NON_ADMIN_CONFIG_REDACTED_METHODS = new Map([
188
226
  ['deviceManager.getSettingsSchema', redactSettingsSections],
189
227
  ['deviceManager.getDeviceAggregate', redactSettingsAggregate],
190
228
  ['deviceManager.getConfigSchema', redactConfigEntries],
229
+ // ── RTSP/RTMP restream URLs (userinfo) — not a config blob, a `url` field ──
230
+ // Every method whose output can carry `rtsp://user:pass@host`. A read-only
231
+ // token reaching these got the camera's own credentials verbatim.
232
+ ['cameraStreams.getCameraStreams', redactStreamUrlSecrets],
233
+ ['cameraStreams.getRtspEntries', redactStreamUrlSecrets],
234
+ ['cameraStreams.getProfileRtspEntries', redactStreamUrlSecrets],
235
+ ['deviceOps.getStreamSources', redactStreamUrlSecrets],
236
+ ['deviceManager.getStreamSources', redactStreamUrlSecrets],
237
+ ['streamBroker.getRtspEntry', redactStreamUrlSecrets],
238
+ ['streamBroker.getAllRtspEntries', redactStreamUrlSecrets],
191
239
  ]);
@@ -7310,7 +7310,7 @@ function createCapRouter_recording(getProvider, createRemoteProxy) {
7310
7310
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
7311
7311
  return p.applyDeviceSettingsPatch(methodInput);
7312
7312
  }),
7313
- getAvailability: trpc_middleware_js_1.adminProcedure
7313
+ getAvailability: trpc_middleware_js_1.protectedProcedure
7314
7314
  .input(types_92.recordingCapability.methods.getAvailability.input.loose())
7315
7315
  .output(types_92.recordingCapability.methods.getAvailability.output)
7316
7316
  .query(async ({ input, ctx }) => {
@@ -7319,7 +7319,7 @@ function createCapRouter_recording(getProvider, createRemoteProxy) {
7319
7319
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
7320
7320
  return p.getAvailability(methodInput);
7321
7321
  }),
7322
- getDaysWithRecordings: trpc_middleware_js_1.adminProcedure
7322
+ getDaysWithRecordings: trpc_middleware_js_1.protectedProcedure
7323
7323
  .input(types_92.recordingCapability.methods.getDaysWithRecordings.input.loose())
7324
7324
  .output(types_92.recordingCapability.methods.getDaysWithRecordings.output)
7325
7325
  .query(async ({ input, ctx }) => {
@@ -7328,7 +7328,7 @@ function createCapRouter_recording(getProvider, createRemoteProxy) {
7328
7328
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
7329
7329
  return p.getDaysWithRecordings(methodInput);
7330
7330
  }),
7331
- getPlaybackManifest: trpc_middleware_js_1.adminProcedure
7331
+ getPlaybackManifest: trpc_middleware_js_1.protectedProcedure
7332
7332
  .input(types_92.recordingCapability.methods.getPlaybackManifest.input.loose())
7333
7333
  .output(types_92.recordingCapability.methods.getPlaybackManifest.output)
7334
7334
  .query(async ({ input, ctx }) => {
@@ -0,0 +1,159 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.callerCanViewDevice = callerCanViewDevice;
4
+ exports.isBroadEventViewer = isBroadEventViewer;
5
+ exports.filterViewableDeviceIds = filterViewableDeviceIds;
6
+ exports.hasUnrestrictedDeviceView = hasUnrestrictedDeviceView;
7
+ exports.projectListAllForPrincipal = projectListAllForPrincipal;
8
+ exports.scopedEventVisibility = scopedEventVisibility;
9
+ /**
10
+ * Non-admin principal device visibility — the shared answer to "which cameras
11
+ * may this scoped caller see?", reused by two hotfixes:
12
+ *
13
+ * - F0.4 `auth.createShareToken` — a scoped user must not mint a `csv_*`
14
+ * share token for a device it cannot see. The requested deviceIds are
15
+ * INTERSECTED with what the caller can view.
16
+ * - F0.5 the event firehose (`live.onEvent`, `systemEvents.subscribe`, …) —
17
+ * a device-restricted scoped token must not receive every camera's motion /
18
+ * detection events. Its stream is filtered to the devices it can view.
19
+ *
20
+ * The single source of truth for "can this principal VIEW device D" is the
21
+ * existing scope matcher ({@link checkScopeAccess}) run against a canonical
22
+ * device-scope VIEW method. Reusing the matcher means share minting and event
23
+ * filtering agree with the middleware that gates the per-camera surfaces
24
+ * themselves — including `device:[parent]` → child-accessory inheritance via
25
+ * the ancestor walk.
26
+ *
27
+ * Pure module — no tRPC machinery — so the spec exercises it directly (same
28
+ * pattern as `scope-access.ts` / `share-view-access.ts`).
29
+ */
30
+ const system_1 = require("@camstack/system");
31
+ const types_1 = require("@camstack/types");
32
+ const scope_access_js_1 = require("./scope-access.js");
33
+ const share_view_access_js_1 = require("./share-view-access.js");
34
+ /**
35
+ * Canonical "view this camera" probe. `snapshot.getSnapshot` is a device-scope,
36
+ * `view`-access method every viewer surface needs (the grid-embed cold-start
37
+ * tile), so a caller who can pass its scope check is a caller who can see the
38
+ * camera. A rename is caught at import time by {@link assertProbeExists} — a
39
+ * loud boot failure, never a silent "deny everyone".
40
+ */
41
+ const DEVICE_VIEW_PROBE = 'snapshot.getSnapshot';
42
+ function assertProbeExists() {
43
+ const meta = system_1.METHOD_ACCESS_MAP[DEVICE_VIEW_PROBE];
44
+ if (!meta || meta.capScope !== 'device' || meta.access !== 'view') {
45
+ throw new Error(`principal-visibility: probe '${DEVICE_VIEW_PROBE}' is not a device-scope view method ` +
46
+ `(codegen drift). Pick another canonical camera-view method.`);
47
+ }
48
+ }
49
+ assertProbeExists();
50
+ /**
51
+ * True when the scoped principal may VIEW device `deviceId`. Handles
52
+ * `device:[parent]` → child inheritance through the ancestor walk, exactly as
53
+ * the per-camera middleware does.
54
+ */
55
+ function callerCanViewDevice(scopes, deviceId, lookup) {
56
+ return (0, scope_access_js_1.checkScopeAccess)(scopes, DEVICE_VIEW_PROBE, { deviceId }, lookup).ok;
57
+ }
58
+ /**
59
+ * A scoped principal sees the WHOLE event/telemetry firehose when it holds any
60
+ * `category`-scope view grant — the operator / family-viewer tiers, whose whole
61
+ * purpose is a broad read. A token scoped only to specific `device:[…]` (or a
62
+ * single `capability`/`addon`) is NOT broad: its event stream is filtered to the
63
+ * devices it can view, and unattributable events are dropped (fail closed).
64
+ *
65
+ * Kept deliberately separate from {@link callerCanViewDevice}: a
66
+ * `category:system` operator legitimately monitors the system dashboard, so
67
+ * gating their event stream to a device-scope probe (which `category:system`
68
+ * does not satisfy) would blank a surface they are entitled to. Availability and
69
+ * security both point the same way here — broad grant, broad stream.
70
+ */
71
+ function isBroadEventViewer(scopes) {
72
+ return scopes.some((s) => s.type === 'category' && s.access.includes('view'));
73
+ }
74
+ /**
75
+ * Filter a requested deviceId list down to those the caller may view. Used by
76
+ * share minting: an admin passes `isAdmin` and skips this; a non-admin gets the
77
+ * intersection, and the router refuses when it is empty.
78
+ */
79
+ function filterViewableDeviceIds(scopes, requested, lookup) {
80
+ return requested.filter((id) => callerCanViewDevice(scopes, id, lookup));
81
+ }
82
+ /** The device identity an event is attributable to, or null when it carries
83
+ * none. Mirrors `liveEventInShareScope`'s matching (source.id, source.deviceId,
84
+ * data.deviceId) so scoped filtering and share filtering agree. */
85
+ function eventDeviceId(evt) {
86
+ const sourceId = evt.source?.id;
87
+ if (typeof sourceId === 'number' && Number.isFinite(sourceId))
88
+ return sourceId;
89
+ if (typeof sourceId === 'string' && sourceId !== '') {
90
+ const n = Number(sourceId);
91
+ if (Number.isInteger(n))
92
+ return n;
93
+ }
94
+ const src = evt.source;
95
+ if (typeof src?.deviceId === 'number' && Number.isFinite(src.deviceId))
96
+ return src.deviceId;
97
+ const dataDeviceId = evt.data?.['deviceId'];
98
+ if (typeof dataDeviceId === 'number' && Number.isFinite(dataDeviceId))
99
+ return dataDeviceId;
100
+ return null;
101
+ }
102
+ /**
103
+ * True when the principal's device reach is UNRESTRICTED at view — admin, a
104
+ * `category:device[view]` grant, or a device grant with an `all` selector.
105
+ * Such a caller enumerates the fleet unprojected, exactly as before.
106
+ */
107
+ function hasUnrestrictedDeviceView(isAdmin, scopes) {
108
+ if (isAdmin)
109
+ return true;
110
+ return scopes.some((s) => s.access.includes('view') &&
111
+ ((s.type === 'category' && s.target === 'device') ||
112
+ (s.type === 'device' && s.selector.kind === 'all')));
113
+ }
114
+ /**
115
+ * Cut a `deviceManager.listAll` payload to the devices this principal may VIEW
116
+ * (F1 #4 — "enumerate only mine").
117
+ *
118
+ * A device outside the scope is INVISIBLE in enumerations (operator decision),
119
+ * not merely un-openable: returning it would leak the deployment's camera
120
+ * names, locations and online state to a token granted one camera.
121
+ *
122
+ * Reuses `resolveViewableDeviceIds` (the same selector engine the matcher
123
+ * uses, so enumeration and by-id access can never disagree) and
124
+ * `projectListAllForDeviceIds` (the same row whitelist the share path uses, so
125
+ * neither surface can forget that `config` carries credentials).
126
+ *
127
+ * A caller with unrestricted reach is returned the payload untouched.
128
+ */
129
+ function projectListAllForPrincipal(data, isAdmin, scopes, fleet) {
130
+ if (hasUnrestrictedDeviceView(isAdmin, scopes))
131
+ return data;
132
+ const viewable = (0, types_1.resolveViewableDeviceIds)(scopes, fleet, 'view');
133
+ return (0, share_view_access_js_1.projectListAllForDeviceIds)(data, viewable);
134
+ }
135
+ /**
136
+ * Build the event-stream predicate for a NON-share principal (F0.5).
137
+ *
138
+ * Returns `null` when the principal sees the whole firehose — an admin, or a
139
+ * broad `category`-view operator (see {@link isBroadEventViewer}). Otherwise a
140
+ * predicate that keeps only events attributable to a device the principal may
141
+ * view; an event with NO device identity is DROPPED (fail closed — "probably in
142
+ * scope" is not a security argument).
143
+ *
144
+ * Share-view (`csv_*`) principals are NOT handled here — they keep their own,
145
+ * stricter `liveEventInShareScope` filter, applied by the routers first.
146
+ */
147
+ function scopedEventVisibility(user, lookup) {
148
+ if (user.isAdmin)
149
+ return null;
150
+ const scopes = user.scopes ?? [];
151
+ if (isBroadEventViewer(scopes))
152
+ return null;
153
+ return (evt) => {
154
+ const id = eventDeviceId(evt);
155
+ if (id === null)
156
+ return false;
157
+ return callerCanViewDevice(scopes, id, lookup);
158
+ };
159
+ }
@@ -1,30 +1,35 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEVICE_ENUMERATION_METHODS = void 0;
3
4
  exports.checkScopeAccess = checkScopeAccess;
4
5
  /**
5
6
  * Pure scope-access matcher.
6
7
  *
7
8
  * Extracted from `trpc.middleware.ts` so the spec can exercise it
8
9
  * without spinning up the tRPC initTRPC machinery. The function is
9
- * stateless aside from an optional device-ancestor lookup callback.
10
+ * stateless aside from an optional {@link DeviceScopeLookup}.
10
11
  *
11
- * Algorithm (v2 — four scope types):
12
+ * Algorithm (v3 — four scope types):
12
13
  * 1. Look the tRPC `path` up in `METHOD_ACCESS_MAP`. Unknown =
13
14
  * FORBIDDEN (codegen drift; fail closed).
14
15
  * 2. For each scope on the caller, check if it matches:
15
16
  * - `category` — scope.target matches meta.capScope ('device'|'system')
16
17
  * - `capability` — scope.target matches meta.capName exactly
17
18
  * - `addon` — scope.target matches meta.addonId (when set)
18
- * - `device` — input.deviceId (OR any of its ancestor deviceIds
19
- * via `getDeviceAncestors`) is in scope.targets.
20
- * Auto-inheritance means granting a Reolink camera
21
- * implicitly grants its siren / floodlight / PIR
22
- * child accessories without re-listing them.
19
+ * - `device` — the request's `input.deviceId` is covered by the
20
+ * grant's v3 `selector` (all / ids / types / locations),
21
+ * resolved against the device's persisted type/location
22
+ * via {@link DeviceScopeLookup}. When the grant inherits
23
+ * (view by default, or `includeLinked`), an ANCESTOR
24
+ * match also covers the device — granting a Reolink
25
+ * camera implicitly grants its siren / floodlight / PIR
26
+ * children without re-listing them.
23
27
  * 3. On a target match, accept iff `scope.access` includes the
24
28
  * method's required `access` flavour.
25
29
  * 4. No matching scope → FORBIDDEN with a human-readable reason.
26
30
  */
27
31
  const system_1 = require("@camstack/system");
32
+ const types_1 = require("@camstack/types");
28
33
  /**
29
34
  * Pull `deviceId` off a tRPC request input. Device-scope cap methods
30
35
  * uniformly take `{deviceId: number, ...}` per the DeviceProxy contract,
@@ -38,25 +43,145 @@ function extractDeviceId(input) {
38
43
  return typeof candidate === 'number' ? candidate : null;
39
44
  }
40
45
  /**
41
- * Build the set of deviceIds that count as "this request" for the
42
- * device-scope match: the deviceId itself plus every ancestor (so a
43
- * scope on the parent camera covers accessory children).
46
+ * The device candidates a request is matched against: the deviceId itself
47
+ * plus (when the grant inherits) its ancestors, each carrying the metadata a
48
+ * v3 selector needs. A parent camera whose grant inherits therefore covers an
49
+ * accessory child, because the child's request tests the grant against the
50
+ * PARENT's metadata too.
44
51
  */
45
- function effectiveDeviceIds(deviceId, getAncestors) {
46
- if (!getAncestors)
47
- return [String(deviceId)];
48
- const out = new Set([String(deviceId)]);
49
- for (const ancestor of getAncestors(deviceId))
50
- out.add(String(ancestor));
51
- return [...out];
52
+ function candidateMetas(deviceId, lookup) {
53
+ const toMeta = (id) => ({
54
+ id,
55
+ type: lookup?.getType(id) ?? '',
56
+ location: lookup?.getLocation(id) ?? null,
57
+ parentDeviceId: null,
58
+ });
59
+ if (!lookup)
60
+ return [toMeta(deviceId)];
61
+ const out = [toMeta(deviceId)];
62
+ for (const ancestor of lookup.getAncestors(deviceId))
63
+ out.push(toMeta(ancestor));
64
+ return out;
52
65
  }
53
- function checkScopeAccess(scopes, path, input, getDeviceAncestors) {
66
+ /** Whether a v3 `device` grant covers this request. Direct selector match on
67
+ * the device, or — when the grant inherits at this access — a match on an
68
+ * ancestor. */
69
+ function deviceScopeCovers(scope, access, candidates) {
70
+ const inherit = (0, types_1.scopeInherits)(scope, access);
71
+ return candidates.some((cand, index) => {
72
+ // index 0 is the device itself (always eligible); ancestors only when the
73
+ // grant inherits.
74
+ if (index > 0 && !inherit)
75
+ return false;
76
+ return (0, types_1.deviceSelectorMatches)(scope.selector, cand);
77
+ });
78
+ }
79
+ /** Render a device selector for the human-readable denial reason. */
80
+ function describeSelector(scope) {
81
+ const sel = scope.selector;
82
+ switch (sel.kind) {
83
+ case 'all':
84
+ return 'all';
85
+ case 'ids':
86
+ return `ids:${sel.ids.join(',')}`;
87
+ case 'types':
88
+ return `types:${sel.types.join(',')}`;
89
+ case 'locations':
90
+ return `locations:${sel.locations.join(',')}`;
91
+ }
92
+ }
93
+ /**
94
+ * Methods that ENUMERATE devices rather than naming one — "which devices are
95
+ * mine". A `device`-selector grant may reach these, and it is safe ONLY
96
+ * because every one of them has its response cut to the caller's viewable set
97
+ * before it leaves the server (`projectListAllForPrincipal`, applied in
98
+ * `trpc.middleware.ts`).
99
+ *
100
+ * Adding a path here WITHOUT adding its projection hands the whole fleet to a
101
+ * one-camera token. The pairing is asserted by
102
+ * `scoped-list-projection.spec.ts`.
103
+ */
104
+ exports.DEVICE_ENUMERATION_METHODS = new Set(['deviceManager.listAll']);
105
+ /**
106
+ * Every deviceId a request NAMES, per the codegen'd
107
+ * {@link METHOD_DEVICE_SELECTORS}. Absent / null / malformed values are
108
+ * skipped: an optional `deviceId` that was not sent references no device, and
109
+ * `linkDeviceId: null` means "clear the link".
110
+ */
111
+ function referencedDeviceIds(path, input) {
112
+ const fields = types_1.METHOD_DEVICE_SELECTORS[path];
113
+ if (!fields || input === null || typeof input !== 'object')
114
+ return [];
115
+ const out = [];
116
+ for (const field of fields) {
117
+ const raw = Reflect.get(input, field.name);
118
+ if (raw === undefined || raw === null)
119
+ continue;
120
+ if (field.form === 'single') {
121
+ if (typeof raw === 'number' && Number.isFinite(raw))
122
+ out.push(raw);
123
+ continue;
124
+ }
125
+ if (!Array.isArray(raw))
126
+ continue;
127
+ for (const item of raw) {
128
+ if (typeof item === 'number' && Number.isFinite(item))
129
+ out.push(item);
130
+ }
131
+ }
132
+ return out;
133
+ }
134
+ /**
135
+ * True when the caller's device reach at `access` is UNRESTRICTED — admin-like
136
+ * breadth expressed as `category:device[access]` or a `device` grant whose
137
+ * selector is `all`. Such a caller is unaffected by the device-reference gate.
138
+ */
139
+ function hasUnrestrictedDeviceReach(scopes, access) {
140
+ return scopes.some((s) => s.access.includes(access) &&
141
+ ((s.type === 'category' && s.target === 'device') ||
142
+ (s.type === 'device' && s.selector.kind === 'all')));
143
+ }
144
+ /**
145
+ * The device-reference gate for SYSTEM-scope cap methods (F1 #3).
146
+ *
147
+ * ~124 system-scope methods take a deviceId (`recording.getPlaybackManifest`,
148
+ * `faceGallery.*`, `pipelineOrchestrator.*`, …) and were never device-filtered:
149
+ * the matcher only extracted a deviceId when the CAP was `scope: 'device'`. So
150
+ * a non-admin holding `category:system[view]` could read ANY camera's
151
+ * recordings or faces regardless of the cameras they were granted. This closes
152
+ * that.
153
+ *
154
+ * Applied ONLY to system-scope caps, and only ON TOP of an already-passing
155
+ * cap-level grant — it never grants anything, it only subtracts. Device-scope
156
+ * caps are deliberately untouched: there the `device` scope branch IS the
157
+ * device check, and `capability:<name>` grants over device caps are
158
+ * cross-device by documented design (the `events` cap, which the
159
+ * `event-listener` preset depends on, is one). Making capability grants
160
+ * device-restricted on device caps too is a separate, operator-visible
161
+ * decision — not smuggled in here.
162
+ */
163
+ function deviceRefsSatisfied(scopes, path, input, access, lookup) {
164
+ const refs = referencedDeviceIds(path, input);
165
+ if (refs.length === 0)
166
+ return { ok: true };
167
+ if (hasUnrestrictedDeviceReach(scopes, access))
168
+ return { ok: true };
169
+ const grants = scopes.filter((s) => s.type === 'device' && s.access.includes(access));
170
+ for (const deviceId of refs) {
171
+ const candidates = candidateMetas(deviceId, lookup);
172
+ const covered = grants.some((g) => deviceScopeCovers(g, access, candidates));
173
+ if (!covered)
174
+ return { ok: false, deviceId };
175
+ }
176
+ return { ok: true };
177
+ }
178
+ function checkScopeAccess(scopes, path, input, lookup) {
54
179
  const meta = system_1.METHOD_ACCESS_MAP[path];
55
180
  if (!meta) {
56
181
  return { ok: false, reason: `Unknown method '${path}' — codegen drift` };
57
182
  }
58
183
  const deviceId = meta.capScope === 'device' ? extractDeviceId(input) : null;
59
- const deviceChain = deviceId !== null ? effectiveDeviceIds(deviceId, getDeviceAncestors) : [];
184
+ const candidates = deviceId !== null ? candidateMetas(deviceId, lookup) : [];
60
185
  for (const s of scopes) {
61
186
  let targetMatches = false;
62
187
  switch (s.type) {
@@ -70,22 +195,43 @@ function checkScopeAccess(scopes, path, input, getDeviceAncestors) {
70
195
  targetMatches = meta.addonId !== null && s.target === meta.addonId;
71
196
  break;
72
197
  case 'device':
73
- // Match if the request's device or any of its ancestors — is
74
- // in the grant's target list. Accessory children inherit the
75
- // parent's scope without re-enumeration.
76
- targetMatches = deviceChain.some((id) => s.targets.includes(id));
198
+ // 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
201
+ // • a DEVICE ENUMERATION method, which names no device precisely
202
+ // because it asks "which are mine". Those are safe to reach ONLY
203
+ // because the RESPONSE is cut to the caller's set (see
204
+ // `DEVICE_ENUMERATION_METHODS`); without that projection this
205
+ // branch would hand the whole fleet to a one-camera token.
206
+ targetMatches =
207
+ deviceId !== null
208
+ ? deviceScopeCovers(s, meta.access, candidates)
209
+ : exports.DEVICE_ENUMERATION_METHODS.has(path);
77
210
  break;
78
211
  }
79
212
  if (!targetMatches)
80
213
  continue;
81
- if (s.access.includes(meta.access))
82
- return { ok: true, access: meta.access };
214
+ if (!s.access.includes(meta.access))
215
+ continue;
216
+ // Cap-level grant passes. A SYSTEM-scope method that NAMES devices must
217
+ // additionally be covered on each of them — otherwise `category:system`
218
+ // reads every camera's recordings (F1 #3).
219
+ if (meta.capScope === 'system') {
220
+ const refs = deviceRefsSatisfied(scopes, path, input, meta.access, lookup);
221
+ if (!refs.ok) {
222
+ return {
223
+ ok: false,
224
+ reason: `Device ${refs.deviceId} is outside your device scope (referenced by '${path}')`,
225
+ };
226
+ }
227
+ }
228
+ return { ok: true, access: meta.access };
83
229
  }
84
230
  return {
85
231
  ok: false,
86
232
  reason: `No scope grants ${meta.access} on '${meta.capName}' (${meta.capScope}-scope cap${deviceId !== null ? `, device=${deviceId}` : ''}). Have: ${scopes
87
233
  .map((s) => {
88
- const target = s.type === 'device' ? `[${s.targets.join(',')}]` : s.target;
234
+ const target = s.type === 'device' ? `[${describeSelector(s)}]` : s.target;
89
235
  return `${s.type}:${target}[${s.access.join(',')}]`;
90
236
  })
91
237
  .join(', ') || '(none)'}`,
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.SHARE_SCOPE_PROJECTED_METHODS = void 0;
4
4
  exports.checkShareViewAccess = checkShareViewAccess;
5
5
  exports.projectListAllForShareScope = projectListAllForShareScope;
6
+ exports.projectListAllForDeviceIds = projectListAllForDeviceIds;
6
7
  exports.projectShareScopeRows = projectShareScopeRows;
7
8
  exports.liveEventInShareScope = liveEventInShareScope;
8
9
  /**
@@ -156,9 +157,21 @@ function checkShareViewAccess(scope, path, input) {
156
157
  * Non-array payloads pass through untouched (the route errored upstream).
157
158
  */
158
159
  function projectListAllForShareScope(data, scope) {
160
+ return projectListAllForDeviceIds(data, new Set(scope.deviceIds));
161
+ }
162
+ /**
163
+ * The projection ENGINE: drop every `listAll` row whose id is not in
164
+ * `allowed`, and project the survivors onto the whitelisted row.
165
+ *
166
+ * Shared by the `csv_*` share path ({@link projectListAllForShareScope}) and
167
+ * the scoped-principal path (`projectListAllForPrincipal` in
168
+ * `principal-visibility.ts`) so enumeration is cut ONE way. Two projectors
169
+ * would be two chances to forget `config` — which carries the camera
170
+ * credentials in plaintext.
171
+ */
172
+ function projectListAllForDeviceIds(data, allowed) {
159
173
  if (!Array.isArray(data))
160
174
  return data;
161
- const allowed = new Set(scope.deviceIds);
162
175
  const rows = [];
163
176
  for (const entry of data) {
164
177
  if (entry === null || typeof entry !== 'object')
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createMeshTrpcContext = createMeshTrpcContext;
4
4
  exports.createTrpcContext = createTrpcContext;
5
5
  exports.createWsTrpcContext = createWsTrpcContext;
6
+ const types_1 = require("@camstack/types");
6
7
  const share_token_service_js_1 = require("../../core/auth/share-token.service.js");
7
8
  const trpc_error_principal_js_1 = require("./trpc-error-principal.js");
8
9
  /** Read `req.query` if present (Fastify-only) without losing type safety. */
@@ -109,7 +110,9 @@ async function resolveUser(token, authService, addonRegistry, shareTokens = null
109
110
  },
110
111
  isApiKey: true,
111
112
  isScoped: true,
112
- scopes: record.scopes,
113
+ // Migrate any v2 `device:targets` grant to a v3 selector at the
114
+ // boundary so the enforcement matcher only ever sees v3.
115
+ scopes: (0, types_1.normalizeTokenScopes)(record.scopes),
113
116
  };
114
117
  }
115
118
  catch {
@@ -144,8 +147,10 @@ async function resolveUser(token, authService, addonRegistry, shareTokens = null
144
147
  isApiKey: payload.type === 'api_key',
145
148
  agentId: payload.agentId,
146
149
  // Scopes are baked into the JWT at login; the middleware uses
147
- // them to gate every call until the user re-logs.
148
- ...(payload.scopes !== undefined ? { scopes: payload.scopes } : {}),
150
+ // them to gate every call until the user re-logs. Normalised at the
151
+ // boundary a v2 `device:targets` JWT (issued before the v3 model)
152
+ // migrates to a selector here, so a pre-v3 token keeps working.
153
+ ...(payload.scopes !== undefined ? { scopes: (0, types_1.normalizeTokenScopes)(payload.scopes) } : {}),
149
154
  ...(credential.credential !== undefined ? { credential: credential.credential } : {}),
150
155
  ...(credential.sessionId !== undefined ? { oauthSessionId: credential.sessionId } : {}),
151
156
  };
@@ -155,24 +160,22 @@ async function resolveUser(token, authService, addonRegistry, shareTokens = null
155
160
  }
156
161
  }
157
162
  /**
158
- * Build the parent-chain walker for the scope-access matcher. Returns
159
- * every ancestor deviceId of `deviceId` (parent, grandparent, …) so a
160
- * grant on a Reolink camera covers its accessory children without
161
- * re-enumerating them.
163
+ * Build the parent-chain walker for the scope-access matcher. Returns every
164
+ * ancestor deviceId of `deviceId` (parent, grandparent, …) so a grant on a
165
+ * Reolink camera covers its accessory children without re-enumerating them.
162
166
  *
163
- * Bounded by hop count (defence-in-depth — the device tree should
164
- * never exceed 2-3 levels but a corrupt registry shouldn't loop forever).
167
+ * Reads `AddonRegistryService.getPersistedAncestors` — the hub-process mirror of
168
+ * the device-manager's PERSISTED parentage (F0.6). It MUST NOT walk the hub
169
+ * `DeviceRegistry` directly: that registry only holds hub-local devices, so it
170
+ * is empty for every forked provider and the inheritance this function exists to
171
+ * provide silently did nothing (0/6 providers inherited). The mirror is warmed
172
+ * off the event path and read synchronously here.
165
173
  */
166
- function makeAncestorLookup(addonRegistry) {
167
- return (deviceId) => {
168
- const out = [];
169
- const registry = addonRegistry.getDeviceRegistry();
170
- let current = registry.getById(deviceId);
171
- for (let hop = 0; hop < 8 && current?.parentDeviceId != null; hop++) {
172
- out.push(current.parentDeviceId);
173
- current = registry.getById(current.parentDeviceId);
174
- }
175
- return out;
174
+ function makeDeviceScopeLookup(addonRegistry) {
175
+ return {
176
+ getAncestors: (deviceId) => addonRegistry.getPersistedAncestors(deviceId),
177
+ getType: (deviceId) => addonRegistry.getPersistedType(deviceId),
178
+ getLocation: (deviceId) => addonRegistry.getPersistedLocation(deviceId),
176
179
  };
177
180
  }
178
181
  /**
@@ -201,7 +204,8 @@ async function createTrpcContext(req, authService, addonRegistry, shareTokens =
201
204
  return {
202
205
  user: await resolveUser(token, authService, addonRegistry, shareTokens),
203
206
  req,
204
- getDeviceAncestors: makeAncestorLookup(addonRegistry),
207
+ deviceScopeLookup: makeDeviceScopeLookup(addonRegistry),
208
+ deviceFleet: () => addonRegistry.getPersistedDeviceList(),
205
209
  };
206
210
  }
207
211
  /**
@@ -217,6 +221,7 @@ async function createWsTrpcContext(opts, authService, addonRegistry, shareTokens
217
221
  return {
218
222
  user,
219
223
  req: opts.req,
220
- getDeviceAncestors: makeAncestorLookup(addonRegistry),
224
+ deviceScopeLookup: makeDeviceScopeLookup(addonRegistry),
225
+ deviceFleet: () => addonRegistry.getPersistedDeviceList(),
221
226
  };
222
227
  }
@@ -11,6 +11,7 @@ const server_1 = require("@trpc/server");
11
11
  const superjson_1 = __importDefault(require("superjson"));
12
12
  const cap_route_error_formatter_js_1 = require("./cap-route-error-formatter.js");
13
13
  const device_config_secret_redaction_js_1 = require("./device-config-secret-redaction.js");
14
+ const principal_visibility_js_1 = require("./principal-visibility.js");
14
15
  const scope_access_js_1 = require("./scope-access.js");
15
16
  const share_view_access_js_1 = require("./share-view-access.js");
16
17
  const t = server_1.initTRPC.context().create({
@@ -150,7 +151,7 @@ exports.protectedProcedure = t.procedure.use(async ({ ctx, next, path, getRawInp
150
151
  // The `getDeviceAncestors` hook lets the matcher walk parent → child
151
152
  // accessory inheritance (grant on Reolink also covers its siren / PIR).
152
153
  const rawInput = await getRawInput();
153
- const result = (0, scope_access_js_1.checkScopeAccess)(ctx.user.scopes ?? [], path, rawInput, ctx.getDeviceAncestors);
154
+ const result = (0, scope_access_js_1.checkScopeAccess)(ctx.user.scopes ?? [], path, rawInput, ctx.deviceScopeLookup);
154
155
  if (!result.ok) {
155
156
  throw new server_1.TRPCError({ code: 'FORBIDDEN', message: result.reason });
156
157
  }
@@ -164,7 +165,20 @@ exports.protectedProcedure = t.procedure.use(async ({ ctx, next, path, getRawInp
164
165
  // orchestrator, the drivers that must actually connect) are unaffected.
165
166
  const redactConfigSecrets = device_config_secret_redaction_js_1.NON_ADMIN_CONFIG_REDACTED_METHODS.get(path);
166
167
  const out = await next({ ctx: { ...ctx, user: ctx.user } });
167
- if (redactConfigSecrets && out.ok) {
168
+ if (!out.ok)
169
+ return out;
170
+ // A device-scoped principal ENUMERATES only its own devices (F1 #4). The
171
+ // matcher lets a `device`-selector grant reach `deviceManager.listAll`
172
+ // precisely because the answer is cut here; without this the grant would
173
+ // hand back the whole fleet — names, locations, online state.
174
+ if (scope_access_js_1.DEVICE_ENUMERATION_METHODS.has(path)) {
175
+ const projected = (0, principal_visibility_js_1.projectListAllForPrincipal)(out.data, ctx.user.isAdmin, ctx.user.scopes ?? [], ctx.deviceFleet?.() ?? []);
176
+ return {
177
+ ...out,
178
+ data: redactConfigSecrets ? redactConfigSecrets(projected) : projected,
179
+ };
180
+ }
181
+ if (redactConfigSecrets) {
168
182
  return { ...out, data: redactConfigSecrets(out.data) };
169
183
  }
170
184
  return out;
@@ -311,6 +311,9 @@ class AddonPackageService {
311
311
  if (!this.isAllowedPackage(name)) {
312
312
  throw new Error(`Package "${name}" is not an allowed @camstack/* package`);
313
313
  }
314
+ if (exports.AUTO_UPDATE_EXCLUDED_PACKAGES.has(name)) {
315
+ throw new Error(`"${name}" is a framework package — roll it back via serverManagement, not the addon-package path`);
316
+ }
314
317
  const previousVersion = this.getInstalledPackageVersion(name);
315
318
  const rolledBackTo = await this.installer.rollbackAddon(name);
316
319
  if (rolledBackTo == null) {
@@ -659,6 +662,11 @@ class AddonPackageService {
659
662
  await Promise.all([...seen].map(async ([name, version]) => {
660
663
  if (!this.isAllowedPackage(name))
661
664
  return;
665
+ // Never OFFER a framework package as an addon update: clicking it would
666
+ // route to `updatePackage`, which now refuses it anyway. Filtering here
667
+ // keeps the UI honest (no dead "Update" button for @camstack/server).
668
+ if (exports.AUTO_UPDATE_EXCLUDED_PACKAGES.has(name))
669
+ return;
662
670
  const latestVersion = await this.fetchLatestVersion(name);
663
671
  if (latestVersion === null || !isVersionNewer(latestVersion, version))
664
672
  return;
@@ -897,6 +905,9 @@ class AddonPackageService {
897
905
  error: `Package "${name}" is not an allowed @camstack/* package`,
898
906
  };
899
907
  }
908
+ const frameworkRefusal = this.refuseExcludedFramework(name);
909
+ if (frameworkRefusal)
910
+ return frameworkRefusal;
900
911
  // Dev-mode npm-install gate REMOVED (2026-05-12). The legacy
901
912
  // workspace-link flow assumed addons in dev came from `packages/*`
902
913
  // and a stray "Update" click would clobber the source. With the
@@ -990,6 +1001,9 @@ class AddonPackageService {
990
1001
  error: `Package "${name}" is not an allowed @camstack/* package`,
991
1002
  };
992
1003
  }
1004
+ const frameworkRefusal = this.refuseExcludedFramework(name);
1005
+ if (frameworkRefusal)
1006
+ return frameworkRefusal;
993
1007
  const category = this.categorize(name);
994
1008
  this.logger.info('Applying staged addon update', { meta: { name, version, category } });
995
1009
  try {
@@ -1620,6 +1634,26 @@ class AddonPackageService {
1620
1634
  isAllowedPackage(name) {
1621
1635
  return name.startsWith('@camstack/');
1622
1636
  }
1637
+ /**
1638
+ * A framework/system-tier package (`@camstack/server`, `system`, `types`,
1639
+ * `sdk`, `kernel`, `core`, `ui-library`) must NEVER be installed by the
1640
+ * addon-package path: it ships exclusively through `applyServerUpdate`
1641
+ * (single-copy collapse). The auto-updater already skips these; the MANUAL
1642
+ * entry points (`updatePackage`, `applyStagedAddonUpdate`, `rollbackPackage`)
1643
+ * resolved them to `'core'` and ran a raw `npm install @camstack/server@X`,
1644
+ * which bypasses the single-copy engine and — on 2026-08-13 — saturated the
1645
+ * host mid-`applyServerUpdate` publish. A refusal, not a silent no-op.
1646
+ */
1647
+ refuseExcludedFramework(name) {
1648
+ if (!exports.AUTO_UPDATE_EXCLUDED_PACKAGES.has(name))
1649
+ return null;
1650
+ return {
1651
+ success: false,
1652
+ version: '',
1653
+ requiresRestart: false,
1654
+ error: `"${name}" is a framework package — install it via serverManagement.applyServerUpdate, not the addon-package path`,
1655
+ };
1656
+ }
1623
1657
  /** Categorize a package as 'addon' or 'core' */
1624
1658
  categorize(name) {
1625
1659
  if (CORE_MANAGED_PACKAGES.includes(name)) {
@@ -436,6 +436,10 @@ class AddonRegistryService {
436
436
  this.capabilityRegistry.registerProvider('addon-settings', '$hub', settingsProvider);
437
437
  // Wire capability consumer actions via EventBus
438
438
  this.wireCapabilityConsumers();
439
+ // Wire the persisted device-parent mirror (F0.6) so the scope matcher's
440
+ // parent→child accessory inheritance works for FORKED provider devices,
441
+ // which never appear in the hub-local DeviceRegistry.
442
+ this.wireDeviceParentMirror();
439
443
  // Subscribe to capability.binding-changed so hub-side capability
440
444
  // overrides take effect on the fly. The orchestrator addon emits
441
445
  // these when the operator swaps the addon implementing a cap on a
@@ -1060,6 +1064,144 @@ class AddonRegistryService {
1060
1064
  getDeviceRegistry() {
1061
1065
  return this.deviceRegistry;
1062
1066
  }
1067
+ // ── Persisted device-parent mirror (F0.6) ─────────────────────────────────
1068
+ //
1069
+ // The scope matcher's parent→child accessory inheritance (`getDeviceAncestors`
1070
+ // in `trpc.context.ts`) needs a deviceId's ancestor chain on EVERY scoped
1071
+ // request. The hub `DeviceRegistry` only knows devices registered IN the hub
1072
+ // process, so it is EMPTY for every forked provider (reolink, hikvision, …):
1073
+ // a grant on a forked camera never reached its siren/floodlight/PIR children
1074
+ // (measured 0/6 providers inherited). The authoritative parentage lives in the
1075
+ // device-manager's PERSISTED meta, which `listAll` projects for the whole
1076
+ // fleet — forked devices included. This is a hub-process, SYNCHRONOUSLY
1077
+ // readable mirror of that parentage, refreshed OFF the request path on every
1078
+ // device-meta lifecycle event — never a per-call DB query.
1079
+ deviceParentMirror = new Map();
1080
+ /**
1081
+ * Hub-process mirror of each device's persisted `type` + `location`, warmed
1082
+ * from the same `listAll({projection:'slim'})` sweep as the parent mirror.
1083
+ * Backs the v3 scope-model `types` / `locations` selectors: the enforcement
1084
+ * matcher resolves a selector against a device's type/location synchronously,
1085
+ * off the request path (D49). Empty for a device the hub has never heard of.
1086
+ */
1087
+ deviceMetaMirror = new Map();
1088
+ /** Parent of a device: the persisted mirror first (covers forked devices),
1089
+ * falling back to the live hub registry (covers a hub-local device before the
1090
+ * first mirror warm). null when top-level or unknown. */
1091
+ parentOfDevice(deviceId) {
1092
+ const mirrored = this.deviceParentMirror.get(deviceId);
1093
+ if (mirrored !== undefined)
1094
+ return mirrored;
1095
+ return this.deviceRegistry.getById(deviceId)?.parentDeviceId ?? null;
1096
+ }
1097
+ /** Persisted `DeviceType` string of a device, or null when unknown. Mirror
1098
+ * first (covers forked devices), then the live hub registry. */
1099
+ getPersistedType = (deviceId) => {
1100
+ const mirrored = this.deviceMetaMirror.get(deviceId);
1101
+ if (mirrored !== undefined)
1102
+ return mirrored.type;
1103
+ return this.deviceRegistry.getById(deviceId)?.type ?? null;
1104
+ };
1105
+ /** Persisted operator `location` label of a device, or null when unset/unknown. */
1106
+ getPersistedLocation = (deviceId) => {
1107
+ const mirrored = this.deviceMetaMirror.get(deviceId);
1108
+ if (mirrored !== undefined)
1109
+ return mirrored.location;
1110
+ return this.deviceRegistry.getById(deviceId)?.location ?? null;
1111
+ };
1112
+ /**
1113
+ * The whole persisted fleet, slim — id + type + location + parentDeviceId.
1114
+ * Fuels the FLEET-wide selector expansion (response projection + `auth.me`
1115
+ * counts). Read synchronously off the mirror, never a per-call DB query.
1116
+ */
1117
+ getPersistedDeviceList = () => {
1118
+ const out = [];
1119
+ for (const [id, m] of this.deviceMetaMirror) {
1120
+ out.push({
1121
+ id,
1122
+ type: m.type,
1123
+ location: m.location,
1124
+ parentDeviceId: this.deviceParentMirror.get(id) ?? null,
1125
+ });
1126
+ }
1127
+ return out;
1128
+ };
1129
+ /**
1130
+ * Ancestor chain (parent, grandparent, …) of a device, bounded to 8 hops
1131
+ * (defence-in-depth against a corrupt registry cycle). Synchronous — it is
1132
+ * read on every scoped request. Backed by the persisted mirror so a grant on
1133
+ * a FORKED camera covers its accessory children. Empty for a top-level device
1134
+ * or one the hub has never heard of.
1135
+ */
1136
+ getPersistedAncestors = (deviceId) => {
1137
+ const out = [];
1138
+ let current = deviceId;
1139
+ for (let hop = 0; hop < 8; hop++) {
1140
+ const parent = this.parentOfDevice(current);
1141
+ if (parent == null || parent === current)
1142
+ break;
1143
+ out.push(parent);
1144
+ current = parent;
1145
+ }
1146
+ return out;
1147
+ };
1148
+ /** Subscribe the mirror to every device-meta lifecycle event, and warm it once
1149
+ * the addon set (device-manager included) is up. */
1150
+ wireDeviceParentMirror() {
1151
+ const refresh = () => {
1152
+ void this.refreshDeviceParentMirror();
1153
+ };
1154
+ for (const category of [
1155
+ types_1.EventCategory.DeviceMetaChanged,
1156
+ types_1.EventCategory.DeviceRegistered,
1157
+ types_1.EventCategory.DeviceUnregistered,
1158
+ types_1.EventCategory.DeviceProvisioned,
1159
+ types_1.EventCategory.SystemAddonsReady,
1160
+ ]) {
1161
+ this.eventBusService.subscribe({ category }, refresh);
1162
+ }
1163
+ }
1164
+ /** Rebuild the parent mirror from the device-manager's persisted fleet
1165
+ * (`listAll`, slim projection — no config blob). Off the request path; a
1166
+ * failed read KEEPS the previous mirror (D49 — a read that fails changes
1167
+ * nothing, and must never look like an unbind that destroys inheritance). */
1168
+ async refreshDeviceParentMirror() {
1169
+ try {
1170
+ const api = this.getBrokerApi();
1171
+ const rows = await api.deviceManager.listAll.query({ projection: 'slim' });
1172
+ if (!Array.isArray(rows))
1173
+ return;
1174
+ const nextParents = new Map();
1175
+ const nextMeta = new Map();
1176
+ for (const row of rows) {
1177
+ if (row === null || typeof row !== 'object')
1178
+ continue;
1179
+ const id = Reflect.get(row, 'id');
1180
+ if (typeof id !== 'number')
1181
+ continue;
1182
+ const parent = Reflect.get(row, 'parentDeviceId');
1183
+ if (typeof parent === 'number')
1184
+ nextParents.set(id, parent);
1185
+ const type = Reflect.get(row, 'type');
1186
+ const location = Reflect.get(row, 'location');
1187
+ nextMeta.set(id, {
1188
+ type: typeof type === 'string' ? type : '',
1189
+ location: typeof location === 'string' ? location : null,
1190
+ });
1191
+ }
1192
+ this.deviceParentMirror.clear();
1193
+ for (const [k, v] of nextParents)
1194
+ this.deviceParentMirror.set(k, v);
1195
+ this.deviceMetaMirror.clear();
1196
+ for (const [k, v] of nextMeta)
1197
+ this.deviceMetaMirror.set(k, v);
1198
+ }
1199
+ catch (err) {
1200
+ this.logger.debug('device-parent mirror refresh failed — keeping previous', {
1201
+ meta: { error: (0, types_1.errMsg)(err) },
1202
+ });
1203
+ }
1204
+ }
1063
1205
  /** Load persisted collection disabled-lists from settings-store into the registry */
1064
1206
  loadCollectionPreferences() {
1065
1207
  // TODO: implement CapabilityRegistry.loadDisabledProviders() to restore persisted preferences
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.104",
3
+ "version": "1.2.106",
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.54",
36
+ "@camstack/addon-admin-ui": "1.2.55",
37
37
  "@camstack/addon-agent-ui": "1.2.17",
38
38
  "@camstack/addon-auth": "1.2.18",
39
39
  "@camstack/addon-decoder-nodeav": "1.2.16",
40
40
  "@camstack/addon-notifiers": "1.2.21",
41
- "@camstack/addon-pipeline": "1.2.73",
42
- "@camstack/addon-pipeline-orchestrator": "1.2.53",
43
- "@camstack/addon-post-analysis": "1.2.70",
41
+ "@camstack/addon-pipeline": "1.2.74",
42
+ "@camstack/addon-pipeline-orchestrator": "1.2.54",
43
+ "@camstack/addon-post-analysis": "1.2.72",
44
44
  "@camstack/sdk": "1.2.18",
45
45
  "@camstack/shm-ring": "1.1.16",
46
46
  "@camstack/system": "1.2.88",
47
- "@camstack/types": "1.2.67",
48
- "@camstack/ui-library": "1.2.46",
47
+ "@camstack/types": "1.2.69",
48
+ "@camstack/ui-library": "1.2.47",
49
49
  "@fastify/compress": "^9.0.0",
50
50
  "@fastify/cookie": "^11.0.2",
51
51
  "@fastify/cors": "^11.2.0",