@camstack/server 1.2.104 → 1.2.105

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 }) => {
@@ -24,6 +24,7 @@ const share_token_service_js_1 = require("../../core/auth/share-token.service.js
24
24
  const handoff_code_service_js_1 = require("../../core/auth/handoff-code.service.js");
25
25
  const session_cookie_js_1 = require("../../auth/session-cookie.js");
26
26
  const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
27
+ const principal_visibility_js_1 = require("../trpc/principal-visibility.js");
27
28
  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,
@@ -881,9 +882,30 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
881
882
  .mutation(async ({ input, ctx }) => {
882
883
  assertRealUserSession(ctx.user);
883
884
  const service = requireShareTokens();
885
+ // SECURITY (F0.4): a share token is a PRIVILEGE HANDOFF — it authorises
886
+ // whoever holds the link to view its devices with no further auth. A
887
+ // scoped (non-admin) minter must therefore not be able to mint for a
888
+ // device it cannot itself see: without this, a `device:[5]` token could
889
+ // mint a `csv_*` grid link for cameras 5..99. Admins mint anything;
890
+ // every other caller's requested deviceIds are INTERSECTED with what
891
+ // they can view (device:[parent] covers child accessories via the
892
+ // ancestor walk). An empty intersection is a refusal, not a silent
893
+ // "share nothing".
894
+ const scope = ctx.user.isAdmin
895
+ ? input.scope
896
+ : {
897
+ ...input.scope,
898
+ deviceIds: (0, principal_visibility_js_1.filterViewableDeviceIds)(ctx.user.scopes ?? [], input.scope.deviceIds, ctx.getDeviceAncestors),
899
+ };
900
+ if (scope.deviceIds.length === 0) {
901
+ throw new server_1.TRPCError({
902
+ code: 'FORBIDDEN',
903
+ message: 'None of the requested devices are within your scope — you cannot mint a share token for devices you cannot see',
904
+ });
905
+ }
884
906
  const { token, record } = await service.create({
885
907
  userId: ctx.user.id,
886
- scope: input.scope,
908
+ scope,
887
909
  ...(input.ttlSec !== undefined ? { ttlSec: input.ttlSec } : {}),
888
910
  });
889
911
  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.getDeviceAncestors);
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.getDeviceAncestors);
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.getDeviceAncestors);
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.getDeviceAncestors);
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
  ]);
@@ -0,0 +1,122 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.callerCanViewDevice = callerCanViewDevice;
4
+ exports.isBroadEventViewer = isBroadEventViewer;
5
+ exports.filterViewableDeviceIds = filterViewableDeviceIds;
6
+ exports.scopedEventVisibility = scopedEventVisibility;
7
+ /**
8
+ * Non-admin principal device visibility — the shared answer to "which cameras
9
+ * may this scoped caller see?", reused by two hotfixes:
10
+ *
11
+ * - F0.4 `auth.createShareToken` — a scoped user must not mint a `csv_*`
12
+ * share token for a device it cannot see. The requested deviceIds are
13
+ * INTERSECTED with what the caller can view.
14
+ * - F0.5 the event firehose (`live.onEvent`, `systemEvents.subscribe`, …) —
15
+ * a device-restricted scoped token must not receive every camera's motion /
16
+ * detection events. Its stream is filtered to the devices it can view.
17
+ *
18
+ * The single source of truth for "can this principal VIEW device D" is the
19
+ * existing scope matcher ({@link checkScopeAccess}) run against a canonical
20
+ * device-scope VIEW method. Reusing the matcher means share minting and event
21
+ * filtering agree with the middleware that gates the per-camera surfaces
22
+ * themselves — including `device:[parent]` → child-accessory inheritance via
23
+ * the ancestor walk.
24
+ *
25
+ * Pure module — no tRPC machinery — so the spec exercises it directly (same
26
+ * pattern as `scope-access.ts` / `share-view-access.ts`).
27
+ */
28
+ const system_1 = require("@camstack/system");
29
+ const scope_access_js_1 = require("./scope-access.js");
30
+ /**
31
+ * Canonical "view this camera" probe. `snapshot.getSnapshot` is a device-scope,
32
+ * `view`-access method every viewer surface needs (the grid-embed cold-start
33
+ * tile), so a caller who can pass its scope check is a caller who can see the
34
+ * camera. A rename is caught at import time by {@link assertProbeExists} — a
35
+ * loud boot failure, never a silent "deny everyone".
36
+ */
37
+ const DEVICE_VIEW_PROBE = 'snapshot.getSnapshot';
38
+ function assertProbeExists() {
39
+ const meta = system_1.METHOD_ACCESS_MAP[DEVICE_VIEW_PROBE];
40
+ if (!meta || meta.capScope !== 'device' || meta.access !== 'view') {
41
+ throw new Error(`principal-visibility: probe '${DEVICE_VIEW_PROBE}' is not a device-scope view method ` +
42
+ `(codegen drift). Pick another canonical camera-view method.`);
43
+ }
44
+ }
45
+ assertProbeExists();
46
+ /**
47
+ * True when the scoped principal may VIEW device `deviceId`. Handles
48
+ * `device:[parent]` → child inheritance through the ancestor walk, exactly as
49
+ * the per-camera middleware does.
50
+ */
51
+ function callerCanViewDevice(scopes, deviceId, getDeviceAncestors) {
52
+ return (0, scope_access_js_1.checkScopeAccess)(scopes, DEVICE_VIEW_PROBE, { deviceId }, getDeviceAncestors).ok;
53
+ }
54
+ /**
55
+ * A scoped principal sees the WHOLE event/telemetry firehose when it holds any
56
+ * `category`-scope view grant — the operator / family-viewer tiers, whose whole
57
+ * purpose is a broad read. A token scoped only to specific `device:[…]` (or a
58
+ * single `capability`/`addon`) is NOT broad: its event stream is filtered to the
59
+ * devices it can view, and unattributable events are dropped (fail closed).
60
+ *
61
+ * Kept deliberately separate from {@link callerCanViewDevice}: a
62
+ * `category:system` operator legitimately monitors the system dashboard, so
63
+ * gating their event stream to a device-scope probe (which `category:system`
64
+ * does not satisfy) would blank a surface they are entitled to. Availability and
65
+ * security both point the same way here — broad grant, broad stream.
66
+ */
67
+ function isBroadEventViewer(scopes) {
68
+ return scopes.some((s) => s.type === 'category' && s.access.includes('view'));
69
+ }
70
+ /**
71
+ * Filter a requested deviceId list down to those the caller may view. Used by
72
+ * share minting: an admin passes `isAdmin` and skips this; a non-admin gets the
73
+ * intersection, and the router refuses when it is empty.
74
+ */
75
+ function filterViewableDeviceIds(scopes, requested, getDeviceAncestors) {
76
+ return requested.filter((id) => callerCanViewDevice(scopes, id, getDeviceAncestors));
77
+ }
78
+ /** The device identity an event is attributable to, or null when it carries
79
+ * none. Mirrors `liveEventInShareScope`'s matching (source.id, source.deviceId,
80
+ * data.deviceId) so scoped filtering and share filtering agree. */
81
+ function eventDeviceId(evt) {
82
+ const sourceId = evt.source?.id;
83
+ if (typeof sourceId === 'number' && Number.isFinite(sourceId))
84
+ return sourceId;
85
+ if (typeof sourceId === 'string' && sourceId !== '') {
86
+ const n = Number(sourceId);
87
+ if (Number.isInteger(n))
88
+ return n;
89
+ }
90
+ const src = evt.source;
91
+ if (typeof src?.deviceId === 'number' && Number.isFinite(src.deviceId))
92
+ return src.deviceId;
93
+ const dataDeviceId = evt.data?.['deviceId'];
94
+ if (typeof dataDeviceId === 'number' && Number.isFinite(dataDeviceId))
95
+ return dataDeviceId;
96
+ return null;
97
+ }
98
+ /**
99
+ * Build the event-stream predicate for a NON-share principal (F0.5).
100
+ *
101
+ * Returns `null` when the principal sees the whole firehose — an admin, or a
102
+ * broad `category`-view operator (see {@link isBroadEventViewer}). Otherwise a
103
+ * predicate that keeps only events attributable to a device the principal may
104
+ * view; an event with NO device identity is DROPPED (fail closed — "probably in
105
+ * scope" is not a security argument).
106
+ *
107
+ * Share-view (`csv_*`) principals are NOT handled here — they keep their own,
108
+ * stricter `liveEventInShareScope` filter, applied by the routers first.
109
+ */
110
+ function scopedEventVisibility(user, getDeviceAncestors) {
111
+ if (user.isAdmin)
112
+ return null;
113
+ const scopes = user.scopes ?? [];
114
+ if (isBroadEventViewer(scopes))
115
+ return null;
116
+ return (evt) => {
117
+ const id = eventDeviceId(evt);
118
+ if (id === null)
119
+ return false;
120
+ return callerCanViewDevice(scopes, id, getDeviceAncestors);
121
+ };
122
+ }
@@ -155,25 +155,19 @@ async function resolveUser(token, authService, addonRegistry, shareTokens = null
155
155
  }
156
156
  }
157
157
  /**
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.
158
+ * Build the parent-chain walker for the scope-access matcher. Returns every
159
+ * ancestor deviceId of `deviceId` (parent, grandparent, …) so a grant on a
160
+ * Reolink camera covers its accessory children without re-enumerating them.
162
161
  *
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).
162
+ * Reads `AddonRegistryService.getPersistedAncestors` — the hub-process mirror of
163
+ * the device-manager's PERSISTED parentage (F0.6). It MUST NOT walk the hub
164
+ * `DeviceRegistry` directly: that registry only holds hub-local devices, so it
165
+ * is empty for every forked provider and the inheritance this function exists to
166
+ * provide silently did nothing (0/6 providers inherited). The mirror is warmed
167
+ * off the event path and read synchronously here.
165
168
  */
166
169
  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;
176
- };
170
+ return (deviceId) => addonRegistry.getPersistedAncestors(deviceId);
177
171
  }
178
172
  /**
179
173
  * Context factory for hub-internal calls originating from the trusted
@@ -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,92 @@ 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
+ /** Parent of a device: the persisted mirror first (covers forked devices),
1081
+ * falling back to the live hub registry (covers a hub-local device before the
1082
+ * first mirror warm). null when top-level or unknown. */
1083
+ parentOfDevice(deviceId) {
1084
+ const mirrored = this.deviceParentMirror.get(deviceId);
1085
+ if (mirrored !== undefined)
1086
+ return mirrored;
1087
+ return this.deviceRegistry.getById(deviceId)?.parentDeviceId ?? null;
1088
+ }
1089
+ /**
1090
+ * Ancestor chain (parent, grandparent, …) of a device, bounded to 8 hops
1091
+ * (defence-in-depth against a corrupt registry cycle). Synchronous — it is
1092
+ * read on every scoped request. Backed by the persisted mirror so a grant on
1093
+ * a FORKED camera covers its accessory children. Empty for a top-level device
1094
+ * or one the hub has never heard of.
1095
+ */
1096
+ getPersistedAncestors = (deviceId) => {
1097
+ const out = [];
1098
+ let current = deviceId;
1099
+ for (let hop = 0; hop < 8; hop++) {
1100
+ const parent = this.parentOfDevice(current);
1101
+ if (parent == null || parent === current)
1102
+ break;
1103
+ out.push(parent);
1104
+ current = parent;
1105
+ }
1106
+ return out;
1107
+ };
1108
+ /** Subscribe the mirror to every device-meta lifecycle event, and warm it once
1109
+ * the addon set (device-manager included) is up. */
1110
+ wireDeviceParentMirror() {
1111
+ const refresh = () => {
1112
+ void this.refreshDeviceParentMirror();
1113
+ };
1114
+ for (const category of [
1115
+ types_1.EventCategory.DeviceMetaChanged,
1116
+ types_1.EventCategory.DeviceRegistered,
1117
+ types_1.EventCategory.DeviceUnregistered,
1118
+ types_1.EventCategory.DeviceProvisioned,
1119
+ types_1.EventCategory.SystemAddonsReady,
1120
+ ]) {
1121
+ this.eventBusService.subscribe({ category }, refresh);
1122
+ }
1123
+ }
1124
+ /** Rebuild the parent mirror from the device-manager's persisted fleet
1125
+ * (`listAll`, slim projection — no config blob). Off the request path; a
1126
+ * failed read KEEPS the previous mirror (D49 — a read that fails changes
1127
+ * nothing, and must never look like an unbind that destroys inheritance). */
1128
+ async refreshDeviceParentMirror() {
1129
+ try {
1130
+ const api = this.getBrokerApi();
1131
+ const rows = await api.deviceManager.listAll.query({ projection: 'slim' });
1132
+ if (!Array.isArray(rows))
1133
+ return;
1134
+ const next = new Map();
1135
+ for (const row of rows) {
1136
+ if (row === null || typeof row !== 'object')
1137
+ continue;
1138
+ const id = Reflect.get(row, 'id');
1139
+ const parent = Reflect.get(row, 'parentDeviceId');
1140
+ if (typeof id === 'number' && typeof parent === 'number')
1141
+ next.set(id, parent);
1142
+ }
1143
+ this.deviceParentMirror.clear();
1144
+ for (const [k, v] of next)
1145
+ this.deviceParentMirror.set(k, v);
1146
+ }
1147
+ catch (err) {
1148
+ this.logger.debug('device-parent mirror refresh failed — keeping previous', {
1149
+ meta: { error: (0, types_1.errMsg)(err) },
1150
+ });
1151
+ }
1152
+ }
1063
1153
  /** Load persisted collection disabled-lists from settings-store into the registry */
1064
1154
  loadCollectionPreferences() {
1065
1155
  // 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.105",
4
4
  "private": false,
5
5
  "files": [
6
6
  "dist",
@@ -38,13 +38,13 @@
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",
41
+ "@camstack/addon-pipeline": "1.2.74",
42
42
  "@camstack/addon-pipeline-orchestrator": "1.2.53",
43
- "@camstack/addon-post-analysis": "1.2.70",
43
+ "@camstack/addon-post-analysis": "1.2.71",
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",
47
+ "@camstack/types": "1.2.68",
48
48
  "@camstack/ui-library": "1.2.46",
49
49
  "@fastify/compress": "^9.0.0",
50
50
  "@fastify/cookie": "^11.0.2",