@xenon-device-management/xenon 2.0.0 → 2.2.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 (289) hide show
  1. package/README.md +2 -1
  2. package/lib/package.json +3 -4
  3. package/lib/public/assets/{AnnotationOverlay-BmTPCAds.js → AnnotationOverlay-XpewX5nn.js} +1 -1
  4. package/lib/public/assets/{ApiKeyGate-B5eB7f8B.js → ApiKeyGate-BQDv3ZCC.js} +1 -1
  5. package/lib/public/assets/BugReportButton-C7OmS61B.js +10 -0
  6. package/lib/public/assets/{DeviceMosaicView-9DUoZnQi.js → DeviceMosaicView-wl3l2Zlt.js} +1 -1
  7. package/lib/public/assets/{EmptyState-lvyNiXev.js → EmptyState-fPxujqqE.js} +1 -1
  8. package/lib/public/assets/{FieldGroup-BduMPM9K.js → FieldGroup-5bCutWrs.js} +1 -1
  9. package/lib/public/assets/{FilterMenu-BSqX6I94.js → FilterMenu-owsBiDT1.js} +1 -1
  10. package/lib/public/assets/{Menu-Ba5cT7jZ.js → Menu-DFhqzPSt.js} +1 -1
  11. package/lib/public/assets/{Modal-CceoMY7D.js → Modal-Bw-a-E-e.js} +1 -1
  12. package/lib/public/assets/{RecordingPage-6T9dOidc.js → RecordingPage-WtByf5bC.js} +1 -1
  13. package/lib/public/assets/{RecordingsPage-qj6FdZXR.js → RecordingsPage-FpWWFNgT.js} +1 -1
  14. package/lib/public/assets/{SegmentedControl-CSRuZ8TR.js → SegmentedControl-Jzn9c7o8.js} +1 -1
  15. package/lib/public/assets/{SettingCard-Bp1kz4By.js → SettingCard-BR7nOhpT.js} +1 -1
  16. package/lib/public/assets/{Table-LV7zFyZg.js → Table-B_Va3V05.js} +1 -1
  17. package/lib/public/assets/{activity-D17TUloi.js → activity-DxRFWvkN.js} +1 -1
  18. package/lib/public/assets/{ai-settings-qvbhY89P.js → ai-settings-CQf-9_V6.js} +1 -1
  19. package/lib/public/assets/{api-keys-BfpvdG7G.js → api-keys-SoE1Kd7N.js} +1 -1
  20. package/lib/public/assets/{apps-Dbnzd0fW.js → apps-CgGXTlgn.js} +1 -1
  21. package/lib/public/assets/{arrow-left-DD1VjtC7.js → arrow-left-uQ6WIwiC.js} +1 -1
  22. package/lib/public/assets/{arrow-right-D0pnM3ma.js → arrow-right-C9RyfzXS.js} +1 -1
  23. package/lib/public/assets/{arrow-up-right-BIC8JOV6.js → arrow-up-right-DFHi_8yG.js} +1 -1
  24. package/lib/public/assets/{auth-shell-DWHPXSnI.js → auth-shell-Bii1t4qc.js} +2 -2
  25. package/lib/public/assets/{builds-page-ClCNBUv_.js → builds-page-Bnv8Z9hi.js} +1 -1
  26. package/lib/public/assets/{button-xtAhYR-f.js → button-_Y78wXiP.js} +1 -1
  27. package/lib/public/assets/{calendar-CupwJ7Bm.js → calendar-D4MqSOc2.js} +1 -1
  28. package/lib/public/assets/{check-CyuEoKkd.js → check-uGHCXe-L.js} +1 -1
  29. package/lib/public/assets/{chevron-left-DgX-F9xK.js → chevron-left-Dy1OqpzB.js} +1 -1
  30. package/lib/public/assets/{chevron-right-BKJgJnU9.js → chevron-right-BUsldEli.js} +1 -1
  31. package/lib/public/assets/{circle-check-CZdg8mMa.js → circle-check-D3Qq0tKp.js} +1 -1
  32. package/lib/public/assets/{circle-x-CZH6t9z-.js → circle-x-B4IOD1DE.js} +1 -1
  33. package/lib/public/assets/{clock-Cd-t7xpe.js → clock-BfYfzvSg.js} +1 -1
  34. package/lib/public/assets/{copy-C6zerX3I.js → copy-DZwYE5Va.js} +1 -1
  35. package/lib/public/assets/{device-explorer-B1pI6b_M.js → device-explorer-CRG4Utt5.js} +3 -3
  36. package/lib/public/assets/device-explorer-D2AqrrLN.css +1 -0
  37. package/lib/public/assets/{download-x9WP7mcL.js → download-CCDYo_TN.js} +1 -1
  38. package/lib/public/assets/{forgot-password-B0TEH4N-.js → forgot-password-DuLCmVFb.js} +1 -1
  39. package/lib/public/assets/{funnel-Cb40MfFN.js → funnel-CtKNPqzb.js} +1 -1
  40. package/lib/public/assets/{index-BR1wTIPC.js → index-3uxl3o7K.js} +43 -43
  41. package/lib/public/assets/{index-BvH_ekRy.js → index-Zy1Op5C1.js} +1 -1
  42. package/lib/public/assets/{input-DejcCiTI.js → input-CzTWvUXZ.js} +1 -1
  43. package/lib/public/assets/{keycodes-Mavx5iGg.js → keycodes-CI-nYDFM.js} +1 -1
  44. package/lib/public/assets/{layers-BKqYLiHE.js → layers-YZZGFyTX.js} +1 -1
  45. package/lib/public/assets/{lock-LFsFhc17.js → lock-L8tzWgDo.js} +1 -1
  46. package/lib/public/assets/{login-x2pkzt-t.js → login-DBbX-fu8.js} +1 -1
  47. package/lib/public/assets/{maintenance-settings-CYafcoV-.js → maintenance-settings-AJwkOIsE.js} +1 -1
  48. package/lib/public/assets/{monitor-_7_uJ8d9.js → monitor-Di_66F2m.js} +1 -1
  49. package/lib/public/assets/{mouse-pointer-2-BfLuURKN.js → mouse-pointer-2-Cnab2zdg.js} +1 -1
  50. package/lib/public/assets/{network-Z_zBhpsD.js → network-DiSumY-1.js} +1 -1
  51. package/lib/public/assets/{overview-BY5uv08S.js → overview-CwE4gXl5.js} +1 -1
  52. package/lib/public/assets/{page-header-CP2UkykG.js → page-header-CfR4Sm9V.js} +1 -1
  53. package/lib/public/assets/{plus-BTD0dnQ0.js → plus-DOS8P9CQ.js} +1 -1
  54. package/lib/public/assets/{profile-page-BhJZuCS7.js → profile-page-BYpHwQIH.js} +1 -1
  55. package/lib/public/assets/{reset-password-CMN0SO81.js → reset-password-BRVJU_qV.js} +1 -1
  56. package/lib/public/assets/{runbook-page-CBpPX9_5.js → runbook-page-Bi3JvAFZ.js} +1 -1
  57. package/lib/public/assets/{select-Uc9E5pye.js → select-Cqx7pEou.js} +1 -1
  58. package/lib/public/assets/{selector-detail-page-BSyBnRuU.js → selector-detail-page-C0DsncKj.js} +1 -1
  59. package/lib/public/assets/{selector-health-DzqxNkzm.js → selector-health-CAhpdilF.js} +1 -1
  60. package/lib/public/assets/{selector-health-page-CyPLjoD1.js → selector-health-page-CrCVeW2m.js} +1 -1
  61. package/lib/public/assets/{session-detail-page-BZQZN1P-.js → session-detail-page-F_hwYqEt.js} +1 -1
  62. package/lib/public/assets/{settings-CkSWiZq8.js → settings-C-s5pypL.js} +1 -1
  63. package/lib/public/assets/{status-pill-outline-BFYni2qo.js → status-pill-outline-DY5uzv6A.js} +1 -1
  64. package/lib/public/assets/{tablet-BSd-xSZK.js → tablet-Dli7gwyg.js} +1 -1
  65. package/lib/public/assets/{teams-PpoEQz31.js → teams-ui4IhHNC.js} +1 -1
  66. package/lib/public/assets/{trash-2-BorJv5_u.js → trash-2-BV4hSmXJ.js} +1 -1
  67. package/lib/public/assets/{upload-C3i7sAZl.js → upload-DkGy_J-L.js} +1 -1
  68. package/lib/public/assets/{use-password-reset-mode-CeZQ3EsH.js → use-password-reset-mode-BFtMqQOh.js} +1 -1
  69. package/lib/public/assets/{users-Drd7ifJx.js → users-BU0nXIVe.js} +1 -1
  70. package/lib/public/assets/{users-CEJ3fC78.js → users-Nb_rzRmF.js} +1 -1
  71. package/lib/public/assets/{video-off-CNzn6bPE.js → video-off-CUNzD2kH.js} +1 -1
  72. package/lib/public/assets/{webhook-settings-BFqX6QXV.js → webhook-settings-Ca3UFXZe.js} +1 -1
  73. package/lib/public/assets/{zap-4yN8Jdfw.js → zap-B2bH2vKX.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 +11 -104
  82. package/lib/src/app/routers/grid.js +28 -8
  83. package/lib/src/app/routers/nodePhoneControl.js +301 -0
  84. package/lib/src/app/ws/h264StreamWs.js +43 -12
  85. package/lib/src/app/ws/logcatWs.js +48 -14
  86. package/lib/src/app/ws/nodeSocketRelay.js +167 -0
  87. package/lib/src/app/ws/upgradeRouter.js +215 -0
  88. package/lib/src/dashboard/event-manager.js +7 -6
  89. package/lib/src/data-service/device-service.js +152 -49
  90. package/lib/src/data-service/device-store.js +92 -10
  91. package/lib/src/data-service/deviceClaims.js +116 -0
  92. package/lib/src/data-service/deviceFieldOwners.js +32 -10
  93. package/lib/src/data-service/prisma-store.js +102 -31
  94. package/lib/src/device-managers/AndroidDeviceManager.js +40 -42
  95. package/lib/src/device-managers/HealthMonitorService.js +17 -2
  96. package/lib/src/device-managers/IOSDeviceManager.js +5 -1
  97. package/lib/src/device-managers/NodeDevices.js +7 -2
  98. package/lib/src/device-managers/android/AndroidH264StreamService.js +13 -5
  99. package/lib/src/device-managers/android/AndroidStreamService.js +9 -6
  100. package/lib/src/device-managers/android/previewHold.js +2 -2
  101. package/lib/src/device-managers/ios/IOSDiscoveryService.js +19 -8
  102. package/lib/src/device-managers/ios/IOSStreamService.js +162 -40
  103. package/lib/src/device-managers/localDeviceHosts.js +86 -0
  104. package/lib/src/device-managers/ownDeviceRow.js +38 -0
  105. package/lib/src/device-utils.js +78 -24
  106. package/lib/src/gateway/createHandoff.js +73 -0
  107. package/lib/src/gateway/defaultGateway.js +80 -0
  108. package/lib/src/gateway/forwardToNode.js +147 -0
  109. package/lib/src/gateway/hubSessionToken.js +356 -0
  110. package/lib/src/gateway/internalCall.js +90 -0
  111. package/lib/src/gateway/nodeSessionStatus.js +140 -0
  112. package/lib/src/gateway/nodeWebDriverUrl.js +139 -0
  113. package/lib/src/gateway/sessionCreate.js +136 -0
  114. package/lib/src/gateway/sessionGateway.js +201 -0
  115. package/lib/src/gateway/sessionLocator.js +100 -0
  116. package/lib/src/generated/client/edge.js +7 -4
  117. package/lib/src/generated/client/index-browser.js +4 -1
  118. package/lib/src/generated/client/index.d.ts +112 -1
  119. package/lib/src/generated/client/index.js +7 -4
  120. package/lib/src/generated/client/package.json +1 -1
  121. package/lib/src/generated/client/schema.prisma +9 -0
  122. package/lib/src/generated/client/wasm.js +4 -1
  123. package/lib/src/helpers/index.js +5 -1
  124. package/lib/src/logger.js +5 -0
  125. package/lib/src/middleware/authMiddleware.js +80 -0
  126. package/lib/src/middleware/commandAuth.js +31 -17
  127. package/lib/src/middleware/csrfMiddleware.js +3 -2
  128. package/lib/src/middleware/deviceAccessGuard.js +4 -1
  129. package/lib/src/middleware/sessionListingFilter.js +153 -0
  130. package/lib/src/middleware/sessionUpgradeGuard.js +203 -0
  131. package/lib/src/plugin.js +27 -1
  132. package/lib/src/prisma.js +2 -1
  133. package/lib/src/services/DeviceReconciler.js +5 -2
  134. package/lib/src/services/OrphanSweeper.js +12 -19
  135. package/lib/src/services/PortAllocator.js +75 -16
  136. package/lib/src/services/ServerManager.js +63 -30
  137. package/lib/src/services/SessionLifecycleService.js +335 -76
  138. package/lib/src/services/ShutdownCoordinator.js +7 -1
  139. package/lib/src/services/SocketServer.js +11 -2
  140. package/lib/src/services/device-access/LiveSessionOwners.js +53 -0
  141. package/lib/src/services/device-access/SessionOwnerResolver.js +74 -0
  142. package/lib/src/services/device-access/ticketActorAccess.js +3 -1
  143. package/lib/src/services/lease/LeaseOrphanSweeper.js +34 -6
  144. package/lib/src/services/lease/LeaseService.js +19 -5
  145. package/lib/src/services/session/nodeCreateCaps.js +55 -0
  146. package/lib/src/services/session/sessionCredentials.js +7 -59
  147. package/lib/src/sessions/CloudSession.js +12 -0
  148. package/lib/src/sessions/LocalSession.js +52 -26
  149. package/lib/src/sessions/RemoteSession.js +116 -9
  150. package/lib/src/sessions/SessionManager.js +49 -6
  151. package/lib/src/sessions/appiumUmbrella.js +32 -0
  152. package/lib/test/helpers/appium-umbrella.js +84 -0
  153. package/lib/test/helpers/fake-device-table.js +97 -0
  154. package/lib/test/helpers/loki-stores.js +41 -0
  155. package/lib/test/helpers/loopbackRequest.js +77 -0
  156. package/lib/test/helpers/own-node-id.js +29 -0
  157. package/lib/test/helpers/scratch-database.js +100 -0
  158. package/lib/test/helpers/scratch-port-leases.js +119 -0
  159. package/lib/test/helpers/test-container.js +35 -27
  160. package/lib/test/integration/androidDevices.spec.js +5 -4
  161. package/lib/test/integration/ios/01iOSSimulator.spec.js +6 -5
  162. package/lib/test/integration/ios/02iOSDevices.spec.js +3 -2
  163. package/lib/test/integration/prisma-device-store-add-devices.spec.js +14 -4
  164. package/lib/test/setup/scratch-default-database.js +32 -0
  165. package/lib/test/unit/AndroidDeviceManager.spec.js +56 -1
  166. package/lib/test/unit/DeviceModel.spec.js +9 -9
  167. package/lib/test/unit/HealingCascade.test.js +5 -1
  168. package/lib/test/unit/OrphanSweeper.test.js +17 -13
  169. package/lib/test/unit/SessionHeartbeatWrite.test.js +6 -7
  170. package/lib/test/unit/StreamTicketService.spec.js +4 -1
  171. package/lib/test/unit/TokenEndpoint.spec.js +17 -3
  172. package/lib/test/unit/aggregate-hotspots-session-scope.spec.js +5 -5
  173. package/lib/test/unit/android-h264-service.spec.js +24 -0
  174. package/lib/test/unit/android-stream-stall-eof.spec.js +10 -0
  175. package/lib/test/unit/apps-team-visibility.spec.js +16 -16
  176. package/lib/test/unit/auth-me-auth-disabled.test.js +7 -4
  177. package/lib/test/unit/authMiddleware.appTicket.spec.js +5 -5
  178. package/lib/test/unit/authMiddleware.bearer.spec.js +4 -1
  179. package/lib/test/unit/authMiddleware.mcpAudience.spec.js +4 -1
  180. package/lib/test/unit/authMiddleware.streamTicket.spec.js +19 -7
  181. package/lib/test/unit/authMiddleware.test.js +3 -0
  182. package/lib/test/unit/bug-report/route.spec.js +9 -9
  183. package/lib/test/unit/cliargs-admin-only.spec.js +3 -3
  184. package/lib/test/unit/clipboard-route.spec.js +22 -8
  185. package/lib/test/unit/command-auth-appium-server.spec.js +187 -32
  186. package/lib/test/unit/command-auth.spec.js +31 -31
  187. package/lib/test/unit/control-device-lookup.spec.js +9 -9
  188. package/lib/test/unit/control-forward-decision.spec.js +115 -0
  189. package/lib/test/unit/control-lookup-failure.spec.js +3 -3
  190. package/lib/test/unit/create-error-message.spec.js +43 -0
  191. package/lib/test/unit/create-handoff.spec.js +86 -0
  192. package/lib/test/unit/dashboard-events-team-scope.spec.js +6 -3
  193. package/lib/test/unit/device-access-guard.spec.js +66 -28
  194. package/lib/test/unit/device-service.spec.js +4 -0
  195. package/lib/test/unit/device-store-clear-at-boot.spec.js +48 -0
  196. package/lib/test/unit/device-sync-lost-update.spec.js +17 -89
  197. package/lib/test/unit/device-team-guard.spec.js +20 -20
  198. package/lib/test/unit/device-utils.spec.js +8 -0
  199. package/lib/test/unit/extract-team-cap.spec.js +65 -0
  200. package/lib/test/unit/grid-admin-only.spec.js +3 -3
  201. package/lib/test/unit/healing-tier-gate.spec.js +5 -1
  202. package/lib/test/unit/health-monitor-own-phones.spec.js +102 -0
  203. package/lib/test/unit/health-monitor-stream-lookup.spec.js +11 -1
  204. package/lib/test/unit/helpers.spec.js +27 -0
  205. package/lib/test/unit/hub-boot-sweep-recovery.spec.js +192 -0
  206. package/lib/test/unit/hub-create-once.spec.js +190 -0
  207. package/lib/test/unit/hub-create-session-umbrella.spec.js +160 -0
  208. package/lib/test/unit/hub-gateway-create-appium-server.spec.js +591 -0
  209. package/lib/test/unit/hub-node-control-forward.spec.js +608 -0
  210. package/lib/test/unit/hub-node-gateway-appium-server.spec.js +594 -0
  211. package/lib/test/unit/hub-node-race.spec.js +426 -0
  212. package/lib/test/unit/hub-node-same-host.spec.js +356 -0
  213. package/lib/test/unit/hub-register-removal.spec.js +165 -0
  214. package/lib/test/unit/hub-remote-session-heartbeat.spec.js +277 -0
  215. package/lib/test/unit/hub-routed-session-recovery.spec.js +151 -0
  216. package/lib/test/unit/hub-session-token.spec.js +265 -0
  217. package/lib/test/unit/insert-before-routes.spec.js +5 -5
  218. package/lib/test/unit/install-repository-app-team.spec.js +6 -2
  219. package/lib/test/unit/interceptor/MitmProxyHost.spec.js +23 -10
  220. package/lib/test/unit/internal-call.spec.js +181 -0
  221. package/lib/test/unit/ios-discovery-same-host.spec.js +193 -0
  222. package/lib/test/unit/ios-stream-appium-tunnel.spec.js +317 -0
  223. package/lib/test/unit/ios-stream-port-leases.spec.js +299 -0
  224. package/lib/test/unit/ios-wda-status-identity.spec.js +84 -0
  225. package/lib/test/unit/is-xenon-running.spec.js +69 -0
  226. package/lib/test/unit/lease/LeaseOrphanSweeper.spec.js +2 -2
  227. package/lib/test/unit/lease/LeaseService.spec.js +28 -1
  228. package/lib/test/unit/lease/lease-reap-held-phone.spec.js +154 -0
  229. package/lib/test/unit/lease/lease-sweeper-expiry.spec.js +107 -0
  230. package/lib/test/unit/lease/lease-team-visibility.spec.js +2 -2
  231. package/lib/test/unit/lease/lease-token-log-filter.spec.js +242 -64
  232. package/lib/test/unit/lease/lease-token-oracle.spec.js +20 -7
  233. package/lib/test/unit/lease/session-lease-proof.spec.js +8 -3
  234. package/lib/test/unit/logcat-ws.spec.js +12 -1
  235. package/lib/test/unit/loopback-request.spec.js +81 -0
  236. package/lib/test/unit/mark-node-busy-loki.spec.js +53 -0
  237. package/lib/test/unit/node-create-caps.spec.js +54 -0
  238. package/lib/test/unit/node-gateway-create-appium-server.spec.js +354 -0
  239. package/lib/test/unit/node-hub-session-owner.spec.js +223 -0
  240. package/lib/test/unit/node-phone-control.spec.js +131 -0
  241. package/lib/test/unit/node-socket-relay.spec.js +357 -0
  242. package/lib/test/unit/omni-vision.spec.js +7 -3
  243. package/lib/test/unit/plugin.spec.js +45 -4
  244. package/lib/test/unit/port-allocator-os-probe.spec.js +151 -0
  245. package/lib/test/unit/profile-router.test.js +11 -11
  246. package/lib/test/unit/queue-requester.spec.js +5 -0
  247. package/lib/test/unit/queue-team-scope-routes.spec.js +17 -16
  248. package/lib/test/unit/queue-visibility.spec.js +1 -1
  249. package/lib/test/unit/recording-downloads-visibility.spec.js +2 -2
  250. package/lib/test/unit/recording-instrumentation.spec.js +5 -0
  251. package/lib/test/unit/recording-source-ended.spec.js +5 -0
  252. package/lib/test/unit/recording-store.spec.js +9 -3
  253. package/lib/test/unit/recordings-library-routes.spec.js +51 -51
  254. package/lib/test/unit/recordings-write-visibility.spec.js +2 -2
  255. package/lib/test/unit/remote-session-create.spec.js +130 -0
  256. package/lib/test/unit/reservation-team-visibility.spec.js +5 -5
  257. package/lib/test/unit/reset-link.test.js +14 -14
  258. package/lib/test/unit/session-app-resolution.spec.js +14 -10
  259. package/lib/test/unit/session-assets.spec.js +9 -9
  260. package/lib/test/unit/session-attribution-wiring.spec.js +29 -2
  261. package/lib/test/unit/session-create-layer.spec.js +255 -0
  262. package/lib/test/unit/session-credential-strip.spec.js +25 -9
  263. package/lib/test/unit/session-credentials.spec.js +0 -47
  264. package/lib/test/unit/session-listing-auth.spec.js +345 -0
  265. package/lib/test/unit/session-locator.spec.js +250 -0
  266. package/lib/test/unit/session-node-calls.spec.js +174 -0
  267. package/lib/test/unit/session-owner-resolver.spec.js +107 -0
  268. package/lib/test/unit/session-recovery-routing.spec.js +118 -0
  269. package/lib/test/unit/session-upgrade-guard.spec.js +571 -0
  270. package/lib/test/unit/shutdown-drain-remote.spec.js +58 -0
  271. package/lib/test/unit/socketServer.bearer.spec.js +3 -1
  272. package/lib/test/unit/stream-leave-route.spec.js +19 -7
  273. package/lib/test/unit/stream-own-device-row.spec.js +164 -0
  274. package/lib/test/unit/stream-stop-ownership.spec.js +17 -5
  275. package/lib/test/unit/stream-stop-recording.spec.js +17 -5
  276. package/lib/test/unit/stream-ticket-identity.spec.js +26 -6
  277. package/lib/test/unit/stream-watchdog-timers.spec.js +46 -0
  278. package/lib/test/unit/upgrade-router.spec.js +446 -0
  279. package/lib/test/unit/users-router.test.js +11 -8
  280. package/lib/test/unit/verify-credential.spec.js +4 -1
  281. package/lib/test/unit/webdriver-info-route.spec.js +64 -0
  282. package/lib/test/unit/ws-upgrade-routing-appium-server.spec.js +500 -0
  283. package/lib/tsconfig.tsbuildinfo +1 -1
  284. package/package.json +3 -4
  285. package/prisma/migrations/20260929120000_device_claims/migration.sql +8 -0
  286. package/prisma/schema.prisma +9 -0
  287. package/lib/public/assets/BugReportButton-DKYsOdGV.js +0 -10
  288. package/lib/public/assets/device-explorer-DV8rWRxL.css +0 -1
  289. package/lib/src/proxy/wd-command-proxy.js +0 -221
@@ -80,6 +80,7 @@ const deviceTeamGuard_1 = require("../../middleware/deviceTeamGuard");
80
80
  const controlDevice_1 = require("../../middleware/controlDevice");
81
81
  const manualLock_1 = require("../../services/recording/manualLock");
82
82
  const streamStartConflict_1 = require("./streamStartConflict");
83
+ const nodePhoneControl_1 = require("./nodePhoneControl");
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");
@@ -110,37 +111,10 @@ router.use((0, deviceTeamGuard_1.deviceTeamGuard)());
110
111
  // another user's Appium session. Mounted here so every current and future
111
112
  // mutation is covered without per-handler opt-in.
112
113
  router.use((0, deviceAccessGuard_1.deviceAccessGuard)());
113
- // Cloud metadata endpoints — never proxy to these regardless of caller.
114
- const FORBIDDEN_PROXY_HOSTS = new Set([
115
- '169.254.169.254', // AWS/Azure/GCP IMDS
116
- 'metadata.google.internal',
117
- 'metadata.goog',
118
- '100.100.100.200', // Alibaba ECS metadata
119
- 'fd00:ec2::254', // AWS IMDSv6
120
- ]);
121
- /**
122
- * Build a safe proxy URL from a device's reported host. Strips any path,
123
- * query, or fragment the host string carried, blocks cloud-metadata targets,
124
- * and refuses non-http(s) schemes. Returns null if the host is unsafe.
125
- */
126
- function buildProxyUrl(deviceHost, req) {
127
- let parsed;
128
- try {
129
- parsed = new URL(deviceHost);
130
- }
131
- catch (_a) {
132
- return null;
133
- }
134
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
135
- return null;
136
- if (FORBIDDEN_PROXY_HOSTS.has(parsed.hostname))
137
- return null;
138
- // Only keep scheme + host + port; discard any attacker-baked path/query/fragment.
139
- const origin = `${parsed.protocol}//${parsed.host}`;
140
- // Treat req.originalUrl as a path — reparse to strip any control characters.
141
- const forwardPath = req.originalUrl.startsWith('/') ? req.originalUrl : `/${req.originalUrl}`;
142
- return `${origin}${forwardPath}`;
143
- }
114
+ // Another server's phone (a node's, or a cloud provider's): its actions are
115
+ // sent on to its node, answered here, or refused, before any handler below
116
+ // runs it with this server's own tools. See nodePhoneControl.ts.
117
+ router.use((0, nodePhoneControl_1.nodePhoneControl)());
144
118
  function getDeviceInfo(udid) {
145
119
  return __awaiter(this, void 0, void 0, function* () {
146
120
  const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
@@ -172,26 +146,12 @@ function getDeviceManagerForPlatform(platform) {
172
146
  });
173
147
  }
174
148
  router.post('/:udid/tap', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
175
- var _a, _b;
176
149
  const { udid } = req.params;
177
150
  const { x, y } = req.body;
178
151
  const device = yield getDeviceInfo(udid);
179
152
  if (!device)
180
153
  return res.status(404).send('Device not found');
181
154
  const manager = yield getDeviceManagerForPlatform(device.platform);
182
- if (device.host && !device.host.includes(req.get('host') || '')) {
183
- const target = buildProxyUrl(device.host, req);
184
- if (!target)
185
- return res.status(400).send({ error: 'Unsafe device host' });
186
- logger_1.default.info(`Proxying tap for ${udid} to ${target}`);
187
- try {
188
- yield InternalHttpClient_1.InternalHttpClient.post(target, req.body);
189
- return res.status(200).send({ success: true });
190
- }
191
- catch (err) {
192
- return res.status(((_a = err.response) === null || _a === void 0 ? void 0 : _a.status) || 500).send(((_b = err.response) === null || _b === void 0 ? void 0 : _b.data) || err.message);
193
- }
194
- }
195
155
  if (manager && manager.tap) {
196
156
  try {
197
157
  yield manager.tap(udid, x, y);
@@ -216,26 +176,12 @@ router.post('/:udid/tap', (req, res) => __awaiter(void 0, void 0, void 0, functi
216
176
  res.status(400).send('Manager not found or tap not supported');
217
177
  }));
218
178
  router.post('/:udid/swipe', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
219
- var _a, _b;
220
179
  const { udid } = req.params;
221
180
  const { x, y, endX, endY, duration = 1000 } = req.body;
222
181
  const device = yield getDeviceInfo(udid);
223
182
  if (!device)
224
183
  return res.status(404).send('Device not found');
225
184
  const manager = yield getDeviceManagerForPlatform(device.platform);
226
- if (device.host && !device.host.includes(req.get('host') || '')) {
227
- const target = buildProxyUrl(device.host, req);
228
- if (!target)
229
- return res.status(400).send({ error: 'Unsafe device host' });
230
- logger_1.default.info(`Proxying swipe for ${udid} to ${target}`);
231
- try {
232
- yield InternalHttpClient_1.InternalHttpClient.post(target, req.body);
233
- return res.status(200).send({ success: true });
234
- }
235
- catch (err) {
236
- return res.status(((_a = err.response) === null || _a === void 0 ? void 0 : _a.status) || 500).send(((_b = err.response) === null || _b === void 0 ? void 0 : _b.data) || err.message);
237
- }
238
- }
239
185
  if (manager && manager.swipe) {
240
186
  try {
241
187
  yield manager.swipe(udid, x, y, endX, endY, duration);
@@ -249,26 +195,12 @@ router.post('/:udid/swipe', (req, res) => __awaiter(void 0, void 0, void 0, func
249
195
  res.status(400).send('Manager not found or swipe not supported');
250
196
  }));
251
197
  router.post('/:udid/text', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
252
- var _a, _b;
253
198
  const { udid } = req.params;
254
199
  const { text } = req.body;
255
200
  const device = yield getDeviceInfo(udid);
256
201
  if (!device)
257
202
  return res.status(404).send('Device not found');
258
203
  const manager = yield getDeviceManagerForPlatform(device.platform);
259
- if (device.host && !device.host.includes(req.get('host') || '')) {
260
- const target = buildProxyUrl(device.host, req);
261
- if (!target)
262
- return res.status(400).send({ error: 'Unsafe device host' });
263
- logger_1.default.info(`Proxying typeText for ${udid} to ${target}`);
264
- try {
265
- yield InternalHttpClient_1.InternalHttpClient.post(target, req.body);
266
- return res.status(200).send({ success: true });
267
- }
268
- catch (err) {
269
- return res.status(((_a = err.response) === null || _a === void 0 ? void 0 : _a.status) || 500).send(((_b = err.response) === null || _b === void 0 ? void 0 : _b.data) || err.message);
270
- }
271
- }
272
204
  if (manager && manager.typeText) {
273
205
  try {
274
206
  yield manager.typeText(udid, text);
@@ -282,26 +214,12 @@ router.post('/:udid/text', (req, res) => __awaiter(void 0, void 0, void 0, funct
282
214
  res.status(400).send('Manager not found or typeText not supported');
283
215
  }));
284
216
  router.post('/:udid/keyevent', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
285
- var _a, _b;
286
217
  const { udid } = req.params;
287
218
  const { keyCode } = req.body;
288
219
  const device = yield getDeviceInfo(udid);
289
220
  if (!device)
290
221
  return res.status(404).send('Device not found');
291
222
  const manager = yield getDeviceManagerForPlatform(device.platform);
292
- if (device.host && !device.host.includes(req.get('host') || '')) {
293
- const target = buildProxyUrl(device.host, req);
294
- if (!target)
295
- return res.status(400).send({ error: 'Unsafe device host' });
296
- logger_1.default.info(`Proxying keyevent for ${udid} to ${target}`);
297
- try {
298
- yield InternalHttpClient_1.InternalHttpClient.post(target, req.body);
299
- return res.status(200).send({ success: true });
300
- }
301
- catch (err) {
302
- return res.status(((_a = err.response) === null || _a === void 0 ? void 0 : _a.status) || 500).send(((_b = err.response) === null || _b === void 0 ? void 0 : _b.data) || err.message);
303
- }
304
- }
305
223
  if (manager && manager.pressKey) {
306
224
  try {
307
225
  yield manager.pressKey(udid, keyCode);
@@ -385,26 +303,12 @@ router.post('/:udid/clipboard', (req, res) => __awaiter(void 0, void 0, void 0,
385
303
  res.status(400).send('Manager not found or setClipboard not supported');
386
304
  }));
387
305
  router.post('/:udid/touchAndHold', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
388
- var _a, _b;
389
306
  const { udid } = req.params;
390
307
  const { x, y, duration = 1000 } = req.body;
391
308
  const device = yield getDeviceInfo(udid);
392
309
  if (!device)
393
310
  return res.status(404).send('Device not found');
394
311
  const manager = yield getDeviceManagerForPlatform(device.platform);
395
- if (device.host && !device.host.includes(req.get('host') || '')) {
396
- const target = buildProxyUrl(device.host, req);
397
- if (!target)
398
- return res.status(400).send({ error: 'Unsafe device host' });
399
- logger_1.default.info(`Proxying touchAndHold for ${udid} to ${target}`);
400
- try {
401
- yield InternalHttpClient_1.InternalHttpClient.post(target, req.body);
402
- return res.status(200).send({ success: true });
403
- }
404
- catch (err) {
405
- return res.status(((_a = err.response) === null || _a === void 0 ? void 0 : _a.status) || 500).send(((_b = err.response) === null || _b === void 0 ? void 0 : _b.data) || err.message);
406
- }
407
- }
408
312
  if (manager && manager.touchAndHold) {
409
313
  try {
410
314
  yield manager.touchAndHold(udid, x, y, duration);
@@ -800,7 +704,9 @@ router.post('/:udid/stream/ticket', (req, res) => __awaiter(void 0, void 0, void
800
704
  function stopPreview(udid, device, release) {
801
705
  return __awaiter(this, void 0, void 0, function* () {
802
706
  if (device.platform === 'ios' || device.platform === 'tvos') {
803
- yield typedi_1.Container.get(IOSStreamService_1.default).stopStream(udid);
707
+ // A viewer's stop: an Appium session on the phone may be driving this
708
+ // stream's WDA, and then the stream stays (see IOSStreamService.stopStream).
709
+ yield typedi_1.Container.get(IOSStreamService_1.default).stopStream(udid, { forViewer: true });
804
710
  }
805
711
  else {
806
712
  yield typedi_1.Container.get(AndroidStreamService_1.default).stopStream(udid);
@@ -1026,8 +932,9 @@ router.get('/:udid/stream', (req, res) => __awaiter(void 0, void 0, void 0, func
1026
932
  getSession: (id) => iosStreamService.getStreamStatus(id),
1027
933
  // Single attempt (retries = 0): a healthy WDA answers in milliseconds,
1028
934
  // 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),
935
+ // 2.5s /status timeout when WDA is dead. No udid: WDA names no phone
936
+ // (see isWDARunning). The port is this phone's stream's own lease.
937
+ isWdaHealthy: (wdaPort) => iosStreamService.isWDARunning(wdaPort, 0),
1031
938
  startStream: (id) => __awaiter(void 0, void 0, void 0, function* () {
1032
939
  var _a;
1033
940
  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,
@@ -0,0 +1,301 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.NODE_CONTROL_TIMEOUT_MS = exports.ANSWERED_HERE = exports.NODE_FORWARDED_CONTROL = void 0;
16
+ exports.safeNodeOrigin = safeNodeOrigin;
17
+ exports.nodeControlUrl = nodeControlUrl;
18
+ exports.isOtherServersPhone = isOtherServersPhone;
19
+ exports.nodePhoneControl = nodePhoneControl;
20
+ const typedi_1 = require("typedi");
21
+ const logger_1 = __importDefault(require("../../logger"));
22
+ const PluginContext_1 = require("../../PluginContext");
23
+ const localDeviceHosts_1 = require("../../device-managers/localDeviceHosts");
24
+ const actor_1 = require("../../services/device-access/actor");
25
+ const hubSessionToken_1 = require("../../gateway/hubSessionToken");
26
+ const forwardToNode_1 = require("../../gateway/forwardToNode");
27
+ const device_store_1 = require("../../data-service/device-store");
28
+ const deviceAccessPolicy_1 = require("../../services/device-access/deviceAccessPolicy");
29
+ const controlDevice_1 = require("../../middleware/controlDevice");
30
+ /**
31
+ * /control on another server's phone: a node's, or a cloud provider's.
32
+ *
33
+ * The handlers in control.ts act on this server's own phones, with this
34
+ * server's adb and go-ios. On a hub they used to run for a node's phone too,
35
+ * against a phone the hub doesn't have: only five actions were sent on to the
36
+ * node. This sits after the team and ownership guards and decides every
37
+ * request for another server's phone before any handler runs, so an action
38
+ * added later is refused for it until someone puts it on a list below.
39
+ */
40
+ /**
41
+ * Sent on to the phone's node, and the node's answer relayed unchanged. Each
42
+ * is about the phone alone. The preview's are too: the node holds the phone
43
+ * for the preview, counts its viewers and releases it, by its own rules and
44
+ * with the hub's user, so none of that is kept here.
45
+ */
46
+ exports.NODE_FORWARDED_CONTROL = [
47
+ 'POST tap',
48
+ 'POST swipe',
49
+ 'POST text',
50
+ 'POST keyevent',
51
+ 'POST touchandhold',
52
+ 'GET screenshot',
53
+ 'GET clipboard',
54
+ 'POST clipboard',
55
+ 'POST lock',
56
+ 'POST unlock',
57
+ 'GET display',
58
+ 'POST uninstall',
59
+ 'GET apps',
60
+ 'GET logs',
61
+ 'POST shell',
62
+ 'GET inspector/snapshot',
63
+ 'POST stream/start',
64
+ 'GET stream/status',
65
+ 'POST stream/leave',
66
+ 'POST stream/stop',
67
+ // The MJPEG stream itself: relayed for as long as the viewer watches.
68
+ 'GET stream',
69
+ ];
70
+ /**
71
+ * Answered by this server's own handler.
72
+ * - appium-session: the session runs through this server, which routes its
73
+ * commands, so its id and base path are this server's.
74
+ * - stream/ticket: the viewer's ticket is this server's, minted after its
75
+ * team check. The stream it opens is relayed from the node: GET stream
76
+ * above, and the H.264 and logcat sockets (src/app/ws/nodeSocketRelay.ts),
77
+ * which get a ticket of the node's own.
78
+ */
79
+ exports.ANSWERED_HERE = ['GET appium-session', 'POST stream/ticket'];
80
+ // Not on either list, and why, so nobody adds them as a plain forward:
81
+ // - upload-install is a multipart upload;
82
+ // - install-repository-app installs a file from this server's app library,
83
+ // which the node doesn't have;
84
+ // - install takes a path, which names a file on one machine only;
85
+ // - omni-scan and test-locator run this server's AI settings on a
86
+ // screenshot.
87
+ /**
88
+ * A node that hasn't started answering in this long is treated as gone. Once
89
+ * it has, the answer may take as long as it takes: a stream lasts as long as
90
+ * its viewer.
91
+ */
92
+ exports.NODE_CONTROL_TIMEOUT_MS = 60000;
93
+ // Cloud metadata endpoints — never proxy to these regardless of caller.
94
+ const FORBIDDEN_PROXY_HOSTS = new Set([
95
+ '169.254.169.254', // AWS/Azure/GCP IMDS
96
+ 'metadata.google.internal',
97
+ 'metadata.goog',
98
+ '100.100.100.200', // Alibaba ECS metadata
99
+ 'fd00:ec2::254', // AWS IMDSv6
100
+ ]);
101
+ /**
102
+ * The node's origin from the phone's reported host: scheme, host and port
103
+ * only, dropping any path, query or fragment the host string carried. Null
104
+ * for a cloud-metadata target or a scheme other than http(s).
105
+ */
106
+ function safeNodeOrigin(deviceHost) {
107
+ let parsed;
108
+ try {
109
+ parsed = new URL(deviceHost);
110
+ }
111
+ catch (_a) {
112
+ return null;
113
+ }
114
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
115
+ return null;
116
+ if (FORBIDDEN_PROXY_HOSTS.has(parsed.hostname))
117
+ return null;
118
+ return `${parsed.protocol}//${parsed.host}`;
119
+ }
120
+ /** The node's own URL for this request, or null if the host is unsafe. */
121
+ function nodeControlUrl(deviceHost, req) {
122
+ const origin = safeNodeOrigin(deviceHost);
123
+ if (!origin)
124
+ return null;
125
+ const forwardPath = req.originalUrl.startsWith('/') ? req.originalUrl : `/${req.originalUrl}`;
126
+ return `${origin}${withoutTicket(forwardPath)}`;
127
+ }
128
+ /**
129
+ * The path without a `ticket` query parameter. A viewer's ticket is this
130
+ * server's, already spent here, and not the node's business: the node gets
131
+ * the hub's token instead.
132
+ */
133
+ function withoutTicket(path) {
134
+ const q = path.indexOf('?');
135
+ if (q < 0)
136
+ return path;
137
+ const params = new URLSearchParams(path.slice(q + 1));
138
+ if (!params.has('ticket'))
139
+ return path;
140
+ params.delete('ticket');
141
+ const query = params.toString();
142
+ return query ? `${path.slice(0, q)}?${query}` : path.slice(0, q);
143
+ }
144
+ /**
145
+ * A phone another server drives. Decided by the phone's own row (its nodeId,
146
+ * else its exact host against this server's own hosts), never by the Host
147
+ * header the caller used: a substring match on that header forwarded a lab's
148
+ * own phone back to itself, with no login, whenever the lab was reached as
149
+ * `localhost`.
150
+ */
151
+ function isOtherServersPhone(device) {
152
+ if (!device.host)
153
+ return false;
154
+ const context = typedi_1.Container.get(PluginContext_1.PluginContext);
155
+ return !(0, localDeviceHosts_1.isOwnDevice)((0, localDeviceHosts_1.localDeviceHosts)(context.pluginArgs, context.port), context.nodeId, device);
156
+ }
157
+ function originOf(host) {
158
+ try {
159
+ return new URL(host).origin;
160
+ }
161
+ catch (_a) {
162
+ return host;
163
+ }
164
+ }
165
+ function refuse(res, error, message) {
166
+ return res.status(501).json({ success: false, error, message });
167
+ }
168
+ function nodePhoneControl(deps = {}) {
169
+ var _a, _b, _c;
170
+ const findDevice = (_a = deps.findDevice) !== null && _a !== void 0 ? _a : controlDevice_1.findControlDeviceInStore;
171
+ const timeoutMs = (_b = deps.timeoutMs) !== null && _b !== void 0 ? _b : exports.NODE_CONTROL_TIMEOUT_MS;
172
+ const markNodeBusy = (_c = deps.markNodeBusy) !== null && _c !== void 0 ? _c : ((udid, host) => device_store_1.DeviceStoreFactory.getStore().markNodeBusy(udid, host));
173
+ return function (req, res, next) {
174
+ return __awaiter(this, void 0, void 0, function* () {
175
+ var _a;
176
+ const action = (0, controlDevice_1.controlAction)(req);
177
+ if (!action)
178
+ return next();
179
+ const udid = (0, controlDevice_1.readControlUdid)(req, res, 'nodePhoneControl');
180
+ if (udid === null)
181
+ return;
182
+ if (!udid)
183
+ return next();
184
+ // The guards' memoized lookup: usually no query of its own.
185
+ let device;
186
+ try {
187
+ device = yield (0, controlDevice_1.lookupControlDevice)(res, udid, findDevice);
188
+ }
189
+ catch (e) {
190
+ // Falling through would let the handler, with its own lookup, run a
191
+ // node's phone here: fail closed.
192
+ // Answered as every /control lookup failure is.
193
+ logger_1.default.error(`nodePhoneControl: device lookup failed for ${udid}: ${(_a = e === null || e === void 0 ? void 0 : e.message) !== null && _a !== void 0 ? _a : e}`);
194
+ return res.status(503).json((0, deviceAccessPolicy_1.ownershipUnavailableBody)());
195
+ }
196
+ if (!device || !isOtherServersPhone(device))
197
+ return next();
198
+ const key = `${req.method} ${action}`;
199
+ if (exports.ANSWERED_HERE.includes(key))
200
+ return next();
201
+ if (device.cloud) {
202
+ return refuse(res, 'not_available_for_cloud_phone', "This phone is a cloud provider's. Device control isn't available for it here.");
203
+ }
204
+ const node = originOf(device.host);
205
+ if (!exports.NODE_FORWARDED_CONTROL.includes(key)) {
206
+ return refuse(res, 'not_available_through_hub', `This phone is on node ${node}, and the hub doesn't pass ${action} on to nodes yet.`);
207
+ }
208
+ return forward(req, res, { udid, device, node, timeoutMs, markNodeBusy });
209
+ });
210
+ };
211
+ }
212
+ function forward(req, res, target) {
213
+ return __awaiter(this, void 0, void 0, function* () {
214
+ var _a, _b, _c, _d, _e, _f;
215
+ const { udid, device, node, timeoutMs } = target;
216
+ const url = nodeControlUrl(device.host, req);
217
+ if (!url)
218
+ return res.status(400).json({ error: 'Unsafe device host' });
219
+ const actor = (0, actor_1.resolveActor)(req);
220
+ const headers = {
221
+ accept: (_a = req.headers.accept) !== null && _a !== void 0 ? _a : 'application/json',
222
+ 'accept-encoding': 'identity',
223
+ };
224
+ // Null when this server can't sign (it warns once): the call goes without,
225
+ // which a node with auth disabled accepts and one with auth enabled refuses.
226
+ const token = actor.userId
227
+ ? yield typedi_1.Container.get(hubSessionToken_1.HubSessionTokenIssuer).controlTokenFor({
228
+ userId: actor.userId,
229
+ isAdmin: actor.isAdmin,
230
+ udid,
231
+ host: device.host,
232
+ })
233
+ : null;
234
+ if (token)
235
+ headers[hubSessionToken_1.HUB_TOKEN_HEADER] = token;
236
+ let body;
237
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
238
+ body = Buffer.from(JSON.stringify((_b = req.body) !== null && _b !== void 0 ? _b : {}));
239
+ headers['content-type'] = 'application/json';
240
+ }
241
+ const controller = new AbortController();
242
+ let timedOut = false;
243
+ const timer = setTimeout(() => {
244
+ timedOut = true;
245
+ controller.abort();
246
+ }, timeoutMs);
247
+ // The caller left: stop waiting for the node on their behalf.
248
+ const onClose = () => {
249
+ if (!res.writableFinished)
250
+ controller.abort();
251
+ };
252
+ res.on('close', onClose);
253
+ logger_1.default.debug(`Forwarding ${req.method} ${(0, controlDevice_1.controlAction)(req)} for ${udid} to node ${node}`);
254
+ try {
255
+ const upstream = yield (0, forwardToNode_1.sendToNode)({
256
+ url,
257
+ method: req.method,
258
+ headers,
259
+ body,
260
+ signal: controller.signal,
261
+ });
262
+ // The node has answered: from here the answer takes as long as it takes.
263
+ clearTimeout(timer);
264
+ const status = (_c = upstream.statusCode) !== null && _c !== void 0 ? _c : 502;
265
+ if (req.method === 'POST' && (0, controlDevice_1.controlAction)(req) === 'stream/start' && status < 300) {
266
+ // The node holds the phone for the preview now, and says so in its
267
+ // next report, up to an interval away. Until then this server could
268
+ // hand the phone to a session the node would refuse.
269
+ yield target.markNodeBusy(udid, device.host).catch((e) => {
270
+ var _a;
271
+ logger_1.default.warn(`nodePhoneControl: could not mark ${udid} busy for its node: ${(_a = e === null || e === void 0 ? void 0 : e.message) !== null && _a !== void 0 ? _a : e}`);
272
+ });
273
+ }
274
+ yield (0, forwardToNode_1.relayAnswer)(upstream, res, false);
275
+ }
276
+ catch (e) {
277
+ if (res.headersSent || res.destroyed) {
278
+ res.destroy();
279
+ }
280
+ else if (timedOut) {
281
+ res.status(504).json({
282
+ success: false,
283
+ error: 'node_timeout',
284
+ message: `Node ${node} didn't answer within ${Math.round(timeoutMs / 1000)} s.`,
285
+ });
286
+ }
287
+ else {
288
+ logger_1.default.warn(`nodePhoneControl: node ${node} unreachable for ${udid}: ${(_d = e === null || e === void 0 ? void 0 : e.message) !== null && _d !== void 0 ? _d : e}`);
289
+ res.status(502).json({
290
+ success: false,
291
+ error: 'node_unreachable',
292
+ message: `Xenon could not reach node ${node} for this phone: ${(_f = (_e = e === null || e === void 0 ? void 0 : e.code) !== null && _e !== void 0 ? _e : e === null || e === void 0 ? void 0 : e.message) !== null && _f !== void 0 ? _f : e}`,
293
+ });
294
+ }
295
+ }
296
+ finally {
297
+ clearTimeout(timer);
298
+ res.off('close', onClose);
299
+ }
300
+ });
301
+ }