@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
@@ -1,5 +1,5 @@
1
1
  {
2
- "name": "prisma-client-d6a27094f142fa4c61402cd0048ae9081cb125a1a6a065e48a3b1066db77d5b1",
2
+ "name": "prisma-client-2fecbe6024329bf05b69cdf448f1b687e32645450beda6a210a882d44a7ffb8d",
3
3
  "main": "index.js",
4
4
  "types": "index.d.ts",
5
5
  "browser": "index-browser.js",
@@ -196,10 +196,19 @@ model Device {
196
196
  lockedAt Float? @map("locked_at")
197
197
  teamId String?
198
198
  team Team? @relation(fields: [teamId], references: [id])
199
+ // This server's claim on the phone for a session: when it was taken, and
200
+ // the session's id once the session exists (null while it is created).
201
+ // See src/data-service/deviceClaims.ts.
202
+ claimSessionId String?
203
+ claimedAt Float?
204
+ // On a hub, what the phone's node last reported: busy there. `busy` is the
205
+ // claim OR this. Always false for a phone this server drives.
206
+ nodeBusy Boolean? @default(false)
199
207
 
200
208
  @@id([udid, host])
201
209
  @@index([owningSessionId])
202
210
  @@index([teamId])
211
+ @@index([claimSessionId])
203
212
  }
204
213
 
205
214
  model PendingSession {
@@ -282,7 +282,10 @@ exports.Prisma.DeviceScalarFieldEnum = {
282
282
  cpuArchitecture: 'cpuArchitecture',
283
283
  owningSessionId: 'owningSessionId',
284
284
  lockedAt: 'lockedAt',
285
- teamId: 'teamId'
285
+ teamId: 'teamId',
286
+ claimSessionId: 'claimSessionId',
287
+ claimedAt: 'claimedAt',
288
+ nodeBusy: 'nodeBusy'
286
289
  };
287
290
 
288
291
  exports.Prisma.PendingSessionScalarFieldEnum = {
@@ -180,7 +180,11 @@ function isXenonRunning(host, tlsRejectUnauthorized) {
180
180
  return __awaiter(this, void 0, void 0, function* () {
181
181
  try {
182
182
  const client = InternalHttpClient_1.InternalHttpClient.getClient(tlsRejectUnauthorized);
183
- yield client.get(`${host}/xenon/api/status`);
183
+ // /health is the one /xenon/api route with no login. The hub probes its
184
+ // nodes, and a node its hub, without credentials, so a login-only route
185
+ // (/status) called a server with auth on dead: the hub pruned its node's
186
+ // phones, and the node never registered with its hub.
187
+ yield client.get(`${host}/xenon/api/health`);
184
188
  return true;
185
189
  }
186
190
  catch (error) {
package/lib/src/logger.js CHANGED
@@ -42,6 +42,11 @@ const SENSITIVE_KEY_PATTERNS = [
42
42
  // 'token' already matches it; named so it survives any narrowing of that
43
43
  // pattern. A lease token gives its holder the lease's device.
44
44
  'leasetoken',
45
+ // accessKey, access_key and the x-xenon-access-key header (underscores
46
+ // and hyphens are stripped before matching). Half of a credential pair: it
47
+ // names the key a leaked token would complete. RequestLogService already
48
+ // hides it through its own 'key' pattern.
49
+ 'accesskey',
45
50
  ];
46
51
  const REDACTED = '***REDACTED***';
47
52
  function isSensitiveKey(key) {
@@ -20,6 +20,9 @@ const AppDownloadTicketService_1 = require("../services/token/AppDownloadTicketS
20
20
  const config_1 = require("../config");
21
21
  const callerTeamIds_1 = require("../services/device-access/callerTeamIds");
22
22
  const verifyCredential_1 = require("./verifyCredential");
23
+ const PluginContext_1 = require("../PluginContext");
24
+ const hubSessionToken_1 = require("../gateway/hubSessionToken");
25
+ const localDeviceHosts_1 = require("../device-managers/localDeviceHosts");
23
26
  const SESSION_COOKIE = 'xenon_dashboard_session';
24
27
  const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
25
28
  // Role → scopes derivation. The same table is used in profile token creation
@@ -64,6 +67,70 @@ function readCookie(req, name) {
64
67
  }
65
68
  return undefined;
66
69
  }
70
+ /** One verifier per hub URL, so its fetched key set is reused. */
71
+ const hubVerifiers = new Map();
72
+ function hubVerifierFor(hub) {
73
+ let verifier = hubVerifiers.get(hub);
74
+ if (!verifier) {
75
+ verifier = new hubSessionToken_1.HubSessionTokenVerifier(hub);
76
+ hubVerifiers.set(hub, verifier);
77
+ }
78
+ return verifier;
79
+ }
80
+ /**
81
+ * On a node: the caller a hub's signed device-control call names, for
82
+ * `/control/<udid>/...` on the phone the token names, of this node. The hub
83
+ * checked the caller, the team rule and ownership before forwarding; the
84
+ * node's own ownership guard then judges that user again. undefined when the
85
+ * request isn't one (no hub token, not a node, not /control): the other
86
+ * credential paths decide it. 'refused' for a token that isn't a valid grant
87
+ * for this phone of this node; 'unavailable' when the hub's keys can't be
88
+ * fetched.
89
+ */
90
+ function hubControlCallerOf(req) {
91
+ return __awaiter(this, void 0, void 0, function* () {
92
+ var _a;
93
+ const presented = req.headers[hubSessionToken_1.HUB_TOKEN_HEADER];
94
+ if (typeof presented !== 'string')
95
+ return undefined;
96
+ const context = typedi_1.Container.get(PluginContext_1.PluginContext);
97
+ const hub = (_a = context.pluginArgs) === null || _a === void 0 ? void 0 : _a.hub;
98
+ if (hub === undefined)
99
+ return undefined;
100
+ const match = /^\/control\/([^/]+)(?:\/|$)/.exec(req.path);
101
+ if (!match)
102
+ return undefined;
103
+ let udid;
104
+ try {
105
+ udid = decodeURIComponent(match[1]);
106
+ }
107
+ catch (_b) {
108
+ return 'refused';
109
+ }
110
+ let grant;
111
+ try {
112
+ grant = yield hubVerifierFor(hub).verifyControl(presented);
113
+ }
114
+ catch (err) {
115
+ return err instanceof hubSessionToken_1.HubTokenUnavailableError ? 'unavailable' : 'refused';
116
+ }
117
+ if (!grant || grant.udid !== udid)
118
+ return 'refused';
119
+ if (!(0, localDeviceHosts_1.isLocalDeviceHost)((0, localDeviceHosts_1.localDeviceHosts)(context.pluginArgs, context.port), grant.host)) {
120
+ return 'refused';
121
+ }
122
+ const role = grant.isAdmin ? 'ADMIN' : 'MEMBER';
123
+ return {
124
+ kind: 'hub-control',
125
+ userId: grant.userId,
126
+ role,
127
+ scopes: scopesForRole(role),
128
+ rateLimit: 300,
129
+ // The hub applied the team rule: the node's rows carry the hub's teams.
130
+ teamIds: undefined,
131
+ };
132
+ });
133
+ }
67
134
  function authMiddleware(req, res, next) {
68
135
  return __awaiter(this, void 0, void 0, function* () {
69
136
  var _a, _b, _c, _d, _e, _f, _g, _h;
@@ -79,6 +146,19 @@ function authMiddleware(req, res, next) {
79
146
  req.apiKey = { id: 'auth-disabled', scopes: 'admin', rateLimit: 100000 };
80
147
  return next();
81
148
  }
149
+ // Path 0: on a node, the hub's signed device-control call
150
+ // (x-xenon-hub-token, audience xenon-node-control), for /control on the
151
+ // phone it names only. Anywhere else the header is not a credential.
152
+ const hubControl = yield hubControlCallerOf(req);
153
+ if (hubControl === 'refused')
154
+ return res.status(401).json({ error: 'invalid hub token' });
155
+ if (hubControl === 'unavailable') {
156
+ return res.status(503).json({ error: "the hub's token could not be checked" });
157
+ }
158
+ if (hubControl) {
159
+ req.auth = hubControl;
160
+ return next();
161
+ }
82
162
  const apiKeySvc = typedi_1.Container.get(ApiKeyService_1.ApiKeyService);
83
163
  const userSessionSvc = typedi_1.Container.get(UserSessionService_1.UserSessionService);
84
164
  const userSvc = typedi_1.Container.get(UserService_1.UserService);
@@ -11,6 +11,8 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.COMMAND_AUTH_UNAVAILABLE_BODY = exports.COMMAND_AUTH_UNAVAILABLE_STATUS = exports.UNKNOWN_SESSION_BODY = exports.UNKNOWN_SESSION_STATUS = void 0;
13
13
  exports.commandAuthEnabled = commandAuthEnabled;
14
+ exports.decideSessionAccess = decideSessionAccess;
15
+ exports.summarize = summarize;
14
16
  exports.createCommandAuthMiddleware = createCommandAuthMiddleware;
15
17
  const sessionTokenGate_1 = require("../services/sessionTokenGate");
16
18
  const commandCaller_1 = require("./commandCaller");
@@ -66,9 +68,19 @@ exports.COMMAND_AUTH_UNAVAILABLE_BODY = {
66
68
  stacktrace: '',
67
69
  },
68
70
  };
69
- function decide(req, sessionId, deps) {
71
+ /**
72
+ * May the caller presenting these headers use every one of these sessions?
73
+ *
74
+ * The one decision behind per-command auth, shared by the WebDriver command
75
+ * middleware and the session WebSocket guard (sessionUpgradeGuard.ts): an
76
+ * override admin may use any session, anyone else only sessions they own. A
77
+ * WebSocket path can name its session more than one way, so it passes every id
78
+ * Appium might use, and the caller must own each. Never throws: a check that
79
+ * could not run comes back `unavailable`.
80
+ */
81
+ function decideSessionAccess(headers, sessionIds, deps) {
70
82
  return __awaiter(this, void 0, void 0, function* () {
71
- const credential = (0, commandCaller_1.readPresentedCredential)(req.headers);
83
+ const credential = (0, commandCaller_1.readPresentedCredential)(headers);
72
84
  if (credential.kind === 'none') {
73
85
  // Nobody without credentials can be an owner or an admin: no lookups.
74
86
  return { allow: false, caller: 'no credentials', reason: 'no credentials presented' };
@@ -86,20 +98,22 @@ function decide(req, sessionId, deps) {
86
98
  const { caller } = verdict;
87
99
  if (caller.overrideAdmin)
88
100
  return { allow: true };
89
- let owner;
90
- try {
91
- owner = yield deps.ownerOf(sessionId);
92
- }
93
- catch (error) {
94
- return { unavailable: true, stage: 'owner lookup', error };
95
- }
96
- if (!owner) {
97
- // Created without credentials, or no such session. Either way nobody but
98
- // an override admin may drive it.
99
- return { allow: false, caller: caller.userId, reason: 'no owner on record for this session' };
100
- }
101
- if (owner !== caller.userId) {
102
- return { allow: false, caller: caller.userId, reason: 'not the session owner' };
101
+ for (const sessionId of sessionIds) {
102
+ let owner;
103
+ try {
104
+ owner = yield deps.ownerOf(sessionId);
105
+ }
106
+ catch (error) {
107
+ return { unavailable: true, stage: 'owner lookup', error };
108
+ }
109
+ if (!owner) {
110
+ // Created without credentials, or no such session. Either way nobody but
111
+ // an override admin may drive it.
112
+ return { allow: false, caller: caller.userId, reason: 'no owner on record for this session' };
113
+ }
114
+ if (owner !== caller.userId) {
115
+ return { allow: false, caller: caller.userId, reason: 'not the session owner' };
116
+ }
103
117
  }
104
118
  return { allow: true };
105
119
  });
@@ -120,7 +134,7 @@ function createCommandAuthMiddleware(deps) {
120
134
  return next();
121
135
  const sessionId = String((_b = (_a = req.params) === null || _a === void 0 ? void 0 : _a.sessionId) !== null && _b !== void 0 ? _b : '');
122
136
  const where = `${req.method} ${String((_c = req.originalUrl) !== null && _c !== void 0 ? _c : req.url).split('?')[0]}`;
123
- decide(req, sessionId, deps)
137
+ decideSessionAccess(req.headers, [sessionId], deps)
124
138
  .then((decision) => {
125
139
  if ('unavailable' in decision) {
126
140
  deps.logger.error(`Command auth unavailable for ${where} (session ${sessionId}): ` +
@@ -46,8 +46,9 @@ function csrfMiddleware(req, res, next) {
46
46
  return next();
47
47
  if (!STATE_CHANGING_METHODS.has(req.method))
48
48
  return next();
49
- // Header-based auth: safe by construction (see block comment above).
50
- if (req.headers['x-xenon-access-key']) {
49
+ // Header-based auth: safe by construction (see block comment above). A
50
+ // hub's signed call to its node (x-xenon-hub-token) is one too.
51
+ if (req.headers['x-xenon-access-key'] || req.headers['x-xenon-hub-token']) {
51
52
  return next();
52
53
  }
53
54
  // Cookie-authed (or unauthenticated) state-changer. Require Origin or
@@ -21,6 +21,7 @@ const controlDevice_1 = require("./controlDevice");
21
21
  const SessionOwnerResolver_1 = require("../services/device-access/SessionOwnerResolver");
22
22
  const deviceAccessPolicy_1 = require("../services/device-access/deviceAccessPolicy");
23
23
  const actor_1 = require("../services/device-access/actor");
24
+ const deviceClaims_1 = require("../data-service/deviceClaims");
24
25
  const STATE_CHANGING = new Set(['POST', 'PUT', 'DELETE', 'PATCH']);
25
26
  /**
26
27
  * Mutations under /control that this guard must NOT handle.
@@ -129,7 +130,9 @@ function deviceAccessGuard(deps = {}) {
129
130
  }
130
131
  const decision = (0, deviceAccessPolicy_1.evaluateDeviceAccess)({
131
132
  udid,
132
- busy: !!device.busy,
133
+ // Not device.busy: a node's phone busy only by its node's report is
134
+ // the node's to judge (heldHere).
135
+ busy: (0, deviceClaims_1.heldHere)(device),
133
136
  sessionId: device.session_id,
134
137
  sessionOwnerUserId,
135
138
  actorUserId: actor.userId,
@@ -0,0 +1,153 @@
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
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.createSessionListingFilter = createSessionListingFilter;
13
+ const commandCaller_1 = require("./commandCaller");
14
+ const commandAuth_1 = require("./commandAuth");
15
+ /**
16
+ * Appium 3's session listing under per-command auth.
17
+ *
18
+ * `GET <basePath>/appium/sessions` (base-driver's only listing route; Appium 3
19
+ * has no `GET /sessions`) answers every live session's id and capabilities.
20
+ * Appium gates it behind the `session_discovery` insecure feature, but once an
21
+ * operator enables that (Appium Inspector's "attach to session" needs it), it
22
+ * answers anyone, without credentials: the easiest way to find an id to use.
23
+ *
24
+ * With XENON_REQUIRE_COMMAND_AUTH on, the credentials are read exactly as for
25
+ * a session command (commandCaller.ts), and Appium's answer is filtered:
26
+ * - an override admin sees the whole list, untouched;
27
+ * - a verified caller sees the sessions they own, decided for the whole list
28
+ * with one owner query (SessionOwnerResolver.ownersOf, ownerOf's rule);
29
+ * - no credentials, or credentials that do not verify: an empty list, in
30
+ * Appium's normal shape, which is exactly what an idle server answers.
31
+ *
32
+ * Appium still produces the listing; this only removes entries from it, by
33
+ * wrapping `res.json` before the route runs. Any other answer (an error, such
34
+ * as session_discovery not being enabled) passes through as Appium wrote it.
35
+ *
36
+ * The owners are looked up when the listing is answered rather than before
37
+ * the route runs, because only then are the listed ids known. The alternative,
38
+ * precomputing "this caller's live sessions", has no reliable "live" signal:
39
+ * `xenon: setSessionStatus` changes a running session's status, and without a
40
+ * status filter it is every session the user ever ran.
41
+ *
42
+ * Fails closed: a credential check that cannot run answers 503 before the
43
+ * route runs, an owner lookup that fails answers 503 instead of the listing,
44
+ * and a listed entry without an id is dropped.
45
+ */
46
+ /** The listed sessions in Appium's answer, or null when the answer is not a listing. */
47
+ function listingOf(body) {
48
+ if (!body || typeof body !== 'object')
49
+ return null;
50
+ const value = body.value;
51
+ return Array.isArray(value) ? value : null;
52
+ }
53
+ function idOf(entry) {
54
+ if (!entry || typeof entry !== 'object')
55
+ return undefined;
56
+ const id = entry.id;
57
+ return typeof id === 'string' && id ? id : undefined;
58
+ }
59
+ /**
60
+ * The listing route itself: GET, or HEAD (which Express answers from the GET
61
+ * route, with a Content-Length that would otherwise reveal the list's size),
62
+ * on the mounted path exactly. Method override has already run by now.
63
+ */
64
+ function isListingRequest(req) {
65
+ return (req.method === 'GET' || req.method === 'HEAD') && req.path === '/';
66
+ }
67
+ /**
68
+ * Replace the listing in the route's answer with `rewrite(entries)`. The
69
+ * route's JSON answer is intercepted once; an answer that is not a listing is
70
+ * sent as the route wrote it. A rewrite that rejects answers 503.
71
+ */
72
+ function rewriteListing(res, rewrite, onFailure) {
73
+ const json = res.json;
74
+ res.json = ((body) => {
75
+ // One answer per request: restore first, so nothing can run this twice.
76
+ res.json = json;
77
+ const entries = listingOf(body);
78
+ if (!entries)
79
+ return json.call(res, body);
80
+ const answer = (value) => json.call(res, Object.assign(Object.assign({}, body), { value }));
81
+ let rewritten;
82
+ try {
83
+ rewritten = rewrite(entries);
84
+ }
85
+ catch (error) {
86
+ rewritten = Promise.reject(error);
87
+ }
88
+ if (!(rewritten instanceof Promise))
89
+ return answer(rewritten);
90
+ rewritten
91
+ .then(answer, (error) => {
92
+ onFailure(error);
93
+ res.status(commandAuth_1.COMMAND_AUTH_UNAVAILABLE_STATUS);
94
+ json.call(res, commandAuth_1.COMMAND_AUTH_UNAVAILABLE_BODY);
95
+ })
96
+ .catch((error) => {
97
+ // Only reachable if sending itself threw; the response is past saving.
98
+ onFailure(error);
99
+ });
100
+ return res;
101
+ });
102
+ }
103
+ function createSessionListingFilter(deps) {
104
+ return (req, res, next) => {
105
+ var _a;
106
+ if (!deps.enabled() || deps.authDisabled())
107
+ return next();
108
+ if (!isListingRequest(req))
109
+ return next();
110
+ const where = `${req.method} ${String((_a = req.originalUrl) !== null && _a !== void 0 ? _a : req.url).split('?')[0]}`;
111
+ const showNothing = (caller, reason) => rewriteListing(res, (entries) => {
112
+ deps.logger.warn(`Session listing emptied: ${where} (caller ${caller}): ${reason}; ` +
113
+ `${entries.length} session(s) withheld.`);
114
+ return [];
115
+ }, () => undefined);
116
+ const showOwn = (userId) => rewriteListing(res, (entries) => __awaiter(this, void 0, void 0, function* () {
117
+ const ids = [...new Set(entries.map(idOf).filter((id) => !!id))];
118
+ const owners = ids.length > 0 ? yield deps.ownersOf(ids) : new Map();
119
+ const kept = entries.filter((entry) => {
120
+ const id = idOf(entry);
121
+ return !!id && owners.get(id) === userId;
122
+ });
123
+ deps.logger.info(`Session listing filtered: ${where} (caller ${userId}): ` +
124
+ `${kept.length} of ${entries.length} sessions shown.`);
125
+ return kept;
126
+ }), (error) => deps.logger.error(`Session listing unavailable for ${where} (caller ${userId}): ` +
127
+ `owner lookup failed: ${(0, commandAuth_1.summarize)(error)}`));
128
+ const credential = (0, commandCaller_1.readPresentedCredential)(req.headers);
129
+ if (credential.kind === 'none') {
130
+ // Nobody without credentials owns anything: no lookups.
131
+ showNothing('no credentials', 'no credentials presented');
132
+ return next();
133
+ }
134
+ deps.verifier
135
+ .verify(credential)
136
+ .then((verdict) => {
137
+ if (!verdict.valid)
138
+ showNothing('invalid credentials', 'credentials did not verify');
139
+ else if (!verdict.caller.overrideAdmin)
140
+ showOwn(verdict.caller.userId);
141
+ next();
142
+ }, (error) => {
143
+ deps.logger.error(`Session listing unavailable for ${where}: credential check failed: ${(0, commandAuth_1.summarize)(error)}`);
144
+ res.status(commandAuth_1.COMMAND_AUTH_UNAVAILABLE_STATUS).json(commandAuth_1.COMMAND_AUTH_UNAVAILABLE_BODY);
145
+ })
146
+ .catch((error) => {
147
+ deps.logger.error(`Session listing check failed for ${where}: ${(0, commandAuth_1.summarize)(error)}`);
148
+ if (!res.headersSent) {
149
+ res.status(commandAuth_1.COMMAND_AUTH_UNAVAILABLE_STATUS).json(commandAuth_1.COMMAND_AUTH_UNAVAILABLE_BODY);
150
+ }
151
+ });
152
+ };
153
+ }
@@ -0,0 +1,203 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UPGRADE_UNAVAILABLE_RESPONSE = exports.UPGRADE_REFUSED_RESPONSE = void 0;
4
+ exports.sessionIdsOfUpgrade = sessionIdsOfUpgrade;
5
+ exports.createSessionUpgradeGuard = createSessionUpgradeGuard;
6
+ exports.guardUpgradeEvents = guardUpgradeEvents;
7
+ const appiumBasePath_1 = require("../app/appiumBasePath");
8
+ const commandAuth_1 = require("./commandAuth");
9
+ /**
10
+ * Per-command auth for session WebSockets.
11
+ *
12
+ * WebDriver BiDi (`<basePath>/bidi/<sessionId>`, added by Appium's main) and
13
+ * the per-session sockets drivers add (`/ws/session/<sessionId>/...`, e.g.
14
+ * UiAutomator2's logcat and XCUITest's syslog broadcasts) are WebSocket
15
+ * upgrades. They never reach the Express routes commandAuth guards, so without
16
+ * this anyone who has a session id can attach to that session.
17
+ *
18
+ * With XENON_REQUIRE_COMMAND_AUTH on, an upgrade for one of those paths must
19
+ * carry the credentials a session command needs (the `x-xenon-*` pair or a
20
+ * Bearer JWT), from the session's owner or an override admin: the same
21
+ * decision, decideSessionAccess. Anything else is answered `404` on the raw
22
+ * socket, which is then closed, before Appium's handler sees it. A check that
23
+ * cannot run answers `503` the same way. Every other upgrade, including
24
+ * Xenon's own ticketed sockets (`/xenon/api/control/:udid/stream/h264`,
25
+ * `/logcat`) and socket.io, is left exactly as it was.
26
+ *
27
+ * Where Appium handles upgrades depends on the Node version (base-driver
28
+ * `server()`):
29
+ *
30
+ * - Node >= 22.21 / 24.9 (`http.Server#shouldUpgradeCallback` exists): an
31
+ * `upgrade` listener on the http.Server, added before any plugin's
32
+ * updateServer runs, so a plugin's own listener always comes after it.
33
+ * - Older Node: an Express middleware (`handleUpgrade`), ahead of the routes.
34
+ * Node hands an upgrade to Express only while the server has no `upgrade`
35
+ * listener at all.
36
+ *
37
+ * The check is asynchronous (a credential and an owner lookup), so being first
38
+ * in line is not enough: Appium's handler must not run until the answer is
39
+ * known. Two hooks cover both paths (registerSessionUpgradeGuard):
40
+ *
41
+ * - `guardUpgradeEvents` wraps the http.Server's `emit` for `upgrade`. A
42
+ * session upgrade is held back from every listener, Appium's included, until
43
+ * it is allowed, and then emitted to them unchanged. Wrapping the one call
44
+ * Node makes (`server.emit('upgrade', req, socket, head)`) covers listeners
45
+ * added before and after, in any order, so Appium's handler can never see a
46
+ * socket this has refused, and cannot be left out if a later Appium adds its
47
+ * listener later. Wrapping Appium's listener itself would miss one added
48
+ * after updateServer.
49
+ * - `middleware`, spliced in front of the whole Express stack, holds a session
50
+ * upgrade that arrives as a request the same way, and calls `next()` only
51
+ * when it is allowed.
52
+ *
53
+ * Refusals are identical whether the session exists or not: without an allow,
54
+ * no lookup's result reaches the answer. A socket the client drops mid-check
55
+ * is never handed on.
56
+ */
57
+ /** Appium's BiDi path (`BIDI_BASE_PATH`), under the base path. */
58
+ const BIDI = '/bidi';
59
+ /** base-driver's `DEFAULT_WS_PATHNAME_PREFIX`, where drivers add session sockets. */
60
+ const DRIVER_WS = '/ws';
61
+ /** The refusal: WebDriver's unknown session is a 404; no body, so nothing to tell apart. */
62
+ exports.UPGRADE_REFUSED_RESPONSE = 'HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n';
63
+ /** A credential check or owner lookup could not run. Retrying may work. */
64
+ exports.UPGRADE_UNAVAILABLE_RESPONSE = 'HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Length: 0\r\n\r\n';
65
+ function escapeRegExp(text) {
66
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
67
+ }
68
+ function decoded(segment) {
69
+ try {
70
+ return decodeURIComponent(segment);
71
+ }
72
+ catch (_a) {
73
+ return segment;
74
+ }
75
+ }
76
+ /**
77
+ * Every session id an upgrade to `url` could attach to, the way Appium would
78
+ * route it; empty when it attaches to no session.
79
+ *
80
+ * Appium matches its WebSocket paths with path-to-regexp against
81
+ * `appiumPathname(url)`: case-insensitively, with an optional trailing slash,
82
+ * and decoding a matched parameter. BiDi then reads the session id a second
83
+ * time, from the raw URL (`/bidi/([^/]+)$`, query string included). Both
84
+ * readings count, as do their decoded forms, and the caller must own every one
85
+ * of them, so no spelling of a path can name a session the check did not.
86
+ * An id no session has simply has no owner, and is refused.
87
+ *
88
+ * The umbrella BiDi socket (`<basePath>/bidi`) belongs to no session and is
89
+ * not guarded.
90
+ */
91
+ function sessionIdsOfUpgrade(url, basePath) {
92
+ const raw = url !== null && url !== void 0 ? url : '';
93
+ const pathname = (0, appiumBasePath_1.appiumPathname)(raw);
94
+ const base = escapeRegExp((0, appiumBasePath_1.normalizeBasePath)(basePath));
95
+ const ids = [];
96
+ const bidi = new RegExp(`^${base}${BIDI}(?:/([^/]+))?/?$`, 'i').exec(pathname);
97
+ if (bidi) {
98
+ if (bidi[1])
99
+ ids.push(bidi[1]);
100
+ const appiumReading = /\/bidi\/([^/]+)$/.exec(raw);
101
+ if (appiumReading)
102
+ ids.push(appiumReading[1]);
103
+ }
104
+ // Drivers add these without the base path; accept it too, in case one does.
105
+ const driver = new RegExp(`^(?:${base})?${DRIVER_WS}/session/([^/]+)(?:/|$)`, 'i').exec(pathname);
106
+ if (driver)
107
+ ids.push(driver[1]);
108
+ return [...new Set(ids.flatMap((id) => [id, decoded(id)]))];
109
+ }
110
+ /** The same test Appium applies before it looks at a path. */
111
+ function isWebSocketUpgrade(req) {
112
+ var _a;
113
+ const upgrade = (_a = req.headers) === null || _a === void 0 ? void 0 : _a.upgrade;
114
+ return typeof upgrade === 'string' && upgrade.toLowerCase() === 'websocket';
115
+ }
116
+ /** Answer on the raw socket, then close it. */
117
+ function answerAndClose(socket, response) {
118
+ if (socket.destroyed)
119
+ return;
120
+ if (!socket.writable) {
121
+ socket.destroy();
122
+ return;
123
+ }
124
+ socket.once('finish', () => socket.destroy());
125
+ socket.end(response);
126
+ }
127
+ function createSessionUpgradeGuard(basePath, deps) {
128
+ const sessionsOf = (req) => {
129
+ if (!deps.enabled() || deps.authDisabled())
130
+ return null;
131
+ if (!isWebSocketUpgrade(req))
132
+ return null;
133
+ const ids = sessionIdsOfUpgrade(req.url, basePath);
134
+ return ids.length > 0 ? ids : null;
135
+ };
136
+ const judge = (req, socket, sessionIds, proceed) => {
137
+ var _a, _b;
138
+ const where = `upgrade ${String((_b = (_a = req.originalUrl) !== null && _a !== void 0 ? _a : req.url) !== null && _b !== void 0 ? _b : '').split('?')[0]}`;
139
+ const sessions = sessionIds.join(', ');
140
+ // Node removes its own socket error handler before it emits 'upgrade',
141
+ // and nothing else is listening until a WebSocket server takes the
142
+ // socket. A client reset during the check would otherwise be an unhandled
143
+ // 'error' event, which ends the process.
144
+ const onError = () => socket.destroy();
145
+ socket.on('error', onError);
146
+ (0, commandAuth_1.decideSessionAccess)(req.headers, sessionIds, deps)
147
+ .then((decision) => {
148
+ if ('unavailable' in decision) {
149
+ deps.logger.error(`Session WebSocket check unavailable for ${where} (session ${sessions}): ` +
150
+ `${decision.stage} failed: ${(0, commandAuth_1.summarize)(decision.error)}`);
151
+ answerAndClose(socket, exports.UPGRADE_UNAVAILABLE_RESPONSE);
152
+ return;
153
+ }
154
+ if (!decision.allow) {
155
+ deps.logger.warn(`Session WebSocket refused: ${where} (session ${sessions}, caller ${decision.caller}): ` +
156
+ decision.reason);
157
+ answerAndClose(socket, exports.UPGRADE_REFUSED_RESPONSE);
158
+ return;
159
+ }
160
+ // The client left while we checked: there is nothing to hand on.
161
+ if (socket.destroyed)
162
+ return;
163
+ socket.removeListener('error', onError);
164
+ proceed();
165
+ })
166
+ .catch((error) => {
167
+ // Only reachable if logging, answering or the handler itself threw.
168
+ deps.logger.error(`Session WebSocket check failed for ${where}: ${(0, commandAuth_1.summarize)(error)}`);
169
+ socket.destroy();
170
+ });
171
+ };
172
+ return {
173
+ hold(req, socket, proceed) {
174
+ const sessionIds = sessionsOf(req);
175
+ if (!sessionIds)
176
+ return false;
177
+ judge(req, socket, sessionIds, proceed);
178
+ return true;
179
+ },
180
+ middleware(req, _res, next) {
181
+ const sessionIds = sessionsOf(req);
182
+ if (!sessionIds)
183
+ return next();
184
+ judge(req, req.socket, sessionIds, () => next());
185
+ },
186
+ };
187
+ }
188
+ /**
189
+ * Route every `upgrade` event through the guard before any listener sees it
190
+ * (see the module comment for why `emit`). Everything else, and every upgrade
191
+ * the guard does not take, is emitted at once and unchanged.
192
+ */
193
+ function guardUpgradeEvents(server, guard) {
194
+ const emit = server.emit;
195
+ server.emit = function emitThroughSessionGuard(event, ...args) {
196
+ if (event === 'upgrade') {
197
+ const [req, socket] = args;
198
+ if (guard.hold(req, socket, () => emit.call(this, event, ...args)))
199
+ return true;
200
+ }
201
+ return emit.call(this, event, ...args);
202
+ };
203
+ }