@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,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
- });