@coffeerunhobby/mcp-ha-connect 2.2.1 → 2.4.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 (293) hide show
  1. package/.env.example +1 -1
  2. package/README.md +24 -2
  3. package/dist/omadaClient/accessControl.d.ts +290 -0
  4. package/dist/omadaClient/accessControl.js +509 -0
  5. package/dist/omadaClient/accessControl.js.map +1 -0
  6. package/dist/omadaClient/client.d.ts +6 -1
  7. package/dist/omadaClient/client.js +12 -1
  8. package/dist/omadaClient/client.js.map +1 -1
  9. package/dist/omadaClient/diagnose.d.ts +68 -0
  10. package/dist/omadaClient/diagnose.js +164 -0
  11. package/dist/omadaClient/diagnose.js.map +1 -0
  12. package/dist/omadaClient/index.d.ts +9 -0
  13. package/dist/omadaClient/index.js +19 -0
  14. package/dist/omadaClient/index.js.map +1 -1
  15. package/dist/omadaClient/macGroups.d.ts +58 -0
  16. package/dist/omadaClient/macGroups.js +110 -0
  17. package/dist/omadaClient/macGroups.js.map +1 -0
  18. package/dist/omadaClient/serialize.d.ts +13 -0
  19. package/dist/omadaClient/serialize.js +29 -0
  20. package/dist/omadaClient/serialize.js.map +1 -0
  21. package/dist/omadaClient/timeRange.d.ts +41 -0
  22. package/dist/omadaClient/timeRange.js +109 -0
  23. package/dist/omadaClient/timeRange.js.map +1 -0
  24. package/dist/tools/infra/actions.d.ts +1 -1
  25. package/dist/tools/infra/actions.js +1 -1
  26. package/dist/tools/omada/accessControl.d.ts +151 -0
  27. package/dist/tools/omada/accessControl.js +158 -0
  28. package/dist/tools/omada/accessControl.js.map +1 -0
  29. package/dist/tools/omada/index.js +9 -0
  30. package/dist/tools/omada/index.js.map +1 -1
  31. package/dist/tools/omada/macGroups.d.ts +19 -0
  32. package/dist/tools/omada/macGroups.js +77 -0
  33. package/dist/tools/omada/macGroups.js.map +1 -0
  34. package/dist/tools/omada/namespace.js +16 -0
  35. package/dist/tools/omada/namespace.js.map +1 -1
  36. package/dist/version.d.ts +1 -1
  37. package/dist/version.js +1 -1
  38. package/package.json +5 -1
  39. package/.codex/review-rules.md +0 -16
  40. package/.dockerignore +0 -8
  41. package/.gitattributes +0 -30
  42. package/.github/workflows/ci.yml +0 -193
  43. package/AGENTS.md +0 -389
  44. package/Dockerfile +0 -50
  45. package/docker-compose.yml +0 -23
  46. package/docs/CHANGELOG.md +0 -341
  47. package/docs/COVERAGE.md +0 -219
  48. package/docs/QUICK_START.md +0 -240
  49. package/eslint.config.js +0 -33
  50. package/n8n/ai-agent-safety-monitor.json +0 -355
  51. package/openapi.json +0 -1259
  52. package/scripts/check-version-sync.mjs +0 -69
  53. package/scripts/generate-jwt.ts +0 -121
  54. package/src/config.ts +0 -440
  55. package/src/haClient/automations.ts +0 -261
  56. package/src/haClient/calendars.ts +0 -94
  57. package/src/haClient/config.ts +0 -52
  58. package/src/haClient/devices.ts +0 -653
  59. package/src/haClient/entities.ts +0 -128
  60. package/src/haClient/events.ts +0 -689
  61. package/src/haClient/history.ts +0 -73
  62. package/src/haClient/index.ts +0 -336
  63. package/src/haClient/request.ts +0 -142
  64. package/src/haClient/services.ts +0 -45
  65. package/src/haClient/states.ts +0 -60
  66. package/src/haClient/updates.ts +0 -58
  67. package/src/haClient/wsCommand.ts +0 -44
  68. package/src/index.ts +0 -164
  69. package/src/localAI/index.ts +0 -88
  70. package/src/localAI/prompts.ts +0 -50
  71. package/src/localAI/providers/base.ts +0 -120
  72. package/src/localAI/providers/ollama.ts +0 -78
  73. package/src/localAI/providers/openai.ts +0 -86
  74. package/src/localAI/types.ts +0 -67
  75. package/src/omadaClient/auth.ts +0 -178
  76. package/src/omadaClient/client.ts +0 -337
  77. package/src/omadaClient/device.ts +0 -118
  78. package/src/omadaClient/index.ts +0 -310
  79. package/src/omadaClient/network.ts +0 -284
  80. package/src/omadaClient/request.ts +0 -365
  81. package/src/omadaClient/security.ts +0 -51
  82. package/src/omadaClient/site.ts +0 -344
  83. package/src/ownerQuestions/service.ts +0 -536
  84. package/src/permissions/index.ts +0 -143
  85. package/src/resources/index.ts +0 -213
  86. package/src/server/auth.ts +0 -175
  87. package/src/server/chatFace.ts +0 -789
  88. package/src/server/chatSlice.ts +0 -57
  89. package/src/server/common.ts +0 -170
  90. package/src/server/eventSubscription.ts +0 -343
  91. package/src/server/http.ts +0 -626
  92. package/src/server/instructions.ts +0 -97
  93. package/src/server/rateLimiter.ts +0 -227
  94. package/src/server/stdio.ts +0 -41
  95. package/src/server/stream.ts +0 -205
  96. package/src/tools/ai/analyzeSensors.ts +0 -33
  97. package/src/tools/ai/index.ts +0 -20
  98. package/src/tools/common.ts +0 -442
  99. package/src/tools/homeassistant/askOwner.ts +0 -133
  100. package/src/tools/homeassistant/automationControls.ts +0 -66
  101. package/src/tools/homeassistant/calendar.ts +0 -97
  102. package/src/tools/homeassistant/callService.ts +0 -25
  103. package/src/tools/homeassistant/controlClimate.ts +0 -68
  104. package/src/tools/homeassistant/controlCover.ts +0 -48
  105. package/src/tools/homeassistant/controlFan.ts +0 -44
  106. package/src/tools/homeassistant/controlLight.ts +0 -38
  107. package/src/tools/homeassistant/controlMediaPlayer.ts +0 -50
  108. package/src/tools/homeassistant/createAutomation.ts +0 -35
  109. package/src/tools/homeassistant/deleteAutomation.ts +0 -24
  110. package/src/tools/homeassistant/entityAction.ts +0 -35
  111. package/src/tools/homeassistant/entityGuard.ts +0 -28
  112. package/src/tools/homeassistant/getAllSensors.ts +0 -46
  113. package/src/tools/homeassistant/getAutomationTrace.ts +0 -30
  114. package/src/tools/homeassistant/getDomainSummary.ts +0 -29
  115. package/src/tools/homeassistant/getEntitiesByDomain.ts +0 -56
  116. package/src/tools/homeassistant/getHistory.ts +0 -24
  117. package/src/tools/homeassistant/getState.ts +0 -24
  118. package/src/tools/homeassistant/getStates.ts +0 -46
  119. package/src/tools/homeassistant/getVersion.ts +0 -21
  120. package/src/tools/homeassistant/index.ts +0 -121
  121. package/src/tools/homeassistant/listAutomations.ts +0 -35
  122. package/src/tools/homeassistant/listEntities.ts +0 -44
  123. package/src/tools/homeassistant/listPersons.ts +0 -46
  124. package/src/tools/homeassistant/sceneAndScript.ts +0 -53
  125. package/src/tools/homeassistant/searchEntities.ts +0 -58
  126. package/src/tools/homeassistant/sendNotification.ts +0 -179
  127. package/src/tools/homeassistant/systemTools.ts +0 -52
  128. package/src/tools/homeassistant/triggerAutomation.ts +0 -24
  129. package/src/tools/index.ts +0 -78
  130. package/src/tools/infra/actions.ts +0 -95
  131. package/src/tools/infra/index.ts +0 -138
  132. package/src/tools/omada/blockClient.ts +0 -28
  133. package/src/tools/omada/cyclePoePort.ts +0 -32
  134. package/src/tools/omada/deleteClient.ts +0 -27
  135. package/src/tools/omada/disableClientRateLimit.ts +0 -23
  136. package/src/tools/omada/getClient.ts +0 -14
  137. package/src/tools/omada/getDevice.ts +0 -14
  138. package/src/tools/omada/getFirewallSetting.ts +0 -14
  139. package/src/tools/omada/getInternetInfo.ts +0 -14
  140. package/src/tools/omada/getLanNetworkList.ts +0 -14
  141. package/src/tools/omada/getLanProfileList.ts +0 -14
  142. package/src/tools/omada/getPortForwardingStatus.ts +0 -26
  143. package/src/tools/omada/getRateLimitProfiles.ts +0 -14
  144. package/src/tools/omada/getSiteNtpStatus.ts +0 -14
  145. package/src/tools/omada/getSsidDetail.ts +0 -22
  146. package/src/tools/omada/getSsidList.ts +0 -21
  147. package/src/tools/omada/getSwitchStackDetail.ts +0 -14
  148. package/src/tools/omada/getThreatList.ts +0 -42
  149. package/src/tools/omada/getWlanGroupList.ts +0 -14
  150. package/src/tools/omada/graph.ts +0 -187
  151. package/src/tools/omada/index.ts +0 -153
  152. package/src/tools/omada/listBlockedClients.ts +0 -17
  153. package/src/tools/omada/listClients.ts +0 -14
  154. package/src/tools/omada/listClientsActivity.ts +0 -25
  155. package/src/tools/omada/listClientsPastConnections.ts +0 -44
  156. package/src/tools/omada/listDevices.ts +0 -14
  157. package/src/tools/omada/listDevicesStats.ts +0 -41
  158. package/src/tools/omada/listMostActiveClients.ts +0 -14
  159. package/src/tools/omada/listSites.ts +0 -18
  160. package/src/tools/omada/namespace.ts +0 -1164
  161. package/src/tools/omada/searchDevices.ts +0 -20
  162. package/src/tools/omada/setClientRateLimit.ts +0 -26
  163. package/src/tools/omada/setClientRateLimitProfile.ts +0 -25
  164. package/src/tools/omada/setSiteNtpServers.ts +0 -36
  165. package/src/tools/omada/setSsidEnabled.ts +0 -38
  166. package/src/tools/omada/unblockClient.ts +0 -26
  167. package/src/tools/types.ts +0 -11
  168. package/src/tools/utils/pagination-schema.ts +0 -27
  169. package/src/types/ai/index.ts +0 -11
  170. package/src/types/ai/types.ts +0 -67
  171. package/src/types/homeassistant/automations.ts +0 -43
  172. package/src/types/homeassistant/calendars.ts +0 -26
  173. package/src/types/homeassistant/config.ts +0 -14
  174. package/src/types/homeassistant/entities.ts +0 -14
  175. package/src/types/homeassistant/errors.ts +0 -33
  176. package/src/types/homeassistant/haConfig.ts +0 -7
  177. package/src/types/homeassistant/history.ts +0 -13
  178. package/src/types/homeassistant/index.ts +0 -46
  179. package/src/types/homeassistant/request.ts +0 -6
  180. package/src/types/homeassistant/serviceCall.ts +0 -18
  181. package/src/types/homeassistant/states.ts +0 -58
  182. package/src/types/homeassistant/updates.ts +0 -28
  183. package/src/types/index.ts +0 -12
  184. package/src/types/omada/activeClientInfo.ts +0 -17
  185. package/src/types/omada/clientActivity.ts +0 -32
  186. package/src/types/omada/clientBlockStatus.ts +0 -11
  187. package/src/types/omada/clientPastConnection.ts +0 -82
  188. package/src/types/omada/index.ts +0 -14
  189. package/src/types/omada/omadaApiResponse.ts +0 -5
  190. package/src/types/omada/omadaClientInfo.ts +0 -11
  191. package/src/types/omada/omadaDeviceInfo.ts +0 -11
  192. package/src/types/omada/omadaDeviceStats.ts +0 -31
  193. package/src/types/omada/omadaSiteSummary.ts +0 -5
  194. package/src/types/omada/oswStackDetail.ts +0 -11
  195. package/src/types/omada/paginatedResult.ts +0 -6
  196. package/src/types/omada/rateLimitProfile.ts +0 -58
  197. package/src/types/omada/threatInfo.ts +0 -38
  198. package/src/types/omada/tokenResult.ts +0 -6
  199. package/src/types.ts +0 -2
  200. package/src/utils/config-validations.ts +0 -68
  201. package/src/utils/jwt.ts +0 -112
  202. package/src/utils/logger.ts +0 -121
  203. package/src/utils/plainText.ts +0 -48
  204. package/src/utils/redact.ts +0 -59
  205. package/src/utils/sanitizeError.ts +0 -18
  206. package/src/utils/tlsDispatcher.ts +0 -69
  207. package/src/version.ts +0 -5
  208. package/tests/bdd/automation-creation.spec.ts +0 -214
  209. package/tests/bdd/automation-management.spec.ts +0 -267
  210. package/tests/bdd/calendar.spec.ts +0 -190
  211. package/tests/bdd/configuration.spec.ts +0 -283
  212. package/tests/bdd/device-control.spec.ts +0 -344
  213. package/tests/bdd/entity-listing.spec.ts +0 -208
  214. package/tests/bdd/entity-search.spec.ts +0 -183
  215. package/tests/bdd/history-retrieval.spec.ts +0 -195
  216. package/tests/bdd/notifications.spec.ts +0 -238
  217. package/tests/bdd/service-calls.spec.ts +0 -144
  218. package/tests/bdd/state-retrieval.spec.ts +0 -204
  219. package/tests/bench/hotpaths.bench.ts +0 -68
  220. package/tests/config.test.ts +0 -355
  221. package/tests/features/automation-creation.feature +0 -36
  222. package/tests/features/automation-management.feature +0 -54
  223. package/tests/features/calendar.feature +0 -37
  224. package/tests/features/configuration.feature +0 -70
  225. package/tests/features/device-control.feature +0 -100
  226. package/tests/features/entity-listing.feature +0 -51
  227. package/tests/features/entity-search.feature +0 -44
  228. package/tests/features/history-retrieval.feature +0 -38
  229. package/tests/features/notifications.feature +0 -37
  230. package/tests/features/service-calls.feature +0 -35
  231. package/tests/features/state-retrieval.feature +0 -37
  232. package/tests/fixtures/openapi-default-slice.json +0 -290
  233. package/tests/haClient/automationTrace.test.ts +0 -59
  234. package/tests/haClient/automations.test.ts +0 -149
  235. package/tests/haClient/config.test.ts +0 -117
  236. package/tests/haClient/entities.test.ts +0 -188
  237. package/tests/haClient/events.test.ts +0 -455
  238. package/tests/haClient/eventsTls.test.ts +0 -52
  239. package/tests/haClient/history.test.ts +0 -228
  240. package/tests/haClient/index.test.ts +0 -243
  241. package/tests/haClient/request.test.ts +0 -217
  242. package/tests/haClient/services.test.ts +0 -120
  243. package/tests/haClient/states.test.ts +0 -119
  244. package/tests/haClient/updates.test.ts +0 -193
  245. package/tests/index.test.ts +0 -160
  246. package/tests/integration/homeassistant.test.ts +0 -144
  247. package/tests/integration/omada.test.ts +0 -167
  248. package/tests/integration/server.test.ts +0 -151
  249. package/tests/localAI/index.test.ts +0 -177
  250. package/tests/localAI/ollama.test.ts +0 -187
  251. package/tests/localAI/openai.test.ts +0 -257
  252. package/tests/omadaClient/actions.test.ts +0 -161
  253. package/tests/omadaClient/clientBlocks.test.ts +0 -170
  254. package/tests/omadaClient/ntp.test.ts +0 -381
  255. package/tests/omadaClient/site.test.ts +0 -37
  256. package/tests/ownerQuestions/service.test.ts +0 -499
  257. package/tests/ownerQuestions/tools.test.ts +0 -186
  258. package/tests/permissions/index.test.ts +0 -217
  259. package/tests/resources/index.test.ts +0 -288
  260. package/tests/scripts/generateJwt.test.ts +0 -44
  261. package/tests/security/accessControl.test.ts +0 -223
  262. package/tests/security/authentication.test.ts +0 -117
  263. package/tests/security/configuration.test.ts +0 -170
  264. package/tests/security/cryptoTransport.test.ts +0 -172
  265. package/tests/security/dependencies.test.ts +0 -152
  266. package/tests/security/injection.test.ts +0 -117
  267. package/tests/security/insecureDesign.test.ts +0 -121
  268. package/tests/security/logging.test.ts +0 -131
  269. package/tests/security/resourceConsumption.test.ts +0 -142
  270. package/tests/server/auth.test.ts +0 -249
  271. package/tests/server/chatFace.test.ts +0 -320
  272. package/tests/server/common.test.ts +0 -84
  273. package/tests/server/http.test.ts +0 -561
  274. package/tests/server/instructions.test.ts +0 -152
  275. package/tests/server/memoizeJsonSchema.test.ts +0 -71
  276. package/tests/server/stdio.test.ts +0 -99
  277. package/tests/server/stream.test.ts +0 -285
  278. package/tests/server/toolVisibility.http.test.ts +0 -111
  279. package/tests/server/toolVisibility.test.ts +0 -129
  280. package/tests/tools/ai/analyzeSensors.test.ts +0 -227
  281. package/tests/tools/common.test.ts +0 -123
  282. package/tests/tools/homeassistant/handlers.test.ts +0 -872
  283. package/tests/tools/infra/invokeAction.test.ts +0 -280
  284. package/tests/tools/omada/graph.test.ts +0 -385
  285. package/tests/tools/omada/handlers.test.ts +0 -897
  286. package/tests/tools/omada/namespace.test.ts +0 -483
  287. package/tests/tools/registry.test.ts +0 -168
  288. package/tests/utils/config-validations.test.ts +0 -352
  289. package/tests/utils/jwt.test.ts +0 -115
  290. package/tests/utils/logger.test.ts +0 -327
  291. package/tests/utils/redact.test.ts +0 -105
  292. package/tsconfig.json +0 -24
  293. package/vitest.config.ts +0 -38
@@ -1,499 +0,0 @@
1
- /**
2
- * OwnerQuestionService: signed, restart-proof owner questions answered by a tap
3
- * on an actionable phone notification.
4
- */
5
-
6
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
7
-
8
- import { OwnerQuestionService, ACTION_EVENT, LIMITS, type ActionEvent, type AskOwnerInput } from '../../src/ownerQuestions/service.js';
9
- import { logger } from '../../src/utils/logger.js';
10
-
11
- const SECRET = 'a'.repeat(40);
12
- const T0 = Date.parse('2026-10-07T08:00:00Z');
13
-
14
- const input = (overrides: Partial<AskOwnerInput> = {}): AskOwnerInput => ({
15
- question: 'Turn on the bathroom light?',
16
- title: 'Harvey asks',
17
- buttons: ['Approve', 'Deny'],
18
- target: 'mobile_app_phone_user5',
19
- timeoutSeconds: 600,
20
- ...overrides,
21
- });
22
-
23
- interface Harness {
24
- service: OwnerQuestionService;
25
- notify: ReturnType<typeof vi.fn>;
26
- subscribe: ReturnType<typeof vi.fn>;
27
- /** The tap handler the service registered. */
28
- tap: (event: ActionEvent) => void;
29
- }
30
-
31
- async function harness(secret: string | null = SECRET): Promise<Harness> {
32
- const notify = vi.fn().mockResolvedValue([]);
33
- let handler: ((event: ActionEvent) => void) | undefined;
34
- const subscribe = vi.fn(async (_type: string, cb: (event: ActionEvent) => void) => {
35
- handler = cb;
36
- return 'sub-1';
37
- });
38
- const service = new OwnerQuestionService({ notify, subscribe, secret: secret ?? undefined, now: () => Date.now() });
39
- await service.start();
40
- return { service, notify, subscribe, tap: (event) => handler!(event) };
41
- }
42
-
43
- /** The action ids of the notification the service sent for a question. */
44
- function sentActionIds(notify: ReturnType<typeof vi.fn>, call = 0): string[] {
45
- const data = notify.mock.calls[call][1].data as { actions: Array<{ action: string }> };
46
- return data.actions.map((a) => a.action);
47
- }
48
-
49
- const phoneTap = (action: string, overrides: Partial<ActionEvent> = {}): ActionEvent => ({
50
- event_type: ACTION_EVENT,
51
- data: { action },
52
- origin: 'REMOTE',
53
- context: { user_id: 'owner-user-id' },
54
- ...overrides,
55
- });
56
-
57
- describe('OwnerQuestionService', () => {
58
- beforeEach(() => {
59
- vi.useFakeTimers();
60
- vi.setSystemTime(T0);
61
- });
62
- afterEach(() => {
63
- vi.useRealTimers();
64
- });
65
-
66
- it('does not start when Home Assistant refuses the tap subscription', async () => {
67
- const service = new OwnerQuestionService({
68
- notify: vi.fn(),
69
- subscribe: vi.fn().mockRejectedValue(new Error("Home Assistant rejected 'subscribe_events' (unauthorized)")),
70
- secret: SECRET,
71
- });
72
-
73
- await expect(service.start()).rejects.toThrow(/unauthorized/);
74
- });
75
-
76
- it('becomes unavailable (and says why) if the subscription is refused later', async () => {
77
- let onRejected!: (error: Error) => void;
78
- const notify = vi.fn().mockResolvedValue([]);
79
- const service = new OwnerQuestionService({
80
- notify,
81
- subscribe: async (_type, _cb, rejected) => (onRejected = rejected),
82
- secret: SECRET,
83
- });
84
- await service.start();
85
-
86
- onRejected(new Error('refused'));
87
-
88
- await expect(service.ask(input(), 'harvey')).rejects.toThrow(/unavailable.*admin Home Assistant token/);
89
- expect(notify).not.toHaveBeenCalled();
90
- service.stop();
91
- });
92
-
93
- it('subscribes to mobile_app_notification_action on start', async () => {
94
- const { subscribe } = await harness();
95
- expect(subscribe).toHaveBeenCalledWith(ACTION_EVENT, expect.any(Function), expect.any(Function));
96
- });
97
-
98
- it('sends a sticky, persistent, self-expiring notification with one signed action per button', async () => {
99
- const { service, notify } = await harness();
100
-
101
- const { requestId, expiresAt } = await service.ask(input(), 'harvey');
102
-
103
- expect(expiresAt).toBe('2026-10-07T08:10:00.000Z');
104
- expect(notify).toHaveBeenCalledTimes(1);
105
- const [target, serviceData] = notify.mock.calls[0];
106
- expect(target).toBe('mobile_app_phone_user5');
107
- expect(serviceData).toMatchObject({
108
- title: 'Harvey asks',
109
- message: 'Turn on the bathroom light?',
110
- data: { sticky: true, persistent: true, priority: 'high', timeout: 600, tag: expect.stringMatching(/^mcpask-/) },
111
- });
112
- const actions = serviceData.data.actions as Array<{ action: string; title: string }>;
113
- expect(actions.map((a) => a.title)).toEqual(['Approve', 'Deny']);
114
- for (const [index, { action }] of actions.entries()) {
115
- expect(action).toMatch(new RegExp(`^MCPASK\\.[A-Za-z0-9_-]{22}\\.${index}\\.\\d+\\.mobile_app_phone_user5\\.[A-Za-z0-9_-]{22}$`));
116
- }
117
- // The requestId does not contain any button's action id (it can't be used to forge a tap).
118
- for (const { action } of actions) {
119
- expect(requestId).not.toContain(action.split('.').at(-1));
120
- }
121
- });
122
-
123
- it('returns pending, then the tapped button with time and HA user', async () => {
124
- const { service, notify, tap } = await harness();
125
- const { requestId } = await service.ask(input(), 'harvey');
126
-
127
- await expect(service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'pending' });
128
-
129
- vi.setSystemTime(T0 + 42_000);
130
- tap(phoneTap(sentActionIds(notify)[1]));
131
-
132
- await expect(service.getAnswer(requestId, 'harvey', 0)).resolves.toEqual({
133
- status: 'answered',
134
- index: 1,
135
- button: 'Deny',
136
- answeredAt: '2026-10-07T08:00:42.000Z',
137
- answeredBy: 'owner-user-id',
138
- question: 'Turn on the bathroom light?',
139
- buttons: ['Approve', 'Deny'],
140
- });
141
- // The question on the phone is replaced (same tag, no buttons, dismissible).
142
- const replacement = notify.mock.calls.at(-1)!;
143
- expect(replacement[1]).toMatchObject({ message: 'Answered: Deny', data: { sticky: false, persistent: false } });
144
- expect(replacement[1].data.actions).toBeUndefined();
145
- expect(replacement[1].data.tag).toBe(notify.mock.calls[0][1].data.tag);
146
- });
147
-
148
- it('a waiting getAnswer returns as soon as the tap arrives', async () => {
149
- const { service, notify, tap } = await harness();
150
- const { requestId } = await service.ask(input(), 'harvey');
151
-
152
- const answer = service.getAnswer(requestId, 'harvey', 50);
153
- await vi.advanceTimersByTimeAsync(5_000);
154
- tap(phoneTap(sentActionIds(notify)[0]));
155
-
156
- await expect(answer).resolves.toMatchObject({ status: 'answered', index: 0, button: 'Approve' });
157
- });
158
-
159
- it('a waiting getAnswer returns pending after waitSeconds', async () => {
160
- const { service } = await harness();
161
- const { requestId } = await service.ask(input(), 'harvey');
162
-
163
- const answer = service.getAnswer(requestId, 'harvey', 30);
164
- await vi.advanceTimersByTimeAsync(30_000);
165
-
166
- await expect(answer).resolves.toMatchObject({ status: 'pending' });
167
- });
168
-
169
- it('the first tap wins', async () => {
170
- const { service, notify, tap } = await harness();
171
- const { requestId } = await service.ask(input(), 'harvey');
172
- const [approve, deny] = sentActionIds(notify);
173
-
174
- tap(phoneTap(deny));
175
- tap(phoneTap(approve));
176
-
177
- await expect(service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ index: 1, button: 'Deny' });
178
- });
179
-
180
- it('times out as its own status, and replaces the notification at expiry', async () => {
181
- const { service, notify } = await harness();
182
- const { requestId } = await service.ask(input({ timeoutSeconds: 60 }), 'harvey');
183
-
184
- await vi.advanceTimersByTimeAsync(60_000);
185
-
186
- await expect(service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({
187
- status: 'timeout',
188
- expiredAt: '2026-10-07T08:01:00.000Z',
189
- });
190
- expect(notify.mock.calls.at(-1)![1]).toMatchObject({ message: 'Expired - treated as no' });
191
- });
192
-
193
- it('ignores a tap that arrives after expiry', async () => {
194
- const { service, notify, tap } = await harness();
195
- const { requestId } = await service.ask(input({ timeoutSeconds: 60 }), 'harvey');
196
- const approve = sentActionIds(notify)[0];
197
-
198
- vi.setSystemTime(T0 + 61_000);
199
- tap(phoneTap(approve));
200
-
201
- await expect(service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'timeout' });
202
- });
203
-
204
- it('updates the phone at the signed expiry even when delivery was slow', async () => {
205
- const { service, notify } = await harness();
206
- notify.mockImplementationOnce(() => new Promise((resolve) => setTimeout(() => resolve([]), 30_000)));
207
-
208
- const asking = service.ask(input({ timeoutSeconds: 60 }), 'harvey');
209
- await vi.advanceTimersByTimeAsync(30_000); // Home Assistant took 30 s
210
- await asking;
211
- await vi.advanceTimersByTimeAsync(30_000); // now exactly at the signed expiry
212
-
213
- expect(notify.mock.calls.at(-1)![1]).toMatchObject({ message: 'Expired - treated as no' });
214
- service.stop();
215
- });
216
-
217
- it('a tap at the exact expiry moment does not count', async () => {
218
- const { service, notify, tap } = await harness();
219
- const { requestId } = await service.ask(input({ timeoutSeconds: 60 }), 'harvey');
220
- const approve = sentActionIds(notify)[0];
221
-
222
- vi.setSystemTime(T0 + 60_000);
223
- await expect(service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'timeout' });
224
- tap(phoneTap(approve));
225
-
226
- await expect(service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'timeout' });
227
- });
228
-
229
- it('a getAnswer that is waiting returns timeout when the question expires', async () => {
230
- const { service } = await harness();
231
- const { requestId } = await service.ask(input({ timeoutSeconds: 60 }), 'harvey');
232
- await vi.advanceTimersByTimeAsync(50_000);
233
-
234
- const answer = service.getAnswer(requestId, 'harvey', 50);
235
- await vi.advanceTimersByTimeAsync(10_000);
236
-
237
- await expect(answer).resolves.toMatchObject({ status: 'timeout' });
238
- });
239
-
240
- it('cancel replaces the notification, and later taps do not count', async () => {
241
- const { service, notify, tap } = await harness();
242
- const { requestId } = await service.ask(input(), 'harvey');
243
- const approve = sentActionIds(notify)[0];
244
-
245
- await expect(service.cancel(requestId, 'harvey')).resolves.toMatchObject({ status: 'cancelled' });
246
- expect(notify.mock.calls.at(-1)![1]).toMatchObject({ message: 'Cancelled - no answer needed' });
247
-
248
- tap(phoneTap(approve));
249
- await expect(service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'cancelled' });
250
- });
251
-
252
- it('cancel after an answer reports the answer and does not overwrite it', async () => {
253
- const { service, notify, tap } = await harness();
254
- const { requestId } = await service.ask(input(), 'harvey');
255
- tap(phoneTap(sentActionIds(notify)[0]));
256
-
257
- await expect(service.cancel(requestId, 'harvey')).resolves.toMatchObject({ status: 'answered', index: 0 });
258
- });
259
-
260
- describe('forgery and tampering', () => {
261
- it('ignores a tap that did not come from a mobile app (origin LOCAL, e.g. an automation)', async () => {
262
- const { service, notify, tap } = await harness();
263
- const { requestId } = await service.ask(input(), 'harvey');
264
-
265
- tap(phoneTap(sentActionIds(notify)[0], { origin: 'LOCAL' }));
266
- tap(phoneTap(sentActionIds(notify)[0], { context: { user_id: null } }));
267
-
268
- await expect(service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'pending' });
269
- });
270
-
271
- it('with an HA user allowlist, only those users can answer', async () => {
272
- const notify = vi.fn().mockResolvedValue([]);
273
- let tapHandler!: (event: ActionEvent) => void;
274
- const service = new OwnerQuestionService({
275
- notify,
276
- subscribe: async (_t, cb) => (tapHandler = cb),
277
- secret: SECRET,
278
- allowedUserIds: ['owner-user-id'],
279
- });
280
- await service.start();
281
- const { requestId } = await service.ask(input(), 'harvey');
282
- const approve = sentActionIds(notify)[0];
283
-
284
- tapHandler(phoneTap(approve, { context: { user_id: 'other-household-member' } }));
285
- await expect(service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'pending' });
286
-
287
- tapHandler(phoneTap(approve, { context: { user_id: 'owner-user-id' } }));
288
- await expect(service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'answered', answeredBy: 'owner-user-id' });
289
- service.stop();
290
- });
291
-
292
- it('ignores an action id with a wrong signature or a changed button index', async () => {
293
- const { service, notify, tap } = await harness();
294
- const { requestId } = await service.ask(input(), 'harvey');
295
- const deny = sentActionIds(notify)[1];
296
- const parts = deny.split('.');
297
-
298
- tap(phoneTap([...parts.slice(0, 5), 'A'.repeat(22)].join('.')));
299
- tap(phoneTap([parts[0], parts[1], '0', ...parts.slice(3)].join('.'))); // Deny's signature, Approve's index
300
-
301
- await expect(service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'pending' });
302
- });
303
-
304
- it('ignores an action id with an extended expiry', async () => {
305
- const { service, notify, tap } = await harness();
306
- const { requestId } = await service.ask(input({ timeoutSeconds: 60 }), 'harvey');
307
- const parts = sentActionIds(notify)[0].split('.');
308
-
309
- vi.setSystemTime(T0 + 120_000);
310
- tap(phoneTap([parts[0], parts[1], parts[2], String(Number(parts[3]) + 3600), ...parts.slice(4)].join('.')));
311
-
312
- await expect(service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'timeout' });
313
- });
314
-
315
- it('ignores unrelated notification actions', async () => {
316
- const { service, tap } = await harness();
317
- const { requestId } = await service.ask(input(), 'harvey');
318
-
319
- tap(phoneTap('HARVEY_OK_45abeb_91065ebabdd1'));
320
- tap(phoneTap(undefined as never));
321
-
322
- await expect(service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'pending' });
323
- });
324
-
325
- it('reports unknown for a tampered or foreign requestId', async () => {
326
- const { service } = await harness();
327
- const { requestId } = await service.ask(input(), 'harvey');
328
- const [prefix, body, sig] = requestId.split('.');
329
- const payload = JSON.parse(Buffer.from(body, 'base64url').toString());
330
- const forgedBody = Buffer.from(JSON.stringify({ ...payload, e: payload.e + 86400 })).toString('base64url');
331
-
332
- for (const bad of [`${prefix}.${forgedBody}.${sig}`, 'q1.x.y', 'nonsense', '']) {
333
- await expect(service.getAnswer(bad, 'harvey', 0)).resolves.toMatchObject({ status: 'unknown' });
334
- }
335
- const other = await harness('b'.repeat(40));
336
- await expect(other.service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'unknown' });
337
- });
338
-
339
- it('only the asking client can read or cancel', async () => {
340
- const { service } = await harness();
341
- const { requestId } = await service.ask(input(), 'harvey');
342
-
343
- await expect(service.getAnswer(requestId, 'openwebui', 0)).rejects.toThrow(/different client/);
344
- await expect(service.cancel(requestId, 'openwebui')).rejects.toThrow(/different client/);
345
- });
346
- });
347
-
348
- it('survives a restart: a new instance with the same secret recognises the question and the tap', async () => {
349
- const first = await harness();
350
- const { requestId } = await first.service.ask(input(), 'harvey');
351
- const approve = sentActionIds(first.notify)[0];
352
-
353
- const second = await harness(); // same secret, empty memory
354
- await expect(second.service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'pending' });
355
- second.tap(phoneTap(approve));
356
-
357
- await expect(second.service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({
358
- status: 'answered',
359
- button: 'Approve',
360
- question: 'Turn on the bathroom light?',
361
- });
362
- expect(second.notify.mock.calls.at(-1)![0]).toBe('mobile_app_phone_user5');
363
- expect(second.notify.mock.calls.at(-1)![1]).toMatchObject({ message: 'Answer recorded' });
364
- });
365
-
366
- it('polling a question from before a restart leaves no state behind once it expires', async () => {
367
- const first = await harness();
368
- const { requestId } = await first.service.ask(input({ timeoutSeconds: 120 }), 'harvey');
369
-
370
- const second = await harness(); // no expiry timer for this question here
371
- const poll = second.service.getAnswer(requestId, 'harvey', 50);
372
- await vi.advanceTimersByTimeAsync(50_000);
373
- await expect(poll).resolves.toMatchObject({ status: 'pending' });
374
- expect(second.service.stateSize().waiters).toBe(0);
375
-
376
- await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000);
377
- await expect(second.service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'timeout' });
378
- expect(second.service.stateSize()).toEqual({ outcomes: 0, known: 0, open: 0, asked: 0, waiters: 0 });
379
- first.service.stop();
380
- second.service.stop();
381
- });
382
-
383
- it('without a secret, questions do not survive a restart', async () => {
384
- const first = await harness(null);
385
- const { requestId } = await first.service.ask(input(), 'harvey');
386
-
387
- const second = await harness(null);
388
- await expect(second.service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'unknown' });
389
- });
390
-
391
- it('cleans up in-memory state on its own, without further activity', async () => {
392
- const { service, notify, tap } = await harness();
393
- const answered = await service.ask(input({ timeoutSeconds: 60 }), 'harvey');
394
- const cancelled = await service.ask(input({ timeoutSeconds: 60 }), 'harvey');
395
- await service.ask(input({ timeoutSeconds: 60 }), 'approver'); // never answered
396
- tap(phoneTap(sentActionIds(notify, 0)[0]));
397
- await service.cancel(cancelled.requestId, 'harvey');
398
- expect(service.stateSize().outcomes).toBe(2);
399
-
400
- // Past expiry + the one-day retention + one sweep interval; nothing else happens.
401
- await vi.advanceTimersByTimeAsync(60_000 + 24 * 60 * 60 * 1000 + 10 * 60 * 1000);
402
-
403
- expect(service.stateSize()).toEqual({ outcomes: 0, known: 0, open: 0, asked: 0, waiters: 0 });
404
- await expect(service.getAnswer(answered.requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'timeout' });
405
- service.stop();
406
- });
407
-
408
- it('never passes notification error text to the caller or the logs', async () => {
409
- const { service, notify, tap } = await harness();
410
- const spies = (['debug', 'info', 'warn', 'error'] as const).map((level) => vi.spyOn(logger, level));
411
- try {
412
- notify.mockRejectedValueOnce(new Error('POST failed, Authorization: Bearer SECRET-TOKEN-1'));
413
- const error = await service.ask(input(), 'harvey').catch((e: Error) => e);
414
- expect((error as Error).message).toMatch(/Could not send the notification/);
415
- expect((error as Error).message).not.toContain('SECRET-TOKEN-1');
416
-
417
- // The replacement after an answer fails too.
418
- const { requestId } = await service.ask(input(), 'harvey');
419
- notify.mockRejectedValueOnce(new Error('Authorization: Bearer SECRET-TOKEN-2'));
420
- tap(phoneTap(sentActionIds(notify, 1)[0]));
421
- await vi.advanceTimersByTimeAsync(0);
422
- await expect(service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'answered' });
423
-
424
- for (const spy of spies) {
425
- const logged = JSON.stringify(spy.mock.calls);
426
- expect(logged).not.toContain('SECRET-TOKEN-1');
427
- expect(logged).not.toContain('SECRET-TOKEN-2');
428
- }
429
- } finally {
430
- for (const spy of spies) spy.mockRestore();
431
- }
432
- });
433
-
434
- describe('rate limits', () => {
435
- it(`allows at most ${LIMITS.maxOpenPerCaller} open questions per client`, async () => {
436
- const { service, notify } = await harness();
437
- for (let i = 0; i < LIMITS.maxOpenPerCaller; i++) {
438
- await service.ask(input(), 'harvey');
439
- }
440
-
441
- await expect(service.ask(input(), 'harvey')).rejects.toThrow(/Too many open questions/);
442
- expect(notify).toHaveBeenCalledTimes(LIMITS.maxOpenPerCaller);
443
- await expect(service.ask(input(), 'approver')).resolves.toHaveProperty('requestId');
444
- });
445
-
446
- it('answered, cancelled and expired questions free their slot', async () => {
447
- const { service, notify, tap } = await harness();
448
- const a = await service.ask(input(), 'harvey');
449
- await service.ask(input(), 'harvey');
450
- await service.ask(input({ timeoutSeconds: 60 }), 'harvey');
451
-
452
- tap(phoneTap(sentActionIds(notify, 0)[0]));
453
- await expect(service.ask(input(), 'harvey')).resolves.toHaveProperty('requestId');
454
- await service.cancel(a.requestId, 'harvey'); // already answered: no extra slot
455
- await expect(service.ask(input(), 'harvey')).rejects.toThrow(/Too many open/);
456
-
457
- await vi.advanceTimersByTimeAsync(60_000);
458
- await expect(service.ask(input(), 'harvey')).resolves.toHaveProperty('requestId');
459
- });
460
-
461
- it(`allows at most ${LIMITS.maxPerCallerPerHour} questions per client per hour`, async () => {
462
- const { service } = await harness();
463
- for (let i = 0; i < LIMITS.maxPerCallerPerHour; i++) {
464
- const { requestId } = await service.ask(input(), 'harvey');
465
- await service.cancel(requestId, 'harvey');
466
- }
467
-
468
- await expect(service.ask(input(), 'harvey')).rejects.toThrow(/last hour/);
469
- vi.setSystemTime(T0 + 60 * 60 * 1000 + 1);
470
- await expect(service.ask(input(), 'harvey')).resolves.toHaveProperty('requestId');
471
- });
472
-
473
- it('concurrent questions cannot exceed the open-question cap', async () => {
474
- const { service, notify } = await harness();
475
- let release!: () => void;
476
- const gate = new Promise<void>((r) => (release = r));
477
- notify.mockImplementation(async () => {
478
- await gate; // slow Home Assistant
479
- return [];
480
- });
481
-
482
- const attempts = Array.from({ length: LIMITS.maxOpenPerCaller + 2 }, () => service.ask(input(), 'harvey'));
483
- release();
484
- const results = await Promise.allSettled(attempts);
485
-
486
- expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(LIMITS.maxOpenPerCaller);
487
- expect(notify).toHaveBeenCalledTimes(LIMITS.maxOpenPerCaller);
488
- });
489
-
490
- it('a failed notification does not use up a slot', async () => {
491
- const { service, notify } = await harness();
492
- notify.mockRejectedValue(new Error('HA unreachable'));
493
-
494
- for (let i = 0; i < LIMITS.maxOpenPerCaller + 1; i++) {
495
- await expect(service.ask(input(), 'harvey')).rejects.toThrow(/Could not send the notification/);
496
- }
497
- });
498
- });
499
- });