@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.
- package/dist/api/core/addon-settings.router.js +5 -5
- package/dist/api/core/auth.router.js +23 -1
- package/dist/api/core/event-bus-proxy.router.js +1 -1
- package/dist/api/core/live-events.router.js +31 -10
- package/dist/api/core/logs.router.js +7 -1
- package/dist/api/core/system-events.router.js +16 -7
- package/dist/api/trpc/device-config-secret-redaction.js +48 -0
- package/dist/api/trpc/principal-visibility.js +122 -0
- package/dist/api/trpc/trpc.context.js +10 -16
- package/dist/core/addon/addon-package.service.js +34 -0
- package/dist/core/addon/addon-registry.service.js +90 -0
- package/package.json +4 -4
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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 }) =>
|
|
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).
|
|
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 (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
*
|
|
160
|
-
*
|
|
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
|
-
*
|
|
164
|
-
*
|
|
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.
|
|
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.
|
|
41
|
+
"@camstack/addon-pipeline": "1.2.74",
|
|
42
42
|
"@camstack/addon-pipeline-orchestrator": "1.2.53",
|
|
43
|
-
"@camstack/addon-post-analysis": "1.2.
|
|
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.
|
|
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",
|