@xenon-device-management/xenon 2.12.0 → 2.13.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 (140) hide show
  1. package/lib/package.json +2 -2
  2. package/lib/public/assets/{AnnotationOverlay-Crkn72_3.js → AnnotationOverlay-CzbFYtPI.js} +1 -1
  3. package/lib/public/assets/{ApiKeyGate-BThVlj_j.js → ApiKeyGate-CA5KFHHJ.js} +1 -1
  4. package/lib/public/assets/{BugReportButton-Dwa0GnBh.js → BugReportButton-D-DSGg8C.js} +1 -1
  5. package/lib/public/assets/{DeviceMosaicView-Ccc-pOls.js → DeviceMosaicView-paDEFU4B.js} +1 -1
  6. package/lib/public/assets/{EmptyState-BeuTrlN_.js → EmptyState-BE_5f8Vw.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-DBIaNxl1.js → FieldGroup-DqMf7Rf3.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-BgSAS6iT.js → FilterMenu-BQY75Su3.js} +1 -1
  9. package/lib/public/assets/{Menu-GCbP6gYN.js → Menu-BiW3os9u.js} +1 -1
  10. package/lib/public/assets/{Modal-ByESeBRC.js → Modal-DfawTxih.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-Bwyf3WMl.js → RecordingPage-BvemyRiy.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-7kM1rB3i.js → RecordingsPage-CXfw7wpd.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-B8tTU8kD.js → SegmentedControl-B7m9kpom.js} +1 -1
  14. package/lib/public/assets/{SettingCard-Clg_Ijbc.js → SettingCard-Cw6lDD0l.js} +1 -1
  15. package/lib/public/assets/{Table-CIaR0QWV.js → Table-DlEuFgv-.js} +1 -1
  16. package/lib/public/assets/{activity-Bengh9SD.js → activity-CnLvNwae.js} +1 -1
  17. package/lib/public/assets/{ai-settings-G9SymiPH.js → ai-settings-D7Te8o-a.js} +1 -1
  18. package/lib/public/assets/{api-keys-CrGlbfn-.js → api-keys-DphSVzBr.js} +1 -1
  19. package/lib/public/assets/{apps-DIbcGVlU.js → apps-vUfu15lX.js} +1 -1
  20. package/lib/public/assets/{arrow-left-BtJIXXMY.js → arrow-left-CLQWAiCY.js} +1 -1
  21. package/lib/public/assets/{arrow-right-zm-NagQh.js → arrow-right-K6A5e_Ee.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-DdU8YEYp.js → arrow-up-right-DUA_lq6f.js} +1 -1
  23. package/lib/public/assets/{auth-shell-CHmBFmOz.js → auth-shell-BYRHcZd6.js} +2 -2
  24. package/lib/public/assets/{builds-page-JkzWKRpU.js → builds-page-C0fTvIG8.js} +1 -1
  25. package/lib/public/assets/{button-CqhPBGRj.js → button-BxnHCCsX.js} +1 -1
  26. package/lib/public/assets/{calendar-C7eYYJTo.js → calendar-CqVsn28A.js} +1 -1
  27. package/lib/public/assets/{check-DpbIM4E0.js → check-R6V3bot0.js} +1 -1
  28. package/lib/public/assets/{chevron-right-Dl4X1PRz.js → chevron-right-BA2Fntu-.js} +1 -1
  29. package/lib/public/assets/{circle-check-Du3sjbfV.js → circle-check-D4EzyGzs.js} +1 -1
  30. package/lib/public/assets/{circle-x-BSSqhpXi.js → circle-x-Cf6AF2B2.js} +1 -1
  31. package/lib/public/assets/{clock-DMT60v1C.js → clock-EZfet0sS.js} +1 -1
  32. package/lib/public/assets/{copy-BKNyOehd.js → copy-Bc6P8v9V.js} +1 -1
  33. package/lib/public/assets/{device-explorer-CoPR8DW3.js → device-explorer-BFXpRq6d.js} +1 -1
  34. package/lib/public/assets/{download-BM6Xn22t.js → download-Bd8OGLzi.js} +1 -1
  35. package/lib/public/assets/{forgot-password-B8WqMqBT.js → forgot-password-CoE7Ugtx.js} +1 -1
  36. package/lib/public/assets/{index-DauQh6ie.js → index-BiL0Enl_.js} +1 -1
  37. package/lib/public/assets/{index-ClrpAMAT.js → index-CHOr4JCs.js} +2 -2
  38. package/lib/public/assets/{input-CyKdLnEx.js → input-CJ8z3IO5.js} +1 -1
  39. package/lib/public/assets/{line-chart-EIBXwYGo.js → line-chart-CPK0ObjV.js} +1 -1
  40. package/lib/public/assets/{list-checks-fWsgD9bI.js → list-checks-mXGHUVbB.js} +1 -1
  41. package/lib/public/assets/{lock-CVCe56TH.js → lock-BSXM1xvq.js} +1 -1
  42. package/lib/public/assets/{login-BJ8a7yVD.js → login-Ct2iW066.js} +1 -1
  43. package/lib/public/assets/{maintenance-settings-DcRmTLS6.js → maintenance-settings-DaOs00P6.js} +1 -1
  44. package/lib/public/assets/{monitor-Bw1YQZnL.js → monitor-D8WQK8md.js} +1 -1
  45. package/lib/public/assets/{mouse-pointer-2-C01jbqkO.js → mouse-pointer-2-Cw2tWzDX.js} +1 -1
  46. package/lib/public/assets/{network-CBGUjJDJ.js → network-Ba_uFxWo.js} +1 -1
  47. package/lib/public/assets/{overview-Cxe8aQ7C.js → overview-CxrZ7_6k.js} +1 -1
  48. package/lib/public/assets/{page-header-B92DKLiq.js → page-header-Cm1XL49f.js} +1 -1
  49. package/lib/public/assets/{play-Ck0L-0_m.js → play-C0CuKLXj.js} +1 -1
  50. package/lib/public/assets/{plus-Dq3tCy2N.js → plus-CIIvR_6Z.js} +1 -1
  51. package/lib/public/assets/{profile-page--dkDiKbr.js → profile-page-BZ_e-Bgw.js} +1 -1
  52. package/lib/public/assets/{recording-group-store-5BYIFFN9.js → recording-group-store-BWeeNs2_.js} +1 -1
  53. package/lib/public/assets/{reset-password-KYWlfjia.js → reset-password-lfNz8Rm1.js} +1 -1
  54. package/lib/public/assets/{runbook-page-mnsxgS1d.js → runbook-page-Cnec3VSx.js} +1 -1
  55. package/lib/public/assets/{select-BBOIZTYm.js → select-CZuCnold.js} +1 -1
  56. package/lib/public/assets/{selector-detail-redirect-CcI2j_Su.js → selector-detail-redirect-BG3pxFLw.js} +1 -1
  57. package/lib/public/assets/{selector-health-page-BdJb_x5L.js → selector-health-page-DsyL58tb.js} +1 -1
  58. package/lib/public/assets/{session-detail-page-BjLaJy8z.js → session-detail-page-ZZyGM413.js} +1 -1
  59. package/lib/public/assets/{settings-B6cpMhsx.js → settings-DGZ7B_AR.js} +1 -1
  60. package/lib/public/assets/{stat-tile-BU9e4s36.js → stat-tile-DFhv8JoI.js} +1 -1
  61. package/lib/public/assets/{tablet-hgbEwrWq.js → tablet-68J5f6zh.js} +1 -1
  62. package/lib/public/assets/{teams-uiiG4hZM.js → teams-eHSyEpbD.js} +1 -1
  63. package/lib/public/assets/{trash-2-NK_Iazmg.js → trash-2-CcSsv4fS.js} +1 -1
  64. package/lib/public/assets/{upload-BUX8TNFi.js → upload-BYf26K71.js} +1 -1
  65. package/lib/public/assets/{use-builds-data-DZszmFyl.js → use-builds-data-CHW-MhaP.js} +1 -1
  66. package/lib/public/assets/{use-password-reset-mode-BK4B9kRC.js → use-password-reset-mode-DozShXkk.js} +1 -1
  67. package/lib/public/assets/{users-BU4XBbMT.js → users-BpYnpOl2.js} +1 -1
  68. package/lib/public/assets/{users-BDh1xjad.js → users-CcSQRoK9.js} +1 -1
  69. package/lib/public/assets/{video-off-BOjNwT4R.js → video-off-dFKgps73.js} +1 -1
  70. package/lib/public/assets/{webhook-settings-BMzyUcx5.js → webhook-settings-DD1XjiOD.js} +1 -1
  71. package/lib/public/assets/{zap-DTWwVMUg.js → zap-COC2tZaH.js} +1 -1
  72. package/lib/public/index.html +1 -1
  73. package/lib/src/app/apiErrors.js +118 -0
  74. package/lib/src/app/index.js +6 -1
  75. package/lib/src/app/openapi/control.yaml +3129 -0
  76. package/lib/src/app/openapi/grid.yaml +2295 -0
  77. package/lib/src/app/openapi/identity.yaml +2168 -0
  78. package/lib/src/app/openapi/platform.yaml +2885 -0
  79. package/lib/src/app/openapi/sessions.yaml +3784 -0
  80. package/lib/src/app/routers/bug-report.js +4 -1
  81. package/lib/src/app/routers/config.js +6 -107
  82. package/lib/src/app/routers/control.js +117 -59
  83. package/lib/src/app/routers/dashboard.js +13 -7
  84. package/lib/src/app/routers/grid.js +54 -14
  85. package/lib/src/app/routers/profile.js +27 -13
  86. package/lib/src/app/routers/recordings.js +11 -5
  87. package/lib/src/app/routers/reservation.js +63 -15
  88. package/lib/src/app/routers/users.js +4 -0
  89. package/lib/src/app/routers/webhook.js +17 -8
  90. package/lib/src/app/swagger.js +259 -177
  91. package/lib/src/data-service/device-service.js +4 -1
  92. package/lib/src/data-service/deviceFieldOwners.js +1 -0
  93. package/lib/src/device-managers/AndroidDeviceManager.js +5 -2
  94. package/lib/src/device-managers/ios/WDAClient.js +32 -32
  95. package/lib/src/generated/client/edge.js +4 -3
  96. package/lib/src/generated/client/index-browser.js +1 -0
  97. package/lib/src/generated/client/index.d.ts +38 -0
  98. package/lib/src/generated/client/index.js +4 -3
  99. package/lib/src/generated/client/package.json +1 -1
  100. package/lib/src/generated/client/schema.prisma +2 -0
  101. package/lib/src/generated/client/wasm.js +1 -0
  102. package/lib/src/middleware/csrfMiddleware.js +13 -5
  103. package/lib/src/middleware/rateLimitMiddleware.js +19 -5
  104. package/lib/src/middleware/roleGuard.js +20 -0
  105. package/lib/src/services/AIService.js +13 -3
  106. package/lib/src/services/NotificationService.js +28 -28
  107. package/lib/src/services/bug-report/BugReportService.js +8 -2
  108. package/lib/src/services/lease/LeaseService.js +71 -20
  109. package/lib/src/services/omni-vision/OmniVisionService.js +14 -5
  110. package/lib/src/services/recording/RecordingOrchestrator.js +16 -2
  111. package/lib/test/helpers/expressRoutes.js +41 -0
  112. package/lib/test/integration/team-visibility-control.spec.js +2 -2
  113. package/lib/test/unit/access-scopes.spec.js +227 -0
  114. package/lib/test/unit/api-error-handling.spec.js +179 -0
  115. package/lib/test/unit/bug-report/route.spec.js +29 -0
  116. package/lib/test/unit/bug-report/service.spec.js +30 -0
  117. package/lib/test/unit/control-honest-answers.spec.js +134 -0
  118. package/lib/test/unit/device-allocation-routes.spec.js +232 -0
  119. package/lib/test/unit/healing-state-endpoints.spec.js +8 -5
  120. package/lib/test/unit/install-repository-app-team.spec.js +4 -1
  121. package/lib/test/unit/lease/LeaseService.spec.js +5 -4
  122. package/lib/test/unit/lease/lease-device-match.spec.js +161 -0
  123. package/lib/test/unit/lease/lease-session-ownership.spec.js +10 -9
  124. package/lib/test/unit/omni-vision-failures.spec.js +81 -0
  125. package/lib/test/unit/openapi-coverage.spec.js +114 -0
  126. package/lib/test/unit/profile-router.test.js +42 -0
  127. package/lib/test/unit/rateLimitMiddleware.test.js +49 -0
  128. package/lib/test/unit/recording-orchestrator.spec.js +80 -0
  129. package/lib/test/unit/recordings-library-routes.spec.js +37 -0
  130. package/lib/test/unit/reservation-team-visibility.spec.js +4 -3
  131. package/lib/test/unit/reset-link.test.js +2 -1
  132. package/lib/test/unit/stream-ticket-identity.spec.js +1 -1
  133. package/lib/test/unit/users-router.test.js +14 -1
  134. package/lib/test/unit/wda-client-failures.spec.js +90 -0
  135. package/lib/test/unit/webhook-delivery.spec.js +142 -0
  136. package/lib/tsconfig.tsbuildinfo +1 -1
  137. package/package.json +2 -2
  138. package/prisma/migrations/20261004120000_reservation_holder/migration.sql +2 -0
  139. package/prisma/schema.prisma +2 -0
  140. package/lib/src/app/swagger-docs.js +0 -1701
@@ -75,6 +75,7 @@ const iosStreamPort_1 = require("./iosStreamPort");
75
75
  const androidH264Config_1 = require("./androidH264Config");
76
76
  const recording_store_1 = require("../../services/recording/recording-store");
77
77
  const clipboardErrors_1 = require("../../device-managers/clipboardErrors");
78
+ const WDAClient_1 = require("../../device-managers/ios/WDAClient");
78
79
  const scopeGuard_1 = require("../../middleware/scopeGuard");
79
80
  const roleGuard_1 = require("../../middleware/roleGuard");
80
81
  const deviceAccessGuard_1 = require("../../middleware/deviceAccessGuard");
@@ -87,6 +88,12 @@ const streamLeave_1 = require("./streamLeave");
87
88
  const SessionOwnerResolver_1 = require("../../services/device-access/SessionOwnerResolver");
88
89
  const actor_1 = require("../../services/device-access/actor");
89
90
  const deviceAccessPolicy_1 = require("../../services/device-access/deviceAccessPolicy");
91
+ /**
92
+ * The answer for an unknown udid, and for another team's phone (see
93
+ * deviceTeamGuard): the same JSON on every route. Most routes used to send
94
+ * this as plain text, which a JSON client couldn't read.
95
+ */
96
+ const DEVICE_NOT_FOUND = { error: 'not_found', message: 'Device not found' };
90
97
  const router = (0, express_1.Router)();
91
98
  /** True when an in-memory iOS/Android (MJPEG or H.264) stream session exists. */
92
99
  function hasActiveManualStream(udid) {
@@ -152,7 +159,7 @@ router.post('/:udid/tap', (req, res) => __awaiter(void 0, void 0, void 0, functi
152
159
  const { x, y } = req.body;
153
160
  const device = yield getDeviceInfo(udid);
154
161
  if (!device)
155
- return res.status(404).send('Device not found');
162
+ return res.status(404).json(DEVICE_NOT_FOUND);
156
163
  const manager = yield getDeviceManagerForPlatform(device.platform);
157
164
  if (manager && manager.tap) {
158
165
  try {
@@ -175,14 +182,16 @@ router.post('/:udid/tap', (req, res) => __awaiter(void 0, void 0, void 0, functi
175
182
  return res.status(500).send({ error: err.message });
176
183
  }
177
184
  }
178
- res.status(400).send('Manager not found or tap not supported');
185
+ res
186
+ .status(400)
187
+ .json({ error: 'not_supported', message: 'Manager not found or tap not supported' });
179
188
  }));
180
189
  router.post('/:udid/swipe', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
181
190
  const { udid } = req.params;
182
191
  const { x, y, endX, endY, duration = 1000 } = req.body;
183
192
  const device = yield getDeviceInfo(udid);
184
193
  if (!device)
185
- return res.status(404).send('Device not found');
194
+ return res.status(404).json(DEVICE_NOT_FOUND);
186
195
  const manager = yield getDeviceManagerForPlatform(device.platform);
187
196
  if (manager && manager.swipe) {
188
197
  try {
@@ -194,14 +203,16 @@ router.post('/:udid/swipe', (req, res) => __awaiter(void 0, void 0, void 0, func
194
203
  return res.status(500).send({ error: err.message });
195
204
  }
196
205
  }
197
- res.status(400).send('Manager not found or swipe not supported');
206
+ res
207
+ .status(400)
208
+ .json({ error: 'not_supported', message: 'Manager not found or swipe not supported' });
198
209
  }));
199
210
  router.post('/:udid/text', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
200
211
  const { udid } = req.params;
201
212
  const { text } = req.body;
202
213
  const device = yield getDeviceInfo(udid);
203
214
  if (!device)
204
- return res.status(404).send('Device not found');
215
+ return res.status(404).json(DEVICE_NOT_FOUND);
205
216
  const manager = yield getDeviceManagerForPlatform(device.platform);
206
217
  if (manager && manager.typeText) {
207
218
  try {
@@ -213,14 +224,16 @@ router.post('/:udid/text', (req, res) => __awaiter(void 0, void 0, void 0, funct
213
224
  return res.status(500).send({ error: err.message });
214
225
  }
215
226
  }
216
- res.status(400).send('Manager not found or typeText not supported');
227
+ res
228
+ .status(400)
229
+ .json({ error: 'not_supported', message: 'Manager not found or typeText not supported' });
217
230
  }));
218
231
  router.post('/:udid/keyevent', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
219
232
  const { udid } = req.params;
220
233
  const { keyCode } = req.body;
221
234
  const device = yield getDeviceInfo(udid);
222
235
  if (!device)
223
- return res.status(404).send('Device not found');
236
+ return res.status(404).json(DEVICE_NOT_FOUND);
224
237
  const manager = yield getDeviceManagerForPlatform(device.platform);
225
238
  if (manager && manager.pressKey) {
226
239
  try {
@@ -228,18 +241,24 @@ router.post('/:udid/keyevent', (req, res) => __awaiter(void 0, void 0, void 0, f
228
241
  return res.status(200).send({ success: true });
229
242
  }
230
243
  catch (err) {
244
+ // A key the phone doesn't have is the caller's mistake, not a failure.
245
+ if (err instanceof WDAClient_1.UnsupportedKeyError) {
246
+ return res.status(400).send({ error: 'unsupported_key', message: err.message });
247
+ }
231
248
  logger_1.default.error(`❌ pressKey failed for ${udid}: ${err.message}`);
232
249
  return res.status(500).send({ error: err.message });
233
250
  }
234
251
  }
235
- res.status(400).send('Manager not found or pressKey not supported');
252
+ res
253
+ .status(400)
254
+ .json({ error: 'not_supported', message: 'Manager not found or pressKey not supported' });
236
255
  }));
237
256
  router.get('/:udid/screenshot', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
238
257
  var _a, _b;
239
258
  const { udid } = req.params;
240
259
  const device = yield getDeviceInfo(udid);
241
260
  if (!device)
242
- return res.status(404).send('Device not found');
261
+ return res.status(404).json(DEVICE_NOT_FOUND);
243
262
  // Principal Engineer Optimization: If a high-speed stream is already running for Android,
244
263
  // we should grab the latest frame instead of triggering a heavy ADB screencap.
245
264
  if (device.platform === 'android') {
@@ -270,13 +289,15 @@ router.get('/:udid/screenshot', (req, res) => __awaiter(void 0, void 0, void 0,
270
289
  error: 'Screenshot capture failed. Device may be busy or WDA is unresponsive. Try again.',
271
290
  });
272
291
  }
273
- res.status(400).send('Manager not found or screenshot not supported');
292
+ res
293
+ .status(400)
294
+ .json({ error: 'not_supported', message: 'Manager not found or screenshot not supported' });
274
295
  }));
275
296
  router.get('/:udid/clipboard', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
276
297
  const { udid } = req.params;
277
298
  const device = yield getDeviceInfo(udid);
278
299
  if (!device)
279
- return res.status(404).send('Device not found');
300
+ return res.status(404).json(DEVICE_NOT_FOUND);
280
301
  const manager = yield getDeviceManagerForPlatform(device.platform);
281
302
  if (manager && manager.getClipboard) {
282
303
  try {
@@ -289,14 +310,16 @@ router.get('/:udid/clipboard', (req, res) => __awaiter(void 0, void 0, void 0, f
289
310
  return res.status(500).send({ error: err.message });
290
311
  }
291
312
  }
292
- res.status(400).send('Manager not found or getClipboard not supported');
313
+ res
314
+ .status(400)
315
+ .json({ error: 'not_supported', message: 'Manager not found or getClipboard not supported' });
293
316
  }));
294
317
  router.post('/:udid/clipboard', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
295
318
  const { udid } = req.params;
296
319
  const { content } = req.body;
297
320
  const device = yield getDeviceInfo(udid);
298
321
  if (!device)
299
- return res.status(404).send('Device not found');
322
+ return res.status(404).json(DEVICE_NOT_FOUND);
300
323
  const manager = yield getDeviceManagerForPlatform(device.platform);
301
324
  if (manager && manager.setClipboard) {
302
325
  try {
@@ -312,14 +335,16 @@ router.post('/:udid/clipboard', (req, res) => __awaiter(void 0, void 0, void 0,
312
335
  return res.status(500).send({ error: err.message });
313
336
  }
314
337
  }
315
- res.status(400).send('Manager not found or setClipboard not supported');
338
+ res
339
+ .status(400)
340
+ .json({ error: 'not_supported', message: 'Manager not found or setClipboard not supported' });
316
341
  }));
317
342
  router.post('/:udid/touchAndHold', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
318
343
  const { udid } = req.params;
319
344
  const { x, y, duration = 1000 } = req.body;
320
345
  const device = yield getDeviceInfo(udid);
321
346
  if (!device)
322
- return res.status(404).send('Device not found');
347
+ return res.status(404).json(DEVICE_NOT_FOUND);
323
348
  const manager = yield getDeviceManagerForPlatform(device.platform);
324
349
  if (manager && manager.touchAndHold) {
325
350
  try {
@@ -331,13 +356,15 @@ router.post('/:udid/touchAndHold', (req, res) => __awaiter(void 0, void 0, void
331
356
  return res.status(500).send({ error: err.message });
332
357
  }
333
358
  }
334
- res.status(400).send('Manager not found or touchAndHold not supported');
359
+ res
360
+ .status(400)
361
+ .json({ error: 'not_supported', message: 'Manager not found or touchAndHold not supported' });
335
362
  }));
336
363
  router.post('/:udid/lock', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
337
364
  const { udid } = req.params;
338
365
  const device = yield getDeviceInfo(udid);
339
366
  if (!device)
340
- return res.status(404).send('Device not found');
367
+ return res.status(404).json(DEVICE_NOT_FOUND);
341
368
  const manager = yield getDeviceManagerForPlatform(device.platform);
342
369
  if (manager && manager.lock) {
343
370
  try {
@@ -349,13 +376,15 @@ router.post('/:udid/lock', (req, res) => __awaiter(void 0, void 0, void 0, funct
349
376
  return res.status(500).send({ error: err.message });
350
377
  }
351
378
  }
352
- res.status(400).send('Manager not found or lock not supported');
379
+ res
380
+ .status(400)
381
+ .json({ error: 'not_supported', message: 'Manager not found or lock not supported' });
353
382
  }));
354
383
  router.post('/:udid/unlock', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
355
384
  const { udid } = req.params;
356
385
  const device = yield getDeviceInfo(udid);
357
386
  if (!device)
358
- return res.status(404).send('Device not found');
387
+ return res.status(404).json(DEVICE_NOT_FOUND);
359
388
  const manager = yield getDeviceManagerForPlatform(device.platform);
360
389
  if (manager && manager.unlock) {
361
390
  try {
@@ -367,7 +396,9 @@ router.post('/:udid/unlock', (req, res) => __awaiter(void 0, void 0, void 0, fun
367
396
  return res.status(500).send({ error: err.message });
368
397
  }
369
398
  }
370
- res.status(400).send('Manager not found or unlock not supported');
399
+ res
400
+ .status(400)
401
+ .json({ error: 'not_supported', message: 'Manager not found or unlock not supported' });
371
402
  }));
372
403
  /**
373
404
  * Is the device's panel lit?
@@ -386,7 +417,7 @@ router.get('/:udid/display', (req, res) => __awaiter(void 0, void 0, void 0, fun
386
417
  const { udid } = req.params;
387
418
  const device = yield getDeviceInfo(udid);
388
419
  if (!device)
389
- return res.status(404).send({ status: 'error', message: 'Device not found' });
420
+ return res.status(404).json(DEVICE_NOT_FOUND);
390
421
  const manager = yield getDeviceManagerForPlatform(device.platform);
391
422
  if (!(manager === null || manager === void 0 ? void 0 : manager.getDisplayState))
392
423
  return res.status(200).send({ state: 'unknown' });
@@ -398,7 +429,7 @@ router.post('/:udid/install', (req, res) => __awaiter(void 0, void 0, void 0, fu
398
429
  const { appPath } = req.body;
399
430
  const device = yield getDeviceInfo(udid);
400
431
  if (!device)
401
- return res.status(404).send('Device not found');
432
+ return res.status(404).json(DEVICE_NOT_FOUND);
402
433
  const manager = yield getDeviceManagerForPlatform(device.platform);
403
434
  if (manager && manager.installApp) {
404
435
  try {
@@ -410,22 +441,28 @@ router.post('/:udid/install', (req, res) => __awaiter(void 0, void 0, void 0, fu
410
441
  return res.status(500).send({ error: err.message });
411
442
  }
412
443
  }
413
- res.status(400).send('Manager not found or installApp not supported');
444
+ res
445
+ .status(400)
446
+ .json({ error: 'not_supported', message: 'Manager not found or installApp not supported' });
414
447
  }));
415
448
  router.post('/:udid/install-repository-app', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
416
- var _a;
449
+ var _a, _b;
417
450
  const { udid } = req.params;
418
- const { appId } = req.body;
451
+ const { appId } = (_a = req.body) !== null && _a !== void 0 ? _a : {};
419
452
  const device = yield getDeviceInfo(udid);
420
453
  if (!device)
421
- return res.status(404).send('Device not found');
454
+ return res.status(404).json(DEVICE_NOT_FOUND);
455
+ // Without one the lookup below threw, and the answer was a 500.
456
+ if (typeof appId !== 'string' || !appId) {
457
+ return res.status(400).json({ error: 'bad_request', message: 'appId is required' });
458
+ }
422
459
  try {
423
460
  const { APP_SERVICE } = yield Promise.resolve().then(() => __importStar(require('../../dashboard/services/app-service')));
424
461
  const app = yield APP_SERVICE.getAppById(appId);
425
462
  // Installing hands over the binary as a download does: another team's
426
463
  // app answers as an unknown one.
427
- if (!(0, appVisibility_1.canSeeApp)(app, (_a = req.auth) === null || _a === void 0 ? void 0 : _a.teamIds)) {
428
- return res.status(404).send('App not found in repository');
464
+ if (!(0, appVisibility_1.canSeeApp)(app, (_b = req.auth) === null || _b === void 0 ? void 0 : _b.teamIds)) {
465
+ return res.status(404).json({ error: 'not_found', message: 'App not found in repository' });
429
466
  }
430
467
  // A node's phone: the app is in this server's library, not the node's,
431
468
  // so the file goes to the node's own upload-install.
@@ -440,7 +477,9 @@ router.post('/:udid/install-repository-app', (req, res) => __awaiter(void 0, voi
440
477
  yield manager.installApp(udid, app.filepath);
441
478
  return res.status(200).send({ success: true, message: `Installed ${app.name}` });
442
479
  }
443
- res.status(400).send('Manager not found or installApp not supported');
480
+ res
481
+ .status(400)
482
+ .json({ error: 'not_supported', message: 'Manager not found or installApp not supported' });
444
483
  }
445
484
  catch (err) {
446
485
  logger_1.default.error(`Installation from repository failed: ${err.message}`);
@@ -484,16 +523,20 @@ router.post('/:udid/upload-install', uploadParser, (req, res) => __awaiter(void
484
523
  const answer = yield (() => __awaiter(void 0, void 0, void 0, function* () {
485
524
  const device = yield getDeviceInfo(udid);
486
525
  if (!device)
487
- return { status: 404, body: 'Device not found' };
526
+ return { status: 404, body: DEVICE_NOT_FOUND };
488
527
  if (!req.files || Object.keys(req.files).length === 0) {
489
- return { status: 400, body: 'No files were uploaded.' };
528
+ return { status: 400, body: { error: 'bad_request', message: 'No files were uploaded.' } };
529
+ }
530
+ if (!appFile || !appPath) {
531
+ return { status: 400, body: { error: 'bad_request', message: 'File "app" is required' } };
490
532
  }
491
- if (!appFile || !appPath)
492
- return { status: 400, body: 'File "app" is required' };
493
533
  yield appFile.mv(appPath);
494
534
  const manager = yield getDeviceManagerForPlatform(device.platform);
495
535
  if (!(manager === null || manager === void 0 ? void 0 : manager.installApp)) {
496
- return { status: 400, body: 'Manager not found or installApp not supported' };
536
+ return {
537
+ status: 400,
538
+ body: { error: 'not_supported', message: 'Manager not found or installApp not supported' },
539
+ };
497
540
  }
498
541
  yield manager.installApp(udid, appPath);
499
542
  return {
@@ -518,7 +561,7 @@ router.post('/:udid/uninstall', (req, res) => __awaiter(void 0, void 0, void 0,
518
561
  const { bundleId } = req.body;
519
562
  const device = yield getDeviceInfo(udid);
520
563
  if (!device)
521
- return res.status(404).send('Device not found');
564
+ return res.status(404).json(DEVICE_NOT_FOUND);
522
565
  const manager = yield getDeviceManagerForPlatform(device.platform);
523
566
  if (manager && manager.uninstallApp) {
524
567
  try {
@@ -530,13 +573,15 @@ router.post('/:udid/uninstall', (req, res) => __awaiter(void 0, void 0, void 0,
530
573
  return res.status(500).send({ error: err.message });
531
574
  }
532
575
  }
533
- res.status(400).send('Manager not found or uninstallApp not supported');
576
+ res
577
+ .status(400)
578
+ .json({ error: 'not_supported', message: 'Manager not found or uninstallApp not supported' });
534
579
  }));
535
580
  router.get('/:udid/apps', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
536
581
  const { udid } = req.params;
537
582
  const device = yield getDeviceInfo(udid);
538
583
  if (!device)
539
- return res.status(404).send('Device not found');
584
+ return res.status(404).json(DEVICE_NOT_FOUND);
540
585
  const manager = yield getDeviceManagerForPlatform(device.platform);
541
586
  if (manager && manager.listApps) {
542
587
  try {
@@ -549,13 +594,15 @@ router.get('/:udid/apps', (req, res) => __awaiter(void 0, void 0, void 0, functi
549
594
  return res.status(500).send({ error: err.message });
550
595
  }
551
596
  }
552
- res.status(400).send('Manager not found or listApps not supported');
597
+ res
598
+ .status(400)
599
+ .json({ error: 'not_supported', message: 'Manager not found or listApps not supported' });
553
600
  }));
554
601
  router.get('/:udid/logs', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
555
602
  const { udid } = req.params;
556
603
  const device = yield getDeviceInfo(udid);
557
604
  if (!device)
558
- return res.status(404).send('Device not found');
605
+ return res.status(404).json(DEVICE_NOT_FOUND);
559
606
  const manager = yield getDeviceManagerForPlatform(device.platform);
560
607
  if (manager && manager.getLogs) {
561
608
  try {
@@ -567,7 +614,9 @@ router.get('/:udid/logs', (req, res) => __awaiter(void 0, void 0, void 0, functi
567
614
  return res.status(500).send({ error: err.message });
568
615
  }
569
616
  }
570
- res.status(400).send('Manager not found or getLogs not supported');
617
+ res
618
+ .status(400)
619
+ .json({ error: 'not_supported', message: 'Manager not found or getLogs not supported' });
571
620
  }));
572
621
  const MJPEG_PROXY_CACHE = new Map();
573
622
  /**
@@ -581,7 +630,7 @@ router.post('/:udid/stream/start', (req, res) => __awaiter(void 0, void 0, void
581
630
  exports.previewLeaves.cancel(udid);
582
631
  const device = yield getDeviceInfo(udid);
583
632
  if (!device)
584
- return res.status(404).send('Device not found');
633
+ return res.status(404).json(DEVICE_NOT_FOUND);
585
634
  // Ownership at stream-start time. A manual lock with no live stream is an
586
635
  // orphan to reclaim; a live foreign stream is a conflict we must refuse.
587
636
  // Refusing is what stops one user's start from silently rewriting another
@@ -728,7 +777,7 @@ router.post('/:udid/stream/ticket', (req, res) => __awaiter(void 0, void 0, void
728
777
  return res.status(503).json((0, deviceAccessPolicy_1.ownershipUnavailableBody)());
729
778
  }
730
779
  if (!device)
731
- return res.status(404).send('Device not found');
780
+ return res.status(404).json(DEVICE_NOT_FOUND);
732
781
  // Carry the two other things `evaluateDeviceAccess` needs alongside the user
733
782
  // id. A ticket consumer (the logcat WS) has no Express request to run
734
783
  // resolveActor against, and re-deriving them from a User row at redeem time
@@ -818,7 +867,7 @@ router.post('/:udid/stream/leave', (req, res) => __awaiter(void 0, void 0, void
818
867
  const { udid } = req.params;
819
868
  const device = yield getDeviceInfo(udid);
820
869
  if (!device)
821
- return res.status(404).send('Device not found');
870
+ return res.status(404).json(DEVICE_NOT_FOUND);
822
871
  const actor = (0, actor_1.resolveActor)(req);
823
872
  const lockInfo = (0, manualLock_1.inspectManualLock)(device.session_id, actor.userId, udid);
824
873
  const mayRelease = (0, deviceAccessPolicy_1.isSelfManualLock)(device.session_id, udid, actor.userId, actor.apiKeyId) ||
@@ -838,7 +887,7 @@ router.post('/:udid/stream/stop', (req, res) => __awaiter(void 0, void 0, void 0
838
887
  const { udid } = req.params;
839
888
  const device = yield getDeviceInfo(udid);
840
889
  if (!device)
841
- return res.status(404).send('Device not found');
890
+ return res.status(404).json(DEVICE_NOT_FOUND);
842
891
  // Multi-user safety: refuse to release a manual lock owned by another user.
843
892
  // Admin scope bypasses this so support can clear stuck sessions.
844
893
  //
@@ -903,7 +952,7 @@ router.get('/:udid/stream/status', (req, res) => __awaiter(void 0, void 0, void
903
952
  const { udid } = req.params;
904
953
  const device = yield getDeviceInfo(udid);
905
954
  if (!device)
906
- return res.status(404).send('Device not found');
955
+ return res.status(404).json(DEVICE_NOT_FOUND);
907
956
  // Advertise which transport the frontend player should use. A recording
908
957
  // device keeps MJPEG (one capture pipeline per device).
909
958
  const flagOn = (0, androidH264Config_1.resolveAndroidH264)((_a = typedi_1.Container.get(PluginContext_1.PluginContext).pluginArgs.streaming) === null || _a === void 0 ? void 0 : _a.androidH264).enabled;
@@ -973,7 +1022,7 @@ router.get('/:udid/stream', (req, res) => __awaiter(void 0, void 0, void 0, func
973
1022
  });
974
1023
  const device = yield getDeviceInfo(udid);
975
1024
  if (!device)
976
- return res.status(404).send('Device not found');
1025
+ return res.status(404).json(DEVICE_NOT_FOUND);
977
1026
  // The MJPEG preview attaches here on a reload; see screenSizeDeps.
978
1027
  void (0, fillMissingScreenSize_1.fillMissingScreenSize)(device, screenSizeDeps);
979
1028
  let mjpegPort = device.mjpegServerPort;
@@ -1029,7 +1078,7 @@ router.get('/:udid/stream', (req, res) => __awaiter(void 0, void 0, void 0, func
1029
1078
  if (!mjpegPort) {
1030
1079
  return res.status(404).send({
1031
1080
  error: 'MJPEG port not found for device',
1032
- hint: 'For iOS: Use POST /stream/start to begin streaming. For Android: Start an Appium session first.',
1081
+ hint: 'Start the preview with POST /stream/start, then open this stream again.',
1033
1082
  });
1034
1083
  }
1035
1084
  const videoUrl = `http://127.0.0.1:${mjpegPort}`;
@@ -1059,7 +1108,7 @@ router.get('/:udid/stream', (req, res) => __awaiter(void 0, void 0, void 0, func
1059
1108
  proxy.proxyRequest(req, res);
1060
1109
  }
1061
1110
  else {
1062
- res.status(404).send('Proxy not created');
1111
+ res.status(404).json({ error: 'not_found', message: 'The preview could not be started' });
1063
1112
  }
1064
1113
  }
1065
1114
  catch (err) {
@@ -1076,9 +1125,9 @@ router.post('/:udid/shell', (req, res) => __awaiter(void 0, void 0, void 0, func
1076
1125
  const { command } = req.body;
1077
1126
  const device = yield getDeviceInfo(udid);
1078
1127
  if (!device)
1079
- return res.status(404).send('Device not found');
1128
+ return res.status(404).json(DEVICE_NOT_FOUND);
1080
1129
  if (!command)
1081
- return res.status(400).send('Command is required');
1130
+ return res.status(400).json({ error: 'bad_request', message: 'Command is required' });
1082
1131
  const manager = yield getDeviceManagerForPlatform(device.platform);
1083
1132
  if (manager && manager.executeShell) {
1084
1133
  try {
@@ -1091,7 +1140,9 @@ router.post('/:udid/shell', (req, res) => __awaiter(void 0, void 0, void 0, func
1091
1140
  return res.status(200).send({ error: err.message });
1092
1141
  }
1093
1142
  }
1094
- res.status(400).send('Manager not found or executeShell not supported');
1143
+ res
1144
+ .status(400)
1145
+ .json({ error: 'not_supported', message: 'Manager not found or executeShell not supported' });
1095
1146
  }));
1096
1147
  /**
1097
1148
  * Where Omni-Vision reads the screen from, for the stand-in driver it is
@@ -1127,14 +1178,20 @@ router.get('/:udid/omni-scan', (req, res) => __awaiter(void 0, void 0, void 0, f
1127
1178
  const { udid } = req.params;
1128
1179
  const device = yield getDeviceInfo(udid);
1129
1180
  if (!device)
1130
- return res.status(404).send('Device not found');
1181
+ return res.status(404).json(DEVICE_NOT_FOUND);
1131
1182
  const screen = yield omniScreen(req, udid, device);
1132
1183
  if (!screen)
1133
- return res.status(400).send('Manager not found');
1184
+ return res.status(400).json({ error: 'not_supported', message: 'Manager not found' });
1134
1185
  try {
1135
1186
  const omniService = typedi_1.Container.get(OmniVisionService_1.OmniVisionService);
1136
1187
  const mockDriver = Object.assign({ sessionId: `manual_${udid}` }, screen);
1137
1188
  const result = yield omniService.analyzeScreen(mockDriver);
1189
+ // analyzeScreen reports a failed analysis in its result rather than
1190
+ // throwing (the Appium execute-script path returns that object as is).
1191
+ if ((result === null || result === void 0 ? void 0 : result.status) === 'error') {
1192
+ logger_1.default.error(`Manual Omni-Scan failed for ${udid}: ${result.message}`);
1193
+ return res.status(500).send({ status: 'error', message: result.message });
1194
+ }
1138
1195
  return res.status(200).send({ status: 'success', value: result });
1139
1196
  }
1140
1197
  catch (err) {
@@ -1161,7 +1218,7 @@ router.get('/:udid/inspector/snapshot', (req, res) => __awaiter(void 0, void 0,
1161
1218
  return res.status(503).json((0, deviceAccessPolicy_1.ownershipUnavailableBody)());
1162
1219
  }
1163
1220
  if (!device)
1164
- return res.status(404).send('Device not found');
1221
+ return res.status(404).json(DEVICE_NOT_FOUND);
1165
1222
  try {
1166
1223
  const inspectorService = typedi_1.Container.get(InspectorService_1.InspectorService);
1167
1224
  const snapshot = yield inspectorService.getSnapshot(udid);
@@ -1193,7 +1250,7 @@ router.get('/:udid/appium-session', (req, res) => __awaiter(void 0, void 0, void
1193
1250
  const { udid } = req.params;
1194
1251
  const device = yield getDeviceInfo(udid);
1195
1252
  if (!device)
1196
- return res.status(404).send({ status: 'error', message: 'Device not found' });
1253
+ return res.status(404).json(DEVICE_NOT_FOUND);
1197
1254
  // A manual lock is the dashboard holding the device for preview/recording,
1198
1255
  // not an Appium session — it cannot resolve locators, so it is not one.
1199
1256
  const sessionId = device.session_id && !(0, manualLock_1.isManualLock)(device.session_id) ? String(device.session_id) : null;
@@ -1211,19 +1268,20 @@ router.post('/:udid/test-locator', (req, res) => __awaiter(void 0, void 0, void
1211
1268
  const { strategy, selector } = req.body;
1212
1269
  const device = yield getDeviceInfo(udid);
1213
1270
  if (!device)
1214
- return res.status(404).send('Device not found');
1271
+ return res.status(404).json(DEVICE_NOT_FOUND);
1215
1272
  const screen = yield omniScreen(req, udid, device);
1216
1273
  if (!screen)
1217
- return res.status(400).send('Manager not found');
1274
+ return res.status(400).json({ error: 'not_supported', message: 'Manager not found' });
1218
1275
  try {
1219
1276
  const omniService = typedi_1.Container.get(OmniVisionService_1.OmniVisionService);
1220
1277
  const mockDriver = { sessionId: `manual_${udid}`, getScreenshot: screen.getScreenshot };
1221
1278
  let value = [];
1222
1279
  if (strategy === '-custom:ai-text') {
1223
- value = yield omniService.findByText(mockDriver, selector);
1280
+ // A failed OCR or AI call is a 500 here, not "no match".
1281
+ value = yield omniService.findByText(mockDriver, selector, { throwOnError: true });
1224
1282
  }
1225
1283
  else if (strategy === '-custom:ai-icon') {
1226
- const match = yield omniService.findByIcon(mockDriver, selector);
1284
+ const match = yield omniService.findByIcon(mockDriver, selector, { throwOnError: true });
1227
1285
  if (match)
1228
1286
  value = [match];
1229
1287
  }
@@ -995,8 +995,10 @@ function getMutedSelectors(request, response) {
995
995
  function getSelectorStateByTuple(request, response) {
996
996
  return __awaiter(this, void 0, void 0, function* () {
997
997
  var _a, _b;
998
- const strategy = decodeURIComponent((_a = request.params.strategy) !== null && _a !== void 0 ? _a : '');
999
- const value = decodeURIComponent((_b = request.params.value) !== null && _b !== void 0 ? _b : '');
998
+ // Express has already decoded the params. Decoding them again made a
999
+ // selector with a `%` need encoding twice, and a lone `%` threw.
1000
+ const strategy = (_a = request.params.strategy) !== null && _a !== void 0 ? _a : '';
1001
+ const value = (_b = request.params.value) !== null && _b !== void 0 ? _b : '';
1000
1002
  const row = yield prisma_1.prisma.selectorState.findUnique({
1001
1003
  where: {
1002
1004
  original_strategy_original_selector: {
@@ -1170,6 +1172,7 @@ function resetMetrics(request, response) {
1170
1172
  }
1171
1173
  });
1172
1174
  }
1175
+ const changeSettingsGuard = (0, roleGuard_1.superAdminGuard)("Only a super admin can change the lab's settings.");
1173
1176
  function register(router) {
1174
1177
  router.use('/session/:sessionId', isValidSession);
1175
1178
  router.use((0, roleGuard_1.roleGuard)('MEMBER'));
@@ -1207,11 +1210,14 @@ function register(router) {
1207
1210
  router.post('/healing/selector/state', (0, roleGuard_1.roleGuard)('MEMBER'), (0, scopeGuard_1.scopeGuard)(['sessions']), postSelectorStateAction);
1208
1211
  router.get('/healing/state/muted', getMutedSelectors);
1209
1212
  router.get('/healing/state/:strategy/:value', getSelectorStateByTuple);
1210
- router.get('/config', getGlobalConfig);
1211
- // Config + destructive ops: admin-only. Read-only config stays open to any
1212
- // authenticated key so dashboards using 'read' scope can still populate.
1213
- router.post('/config', (0, roleGuard_1.roleGuard)('ADMIN'), (0, scopeGuard_1.scopeGuard)(['admin']), updateGlobalConfig);
1214
- router.post('/config/reset-metrics', (0, roleGuard_1.roleGuard)('ADMIN'), (0, scopeGuard_1.scopeGuard)(['admin']), resetMetrics);
1213
+ // The lab's settings. Reading them needs ADMIN, as the pages that show
1214
+ // them do; changing them needs SUPER_ADMIN and the admin scope. Through
1215
+ // 2.12 any member could read them and any admin change them: the stricter
1216
+ // guards in ConfigRouter were never reached, since these routes are
1217
+ // registered first and answer.
1218
+ router.get('/config', (0, roleGuard_1.roleGuard)('ADMIN'), getGlobalConfig);
1219
+ router.post('/config', changeSettingsGuard, (0, scopeGuard_1.scopeGuard)(['admin']), updateGlobalConfig);
1220
+ router.post('/config/reset-metrics', changeSettingsGuard, (0, scopeGuard_1.scopeGuard)(['admin']), resetMetrics);
1215
1221
  }
1216
1222
  exports.default = {
1217
1223
  register,