@matter-server/dashboard 1.0.0 → 1.1.0

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.
Files changed (111) hide show
  1. package/dist/esm/components/dialogs/acl/acl-actions.d.ts +12 -0
  2. package/dist/esm/components/dialogs/acl/acl-actions.d.ts.map +1 -0
  3. package/dist/esm/components/dialogs/acl/acl-actions.js +56 -0
  4. package/dist/esm/components/dialogs/acl/acl-actions.js.map +6 -0
  5. package/dist/esm/components/dialogs/acl/model.d.ts +2 -2
  6. package/dist/esm/components/dialogs/acl/model.d.ts.map +1 -1
  7. package/dist/esm/components/dialogs/acl/model.js +11 -15
  8. package/dist/esm/components/dialogs/acl/model.js.map +1 -1
  9. package/dist/esm/components/dialogs/acl/node-acl-add-dialog.d.ts +43 -0
  10. package/dist/esm/components/dialogs/acl/node-acl-add-dialog.d.ts.map +1 -0
  11. package/dist/esm/components/dialogs/acl/node-acl-add-dialog.js +353 -0
  12. package/dist/esm/components/dialogs/acl/node-acl-add-dialog.js.map +6 -0
  13. package/dist/esm/components/dialogs/acl/show-node-acl-add-dialog.d.ts +8 -0
  14. package/dist/esm/components/dialogs/acl/show-node-acl-add-dialog.d.ts.map +1 -0
  15. package/dist/esm/components/dialogs/acl/show-node-acl-add-dialog.js +15 -0
  16. package/dist/esm/components/dialogs/acl/show-node-acl-add-dialog.js.map +6 -0
  17. package/dist/esm/components/dialogs/binding/binding-actions.d.ts +17 -0
  18. package/dist/esm/components/dialogs/binding/binding-actions.d.ts.map +1 -0
  19. package/dist/esm/components/dialogs/binding/binding-actions.js +135 -0
  20. package/dist/esm/components/dialogs/binding/binding-actions.js.map +6 -0
  21. package/dist/esm/components/dialogs/binding/model.d.ts +1 -1
  22. package/dist/esm/components/dialogs/binding/model.d.ts.map +1 -1
  23. package/dist/esm/components/dialogs/binding/model.js +8 -3
  24. package/dist/esm/components/dialogs/binding/model.js.map +1 -1
  25. package/dist/esm/components/dialogs/binding/node-binding-dialog.d.ts +16 -24
  26. package/dist/esm/components/dialogs/binding/node-binding-dialog.d.ts.map +1 -1
  27. package/dist/esm/components/dialogs/binding/node-binding-dialog.js +210 -332
  28. package/dist/esm/components/dialogs/binding/node-binding-dialog.js.map +1 -1
  29. package/dist/esm/pages/cluster-commands/clusters/access-control-commands.d.ts +37 -0
  30. package/dist/esm/pages/cluster-commands/clusters/access-control-commands.d.ts.map +1 -0
  31. package/dist/esm/pages/cluster-commands/clusters/access-control-commands.js +387 -0
  32. package/dist/esm/pages/cluster-commands/clusters/access-control-commands.js.map +6 -0
  33. package/dist/esm/pages/cluster-commands/clusters/binding-commands.d.ts +35 -0
  34. package/dist/esm/pages/cluster-commands/clusters/binding-commands.d.ts.map +1 -0
  35. package/dist/esm/pages/cluster-commands/clusters/binding-commands.js +254 -0
  36. package/dist/esm/pages/cluster-commands/clusters/binding-commands.js.map +6 -0
  37. package/dist/esm/pages/cluster-commands/index.d.ts +2 -0
  38. package/dist/esm/pages/cluster-commands/index.d.ts.map +1 -1
  39. package/dist/esm/pages/cluster-commands/index.js +2 -0
  40. package/dist/esm/pages/cluster-commands/index.js.map +1 -1
  41. package/dist/esm/pages/components/node-details.d.ts +0 -1
  42. package/dist/esm/pages/components/node-details.d.ts.map +1 -1
  43. package/dist/esm/pages/components/node-details.js +2 -18
  44. package/dist/esm/pages/components/node-details.js.map +1 -1
  45. package/dist/esm/pages/components/server-details.js +3 -3
  46. package/dist/esm/pages/components/server-details.js.map +1 -1
  47. package/dist/esm/pages/matter-cluster-view.d.ts.map +1 -1
  48. package/dist/esm/pages/matter-cluster-view.js +2 -1
  49. package/dist/esm/pages/matter-cluster-view.js.map +1 -1
  50. package/dist/esm/pages/matter-endpoint-view.d.ts +3 -3
  51. package/dist/esm/pages/matter-endpoint-view.d.ts.map +1 -1
  52. package/dist/esm/pages/matter-endpoint-view.js +13 -10
  53. package/dist/esm/pages/matter-endpoint-view.js.map +1 -1
  54. package/dist/esm/pages/matter-node-view.js +2 -2
  55. package/dist/esm/pages/matter-node-view.js.map +1 -1
  56. package/dist/esm/pages/network/network-utils.d.ts +1 -5
  57. package/dist/esm/pages/network/network-utils.d.ts.map +1 -1
  58. package/dist/esm/pages/network/network-utils.js +1 -11
  59. package/dist/esm/pages/network/network-utils.js.map +1 -1
  60. package/dist/esm/util/access-control.d.ts +54 -0
  61. package/dist/esm/util/access-control.d.ts.map +1 -0
  62. package/dist/esm/util/access-control.js +100 -0
  63. package/dist/esm/util/access-control.js.map +6 -0
  64. package/dist/esm/util/binding.d.ts +54 -0
  65. package/dist/esm/util/binding.d.ts.map +1 -0
  66. package/dist/esm/util/binding.js +113 -0
  67. package/dist/esm/util/binding.js.map +6 -0
  68. package/dist/esm/util/endpoints.d.ts +9 -0
  69. package/dist/esm/util/endpoints.d.ts.map +1 -0
  70. package/dist/esm/util/endpoints.js +18 -0
  71. package/dist/esm/util/endpoints.js.map +6 -0
  72. package/dist/esm/util/node-name.d.ts +12 -0
  73. package/dist/esm/util/node-name.d.ts.map +1 -0
  74. package/dist/esm/util/node-name.js +15 -0
  75. package/dist/esm/util/node-name.js.map +6 -0
  76. package/dist/web/js/{attribute-write-dialog-W7xpCE2E.js → attribute-write-dialog-CqqdRniU.js} +1 -1
  77. package/dist/web/js/{command-invoke-dialog-BAqAAdJw.js → command-invoke-dialog-BuvBOrdC.js} +1 -1
  78. package/dist/web/js/{commission-node-dialog-BTzCGgdy.js → commission-node-dialog-nVZp3go0.js} +5 -5
  79. package/dist/web/js/{commission-node-existing-B2M2hyDh.js → commission-node-existing-Cx3Ahk2t.js} +2 -2
  80. package/dist/web/js/{commission-node-thread-djdz2dXW.js → commission-node-thread-CI8mWWPs.js} +2 -2
  81. package/dist/web/js/{commission-node-wifi-DxAYNS1A.js → commission-node-wifi-lUX4LK2R.js} +2 -2
  82. package/dist/web/js/{dialog-box-tHvPVxDN.js → dialog-box-bAdbnf-T.js} +1 -1
  83. package/dist/web/js/{fire_event-BleYfTLc.js → fire_event-tWhqPfdz.js} +1 -1
  84. package/dist/web/js/main.js +1 -1
  85. package/dist/web/js/{matter-dashboard-app-CVi_GDky.js → matter-dashboard-app-CONA_608.js} +1485 -390
  86. package/dist/web/js/node-acl-add-dialog-DlR-sF-b.js +320 -0
  87. package/dist/web/js/node-binding-dialog-DhnX_86M.js +267 -0
  88. package/dist/web/js/{node-label-dialog-DVZSjsXU.js → node-label-dialog-T3nPG-Qy.js} +1 -1
  89. package/dist/web/js/{settings-dialog-BG5MgZcO.js → settings-dialog-DrHzJtsi.js} +1 -1
  90. package/package.json +4 -4
  91. package/src/components/dialogs/acl/acl-actions.ts +71 -0
  92. package/src/components/dialogs/acl/model.ts +18 -17
  93. package/src/components/dialogs/acl/node-acl-add-dialog.ts +350 -0
  94. package/src/components/dialogs/acl/show-node-acl-add-dialog.ts +14 -0
  95. package/src/components/dialogs/binding/binding-actions.ts +201 -0
  96. package/src/components/dialogs/binding/model.ts +11 -4
  97. package/src/components/dialogs/binding/node-binding-dialog.ts +221 -399
  98. package/src/pages/cluster-commands/clusters/access-control-commands.ts +407 -0
  99. package/src/pages/cluster-commands/clusters/binding-commands.ts +273 -0
  100. package/src/pages/cluster-commands/index.ts +2 -0
  101. package/src/pages/components/node-details.ts +2 -21
  102. package/src/pages/components/server-details.ts +3 -3
  103. package/src/pages/matter-cluster-view.ts +4 -1
  104. package/src/pages/matter-endpoint-view.ts +16 -10
  105. package/src/pages/matter-node-view.ts +2 -2
  106. package/src/pages/network/network-utils.ts +1 -18
  107. package/src/util/access-control.ts +135 -0
  108. package/src/util/binding.ts +182 -0
  109. package/src/util/endpoints.ts +17 -0
  110. package/src/util/node-name.ts +18 -0
  111. package/dist/web/js/node-binding-dialog-B9IdqHrZ.js +0 -624
@@ -0,0 +1,201 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { AccessControlEntry, BindingTarget, MatterClient, MatterNode } from "@matter-server/ws-client";
8
+ import {
9
+ AuthMode,
10
+ Privilege,
11
+ attributeArray,
12
+ entriesForFabric,
13
+ entryMatchesTarget,
14
+ isWholeNode,
15
+ nodeIdKey,
16
+ subjectsInclude,
17
+ } from "../../../util/access-control.js";
18
+ import { AccessControlEntryDataTransformer, type AccessControlEntryStruct } from "../acl/model.js";
19
+ import { BindingEntryDataTransformer, type BindingEntryStruct } from "./model.js";
20
+
21
+ function toBindingTarget(e: BindingEntryStruct): BindingTarget {
22
+ return { node: e.node ?? null, group: e.group ?? null, endpoint: e.endpoint ?? null, cluster: e.cluster ?? null };
23
+ }
24
+
25
+ function toApiAcl(e: AccessControlEntryStruct): AccessControlEntry {
26
+ return {
27
+ privilege: e.privilege,
28
+ auth_mode: e.authMode,
29
+ subjects: e.subjects ?? null,
30
+ targets:
31
+ e.targets?.map(t => ({
32
+ cluster: t.cluster ?? null,
33
+ endpoint: t.endpoint ?? null,
34
+ device_type: t.deviceType ?? null,
35
+ })) ?? null,
36
+ };
37
+ }
38
+
39
+ function requireFabricIndex(res: Record<string, unknown>, nodeId: number | bigint): number {
40
+ const fi = res["0/62/5"];
41
+ if (typeof fi !== "number") {
42
+ throw new Error(`Cannot determine the current fabric index (0/62/5) for node ${nodeId}`);
43
+ }
44
+ return fi;
45
+ }
46
+
47
+ /**
48
+ * Read the node's ACL + CurrentFabricIndex fresh (explicit reads are not fabric-filtered) and narrow
49
+ * to our fabric. Fails rather than risk writing back other fabrics' entries if the index is unknown.
50
+ */
51
+ async function freshOurAcl(client: MatterClient, nodeId: number | bigint): Promise<AccessControlEntryStruct[]> {
52
+ const res = await client.readAttribute(nodeId, ["0/31/0", "0/62/5"]);
53
+ const all = attributeArray(res["0/31/0"]).map(v => AccessControlEntryDataTransformer.transform(v));
54
+ return entriesForFabric(all, requireFabricIndex(res, nodeId));
55
+ }
56
+
57
+ async function freshOurBindings(
58
+ client: MatterClient,
59
+ nodeId: number | bigint,
60
+ endpoint: number,
61
+ ): Promise<BindingEntryStruct[]> {
62
+ const res = await client.readAttribute(nodeId, [`${endpoint}/30/0`, "0/62/5"]);
63
+ const all = attributeArray(res[`${endpoint}/30/0`]).map(v => BindingEntryDataTransformer.transform(v));
64
+ const fabricIndex = requireFabricIndex(res, nodeId);
65
+ return all.filter(b => b.fabricIndex === fabricIndex);
66
+ }
67
+
68
+ function hasTarget(e: AccessControlEntryStruct, endpoint: number, cluster: number | undefined): boolean {
69
+ return (e.targets ?? []).some(t => t.endpoint === endpoint && t.cluster === cluster);
70
+ }
71
+
72
+ function aclTargetsMax(client: MatterClient, nodeId: number | bigint): number {
73
+ const raw = client.nodes[nodeIdKey(nodeId)]?.attributes["0/31/3"];
74
+ return typeof raw === "number" && raw > 0 ? raw : Number.MAX_SAFE_INTEGER;
75
+ }
76
+
77
+ /** Ensure the target grants the source an Operate ACL for {endpoint, cluster}, merging where possible. */
78
+ export async function ensureBindingAcl(
79
+ client: MatterClient,
80
+ sourceNodeId: number | bigint,
81
+ targetNodeId: number | bigint,
82
+ targetEndpoint: number,
83
+ cluster: number | undefined,
84
+ ): Promise<void> {
85
+ const acl = await freshOurAcl(client, targetNodeId);
86
+
87
+ const alreadyGranted = acl.some(
88
+ e =>
89
+ e.authMode === AuthMode.Case &&
90
+ e.privilege >= Privilege.Operate &&
91
+ subjectsInclude(e, sourceNodeId) &&
92
+ entryMatchesTarget(e, targetEndpoint, cluster),
93
+ );
94
+ if (alreadyGranted) return;
95
+
96
+ const targetsMax = aclTargetsMax(client, targetNodeId);
97
+ const reusable = acl.find(
98
+ e =>
99
+ e.authMode === AuthMode.Case &&
100
+ e.privilege >= Privilege.Operate &&
101
+ subjectsInclude(e, sourceNodeId) &&
102
+ (isWholeNode(e) || hasTarget(e, targetEndpoint, cluster) || (e.targets?.length ?? 0) < targetsMax),
103
+ );
104
+ if (reusable) {
105
+ if (!isWholeNode(reusable) && !hasTarget(reusable, targetEndpoint, cluster)) {
106
+ reusable.targets = reusable.targets ?? [];
107
+ reusable.targets.push({ endpoint: targetEndpoint, cluster, deviceType: undefined });
108
+ }
109
+ } else {
110
+ acl.push({
111
+ privilege: Privilege.Operate,
112
+ authMode: AuthMode.Case,
113
+ subjects: [sourceNodeId],
114
+ targets: [{ endpoint: targetEndpoint, cluster, deviceType: undefined }],
115
+ fabricIndex: 0,
116
+ });
117
+ }
118
+ await client.setACLEntry(targetNodeId, acl.map(toApiAcl));
119
+ }
120
+
121
+ /** Downgrade an over-privileged (>Operate) binding ACL on the target back to Operate. */
122
+ export async function fixOverPrivilegedBindingAcl(
123
+ client: MatterClient,
124
+ sourceNodeId: number | bigint,
125
+ targetNodeId: number | bigint,
126
+ targetEndpoint: number,
127
+ cluster: number | undefined,
128
+ ): Promise<void> {
129
+ const acl = await freshOurAcl(client, targetNodeId);
130
+ const updated = acl.map(e =>
131
+ e.authMode === AuthMode.Case &&
132
+ subjectsInclude(e, sourceNodeId) &&
133
+ e.privilege > Privilege.Operate &&
134
+ entryMatchesTarget(e, targetEndpoint, cluster)
135
+ ? { ...e, privilege: Privilege.Operate }
136
+ : e,
137
+ );
138
+ await client.setACLEntry(targetNodeId, updated.map(toApiAcl));
139
+ }
140
+
141
+ export async function addBinding(
142
+ client: MatterClient,
143
+ sourceNode: MatterNode,
144
+ sourceEndpoint: number,
145
+ targetNodeId: number | bigint,
146
+ targetEndpoint: number,
147
+ cluster: number | undefined,
148
+ ): Promise<void> {
149
+ await ensureBindingAcl(client, sourceNode.node_id, targetNodeId, targetEndpoint, cluster);
150
+
151
+ const bindings = await freshOurBindings(client, sourceNode.node_id, sourceEndpoint);
152
+ const targetKey = nodeIdKey(targetNodeId);
153
+ const exists = bindings.some(
154
+ b =>
155
+ b.node != null && nodeIdKey(b.node) === targetKey && b.endpoint === targetEndpoint && b.cluster === cluster,
156
+ );
157
+ if (exists) return;
158
+ bindings.push({ node: targetNodeId, group: undefined, endpoint: targetEndpoint, cluster, fabricIndex: undefined });
159
+ await client.setNodeBinding(sourceNode.node_id, sourceEndpoint, bindings.map(toBindingTarget));
160
+ }
161
+
162
+ /**
163
+ * Remove the binding at `index`, then drop the matching target from the source's ACL entry on the
164
+ * (binding) target node. Matches on the binding's TARGET endpoint + cluster.
165
+ */
166
+ export async function deleteBindingAtIndex(
167
+ client: MatterClient,
168
+ sourceNode: MatterNode,
169
+ sourceEndpoint: number,
170
+ index: number,
171
+ ): Promise<void> {
172
+ const bindings = await freshOurBindings(client, sourceNode.node_id, sourceEndpoint);
173
+ const removed = bindings[index];
174
+ if (!removed) return;
175
+ const updated = [...bindings.slice(0, index), ...bindings.slice(index + 1)];
176
+ await client.setNodeBinding(sourceNode.node_id, sourceEndpoint, updated.map(toBindingTarget));
177
+
178
+ if (removed.node == null || removed.endpoint == null) return;
179
+ const targetEndpoint = removed.endpoint;
180
+ const removedCluster = removed.cluster;
181
+ try {
182
+ const acl = await freshOurAcl(client, removed.node);
183
+ const kept = acl
184
+ .map(e => {
185
+ if (!subjectsInclude(e, sourceNode.node_id) || isWholeNode(e)) return e;
186
+ const targets = e.targets!.filter(
187
+ t => !(t.endpoint === targetEndpoint && t.cluster === removedCluster),
188
+ );
189
+ if (targets.length === 0) return undefined;
190
+ return { ...e, targets };
191
+ })
192
+ .filter((e): e is AccessControlEntryStruct => e !== undefined);
193
+ await client.setACLEntry(removed.node, kept.map(toApiAcl));
194
+ } catch (err) {
195
+ const detail = err instanceof Error ? err.message : String(err);
196
+ throw new Error(
197
+ `Binding removed, but cleaning up the access control entry on the target node failed: ${detail}. ` +
198
+ "The target may retain a stale access grant.",
199
+ );
200
+ }
201
+ }
@@ -16,6 +16,10 @@ export interface BindingEntryStruct {
16
16
  fabricIndex: number | undefined;
17
17
  }
18
18
 
19
+ function isRecord(value: unknown): value is Record<string, unknown> {
20
+ return typeof value === "object" && value !== null;
21
+ }
22
+
19
23
  export class BindingEntryDataTransformer {
20
24
  private static readonly KEY_MAPPING: {
21
25
  [inputKey: string]: keyof BindingEntryStruct;
@@ -27,8 +31,8 @@ export class BindingEntryDataTransformer {
27
31
  "254": "fabricIndex",
28
32
  };
29
33
 
30
- public static transform(input: any): BindingEntryStruct {
31
- if (!input || typeof input !== "object") {
34
+ public static transform(input: unknown): BindingEntryStruct {
35
+ if (!isRecord(input)) {
32
36
  throw new Error("Invalid input: expected an object");
33
37
  }
34
38
 
@@ -40,12 +44,15 @@ export class BindingEntryDataTransformer {
40
44
  const mappedKey = keyMapping[key];
41
45
  if (mappedKey) {
42
46
  const value = input[key];
43
- if (value === undefined) {
47
+ // Treat unset/wildcard fields (null or absent) as omitted, not numeric 0.
48
+ if (value == null) {
44
49
  continue;
45
50
  }
46
51
  if (mappedKey === "node") {
47
52
  // Node IDs can be bigint - preserve the original type
48
- result[mappedKey] = value;
53
+ if (typeof value === "number" || typeof value === "bigint") {
54
+ result.node = value;
55
+ }
49
56
  } else {
50
57
  // group, endpoint, cluster, fabricIndex are all numeric
51
58
  result[mappedKey] = Number(value);