@xenon-device-management/xenon 2.0.0 → 2.1.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 (279) hide show
  1. package/README.md +2 -1
  2. package/lib/package.json +1 -2
  3. package/lib/public/assets/{AnnotationOverlay-BmTPCAds.js → AnnotationOverlay-C-Pw_hvw.js} +1 -1
  4. package/lib/public/assets/{ApiKeyGate-B5eB7f8B.js → ApiKeyGate-DmYQ0oSk.js} +1 -1
  5. package/lib/public/assets/BugReportButton-n8T1dFUp.js +10 -0
  6. package/lib/public/assets/{DeviceMosaicView-9DUoZnQi.js → DeviceMosaicView-D-rGOBbJ.js} +1 -1
  7. package/lib/public/assets/{EmptyState-lvyNiXev.js → EmptyState-DExETevp.js} +1 -1
  8. package/lib/public/assets/{FieldGroup-BduMPM9K.js → FieldGroup-CCdFlZm6.js} +1 -1
  9. package/lib/public/assets/{FilterMenu-BSqX6I94.js → FilterMenu-DiUSOSL-.js} +1 -1
  10. package/lib/public/assets/{Menu-Ba5cT7jZ.js → Menu-ZOfpH6gh.js} +1 -1
  11. package/lib/public/assets/{Modal-CceoMY7D.js → Modal-CZNtoB-1.js} +1 -1
  12. package/lib/public/assets/{RecordingPage-6T9dOidc.js → RecordingPage-BKp6h8Jl.js} +1 -1
  13. package/lib/public/assets/{RecordingsPage-qj6FdZXR.js → RecordingsPage-Bf881FX4.js} +1 -1
  14. package/lib/public/assets/{SegmentedControl-CSRuZ8TR.js → SegmentedControl-EUoyizvW.js} +1 -1
  15. package/lib/public/assets/{SettingCard-Bp1kz4By.js → SettingCard-ClT4hf4_.js} +1 -1
  16. package/lib/public/assets/{Table-LV7zFyZg.js → Table-ucR4bnmc.js} +1 -1
  17. package/lib/public/assets/{activity-D17TUloi.js → activity-K-hdylwq.js} +1 -1
  18. package/lib/public/assets/{ai-settings-qvbhY89P.js → ai-settings-BLhdwI3c.js} +1 -1
  19. package/lib/public/assets/{api-keys-BfpvdG7G.js → api-keys-BbiN7rBI.js} +1 -1
  20. package/lib/public/assets/{apps-Dbnzd0fW.js → apps-Dez1petz.js} +1 -1
  21. package/lib/public/assets/{arrow-left-DD1VjtC7.js → arrow-left-UjmFHLAJ.js} +1 -1
  22. package/lib/public/assets/{arrow-right-D0pnM3ma.js → arrow-right-BVyEigbu.js} +1 -1
  23. package/lib/public/assets/{arrow-up-right-BIC8JOV6.js → arrow-up-right-B-ZlBof3.js} +1 -1
  24. package/lib/public/assets/{auth-shell-DWHPXSnI.js → auth-shell-DAQjAgUv.js} +2 -2
  25. package/lib/public/assets/{builds-page-ClCNBUv_.js → builds-page-CmIPa5JK.js} +1 -1
  26. package/lib/public/assets/{button-xtAhYR-f.js → button-Dd-IhMC1.js} +1 -1
  27. package/lib/public/assets/{calendar-CupwJ7Bm.js → calendar-CAIeJwa5.js} +1 -1
  28. package/lib/public/assets/{check-CyuEoKkd.js → check-C4Jrjg8f.js} +1 -1
  29. package/lib/public/assets/{chevron-left-DgX-F9xK.js → chevron-left-BGzlf-4i.js} +1 -1
  30. package/lib/public/assets/{chevron-right-BKJgJnU9.js → chevron-right-_wYqDjDC.js} +1 -1
  31. package/lib/public/assets/{circle-check-CZdg8mMa.js → circle-check-Ckn00yMB.js} +1 -1
  32. package/lib/public/assets/{circle-x-CZH6t9z-.js → circle-x-pdVkomLa.js} +1 -1
  33. package/lib/public/assets/{clock-Cd-t7xpe.js → clock-B8vmwkHj.js} +1 -1
  34. package/lib/public/assets/{copy-C6zerX3I.js → copy-BAqnFI-F.js} +1 -1
  35. package/lib/public/assets/{device-explorer-B1pI6b_M.js → device-explorer-CYq_4F_N.js} +3 -3
  36. package/lib/public/assets/device-explorer-D2AqrrLN.css +1 -0
  37. package/lib/public/assets/{download-x9WP7mcL.js → download-B6UEAp_1.js} +1 -1
  38. package/lib/public/assets/{forgot-password-B0TEH4N-.js → forgot-password-CLqNvmrN.js} +1 -1
  39. package/lib/public/assets/{funnel-Cb40MfFN.js → funnel-Da90BBbT.js} +1 -1
  40. package/lib/public/assets/{index-BR1wTIPC.js → index-BuIIcmGp.js} +2 -2
  41. package/lib/public/assets/{index-BvH_ekRy.js → index-MX7IzqyW.js} +1 -1
  42. package/lib/public/assets/{input-DejcCiTI.js → input-Bq9aY-We.js} +1 -1
  43. package/lib/public/assets/{keycodes-Mavx5iGg.js → keycodes-CIhXIJyP.js} +1 -1
  44. package/lib/public/assets/{layers-BKqYLiHE.js → layers-bXjpswQI.js} +1 -1
  45. package/lib/public/assets/{lock-LFsFhc17.js → lock-pJ72y5Fy.js} +1 -1
  46. package/lib/public/assets/{login-x2pkzt-t.js → login-UOcHALV2.js} +1 -1
  47. package/lib/public/assets/{maintenance-settings-CYafcoV-.js → maintenance-settings-XnJgp5yb.js} +1 -1
  48. package/lib/public/assets/{monitor-_7_uJ8d9.js → monitor-QkAhI1Jx.js} +1 -1
  49. package/lib/public/assets/{mouse-pointer-2-BfLuURKN.js → mouse-pointer-2-B42Da0m4.js} +1 -1
  50. package/lib/public/assets/{network-Z_zBhpsD.js → network-CKVqcyp6.js} +1 -1
  51. package/lib/public/assets/{overview-BY5uv08S.js → overview-BORGkN3N.js} +1 -1
  52. package/lib/public/assets/{page-header-CP2UkykG.js → page-header-CKWFOjOI.js} +1 -1
  53. package/lib/public/assets/{plus-BTD0dnQ0.js → plus-BpFKVdC8.js} +1 -1
  54. package/lib/public/assets/{profile-page-BhJZuCS7.js → profile-page-CcbKBicY.js} +1 -1
  55. package/lib/public/assets/{reset-password-CMN0SO81.js → reset-password-BA0W9bxl.js} +1 -1
  56. package/lib/public/assets/{runbook-page-CBpPX9_5.js → runbook-page-Cp7ZBq3s.js} +1 -1
  57. package/lib/public/assets/{select-Uc9E5pye.js → select-DIZWIoVR.js} +1 -1
  58. package/lib/public/assets/{selector-detail-page-BSyBnRuU.js → selector-detail-page-C_4YuvAp.js} +1 -1
  59. package/lib/public/assets/{selector-health-DzqxNkzm.js → selector-health-CXjD_Xth.js} +1 -1
  60. package/lib/public/assets/{selector-health-page-CyPLjoD1.js → selector-health-page-CTqIHeoG.js} +1 -1
  61. package/lib/public/assets/{session-detail-page-BZQZN1P-.js → session-detail-page-B0KYkk8z.js} +1 -1
  62. package/lib/public/assets/{settings-CkSWiZq8.js → settings-BcRBKUXd.js} +1 -1
  63. package/lib/public/assets/{status-pill-outline-BFYni2qo.js → status-pill-outline-Dh2PXSf_.js} +1 -1
  64. package/lib/public/assets/{tablet-BSd-xSZK.js → tablet-_sXqZztt.js} +1 -1
  65. package/lib/public/assets/{teams-PpoEQz31.js → teams-DGHT8MKm.js} +1 -1
  66. package/lib/public/assets/{trash-2-BorJv5_u.js → trash-2-BV88cV3Z.js} +1 -1
  67. package/lib/public/assets/{upload-C3i7sAZl.js → upload-CIDH9wkj.js} +1 -1
  68. package/lib/public/assets/{use-password-reset-mode-CeZQ3EsH.js → use-password-reset-mode-DYGuSNwl.js} +1 -1
  69. package/lib/public/assets/{users-CEJ3fC78.js → users-7yviqflc.js} +1 -1
  70. package/lib/public/assets/{users-Drd7ifJx.js → users-BUE7MoTq.js} +1 -1
  71. package/lib/public/assets/{video-off-CNzn6bPE.js → video-off-BTRxtlGo.js} +1 -1
  72. package/lib/public/assets/{webhook-settings-BFqX6QXV.js → webhook-settings-DMKD9Pgm.js} +1 -1
  73. package/lib/public/assets/{zap-4yN8Jdfw.js → zap-CWE94qcK.js} +1 -1
  74. package/lib/public/index.html +1 -1
  75. package/lib/src/InternalHttpClient.js +5 -1
  76. package/lib/src/XenonCapabilityManager.js +15 -1
  77. package/lib/src/app/appiumBasePath.js +32 -0
  78. package/lib/src/app/index.js +12 -0
  79. package/lib/src/app/insertBeforeRoutes.js +24 -4
  80. package/lib/src/app/registerCommandAuth.js +152 -33
  81. package/lib/src/app/routers/control.js +57 -13
  82. package/lib/src/app/routers/grid.js +28 -8
  83. package/lib/src/app/ws/h264StreamWs.js +13 -8
  84. package/lib/src/app/ws/logcatWs.js +12 -7
  85. package/lib/src/app/ws/upgradeRouter.js +215 -0
  86. package/lib/src/dashboard/event-manager.js +7 -6
  87. package/lib/src/data-service/device-service.js +152 -49
  88. package/lib/src/data-service/device-store.js +81 -10
  89. package/lib/src/data-service/deviceClaims.js +98 -0
  90. package/lib/src/data-service/deviceFieldOwners.js +32 -10
  91. package/lib/src/data-service/prisma-store.js +94 -31
  92. package/lib/src/device-managers/AndroidDeviceManager.js +13 -15
  93. package/lib/src/device-managers/HealthMonitorService.js +17 -2
  94. package/lib/src/device-managers/IOSDeviceManager.js +5 -1
  95. package/lib/src/device-managers/NodeDevices.js +7 -2
  96. package/lib/src/device-managers/android/AndroidH264StreamService.js +3 -3
  97. package/lib/src/device-managers/android/AndroidStreamService.js +6 -5
  98. package/lib/src/device-managers/android/previewHold.js +2 -2
  99. package/lib/src/device-managers/ios/IOSDiscoveryService.js +19 -8
  100. package/lib/src/device-managers/ios/IOSStreamService.js +158 -38
  101. package/lib/src/device-managers/localDeviceHosts.js +86 -0
  102. package/lib/src/device-managers/ownDeviceRow.js +38 -0
  103. package/lib/src/device-utils.js +78 -24
  104. package/lib/src/gateway/createHandoff.js +73 -0
  105. package/lib/src/gateway/defaultGateway.js +80 -0
  106. package/lib/src/gateway/forwardToNode.js +147 -0
  107. package/lib/src/gateway/hubSessionToken.js +356 -0
  108. package/lib/src/gateway/internalCall.js +90 -0
  109. package/lib/src/gateway/nodeSessionStatus.js +140 -0
  110. package/lib/src/gateway/nodeWebDriverUrl.js +139 -0
  111. package/lib/src/gateway/sessionCreate.js +136 -0
  112. package/lib/src/gateway/sessionGateway.js +201 -0
  113. package/lib/src/gateway/sessionLocator.js +100 -0
  114. package/lib/src/generated/client/edge.js +7 -4
  115. package/lib/src/generated/client/index-browser.js +4 -1
  116. package/lib/src/generated/client/index.d.ts +112 -1
  117. package/lib/src/generated/client/index.js +7 -4
  118. package/lib/src/generated/client/package.json +1 -1
  119. package/lib/src/generated/client/schema.prisma +9 -0
  120. package/lib/src/generated/client/wasm.js +4 -1
  121. package/lib/src/helpers/index.js +5 -1
  122. package/lib/src/logger.js +5 -0
  123. package/lib/src/middleware/authMiddleware.js +80 -0
  124. package/lib/src/middleware/commandAuth.js +31 -17
  125. package/lib/src/middleware/csrfMiddleware.js +3 -2
  126. package/lib/src/middleware/sessionListingFilter.js +153 -0
  127. package/lib/src/middleware/sessionUpgradeGuard.js +203 -0
  128. package/lib/src/plugin.js +27 -1
  129. package/lib/src/prisma.js +2 -1
  130. package/lib/src/services/DeviceReconciler.js +5 -2
  131. package/lib/src/services/OrphanSweeper.js +12 -19
  132. package/lib/src/services/PortAllocator.js +75 -16
  133. package/lib/src/services/ServerManager.js +50 -30
  134. package/lib/src/services/SessionLifecycleService.js +335 -76
  135. package/lib/src/services/ShutdownCoordinator.js +7 -1
  136. package/lib/src/services/SocketServer.js +11 -2
  137. package/lib/src/services/device-access/LiveSessionOwners.js +53 -0
  138. package/lib/src/services/device-access/SessionOwnerResolver.js +74 -0
  139. package/lib/src/services/lease/LeaseOrphanSweeper.js +32 -6
  140. package/lib/src/services/lease/LeaseService.js +19 -5
  141. package/lib/src/services/session/nodeCreateCaps.js +55 -0
  142. package/lib/src/services/session/sessionCredentials.js +7 -59
  143. package/lib/src/sessions/CloudSession.js +12 -0
  144. package/lib/src/sessions/LocalSession.js +52 -26
  145. package/lib/src/sessions/RemoteSession.js +116 -9
  146. package/lib/src/sessions/SessionManager.js +49 -6
  147. package/lib/src/sessions/appiumUmbrella.js +32 -0
  148. package/lib/test/helpers/appium-umbrella.js +84 -0
  149. package/lib/test/helpers/fake-device-table.js +97 -0
  150. package/lib/test/helpers/loki-stores.js +41 -0
  151. package/lib/test/helpers/loopbackRequest.js +77 -0
  152. package/lib/test/helpers/scratch-database.js +100 -0
  153. package/lib/test/helpers/scratch-port-leases.js +119 -0
  154. package/lib/test/helpers/test-container.js +35 -27
  155. package/lib/test/integration/androidDevices.spec.js +5 -4
  156. package/lib/test/integration/ios/01iOSSimulator.spec.js +6 -5
  157. package/lib/test/integration/ios/02iOSDevices.spec.js +3 -2
  158. package/lib/test/integration/prisma-device-store-add-devices.spec.js +14 -4
  159. package/lib/test/unit/AndroidDeviceManager.spec.js +8 -1
  160. package/lib/test/unit/DeviceModel.spec.js +9 -9
  161. package/lib/test/unit/HealingCascade.test.js +5 -1
  162. package/lib/test/unit/OrphanSweeper.test.js +17 -13
  163. package/lib/test/unit/SessionHeartbeatWrite.test.js +6 -7
  164. package/lib/test/unit/StreamTicketService.spec.js +4 -1
  165. package/lib/test/unit/TokenEndpoint.spec.js +17 -3
  166. package/lib/test/unit/aggregate-hotspots-session-scope.spec.js +5 -5
  167. package/lib/test/unit/android-h264-service.spec.js +6 -0
  168. package/lib/test/unit/android-stream-stall-eof.spec.js +10 -0
  169. package/lib/test/unit/apps-team-visibility.spec.js +16 -16
  170. package/lib/test/unit/auth-me-auth-disabled.test.js +7 -4
  171. package/lib/test/unit/authMiddleware.appTicket.spec.js +5 -5
  172. package/lib/test/unit/authMiddleware.bearer.spec.js +4 -1
  173. package/lib/test/unit/authMiddleware.mcpAudience.spec.js +4 -1
  174. package/lib/test/unit/authMiddleware.streamTicket.spec.js +19 -7
  175. package/lib/test/unit/authMiddleware.test.js +3 -0
  176. package/lib/test/unit/bug-report/route.spec.js +9 -9
  177. package/lib/test/unit/cliargs-admin-only.spec.js +3 -3
  178. package/lib/test/unit/clipboard-route.spec.js +18 -8
  179. package/lib/test/unit/command-auth-appium-server.spec.js +187 -32
  180. package/lib/test/unit/command-auth.spec.js +31 -31
  181. package/lib/test/unit/control-device-lookup.spec.js +9 -9
  182. package/lib/test/unit/control-forward-decision.spec.js +115 -0
  183. package/lib/test/unit/control-lookup-failure.spec.js +3 -3
  184. package/lib/test/unit/create-error-message.spec.js +43 -0
  185. package/lib/test/unit/create-handoff.spec.js +86 -0
  186. package/lib/test/unit/dashboard-events-team-scope.spec.js +6 -3
  187. package/lib/test/unit/device-access-guard.spec.js +28 -28
  188. package/lib/test/unit/device-service.spec.js +4 -0
  189. package/lib/test/unit/device-store-clear-at-boot.spec.js +48 -0
  190. package/lib/test/unit/device-sync-lost-update.spec.js +17 -89
  191. package/lib/test/unit/device-team-guard.spec.js +20 -20
  192. package/lib/test/unit/device-utils.spec.js +8 -0
  193. package/lib/test/unit/extract-team-cap.spec.js +65 -0
  194. package/lib/test/unit/grid-admin-only.spec.js +3 -3
  195. package/lib/test/unit/healing-tier-gate.spec.js +5 -1
  196. package/lib/test/unit/health-monitor-own-phones.spec.js +102 -0
  197. package/lib/test/unit/health-monitor-stream-lookup.spec.js +11 -1
  198. package/lib/test/unit/helpers.spec.js +27 -0
  199. package/lib/test/unit/hub-boot-sweep-recovery.spec.js +192 -0
  200. package/lib/test/unit/hub-create-once.spec.js +190 -0
  201. package/lib/test/unit/hub-create-session-umbrella.spec.js +160 -0
  202. package/lib/test/unit/hub-gateway-create-appium-server.spec.js +591 -0
  203. package/lib/test/unit/hub-node-control-forward.spec.js +291 -0
  204. package/lib/test/unit/hub-node-gateway-appium-server.spec.js +594 -0
  205. package/lib/test/unit/hub-node-race.spec.js +401 -0
  206. package/lib/test/unit/hub-node-same-host.spec.js +356 -0
  207. package/lib/test/unit/hub-register-removal.spec.js +165 -0
  208. package/lib/test/unit/hub-remote-session-heartbeat.spec.js +277 -0
  209. package/lib/test/unit/hub-routed-session-recovery.spec.js +151 -0
  210. package/lib/test/unit/hub-session-token.spec.js +265 -0
  211. package/lib/test/unit/insert-before-routes.spec.js +5 -5
  212. package/lib/test/unit/install-repository-app-team.spec.js +2 -2
  213. package/lib/test/unit/interceptor/MitmProxyHost.spec.js +23 -10
  214. package/lib/test/unit/internal-call.spec.js +181 -0
  215. package/lib/test/unit/ios-discovery-same-host.spec.js +193 -0
  216. package/lib/test/unit/ios-stream-appium-tunnel.spec.js +317 -0
  217. package/lib/test/unit/ios-stream-port-leases.spec.js +299 -0
  218. package/lib/test/unit/ios-wda-status-identity.spec.js +84 -0
  219. package/lib/test/unit/is-xenon-running.spec.js +69 -0
  220. package/lib/test/unit/lease/LeaseOrphanSweeper.spec.js +2 -2
  221. package/lib/test/unit/lease/LeaseService.spec.js +28 -1
  222. package/lib/test/unit/lease/lease-reap-held-phone.spec.js +154 -0
  223. package/lib/test/unit/lease/lease-sweeper-expiry.spec.js +92 -0
  224. package/lib/test/unit/lease/lease-team-visibility.spec.js +2 -2
  225. package/lib/test/unit/lease/lease-token-log-filter.spec.js +242 -64
  226. package/lib/test/unit/lease/lease-token-oracle.spec.js +20 -7
  227. package/lib/test/unit/lease/session-lease-proof.spec.js +8 -3
  228. package/lib/test/unit/logcat-ws.spec.js +4 -1
  229. package/lib/test/unit/loopback-request.spec.js +81 -0
  230. package/lib/test/unit/node-create-caps.spec.js +54 -0
  231. package/lib/test/unit/node-gateway-create-appium-server.spec.js +354 -0
  232. package/lib/test/unit/node-hub-session-owner.spec.js +223 -0
  233. package/lib/test/unit/omni-vision.spec.js +7 -3
  234. package/lib/test/unit/plugin.spec.js +45 -4
  235. package/lib/test/unit/port-allocator-os-probe.spec.js +151 -0
  236. package/lib/test/unit/profile-router.test.js +11 -11
  237. package/lib/test/unit/queue-requester.spec.js +5 -0
  238. package/lib/test/unit/queue-team-scope-routes.spec.js +17 -16
  239. package/lib/test/unit/queue-visibility.spec.js +1 -1
  240. package/lib/test/unit/recording-downloads-visibility.spec.js +2 -2
  241. package/lib/test/unit/recording-instrumentation.spec.js +5 -0
  242. package/lib/test/unit/recording-source-ended.spec.js +5 -0
  243. package/lib/test/unit/recording-store.spec.js +9 -3
  244. package/lib/test/unit/recordings-library-routes.spec.js +51 -51
  245. package/lib/test/unit/recordings-write-visibility.spec.js +2 -2
  246. package/lib/test/unit/remote-session-create.spec.js +130 -0
  247. package/lib/test/unit/reservation-team-visibility.spec.js +5 -5
  248. package/lib/test/unit/reset-link.test.js +14 -14
  249. package/lib/test/unit/session-app-resolution.spec.js +14 -10
  250. package/lib/test/unit/session-assets.spec.js +9 -9
  251. package/lib/test/unit/session-attribution-wiring.spec.js +29 -2
  252. package/lib/test/unit/session-create-layer.spec.js +255 -0
  253. package/lib/test/unit/session-credential-strip.spec.js +25 -9
  254. package/lib/test/unit/session-credentials.spec.js +0 -47
  255. package/lib/test/unit/session-listing-auth.spec.js +345 -0
  256. package/lib/test/unit/session-locator.spec.js +250 -0
  257. package/lib/test/unit/session-node-calls.spec.js +174 -0
  258. package/lib/test/unit/session-owner-resolver.spec.js +107 -0
  259. package/lib/test/unit/session-recovery-routing.spec.js +118 -0
  260. package/lib/test/unit/session-upgrade-guard.spec.js +571 -0
  261. package/lib/test/unit/shutdown-drain-remote.spec.js +58 -0
  262. package/lib/test/unit/socketServer.bearer.spec.js +3 -1
  263. package/lib/test/unit/stream-leave-route.spec.js +15 -7
  264. package/lib/test/unit/stream-own-device-row.spec.js +164 -0
  265. package/lib/test/unit/stream-stop-ownership.spec.js +13 -5
  266. package/lib/test/unit/stream-stop-recording.spec.js +13 -5
  267. package/lib/test/unit/stream-ticket-identity.spec.js +15 -5
  268. package/lib/test/unit/upgrade-router.spec.js +446 -0
  269. package/lib/test/unit/users-router.test.js +11 -8
  270. package/lib/test/unit/verify-credential.spec.js +4 -1
  271. package/lib/test/unit/webdriver-info-route.spec.js +64 -0
  272. package/lib/test/unit/ws-upgrade-routing-appium-server.spec.js +500 -0
  273. package/lib/tsconfig.tsbuildinfo +1 -1
  274. package/package.json +1 -2
  275. package/prisma/migrations/20260929120000_device_claims/migration.sql +8 -0
  276. package/prisma/schema.prisma +9 -0
  277. package/lib/public/assets/BugReportButton-DKYsOdGV.js +0 -10
  278. package/lib/public/assets/device-explorer-DV8rWRxL.css +0 -1
  279. package/lib/src/proxy/wd-command-proxy.js +0 -221
@@ -36,7 +36,7 @@
36
36
  Learn how to configure a non-root public URL by running `npm run build`.
37
37
  -->
38
38
  <title>Xenon</title>
39
- <script type="module" crossorigin src="/xenon/assets/index-BR1wTIPC.js"></script>
39
+ <script type="module" crossorigin src="/xenon/assets/index-BuIIcmGp.js"></script>
40
40
  <link rel="stylesheet" crossorigin href="/xenon/assets/index-hymrhlEZ.css">
41
41
  </head>
42
42
 
@@ -55,7 +55,8 @@ const logger_1 = __importDefault(require("./logger"));
55
55
  *
56
56
  * Features:
57
57
  * - Keep-alive connections for performance
58
- * - Automatic retry with exponential backoff
58
+ * - Automatic retry with exponential backoff, except for a request that sets
59
+ * `retry: false` (InternalRequestConfig)
59
60
  * - Request/response logging for debugging
60
61
  * - Correlation ID tracking
61
62
  */
@@ -178,6 +179,9 @@ class InternalHttpClient {
178
179
  logger_1.default.warn(`[HTTP ←] ${(_c = config === null || config === void 0 ? void 0 : config.method) === null || _c === void 0 ? void 0 : _c.toUpperCase()} ${config === null || config === void 0 ? void 0 : config.url} ` +
179
180
  `[${status || 'ERR'}] ${duration}ms - ${error.message}`);
180
181
  }
182
+ if (config.retry === false) {
183
+ return Promise.reject(error);
184
+ }
181
185
  const decision = InternalHttpClient.classifyRetry(error);
182
186
  if (!decision.retryable || config.retryCount >= InternalHttpClient.MAX_RETRIES) {
183
187
  return Promise.reject(error);
@@ -206,9 +206,23 @@ function iOSCapabilities(caps, freeDevice) {
206
206
  deleteMatch.forEach((value) => deleteAlwaysMatch(caps, value));
207
207
  });
208
208
  }
209
+ /**
210
+ * The team a session asks to be placed in, or undefined. Documented as
211
+ * `xe:options.team` (`teamId` is read too), with `xenon:options` as the
212
+ * alias, through xenonOptionsOf like every other Xenon option. The flat caps
213
+ * older clients send (`xenon:team`, `xe:teamId`, ...) still work; an options
214
+ * field wins over them. `df:` is never read.
215
+ *
216
+ * The merge is field by field, so if a request spells it `team` in one
217
+ * namespace and `teamId` in the other, `team` wins.
218
+ */
209
219
  function extractTeamCap(caps) {
210
220
  var _a;
211
- const merged = Object.assign({}, ((_a = caps.firstMatch) === null || _a === void 0 ? void 0 : _a[0]) || {}, caps.alwaysMatch || {});
221
+ const options = (0, xenonOptions_1.xenonOptionsOf)(caps);
222
+ const fromOptions = (_a = (0, xenonOptions_1.stringOption)(options, 'team')) !== null && _a !== void 0 ? _a : (0, xenonOptions_1.stringOption)(options, 'teamId');
223
+ if (fromOptions)
224
+ return fromOptions;
225
+ const merged = (0, xenonOptions_1.mergedFirstMatch)(caps);
212
226
  const prefixes = ['xenon:', 'xe:', 'appium:', ''];
213
227
  const names = ['teamId', 'team_id', 'team'];
214
228
  for (const prefix of prefixes) {
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeBasePath = normalizeBasePath;
4
+ exports.appiumPathname = appiumPathname;
5
+ /**
6
+ * Appium's base-path rule (base-driver `normalizeBasePath`): drop one trailing
7
+ * '/', and add a leading '/' unless the path is empty. `cliArgs.basePath`
8
+ * reaches a plugin as the operator wrote it, while Appium normalises its own
9
+ * copy before building routes and WebSocket paths, so anything placed in front
10
+ * of those must normalise the same way or it would miss what it guards.
11
+ */
12
+ function normalizeBasePath(basePath) {
13
+ let normalized = typeof basePath === 'string' ? basePath : '';
14
+ normalized = normalized.replace(/\/$/, '');
15
+ if (normalized !== '' && !normalized.startsWith('/'))
16
+ normalized = `/${normalized}`;
17
+ return normalized;
18
+ }
19
+ /**
20
+ * The pathname Appium matches an upgrade against, computed as
21
+ * base-driver's tryHandleWebSocketUpgrade does: WHATWG URL parsing, which
22
+ * resolves dot segments (also `%2e%2e`) and drops the query and an absolute
23
+ * form's host.
24
+ */
25
+ function appiumPathname(url) {
26
+ try {
27
+ return new URL(url, 'http://localhost').pathname;
28
+ }
29
+ catch (_a) {
30
+ return url;
31
+ }
32
+ }
@@ -86,6 +86,9 @@ const rateLimitMiddleware_1 = require("../middleware/rateLimitMiddleware");
86
86
  const csrfMiddleware_1 = require("../middleware/csrfMiddleware");
87
87
  const swagger_1 = require("./swagger");
88
88
  const typedi_1 = require("typedi");
89
+ const PluginContext_1 = require("../PluginContext");
90
+ const nodeWebDriverUrl_1 = require("../gateway/nodeWebDriverUrl");
91
+ const nodeSessionStatus_1 = require("../gateway/nodeSessionStatus");
89
92
  const dashboardPluginUrl = null;
90
93
  const ASYNC_LOCK = new async_lock_1.default();
91
94
  const router = express_1.default.Router(), apiRouter = express_1.default.Router(), staticFilesRouter = express_1.default.Router();
@@ -228,6 +231,15 @@ function createRouter(pluginArgs) {
228
231
  apiRouter.use(csrfMiddleware_1.csrfMiddleware);
229
232
  // Health endpoint: no auth, no rate limit
230
233
  apiRouter.get('/health', (_req, res) => res.json({ ok: true }));
234
+ // Where this server's WebDriver API lives: a hub asks a node before sending
235
+ // it a session or a command (gateway/nodeWebDriverUrl.ts). No login: the
236
+ // hub holds no credentials for the node, and the base path is in every
237
+ // client's URL anyway.
238
+ apiRouter.get('/webdriver', (0, nodeWebDriverUrl_1.webdriverInfoHandler)(() => typedi_1.Container.get(PluginContext_1.PluginContext).nodeBasePath));
239
+ // On a node: whether a session exists, for the hub's heartbeat, answered
240
+ // without sending the session a command. No login (the hub holds no
241
+ // credentials for the node); it takes the hub's session token instead.
242
+ (0, nodeSessionStatus_1.registerNodeSessionStatus)(apiRouter, pluginArgs);
231
243
  // Dashboard login: unauthenticated (rate-limited internally via separate IP logic)
232
244
  apiRouter.use('/auth', (0, auth_1.authPublicRouter)()); // login, logout — unauthenticated
233
245
  // All remaining /api/* requires authentication (cookie session for
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.routerStackOf = routerStackOf;
4
4
  exports.insertBeforeRoutes = insertBeforeRoutes;
5
+ exports.insertAtStart = insertAtStart;
5
6
  function isRouter(candidate) {
6
7
  return !!candidate && Array.isArray(candidate.stack);
7
8
  }
@@ -28,7 +29,11 @@ function routerStackOf(app) {
28
29
  var _a;
29
30
  return (_a = findRouter(app)) === null || _a === void 0 ? void 0 : _a.router.stack;
30
31
  }
31
- function insertBeforeRoutes(app, path, handler) {
32
+ /**
33
+ * Let `app.use()` build one layer, then move it to `positionOf(stack)` (the
34
+ * stack without the new layer).
35
+ */
36
+ function spliceNewLayer(app, use, positionOf) {
32
37
  const found = findRouter(app);
33
38
  if (!found) {
34
39
  return {
@@ -38,7 +43,7 @@ function insertBeforeRoutes(app, path, handler) {
38
43
  }
39
44
  const { stack } = found.router;
40
45
  const lengthBefore = stack.length;
41
- app.use(path, handler);
46
+ use();
42
47
  const added = stack.splice(lengthBefore);
43
48
  if (added.length !== 1) {
44
49
  // app.use() added its layer somewhere other than the stack we found, or
@@ -50,8 +55,23 @@ function insertBeforeRoutes(app, path, handler) {
50
55
  reason: `app.use() added ${added.length} layers to the router stack, expected 1`,
51
56
  };
52
57
  }
53
- const firstRoute = stack.findIndex((layer) => !!layer.route);
54
- const index = firstRoute === -1 ? stack.length : firstRoute;
58
+ const index = positionOf(stack);
55
59
  stack.splice(index, 0, added[0]);
56
60
  return { placed: true, shape: found.shape, index };
57
61
  }
62
+ function insertBeforeRoutes(app, path, handler) {
63
+ return spliceNewLayer(app, () => app.use(path, handler), (stack) => {
64
+ const firstRoute = stack.findIndex((layer) => !!layer.route);
65
+ return firstRoute === -1 ? stack.length : firstRoute;
66
+ });
67
+ }
68
+ /**
69
+ * Put a path-less middleware at the very front of the router stack, ahead of
70
+ * middleware as well as routes. For a check that must run before one of
71
+ * Appium's own middlewares (its WebSocket `handleUpgrade`), which sit ahead of
72
+ * the routes. On Express 4 that is also ahead of `query` and `expressInit`, so
73
+ * the handler must use only Node's own request and response API.
74
+ */
75
+ function insertAtStart(app, handler) {
76
+ return spliceNewLayer(app, () => app.use(handler), () => 0);
77
+ }
@@ -1,67 +1,186 @@
1
1
  "use strict";
2
+ var __rest = (this && this.__rest) || function (s, e) {
3
+ var t = {};
4
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
5
+ t[p] = s[p];
6
+ if (s != null && typeof Object.getOwnPropertySymbols === "function")
7
+ for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
8
+ if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
9
+ t[p[i]] = s[p[i]];
10
+ }
11
+ return t;
12
+ };
2
13
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
14
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
15
  };
5
16
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.normalizeBasePath = normalizeBasePath;
17
+ exports.normalizeBasePath = void 0;
7
18
  exports.sessionCommandPath = sessionCommandPath;
19
+ exports.sessionListingPath = sessionListingPath;
20
+ exports.commandAuthDeps = commandAuthDeps;
21
+ exports.sessionCreatePath = sessionCreatePath;
22
+ exports.registerSessionGateway = registerSessionGateway;
8
23
  exports.registerCommandAuth = registerCommandAuth;
24
+ exports.registerSessionUpgradeGuard = registerSessionUpgradeGuard;
9
25
  const typedi_1 = require("typedi");
10
26
  const logger_1 = __importDefault(require("../logger"));
11
27
  const config_1 = require("../config");
12
28
  const SessionOwnerResolver_1 = require("../services/device-access/SessionOwnerResolver");
13
29
  const commandAuth_1 = require("../middleware/commandAuth");
14
30
  const commandCaller_1 = require("../middleware/commandCaller");
31
+ const sessionListingFilter_1 = require("../middleware/sessionListingFilter");
32
+ const sessionUpgradeGuard_1 = require("../middleware/sessionUpgradeGuard");
33
+ const internalCall_1 = require("../gateway/internalCall");
34
+ const sessionGateway_1 = require("../gateway/sessionGateway");
35
+ const sessionCreate_1 = require("../gateway/sessionCreate");
36
+ const appiumBasePath_1 = require("./appiumBasePath");
37
+ Object.defineProperty(exports, "normalizeBasePath", { enumerable: true, get: function () { return appiumBasePath_1.normalizeBasePath; } });
15
38
  const insertBeforeRoutes_1 = require("./insertBeforeRoutes");
16
- /**
17
- * Appium's base-path rule (base-driver `normalizeBasePath`): drop one trailing
18
- * '/', and add a leading '/' unless the path is empty. `cliArgs.basePath`
19
- * reaches a plugin as the operator wrote it, while Appium normalises its own
20
- * copy before building routes, so the guard must normalise the same way or it
21
- * would miss the routes it is meant to sit in front of.
22
- */
23
- function normalizeBasePath(basePath) {
24
- let normalized = typeof basePath === 'string' ? basePath : '';
25
- normalized = normalized.replace(/\/$/, '');
26
- if (normalized !== '' && !normalized.startsWith('/'))
27
- normalized = `/${normalized}`;
28
- return normalized;
29
- }
30
39
  /**
31
40
  * Everything under `<basePath>/session/:sessionId`, every method. The create
32
41
  * route (`POST <basePath>/session`) has no id segment and does not match.
33
42
  */
34
43
  function sessionCommandPath(basePath) {
35
- return `${normalizeBasePath(basePath)}/session/:sessionId`;
44
+ return `${(0, appiumBasePath_1.normalizeBasePath)(basePath)}/session/:sessionId`;
45
+ }
46
+ /** Appium 3's session listing route (base-driver `getAppiumSessions`). */
47
+ function sessionListingPath(basePath) {
48
+ return `${(0, appiumBasePath_1.normalizeBasePath)(basePath)}/appium/sessions`;
36
49
  }
37
50
  /**
38
- * Install per-command auth (commandAuth.ts) ahead of Appium's routes. Called
39
- * from ServerManager.registerRoutes, which Appium runs after adding them.
51
+ * The dependencies per-command auth runs with. ServerManager builds one set
52
+ * and hands it to both registrations below, so commands, the session listing
53
+ * and session WebSockets share one credential cache.
54
+ */
55
+ function commandAuthDeps(overrides = {}) {
56
+ var _a, _b, _c, _d, _e, _f;
57
+ return {
58
+ enabled: (_a = overrides.enabled) !== null && _a !== void 0 ? _a : (() => (0, commandAuth_1.commandAuthEnabled)()),
59
+ authDisabled: (_b = overrides.authDisabled) !== null && _b !== void 0 ? _b : (() => config_1.config.authDisabled === true),
60
+ verifier: (_c = overrides.verifier) !== null && _c !== void 0 ? _c : new commandCaller_1.CommandCallerVerifier(),
61
+ ownerOf: (_d = overrides.ownerOf) !== null && _d !== void 0 ? _d : ((sessionId) => typedi_1.Container.get(SessionOwnerResolver_1.SessionOwnerResolver).ownerOf(sessionId)),
62
+ ownersOf: (_e = overrides.ownersOf) !== null && _e !== void 0 ? _e : ((sessionIds) => typedi_1.Container.get(SessionOwnerResolver_1.SessionOwnerResolver).ownersOf(sessionIds)),
63
+ logger: (_f = overrides.logger) !== null && _f !== void 0 ? _f : logger_1.default.scope('CommandAuth'),
64
+ };
65
+ }
66
+ const refuseToStart = (what) => new Error(`XENON_REQUIRE_COMMAND_AUTH is on, but ${what}. Refusing to start rather than ` +
67
+ 'serve sessions unchecked.');
68
+ /** The create route (`POST <basePath>/session`), where the create layer is mounted. */
69
+ function sessionCreatePath(basePath) {
70
+ return `${(0, appiumBasePath_1.normalizeBasePath)(basePath)}/session`;
71
+ }
72
+ /**
73
+ * Install the session gateway (gateway/sessionGateway.ts) and the
74
+ * session-listing filter (sessionListingFilter.ts) ahead of Appium's routes.
75
+ * Called from ServerManager.registerRoutes, which Appium runs after adding
76
+ * them. The gateway is three adjacent layers:
77
+ *
78
+ * 1. the internal-call layer (gateway/internalCall.ts), path-less, which turns
79
+ * Xenon's own `<basePath>/wd-internal/...` calls, when they carry this
80
+ * process's secret, into the plain session command;
81
+ * 2. with `options.create`, the create layer at `<basePath>/session`
82
+ * (gateway/sessionCreate.ts), which allocates the phone for
83
+ * `POST <basePath>/session` and creates a remote phone's session itself;
84
+ * 3. the session layer at `<basePath>/session/:sessionId`: per-command auth,
85
+ * then, on a hub, forwarding of the sessions other servers run.
86
+ *
87
+ * The first comes first, so the others see an internal call by its real path
88
+ * and know it for one. It is path-less because Express puts a mount path back
89
+ * onto the URL when a mounted layer calls next(), which would undo the strip.
90
+ * The others are mounted, so their path matching is the router's own.
91
+ *
92
+ * All of it is always installed and reads the setting per request, so a
93
+ * server with per-command auth off pays one env check per session command and
94
+ * does no lookups for it. If a layer cannot be placed ahead of the routes the
95
+ * error is logged, and with the setting on (and auth enabled) the server
96
+ * refuses to start: an operator who asked for the check must not get a server
97
+ * that silently serves session commands without it.
40
98
  *
41
- * The middleware is always installed and reads the setting per request, so a
42
- * server with the setting off pays one env check per session command and does
43
- * no lookups. If it cannot be placed ahead of the routes the error is logged,
44
- * and with the setting on (and auth enabled) the server refuses to start:
45
- * an operator who asked for the check must not get a server that silently
46
- * serves session commands without it.
99
+ * Returns where the session layer was placed.
47
100
  */
48
- function registerCommandAuth(app, cliArgs, overrides = {}) {
49
- const deps = Object.assign({ enabled: () => (0, commandAuth_1.commandAuthEnabled)(), authDisabled: () => config_1.config.authDisabled === true, verifier: new commandCaller_1.CommandCallerVerifier(), ownerOf: (sessionId) => typedi_1.Container.get(SessionOwnerResolver_1.SessionOwnerResolver).ownerOf(sessionId), logger: logger_1.default.scope('CommandAuth') }, overrides);
50
- const path = sessionCommandPath(cliArgs === null || cliArgs === void 0 ? void 0 : cliArgs.basePath);
51
- const result = (0, insertBeforeRoutes_1.insertBeforeRoutes)(app, path, (0, commandAuth_1.createCommandAuthMiddleware)(deps));
101
+ function registerSessionGateway(app, cliArgs, deps, options = {}) {
102
+ var _a;
52
103
  const active = deps.enabled() && !deps.authDisabled();
104
+ const basePath = (0, appiumBasePath_1.normalizeBasePath)(cliArgs === null || cliArgs === void 0 ? void 0 : cliArgs.basePath);
105
+ const { create } = options, gateway = __rest(options, ["create"]);
106
+ const path = sessionCommandPath(basePath);
107
+ const internal = (0, insertBeforeRoutes_1.insertBeforeRoutes)(app, '/', (0, internalCall_1.createInternalCallLayer)(basePath));
108
+ const created = internal.placed && create
109
+ ? (0, insertBeforeRoutes_1.insertBeforeRoutes)(app, sessionCreatePath(basePath), (0, sessionCreate_1.createSessionCreateLayer)(Object.assign(Object.assign({}, create), { authDisabled: deps.authDisabled, logger: (_a = create.logger) !== null && _a !== void 0 ? _a : deps.logger })))
110
+ : internal;
111
+ const result = created.placed
112
+ ? (0, insertBeforeRoutes_1.insertBeforeRoutes)(app, path, (0, sessionGateway_1.createSessionGatewayLayer)(Object.assign(Object.assign({}, gateway), { auth: deps })))
113
+ : created;
53
114
  if (!result.placed) {
54
115
  deps.logger.error(`Per-command auth could not be placed ahead of Appium's routes for ${path}: ${result.reason}.`);
55
116
  if (active) {
56
- throw new Error('XENON_REQUIRE_COMMAND_AUTH is on, but the per-command check could not be placed ahead ' +
57
- "of Appium's WebDriver routes, so it would never run. Refusing to start rather than " +
58
- 'serve session commands unchecked.');
117
+ throw refuseToStart("the per-command check could not be placed ahead of Appium's WebDriver routes, " +
118
+ 'so it would never run');
119
+ }
120
+ return result;
121
+ }
122
+ const listingPath = sessionListingPath(cliArgs === null || cliArgs === void 0 ? void 0 : cliArgs.basePath);
123
+ const listing = (0, insertBeforeRoutes_1.insertBeforeRoutes)(app, listingPath, (0, sessionListingFilter_1.createSessionListingFilter)(deps));
124
+ if (!listing.placed) {
125
+ deps.logger.error("The session-listing filter could not be placed ahead of Appium's routes for " +
126
+ `${listingPath}: ${listing.reason}.`);
127
+ if (active) {
128
+ throw refuseToStart(`the session-listing filter could not be placed ahead of ${listingPath}`);
59
129
  }
60
130
  return result;
61
131
  }
62
132
  deps.logger.info(active
63
133
  ? `Per-command auth is ON: every request under ${path} needs the session owner's ` +
64
- 'credentials or an override admin (XENON_REQUIRE_COMMAND_AUTH).'
65
- : `Per-command auth is off for ${path} (XENON_REQUIRE_COMMAND_AUTH not set, or auth disabled).`);
134
+ `credentials or an override admin, and ${listingPath} lists only the caller's ` +
135
+ 'sessions (XENON_REQUIRE_COMMAND_AUTH).'
136
+ : `Per-command auth is off for ${path} and ${listingPath} ` +
137
+ '(XENON_REQUIRE_COMMAND_AUTH not set, or auth disabled).');
66
138
  return result;
67
139
  }
140
+ /**
141
+ * Per-command auth (commandAuth.ts) and the session-listing filter, through the
142
+ * session gateway with no routing: a server that runs every session itself.
143
+ */
144
+ function registerCommandAuth(app, cliArgs, overrides = {}) {
145
+ return registerSessionGateway(app, cliArgs, commandAuthDeps(overrides));
146
+ }
147
+ /**
148
+ * Install the session WebSocket guard (sessionUpgradeGuard.ts) on both of the
149
+ * paths Appium can take an upgrade by: the http.Server's `upgrade` event and
150
+ * the front of the Express stack. Called from ServerManager.updateServer with
151
+ * the http.Server Appium hands plugins.
152
+ *
153
+ * Like registerCommandAuth, it is always installed and reads the setting per
154
+ * upgrade; with the setting off every upgrade passes straight through. If
155
+ * either hook cannot be installed the error is logged, and with the setting on
156
+ * the server refuses to start.
157
+ */
158
+ function registerSessionUpgradeGuard(httpServer, app, cliArgs, overrides = {}) {
159
+ const deps = commandAuthDeps(overrides);
160
+ const active = deps.enabled() && !deps.authDisabled();
161
+ const basePath = (0, appiumBasePath_1.normalizeBasePath)(cliArgs === null || cliArgs === void 0 ? void 0 : cliArgs.basePath);
162
+ const guard = (0, sessionUpgradeGuard_1.createSessionUpgradeGuard)(basePath, deps);
163
+ const problems = [];
164
+ if (httpServer && typeof httpServer.emit === 'function') {
165
+ (0, sessionUpgradeGuard_1.guardUpgradeEvents)(httpServer, guard);
166
+ }
167
+ else {
168
+ problems.push('no HTTP server was given to guard');
169
+ }
170
+ const front = (0, insertBeforeRoutes_1.insertAtStart)(app, guard.middleware);
171
+ if (!front.placed)
172
+ problems.push(`the Express guard was not placed: ${front.reason}`);
173
+ if (problems.length > 0) {
174
+ const reason = problems.join('; ');
175
+ deps.logger.error(`The session WebSocket guard could not be installed: ${reason}.`);
176
+ if (active) {
177
+ throw refuseToStart(`session WebSockets (${basePath}/bidi/<id>, /ws/session/<id>/...) could not be guarded`);
178
+ }
179
+ return { placed: false, shape: front.shape, reason };
180
+ }
181
+ deps.logger.info(active
182
+ ? `Session WebSocket guard is ON: upgrades to ${basePath}/bidi/:sessionId and ` +
183
+ "/ws/session/:sessionId/... need the session owner's credentials or an override admin."
184
+ : 'Session WebSocket guard is off (XENON_REQUIRE_COMMAND_AUTH not set, or auth disabled).');
185
+ return front;
186
+ }
@@ -68,6 +68,7 @@ const InspectorService_1 = require("../../services/InspectorService");
68
68
  const StreamTicketService_1 = require("../../services/token/StreamTicketService");
69
69
  const appVisibility_1 = require("../../services/device-access/appVisibility");
70
70
  const PluginContext_1 = require("../../PluginContext");
71
+ const localDeviceHosts_1 = require("../../device-managers/localDeviceHosts");
71
72
  const streamType_1 = require("./streamType");
72
73
  const iosStreamPort_1 = require("./iosStreamPort");
73
74
  const androidH264Config_1 = require("./androidH264Config");
@@ -83,6 +84,7 @@ const streamStartConflict_1 = require("./streamStartConflict");
83
84
  const streamLeave_1 = require("./streamLeave");
84
85
  const SessionOwnerResolver_1 = require("../../services/device-access/SessionOwnerResolver");
85
86
  const actor_1 = require("../../services/device-access/actor");
87
+ const hubSessionToken_1 = require("../../gateway/hubSessionToken");
86
88
  const deviceAccessPolicy_1 = require("../../services/device-access/deviceAccessPolicy");
87
89
  const router = (0, express_1.Router)();
88
90
  /** True when an in-memory iOS/Android (MJPEG or H.264) stream session exists. */
@@ -141,6 +143,45 @@ function buildProxyUrl(deviceHost, req) {
141
143
  const forwardPath = req.originalUrl.startsWith('/') ? req.originalUrl : `/${req.originalUrl}`;
142
144
  return `${origin}${forwardPath}`;
143
145
  }
146
+ /**
147
+ * Forward a control action on another server's phone to that server, signed
148
+ * for the caller: the hub's control token (hubSessionToken.ts) names the
149
+ * user, whether they are an admin, the phone and its node. This hub already
150
+ * checked them (the guards above); a node with auth enabled accepts the token
151
+ * for /control on that phone and runs its own ownership guard with that user.
152
+ * Sent with no credential at all, it was refused by every such node. A cloud
153
+ * provider never gets a hub token.
154
+ */
155
+ function forwardControl(device, req, target) {
156
+ return __awaiter(this, void 0, void 0, function* () {
157
+ const actor = (0, actor_1.resolveActor)(req);
158
+ const token = !device.cloud && actor.userId
159
+ ? yield typedi_1.Container.get(hubSessionToken_1.HubSessionTokenIssuer).controlTokenFor({
160
+ userId: actor.userId,
161
+ isAdmin: actor.isAdmin,
162
+ udid: device.udid,
163
+ host: device.host,
164
+ })
165
+ : null;
166
+ // Sent once: a retry after a slow node's timeout or 5xx could land the
167
+ // same tap twice.
168
+ const config = Object.assign(Object.assign({}, (token ? { headers: { [hubSessionToken_1.HUB_TOKEN_HEADER]: token } } : {})), { retry: false });
169
+ yield InternalHttpClient_1.InternalHttpClient.post(target, req.body, config);
170
+ });
171
+ }
172
+ /**
173
+ * A phone another server drives, whose /control actions are forwarded there.
174
+ * Decided by the phone's own row (its nodeId, else its exact host against this
175
+ * server's own hosts), never by the Host header the caller used: a substring
176
+ * match on that header forwarded a lab's own phone back to itself, with no
177
+ * login, whenever the lab was reached as `localhost`.
178
+ */
179
+ function isOtherServersPhone(device) {
180
+ if (!device.host)
181
+ return false;
182
+ const context = typedi_1.Container.get(PluginContext_1.PluginContext);
183
+ return !(0, localDeviceHosts_1.isOwnDevice)((0, localDeviceHosts_1.localDeviceHosts)(context.pluginArgs, context.port), context.nodeId, device);
184
+ }
144
185
  function getDeviceInfo(udid) {
145
186
  return __awaiter(this, void 0, void 0, function* () {
146
187
  const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
@@ -179,13 +220,13 @@ router.post('/:udid/tap', (req, res) => __awaiter(void 0, void 0, void 0, functi
179
220
  if (!device)
180
221
  return res.status(404).send('Device not found');
181
222
  const manager = yield getDeviceManagerForPlatform(device.platform);
182
- if (device.host && !device.host.includes(req.get('host') || '')) {
223
+ if (isOtherServersPhone(device)) {
183
224
  const target = buildProxyUrl(device.host, req);
184
225
  if (!target)
185
226
  return res.status(400).send({ error: 'Unsafe device host' });
186
227
  logger_1.default.info(`Proxying tap for ${udid} to ${target}`);
187
228
  try {
188
- yield InternalHttpClient_1.InternalHttpClient.post(target, req.body);
229
+ yield forwardControl(device, req, target);
189
230
  return res.status(200).send({ success: true });
190
231
  }
191
232
  catch (err) {
@@ -223,13 +264,13 @@ router.post('/:udid/swipe', (req, res) => __awaiter(void 0, void 0, void 0, func
223
264
  if (!device)
224
265
  return res.status(404).send('Device not found');
225
266
  const manager = yield getDeviceManagerForPlatform(device.platform);
226
- if (device.host && !device.host.includes(req.get('host') || '')) {
267
+ if (isOtherServersPhone(device)) {
227
268
  const target = buildProxyUrl(device.host, req);
228
269
  if (!target)
229
270
  return res.status(400).send({ error: 'Unsafe device host' });
230
271
  logger_1.default.info(`Proxying swipe for ${udid} to ${target}`);
231
272
  try {
232
- yield InternalHttpClient_1.InternalHttpClient.post(target, req.body);
273
+ yield forwardControl(device, req, target);
233
274
  return res.status(200).send({ success: true });
234
275
  }
235
276
  catch (err) {
@@ -256,13 +297,13 @@ router.post('/:udid/text', (req, res) => __awaiter(void 0, void 0, void 0, funct
256
297
  if (!device)
257
298
  return res.status(404).send('Device not found');
258
299
  const manager = yield getDeviceManagerForPlatform(device.platform);
259
- if (device.host && !device.host.includes(req.get('host') || '')) {
300
+ if (isOtherServersPhone(device)) {
260
301
  const target = buildProxyUrl(device.host, req);
261
302
  if (!target)
262
303
  return res.status(400).send({ error: 'Unsafe device host' });
263
304
  logger_1.default.info(`Proxying typeText for ${udid} to ${target}`);
264
305
  try {
265
- yield InternalHttpClient_1.InternalHttpClient.post(target, req.body);
306
+ yield forwardControl(device, req, target);
266
307
  return res.status(200).send({ success: true });
267
308
  }
268
309
  catch (err) {
@@ -289,13 +330,13 @@ router.post('/:udid/keyevent', (req, res) => __awaiter(void 0, void 0, void 0, f
289
330
  if (!device)
290
331
  return res.status(404).send('Device not found');
291
332
  const manager = yield getDeviceManagerForPlatform(device.platform);
292
- if (device.host && !device.host.includes(req.get('host') || '')) {
333
+ if (isOtherServersPhone(device)) {
293
334
  const target = buildProxyUrl(device.host, req);
294
335
  if (!target)
295
336
  return res.status(400).send({ error: 'Unsafe device host' });
296
337
  logger_1.default.info(`Proxying keyevent for ${udid} to ${target}`);
297
338
  try {
298
- yield InternalHttpClient_1.InternalHttpClient.post(target, req.body);
339
+ yield forwardControl(device, req, target);
299
340
  return res.status(200).send({ success: true });
300
341
  }
301
342
  catch (err) {
@@ -392,13 +433,13 @@ router.post('/:udid/touchAndHold', (req, res) => __awaiter(void 0, void 0, void
392
433
  if (!device)
393
434
  return res.status(404).send('Device not found');
394
435
  const manager = yield getDeviceManagerForPlatform(device.platform);
395
- if (device.host && !device.host.includes(req.get('host') || '')) {
436
+ if (isOtherServersPhone(device)) {
396
437
  const target = buildProxyUrl(device.host, req);
397
438
  if (!target)
398
439
  return res.status(400).send({ error: 'Unsafe device host' });
399
440
  logger_1.default.info(`Proxying touchAndHold for ${udid} to ${target}`);
400
441
  try {
401
- yield InternalHttpClient_1.InternalHttpClient.post(target, req.body);
442
+ yield forwardControl(device, req, target);
402
443
  return res.status(200).send({ success: true });
403
444
  }
404
445
  catch (err) {
@@ -800,7 +841,9 @@ router.post('/:udid/stream/ticket', (req, res) => __awaiter(void 0, void 0, void
800
841
  function stopPreview(udid, device, release) {
801
842
  return __awaiter(this, void 0, void 0, function* () {
802
843
  if (device.platform === 'ios' || device.platform === 'tvos') {
803
- yield typedi_1.Container.get(IOSStreamService_1.default).stopStream(udid);
844
+ // A viewer's stop: an Appium session on the phone may be driving this
845
+ // stream's WDA, and then the stream stays (see IOSStreamService.stopStream).
846
+ yield typedi_1.Container.get(IOSStreamService_1.default).stopStream(udid, { forViewer: true });
804
847
  }
805
848
  else {
806
849
  yield typedi_1.Container.get(AndroidStreamService_1.default).stopStream(udid);
@@ -1026,8 +1069,9 @@ router.get('/:udid/stream', (req, res) => __awaiter(void 0, void 0, void 0, func
1026
1069
  getSession: (id) => iosStreamService.getStreamStatus(id),
1027
1070
  // Single attempt (retries = 0): a healthy WDA answers in milliseconds,
1028
1071
  // so this costs nothing on the happy path and stays bounded by the
1029
- // 2.5s /status timeout when WDA is dead.
1030
- isWdaHealthy: (wdaPort, id) => iosStreamService.isWDARunning(wdaPort, id, 0),
1072
+ // 2.5s /status timeout when WDA is dead. No udid: WDA names no phone
1073
+ // (see isWDARunning). The port is this phone's stream's own lease.
1074
+ isWdaHealthy: (wdaPort) => iosStreamService.isWDARunning(wdaPort, 0),
1031
1075
  startStream: (id) => __awaiter(void 0, void 0, void 0, function* () {
1032
1076
  var _a;
1033
1077
  logger_1.default.info(`Stream for iOS device ${id} requested (Status: ${((_a = iosStreamService.getStreamStatus(id)) === null || _a === void 0 ? void 0 : _a.status) || 'idle'})...`);
@@ -58,6 +58,8 @@ const device_managers_1 = require("../../device-managers");
58
58
  const typedi_1 = require("typedi");
59
59
  const prisma_1 = require("../../prisma");
60
60
  const DeviceTeamResolver_1 = require("../../services/device-access/DeviceTeamResolver");
61
+ const PluginContext_1 = require("../../PluginContext");
62
+ const localDeviceHosts_1 = require("../../device-managers/localDeviceHosts");
61
63
  const store = device_store_1.DeviceStoreFactory.getStore();
62
64
  const pendingStore = device_store_1.DeviceStoreFactory.getPendingSessionStore();
63
65
  const SERVER_UP_TIME = new Date().toISOString();
@@ -141,26 +143,44 @@ function getDeviceByPlatform(request, response) {
141
143
  return response.status(200).send(yield (0, device_service_1.enrichDevicesWithTeamNames)(devices));
142
144
  });
143
145
  }
146
+ /** A removal a node asked for, from what it sent: strings only. */
147
+ function nodeRemoval(host, nodeId, udid) {
148
+ return {
149
+ udid,
150
+ host: typeof host === 'string' && host !== '' ? host : undefined,
151
+ nodeId: typeof nodeId === 'string' && nodeId !== '' ? nodeId : undefined,
152
+ };
153
+ }
154
+ /** Whether a row is one of this server's own phones, which no node may remove. */
155
+ function isThisServersOwn() {
156
+ const context = typedi_1.Container.get(PluginContext_1.PluginContext);
157
+ const local = (0, localDeviceHosts_1.localDeviceHosts)(context.pluginArgs, context.port);
158
+ return (device) => (0, localDeviceHosts_1.isOwnDevice)(local, context.nodeId, device);
159
+ }
144
160
  function registerNode(request, response) {
145
161
  return __awaiter(this, void 0, void 0, function* () {
146
162
  const requestBody = request.body;
147
163
  const { type } = request.query;
148
164
  if (type === 'add') {
149
- // A node's own report of its phones: it is their source of truth, so the
150
- // hub writes every column (AddDevicesOptions.mirror).
151
- const addedDevices = yield (0, device_service_1.addNewDevice)(requestBody, undefined, { mirror: true });
165
+ // A node's report of its phones (AddDevicesOptions.nodeReport): what the
166
+ // node observes of them, and whether they are busy there (`nodeBusy`).
167
+ // It never frees a phone this hub has claimed for a session, nor changes
168
+ // the settings this hub owns for it: team, tags, reservation, block.
169
+ const addedDevices = yield (0, device_service_1.addNewDevice)(requestBody, undefined, { nodeReport: true });
152
170
  if (addedDevices.length > 0) {
153
171
  logger_1.default.info(`Added new devices: ${JSON.stringify(addedDevices)}`);
154
172
  }
155
173
  }
156
174
  else if (type === 'remove') {
157
- yield (0, device_service_1.removeDevice)(requestBody);
175
+ // One phone each, by udid: an entry without one names no phone.
176
+ const removals = (Array.isArray(requestBody) ? requestBody : [])
177
+ .filter((entry) => typeof (entry === null || entry === void 0 ? void 0 : entry.udid) === 'string' && entry.udid !== '')
178
+ .map((entry) => nodeRemoval(entry.host, entry.nodeId, entry.udid));
179
+ yield (0, device_service_1.removeNodeDevices)(removals, isThisServersOwn());
158
180
  }
159
181
  else if (type === 'unregister') {
160
- const { host } = request.query;
161
- if (host) {
162
- yield (0, device_service_1.removeDevicesByHost)(host);
163
- }
182
+ const { host, nodeId } = request.query;
183
+ yield (0, device_service_1.removeNodeDevices)([nodeRemoval(host, nodeId)], isThisServersOwn());
164
184
  }
165
185
  response.status(200).send({
166
186
  success: true,