@camstack/server 1.2.619 → 1.2.620

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.
@@ -224,6 +224,10 @@ const ScopeGrantSummarySchema = zod_1.z.object({
224
224
  */
225
225
  const EffectiveScopeSchema = zod_1.z.object({
226
226
  isAdmin: zod_1.z.boolean(),
227
+ /** `admin`, `viewer` (D921), or `scoped` — a legacy grant set not yet converted. */
228
+ role: zod_1.z.enum(['admin', 'viewer', 'scoped']),
229
+ /** The viewer features granted (D921); empty for every other role. */
230
+ features: zod_1.z.array(types_1.ViewerFeatureIdSchema).readonly(),
227
231
  allDevicesViewable: zod_1.z.boolean(),
228
232
  viewableDeviceCount: zod_1.z.number().int(),
229
233
  device: zod_1.z.object({
@@ -935,7 +939,7 @@ function createAuthRouter(auth, registry, moleculer = null, handoffCodes = new h
935
939
  */
936
940
  previewScope: trpc_middleware_js_1.adminProcedure
937
941
  .input(zod_1.z.object({
938
- scopes: zod_1.z.array(types_1.TokenScopeSchema),
942
+ scopes: zod_1.z.array(types_1.UserScopeSchema),
939
943
  isAdmin: zod_1.z.boolean().default(false),
940
944
  }))
941
945
  .output(EffectiveScopeSchema)
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.authorizeProcedure = authorizeProcedure;
4
+ exports.viewerPassesAdminTier = viewerPassesAdminTier;
4
5
  exports.authorizeAddonRoute = authorizeAddonRoute;
5
6
  /**
6
7
  * The ONE scope matcher (D892): every authorization decision about a verified
@@ -43,6 +44,22 @@ function authorizeProcedure(scopes, path, rawInput, lookup) {
43
44
  }
44
45
  return { ok: true, mode: 'scoped', scopes: capability };
45
46
  }
47
+ /**
48
+ * May a VIEWER (D921) pass the admin tier of the cap method at `path`? Only
49
+ * when the method's role is a feature the viewer was granted (or explicitly
50
+ * `viewer`). `adminProcedure` asks this AFTER `protectedProcedure` ran the
51
+ * matcher, which already required the role and every named device; a
52
+ * hand-written admin route has no cap entry and is never passed.
53
+ */
54
+ function viewerPassesAdminTier(scopes, path) {
55
+ const viewer = (0, types_1.viewerScopeOf)((0, types_1.capabilityScopesOf)(scopes));
56
+ if (viewer === null)
57
+ return false;
58
+ const meta = system_1.METHOD_ACCESS_MAP[path];
59
+ if (meta === undefined || meta.role === 'admin')
60
+ return false;
61
+ return (0, types_1.viewerHoldsRole)(viewer, meta.role);
62
+ }
46
63
  /** May a principal holding `scopes` reach `/addon/<addonId>/…` with this verb? */
47
64
  function authorizeAddonRoute(scopes, target) {
48
65
  const grant = (0, types_1.shareViewGrantOf)(scopes);
@@ -70,7 +87,7 @@ function authorizeAddonRoute(scopes, target) {
70
87
  }
71
88
  const access = (0, types_1.methodAccessForHttpMethod)(target.method);
72
89
  if ((0, types_1.scopesAllowAddonRoutes)(scopes, access) ||
73
- (0, types_1.scopesAllowAddon)((0, types_1.capabilityScopesOf)(scopes), target.addonId, access)) {
90
+ (0, types_1.scopesAllowAddon)((0, types_1.capabilityScopesOf)(scopes).flatMap((s) => (s.type === 'viewer' ? [] : [s])), target.addonId, access)) {
74
91
  return { ok: true, shareHeaders: null };
75
92
  }
76
93
  return {
@@ -69,6 +69,11 @@ function callerCanViewDevice(scopes, deviceId, lookup) {
69
69
  * security both point the same way here — broad grant, broad stream.
70
70
  */
71
71
  function isBroadEventViewer(scopes) {
72
+ // A viewer (D921) sees the firehose only with every device admitted; any
73
+ // other row it carries never counts.
74
+ const viewer = (0, types_1.viewerScopeOf)(scopes);
75
+ if (viewer !== null)
76
+ return viewer.allow === '*' && viewer.block.length === 0;
72
77
  return scopes.some((s) => s.type === 'category' && s.access.includes('view'));
73
78
  }
74
79
  /**
@@ -107,9 +112,13 @@ function eventDeviceId(evt) {
107
112
  function hasUnrestrictedDeviceView(isAdmin, scopes) {
108
113
  if (isAdmin)
109
114
  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')));
115
+ // A viewer (D921) is unrestricted only with every device allowed and none
116
+ // blocked; the rest of its set never counts.
117
+ const viewer = (0, types_1.viewerScopeOf)(scopes);
118
+ if (viewer !== null)
119
+ return viewer.allow === '*' && viewer.block.length === 0;
120
+ return scopes.some((s) => (s.type === 'category' && s.target === 'device' && s.access.includes('view')) ||
121
+ (s.type === 'device' && s.selector.kind === 'all' && s.access.includes('view')));
113
122
  }
114
123
  /**
115
124
  * Cut a `deviceManager.listAll` payload to the devices this principal may VIEW
@@ -151,9 +151,8 @@ function referencedDeviceIds(path, input) {
151
151
  * selector is `all`. Such a caller is unaffected by the device-reference gate.
152
152
  */
153
153
  function hasUnrestrictedDeviceReach(scopes, access) {
154
- return scopes.some((s) => s.access.includes(access) &&
155
- ((s.type === 'category' && s.target === 'device') ||
156
- (s.type === 'device' && s.selector.kind === 'all')));
154
+ return scopes.some((s) => (s.type === 'category' && s.target === 'device' && s.access.includes(access)) ||
155
+ (s.type === 'device' && s.selector.kind === 'all' && s.access.includes(access)));
157
156
  }
158
157
  /**
159
158
  * The device-reference gate for SYSTEM-scope cap methods (F1 #3).
@@ -189,7 +188,43 @@ function deviceRefsSatisfied(scopes, path, input, access, lookup) {
189
188
  }
190
189
  return { ok: true };
191
190
  }
191
+ /**
192
+ * The viewer role's matcher (D921). The method's ROLE decides what a viewer
193
+ * may call (`viewer` baseline, `feature:<id>` when granted, never `admin`),
194
+ * and every device the request names must be admitted by the viewer's
195
+ * allow/block lists — through itself or an ancestor, with unknown parentage
196
+ * failing closed (`viewerAdmitsDevice`). A method that names no device is
197
+ * cross-device by nature (an enumeration, a gallery): its answer is cut where
198
+ * one exists (`DEVICE_ENUMERATION_METHODS`, the event firehose filter).
199
+ */
200
+ function checkViewerAccess(viewer, path, input, lookup) {
201
+ const meta = system_1.METHOD_ACCESS_MAP[path];
202
+ if (!meta)
203
+ return { ok: false, reason: `Unknown method '${path}' — codegen drift` };
204
+ if (!(0, types_1.viewerHoldsRole)(viewer, meta.role)) {
205
+ const feature = (0, types_1.featureOfRole)(meta.role);
206
+ return {
207
+ ok: false,
208
+ reason: feature !== null
209
+ ? `'${path}' needs the '${feature}' feature, which this viewer does not have`
210
+ : `'${path}' is for administrators — a viewer cannot call it`,
211
+ };
212
+ }
213
+ const ancestors = lookup === undefined
214
+ ? undefined
215
+ : (id) => lookup.getType(id) === null ? null : lookup.getAncestors(id);
216
+ for (const deviceId of referencedDeviceIds(path, input)) {
217
+ if (!(0, types_1.viewerAdmitsDevice)(viewer, deviceId, ancestors)) {
218
+ return { ok: false, reason: `Device ${deviceId} is outside this viewer's devices` };
219
+ }
220
+ }
221
+ return { ok: true, access: meta.access };
222
+ }
192
223
  function checkScopeAccess(scopes, path, input, lookup) {
224
+ // A viewer is decided by its own rules, alone (D921).
225
+ const viewer = (0, types_1.viewerScopeOf)(scopes);
226
+ if (viewer !== null)
227
+ return checkViewerAccess(viewer, path, input, lookup);
193
228
  const meta = system_1.METHOD_ACCESS_MAP[path];
194
229
  if (!meta) {
195
230
  return { ok: false, reason: `Unknown method '${path}' — codegen drift` };
@@ -229,6 +264,9 @@ function checkScopeAccess(scopes, path, input, lookup) {
229
264
  : exports.DEVICE_ENUMERATION_METHODS.has(path);
230
265
  break;
231
266
  }
267
+ case 'viewer':
268
+ // Unreachable: a viewer row is decided above, alone.
269
+ continue;
232
270
  }
233
271
  if (!targetMatches)
234
272
  continue;
@@ -252,6 +290,8 @@ function checkScopeAccess(scopes, path, input, lookup) {
252
290
  ok: false,
253
291
  reason: `No scope grants ${meta.access} on '${meta.capName}' (${meta.capScope}-scope cap${deviceId !== null ? `, device=${deviceId}` : ''}). Have: ${scopes
254
292
  .map((s) => {
293
+ if (s.type === 'viewer')
294
+ return 'viewer';
255
295
  const target = s.type === 'device' ? `[${describeSelector(s)}]` : s.target;
256
296
  return `${s.type}:${target}[${s.access.join(',')}]`;
257
297
  })
@@ -176,8 +176,11 @@ exports.protectedProcedure = localized.use(async ({ ctx, next, path, getRawInput
176
176
  * `protectedProcedure`. Useful on hand-written routes whose admin-only
177
177
  * nature should be obvious to a code reader.
178
178
  */
179
- exports.adminProcedure = exports.protectedProcedure.use(({ ctx, next }) => {
180
- if (!ctx.user.isAdmin) {
179
+ exports.adminProcedure = exports.protectedProcedure.use(({ ctx, next, path }) => {
180
+ // A viewer passes the admin tier only on a method of a feature it was
181
+ // granted (D921); the matcher in `protectedProcedure` already checked the
182
+ // feature and every device the call names.
183
+ if (!ctx.user.isAdmin && !(0, principal_authorization_js_1.viewerPassesAdminTier)(ctx.user.scopes ?? [], path)) {
181
184
  throw (0, user_error_js_1.trpcUserError)('FORBIDDEN', 'auth.admin-required', undefined, 'Admin required');
182
185
  }
183
186
  return next({ ctx });
@@ -37,7 +37,8 @@ function addonRouteCaller(principal) {
37
37
  scopedToken: {
38
38
  id: principal.credentialId,
39
39
  userId: principal.subjectId,
40
- scopes: [...(0, types_1.capabilityScopesOf)(scoped)],
40
+ // Capability rows only: an addon reads grants, never a viewer row (D921).
41
+ scopes: (0, types_1.capabilityScopesOf)(scoped).flatMap((s) => (s.type === 'viewer' ? [] : [s])),
41
42
  },
42
43
  }),
43
44
  };
@@ -19,6 +19,10 @@ function accessCovered(asked, held) {
19
19
  }
20
20
  /** The grant without its access list, as a comparable key. */
21
21
  function grantKey(scope) {
22
+ // A viewer row (D921) is compared WHOLE: its lists and features are the
23
+ // grant, there is no access flavour to cut.
24
+ if (scope.type === 'viewer')
25
+ return JSON.stringify(scope);
22
26
  if (scope.type === 'device') {
23
27
  return JSON.stringify({
24
28
  type: scope.type,
@@ -37,6 +41,8 @@ function describe(scope) {
37
41
  return `addon-routes[${scope.access.join(',')}]`;
38
42
  if (scope.type === 'preferences-self')
39
43
  return 'preferences-self';
44
+ if (scope.type === 'viewer')
45
+ return 'viewer';
40
46
  if (scope.type === 'device')
41
47
  return `device[${scope.access.join(',')}]`;
42
48
  return `${scope.type}:${scope.target}[${scope.access.join(',')}]`;
@@ -55,7 +61,10 @@ function scopesWithin(requested, held, lookup) {
55
61
  return { ok: false, reason: 'a share-view grant names a device you cannot view' };
56
62
  }
57
63
  if (scope.grant.talk === true && !canTalkOn(heldCapability, scope.grant.deviceIds, lookup)) {
58
- return { ok: false, reason: 'a share-view grant asks talk-back on a device you cannot talk on' };
64
+ return {
65
+ ok: false,
66
+ reason: 'a share-view grant asks talk-back on a device you cannot talk on',
67
+ };
59
68
  }
60
69
  continue;
61
70
  }
@@ -72,7 +81,18 @@ function scopesWithin(requested, held, lookup) {
72
81
  };
73
82
  }
74
83
  const key = grantKey(scope);
75
- const covered = heldCapability.some((h) => grantKey(h) === key && accessCovered(scope.access, h.access));
84
+ if (scope.type === 'viewer') {
85
+ // Only the SAME viewer grant the caller holds — a viewer mints a token
86
+ // for itself, never a wider (or differently shaped) one.
87
+ if (!heldCapability.some((h) => grantKey(h) === key)) {
88
+ return {
89
+ ok: false,
90
+ reason: `you do not hold ${describe(scope)} with these devices and features`,
91
+ };
92
+ }
93
+ continue;
94
+ }
95
+ const covered = heldCapability.some((h) => h.type !== 'viewer' && grantKey(h) === key && accessCovered(scope.access, h.access));
76
96
  if (!covered)
77
97
  return { ok: false, reason: `you do not hold ${describe(scope)}` };
78
98
  }
@@ -124,7 +144,15 @@ function intersectScopes(token, held, lookup) {
124
144
  continue;
125
145
  }
126
146
  const key = grantKey(scope);
127
- const heldAccess = new Set(held.filter((h) => grantKey(h) === key).flatMap((h) => [...h.access]));
147
+ if (scope.type === 'viewer') {
148
+ // Kept only while the subject still holds the identical viewer grant:
149
+ // an admin who edits a viewer's devices or features ends that viewer's
150
+ // tokens rather than letting them keep (or gain) another reach.
151
+ if (held.some((h) => grantKey(h) === key))
152
+ out.push(scope);
153
+ continue;
154
+ }
155
+ const heldAccess = new Set(held.flatMap((h) => (h.type !== 'viewer' && grantKey(h) === key ? [...h.access] : [])));
128
156
  const access = scope.access.filter((a) => heldAccess.has(a));
129
157
  if (access.length > 0)
130
158
  out.push({ ...scope, access });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.619",
3
+ "version": "1.2.620",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "files": [
@@ -38,18 +38,18 @@
38
38
  ]
39
39
  },
40
40
  "dependencies": {
41
- "@camstack/addon-admin-ui": "1.2.456",
41
+ "@camstack/addon-admin-ui": "1.2.457",
42
42
  "@camstack/addon-agent-ui": "1.2.261",
43
43
  "@camstack/addon-auth": "1.2.166",
44
44
  "@camstack/addon-decoder-nodeav": "1.2.141",
45
45
  "@camstack/addon-notifiers": "1.2.619",
46
- "@camstack/addon-pipeline": "1.2.620",
46
+ "@camstack/addon-pipeline": "1.2.621",
47
47
  "@camstack/addon-pipeline-orchestrator": "1.2.622",
48
- "@camstack/addon-post-analysis": "1.2.626",
48
+ "@camstack/addon-post-analysis": "1.2.627",
49
49
  "@camstack/develop": "0.1.31",
50
50
  "@camstack/sdk": "1.2.151",
51
- "@camstack/system": "1.2.470",
52
- "@camstack/types": "1.2.402",
51
+ "@camstack/system": "1.2.471",
52
+ "@camstack/types": "1.2.403",
53
53
  "@camstack/ui-library": "1.2.318",
54
54
  "@fastify/compress": "^9.0.0",
55
55
  "@fastify/cookie": "^11.0.2",