@camstack/server 1.2.105 → 1.2.107
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/auth.router.js +82 -11
- package/dist/api/core/live-events.router.js +2 -2
- package/dist/api/core/system-events.router.js +2 -2
- package/dist/api/trpc/generated-cap-routers.js +3 -3
- package/dist/api/trpc/principal-visibility.js +43 -6
- package/dist/api/trpc/scope-access.js +172 -26
- package/dist/api/trpc/share-view-access.js +14 -1
- package/dist/api/trpc/trpc.context.js +18 -7
- package/dist/api/trpc/trpc.middleware.js +16 -2
- package/dist/core/addon/addon-registry.service.js +56 -4
- package/package.json +6 -6
|
@@ -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,15 +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
|
-
|
|
21
|
-
|
|
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
|
|
24
|
-
const
|
|
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");
|
|
26
|
-
const
|
|
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
27
|
const principal_visibility_js_1 = require("../trpc/principal-visibility.js");
|
|
28
|
-
const
|
|
28
|
+
const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
|
|
29
29
|
// ── Public-auth rate limiting ────────────────────────────────────────
|
|
30
30
|
// The public surface below (credential validation, passkey ceremonies,
|
|
31
31
|
// one-time-code redemption) had NO throttling — unlimited online
|
|
@@ -274,6 +274,35 @@ function readRequestHeader(req, name) {
|
|
|
274
274
|
return value[0] ?? null;
|
|
275
275
|
return null;
|
|
276
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
|
+
});
|
|
277
306
|
/** Wire shape of the authenticated user returned by `auth.me`. */
|
|
278
307
|
const MeSchema = zod_1.z
|
|
279
308
|
.object({
|
|
@@ -287,6 +316,11 @@ const MeSchema = zod_1.z
|
|
|
287
316
|
}),
|
|
288
317
|
isApiKey: zod_1.z.boolean(),
|
|
289
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(),
|
|
290
324
|
})
|
|
291
325
|
.nullable();
|
|
292
326
|
function createAuthRouter(auth, registry, moleculer = null, shareTokens = null, handoffCodes = new handoff_code_service_js_1.HandoffCodeService()) {
|
|
@@ -693,7 +727,44 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
|
|
|
693
727
|
me: trpc_middleware_js_1.protectedProcedure
|
|
694
728
|
.input(zod_1.z.void())
|
|
695
729
|
.output(MeSchema)
|
|
696
|
-
.query(({ ctx }) =>
|
|
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)),
|
|
697
768
|
// ── Self-service profile operations ───────────────────────────────
|
|
698
769
|
//
|
|
699
770
|
// These route through the `user-management` capability provider but
|
|
@@ -895,7 +966,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
|
|
|
895
966
|
? input.scope
|
|
896
967
|
: {
|
|
897
968
|
...input.scope,
|
|
898
|
-
deviceIds: (0, principal_visibility_js_1.filterViewableDeviceIds)(ctx.user.scopes ?? [], input.scope.deviceIds, ctx.
|
|
969
|
+
deviceIds: (0, principal_visibility_js_1.filterViewableDeviceIds)(ctx.user.scopes ?? [], input.scope.deviceIds, ctx.deviceScopeLookup),
|
|
899
970
|
};
|
|
900
971
|
if (scope.deviceIds.length === 0) {
|
|
901
972
|
throw new server_1.TRPCError({
|
|
@@ -33,7 +33,7 @@ function createLiveEventsRouter(eb, ar) {
|
|
|
33
33
|
// for admins and broad category-view operators (no filtering), or a
|
|
34
34
|
// per-event predicate that keeps only events for devices the caller may
|
|
35
35
|
// view. (Share-view tokens cannot reach this method — not allowlisted.)
|
|
36
|
-
const keep = (0, principal_visibility_js_1.scopedEventVisibility)(ctx.user, ctx.
|
|
36
|
+
const keep = (0, principal_visibility_js_1.scopedEventVisibility)(ctx.user, ctx.deviceScopeLookup);
|
|
37
37
|
return keep ? recent.filter((evt) => keep(evt)) : recent;
|
|
38
38
|
}),
|
|
39
39
|
onEvent: trpc_middleware_js_1.protectedProcedure
|
|
@@ -47,7 +47,7 @@ function createLiveEventsRouter(eb, ar) {
|
|
|
47
47
|
// view (F0.5); admins and broad category-view operators are unfiltered.
|
|
48
48
|
const scopedKeep = shareScope
|
|
49
49
|
? null
|
|
50
|
-
: (0, principal_visibility_js_1.scopedEventVisibility)(ctx.user, ctx.
|
|
50
|
+
: (0, principal_visibility_js_1.scopedEventVisibility)(ctx.user, ctx.deviceScopeLookup);
|
|
51
51
|
return (0, trpc_middleware_js_1.iterableSubscription)((push) => {
|
|
52
52
|
const filter = {};
|
|
53
53
|
if (input.category)
|
|
@@ -91,13 +91,13 @@ function createSystemEventsRouter(eb) {
|
|
|
91
91
|
}, input.limit);
|
|
92
92
|
// SECURITY (F0.5): filter the history to what a device-restricted scoped
|
|
93
93
|
// token may view. null ⇒ admin / broad category-view operator ⇒ unfiltered.
|
|
94
|
-
const keep = (0, principal_visibility_js_1.scopedEventVisibility)(ctx.user, ctx.
|
|
94
|
+
const keep = (0, principal_visibility_js_1.scopedEventVisibility)(ctx.user, ctx.deviceScopeLookup);
|
|
95
95
|
return (keep ? recent.filter((evt) => keep(evt)) : recent).map(serialize);
|
|
96
96
|
}),
|
|
97
97
|
subscribe: trpc_middleware_js_1.protectedProcedure.input(SubscribeInputSchema).subscription(({ input, ctx }) => {
|
|
98
98
|
// SECURITY (F0.5): a device-restricted scoped token must not receive every
|
|
99
99
|
// camera's live events. Same projection as `getRecent`.
|
|
100
|
-
const keep = (0, principal_visibility_js_1.scopedEventVisibility)(ctx.user, ctx.
|
|
100
|
+
const keep = (0, principal_visibility_js_1.scopedEventVisibility)(ctx.user, ctx.deviceScopeLookup);
|
|
101
101
|
return (0, trpc_middleware_js_1.iterableSubscription)((push) => {
|
|
102
102
|
return eb.subscribe({
|
|
103
103
|
...(input.source ? { source: input.source } : {}),
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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 }) => {
|
|
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.callerCanViewDevice = callerCanViewDevice;
|
|
4
4
|
exports.isBroadEventViewer = isBroadEventViewer;
|
|
5
5
|
exports.filterViewableDeviceIds = filterViewableDeviceIds;
|
|
6
|
+
exports.hasUnrestrictedDeviceView = hasUnrestrictedDeviceView;
|
|
7
|
+
exports.projectListAllForPrincipal = projectListAllForPrincipal;
|
|
6
8
|
exports.scopedEventVisibility = scopedEventVisibility;
|
|
7
9
|
/**
|
|
8
10
|
* Non-admin principal device visibility — the shared answer to "which cameras
|
|
@@ -26,7 +28,9 @@ exports.scopedEventVisibility = scopedEventVisibility;
|
|
|
26
28
|
* pattern as `scope-access.ts` / `share-view-access.ts`).
|
|
27
29
|
*/
|
|
28
30
|
const system_1 = require("@camstack/system");
|
|
31
|
+
const types_1 = require("@camstack/types");
|
|
29
32
|
const scope_access_js_1 = require("./scope-access.js");
|
|
33
|
+
const share_view_access_js_1 = require("./share-view-access.js");
|
|
30
34
|
/**
|
|
31
35
|
* Canonical "view this camera" probe. `snapshot.getSnapshot` is a device-scope,
|
|
32
36
|
* `view`-access method every viewer surface needs (the grid-embed cold-start
|
|
@@ -48,8 +52,8 @@ assertProbeExists();
|
|
|
48
52
|
* `device:[parent]` → child inheritance through the ancestor walk, exactly as
|
|
49
53
|
* the per-camera middleware does.
|
|
50
54
|
*/
|
|
51
|
-
function callerCanViewDevice(scopes, deviceId,
|
|
52
|
-
return (0, scope_access_js_1.checkScopeAccess)(scopes, DEVICE_VIEW_PROBE, { deviceId },
|
|
55
|
+
function callerCanViewDevice(scopes, deviceId, lookup) {
|
|
56
|
+
return (0, scope_access_js_1.checkScopeAccess)(scopes, DEVICE_VIEW_PROBE, { deviceId }, lookup).ok;
|
|
53
57
|
}
|
|
54
58
|
/**
|
|
55
59
|
* A scoped principal sees the WHOLE event/telemetry firehose when it holds any
|
|
@@ -72,8 +76,8 @@ function isBroadEventViewer(scopes) {
|
|
|
72
76
|
* share minting: an admin passes `isAdmin` and skips this; a non-admin gets the
|
|
73
77
|
* intersection, and the router refuses when it is empty.
|
|
74
78
|
*/
|
|
75
|
-
function filterViewableDeviceIds(scopes, requested,
|
|
76
|
-
return requested.filter((id) => callerCanViewDevice(scopes, id,
|
|
79
|
+
function filterViewableDeviceIds(scopes, requested, lookup) {
|
|
80
|
+
return requested.filter((id) => callerCanViewDevice(scopes, id, lookup));
|
|
77
81
|
}
|
|
78
82
|
/** The device identity an event is attributable to, or null when it carries
|
|
79
83
|
* none. Mirrors `liveEventInShareScope`'s matching (source.id, source.deviceId,
|
|
@@ -95,6 +99,39 @@ function eventDeviceId(evt) {
|
|
|
95
99
|
return dataDeviceId;
|
|
96
100
|
return null;
|
|
97
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
|
+
}
|
|
98
135
|
/**
|
|
99
136
|
* Build the event-stream predicate for a NON-share principal (F0.5).
|
|
100
137
|
*
|
|
@@ -107,7 +144,7 @@ function eventDeviceId(evt) {
|
|
|
107
144
|
* Share-view (`csv_*`) principals are NOT handled here — they keep their own,
|
|
108
145
|
* stricter `liveEventInShareScope` filter, applied by the routers first.
|
|
109
146
|
*/
|
|
110
|
-
function scopedEventVisibility(user,
|
|
147
|
+
function scopedEventVisibility(user, lookup) {
|
|
111
148
|
if (user.isAdmin)
|
|
112
149
|
return null;
|
|
113
150
|
const scopes = user.scopes ?? [];
|
|
@@ -117,6 +154,6 @@ function scopedEventVisibility(user, getDeviceAncestors) {
|
|
|
117
154
|
const id = eventDeviceId(evt);
|
|
118
155
|
if (id === null)
|
|
119
156
|
return false;
|
|
120
|
-
return callerCanViewDevice(scopes, id,
|
|
157
|
+
return callerCanViewDevice(scopes, id, lookup);
|
|
121
158
|
};
|
|
122
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
|
|
10
|
+
* stateless aside from an optional {@link DeviceScopeLookup}.
|
|
10
11
|
*
|
|
11
|
-
* Algorithm (
|
|
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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|
|
@@ -166,8 +171,12 @@ async function resolveUser(token, authService, addonRegistry, shareTokens = null
|
|
|
166
171
|
* provide silently did nothing (0/6 providers inherited). The mirror is warmed
|
|
167
172
|
* off the event path and read synchronously here.
|
|
168
173
|
*/
|
|
169
|
-
function
|
|
170
|
-
return
|
|
174
|
+
function makeDeviceScopeLookup(addonRegistry) {
|
|
175
|
+
return {
|
|
176
|
+
getAncestors: (deviceId) => addonRegistry.getPersistedAncestors(deviceId),
|
|
177
|
+
getType: (deviceId) => addonRegistry.getPersistedType(deviceId),
|
|
178
|
+
getLocation: (deviceId) => addonRegistry.getPersistedLocation(deviceId),
|
|
179
|
+
};
|
|
171
180
|
}
|
|
172
181
|
/**
|
|
173
182
|
* Context factory for hub-internal calls originating from the trusted
|
|
@@ -195,7 +204,8 @@ async function createTrpcContext(req, authService, addonRegistry, shareTokens =
|
|
|
195
204
|
return {
|
|
196
205
|
user: await resolveUser(token, authService, addonRegistry, shareTokens),
|
|
197
206
|
req,
|
|
198
|
-
|
|
207
|
+
deviceScopeLookup: makeDeviceScopeLookup(addonRegistry),
|
|
208
|
+
deviceFleet: () => addonRegistry.getPersistedDeviceList(),
|
|
199
209
|
};
|
|
200
210
|
}
|
|
201
211
|
/**
|
|
@@ -211,6 +221,7 @@ async function createWsTrpcContext(opts, authService, addonRegistry, shareTokens
|
|
|
211
221
|
return {
|
|
212
222
|
user,
|
|
213
223
|
req: opts.req,
|
|
214
|
-
|
|
224
|
+
deviceScopeLookup: makeDeviceScopeLookup(addonRegistry),
|
|
225
|
+
deviceFleet: () => addonRegistry.getPersistedDeviceList(),
|
|
215
226
|
};
|
|
216
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.
|
|
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 (
|
|
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;
|
|
@@ -1077,6 +1077,14 @@ class AddonRegistryService {
|
|
|
1077
1077
|
// readable mirror of that parentage, refreshed OFF the request path on every
|
|
1078
1078
|
// device-meta lifecycle event — never a per-call DB query.
|
|
1079
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();
|
|
1080
1088
|
/** Parent of a device: the persisted mirror first (covers forked devices),
|
|
1081
1089
|
* falling back to the live hub registry (covers a hub-local device before the
|
|
1082
1090
|
* first mirror warm). null when top-level or unknown. */
|
|
@@ -1086,6 +1094,38 @@ class AddonRegistryService {
|
|
|
1086
1094
|
return mirrored;
|
|
1087
1095
|
return this.deviceRegistry.getById(deviceId)?.parentDeviceId ?? null;
|
|
1088
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
|
+
};
|
|
1089
1129
|
/**
|
|
1090
1130
|
* Ancestor chain (parent, grandparent, …) of a device, bounded to 8 hops
|
|
1091
1131
|
* (defence-in-depth against a corrupt registry cycle). Synchronous — it is
|
|
@@ -1131,18 +1171,30 @@ class AddonRegistryService {
|
|
|
1131
1171
|
const rows = await api.deviceManager.listAll.query({ projection: 'slim' });
|
|
1132
1172
|
if (!Array.isArray(rows))
|
|
1133
1173
|
return;
|
|
1134
|
-
const
|
|
1174
|
+
const nextParents = new Map();
|
|
1175
|
+
const nextMeta = new Map();
|
|
1135
1176
|
for (const row of rows) {
|
|
1136
1177
|
if (row === null || typeof row !== 'object')
|
|
1137
1178
|
continue;
|
|
1138
1179
|
const id = Reflect.get(row, 'id');
|
|
1180
|
+
if (typeof id !== 'number')
|
|
1181
|
+
continue;
|
|
1139
1182
|
const parent = Reflect.get(row, 'parentDeviceId');
|
|
1140
|
-
if (typeof
|
|
1141
|
-
|
|
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
|
+
});
|
|
1142
1191
|
}
|
|
1143
1192
|
this.deviceParentMirror.clear();
|
|
1144
|
-
for (const [k, v] of
|
|
1193
|
+
for (const [k, v] of nextParents)
|
|
1145
1194
|
this.deviceParentMirror.set(k, v);
|
|
1195
|
+
this.deviceMetaMirror.clear();
|
|
1196
|
+
for (const [k, v] of nextMeta)
|
|
1197
|
+
this.deviceMetaMirror.set(k, v);
|
|
1146
1198
|
}
|
|
1147
1199
|
catch (err) {
|
|
1148
1200
|
this.logger.debug('device-parent mirror refresh failed — keeping previous', {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/server",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.107",
|
|
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.
|
|
36
|
+
"@camstack/addon-admin-ui": "1.2.56",
|
|
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
41
|
"@camstack/addon-pipeline": "1.2.74",
|
|
42
|
-
"@camstack/addon-pipeline-orchestrator": "1.2.
|
|
43
|
-
"@camstack/addon-post-analysis": "1.2.
|
|
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.
|
|
48
|
-
"@camstack/ui-library": "1.2.
|
|
47
|
+
"@camstack/types": "1.2.69",
|
|
48
|
+
"@camstack/ui-library": "1.2.48",
|
|
49
49
|
"@fastify/compress": "^9.0.0",
|
|
50
50
|
"@fastify/cookie": "^11.0.2",
|
|
51
51
|
"@fastify/cors": "^11.2.0",
|