@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
@@ -1,5 +1,5 @@
1
1
  {
2
- "name": "prisma-client-a28196e0a1882c56bf7139825d8dbfc28b79d73db89e92652213deb3f08362bd",
2
+ "name": "prisma-client-ef3fbf8faed5c4c712a2dce2686e5afc3ad0aa576aedb9c4cd9e5c99cfc0f03c",
3
3
  "main": "index.js",
4
4
  "types": "index.d.ts",
5
5
  "browser": "index-browser.js",
@@ -205,6 +205,8 @@ model Device {
205
205
  batteryLevel Int?
206
206
  reservationReason String?
207
207
  reservedBy String?
208
+ /// The user who took the reservation; only they or an admin may release or extend it.
209
+ reservedByUserId String?
208
210
  reservedUntil Float?
209
211
  storageFree String?
210
212
  tags String?
@@ -280,6 +280,7 @@ exports.Prisma.DeviceScalarFieldEnum = {
280
280
  batteryLevel: 'batteryLevel',
281
281
  reservationReason: 'reservationReason',
282
282
  reservedBy: 'reservedBy',
283
+ reservedByUserId: 'reservedByUserId',
283
284
  reservedUntil: 'reservedUntil',
284
285
  storageFree: 'storageFree',
285
286
  tags: 'tags',
@@ -14,11 +14,15 @@ const config_1 = require("../config");
14
14
  // - reverse-proxy misconfigurations that strip SameSite enforcement
15
15
  // - older browsers whose SameSite implementation has known gaps
16
16
  //
17
- // Header-authed callers — the (x-xenon-access-key, x-xenon-token) pair —
18
- // are immune to CSRF by construction: a browser will not attach a custom
19
- // header without an explicit CORS preflight, and the apiRouter's
20
- // cors({origin:false}) already refuses preflights. Those requests pass
21
- // through unchanged.
17
+ // Header-authed callers — the (x-xenon-access-key, x-xenon-token) pair, or
18
+ // an `Authorization: Bearer` token — are immune to CSRF by construction: a
19
+ // browser will not attach either header cross-origin without an explicit CORS
20
+ // preflight, and the apiRouter's cors({origin:false}) already refuses
21
+ // preflights. Those requests pass through unchanged. A bearer request is
22
+ // authenticated by its token alone: authMiddleware answers a bad one 401 and
23
+ // never falls back to the cookie. Through 2.12 bearer callers (SDK, MCP
24
+ // tools) were held to the Origin check, so every state change they sent
25
+ // without a browser's Origin header was refused.
22
26
  const STATE_CHANGING_METHODS = new Set(['POST', 'PUT', 'DELETE', 'PATCH']);
23
27
  // Comma-separated list of allowed origins, intended for ops running the
24
28
  // dashboard behind a reverse proxy on a different host than the Xenon API.
@@ -51,6 +55,10 @@ function csrfMiddleware(req, res, next) {
51
55
  if (req.headers['x-xenon-access-key'] || req.headers['x-xenon-hub-token']) {
52
56
  return next();
53
57
  }
58
+ const authorization = req.headers.authorization;
59
+ if (typeof authorization === 'string' && authorization.startsWith('Bearer ')) {
60
+ return next();
61
+ }
54
62
  // Cookie-authed (or unauthenticated) state-changer. Require Origin or
55
63
  // Referer to match the Host we're being served from.
56
64
  const host = req.headers.host;
@@ -15,11 +15,25 @@ function categorize(req) {
15
15
  return 'heavy';
16
16
  return 'control';
17
17
  }
18
- function capacityFor(keyLimit, cat) {
18
+ const USER_READ_MULTIPLIER = 10;
19
+ function capacityFor(keyLimit, cat, perUser) {
19
20
  if (cat === 'heavy')
20
21
  return Math.max(10, Math.floor(keyLimit / 4));
22
+ if (cat === 'read' && perUser)
23
+ return keyLimit * USER_READ_MULTIPLIER;
21
24
  return keyLimit;
22
25
  }
26
+ /** Whose buckets a request draws from, and their per-minute budget; undefined is unlimited. */
27
+ function limitedCaller(req) {
28
+ const key = req.apiKey;
29
+ if (key)
30
+ return { id: key.id, rateLimit: key.rateLimit, perUser: false };
31
+ const auth = req.auth;
32
+ if (auth && (auth.kind === 'user-session' || auth.kind === 'bearer') && auth.userId) {
33
+ return { id: `user:${auth.userId}`, rateLimit: auth.rateLimit, perUser: true };
34
+ }
35
+ return undefined;
36
+ }
23
37
  function refill(b) {
24
38
  const now = Date.now();
25
39
  const elapsed = (now - b.lastRefill) / 1000;
@@ -28,14 +42,14 @@ function refill(b) {
28
42
  }
29
43
  function rateLimitMiddleware() {
30
44
  return function (req, res, next) {
31
- const key = req.apiKey;
32
- if (!key)
45
+ const caller = limitedCaller(req);
46
+ if (!caller)
33
47
  return next();
34
48
  const category = categorize(req);
35
- const bucketKey = `${key.id}:${category}`;
49
+ const bucketKey = `${caller.id}:${category}`;
36
50
  let bucket = buckets.get(bucketKey);
37
51
  if (!bucket) {
38
- const capacity = capacityFor(key.rateLimit, category);
52
+ const capacity = capacityFor(caller.rateLimit, category, caller.perUser);
39
53
  bucket = {
40
54
  tokens: capacity,
41
55
  lastRefill: Date.now(),
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.roleGuard = roleGuard;
4
+ exports.superAdminGuard = superAdminGuard;
4
5
  const RANK = {
5
6
  SUPER_ADMIN: 3,
6
7
  ADMIN: 2,
@@ -25,3 +26,22 @@ function roleGuard(min) {
25
26
  next();
26
27
  };
27
28
  }
29
+ /**
30
+ * SUPER_ADMIN only, refused in words a tester can act on: the dashboard shows
31
+ * the `error` of a refused change as it is.
32
+ */
33
+ function superAdminGuard(message) {
34
+ return function (req, res, next) {
35
+ const auth = req.auth;
36
+ if (!auth) {
37
+ res.status(401).json({ error: 'unauthenticated' });
38
+ return;
39
+ }
40
+ if (auth.role !== 'SUPER_ADMIN') {
41
+ // `message` too: the AI Engine page shows a refused test's message.
42
+ res.status(403).json({ error: message, message });
43
+ return;
44
+ }
45
+ next();
46
+ };
47
+ }
@@ -323,12 +323,20 @@ Fix: Add a pre-emptive check for the location permission dialog or use the \`aut
323
323
  /**
324
324
  * Finds coordinates for an element based on a visual description (Tier 4)
325
325
  */
326
- visualFind(screenshotBase64, description) {
327
- return __awaiter(this, void 0, void 0, function* () {
326
+ /**
327
+ * `throwOnError`: fail, rather than answer "not found", when no provider is
328
+ * configured or the call fails. Device control's "Test locator" asks for
329
+ * it; Appium's ai-icon findElement doesn't.
330
+ */
331
+ visualFind(screenshotBase64_1, description_1) {
332
+ return __awaiter(this, arguments, void 0, function* (screenshotBase64, description, opts = {}) {
328
333
  var _a, _b;
329
334
  this.initializeProvider();
330
- if (!this.isEnabled())
335
+ if (!this.isEnabled()) {
336
+ if (opts.throwOnError)
337
+ throw new Error('No AI provider is configured');
331
338
  return null;
339
+ }
332
340
  const prompt = `
333
341
  Task: Find the center coordinates (X, Y) of the element described as: "${description}"
334
342
  Response Format: JSON only, strictly { "x": number, "y": number }.
@@ -351,6 +359,8 @@ Fix: Add a pre-emptive check for the location permission dialog or use the \`aut
351
359
  else {
352
360
  logger_1.default.warn(`[AIService] visualFind failed: ${err.message}`);
353
361
  }
362
+ if (opts.throwOnError)
363
+ throw err;
354
364
  return null;
355
365
  }
356
366
  });
@@ -66,38 +66,46 @@ let NotificationService = class NotificationService {
66
66
  }
67
67
  }
68
68
  catch (err) {
69
- logger_1.default.error(`Failed to process webhook config ${config.id}: ${err}`);
69
+ logger_1.default.error(`Webhook ${config.id} (${config.url}) failed for ${eventType}: ${err}`);
70
70
  }
71
71
  }
72
72
  });
73
73
  }
74
+ /**
75
+ * Sends a sample event to a webhook the way a real one would be sent, and
76
+ * fails if the delivery does: "Send test" used to say it worked whatever
77
+ * happened, and ignored the webhook's type.
78
+ */
79
+ sendTest(url_1) {
80
+ return __awaiter(this, arguments, void 0, function* (url, type = 'slack', payloadTemplate) {
81
+ yield this.sendToWebhook({ url, type, payloadTemplate: payloadTemplate !== null && payloadTemplate !== void 0 ? payloadTemplate : null }, 'device_new', { udid: 'test-device-udid', name: 'Test Device', host: '127.0.0.1' });
82
+ });
83
+ }
84
+ /** Delivers one event. Throws when the webhook refuses or can't be reached. */
74
85
  sendToWebhook(config, eventType, payload) {
75
86
  return __awaiter(this, void 0, void 0, function* () {
76
87
  // Principal Logic: Use custom template if defined
77
88
  if (config.payloadTemplate) {
78
89
  const substitutedBody = this.substituteTemplate(config.payloadTemplate, Object.assign({ eventType }, payload));
90
+ // JSON if the template renders to JSON, otherwise sent as text. Only
91
+ // the parse may fall back: one catch around both used to post again,
92
+ // as text, when the JSON delivery itself failed.
93
+ let body;
79
94
  try {
80
- // Try to parse as JSON first
81
- const jsonBody = JSON.parse(substitutedBody);
82
- yield axios_1.default.post(config.url, jsonBody);
95
+ body = JSON.parse(substitutedBody);
83
96
  }
84
- catch (e) {
85
- // Fallback to sending as plain text or simple object
86
- yield axios_1.default.post(config.url, { text: substitutedBody });
97
+ catch (_a) {
98
+ body = { text: substitutedBody };
87
99
  }
100
+ yield axios_1.default.post(config.url, body);
88
101
  return;
89
102
  }
90
103
  if (config.type === 'slack') {
91
104
  yield this.sendSlackMessage(config.url, eventType, payload);
92
105
  }
93
106
  else {
94
- // Generic webhook fallback
95
- try {
96
- yield axios_1.default.post(config.url, { event: eventType, payload });
97
- }
98
- catch (err) {
99
- logger_1.default.error(`Webhook failed for ${config.url}: ${err}`);
100
- }
107
+ // Generic webhook
108
+ yield axios_1.default.post(config.url, { event: eventType, payload });
101
109
  }
102
110
  });
103
111
  }
@@ -154,13 +162,8 @@ let NotificationService = class NotificationService {
154
162
  },
155
163
  ],
156
164
  };
157
- try {
158
- yield axios_1.default.post(url, body);
159
- logger_1.default.info(`Selector Health digest sent to ${url}`);
160
- }
161
- catch (err) {
162
- logger_1.default.error(`Failed to send digest to ${url}: ${err}`);
163
- }
165
+ yield axios_1.default.post(url, body);
166
+ logger_1.default.info(`Selector Health digest sent to ${url}`);
164
167
  return;
165
168
  }
166
169
  default:
@@ -181,13 +184,10 @@ let NotificationService = class NotificationService {
181
184
  },
182
185
  ],
183
186
  };
184
- try {
185
- yield axios_1.default.post(url, body);
186
- logger_1.default.info(`Slack notification sent to ${url}`);
187
- }
188
- catch (err) {
189
- logger_1.default.error(`Failed to send Slack notification: ${err}`);
190
- }
187
+ // A failure is the caller's: dispatchEvent logs it and goes on to the
188
+ // next webhook; sendTest reports it.
189
+ yield axios_1.default.post(url, body);
190
+ logger_1.default.info(`Slack notification sent to ${url}`);
191
191
  });
192
192
  }
193
193
  };
@@ -79,9 +79,15 @@ let BugReportService = class BugReportService {
79
79
  const warnings = [];
80
80
  const window = (0, window_1.resolveWindow)(session, opts.mode, (_a = opts.windowSec) !== null && _a !== void 0 ? _a : types_1.SLICE_DEFAULT_SEC);
81
81
  const logs = yield this.collectLogs(opts.sessionId, window.startedAt, window.endedAt);
82
- const harText = (0, har_collector_1.collectHar)(opts.sessionId, config_1.config.sessionAssetsPath);
83
- if (!harText)
82
+ const harText = opts.includeNetwork
83
+ ? (0, har_collector_1.collectHar)(opts.sessionId, config_1.config.sessionAssetsPath)
84
+ : null;
85
+ if (!opts.includeNetwork) {
86
+ warnings.push('network capture left out: only admins can download it');
87
+ }
88
+ else if (!harText) {
84
89
  warnings.push('no network capture for this session');
90
+ }
85
91
  const aiSummary = (_b = session.ai_analysis) !== null && _b !== void 0 ? _b : null;
86
92
  const tmpDir = yield fs.promises.mkdtemp(path.join(os.tmpdir(), 'xenon-bugreport-'));
87
93
  const cleanups = [
@@ -104,12 +104,45 @@ function defaultNodePairAuthProvider() {
104
104
  function teamScope(callerTeamIds) {
105
105
  return callerTeamIds === undefined ? {} : { callerTeamIds };
106
106
  }
107
- /** The client's filters without a team list, which only the credential may set. */
107
+ /**
108
+ * The client's filters as a device-store filter, without a team list, which
109
+ * only the credential may set. `sdk` is the OS version (`platformVersion`,
110
+ * which wins when both are sent). `deviceName` isn't a store filter: see
111
+ * nameMatches.
112
+ */
108
113
  function clientFilters(filters) {
109
114
  const out = Object.assign({}, filters);
110
115
  delete out.callerTeamIds;
116
+ delete out.deviceName;
117
+ delete out.sdk;
118
+ // The availability fields are the lease's to decide, never the client's.
119
+ delete out.busy;
120
+ delete out.userBlocked;
121
+ delete out.offline;
122
+ if (typeof filters.sdk === 'string' && filters.sdk !== '' && out.platformVersion === undefined) {
123
+ out.platformVersion = filters.sdk;
124
+ }
111
125
  return out;
112
126
  }
127
+ /** `filters.deviceName`: the device's name, ignoring case. */
128
+ function nameMatches(deviceName) {
129
+ if (typeof deviceName !== 'string' || deviceName === '')
130
+ return () => true;
131
+ const wanted = deviceName.toLowerCase();
132
+ return (device) => typeof device.name === 'string' && device.name.toLowerCase() === wanted;
133
+ }
134
+ /**
135
+ * Whether a lease may take a matching device now. Session allocation skips
136
+ * the same devices: blocked by an admin, reserved, or failing its health
137
+ * check. Busy and already-leased devices are skipped by the lock itself.
138
+ */
139
+ function leasable(device, now) {
140
+ if (device.busy || device.userBlocked || device.offline)
141
+ return false;
142
+ if (device.reservedUntil && now < device.reservedUntil)
143
+ return false;
144
+ return device.healthStatus !== 'Unhealthy';
145
+ }
113
146
  // What a token for an unknown lease id is compared against: a real hash's
114
147
  // length, of a random token nobody holds.
115
148
  const UNKNOWN_LEASE_TOKEN_HASH = (0, leaseToken_1.hashToken)((0, leaseToken_1.generateToken)());
@@ -146,23 +179,41 @@ let LeaseService = class LeaseService {
146
179
  }
147
180
  create(req) {
148
181
  return __awaiter(this, void 0, void 0, function* () {
182
+ var _a;
149
183
  const durationMs = Math.max(MIN_DURATION_MS, Math.min(req.durationMs, MAX_LEASE_MS));
150
184
  const heartbeatSeconds = Math.max(MIN_HEARTBEAT_SECONDS, Math.min(req.heartbeatSeconds, MAX_HEARTBEAT_SECONDS));
151
- // Step 1: atomic find + lock, among the phones the caller can see.
152
- const teams = teamScope(req.callerTeamIds);
153
- const device = yield this.store.findAndLockDevice(Object.assign(Object.assign({}, clientFilters(req.filters)), teams));
154
- if (!device) {
155
- // Distinguish "no device matches the filter" (404) from "matching
156
- // devices exist but are all busy or already leased" (409). Spec §4.2.
157
- // Counted within the caller's teams too, so the answer can't reveal
158
- // that another team has a phone of this platform.
159
- const anyMatching = yield this.store.getDevices(Object.assign({ platform: req.filters.platform }, teams));
160
- if (anyMatching && anyMatching.length > 0) {
161
- throw new AllMatchingBusy(`all devices matching ${JSON.stringify(req.filters)} are busy`);
185
+ // Step 1: the devices matching every filter, among the phones the caller
186
+ // can see. None is "no device matches" (404); some, but none free, is
187
+ // "all busy" (409). Spec §4.2. Counted within the caller's teams, so the
188
+ // answer can't reveal that another team has such a phone. Through 2.12 only
189
+ // the platform was counted, so an unknown udid answered 409, and `sdk`
190
+ // and `deviceName` were dropped.
191
+ const filters = Object.assign(Object.assign({}, clientFilters(req.filters)), teamScope(req.callerTeamIds));
192
+ const matching = ((_a = (yield this.store.getDevices(filters))) !== null && _a !== void 0 ? _a : []).filter(nameMatches(req.filters.deviceName));
193
+ if (matching.length === 0) {
194
+ // A fixed sentence, so a hidden phone and an unknown udid answer alike.
195
+ throw new NoMatchingDevice('No device matches the filters');
196
+ }
197
+ // Step 2: lock the first free one atomically. Through 2.12 the lock took
198
+ // a blocked, reserved or unhealthy device as readily as any other.
199
+ const lockedAt = Date.now();
200
+ let device = null;
201
+ for (const candidate of matching.filter((d) => leasable(d, lockedAt))) {
202
+ const locked = yield this.store.findAndLockDevice(Object.assign(Object.assign({}, filters), { udid: [candidate.udid], filterByHost: candidate.host }));
203
+ if (!locked)
204
+ continue;
205
+ if (locked.udid === candidate.udid && locked.host === candidate.host) {
206
+ device = locked;
207
+ break;
162
208
  }
163
- throw new NoMatchingDevice(`no device matching ${JSON.stringify(req.filters)}`);
209
+ // The host filter is a substring match: another row of that udid was
210
+ // locked. Give it back.
211
+ yield this.store.updateDevice(locked.udid, locked.host, { busy: false });
212
+ }
213
+ if (!device) {
214
+ throw new AllMatchingBusy(`all devices matching ${JSON.stringify(req.filters)} are busy`);
164
215
  }
165
- // Step 2: port RPC (or local-call if device.host == this host)
216
+ // Step 3: port RPC (or local-call if device.host == this host)
166
217
  let ports;
167
218
  try {
168
219
  const purposes = String(device.platform).toLowerCase() === 'android'
@@ -182,7 +233,7 @@ let LeaseService = class LeaseService {
182
233
  yield this.store.updateDevice(device.udid, device.host, { busy: false });
183
234
  throw new DeviceUnhealthy(`port allocation failed: ${err.message}`, err);
184
235
  }
185
- // Step 3: build token + insert lease row
236
+ // Step 4: build token + insert lease row
186
237
  const token = (0, leaseToken_1.generateToken)();
187
238
  const tokenHash = (0, leaseToken_1.hashToken)(token);
188
239
  const now = Date.now();
@@ -212,7 +263,7 @@ let LeaseService = class LeaseService {
212
263
  yield this.db.portLease.deleteMany({ where: { port: { in: Object.values(ports) } } });
213
264
  throw err;
214
265
  }
215
- // Step 4: build the cap bag now that we have lease.id, persist + return.
266
+ // Step 5: build the cap bag now that we have lease.id, persist + return.
216
267
  // If this update fails (rare, transient DB error), roll the lease back so
217
268
  // we don't leave a zombie row with capabilityBag='' that would crash on
218
269
  // any later JSON.parse in authorizeSessionUse().
@@ -222,7 +273,7 @@ let LeaseService = class LeaseService {
222
273
  where: { id: lease.id },
223
274
  data: { capabilityBag: JSON.stringify(bag) },
224
275
  });
225
- // Step 5: backfill leaseId on the PortLease rows
276
+ // Step 6: backfill leaseId on the PortLease rows
226
277
  yield this.db.portLease.updateMany({
227
278
  where: { port: { in: Object.values(ports) } },
228
279
  data: { leaseId: lease.id },
@@ -232,15 +283,15 @@ let LeaseService = class LeaseService {
232
283
  try {
233
284
  yield this.db.lease.delete({ where: { id: lease.id } });
234
285
  }
235
- catch (_a) { }
286
+ catch (_b) { }
236
287
  try {
237
288
  yield this.db.portLease.deleteMany({ where: { port: { in: Object.values(ports) } } });
238
289
  }
239
- catch (_b) { }
290
+ catch (_c) { }
240
291
  try {
241
292
  yield this.store.updateDevice(device.udid, device.host, { busy: false });
242
293
  }
243
- catch (_c) { }
294
+ catch (_d) { }
244
295
  throw err;
245
296
  }
246
297
  return {
@@ -382,8 +382,13 @@ let OmniVisionService = OmniVisionService_1 = class OmniVisionService {
382
382
  /**
383
383
  * Proactive OCR Search: Finds elements matching text even if not in XML
384
384
  */
385
- findByText(driver, text) {
386
- return __awaiter(this, void 0, void 0, function* () {
385
+ /**
386
+ * `throwOnError`: fail instead of answering "no match" when the screenshot
387
+ * or OCR fails. Device control's "Test locator" asks for it; an Appium
388
+ * findElement doesn't, and turns the empty list into NoSuchElement.
389
+ */
390
+ findByText(driver_1, text_1) {
391
+ return __awaiter(this, arguments, void 0, function* (driver, text, opts = {}) {
387
392
  this.logger.info(`Searching for text: "${text}" via proactive OCR...`);
388
393
  try {
389
394
  const screenshot = yield driver.getScreenshot();
@@ -412,6 +417,8 @@ let OmniVisionService = OmniVisionService_1 = class OmniVisionService {
412
417
  }
413
418
  catch (err) {
414
419
  this.logger.error(`OCR proactive search failed: ${err.message}`);
420
+ if (opts.throwOnError)
421
+ throw err;
415
422
  return [];
416
423
  }
417
424
  });
@@ -419,12 +426,12 @@ let OmniVisionService = OmniVisionService_1 = class OmniVisionService {
419
426
  /**
420
427
  * AI-based visual find for icons or specific descriptions
421
428
  */
422
- findByIcon(driver, iconDescription) {
423
- return __awaiter(this, void 0, void 0, function* () {
429
+ findByIcon(driver_1, iconDescription_1) {
430
+ return __awaiter(this, arguments, void 0, function* (driver, iconDescription, opts = {}) {
424
431
  this.logger.info(`Searching for: "${iconDescription}" via AI Vision...`);
425
432
  try {
426
433
  const screenshot = yield driver.getScreenshot();
427
- const coordinates = yield AIService_1.AI_SERVICE.visualFind(screenshot, iconDescription);
434
+ const coordinates = yield AIService_1.AI_SERVICE.visualFind(screenshot, iconDescription, opts);
428
435
  if (coordinates) {
429
436
  const el = {
430
437
  id: `omni_ai_${Date.now()}`,
@@ -442,6 +449,8 @@ let OmniVisionService = OmniVisionService_1 = class OmniVisionService {
442
449
  }
443
450
  catch (err) {
444
451
  this.logger.error(`AI proactive find failed: ${err.message}`);
452
+ if (opts.throwOnError)
453
+ throw err;
445
454
  }
446
455
  return null;
447
456
  });
@@ -519,7 +519,7 @@ let RecordingOrchestrator = class RecordingOrchestrator {
519
519
  */
520
520
  stop(groupId) {
521
521
  return __awaiter(this, void 0, void 0, function* () {
522
- var _a, _b;
522
+ var _a, _b, _c, _d;
523
523
  ensureOtelInstruments();
524
524
  const span = api_1.trace.getTracer('xenon.recording').startSpan('xenon.recording.stop', {
525
525
  attributes: { [attributes_1.ATTR.RECORDING_GROUP_ID]: groupId },
@@ -538,6 +538,20 @@ let RecordingOrchestrator = class RecordingOrchestrator {
538
538
  const recordings = yield this.store.listGroup(groupId);
539
539
  const out = [];
540
540
  for (const r of recordings) {
541
+ // A phone whose recording already ended (its source ended, or this
542
+ // is a second stop), or that handleSourceEnded is finalizing now, is
543
+ // reported as it stands. Finalizing it again overwrote its end,
544
+ // duration and fail_reason, and let go of the phone a second time.
545
+ if (r.status === 'STOPPED' || r.status === 'FAILED' || this.finalizing.has(r.id)) {
546
+ out.push({
547
+ id: r.id,
548
+ udid: r.device_udid,
549
+ status: r.status,
550
+ durationMs: (_b = r.duration_ms) !== null && _b !== void 0 ? _b : undefined,
551
+ sizeBytes: (_c = r.size_bytes) !== null && _c !== void 0 ? _c : undefined,
552
+ });
553
+ continue;
554
+ }
541
555
  // Claim it so an ffmpeg exit landing mid-stop doesn't finalize the same
542
556
  // row from handleSourceEnded at the same time.
543
557
  this.finalizing.add(r.id);
@@ -555,7 +569,7 @@ let RecordingOrchestrator = class RecordingOrchestrator {
555
569
  }
556
570
  catch (err) {
557
571
  span.recordException(err);
558
- span.setStatus({ code: api_1.SpanStatusCode.ERROR, message: (_b = err === null || err === void 0 ? void 0 : err.message) !== null && _b !== void 0 ? _b : 'unknown' });
572
+ span.setStatus({ code: api_1.SpanStatusCode.ERROR, message: (_d = err === null || err === void 0 ? void 0 : err.message) !== null && _d !== void 0 ? _d : 'unknown' });
559
573
  throw err;
560
574
  }
561
575
  finally {
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.mountPath = mountPath;
4
+ exports.routesOf = routesOf;
5
+ /** '/api', '/control/:udid' or '' from a `use()` layer's regexp. */
6
+ function mountPath(layer) {
7
+ if (layer.regexp.fast_slash)
8
+ return '';
9
+ let n = 0;
10
+ return layer.regexp.source
11
+ .replace(/^\^/, '')
12
+ .replace(/\\\/\?\(\?=\\\/\|\$\)$/i, '')
13
+ .replace(/\(\?:\(\[\^\\\/\]\+\?\)\)/g, () => { var _a, _b; return `:${(_b = (_a = layer.keys[n++]) === null || _a === void 0 ? void 0 : _a.name) !== null && _b !== void 0 ? _b : 'param'}`; })
14
+ .replace(/\\\//g, '/')
15
+ .replace(/\\\./g, '.')
16
+ .replace(/\\-/g, '-');
17
+ }
18
+ function join(a, b) {
19
+ const joined = `${a}/${b}`.replace(/\/+/g, '/');
20
+ return joined.length > 1 ? joined.replace(/\/$/, '') : joined;
21
+ }
22
+ function routesOf(router, prefix = '') {
23
+ const out = new Set();
24
+ const walk = (stack, at) => {
25
+ var _a;
26
+ for (const layer of stack) {
27
+ if (layer.route) {
28
+ const path = join(at, String(layer.route.path));
29
+ for (const [method, on] of Object.entries(layer.route.methods)) {
30
+ if (on && method !== '_all')
31
+ out.add(`${method.toUpperCase()} ${path}`);
32
+ }
33
+ }
34
+ else if ((_a = layer.handle) === null || _a === void 0 ? void 0 : _a.stack) {
35
+ walk(layer.handle.stack, join(at, mountPath(layer)));
36
+ }
37
+ }
38
+ };
39
+ walk(router.stack, prefix);
40
+ return out;
41
+ }
@@ -214,12 +214,12 @@ describe('team boundary on /control (integration)', function () {
214
214
  it('gets 404 on screenshot', () => __awaiter(this, void 0, void 0, function* () {
215
215
  const res = yield screenshot(alice, TEAM_B_UDID);
216
216
  (0, chai_1.expect)(res.status).to.equal(404);
217
- (0, chai_1.expect)(res.text).to.equal('Device not found');
217
+ (0, chai_1.expect)(res.body).to.deep.equal({ error: 'not_found', message: 'Device not found' });
218
218
  }));
219
219
  it('gets 404 on tap', () => __awaiter(this, void 0, void 0, function* () {
220
220
  const res = yield tap(alice, TEAM_B_UDID);
221
221
  (0, chai_1.expect)(res.status).to.equal(404);
222
- (0, chai_1.expect)(res.text).to.equal('Device not found');
222
+ (0, chai_1.expect)(res.body).to.deep.equal({ error: 'not_found', message: 'Device not found' });
223
223
  }));
224
224
  it('gets 404 on stream/ticket, and no ticket', () => __awaiter(this, void 0, void 0, function* () {
225
225
  const res = yield ticket(alice, TEAM_B_UDID);