@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
@@ -187,13 +187,36 @@ function registerNode(request, response) {
187
187
  });
188
188
  });
189
189
  }
190
- function blockDevice(request, response) {
190
+ /**
191
+ * The phone a block or unblock names: exactly one row, by udid and host.
192
+ * Through 2.12 the body went to getDevice as a filter, so an empty body
193
+ * blocked the first phone in the store, and a udid alone the first row of it,
194
+ * whatever server it was on. Answers the refusal itself and returns
195
+ * undefined, or the row.
196
+ */
197
+ function deviceToBlock(request, response) {
191
198
  return __awaiter(this, void 0, void 0, function* () {
192
- const requestBody = request.body;
193
- const device = yield (0, device_service_1.getDevice)(requestBody);
194
- if (lodash_1.default.isNil(device)) {
195
- return response.status(404).json({ success: false, error: 'Device not found' });
199
+ var _a;
200
+ const { udid, host } = (_a = request.body) !== null && _a !== void 0 ? _a : {};
201
+ if (typeof udid !== 'string' || udid === '' || typeof host !== 'string' || host === '') {
202
+ response
203
+ .status(400)
204
+ .json({ success: false, error: 'bad_request', message: 'udid and host are required' });
205
+ return undefined;
196
206
  }
207
+ const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid, host });
208
+ if (!device) {
209
+ response.status(404).json({ success: false, error: 'Device not found' });
210
+ return undefined;
211
+ }
212
+ return device;
213
+ });
214
+ }
215
+ function blockDevice(request, response) {
216
+ return __awaiter(this, void 0, void 0, function* () {
217
+ const device = yield deviceToBlock(request, response);
218
+ if (!device)
219
+ return;
197
220
  try {
198
221
  yield (0, device_service_1.userBlockDevice)(device.udid, device.host);
199
222
  }
@@ -208,11 +231,9 @@ function blockDevice(request, response) {
208
231
  }
209
232
  function unBlockDevice(request, response) {
210
233
  return __awaiter(this, void 0, void 0, function* () {
211
- const requestBody = request.body;
212
- const device = yield (0, device_service_1.getDevice)(requestBody);
213
- if (lodash_1.default.isNil(device)) {
214
- return response.status(404).json({ success: false, error: 'Device not found' });
215
- }
234
+ const device = yield deviceToBlock(request, response);
235
+ if (!device)
236
+ return;
216
237
  try {
217
238
  yield (0, device_service_1.userUnblockDevice)(device.udid, device.host);
218
239
  }
@@ -295,6 +316,7 @@ function getQueueSummary(request, response) {
295
316
  }
296
317
  function nodeAdbStatusOnOtherHost(currentHost, request, response) {
297
318
  return __awaiter(this, void 0, void 0, function* () {
319
+ var _a;
298
320
  const { host } = request.params;
299
321
  // when host is this hub, return status from AndroidDeviceManager directly
300
322
  // otherwise, forward request to the node
@@ -312,7 +334,9 @@ function nodeAdbStatusOnOtherHost(currentHost, request, response) {
312
334
  }
313
335
  else {
314
336
  // find node url from database of devices
315
- const devices = yield store.findDevices({ host: { $contains: host } });
337
+ // The store's own host filter: findDevices hands its filter to Prisma
338
+ // as it is, and Prisma rejected the `$contains` that used to be here.
339
+ const devices = yield store.getDevices({ filterByHost: host });
316
340
  if (devices.length === 0) {
317
341
  response
318
342
  .status(404)
@@ -323,7 +347,17 @@ function nodeAdbStatusOnOtherHost(currentHost, request, response) {
323
347
  // remove wd/hub from url
324
348
  const normalizedUrl = device.host.replace(/\/wd\/hub$/, '');
325
349
  const url = `${normalizedUrl}/xenon/api/node/status`;
326
- const result = yield InternalHttpClient_1.InternalHttpClient.get(url);
350
+ let result;
351
+ try {
352
+ result = yield InternalHttpClient_1.InternalHttpClient.get(url);
353
+ }
354
+ catch (err) {
355
+ response.status(502).json({
356
+ error: 'node_unreachable',
357
+ message: `Could not get ${normalizedUrl}'s status: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`,
358
+ });
359
+ return;
360
+ }
327
361
  response.json(result);
328
362
  }
329
363
  });
@@ -404,7 +438,6 @@ function getActiveSessions(request, response) {
404
438
  return __awaiter(this, void 0, void 0, function* () {
405
439
  const { SessionManager } = yield Promise.resolve().then(() => __importStar(require('../../sessions/SessionManager')));
406
440
  const sessionManager = typedi_1.Container.get(SessionManager);
407
- const stats = sessionManager.getStats();
408
441
  const sessions = sessionManager.getAllSessions().map((s) => {
409
442
  var _a, _b, _c;
410
443
  return ({
@@ -418,8 +451,15 @@ function getActiveSessions(request, response) {
418
451
  // Phase 4A: filter by visibility of the underlying device.
419
452
  const auth = request.auth;
420
453
  const visibleSessions = yield (0, device_service_1.filterRowsByVisibleDevice)(sessions, auth === null || auth === void 0 ? void 0 : auth.teamIds, 'deviceUdid');
454
+ // The counts are of the sessions listed, so a member's describe only what
455
+ // they may see. Through 2.12 they counted every team's sessions.
456
+ const byType = { local: 0, remote: 0, cloud: 0 };
457
+ for (const session of visibleSessions) {
458
+ const type = String(session.type).toLowerCase();
459
+ byType[type] = (byType[type] || 0) + 1;
460
+ }
421
461
  response.json({
422
- stats,
462
+ stats: { total: visibleSessions.length, byType },
423
463
  sessions: visibleSessions,
424
464
  });
425
465
  });
@@ -22,15 +22,24 @@ const ROLE_SCOPES = {
22
22
  ADMIN: ['devices', 'sessions', 'read'],
23
23
  MEMBER: ['sessions', 'read'],
24
24
  };
25
- function widenedScope(requested, allowed) {
26
- // 'admin' is widening for any non-super-admin allowed set.
27
- for (const r of requested) {
28
- if (r === 'admin' && !allowed.includes('admin'))
29
- return true;
30
- if (!allowed.includes(r))
31
- return true;
32
- }
33
- return false;
25
+ const KNOWN_SCOPES = ['read', 'sessions', 'devices', 'admin'];
26
+ function isScope(value) {
27
+ return KNOWN_SCOPES.includes(value);
28
+ }
29
+ /** Whether a scope list grants `scope`; `admin` grants every scope, as scopeGuard reads it. */
30
+ function grants(scopes, scope) {
31
+ return scopes.includes('admin') || scopes.includes(scope);
32
+ }
33
+ /**
34
+ * Whether a token with `requested` would reach past the caller's role or past
35
+ * the credential creating it. Through 2.12 only the role was checked, so a
36
+ * `read`-only key of an admin could mint itself a `devices,sessions` token.
37
+ * And a SUPER_ADMIN, whose role's list is just `admin`, could not ask for a
38
+ * narrower token than that.
39
+ */
40
+ function widenedScope(requested, allowed, credential) {
41
+ const credentialScopes = credential.split(',').map((s) => s.trim());
42
+ return requested.some((r) => !grants(allowed, r) || !grants(credentialScopes, r));
34
43
  }
35
44
  function profileRouter() {
36
45
  const r = (0, express_1.Router)();
@@ -102,7 +111,7 @@ function profileRouter() {
102
111
  })));
103
112
  }));
104
113
  r.post('/tokens', (req, res) => __awaiter(this, void 0, void 0, function* () {
105
- var _a, _b;
114
+ var _a, _b, _c;
106
115
  const auth = requireAuth(req, res);
107
116
  if (!auth)
108
117
  return;
@@ -124,9 +133,14 @@ function profileRouter() {
124
133
  expiresAtDate = d;
125
134
  }
126
135
  const allowed = (_a = ROLE_SCOPES[auth.role]) !== null && _a !== void 0 ? _a : ROLE_SCOPES.MEMBER;
136
+ if (scopes !== undefined && (!Array.isArray(scopes) || !scopes.every(isScope))) {
137
+ return res.status(400).json({ error: `scopes must be some of ${KNOWN_SCOPES.join(', ')}` });
138
+ }
127
139
  const requested = scopes && scopes.length > 0 ? scopes : allowed;
128
- if (widenedScope(requested, allowed)) {
129
- return res.status(400).json({ error: 'cannot widen scopes beyond your role' });
140
+ if (widenedScope(requested, allowed, String((_b = auth.scopes) !== null && _b !== void 0 ? _b : ''))) {
141
+ return res
142
+ .status(400)
143
+ .json({ error: 'cannot widen scopes beyond your role or the credential you are using' });
130
144
  }
131
145
  const { id, raw } = yield apiKeySvc.create({
132
146
  name,
@@ -137,7 +151,7 @@ function profileRouter() {
137
151
  return res.status(201).json({
138
152
  id,
139
153
  token: raw,
140
- expiresAt: (_b = expiresAtDate === null || expiresAtDate === void 0 ? void 0 : expiresAtDate.toISOString()) !== null && _b !== void 0 ? _b : null,
154
+ expiresAt: (_c = expiresAtDate === null || expiresAtDate === void 0 ? void 0 : expiresAtDate.toISOString()) !== null && _c !== void 0 ? _c : null,
141
155
  });
142
156
  }));
143
157
  r.delete('/tokens/:id', (req, res) => __awaiter(this, void 0, void 0, function* () {
@@ -275,6 +275,11 @@ function summaryContext(req, all, visible) {
275
275
  const recLog = logger_1.default.scope('RecordingsRouter');
276
276
  const router = (0, express_1.Router)();
277
277
  router.use((0, roleGuard_1.roleGuard)('MEMBER'));
278
+ // Every recording write needs the `devices` scope: it holds, records and marks
279
+ // phones. Through 2.12 only DELETE asked, so a `read`-only key could start and
280
+ // stop recordings. Mounted on /recordings only: this router sits at the API's
281
+ // root, and a router-wide guard would also catch the routes after it.
282
+ router.use('/recordings', (0, scopeGuard_1.mutationScopeGuard)(['devices']));
278
283
  router.get('/recordings', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
279
284
  const parsed = (0, recordingRequests_1.parseLibraryQuery)(req.query);
280
285
  if (!parsed.ok)
@@ -559,8 +564,7 @@ router.get('/recordings/:groupId', (req, res) => __awaiter(void 0, void 0, void
559
564
  }));
560
565
  // Irreversible, so an API key needs `devices`, as on /control: owning the
561
566
  // recording, or an admin role, is not enough for a read-only key.
562
- const devicesScope = (0, scopeGuard_1.mutationScopeGuard)(['devices']);
563
- router.delete('/recordings/:groupId', devicesScope, (req, res) => __awaiter(void 0, void 0, void 0, function* () {
567
+ router.delete('/recordings/:groupId', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
564
568
  const { groupId } = req.params;
565
569
  try {
566
570
  const store = typedi_1.Container.get(recording_store_1.RecordingStore);
@@ -619,9 +623,11 @@ router.get('/recordings/:groupId/composite.mp4', (req, res) => __awaiter(void 0,
619
623
  if (!compositePath) {
620
624
  return res.status(404).json({ error: 'composite_not_found' });
621
625
  }
622
- res.setHeader('Content-Type', 'video/mp4');
623
- res.setHeader('Accept-Ranges', 'bytes');
624
- fs.createReadStream(compositePath).pipe(res);
626
+ // sendFile, as source.mp4: it answers Range requests (206, 416) so a
627
+ // player can seek. A piped stream sent `Accept-Ranges: bytes` and then the
628
+ // whole file, with 200, whatever was asked. DOWNLOAD_OPTIONS lets the
629
+ // path under ~/.cache through on Express 5 (see sourceMp4Handler).
630
+ return res.sendFile(path.resolve(compositePath), Object.assign(Object.assign({}, apps_1.DOWNLOAD_OPTIONS), { headers: { 'Content-Type': 'video/mp4' } }), (err) => handleSendError('composite.mp4', req, res, err));
625
631
  }));
626
632
  /**
627
633
  * Videos-only zip (mp4 files). Prefer this over proof bundle for dashboard
@@ -14,6 +14,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
15
  const express_1 = __importDefault(require("express"));
16
16
  const device_service_1 = require("../../data-service/device-service");
17
+ const device_store_1 = require("../../data-service/device-store");
18
+ const actor_1 = require("../../services/device-access/actor");
17
19
  const logger_1 = __importDefault(require("../../logger"));
18
20
  const scopeGuard_1 = require("../../middleware/scopeGuard");
19
21
  const roleGuard_1 = require("../../middleware/roleGuard");
@@ -36,16 +38,40 @@ router.use((0, roleGuard_1.roleGuard)('MEMBER'));
36
38
  // GET listings remain open to any authenticated key.
37
39
  router.use((0, scopeGuard_1.mutationScopeGuard)(['devices']));
38
40
  /**
39
- * The device a reservation route acts on, or undefined when the caller may not
40
- * see it. Teams are a device boundary: another team's phone takes the same 404
41
- * as a udid that doesn't exist, so the answer never says that it exists.
41
+ * The device a reservation route acts on: the row of that udid on that host,
42
+ * or undefined when there is none or the caller may not see it. Teams are a
43
+ * device boundary: another team's phone takes the same 404 as a udid that
44
+ * doesn't exist, so the answer never says that it exists. Through 2.12 the
45
+ * host was ignored when looking the phone up, then written to: a host with no
46
+ * such phone answered 200, and the reservation went nowhere.
42
47
  */
43
- function findVisibleDevice(req, udid) {
48
+ function findVisibleDevice(req, udid, host) {
44
49
  return __awaiter(this, void 0, void 0, function* () {
45
- const device = yield (0, device_service_1.getDevice)({ udid: [udid] });
50
+ if (typeof udid !== 'string' || typeof host !== 'string')
51
+ return undefined;
52
+ const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid, host });
46
53
  return device && (0, deviceVisibility_1.isDeviceVisible)(device.teamId, teamIdsOf(req)) ? device : undefined;
47
54
  });
48
55
  }
56
+ /**
57
+ * Whether the caller is the user who took a device's reservation. One from
58
+ * before 2.13 names no user, so anyone is.
59
+ */
60
+ function holdsReservation(req, device) {
61
+ return !device.reservedByUserId || (0, actor_1.resolveActor)(req).userId === device.reservedByUserId;
62
+ }
63
+ /**
64
+ * Whether the caller may release or extend a device's reservation: the user
65
+ * who took it, or an admin. Through 2.12 anyone who could see the phone could.
66
+ */
67
+ function mayChangeReservation(req, device) {
68
+ return (0, actor_1.resolveActor)(req).isAdmin || holdsReservation(req, device);
69
+ }
70
+ const NOT_HOLDER = {
71
+ success: false,
72
+ error: 'not_reservation_holder',
73
+ message: 'Only the person who reserved this device, or an admin, can change the reservation.',
74
+ };
49
75
  /** The caller's teams; undefined for an admin or an auth-disabled server. */
50
76
  function teamIdsOf(req) {
51
77
  var _a;
@@ -58,6 +84,8 @@ const DURATION_OPTIONS = {
58
84
  '4h': 4 * 60 * 60 * 1000,
59
85
  '8h': 8 * 60 * 60 * 1000,
60
86
  };
87
+ const MIN_DURATION_MS = 60 * 1000; // 1 minute
88
+ const MAX_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
61
89
  /**
62
90
  * GET /api/reservation
63
91
  * List all currently reserved devices
@@ -114,8 +142,6 @@ router.post('/', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
114
142
  });
115
143
  }
116
144
  // Bounds check: refuse negative, zero, NaN, Infinity, and anything over 24h.
117
- const MIN_DURATION_MS = 60 * 1000; // 1 minute
118
- const MAX_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
119
145
  if (!Number.isFinite(durationMs) ||
120
146
  durationMs < MIN_DURATION_MS ||
121
147
  durationMs > MAX_DURATION_MS) {
@@ -125,12 +151,15 @@ router.post('/', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
125
151
  });
126
152
  }
127
153
  // Check if device exists
128
- const device = yield findVisibleDevice(req, udid);
154
+ const device = yield findVisibleDevice(req, udid, host);
129
155
  if (!device) {
130
156
  return res.status(404).json({ success: false, error: 'Device not found' });
131
157
  }
132
158
  // Check if device is already reserved by someone else
133
- if ((0, device_service_1.isDeviceReserved)(device) && device.reservedBy !== reservedBy) {
159
+ // Renewing your own: the same user (with auth disabled every caller is
160
+ // one), under the same name.
161
+ if ((0, device_service_1.isDeviceReserved)(device) &&
162
+ !(holdsReservation(req, device) && device.reservedBy === reservedBy)) {
134
163
  return res.status(409).json({
135
164
  success: false,
136
165
  error: `Device is already reserved by ${device.reservedBy} until ${new Date(device.reservedUntil).toISOString()}`,
@@ -143,7 +172,7 @@ router.post('/', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
143
172
  error: 'Device is currently busy with an Appium session. Cannot reserve.',
144
173
  });
145
174
  }
146
- yield (0, device_service_1.reserveDevice)(udid, host, reservedBy, durationMs, reason);
175
+ yield (0, device_service_1.reserveDevice)(udid, host, reservedBy, durationMs, reason, (0, actor_1.resolveActor)(req).userId);
147
176
  const reservedUntil = Date.now() + durationMs;
148
177
  res.json({
149
178
  success: true,
@@ -171,13 +200,15 @@ router.delete('/:udid/:host', (req, res) => __awaiter(void 0, void 0, void 0, fu
171
200
  try {
172
201
  const { udid } = req.params;
173
202
  const host = decodeURIComponent(req.params.host);
174
- const device = yield findVisibleDevice(req, udid);
203
+ const device = yield findVisibleDevice(req, udid, host);
175
204
  if (!device) {
176
205
  return res.status(404).json({ success: false, error: 'Device not found' });
177
206
  }
178
207
  if (!(0, device_service_1.isDeviceReserved)(device)) {
179
208
  return res.status(400).json({ success: false, error: 'Device is not reserved' });
180
209
  }
210
+ if (!mayChangeReservation(req, device))
211
+ return res.status(403).json(NOT_HOLDER);
181
212
  yield (0, device_service_1.releaseReservation)(udid, host);
182
213
  res.json({
183
214
  success: true,
@@ -199,13 +230,15 @@ router.post('/:udid/:host/extend', (req, res) => __awaiter(void 0, void 0, void
199
230
  const { udid } = req.params;
200
231
  const host = decodeURIComponent(req.params.host);
201
232
  const { duration } = req.body;
202
- const device = yield findVisibleDevice(req, udid);
233
+ const device = yield findVisibleDevice(req, udid, host);
203
234
  if (!device) {
204
235
  return res.status(404).json({ success: false, error: 'Device not found' });
205
236
  }
206
237
  if (!(0, device_service_1.isDeviceReserved)(device)) {
207
238
  return res.status(400).json({ success: false, error: 'Device is not reserved' });
208
239
  }
240
+ if (!mayChangeReservation(req, device))
241
+ return res.status(403).json(NOT_HOLDER);
209
242
  // Parse duration
210
243
  let extensionMs;
211
244
  if (typeof duration === 'string' && DURATION_OPTIONS[duration]) {
@@ -220,9 +253,24 @@ router.post('/:udid/:host/extend', (req, res) => __awaiter(void 0, void 0, void
220
253
  error: `Invalid duration. Use: ${Object.keys(DURATION_OPTIONS).join(', ')} or a number in milliseconds`,
221
254
  });
222
255
  }
223
- // Extend from current expiry time
224
- const newExpiry = (device.reservedUntil || Date.now()) + extensionMs;
225
- yield (0, device_service_1.reserveDevice)(udid, host, device.reservedBy, newExpiry - Date.now(), device.reservationReason);
256
+ // Extend from current expiry time, to at most 24 hours from now, as a new
257
+ // reservation may last. Through 2.12 any number was accepted, a negative
258
+ // one included, so one call could hold a phone for years.
259
+ const now = Date.now();
260
+ const newExpiry = (device.reservedUntil || now) + extensionMs;
261
+ if (!Number.isFinite(extensionMs) || extensionMs < MIN_DURATION_MS) {
262
+ return res.status(400).json({
263
+ success: false,
264
+ error: `duration must be at least ${MIN_DURATION_MS}ms (1 minute)`,
265
+ });
266
+ }
267
+ if (newExpiry - now > MAX_DURATION_MS) {
268
+ return res.status(400).json({
269
+ success: false,
270
+ error: 'A reservation can end at most 24 hours from now',
271
+ });
272
+ }
273
+ yield (0, device_service_1.reserveDevice)(udid, host, device.reservedBy, newExpiry - now, device.reservationReason, device.reservedByUserId);
226
274
  res.json({
227
275
  success: true,
228
276
  message: `Reservation extended for device ${udid}`,
@@ -19,6 +19,7 @@ const typedi_1 = require("typedi");
19
19
  const crypto_1 = __importDefault(require("crypto"));
20
20
  const UserService_1 = require("../../services/UserService");
21
21
  const roleGuard_1 = require("../../middleware/roleGuard");
22
+ const scopeGuard_1 = require("../../middleware/scopeGuard");
22
23
  const userAuthorization_1 = require("../../services/identity/userAuthorization");
23
24
  const prisma_1 = require("../../prisma");
24
25
  const PasswordResetService_1 = require("../../services/PasswordResetService");
@@ -36,6 +37,9 @@ function makeTempPassword() {
36
37
  function usersRouter() {
37
38
  const r = (0, express_1.Router)();
38
39
  r.use((0, roleGuard_1.roleGuard)('ADMIN'));
40
+ // And the admin scope, as /apikeys and /teams ask: an admin's `read`-only
41
+ // key or token is not a grant to manage accounts. Through 2.12 it was.
42
+ r.use((0, scopeGuard_1.scopeGuard)(['admin']));
39
43
  const userSvc = typedi_1.Container.get(UserService_1.UserService);
40
44
  function getAuth(req) {
41
45
  return req.auth;
@@ -54,31 +54,40 @@ function deleteConfig(req, res) {
54
54
  res.json({ success: true });
55
55
  }
56
56
  catch (err) {
57
+ // Prisma's "record to delete does not exist".
58
+ if ((err === null || err === void 0 ? void 0 : err.code) === 'P2025') {
59
+ return res.status(404).json({ error: 'not_found', message: 'Webhook not found' });
60
+ }
57
61
  logger_1.default.error(`Failed to delete webhook config: ${err}`);
58
62
  res.status(500).json({ error: 'Failed to delete configuration' });
59
63
  }
60
64
  });
61
65
  }
66
+ // Sends a sample event the way the webhook's real events go out (its type
67
+ // and template), and says whether it was delivered.
62
68
  function testWebhook(req, res) {
63
69
  return __awaiter(this, void 0, void 0, function* () {
64
- const { url, type } = req.body;
70
+ var _a, _b;
71
+ const { url, type, payloadTemplate } = (_a = req.body) !== null && _a !== void 0 ? _a : {};
72
+ if (!url || typeof url !== 'string') {
73
+ return res.status(400).json({ error: 'url is required' });
74
+ }
65
75
  try {
66
- yield typedi_1.Container.get(NotificationService_1.NotificationService).sendSlackMessage(url, 'device_new', {
67
- udid: 'test-device-udid',
68
- name: 'Test Device',
69
- host: '127.0.0.1',
70
- });
76
+ yield typedi_1.Container.get(NotificationService_1.NotificationService).sendTest(url, type || 'slack', payloadTemplate);
71
77
  res.json({ success: true });
72
78
  }
73
79
  catch (err) {
74
- res.status(500).json({ error: `Test failed: ${err}` });
80
+ res.status(502).json({ error: 'delivery_failed', message: (_b = err === null || err === void 0 ? void 0 : err.message) !== null && _b !== void 0 ? _b : String(err) });
75
81
  }
76
82
  });
77
83
  }
78
84
  function register(parentRouter) {
79
85
  const webhookRouterInstance = (0, express_1.Router)();
80
86
  webhookRouterInstance.use((0, roleGuard_1.roleGuard)('ADMIN'));
81
- webhookRouterInstance.get('/', getConfigs);
87
+ // The list holds every webhook's URL, which is often its secret (Slack's
88
+ // are), so reading it needs the admin scope too. Through 2.12 an admin's
89
+ // `read`-only key could list them.
90
+ webhookRouterInstance.get('/', (0, scopeGuard_1.scopeGuard)(['admin']), getConfigs);
82
91
  // Webhook mutations are admin-only: adding / removing / test-firing global
83
92
  // webhooks is a fleet-wide config change.
84
93
  webhookRouterInstance.post('/', (0, scopeGuard_1.scopeGuard)(['admin']), addConfig);