@coffeerunhobby/mcp-ha-connect 1.3.0 → 1.3.1

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.
@@ -19,24 +19,39 @@ export const Role = {
19
19
  ADMIN: Permission.QUERY | Permission.CONTROL | Permission.NOTIFY | Permission.CONFIGURE | Permission.AI | Permission.ADMIN,
20
20
  SUPERUSER: 0xFF, // All permissions
21
21
  };
22
+ /**
23
+ * Resolve a role name to its permission mask, case-insensitively.
24
+ *
25
+ * Config (and JSON) commonly use lowercase role names ("operator") while the
26
+ * `Role` map is keyed uppercase ("OPERATOR"). Normalize here so BOTH the per-user
27
+ * role and the `defaultRole` fall-back resolve consistently. An unknown/undefined
28
+ * name fails closed to `Role.NONE` (never `undefined`, which downstream coerces to
29
+ * a zero mask anyway but is easy to mishandle).
30
+ */
31
+ function resolveRoleName(name) {
32
+ if (!name) {
33
+ return Role.NONE;
34
+ }
35
+ const key = name.toUpperCase();
36
+ return Role[key] ?? Role.NONE;
37
+ }
22
38
  /**
23
39
  * Get permission mask for a user by their JWT sub claim
24
40
  */
25
41
  export function getUserPermissions(sub, config) {
42
+ const fallback = resolveRoleName(config.defaultRole);
26
43
  if (!sub) {
27
- return config.defaultRole ? Role[config.defaultRole] : Role.NONE;
44
+ return fallback;
28
45
  }
29
46
  const user = config.users.find((u) => u.sub === sub);
30
47
  if (!user) {
31
- return config.defaultRole ? Role[config.defaultRole] : Role.NONE;
48
+ return fallback;
32
49
  }
33
50
  // Direct mask takes precedence over role
34
51
  if (user.mask !== undefined) {
35
52
  return user.mask;
36
53
  }
37
- // Convert role to uppercase for lookup (config may have lowercase)
38
- const roleKey = user.role?.toUpperCase();
39
- return roleKey && Role[roleKey] !== undefined ? Role[roleKey] : Role.NONE;
54
+ return resolveRoleName(user.role);
40
55
  }
41
56
  /**
42
57
  * Check if a permission mask includes required permission
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/permissions/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/permissions/index.ts"],"names":[],"mappings":"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"}
package/dist/version.d.ts CHANGED
@@ -2,4 +2,4 @@
2
2
  * Package version - hardcoded at build time for reliability
3
3
  * Keep in sync with package.json version
4
4
  */
5
- export declare const VERSION = "1.3.0";
5
+ export declare const VERSION = "1.3.1";
package/dist/version.js CHANGED
@@ -2,5 +2,5 @@
2
2
  * Package version - hardcoded at build time for reliability
3
3
  * Keep in sync with package.json version
4
4
  */
5
- export const VERSION = '1.3.0';
5
+ export const VERSION = '1.3.1';
6
6
  //# sourceMappingURL=version.js.map
package/docs/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ### 1.3.1
2
+ **Patch — RBAC role-name case-insensitivity.** Follow-up to the v1.3.0 fail-closed
3
+ permission change. `getUserPermissions` now resolves both the per-user `role` and the
4
+ `defaultRole` fall-back case-insensitively: a lowercase `"operator"` / `"admin"` in
5
+ `MCP_PERMISSIONS_CONFIG` (a common real-world spelling) maps to the correct mask instead
6
+ of silently collapsing to an empty (no-permission) mask. Unknown role names still fail
7
+ closed to `NONE`. Regression tests added under `tests/permissions/`.
8
+
1
9
  ### 1.3.0
2
10
  **Security hardening release.** A full OWASP-aligned audit (API Top 10 2023 + Top 10 2021)
3
11
  drove fixes across access control, authentication, injection, transport, configuration,
package/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Home Assistant MCP Wrapper",
5
- "version": "1.3.0",
5
+ "version": "1.3.1",
6
6
  "description": "REST endpoints that wrap MCP tools for Home Assistant integration. Raw JSON-RPC passthrough available at POST / for n8n compatibility."
7
7
  },
8
8
  "servers": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffeerunhobby/mcp-ha-connect",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "Smart home MCP server for Home Assistant plus Local AI integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -38,17 +38,36 @@ export interface PermissionsConfig {
38
38
  defaultRole?: RoleName;
39
39
  }
40
40
 
41
+ /**
42
+ * Resolve a role name to its permission mask, case-insensitively.
43
+ *
44
+ * Config (and JSON) commonly use lowercase role names ("operator") while the
45
+ * `Role` map is keyed uppercase ("OPERATOR"). Normalize here so BOTH the per-user
46
+ * role and the `defaultRole` fall-back resolve consistently. An unknown/undefined
47
+ * name fails closed to `Role.NONE` (never `undefined`, which downstream coerces to
48
+ * a zero mask anyway but is easy to mishandle).
49
+ */
50
+ function resolveRoleName(name: string | undefined): number {
51
+ if (!name) {
52
+ return Role.NONE;
53
+ }
54
+ const key = name.toUpperCase() as RoleName;
55
+ return Role[key] ?? Role.NONE;
56
+ }
57
+
41
58
  /**
42
59
  * Get permission mask for a user by their JWT sub claim
43
60
  */
44
61
  export function getUserPermissions(sub: string | undefined, config: PermissionsConfig): number {
62
+ const fallback = resolveRoleName(config.defaultRole);
63
+
45
64
  if (!sub) {
46
- return config.defaultRole ? Role[config.defaultRole] : Role.NONE;
65
+ return fallback;
47
66
  }
48
67
 
49
68
  const user = config.users.find((u) => u.sub === sub);
50
69
  if (!user) {
51
- return config.defaultRole ? Role[config.defaultRole] : Role.NONE;
70
+ return fallback;
52
71
  }
53
72
 
54
73
  // Direct mask takes precedence over role
@@ -56,9 +75,7 @@ export function getUserPermissions(sub: string | undefined, config: PermissionsC
56
75
  return user.mask;
57
76
  }
58
77
 
59
- // Convert role to uppercase for lookup (config may have lowercase)
60
- const roleKey = user.role?.toUpperCase() as RoleName | undefined;
61
- return roleKey && Role[roleKey] !== undefined ? Role[roleKey] : Role.NONE;
78
+ return resolveRoleName(user.role);
62
79
  }
63
80
 
64
81
  /**
package/src/version.ts CHANGED
@@ -2,4 +2,4 @@
2
2
  * Package version - hardcoded at build time for reliability
3
3
  * Keep in sync with package.json version
4
4
  */
5
- export const VERSION = '1.3.0';
5
+ export const VERSION = '1.3.1';
@@ -143,6 +143,27 @@ describe('getUserPermissions', () => {
143
143
  const configWithDefault = { ...config, defaultRole: 'READONLY' as const };
144
144
  expect(getUserPermissions('unknown', configWithDefault)).toBe(Role.READONLY);
145
145
  });
146
+
147
+ it('should resolve a lowercase defaultRole (case-insensitive)', () => {
148
+ // Real-world config commonly uses lowercase ("operator"); it must resolve to
149
+ // the OPERATOR mask, not silently fall through to an empty/undefined mask.
150
+ const cfg = { users: [], defaultRole: 'operator' as unknown as 'OPERATOR' };
151
+ expect(getUserPermissions('unlisted-sub', cfg)).toBe(Role.OPERATOR);
152
+ expect(getUserPermissions(undefined, cfg)).toBe(Role.OPERATOR);
153
+ });
154
+
155
+ it('should resolve a lowercase per-user role (case-insensitive)', () => {
156
+ const cfg = {
157
+ users: [{ sub: 'claude', role: 'admin' as unknown as 'ADMIN' }],
158
+ defaultRole: 'NONE' as const,
159
+ };
160
+ expect(getUserPermissions('claude', cfg)).toBe(Role.ADMIN);
161
+ });
162
+
163
+ it('should fail closed to NONE for an unrecognized role name', () => {
164
+ const cfg = { users: [], defaultRole: 'wizard' as unknown as 'ADMIN' };
165
+ expect(getUserPermissions('anyone', cfg)).toBe(Role.NONE);
166
+ });
146
167
  });
147
168
 
148
169
  describe('parsePermissionsConfig', () => {