@coffeerunhobby/mcp-ha-connect 2.2.1 → 2.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.
Files changed (281) hide show
  1. package/.env.example +1 -1
  2. package/README.md +11 -2
  3. package/dist/omadaClient/client.d.ts +6 -1
  4. package/dist/omadaClient/client.js +12 -1
  5. package/dist/omadaClient/client.js.map +1 -1
  6. package/dist/omadaClient/diagnose.d.ts +68 -0
  7. package/dist/omadaClient/diagnose.js +164 -0
  8. package/dist/omadaClient/diagnose.js.map +1 -0
  9. package/dist/omadaClient/index.d.ts +6 -0
  10. package/dist/omadaClient/index.js +15 -0
  11. package/dist/omadaClient/index.js.map +1 -1
  12. package/dist/omadaClient/macGroups.d.ts +58 -0
  13. package/dist/omadaClient/macGroups.js +124 -0
  14. package/dist/omadaClient/macGroups.js.map +1 -0
  15. package/dist/tools/infra/actions.d.ts +1 -1
  16. package/dist/tools/infra/actions.js +1 -1
  17. package/dist/tools/omada/index.js +6 -0
  18. package/dist/tools/omada/index.js.map +1 -1
  19. package/dist/tools/omada/macGroups.d.ts +19 -0
  20. package/dist/tools/omada/macGroups.js +77 -0
  21. package/dist/tools/omada/macGroups.js.map +1 -0
  22. package/dist/tools/omada/namespace.js +8 -0
  23. package/dist/tools/omada/namespace.js.map +1 -1
  24. package/dist/version.d.ts +1 -1
  25. package/dist/version.js +1 -1
  26. package/package.json +5 -1
  27. package/.codex/review-rules.md +0 -16
  28. package/.dockerignore +0 -8
  29. package/.gitattributes +0 -30
  30. package/.github/workflows/ci.yml +0 -193
  31. package/AGENTS.md +0 -389
  32. package/Dockerfile +0 -50
  33. package/docker-compose.yml +0 -23
  34. package/docs/CHANGELOG.md +0 -341
  35. package/docs/COVERAGE.md +0 -219
  36. package/docs/QUICK_START.md +0 -240
  37. package/eslint.config.js +0 -33
  38. package/n8n/ai-agent-safety-monitor.json +0 -355
  39. package/openapi.json +0 -1259
  40. package/scripts/check-version-sync.mjs +0 -69
  41. package/scripts/generate-jwt.ts +0 -121
  42. package/src/config.ts +0 -440
  43. package/src/haClient/automations.ts +0 -261
  44. package/src/haClient/calendars.ts +0 -94
  45. package/src/haClient/config.ts +0 -52
  46. package/src/haClient/devices.ts +0 -653
  47. package/src/haClient/entities.ts +0 -128
  48. package/src/haClient/events.ts +0 -689
  49. package/src/haClient/history.ts +0 -73
  50. package/src/haClient/index.ts +0 -336
  51. package/src/haClient/request.ts +0 -142
  52. package/src/haClient/services.ts +0 -45
  53. package/src/haClient/states.ts +0 -60
  54. package/src/haClient/updates.ts +0 -58
  55. package/src/haClient/wsCommand.ts +0 -44
  56. package/src/index.ts +0 -164
  57. package/src/localAI/index.ts +0 -88
  58. package/src/localAI/prompts.ts +0 -50
  59. package/src/localAI/providers/base.ts +0 -120
  60. package/src/localAI/providers/ollama.ts +0 -78
  61. package/src/localAI/providers/openai.ts +0 -86
  62. package/src/localAI/types.ts +0 -67
  63. package/src/omadaClient/auth.ts +0 -178
  64. package/src/omadaClient/client.ts +0 -337
  65. package/src/omadaClient/device.ts +0 -118
  66. package/src/omadaClient/index.ts +0 -310
  67. package/src/omadaClient/network.ts +0 -284
  68. package/src/omadaClient/request.ts +0 -365
  69. package/src/omadaClient/security.ts +0 -51
  70. package/src/omadaClient/site.ts +0 -344
  71. package/src/ownerQuestions/service.ts +0 -536
  72. package/src/permissions/index.ts +0 -143
  73. package/src/resources/index.ts +0 -213
  74. package/src/server/auth.ts +0 -175
  75. package/src/server/chatFace.ts +0 -789
  76. package/src/server/chatSlice.ts +0 -57
  77. package/src/server/common.ts +0 -170
  78. package/src/server/eventSubscription.ts +0 -343
  79. package/src/server/http.ts +0 -626
  80. package/src/server/instructions.ts +0 -97
  81. package/src/server/rateLimiter.ts +0 -227
  82. package/src/server/stdio.ts +0 -41
  83. package/src/server/stream.ts +0 -205
  84. package/src/tools/ai/analyzeSensors.ts +0 -33
  85. package/src/tools/ai/index.ts +0 -20
  86. package/src/tools/common.ts +0 -442
  87. package/src/tools/homeassistant/askOwner.ts +0 -133
  88. package/src/tools/homeassistant/automationControls.ts +0 -66
  89. package/src/tools/homeassistant/calendar.ts +0 -97
  90. package/src/tools/homeassistant/callService.ts +0 -25
  91. package/src/tools/homeassistant/controlClimate.ts +0 -68
  92. package/src/tools/homeassistant/controlCover.ts +0 -48
  93. package/src/tools/homeassistant/controlFan.ts +0 -44
  94. package/src/tools/homeassistant/controlLight.ts +0 -38
  95. package/src/tools/homeassistant/controlMediaPlayer.ts +0 -50
  96. package/src/tools/homeassistant/createAutomation.ts +0 -35
  97. package/src/tools/homeassistant/deleteAutomation.ts +0 -24
  98. package/src/tools/homeassistant/entityAction.ts +0 -35
  99. package/src/tools/homeassistant/entityGuard.ts +0 -28
  100. package/src/tools/homeassistant/getAllSensors.ts +0 -46
  101. package/src/tools/homeassistant/getAutomationTrace.ts +0 -30
  102. package/src/tools/homeassistant/getDomainSummary.ts +0 -29
  103. package/src/tools/homeassistant/getEntitiesByDomain.ts +0 -56
  104. package/src/tools/homeassistant/getHistory.ts +0 -24
  105. package/src/tools/homeassistant/getState.ts +0 -24
  106. package/src/tools/homeassistant/getStates.ts +0 -46
  107. package/src/tools/homeassistant/getVersion.ts +0 -21
  108. package/src/tools/homeassistant/index.ts +0 -121
  109. package/src/tools/homeassistant/listAutomations.ts +0 -35
  110. package/src/tools/homeassistant/listEntities.ts +0 -44
  111. package/src/tools/homeassistant/listPersons.ts +0 -46
  112. package/src/tools/homeassistant/sceneAndScript.ts +0 -53
  113. package/src/tools/homeassistant/searchEntities.ts +0 -58
  114. package/src/tools/homeassistant/sendNotification.ts +0 -179
  115. package/src/tools/homeassistant/systemTools.ts +0 -52
  116. package/src/tools/homeassistant/triggerAutomation.ts +0 -24
  117. package/src/tools/index.ts +0 -78
  118. package/src/tools/infra/actions.ts +0 -95
  119. package/src/tools/infra/index.ts +0 -138
  120. package/src/tools/omada/blockClient.ts +0 -28
  121. package/src/tools/omada/cyclePoePort.ts +0 -32
  122. package/src/tools/omada/deleteClient.ts +0 -27
  123. package/src/tools/omada/disableClientRateLimit.ts +0 -23
  124. package/src/tools/omada/getClient.ts +0 -14
  125. package/src/tools/omada/getDevice.ts +0 -14
  126. package/src/tools/omada/getFirewallSetting.ts +0 -14
  127. package/src/tools/omada/getInternetInfo.ts +0 -14
  128. package/src/tools/omada/getLanNetworkList.ts +0 -14
  129. package/src/tools/omada/getLanProfileList.ts +0 -14
  130. package/src/tools/omada/getPortForwardingStatus.ts +0 -26
  131. package/src/tools/omada/getRateLimitProfiles.ts +0 -14
  132. package/src/tools/omada/getSiteNtpStatus.ts +0 -14
  133. package/src/tools/omada/getSsidDetail.ts +0 -22
  134. package/src/tools/omada/getSsidList.ts +0 -21
  135. package/src/tools/omada/getSwitchStackDetail.ts +0 -14
  136. package/src/tools/omada/getThreatList.ts +0 -42
  137. package/src/tools/omada/getWlanGroupList.ts +0 -14
  138. package/src/tools/omada/graph.ts +0 -187
  139. package/src/tools/omada/index.ts +0 -153
  140. package/src/tools/omada/listBlockedClients.ts +0 -17
  141. package/src/tools/omada/listClients.ts +0 -14
  142. package/src/tools/omada/listClientsActivity.ts +0 -25
  143. package/src/tools/omada/listClientsPastConnections.ts +0 -44
  144. package/src/tools/omada/listDevices.ts +0 -14
  145. package/src/tools/omada/listDevicesStats.ts +0 -41
  146. package/src/tools/omada/listMostActiveClients.ts +0 -14
  147. package/src/tools/omada/listSites.ts +0 -18
  148. package/src/tools/omada/namespace.ts +0 -1164
  149. package/src/tools/omada/searchDevices.ts +0 -20
  150. package/src/tools/omada/setClientRateLimit.ts +0 -26
  151. package/src/tools/omada/setClientRateLimitProfile.ts +0 -25
  152. package/src/tools/omada/setSiteNtpServers.ts +0 -36
  153. package/src/tools/omada/setSsidEnabled.ts +0 -38
  154. package/src/tools/omada/unblockClient.ts +0 -26
  155. package/src/tools/types.ts +0 -11
  156. package/src/tools/utils/pagination-schema.ts +0 -27
  157. package/src/types/ai/index.ts +0 -11
  158. package/src/types/ai/types.ts +0 -67
  159. package/src/types/homeassistant/automations.ts +0 -43
  160. package/src/types/homeassistant/calendars.ts +0 -26
  161. package/src/types/homeassistant/config.ts +0 -14
  162. package/src/types/homeassistant/entities.ts +0 -14
  163. package/src/types/homeassistant/errors.ts +0 -33
  164. package/src/types/homeassistant/haConfig.ts +0 -7
  165. package/src/types/homeassistant/history.ts +0 -13
  166. package/src/types/homeassistant/index.ts +0 -46
  167. package/src/types/homeassistant/request.ts +0 -6
  168. package/src/types/homeassistant/serviceCall.ts +0 -18
  169. package/src/types/homeassistant/states.ts +0 -58
  170. package/src/types/homeassistant/updates.ts +0 -28
  171. package/src/types/index.ts +0 -12
  172. package/src/types/omada/activeClientInfo.ts +0 -17
  173. package/src/types/omada/clientActivity.ts +0 -32
  174. package/src/types/omada/clientBlockStatus.ts +0 -11
  175. package/src/types/omada/clientPastConnection.ts +0 -82
  176. package/src/types/omada/index.ts +0 -14
  177. package/src/types/omada/omadaApiResponse.ts +0 -5
  178. package/src/types/omada/omadaClientInfo.ts +0 -11
  179. package/src/types/omada/omadaDeviceInfo.ts +0 -11
  180. package/src/types/omada/omadaDeviceStats.ts +0 -31
  181. package/src/types/omada/omadaSiteSummary.ts +0 -5
  182. package/src/types/omada/oswStackDetail.ts +0 -11
  183. package/src/types/omada/paginatedResult.ts +0 -6
  184. package/src/types/omada/rateLimitProfile.ts +0 -58
  185. package/src/types/omada/threatInfo.ts +0 -38
  186. package/src/types/omada/tokenResult.ts +0 -6
  187. package/src/types.ts +0 -2
  188. package/src/utils/config-validations.ts +0 -68
  189. package/src/utils/jwt.ts +0 -112
  190. package/src/utils/logger.ts +0 -121
  191. package/src/utils/plainText.ts +0 -48
  192. package/src/utils/redact.ts +0 -59
  193. package/src/utils/sanitizeError.ts +0 -18
  194. package/src/utils/tlsDispatcher.ts +0 -69
  195. package/src/version.ts +0 -5
  196. package/tests/bdd/automation-creation.spec.ts +0 -214
  197. package/tests/bdd/automation-management.spec.ts +0 -267
  198. package/tests/bdd/calendar.spec.ts +0 -190
  199. package/tests/bdd/configuration.spec.ts +0 -283
  200. package/tests/bdd/device-control.spec.ts +0 -344
  201. package/tests/bdd/entity-listing.spec.ts +0 -208
  202. package/tests/bdd/entity-search.spec.ts +0 -183
  203. package/tests/bdd/history-retrieval.spec.ts +0 -195
  204. package/tests/bdd/notifications.spec.ts +0 -238
  205. package/tests/bdd/service-calls.spec.ts +0 -144
  206. package/tests/bdd/state-retrieval.spec.ts +0 -204
  207. package/tests/bench/hotpaths.bench.ts +0 -68
  208. package/tests/config.test.ts +0 -355
  209. package/tests/features/automation-creation.feature +0 -36
  210. package/tests/features/automation-management.feature +0 -54
  211. package/tests/features/calendar.feature +0 -37
  212. package/tests/features/configuration.feature +0 -70
  213. package/tests/features/device-control.feature +0 -100
  214. package/tests/features/entity-listing.feature +0 -51
  215. package/tests/features/entity-search.feature +0 -44
  216. package/tests/features/history-retrieval.feature +0 -38
  217. package/tests/features/notifications.feature +0 -37
  218. package/tests/features/service-calls.feature +0 -35
  219. package/tests/features/state-retrieval.feature +0 -37
  220. package/tests/fixtures/openapi-default-slice.json +0 -290
  221. package/tests/haClient/automationTrace.test.ts +0 -59
  222. package/tests/haClient/automations.test.ts +0 -149
  223. package/tests/haClient/config.test.ts +0 -117
  224. package/tests/haClient/entities.test.ts +0 -188
  225. package/tests/haClient/events.test.ts +0 -455
  226. package/tests/haClient/eventsTls.test.ts +0 -52
  227. package/tests/haClient/history.test.ts +0 -228
  228. package/tests/haClient/index.test.ts +0 -243
  229. package/tests/haClient/request.test.ts +0 -217
  230. package/tests/haClient/services.test.ts +0 -120
  231. package/tests/haClient/states.test.ts +0 -119
  232. package/tests/haClient/updates.test.ts +0 -193
  233. package/tests/index.test.ts +0 -160
  234. package/tests/integration/homeassistant.test.ts +0 -144
  235. package/tests/integration/omada.test.ts +0 -167
  236. package/tests/integration/server.test.ts +0 -151
  237. package/tests/localAI/index.test.ts +0 -177
  238. package/tests/localAI/ollama.test.ts +0 -187
  239. package/tests/localAI/openai.test.ts +0 -257
  240. package/tests/omadaClient/actions.test.ts +0 -161
  241. package/tests/omadaClient/clientBlocks.test.ts +0 -170
  242. package/tests/omadaClient/ntp.test.ts +0 -381
  243. package/tests/omadaClient/site.test.ts +0 -37
  244. package/tests/ownerQuestions/service.test.ts +0 -499
  245. package/tests/ownerQuestions/tools.test.ts +0 -186
  246. package/tests/permissions/index.test.ts +0 -217
  247. package/tests/resources/index.test.ts +0 -288
  248. package/tests/scripts/generateJwt.test.ts +0 -44
  249. package/tests/security/accessControl.test.ts +0 -223
  250. package/tests/security/authentication.test.ts +0 -117
  251. package/tests/security/configuration.test.ts +0 -170
  252. package/tests/security/cryptoTransport.test.ts +0 -172
  253. package/tests/security/dependencies.test.ts +0 -152
  254. package/tests/security/injection.test.ts +0 -117
  255. package/tests/security/insecureDesign.test.ts +0 -121
  256. package/tests/security/logging.test.ts +0 -131
  257. package/tests/security/resourceConsumption.test.ts +0 -142
  258. package/tests/server/auth.test.ts +0 -249
  259. package/tests/server/chatFace.test.ts +0 -320
  260. package/tests/server/common.test.ts +0 -84
  261. package/tests/server/http.test.ts +0 -561
  262. package/tests/server/instructions.test.ts +0 -152
  263. package/tests/server/memoizeJsonSchema.test.ts +0 -71
  264. package/tests/server/stdio.test.ts +0 -99
  265. package/tests/server/stream.test.ts +0 -285
  266. package/tests/server/toolVisibility.http.test.ts +0 -111
  267. package/tests/server/toolVisibility.test.ts +0 -129
  268. package/tests/tools/ai/analyzeSensors.test.ts +0 -227
  269. package/tests/tools/common.test.ts +0 -123
  270. package/tests/tools/homeassistant/handlers.test.ts +0 -872
  271. package/tests/tools/infra/invokeAction.test.ts +0 -280
  272. package/tests/tools/omada/graph.test.ts +0 -385
  273. package/tests/tools/omada/handlers.test.ts +0 -897
  274. package/tests/tools/omada/namespace.test.ts +0 -483
  275. package/tests/tools/registry.test.ts +0 -168
  276. package/tests/utils/config-validations.test.ts +0 -352
  277. package/tests/utils/jwt.test.ts +0 -115
  278. package/tests/utils/logger.test.ts +0 -327
  279. package/tests/utils/redact.test.ts +0 -105
  280. package/tsconfig.json +0 -24
  281. package/vitest.config.ts +0 -38
@@ -1,536 +0,0 @@
1
- /**
2
- * Owner questions: ask the owner a yes/no-style question as an actionable phone
3
- * notification and collect the button they tap.
4
- *
5
- * Stateless by design: everything needed to recognise a question travels in
6
- * signed identifiers, so a server restart does not lose open questions.
7
- * - The requestId given to the caller carries the question, title, buttons,
8
- * target, expiry, the asking token's `sub` and a random nonce, signed with a
9
- * key derived from the server secret.
10
- * - Each button's action id carries the nonce, the button index, the expiry,
11
- * the target and its own signature, so a tap that arrives after a restart is
12
- * still verified and attributed. The secret never leaves the server, and the
13
- * requestId is not enough to forge a button id.
14
- * Only answers (and cancellations) are kept in memory until they are read. A tap
15
- * that lands while the server is down is lost; the question then expires as NO.
16
- *
17
- * A tap counts only if Home Assistant reports it as coming from a mobile app
18
- * webhook (origin REMOTE, with a user), so an automation that fires a fake
19
- * `mobile_app_notification_action` event (origin LOCAL) cannot approve anything.
20
- * With `allowedUserIds` (MCP_OWNER_QUESTIONS_HA_USERS) only those HA users can answer.
21
- *
22
- * Known limit, accepted: cancellations and answers are remembered in memory
23
- * only, while a button's signed id stays valid until the question expires. After
24
- * a restart, re-sending an old button id through Home Assistant's own API (which
25
- * needs an HA login, and the id from HA's history) could answer a question that
26
- * had been cancelled or denied. The phone itself no longer shows the buttons.
27
- * Anyone with that access can already control the house directly.
28
- */
29
-
30
- import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
31
-
32
- import { logger } from '../utils/logger.js';
33
-
34
- export const ACTION_EVENT = 'mobile_app_notification_action';
35
-
36
- export const LIMITS = {
37
- questionChars: 256,
38
- titleChars: 60,
39
- buttonChars: 20,
40
- maxButtons: 3,
41
- minTimeoutSeconds: 60,
42
- maxTimeoutSeconds: 7 * 24 * 60 * 60,
43
- defaultTimeoutSeconds: 600,
44
- maxWaitSeconds: 50,
45
- maxOpenPerCaller: 3,
46
- maxPerCallerPerHour: 30,
47
- } as const;
48
-
49
- const REQUEST_PREFIX = 'q1';
50
- const ACTION_PREFIX = 'MCPASK';
51
- /** How long the confirmation that replaces an answered question stays on the phone. */
52
- const CONFIRMATION_SECONDS = 300;
53
- /** Outcomes stay readable this long after their question expired. */
54
- const OUTCOME_RETENTION_MS = 24 * 60 * 60 * 1000;
55
- const SWEEP_INTERVAL_MS = 10 * 60 * 1000;
56
-
57
- export interface AskOwnerInput {
58
- question: string;
59
- title: string;
60
- buttons: string[];
61
- target: string;
62
- timeoutSeconds: number;
63
- channel?: string;
64
- importance?: 'high' | 'default' | 'low' | 'min';
65
- }
66
-
67
- export type OwnerAnswer =
68
- | {
69
- status: 'answered';
70
- index: number;
71
- button: string;
72
- answeredAt: string;
73
- answeredBy: string;
74
- question: string;
75
- buttons: string[];
76
- }
77
- | { status: 'pending'; expiresAt: string; question: string; buttons: string[] }
78
- | { status: 'timeout'; expiredAt: string; question: string; buttons: string[] }
79
- | { status: 'cancelled'; cancelledAt: string; question: string; buttons: string[] }
80
- | { status: 'unknown'; reason: string };
81
-
82
- /** Signed contents of a requestId. */
83
- interface QuestionPayload {
84
- v: 1;
85
- n: string;
86
- s: string;
87
- t: string;
88
- e: number;
89
- b: string[];
90
- q: string;
91
- ti: string;
92
- }
93
-
94
- type Outcome =
95
- | { status: 'answered'; index: number; answeredAt: number; answeredBy: string }
96
- | { status: 'cancelled'; cancelledAt: number };
97
-
98
- export interface OwnerQuestionDeps {
99
- /** Calls `notify.<target>`. */
100
- notify: (target: string, serviceData: Record<string, unknown>) => Promise<unknown>;
101
- /**
102
- * Subscribes to a Home Assistant event type. Rejects if Home Assistant refuses
103
- * it now; `onRejected` reports a refusal seen later (e.g. after a reconnect).
104
- */
105
- subscribe: (
106
- eventType: string,
107
- callback: (event: ActionEvent) => void,
108
- onRejected: (error: Error) => void
109
- ) => Promise<unknown>;
110
- /** Key material for signing (the server's auth secret); a random key is used when absent. */
111
- secret?: string;
112
- /** HA user ids allowed to answer; empty or absent = any HA user. */
113
- allowedUserIds?: string[];
114
- now?: () => number;
115
- }
116
-
117
- export interface ActionEvent {
118
- event_type: string;
119
- data: { action?: unknown; [key: string]: unknown };
120
- origin: string;
121
- time_fired?: string;
122
- context: { user_id: string | null };
123
- }
124
-
125
- const b64url = (buffer: Buffer): string => buffer.toString('base64url');
126
-
127
- export class OwnerQuestionService {
128
- private readonly key: Buffer;
129
- private readonly now: () => number;
130
- /** Final outcomes by nonce, until the question's expiry passes. */
131
- private readonly outcomes = new Map<string, Outcome & { expiresAt: number }>();
132
- /** Open questions per caller sub: nonce -> expiry (ms). */
133
- private readonly open = new Map<string, Map<string, number>>();
134
- /** Ask timestamps per caller sub, for the hourly cap. */
135
- private readonly asked = new Map<string, number[]>();
136
- /** Pollers waiting for an outcome, by nonce. */
137
- private readonly waiters = new Map<string, Set<() => void>>();
138
- /** Expiry timers by nonce (best effort: lost on restart; the phone also times out on its own). */
139
- private readonly expiryTimers = new Map<string, ReturnType<typeof setTimeout>>();
140
- /** Labels and target of open questions, for nicer confirmations while the server stays up. */
141
- private readonly known = new Map<string, { target: string; title: string; buttons: string[]; expiresAt: number }>();
142
- private sweepTimer: ReturnType<typeof setInterval> | undefined;
143
- /** Why questions cannot be answered right now (Home Assistant refused the tap subscription). */
144
- private unavailable: string | undefined;
145
-
146
- constructor(private readonly deps: OwnerQuestionDeps) {
147
- this.now = deps.now ?? Date.now;
148
- if (deps.secret) {
149
- this.key = createHmac('sha256', deps.secret).update('mcp-ha-connect/owner-questions/v1').digest();
150
- } else {
151
- this.key = randomBytes(32);
152
- logger.warn('Owner questions: no server secret configured; open questions will not survive a restart');
153
- }
154
- }
155
-
156
- async start(): Promise<void> {
157
- await this.deps.subscribe(
158
- ACTION_EVENT,
159
- (event) => this.handleActionEvent(event),
160
- () => {
161
- this.unavailable =
162
- 'Home Assistant refused the subscription to notification taps (the server needs an admin Home Assistant token)';
163
- logger.error(`Owner questions unavailable: ${this.unavailable}`);
164
- }
165
- );
166
- this.sweepTimer = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS);
167
- this.sweepTimer.unref?.();
168
- logger.info('Owner questions: listening for notification taps');
169
- if (!this.deps.allowedUserIds?.length) {
170
- logger.warn('Owner questions: any Home Assistant user can answer; set MCP_OWNER_QUESTIONS_HA_USERS to restrict it');
171
- }
172
- }
173
-
174
- stop(): void {
175
- clearInterval(this.sweepTimer);
176
- for (const timer of this.expiryTimers.values()) clearTimeout(timer);
177
- this.expiryTimers.clear();
178
- }
179
-
180
- async ask(input: AskOwnerInput, caller: string): Promise<{ requestId: string; expiresAt: string }> {
181
- if (this.unavailable) {
182
- throw new Error(`Owner questions are unavailable: ${this.unavailable}`);
183
- }
184
- const now = this.now();
185
- this.enforceRateLimits(caller, now);
186
-
187
- const nonce = b64url(randomBytes(16));
188
- const expiresAtSec = Math.floor(now / 1000) + input.timeoutSeconds;
189
- const payload: QuestionPayload = {
190
- v: 1,
191
- n: nonce,
192
- s: caller,
193
- t: input.target,
194
- e: expiresAtSec,
195
- b: input.buttons,
196
- q: input.question,
197
- ti: input.title,
198
- };
199
- const requestId = this.signRequest(payload);
200
-
201
- const data: Record<string, unknown> = {
202
- tag: this.tagFor(nonce),
203
- actions: input.buttons.map((title, index) => ({
204
- action: this.actionId(nonce, index, expiresAtSec, input.target),
205
- title,
206
- })),
207
- // Stays until answered: not dismissed by tapping the body (sticky) or by
208
- // swiping (persistent, needs the tag); the phone removes it at expiry.
209
- sticky: true,
210
- persistent: true,
211
- priority: 'high',
212
- ttl: 0,
213
- timeout: input.timeoutSeconds,
214
- };
215
- if (input.channel) data.channel = input.channel;
216
- if (input.importance) data.importance = input.importance;
217
-
218
- // Reserve the slot before sending, so concurrent calls cannot all pass the limits.
219
- this.reserve(caller, nonce, expiresAtSec * 1000, now);
220
- try {
221
- await this.deps.notify(input.target, { title: input.title, message: input.question, data });
222
- } catch {
223
- // The underlying error can carry request details; neither the caller
224
- // nor this log gets its text.
225
- this.release(caller, nonce, now);
226
- logger.warn('Owner questions: sending the notification failed', { target: input.target });
227
- throw new Error(`Could not send the notification through Home Assistant (is '${input.target}' a valid notify target?)`);
228
- }
229
-
230
- this.known.set(nonce, { target: input.target, title: input.title, buttons: input.buttons, expiresAt: expiresAtSec * 1000 });
231
- // Count from now, after delivery: the signed expiry is what matters.
232
- this.scheduleExpiry(nonce, Math.max(expiresAtSec * 1000 - this.now(), 0));
233
- logger.info('Owner question sent', { caller, target: input.target, buttons: input.buttons.length, expiresAt: expiresAtSec });
234
-
235
- return { requestId, expiresAt: new Date(expiresAtSec * 1000).toISOString() };
236
- }
237
-
238
- async getAnswer(requestId: string, caller: string, waitSeconds: number): Promise<OwnerAnswer> {
239
- const payload = this.verifyRequest(requestId);
240
- if (!payload) {
241
- return { status: 'unknown', reason: 'Not a valid question id from this server (or the server secret changed)' };
242
- }
243
- this.assertOwner(payload, caller);
244
-
245
- const settled = this.settledAnswer(payload);
246
- if (settled || waitSeconds <= 0) {
247
- return settled ?? this.pendingAnswer(payload);
248
- }
249
-
250
- const untilExpiry = payload.e * 1000 - this.now();
251
- await this.waitForOutcome(payload.n, Math.min(waitSeconds * 1000, Math.max(untilExpiry, 0)));
252
- return this.settledAnswer(payload) ?? this.pendingAnswer(payload);
253
- }
254
-
255
- async cancel(requestId: string, caller: string): Promise<OwnerAnswer> {
256
- const payload = this.verifyRequest(requestId);
257
- if (!payload) {
258
- return { status: 'unknown', reason: 'Not a valid question id from this server (or the server secret changed)' };
259
- }
260
- this.assertOwner(payload, caller);
261
-
262
- const settled = this.settledAnswer(payload);
263
- if (settled) {
264
- return settled;
265
- }
266
- this.settle(payload.n, { status: 'cancelled', cancelledAt: this.now() }, payload.e * 1000);
267
- await this.replaceNotification(payload.n, payload.t, payload.ti, 'Cancelled - no answer needed');
268
- return this.settledAnswer(payload)!;
269
- }
270
-
271
- /** Handle a `mobile_app_notification_action` event. Exposed for tests. */
272
- handleActionEvent(event: ActionEvent): void {
273
- const parsed = this.parseActionId(event.data.action);
274
- if (!parsed) {
275
- return; // not one of ours
276
- }
277
- if (event.origin !== 'REMOTE' || !event.context.user_id) {
278
- logger.warn('Owner questions: ignored a tap not coming from a mobile app', { origin: event.origin });
279
- return;
280
- }
281
- if (this.deps.allowedUserIds?.length && !this.deps.allowedUserIds.includes(event.context.user_id)) {
282
- logger.warn('Owner questions: ignored a tap from a Home Assistant user that may not answer');
283
- return;
284
- }
285
- const { nonce, index, expiresAtSec, target } = parsed;
286
- const known = this.known.get(nonce);
287
- if (this.now() >= expiresAtSec * 1000) {
288
- logger.info('Owner questions: ignored a tap after expiry');
289
- void this.replaceNotification(nonce, target, known?.title ?? 'Question', 'Expired - the answer was not recorded');
290
- return;
291
- }
292
- if (this.outcomes.has(nonce)) {
293
- return; // first tap wins
294
- }
295
- this.settle(nonce, { status: 'answered', index, answeredAt: this.now(), answeredBy: event.context.user_id }, expiresAtSec * 1000);
296
- logger.info('Owner question answered', { index });
297
- const label = known?.buttons[index];
298
- void this.replaceNotification(nonce, target, known?.title ?? 'Question', label ? `Answered: ${label}` : 'Answer recorded');
299
- }
300
-
301
- // ---- internals -------------------------------------------------------
302
-
303
- private signRequest(payload: QuestionPayload): string {
304
- const body = `${REQUEST_PREFIX}.${b64url(Buffer.from(JSON.stringify(payload), 'utf8'))}`;
305
- return `${body}.${b64url(this.hmac(`request|${body}`))}`;
306
- }
307
-
308
- private verifyRequest(requestId: string): QuestionPayload | null {
309
- const parts = typeof requestId === 'string' ? requestId.split('.') : [];
310
- if (parts.length !== 3 || parts[0] !== REQUEST_PREFIX) {
311
- return null;
312
- }
313
- const body = `${parts[0]}.${parts[1]}`;
314
- if (!this.safeEqual(parts[2], b64url(this.hmac(`request|${body}`)))) {
315
- return null;
316
- }
317
- try {
318
- const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) as QuestionPayload;
319
- return payload.v === 1 ? payload : null;
320
- } catch {
321
- return null;
322
- }
323
- }
324
-
325
- private actionId(nonce: string, index: number, expiresAtSec: number, target: string): string {
326
- const sig = b64url(this.hmac(`action|${nonce}|${index}|${expiresAtSec}|${target}`)).slice(0, 22);
327
- return `${ACTION_PREFIX}.${nonce}.${index}.${expiresAtSec}.${target}.${sig}`;
328
- }
329
-
330
- private parseActionId(action: unknown): { nonce: string; index: number; expiresAtSec: number; target: string } | null {
331
- if (typeof action !== 'string' || !action.startsWith(`${ACTION_PREFIX}.`)) {
332
- return null;
333
- }
334
- const match = /^MCPASK\.([A-Za-z0-9_-]{22})\.([0-2])\.(\d{1,12})\.(mobile_app_[a-z0-9_]{1,64})\.([A-Za-z0-9_-]{22})$/.exec(action);
335
- if (!match) {
336
- logger.warn('Owner questions: ignored a malformed action id');
337
- return null;
338
- }
339
- const [, nonce, index, expires, target, sig] = match;
340
- const expected = b64url(this.hmac(`action|${nonce}|${index}|${expires}|${target}`)).slice(0, 22);
341
- if (!this.safeEqual(sig, expected)) {
342
- logger.warn('Owner questions: ignored a tap with an invalid signature');
343
- return null;
344
- }
345
- return { nonce, index: Number(index), expiresAtSec: Number(expires), target };
346
- }
347
-
348
- private hmac(message: string): Buffer {
349
- return createHmac('sha256', this.key).update(message).digest();
350
- }
351
-
352
- private safeEqual(a: string, b: string): boolean {
353
- const left = Buffer.from(a);
354
- const right = Buffer.from(b);
355
- return left.length === right.length && timingSafeEqual(left, right);
356
- }
357
-
358
- private tagFor(nonce: string): string {
359
- return `mcpask-${nonce}`;
360
- }
361
-
362
- private assertOwner(payload: QuestionPayload, caller: string): void {
363
- if (payload.s !== caller) {
364
- throw new Error('This question was asked by a different client');
365
- }
366
- }
367
-
368
- private settledAnswer(payload: QuestionPayload): OwnerAnswer | null {
369
- const base = { question: payload.q, buttons: payload.b };
370
- const outcome = this.outcomes.get(payload.n);
371
- if (outcome?.status === 'answered') {
372
- return {
373
- status: 'answered',
374
- index: outcome.index,
375
- button: payload.b[outcome.index] ?? `#${outcome.index}`,
376
- answeredAt: new Date(outcome.answeredAt).toISOString(),
377
- answeredBy: outcome.answeredBy,
378
- ...base,
379
- };
380
- }
381
- if (outcome?.status === 'cancelled') {
382
- return { status: 'cancelled', cancelledAt: new Date(outcome.cancelledAt).toISOString(), ...base };
383
- }
384
- if (this.now() >= payload.e * 1000) {
385
- return { status: 'timeout', expiredAt: new Date(payload.e * 1000).toISOString(), ...base };
386
- }
387
- return null;
388
- }
389
-
390
- private pendingAnswer(payload: QuestionPayload): OwnerAnswer {
391
- return { status: 'pending', expiresAt: new Date(payload.e * 1000).toISOString(), question: payload.q, buttons: payload.b };
392
- }
393
-
394
- private settle(nonce: string, outcome: Outcome, expiresAtMs: number): void {
395
- this.outcomes.set(nonce, { ...outcome, expiresAt: expiresAtMs });
396
- this.known.delete(nonce);
397
- for (const questions of this.open.values()) {
398
- questions.delete(nonce);
399
- }
400
- const timer = this.expiryTimers.get(nonce);
401
- if (timer) {
402
- clearTimeout(timer);
403
- this.expiryTimers.delete(nonce);
404
- }
405
- this.wake(nonce);
406
- }
407
-
408
- private wake(nonce: string): void {
409
- for (const resolve of this.waiters.get(nonce) ?? []) {
410
- resolve();
411
- }
412
- this.waiters.delete(nonce);
413
- }
414
-
415
- private waitForOutcome(nonce: string, ms: number): Promise<void> {
416
- return new Promise((resolve) => {
417
- const waiters = this.waiters.get(nonce) ?? new Set<() => void>();
418
- this.waiters.set(nonce, waiters);
419
- const done = () => {
420
- clearTimeout(timer);
421
- waiters.delete(done);
422
- if (waiters.size === 0 && this.waiters.get(nonce) === waiters) {
423
- this.waiters.delete(nonce);
424
- }
425
- resolve();
426
- };
427
- const timer = setTimeout(done, ms);
428
- waiters.add(done);
429
- });
430
- }
431
-
432
- private scheduleExpiry(nonce: string, ms: number): void {
433
- // Node timers overflow above ~24.8 days; questions last at most 7 days.
434
- const timer = setTimeout(() => {
435
- this.expiryTimers.delete(nonce);
436
- if (this.outcomes.has(nonce)) {
437
- return;
438
- }
439
- for (const questions of this.open.values()) {
440
- questions.delete(nonce);
441
- }
442
- this.wake(nonce);
443
- const known = this.known.get(nonce);
444
- this.known.delete(nonce);
445
- if (known) {
446
- void this.replaceNotification(nonce, known.target, known.title, 'Expired - treated as no');
447
- }
448
- }, ms);
449
- timer.unref?.();
450
- this.expiryTimers.set(nonce, timer);
451
- }
452
-
453
- private async replaceNotification(nonce: string, target: string, title: string, message: string): Promise<void> {
454
- try {
455
- // Same tag replaces the question; no actions, dismissible, gone after a while.
456
- await this.deps.notify(target, {
457
- title,
458
- message,
459
- data: { tag: this.tagFor(nonce), sticky: false, persistent: false, timeout: CONFIRMATION_SECONDS },
460
- });
461
- } catch {
462
- logger.warn('Owner questions: could not update the notification', { target });
463
- }
464
- }
465
-
466
- private enforceRateLimits(caller: string, now: number): void {
467
- const open = this.open.get(caller);
468
- if (open) {
469
- for (const [nonce, expiresAt] of open) {
470
- if (expiresAt <= now) open.delete(nonce);
471
- }
472
- if (open.size >= LIMITS.maxOpenPerCaller) {
473
- throw new Error(`Too many open questions (${open.size}); answer, cancel or wait for one to expire first`);
474
- }
475
- }
476
- const recent = (this.asked.get(caller) ?? []).filter((t) => t > now - 60 * 60 * 1000);
477
- if (recent.length >= LIMITS.maxPerCallerPerHour) {
478
- throw new Error(`Too many questions in the last hour (limit ${LIMITS.maxPerCallerPerHour})`);
479
- }
480
- this.asked.set(caller, recent);
481
- }
482
-
483
- private reserve(caller: string, nonce: string, expiresAtMs: number, now: number): void {
484
- const open = this.open.get(caller) ?? new Map<string, number>();
485
- open.set(nonce, expiresAtMs);
486
- this.open.set(caller, open);
487
- const asked = this.asked.get(caller) ?? [];
488
- asked.push(now);
489
- this.asked.set(caller, asked);
490
- }
491
-
492
- private release(caller: string, nonce: string, now: number): void {
493
- this.open.get(caller)?.delete(nonce);
494
- const asked = this.asked.get(caller);
495
- const index = asked?.lastIndexOf(now) ?? -1;
496
- if (asked && index >= 0) asked.splice(index, 1);
497
- }
498
-
499
- /**
500
- * Periodic cleanup, independent of new activity: outcomes a day after their
501
- * question expired (a caller polling that late sees "timeout"), labels of
502
- * expired questions, and rate-limit entries that no longer count.
503
- * Exposed for tests.
504
- */
505
- sweep(): void {
506
- const now = this.now();
507
- for (const [nonce, outcome] of this.outcomes) {
508
- if (outcome.expiresAt + OUTCOME_RETENTION_MS < now) this.outcomes.delete(nonce);
509
- }
510
- for (const [nonce, known] of this.known) {
511
- if (known.expiresAt < now) this.known.delete(nonce);
512
- }
513
- for (const [caller, open] of this.open) {
514
- for (const [nonce, expiresAt] of open) {
515
- if (expiresAt <= now) open.delete(nonce);
516
- }
517
- if (open.size === 0) this.open.delete(caller);
518
- }
519
- for (const [caller, asked] of this.asked) {
520
- const recent = asked.filter((t) => t > now - 60 * 60 * 1000);
521
- if (recent.length === 0) this.asked.delete(caller);
522
- else this.asked.set(caller, recent);
523
- }
524
- }
525
-
526
- /** Sizes of the in-memory state, for tests and diagnostics. */
527
- stateSize(): { outcomes: number; known: number; open: number; asked: number; waiters: number } {
528
- return {
529
- outcomes: this.outcomes.size,
530
- known: this.known.size,
531
- open: this.open.size,
532
- asked: this.asked.size,
533
- waiters: this.waiters.size,
534
- };
535
- }
536
- }
@@ -1,143 +0,0 @@
1
- /**
2
- * Permission System - Role-based access control with binary masks
3
- */
4
-
5
- import { logger } from '../utils/logger.js';
6
-
7
- /** Permission flags (powers of 2 for bitwise operations, sorted by criticality) */
8
- export const Permission = {
9
- ADMIN: 1, // System operations (restart, updates) - highest criticality
10
- CONFIGURE: 2, // Create/modify automations, scripts
11
- CONTROL: 4, // Control devices (lights, climate, etc.)
12
- QUERY: 8, // Read entity states, history, lists
13
- NOTIFY: 16, // Send notifications
14
- AI: 32, // Use AI analysis features
15
- } as const;
16
-
17
- export type PermissionFlag = (typeof Permission)[keyof typeof Permission];
18
-
19
- /** Role presets (combinations of permissions) */
20
- export const Role = {
21
- NONE: 0,
22
- READONLY: Permission.QUERY,
23
- OPERATOR: Permission.QUERY | Permission.CONTROL | Permission.NOTIFY,
24
- CONTRIBUTOR: Permission.QUERY | Permission.CONTROL | Permission.NOTIFY | Permission.CONFIGURE,
25
- ADMIN: Permission.QUERY | Permission.CONTROL | Permission.NOTIFY | Permission.CONFIGURE | Permission.AI | Permission.ADMIN,
26
- SUPERUSER: 0xFF, // All permissions
27
- } as const;
28
-
29
- export type RoleName = keyof typeof Role;
30
-
31
- /** User permission mapping from config */
32
- export interface UserPermission {
33
- sub: string;
34
- role?: RoleName;
35
- mask?: number; // Direct mask override
36
- }
37
-
38
- export interface PermissionsConfig {
39
- users: UserPermission[];
40
- defaultRole?: RoleName;
41
- }
42
-
43
- /**
44
- * Resolve a role name to its permission mask, case-insensitively.
45
- *
46
- * Config (and JSON) commonly use lowercase role names ("operator") while the
47
- * `Role` map is keyed uppercase ("OPERATOR"). Normalize here so BOTH the per-user
48
- * role and the `defaultRole` fall-back resolve consistently. An unknown/undefined
49
- * name fails closed to `Role.NONE` (never `undefined`, which downstream coerces to
50
- * a zero mask anyway but is easy to mishandle).
51
- */
52
- function resolveRoleName(name: string | undefined): number {
53
- if (!name) {
54
- return Role.NONE;
55
- }
56
- const key = name.toUpperCase() as RoleName;
57
- return Role[key] ?? Role.NONE;
58
- }
59
-
60
- /**
61
- * Get permission mask for a user by their JWT sub claim
62
- */
63
- export function getUserPermissions(sub: string | undefined, config: PermissionsConfig): number {
64
- const fallback = resolveRoleName(config.defaultRole);
65
-
66
- if (!sub) {
67
- return fallback;
68
- }
69
-
70
- const user = config.users.find((u) => u.sub === sub);
71
- if (!user) {
72
- return fallback;
73
- }
74
-
75
- // Direct mask takes precedence over role
76
- if (user.mask !== undefined) {
77
- return user.mask;
78
- }
79
-
80
- return resolveRoleName(user.role);
81
- }
82
-
83
- /**
84
- * Warning text when `defaultRole` grants anything, or `undefined` when it is NONE.
85
- *
86
- * `defaultRole` applies to every validly signed token whose `sub` is not listed in
87
- * `users` — so a non-NONE default silently gives unmapped clients real access. The
88
- * recommended setup is `defaultRole: none` plus one explicit `users` entry per client.
89
- */
90
- export function defaultRoleWarning(config: PermissionsConfig): string | undefined {
91
- const mask = resolveRoleName(config.defaultRole);
92
- if (mask === Role.NONE) {
93
- return undefined;
94
- }
95
- return (
96
- `MCP_PERMISSIONS_CONFIG defaultRole is "${config.defaultRole}": any valid token with an unmapped sub ` +
97
- `gets [${getPermissionNames(mask).join(', ')}]. Recommended: defaultRole "none" and one users[] entry per client.`
98
- );
99
- }
100
-
101
- /**
102
- * Check if a permission mask includes required permission
103
- */
104
- export function hasPermission(userMask: number, required: number): boolean {
105
- return (userMask & required) === required;
106
- }
107
-
108
- /**
109
- * Get human-readable permission names from mask
110
- */
111
- export function getPermissionNames(mask: number): string[] {
112
- const names: string[] = [];
113
- for (const [name, flag] of Object.entries(Permission)) {
114
- if ((mask & flag) === flag) {
115
- names.push(name);
116
- }
117
- }
118
- return names;
119
- }
120
-
121
- /**
122
- * Parse permissions config from environment JSON
123
- */
124
- export function parsePermissionsConfig(json: string | undefined): PermissionsConfig {
125
- if (!json) {
126
- return { users: [], defaultRole: 'NONE' };
127
- }
128
-
129
- try {
130
- const parsed = JSON.parse(json);
131
- return {
132
- users: Array.isArray(parsed.users) ? parsed.users : [],
133
- defaultRole: parsed.defaultRole ?? 'NONE',
134
- };
135
- } catch (error) {
136
- // Fail closed (empty config → every caller resolves to NONE), but make the
137
- // misconfiguration loud: a silent all-NONE is otherwise very hard to diagnose.
138
- logger.warn('MCP_PERMISSIONS_CONFIG is not valid JSON — ignoring it; all callers resolve to NONE (fail-closed)', {
139
- error: error instanceof Error ? error.message : String(error),
140
- });
141
- return { users: [], defaultRole: 'NONE' };
142
- }
143
- }