@camstack/types 1.2.67 → 1.2.69

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.
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The two questions the operator-facing Access panel asks, as pure logic.
3
+ *
4
+ * 1. WHAT can this user do? → an {@link AccessRoleId}
5
+ * 2. On WHICH devices? → a {@link DeviceSelector} + `includeLinked`
6
+ *
7
+ * and the composition of those two answers into the `TokenScope[]` the v3
8
+ * enforcement matcher actually reads.
9
+ *
10
+ * Why this is not a {@link SCOPE_PRESETS} entry: a preset is a frozen list of
11
+ * rows an operator drops into the raw picker. A ROLE is only half a grant set
12
+ * — its device row carries the selector chosen in question 2 — so it cannot be
13
+ * pre-baked. The two coexist: `SCOPE_PRESETS` fills the Advanced picker,
14
+ * `ACCESS_ROLES` drives the simple panel.
15
+ *
16
+ * The one rule that makes the pairing work: a role NEVER emits a
17
+ * `category:device` row. That row is unrestricted device reach
18
+ * (`hasUnrestrictedDeviceReach` in `device-selector.ts` / `scope-access.ts`)
19
+ * and would silently discard the operator's device choice — the panel would
20
+ * show "3 cameras" over a grant that reaches all of them. The device row is
21
+ * always `{type:'device', selector}` so question 2 is load-bearing; a
22
+ * selector of `{kind:'all'}` is the deliberate way to say "everything", and
23
+ * the matcher treats it as the same unrestricted reach.
24
+ *
25
+ * The `category:system` row is what makes a role USEFUL: recordings, the
26
+ * events feed, faces and playback manifests live on system-scope caps, so a
27
+ * device grant alone reaches almost nothing a viewer wants. It is safe at
28
+ * these tiers because (a) admin-tier methods are refused by the auth gate
29
+ * regardless of scope, and (b) every system-scope method that NAMES a device
30
+ * is cut back to the role's own device set by the F1 #3 device-reference gate.
31
+ *
32
+ * Lives in `@camstack/types` next to `device-selector.ts` for the same reason
33
+ * that file does: the admin UI composes these and the backend resolves them,
34
+ * and a hand-copied second derivation drifts (D103).
35
+ */
36
+ import type { DeviceSelector, MethodAccess, TokenScope } from './auth-records.js';
37
+ /** The three answers to "what can this user do". */
38
+ export type AccessRoleId = 'viewer' | 'operator' | 'manager';
39
+ export interface AccessRoleSpec {
40
+ readonly id: AccessRoleId;
41
+ /** Radio-button label. */
42
+ readonly label: string;
43
+ /** One sentence saying what the role can do — shown under the label. */
44
+ readonly description: string;
45
+ /** Access flavours carried by the role's `device` grant. */
46
+ readonly deviceAccess: readonly MethodAccess[];
47
+ /** Access flavours carried by the role's `category:system` grant. */
48
+ readonly systemAccess: readonly MethodAccess[];
49
+ }
50
+ /**
51
+ * Ordered widest-last — the panel renders them top to bottom and the operator
52
+ * reads the list as an escalation.
53
+ */
54
+ export declare const ACCESS_ROLES: readonly AccessRoleSpec[];
55
+ /** Look a role up by id. Throws on an unknown id — the union makes that
56
+ * unreachable from typed callers, and a silent fallback would grant the
57
+ * wrong thing. */
58
+ export declare function roleSpec(id: AccessRoleId): AccessRoleSpec;
59
+ /** A complete answer to both panel questions. */
60
+ export interface AccessRoleAssignment {
61
+ readonly roleId: AccessRoleId;
62
+ readonly selector: DeviceSelector;
63
+ /**
64
+ * Whether a grant on a parent device covers its accessory children.
65
+ * ALWAYS written explicitly — see {@link buildRoleScopes}.
66
+ */
67
+ readonly includeLinked: boolean;
68
+ }
69
+ /**
70
+ * Compose an assignment into the grants the matcher reads.
71
+ *
72
+ * `includeLinked` is written explicitly in BOTH directions on purpose. Absent,
73
+ * the matcher derives it from the access flavour (`view` inherits, `create` /
74
+ * `delete` do not), so an omitted `false` would silently re-grant the
75
+ * accessories the operator just excluded, and the panel would be lying about
76
+ * its own toggle.
77
+ */
78
+ export declare function buildRoleScopes(assignment: AccessRoleAssignment): TokenScope[];
79
+ /**
80
+ * Recover the assignment behind a grant set, or `null` when the grants are
81
+ * not something this panel authored.
82
+ *
83
+ * `null` is not an error — it is the "Advanced" case, and the panel MUST show
84
+ * it as such rather than defaulting the radio to a role the user does not
85
+ * have. Silently rendering an unrecognised grant set as "View" would make the
86
+ * next save a downgrade nobody asked for.
87
+ *
88
+ * Strict by design: exactly the two rows `buildRoleScopes` writes, an
89
+ * explicit `includeLinked`, and device/system access that agree on a single
90
+ * role. Anything else belongs in the raw editor.
91
+ */
92
+ export declare function detectAccessRole(scopes: readonly TokenScope[]): AccessRoleAssignment | null;
@@ -10,6 +10,7 @@
10
10
  * keeps the original name for backward compatibility.
11
11
  */
12
12
  import { z } from 'zod';
13
+ import { DeviceType } from '../device/device-type.js';
13
14
  /**
14
15
  * Auth model (v2 — 2026-05-12 caps-only):
15
16
  *
@@ -37,7 +38,62 @@ export declare const CapScopeSchema: z.ZodEnum<{
37
38
  device: "device";
38
39
  }>;
39
40
  export type CapScope = z.infer<typeof CapScopeSchema>;
40
- export declare const TokenScopeSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
41
+ /**
42
+ * DeviceSelector (scope model v3 — 2026-08-12).
43
+ *
44
+ * A `device` grant no longer carries a frozen list of deviceIds. It carries
45
+ * a SELECTOR the matcher resolves against the live fleet, so the grant can be
46
+ * DYNAMIC: a `types:['camera']` selector automatically covers a camera added
47
+ * AFTER the grant was minted — no re-grant, no re-login.
48
+ *
49
+ * - `all` — every device in the deployment. The broad viewer/operator
50
+ * lever without a `category` grant (a `category` grant also covers device
51
+ * caps that carry no deviceId; `all` is specifically the device set).
52
+ * - `ids` — an explicit deviceId list. This is what a v2 `device:[…]`
53
+ * grant migrates to (see {@link TokenScopeSchema}); STATIC — a new camera
54
+ * is NOT covered until the grant is edited.
55
+ * - `types` — every device of a `DeviceType` (e.g. every `camera`).
56
+ * DYNAMIC. A device that changes type, or a new device of the type,
57
+ * re-resolves on the next request.
58
+ * - `locations` — every device whose operator-assigned `location` label is
59
+ * in the set (e.g. "Garden", "Front door"). DYNAMIC. A device with a
60
+ * null/unset location matches NO `locations` selector.
61
+ */
62
+ export declare const DeviceSelectorSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
63
+ kind: z.ZodLiteral<"all">;
64
+ }, z.core.$strip>, z.ZodObject<{
65
+ kind: z.ZodLiteral<"ids">;
66
+ ids: z.ZodArray<z.ZodNumber>;
67
+ }, z.core.$strip>, z.ZodObject<{
68
+ kind: z.ZodLiteral<"types">;
69
+ types: z.ZodArray<z.ZodEnum<typeof DeviceType>>;
70
+ }, z.core.$strip>, z.ZodObject<{
71
+ kind: z.ZodLiteral<"locations">;
72
+ locations: z.ZodArray<z.ZodString>;
73
+ }, z.core.$strip>], "kind">;
74
+ export type DeviceSelector = z.infer<typeof DeviceSelectorSchema>;
75
+ declare const DeviceTokenScopeSchema: z.ZodObject<{
76
+ type: z.ZodLiteral<"device">;
77
+ selector: z.ZodDiscriminatedUnion<[z.ZodObject<{
78
+ kind: z.ZodLiteral<"all">;
79
+ }, z.core.$strip>, z.ZodObject<{
80
+ kind: z.ZodLiteral<"ids">;
81
+ ids: z.ZodArray<z.ZodNumber>;
82
+ }, z.core.$strip>, z.ZodObject<{
83
+ kind: z.ZodLiteral<"types">;
84
+ types: z.ZodArray<z.ZodEnum<typeof DeviceType>>;
85
+ }, z.core.$strip>, z.ZodObject<{
86
+ kind: z.ZodLiteral<"locations">;
87
+ locations: z.ZodArray<z.ZodString>;
88
+ }, z.core.$strip>], "kind">;
89
+ access: z.ZodArray<z.ZodEnum<{
90
+ view: "view";
91
+ create: "create";
92
+ delete: "delete";
93
+ }>>;
94
+ includeLinked: z.ZodOptional<z.ZodBoolean>;
95
+ }, z.core.$strip>;
96
+ export declare const TokenScopeSchema: z.ZodPreprocess<z.ZodDiscriminatedUnion<[z.ZodObject<{
41
97
  type: z.ZodLiteral<"category">;
42
98
  target: z.ZodEnum<{
43
99
  system: "system";
@@ -66,14 +122,28 @@ export declare const TokenScopeSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
66
122
  }>>;
67
123
  }, z.core.$strip>, z.ZodObject<{
68
124
  type: z.ZodLiteral<"device">;
69
- targets: z.ZodArray<z.ZodString>;
125
+ selector: z.ZodDiscriminatedUnion<[z.ZodObject<{
126
+ kind: z.ZodLiteral<"all">;
127
+ }, z.core.$strip>, z.ZodObject<{
128
+ kind: z.ZodLiteral<"ids">;
129
+ ids: z.ZodArray<z.ZodNumber>;
130
+ }, z.core.$strip>, z.ZodObject<{
131
+ kind: z.ZodLiteral<"types">;
132
+ types: z.ZodArray<z.ZodEnum<typeof DeviceType>>;
133
+ }, z.core.$strip>, z.ZodObject<{
134
+ kind: z.ZodLiteral<"locations">;
135
+ locations: z.ZodArray<z.ZodString>;
136
+ }, z.core.$strip>], "kind">;
70
137
  access: z.ZodArray<z.ZodEnum<{
71
138
  view: "view";
72
139
  create: "create";
73
140
  delete: "delete";
74
141
  }>>;
75
- }, z.core.$strip>], "type">;
142
+ includeLinked: z.ZodOptional<z.ZodBoolean>;
143
+ }, z.core.$strip>], "type">>;
76
144
  export type TokenScope = z.infer<typeof TokenScopeSchema>;
145
+ /** The `device`-typed member of {@link TokenScope}, narrowed for matchers. */
146
+ export type DeviceTokenScope = z.infer<typeof DeviceTokenScopeSchema>;
77
147
  export declare const UserRecordSchema: z.ZodObject<{
78
148
  id: z.ZodString;
79
149
  username: z.ZodString;
@@ -81,7 +151,7 @@ export declare const UserRecordSchema: z.ZodObject<{
81
151
  isAdmin: z.ZodDefault<z.ZodBoolean>;
82
152
  allowedProviders: z.ZodUnion<readonly [z.ZodLiteral<"*">, z.ZodArray<z.ZodString>]>;
83
153
  allowedDevices: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodLiteral<"*">, z.ZodArray<z.ZodString>]>>;
84
- scopes: z.ZodDefault<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
154
+ scopes: z.ZodDefault<z.ZodArray<z.ZodPreprocess<z.ZodDiscriminatedUnion<[z.ZodObject<{
85
155
  type: z.ZodLiteral<"category">;
86
156
  target: z.ZodEnum<{
87
157
  system: "system";
@@ -110,13 +180,25 @@ export declare const UserRecordSchema: z.ZodObject<{
110
180
  }>>;
111
181
  }, z.core.$strip>, z.ZodObject<{
112
182
  type: z.ZodLiteral<"device">;
113
- targets: z.ZodArray<z.ZodString>;
183
+ selector: z.ZodDiscriminatedUnion<[z.ZodObject<{
184
+ kind: z.ZodLiteral<"all">;
185
+ }, z.core.$strip>, z.ZodObject<{
186
+ kind: z.ZodLiteral<"ids">;
187
+ ids: z.ZodArray<z.ZodNumber>;
188
+ }, z.core.$strip>, z.ZodObject<{
189
+ kind: z.ZodLiteral<"types">;
190
+ types: z.ZodArray<z.ZodEnum<typeof DeviceType>>;
191
+ }, z.core.$strip>, z.ZodObject<{
192
+ kind: z.ZodLiteral<"locations">;
193
+ locations: z.ZodArray<z.ZodString>;
194
+ }, z.core.$strip>], "kind">;
114
195
  access: z.ZodArray<z.ZodEnum<{
115
196
  view: "view";
116
197
  create: "create";
117
198
  delete: "delete";
118
199
  }>>;
119
- }, z.core.$strip>], "type">>>;
200
+ includeLinked: z.ZodOptional<z.ZodBoolean>;
201
+ }, z.core.$strip>], "type">>>>;
120
202
  createdAt: z.ZodNumber;
121
203
  updatedAt: z.ZodNumber;
122
204
  }, z.core.$strip>;
@@ -139,7 +221,7 @@ export declare const ScopedTokenSchema: z.ZodObject<{
139
221
  name: z.ZodString;
140
222
  tokenHash: z.ZodString;
141
223
  tokenPrefix: z.ZodString;
142
- scopes: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
224
+ scopes: z.ZodArray<z.ZodPreprocess<z.ZodDiscriminatedUnion<[z.ZodObject<{
143
225
  type: z.ZodLiteral<"category">;
144
226
  target: z.ZodEnum<{
145
227
  system: "system";
@@ -168,15 +250,28 @@ export declare const ScopedTokenSchema: z.ZodObject<{
168
250
  }>>;
169
251
  }, z.core.$strip>, z.ZodObject<{
170
252
  type: z.ZodLiteral<"device">;
171
- targets: z.ZodArray<z.ZodString>;
253
+ selector: z.ZodDiscriminatedUnion<[z.ZodObject<{
254
+ kind: z.ZodLiteral<"all">;
255
+ }, z.core.$strip>, z.ZodObject<{
256
+ kind: z.ZodLiteral<"ids">;
257
+ ids: z.ZodArray<z.ZodNumber>;
258
+ }, z.core.$strip>, z.ZodObject<{
259
+ kind: z.ZodLiteral<"types">;
260
+ types: z.ZodArray<z.ZodEnum<typeof DeviceType>>;
261
+ }, z.core.$strip>, z.ZodObject<{
262
+ kind: z.ZodLiteral<"locations">;
263
+ locations: z.ZodArray<z.ZodString>;
264
+ }, z.core.$strip>], "kind">;
172
265
  access: z.ZodArray<z.ZodEnum<{
173
266
  view: "view";
174
267
  create: "create";
175
268
  delete: "delete";
176
269
  }>>;
177
- }, z.core.$strip>], "type">>;
270
+ includeLinked: z.ZodOptional<z.ZodBoolean>;
271
+ }, z.core.$strip>], "type">>>;
178
272
  expiresAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
179
273
  lastUsedAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
180
274
  createdAt: z.ZodNumber;
181
275
  }, z.core.$strip>;
182
276
  export type ScopedToken = z.infer<typeof ScopedTokenSchema>;
277
+ export {};
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Pure DeviceSelector semantics — scope model v3 (2026-08-12).
3
+ *
4
+ * One derivation of "does this grant cover this device", reused by:
5
+ * - the enforcement matcher (`server/backend/.../scope-access.ts`), per
6
+ * request, against a single deviceId + its ancestors;
7
+ * - response projection + `auth.me`, against the WHOLE fleet, to compute
8
+ * the viewable device set and the effective-scope summary the UI reads.
9
+ *
10
+ * Lives in `@camstack/types` (framework, train-bound) because BOTH the
11
+ * backend enforcement path and any addon/UI need the same rule — the F0.x
12
+ * lesson that a hand-copied scope check drifts (D103).
13
+ *
14
+ * No I/O, no registry — the fleet + parentage are passed in.
15
+ */
16
+ import type { DeviceSelector, DeviceTokenScope, MethodAccess, TokenScope } from './auth-records.js';
17
+ /** The minimal device shape selectors resolve against. */
18
+ export interface DeviceMetaLike {
19
+ readonly id: number;
20
+ /** `DeviceType` string value (e.g. `'camera'`). */
21
+ readonly type: string;
22
+ /** Operator-assigned location label; `null` when unset. */
23
+ readonly location: string | null;
24
+ readonly parentDeviceId: number | null;
25
+ }
26
+ /**
27
+ * Does `selector` cover `device` DIRECTLY (no inheritance)? Inheritance is
28
+ * layered on by the resolver/matcher, which tests this against a device's
29
+ * ancestors too.
30
+ */
31
+ export declare function deviceSelectorMatches(selector: DeviceSelector, device: DeviceMetaLike): boolean;
32
+ /** Whether inheritance applies for a grant at `access` (view inherits, others
33
+ * don't, unless the grant overrides via `includeLinked`). */
34
+ export declare function scopeInherits(scope: DeviceTokenScope, access: MethodAccess): boolean;
35
+ /**
36
+ * Resolve the set of deviceIds the caller may reach at `access`, expanded
37
+ * against the live `fleet`. A `category:device` grant returns the whole
38
+ * fleet. Device selectors are matched per device — directly, or (when the
39
+ * grant inherits) against an ancestor, so a parent grant covers its
40
+ * accessories.
41
+ */
42
+ export declare function resolveViewableDeviceIds(scopes: readonly TokenScope[], fleet: readonly DeviceMetaLike[], access: MethodAccess): Set<number>;
43
+ /**
44
+ * Normalise a raw scope array read off a JWT payload or a stored record into
45
+ * validated v3 `TokenScope`s, migrating any v2 `device:targets` grant on the
46
+ * way (via {@link TokenScopeSchema}'s preprocess). Applied at the request
47
+ * boundary (`resolveUser`) so the enforcement matcher only ever sees v3.
48
+ *
49
+ * Per-ITEM parse (not whole-array) so a single malformed scope is dropped
50
+ * without discarding the caller's valid grants — losing a good grant reads as
51
+ * a lockout, and a lockout reads as broken. A non-array input yields `[]`
52
+ * (fail closed).
53
+ */
54
+ export declare function normalizeTokenScopes(raw: unknown): TokenScope[];
55
+ /** A capability/addon grant flattened for the UI. */
56
+ export interface ScopeGrantSummary {
57
+ readonly name: string;
58
+ readonly access: readonly MethodAccess[];
59
+ }
60
+ /** Per-access device reach — a COUNT and the "everything" flag, never the id
61
+ * list (the UI hides surfaces from counts; it never needs the ids). */
62
+ export interface DeviceReachSummary {
63
+ readonly all: boolean;
64
+ readonly deviceCount: number;
65
+ }
66
+ /**
67
+ * The resolved, effective scope the caller actually has — what `auth.me`
68
+ * returns so the viewer (F3/F4) can HIDE out-of-scope surfaces. Selectors are
69
+ * expanded to counts/flags against the live fleet, never to raw id lists.
70
+ */
71
+ export interface EffectiveScope {
72
+ readonly isAdmin: boolean;
73
+ /** true iff every device is viewable (admin, `category:device[view]`, or a
74
+ * device grant with an `all` selector). */
75
+ readonly allDevicesViewable: boolean;
76
+ /** How many devices the caller may VIEW right now. */
77
+ readonly viewableDeviceCount: number;
78
+ /** Device reach per access flavour. */
79
+ readonly device: Readonly<Record<MethodAccess, DeviceReachSummary>>;
80
+ /** `category:system` access flavours held. */
81
+ readonly system: readonly MethodAccess[];
82
+ /** `capability:<name>` grants. */
83
+ readonly capabilities: readonly ScopeGrantSummary[];
84
+ /** `addon:<id>` grants. */
85
+ readonly addons: readonly ScopeGrantSummary[];
86
+ }
87
+ /** Build the {@link EffectiveScope} for a caller from their scopes + the fleet. */
88
+ export declare function summarizeEffectiveScope(scopes: readonly TokenScope[], fleet: readonly DeviceMetaLike[], isAdmin: boolean): EffectiveScope;
@@ -2901,6 +2901,7 @@ function createDeviceProxy(api, binding, opts) {
2901
2901
  humiditySensor: createSliceHandle(stateSource, binding.deviceId, "humidity-sensor"),
2902
2902
  image: createSliceHandle(stateSource, binding.deviceId, "image"),
2903
2903
  imageSettings: createSliceHandle(stateSource, binding.deviceId, "image-settings"),
2904
+ intercom: createSliceHandle(stateSource, binding.deviceId, "intercom"),
2904
2905
  lawnMowerControl: createSliceHandle(stateSource, binding.deviceId, "lawn-mower-control"),
2905
2906
  lockControl: createSliceHandle(stateSource, binding.deviceId, "lock-control"),
2906
2907
  mediaPlayer: createSliceHandle(stateSource, binding.deviceId, "media-player"),
@@ -2901,6 +2901,7 @@ function createDeviceProxy(api, binding, opts) {
2901
2901
  humiditySensor: createSliceHandle(stateSource, binding.deviceId, "humidity-sensor"),
2902
2902
  image: createSliceHandle(stateSource, binding.deviceId, "image"),
2903
2903
  imageSettings: createSliceHandle(stateSource, binding.deviceId, "image-settings"),
2904
+ intercom: createSliceHandle(stateSource, binding.deviceId, "intercom"),
2904
2905
  lawnMowerControl: createSliceHandle(stateSource, binding.deviceId, "lawn-mower-control"),
2905
2906
  lockControl: createSliceHandle(stateSource, binding.deviceId, "lock-control"),
2906
2907
  mediaPlayer: createSliceHandle(stateSource, binding.deviceId, "media-player"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/types",
3
- "version": "1.2.67",
3
+ "version": "1.2.69",
4
4
  "description": "Shared types, interfaces, and model catalogs for the CamStack detection ecosystem",
5
5
  "keywords": [
6
6
  "camstack",