@xenon-device-management/xenon 2.1.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 (110) hide show
  1. package/lib/package.json +3 -3
  2. package/lib/public/assets/{AnnotationOverlay-C-Pw_hvw.js → AnnotationOverlay-XpewX5nn.js} +1 -1
  3. package/lib/public/assets/{ApiKeyGate-DmYQ0oSk.js → ApiKeyGate-BQDv3ZCC.js} +1 -1
  4. package/lib/public/assets/{BugReportButton-n8T1dFUp.js → BugReportButton-C7OmS61B.js} +1 -1
  5. package/lib/public/assets/{DeviceMosaicView-D-rGOBbJ.js → DeviceMosaicView-wl3l2Zlt.js} +1 -1
  6. package/lib/public/assets/{EmptyState-DExETevp.js → EmptyState-fPxujqqE.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-CCdFlZm6.js → FieldGroup-5bCutWrs.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-DiUSOSL-.js → FilterMenu-owsBiDT1.js} +1 -1
  9. package/lib/public/assets/{Menu-ZOfpH6gh.js → Menu-DFhqzPSt.js} +1 -1
  10. package/lib/public/assets/{Modal-CZNtoB-1.js → Modal-Bw-a-E-e.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-BKp6h8Jl.js → RecordingPage-WtByf5bC.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-Bf881FX4.js → RecordingsPage-FpWWFNgT.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-EUoyizvW.js → SegmentedControl-Jzn9c7o8.js} +1 -1
  14. package/lib/public/assets/{SettingCard-ClT4hf4_.js → SettingCard-BR7nOhpT.js} +1 -1
  15. package/lib/public/assets/{Table-ucR4bnmc.js → Table-B_Va3V05.js} +1 -1
  16. package/lib/public/assets/{activity-K-hdylwq.js → activity-DxRFWvkN.js} +1 -1
  17. package/lib/public/assets/{ai-settings-BLhdwI3c.js → ai-settings-CQf-9_V6.js} +1 -1
  18. package/lib/public/assets/{api-keys-BbiN7rBI.js → api-keys-SoE1Kd7N.js} +1 -1
  19. package/lib/public/assets/{apps-Dez1petz.js → apps-CgGXTlgn.js} +1 -1
  20. package/lib/public/assets/{arrow-left-UjmFHLAJ.js → arrow-left-uQ6WIwiC.js} +1 -1
  21. package/lib/public/assets/{arrow-right-BVyEigbu.js → arrow-right-C9RyfzXS.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-B-ZlBof3.js → arrow-up-right-DFHi_8yG.js} +1 -1
  23. package/lib/public/assets/{auth-shell-DAQjAgUv.js → auth-shell-Bii1t4qc.js} +2 -2
  24. package/lib/public/assets/{builds-page-CmIPa5JK.js → builds-page-Bnv8Z9hi.js} +1 -1
  25. package/lib/public/assets/{button-Dd-IhMC1.js → button-_Y78wXiP.js} +1 -1
  26. package/lib/public/assets/{calendar-CAIeJwa5.js → calendar-D4MqSOc2.js} +1 -1
  27. package/lib/public/assets/{check-C4Jrjg8f.js → check-uGHCXe-L.js} +1 -1
  28. package/lib/public/assets/{chevron-left-BGzlf-4i.js → chevron-left-Dy1OqpzB.js} +1 -1
  29. package/lib/public/assets/{chevron-right-_wYqDjDC.js → chevron-right-BUsldEli.js} +1 -1
  30. package/lib/public/assets/{circle-check-Ckn00yMB.js → circle-check-D3Qq0tKp.js} +1 -1
  31. package/lib/public/assets/{circle-x-pdVkomLa.js → circle-x-B4IOD1DE.js} +1 -1
  32. package/lib/public/assets/{clock-B8vmwkHj.js → clock-BfYfzvSg.js} +1 -1
  33. package/lib/public/assets/{copy-BAqnFI-F.js → copy-DZwYE5Va.js} +1 -1
  34. package/lib/public/assets/{device-explorer-CYq_4F_N.js → device-explorer-CRG4Utt5.js} +1 -1
  35. package/lib/public/assets/{download-B6UEAp_1.js → download-CCDYo_TN.js} +1 -1
  36. package/lib/public/assets/{forgot-password-CLqNvmrN.js → forgot-password-DuLCmVFb.js} +1 -1
  37. package/lib/public/assets/{funnel-Da90BBbT.js → funnel-CtKNPqzb.js} +1 -1
  38. package/lib/public/assets/{index-BuIIcmGp.js → index-3uxl3o7K.js} +43 -43
  39. package/lib/public/assets/{index-MX7IzqyW.js → index-Zy1Op5C1.js} +1 -1
  40. package/lib/public/assets/{input-Bq9aY-We.js → input-CzTWvUXZ.js} +1 -1
  41. package/lib/public/assets/{keycodes-CIhXIJyP.js → keycodes-CI-nYDFM.js} +1 -1
  42. package/lib/public/assets/{layers-bXjpswQI.js → layers-YZZGFyTX.js} +1 -1
  43. package/lib/public/assets/{lock-pJ72y5Fy.js → lock-L8tzWgDo.js} +1 -1
  44. package/lib/public/assets/{login-UOcHALV2.js → login-DBbX-fu8.js} +1 -1
  45. package/lib/public/assets/{maintenance-settings-XnJgp5yb.js → maintenance-settings-AJwkOIsE.js} +1 -1
  46. package/lib/public/assets/{monitor-QkAhI1Jx.js → monitor-Di_66F2m.js} +1 -1
  47. package/lib/public/assets/{mouse-pointer-2-B42Da0m4.js → mouse-pointer-2-Cnab2zdg.js} +1 -1
  48. package/lib/public/assets/{network-CKVqcyp6.js → network-DiSumY-1.js} +1 -1
  49. package/lib/public/assets/{overview-BORGkN3N.js → overview-CwE4gXl5.js} +1 -1
  50. package/lib/public/assets/{page-header-CKWFOjOI.js → page-header-CfR4Sm9V.js} +1 -1
  51. package/lib/public/assets/{plus-BpFKVdC8.js → plus-DOS8P9CQ.js} +1 -1
  52. package/lib/public/assets/{profile-page-CcbKBicY.js → profile-page-BYpHwQIH.js} +1 -1
  53. package/lib/public/assets/{reset-password-BA0W9bxl.js → reset-password-BRVJU_qV.js} +1 -1
  54. package/lib/public/assets/{runbook-page-Cp7ZBq3s.js → runbook-page-Bi3JvAFZ.js} +1 -1
  55. package/lib/public/assets/{select-DIZWIoVR.js → select-Cqx7pEou.js} +1 -1
  56. package/lib/public/assets/{selector-detail-page-C_4YuvAp.js → selector-detail-page-C0DsncKj.js} +1 -1
  57. package/lib/public/assets/{selector-health-CXjD_Xth.js → selector-health-CAhpdilF.js} +1 -1
  58. package/lib/public/assets/{selector-health-page-CTqIHeoG.js → selector-health-page-CrCVeW2m.js} +1 -1
  59. package/lib/public/assets/{session-detail-page-B0KYkk8z.js → session-detail-page-F_hwYqEt.js} +1 -1
  60. package/lib/public/assets/{settings-BcRBKUXd.js → settings-C-s5pypL.js} +1 -1
  61. package/lib/public/assets/{status-pill-outline-Dh2PXSf_.js → status-pill-outline-DY5uzv6A.js} +1 -1
  62. package/lib/public/assets/{tablet-_sXqZztt.js → tablet-Dli7gwyg.js} +1 -1
  63. package/lib/public/assets/{teams-DGHT8MKm.js → teams-ui4IhHNC.js} +1 -1
  64. package/lib/public/assets/{trash-2-BV88cV3Z.js → trash-2-BV4hSmXJ.js} +1 -1
  65. package/lib/public/assets/{upload-CIDH9wkj.js → upload-DkGy_J-L.js} +1 -1
  66. package/lib/public/assets/{use-password-reset-mode-DYGuSNwl.js → use-password-reset-mode-BFtMqQOh.js} +1 -1
  67. package/lib/public/assets/{users-BUE7MoTq.js → users-BU0nXIVe.js} +1 -1
  68. package/lib/public/assets/{users-7yviqflc.js → users-Nb_rzRmF.js} +1 -1
  69. package/lib/public/assets/{video-off-BTRxtlGo.js → video-off-CUNzD2kH.js} +1 -1
  70. package/lib/public/assets/{webhook-settings-DMKD9Pgm.js → webhook-settings-Ca3UFXZe.js} +1 -1
  71. package/lib/public/assets/{zap-CWE94qcK.js → zap-B2bH2vKX.js} +1 -1
  72. package/lib/public/index.html +1 -1
  73. package/lib/src/app/routers/control.js +5 -142
  74. package/lib/src/app/routers/nodePhoneControl.js +301 -0
  75. package/lib/src/app/ws/h264StreamWs.js +30 -4
  76. package/lib/src/app/ws/logcatWs.js +36 -7
  77. package/lib/src/app/ws/nodeSocketRelay.js +167 -0
  78. package/lib/src/data-service/device-store.js +11 -0
  79. package/lib/src/data-service/deviceClaims.js +18 -0
  80. package/lib/src/data-service/prisma-store.js +8 -0
  81. package/lib/src/device-managers/AndroidDeviceManager.js +27 -27
  82. package/lib/src/device-managers/android/AndroidH264StreamService.js +10 -2
  83. package/lib/src/device-managers/android/AndroidStreamService.js +3 -1
  84. package/lib/src/device-managers/ios/IOSStreamService.js +4 -2
  85. package/lib/src/middleware/deviceAccessGuard.js +4 -1
  86. package/lib/src/services/ServerManager.js +13 -0
  87. package/lib/src/services/device-access/ticketActorAccess.js +3 -1
  88. package/lib/src/services/lease/LeaseOrphanSweeper.js +4 -2
  89. package/lib/test/helpers/own-node-id.js +29 -0
  90. package/lib/test/setup/scratch-default-database.js +32 -0
  91. package/lib/test/unit/AndroidDeviceManager.spec.js +48 -0
  92. package/lib/test/unit/android-h264-service.spec.js +18 -0
  93. package/lib/test/unit/clipboard-route.spec.js +4 -0
  94. package/lib/test/unit/control-forward-decision.spec.js +17 -17
  95. package/lib/test/unit/device-access-guard.spec.js +38 -0
  96. package/lib/test/unit/hub-node-control-forward.spec.js +318 -1
  97. package/lib/test/unit/hub-node-race.spec.js +25 -0
  98. package/lib/test/unit/install-repository-app-team.spec.js +4 -0
  99. package/lib/test/unit/lease/lease-sweeper-expiry.spec.js +15 -0
  100. package/lib/test/unit/logcat-ws.spec.js +8 -0
  101. package/lib/test/unit/mark-node-busy-loki.spec.js +53 -0
  102. package/lib/test/unit/node-phone-control.spec.js +131 -0
  103. package/lib/test/unit/node-socket-relay.spec.js +357 -0
  104. package/lib/test/unit/stream-leave-route.spec.js +4 -0
  105. package/lib/test/unit/stream-stop-ownership.spec.js +4 -0
  106. package/lib/test/unit/stream-stop-recording.spec.js +4 -0
  107. package/lib/test/unit/stream-ticket-identity.spec.js +11 -1
  108. package/lib/test/unit/stream-watchdog-timers.spec.js +46 -0
  109. package/lib/tsconfig.tsbuildinfo +1 -1
  110. package/package.json +3 -3
@@ -1,4 +1,4 @@
1
- import{c as w,b as k,r as a,j as e,$ as C}from"./index-BuIIcmGp.js";import{l as M,d as R,c as I,a as A,u as D}from"./users-BUE7MoTq.js";import{f as U}from"./time-B8FIgwPE.js";import{P}from"./page-header-CKWFOjOI.js";import{B as y}from"./button-Dd-IhMC1.js";import{I as N}from"./input-Bq9aY-We.js";import{S as g}from"./select-DIZWIoVR.js";import{P as B}from"./plus-BpFKVdC8.js";import{T as L}from"./trash-2-BV88cV3Z.js";import"./utils-CDN07tui.js";/**
1
+ import{c as w,b as k,r as a,j as e,$ as C}from"./index-3uxl3o7K.js";import{l as M,d as R,c as I,a as A,u as D}from"./users-BU0nXIVe.js";import{f as U}from"./time-B8FIgwPE.js";import{P}from"./page-header-CfR4Sm9V.js";import{B as y}from"./button-_Y78wXiP.js";import{I as N}from"./input-CzTWvUXZ.js";import{S as g}from"./select-Cqx7pEou.js";import{P as B}from"./plus-DOS8P9CQ.js";import{T as L}from"./trash-2-BV4hSmXJ.js";import"./utils-CDN07tui.js";/**
2
2
  * @license lucide-react v0.555.0 - ISC
3
3
  *
4
4
  * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
1
- import{c as o}from"./index-BuIIcmGp.js";/**
1
+ import{c as o}from"./index-3uxl3o7K.js";/**
2
2
  * @license lucide-react v0.555.0 - ISC
3
3
  *
4
4
  * This source code is licensed under the ISC license.
@@ -1 +1 @@
1
- import{t as P,r,X as c,j as e,J as u,A as b,C as U,K as D}from"./index-BuIIcmGp.js";/* empty css */import{F as S}from"./FieldGroup-CCdFlZm6.js";import{P as O}from"./page-header-CKWFOjOI.js";import{T as V}from"./trash-2-BV88cV3Z.js";import{P as x}from"./plus-BpFKVdC8.js";import{C as B}from"./circle-x-pdVkomLa.js";import{C as H}from"./circle-check-Ckn00yMB.js";import{Z as I}from"./zap-CWE94qcK.js";import{A as J}from"./activity-K-hdylwq.js";const C=[{id:"device_offline",label:"Device offline",icon:U,tone:"red"},{id:"device_new",label:"New device",icon:x,tone:"green"},{id:"session_failed",label:"Session failed",icon:B,tone:"amber"},{id:"selector_health_digest",label:"Selector health digest",icon:D,tone:"green"}],X=["udid","host","name","sessionId","failureReason","eventType","platform"],ae=()=>{const{toast:o}=P(),[g,T]=r.useState([]),[A,w]=r.useState(!0),[l,j]=r.useState(""),[d,k]=r.useState(["device_offline","session_failed"]),[h,f]=r.useState(!1),[v,N]=r.useState(!1),[n,_]=r.useState(!1),[y,m]=r.useState("");r.useEffect(()=>{p()},[]);const p=async()=>{w(!0);try{const s=await c.getWebhookConfigs();T(s||[])}catch(s){console.error("Failed to load webhook configs",s),o("Failed to load webhooks.","error")}finally{w(!1)}},z=async()=>{if(!l.trim()){o("Webhook URL is required.","error");return}if(d.length===0){o("Select at least one trigger event.","error");return}f(!0);try{await c.addWebhookConfig(l.trim(),d,"slack",y||void 0),j(""),m(""),_(!1),k(["device_offline","session_failed"]),await p(),o("Webhook saved.","success")}catch(s){console.error("Failed to add webhook",s),o(`Failed to add webhook: ${s.message||"unknown error"}`,"error")}finally{f(!1)}},E=async(s,t)=>{if(window.confirm(`Remove webhook for "${t}"?`))try{await c.deleteWebhookConfig(s),await p(),o("Webhook removed.","success")}catch(a){console.error("Failed to delete webhook",a),o(`Failed to delete: ${a.message||"unknown error"}`,"error")}},L=async()=>{if(l.trim()){N(!0);try{await c.testWebhook(l.trim(),"slack"),o("Test payload delivered.","success")}catch(s){o(`Test failed: ${s.message||"check the URL"}`,"error")}finally{N(!1)}}},$=s=>{k(t=>t.includes(s)?t.filter(a=>a!==s):[...t,s])},R=s=>{m(t=>t+`{{${s}}} `)};return e.jsxs("div",{className:"settings-container",children:[e.jsx(O,{icon:u,title:"Notifications",subtitle:"Configure Slack or generic webhooks to receive alerts for critical infrastructure events."}),e.jsxs("div",{className:"settings-content",children:[A?e.jsxs("div",{className:"settings-loading",style:{height:200},children:[e.jsx(b,{className:"animate-spin",size:28}),e.jsx("span",{children:"Loading webhooks…"})]}):g.length===0?e.jsxs("div",{className:"empty-state",children:[e.jsx("div",{className:"empty-state__icon",children:e.jsx(u,{size:28})}),e.jsx("h3",{className:"empty-state__title",children:"No webhooks configured"}),e.jsx("p",{className:"empty-state__copy",children:"Wire up a Slack incoming webhook or any HTTPS endpoint to receive alerts when devices go offline, new devices register, or sessions fail. Configure your first webhook below."})]}):e.jsx("div",{className:"webhook-list-grid",children:g.map(s=>{const t=(()=>{try{return JSON.parse(s.events)}catch{return[]}})();return e.jsxs("div",{className:"webhook-row-card",children:[e.jsxs("div",{className:"webhook-row-card__header",children:[e.jsx("span",{className:`pill-chip ${s.payloadTemplate?"pill-chip--admin":"pill-chip--scope"}`,children:s.payloadTemplate?"CUSTOM":"SLACK"}),e.jsx("span",{className:"webhook-row-card__url",title:s.url,children:s.url}),e.jsxs("button",{type:"button",className:"row-action row-action--danger",onClick:()=>E(s.id,s.url),"aria-label":`Remove webhook for ${s.url}`,children:[e.jsx(V,{size:13}),e.jsx("span",{children:"Remove"})]})]}),e.jsx("div",{className:"webhook-row-card__events",children:t.length===0?e.jsx("span",{className:"webhook-row-card__no-events",children:"No triggers"}):t.map(a=>{const i=C.find(F=>F.id===a),W=(i==null?void 0:i.icon)??u;return e.jsxs("span",{className:`event-pill event-pill--${(i==null?void 0:i.tone)||"gray"}`,children:[e.jsx(W,{size:11}),(i==null?void 0:i.label)||a]},a)})})]},s.id)})}),e.jsxs("div",{className:"add-webhook-card",children:[e.jsxs("div",{className:"add-webhook-card__header",children:[e.jsx(x,{size:14}),e.jsx("span",{children:"Add a new webhook"})]}),e.jsxs("div",{className:"add-webhook-card__body",children:[e.jsx(S,{label:"Webhook URL",description:"The endpoint we POST webhook events to.",htmlFor:"webhook-url",children:e.jsx("div",{className:"setting-input-wrapper",children:e.jsx("input",{id:"webhook-url",type:"url",placeholder:"https://hooks.slack.com/services/...",value:l,onChange:s=>j(s.target.value)})})}),e.jsx(S,{label:"Trigger events",children:e.jsx("div",{className:"event-toggle-grid",children:C.map(s=>{const t=d.includes(s.id),a=s.icon;return e.jsxs("button",{type:"button",role:"checkbox","aria-checked":t,className:`event-toggle event-toggle--${s.tone} ${t?"is-selected":""}`,onClick:()=>$(s.id),children:[e.jsx(a,{size:14,className:"event-toggle__lead"}),e.jsx("span",{children:s.label}),t&&e.jsx(H,{size:14,className:"event-toggle__check"})]},s.id)})})}),e.jsxs("div",{className:"template-section",children:[e.jsxs("button",{type:"button",className:"template-section__toggle",onClick:()=>_(!n),"aria-expanded":n,children:[e.jsx(I,{size:14,className:n?"tone-amber":"tone-dim"}),e.jsx("span",{children:"Use custom payload (optional)"}),e.jsx("span",{className:"template-section__indicator",children:n?"−":"+"})]}),n&&e.jsxs("div",{className:"template-editor",children:[e.jsx("p",{className:"template-editor__hint",children:"Define a JSON or text template. Click a variable below to insert it."}),e.jsx("div",{className:"template-editor__chips",children:X.map(s=>e.jsx("button",{type:"button",className:"template-editor__chip",onClick:()=>R(s),children:`{{${s}}}`},s))}),e.jsx("textarea",{className:"template-editor__textarea",placeholder:'Example: { "text": "Alert: Device {{udid}} is offline!" }',value:y,onChange:s=>m(s.target.value),rows:4})]})]})]}),e.jsxs("div",{className:"add-webhook-card__footer",children:[e.jsxs("button",{type:"button",className:"page-header-action page-header-action--ghost",onClick:L,disabled:!l.trim()||v,children:[v?e.jsx(b,{size:14,className:"animate-spin"}):e.jsx(J,{size:14}),e.jsx("span",{children:"Test payload"})]}),e.jsxs("button",{type:"button",className:"page-header-action",onClick:z,disabled:h||!l.trim(),children:[h?e.jsx(b,{size:14,className:"animate-spin"}):e.jsx(x,{size:14}),e.jsx("span",{children:h?"Saving…":"Save webhook"})]})]})]})]})]})};export{ae as WebhookSettings};
1
+ import{t as P,r,X as c,j as e,J as u,A as b,C as U,K as D}from"./index-3uxl3o7K.js";/* empty css */import{F as S}from"./FieldGroup-5bCutWrs.js";import{P as O}from"./page-header-CfR4Sm9V.js";import{T as V}from"./trash-2-BV4hSmXJ.js";import{P as x}from"./plus-DOS8P9CQ.js";import{C as B}from"./circle-x-B4IOD1DE.js";import{C as H}from"./circle-check-D3Qq0tKp.js";import{Z as I}from"./zap-B2bH2vKX.js";import{A as J}from"./activity-DxRFWvkN.js";const C=[{id:"device_offline",label:"Device offline",icon:U,tone:"red"},{id:"device_new",label:"New device",icon:x,tone:"green"},{id:"session_failed",label:"Session failed",icon:B,tone:"amber"},{id:"selector_health_digest",label:"Selector health digest",icon:D,tone:"green"}],X=["udid","host","name","sessionId","failureReason","eventType","platform"],ae=()=>{const{toast:o}=P(),[g,T]=r.useState([]),[A,w]=r.useState(!0),[l,j]=r.useState(""),[d,k]=r.useState(["device_offline","session_failed"]),[h,f]=r.useState(!1),[v,N]=r.useState(!1),[n,_]=r.useState(!1),[y,m]=r.useState("");r.useEffect(()=>{p()},[]);const p=async()=>{w(!0);try{const s=await c.getWebhookConfigs();T(s||[])}catch(s){console.error("Failed to load webhook configs",s),o("Failed to load webhooks.","error")}finally{w(!1)}},z=async()=>{if(!l.trim()){o("Webhook URL is required.","error");return}if(d.length===0){o("Select at least one trigger event.","error");return}f(!0);try{await c.addWebhookConfig(l.trim(),d,"slack",y||void 0),j(""),m(""),_(!1),k(["device_offline","session_failed"]),await p(),o("Webhook saved.","success")}catch(s){console.error("Failed to add webhook",s),o(`Failed to add webhook: ${s.message||"unknown error"}`,"error")}finally{f(!1)}},E=async(s,t)=>{if(window.confirm(`Remove webhook for "${t}"?`))try{await c.deleteWebhookConfig(s),await p(),o("Webhook removed.","success")}catch(a){console.error("Failed to delete webhook",a),o(`Failed to delete: ${a.message||"unknown error"}`,"error")}},L=async()=>{if(l.trim()){N(!0);try{await c.testWebhook(l.trim(),"slack"),o("Test payload delivered.","success")}catch(s){o(`Test failed: ${s.message||"check the URL"}`,"error")}finally{N(!1)}}},$=s=>{k(t=>t.includes(s)?t.filter(a=>a!==s):[...t,s])},R=s=>{m(t=>t+`{{${s}}} `)};return e.jsxs("div",{className:"settings-container",children:[e.jsx(O,{icon:u,title:"Notifications",subtitle:"Configure Slack or generic webhooks to receive alerts for critical infrastructure events."}),e.jsxs("div",{className:"settings-content",children:[A?e.jsxs("div",{className:"settings-loading",style:{height:200},children:[e.jsx(b,{className:"animate-spin",size:28}),e.jsx("span",{children:"Loading webhooks…"})]}):g.length===0?e.jsxs("div",{className:"empty-state",children:[e.jsx("div",{className:"empty-state__icon",children:e.jsx(u,{size:28})}),e.jsx("h3",{className:"empty-state__title",children:"No webhooks configured"}),e.jsx("p",{className:"empty-state__copy",children:"Wire up a Slack incoming webhook or any HTTPS endpoint to receive alerts when devices go offline, new devices register, or sessions fail. Configure your first webhook below."})]}):e.jsx("div",{className:"webhook-list-grid",children:g.map(s=>{const t=(()=>{try{return JSON.parse(s.events)}catch{return[]}})();return e.jsxs("div",{className:"webhook-row-card",children:[e.jsxs("div",{className:"webhook-row-card__header",children:[e.jsx("span",{className:`pill-chip ${s.payloadTemplate?"pill-chip--admin":"pill-chip--scope"}`,children:s.payloadTemplate?"CUSTOM":"SLACK"}),e.jsx("span",{className:"webhook-row-card__url",title:s.url,children:s.url}),e.jsxs("button",{type:"button",className:"row-action row-action--danger",onClick:()=>E(s.id,s.url),"aria-label":`Remove webhook for ${s.url}`,children:[e.jsx(V,{size:13}),e.jsx("span",{children:"Remove"})]})]}),e.jsx("div",{className:"webhook-row-card__events",children:t.length===0?e.jsx("span",{className:"webhook-row-card__no-events",children:"No triggers"}):t.map(a=>{const i=C.find(F=>F.id===a),W=(i==null?void 0:i.icon)??u;return e.jsxs("span",{className:`event-pill event-pill--${(i==null?void 0:i.tone)||"gray"}`,children:[e.jsx(W,{size:11}),(i==null?void 0:i.label)||a]},a)})})]},s.id)})}),e.jsxs("div",{className:"add-webhook-card",children:[e.jsxs("div",{className:"add-webhook-card__header",children:[e.jsx(x,{size:14}),e.jsx("span",{children:"Add a new webhook"})]}),e.jsxs("div",{className:"add-webhook-card__body",children:[e.jsx(S,{label:"Webhook URL",description:"The endpoint we POST webhook events to.",htmlFor:"webhook-url",children:e.jsx("div",{className:"setting-input-wrapper",children:e.jsx("input",{id:"webhook-url",type:"url",placeholder:"https://hooks.slack.com/services/...",value:l,onChange:s=>j(s.target.value)})})}),e.jsx(S,{label:"Trigger events",children:e.jsx("div",{className:"event-toggle-grid",children:C.map(s=>{const t=d.includes(s.id),a=s.icon;return e.jsxs("button",{type:"button",role:"checkbox","aria-checked":t,className:`event-toggle event-toggle--${s.tone} ${t?"is-selected":""}`,onClick:()=>$(s.id),children:[e.jsx(a,{size:14,className:"event-toggle__lead"}),e.jsx("span",{children:s.label}),t&&e.jsx(H,{size:14,className:"event-toggle__check"})]},s.id)})})}),e.jsxs("div",{className:"template-section",children:[e.jsxs("button",{type:"button",className:"template-section__toggle",onClick:()=>_(!n),"aria-expanded":n,children:[e.jsx(I,{size:14,className:n?"tone-amber":"tone-dim"}),e.jsx("span",{children:"Use custom payload (optional)"}),e.jsx("span",{className:"template-section__indicator",children:n?"−":"+"})]}),n&&e.jsxs("div",{className:"template-editor",children:[e.jsx("p",{className:"template-editor__hint",children:"Define a JSON or text template. Click a variable below to insert it."}),e.jsx("div",{className:"template-editor__chips",children:X.map(s=>e.jsx("button",{type:"button",className:"template-editor__chip",onClick:()=>R(s),children:`{{${s}}}`},s))}),e.jsx("textarea",{className:"template-editor__textarea",placeholder:'Example: { "text": "Alert: Device {{udid}} is offline!" }',value:y,onChange:s=>m(s.target.value),rows:4})]})]})]}),e.jsxs("div",{className:"add-webhook-card__footer",children:[e.jsxs("button",{type:"button",className:"page-header-action page-header-action--ghost",onClick:L,disabled:!l.trim()||v,children:[v?e.jsx(b,{size:14,className:"animate-spin"}):e.jsx(J,{size:14}),e.jsx("span",{children:"Test payload"})]}),e.jsxs("button",{type:"button",className:"page-header-action",onClick:z,disabled:h||!l.trim(),children:[h?e.jsx(b,{size:14,className:"animate-spin"}):e.jsx(x,{size:14}),e.jsx("span",{children:h?"Saving…":"Save webhook"})]})]})]})]})]})};export{ae as WebhookSettings};
@@ -1,4 +1,4 @@
1
- import{c as a}from"./index-BuIIcmGp.js";/**
1
+ import{c as a}from"./index-3uxl3o7K.js";/**
2
2
  * @license lucide-react v0.555.0 - ISC
3
3
  *
4
4
  * This source code is licensed under the ISC license.
@@ -36,7 +36,7 @@
36
36
  Learn how to configure a non-root public URL by running `npm run build`.
37
37
  -->
38
38
  <title>Xenon</title>
39
- <script type="module" crossorigin src="/xenon/assets/index-BuIIcmGp.js"></script>
39
+ <script type="module" crossorigin src="/xenon/assets/index-3uxl3o7K.js"></script>
40
40
  <link rel="stylesheet" crossorigin href="/xenon/assets/index-hymrhlEZ.css">
41
41
  </head>
42
42
 
@@ -68,7 +68,6 @@ const InspectorService_1 = require("../../services/InspectorService");
68
68
  const StreamTicketService_1 = require("../../services/token/StreamTicketService");
69
69
  const appVisibility_1 = require("../../services/device-access/appVisibility");
70
70
  const PluginContext_1 = require("../../PluginContext");
71
- const localDeviceHosts_1 = require("../../device-managers/localDeviceHosts");
72
71
  const streamType_1 = require("./streamType");
73
72
  const iosStreamPort_1 = require("./iosStreamPort");
74
73
  const androidH264Config_1 = require("./androidH264Config");
@@ -81,10 +80,10 @@ const deviceTeamGuard_1 = require("../../middleware/deviceTeamGuard");
81
80
  const controlDevice_1 = require("../../middleware/controlDevice");
82
81
  const manualLock_1 = require("../../services/recording/manualLock");
83
82
  const streamStartConflict_1 = require("./streamStartConflict");
83
+ const nodePhoneControl_1 = require("./nodePhoneControl");
84
84
  const streamLeave_1 = require("./streamLeave");
85
85
  const SessionOwnerResolver_1 = require("../../services/device-access/SessionOwnerResolver");
86
86
  const actor_1 = require("../../services/device-access/actor");
87
- const hubSessionToken_1 = require("../../gateway/hubSessionToken");
88
87
  const deviceAccessPolicy_1 = require("../../services/device-access/deviceAccessPolicy");
89
88
  const router = (0, express_1.Router)();
90
89
  /** True when an in-memory iOS/Android (MJPEG or H.264) stream session exists. */
@@ -112,76 +111,10 @@ router.use((0, deviceTeamGuard_1.deviceTeamGuard)());
112
111
  // another user's Appium session. Mounted here so every current and future
113
112
  // mutation is covered without per-handler opt-in.
114
113
  router.use((0, deviceAccessGuard_1.deviceAccessGuard)());
115
- // Cloud metadata endpoints — never proxy to these regardless of caller.
116
- const FORBIDDEN_PROXY_HOSTS = new Set([
117
- '169.254.169.254', // AWS/Azure/GCP IMDS
118
- 'metadata.google.internal',
119
- 'metadata.goog',
120
- '100.100.100.200', // Alibaba ECS metadata
121
- 'fd00:ec2::254', // AWS IMDSv6
122
- ]);
123
- /**
124
- * Build a safe proxy URL from a device's reported host. Strips any path,
125
- * query, or fragment the host string carried, blocks cloud-metadata targets,
126
- * and refuses non-http(s) schemes. Returns null if the host is unsafe.
127
- */
128
- function buildProxyUrl(deviceHost, req) {
129
- let parsed;
130
- try {
131
- parsed = new URL(deviceHost);
132
- }
133
- catch (_a) {
134
- return null;
135
- }
136
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
137
- return null;
138
- if (FORBIDDEN_PROXY_HOSTS.has(parsed.hostname))
139
- return null;
140
- // Only keep scheme + host + port; discard any attacker-baked path/query/fragment.
141
- const origin = `${parsed.protocol}//${parsed.host}`;
142
- // Treat req.originalUrl as a path — reparse to strip any control characters.
143
- const forwardPath = req.originalUrl.startsWith('/') ? req.originalUrl : `/${req.originalUrl}`;
144
- return `${origin}${forwardPath}`;
145
- }
146
- /**
147
- * Forward a control action on another server's phone to that server, signed
148
- * for the caller: the hub's control token (hubSessionToken.ts) names the
149
- * user, whether they are an admin, the phone and its node. This hub already
150
- * checked them (the guards above); a node with auth enabled accepts the token
151
- * for /control on that phone and runs its own ownership guard with that user.
152
- * Sent with no credential at all, it was refused by every such node. A cloud
153
- * provider never gets a hub token.
154
- */
155
- function forwardControl(device, req, target) {
156
- return __awaiter(this, void 0, void 0, function* () {
157
- const actor = (0, actor_1.resolveActor)(req);
158
- const token = !device.cloud && actor.userId
159
- ? yield typedi_1.Container.get(hubSessionToken_1.HubSessionTokenIssuer).controlTokenFor({
160
- userId: actor.userId,
161
- isAdmin: actor.isAdmin,
162
- udid: device.udid,
163
- host: device.host,
164
- })
165
- : null;
166
- // Sent once: a retry after a slow node's timeout or 5xx could land the
167
- // same tap twice.
168
- const config = Object.assign(Object.assign({}, (token ? { headers: { [hubSessionToken_1.HUB_TOKEN_HEADER]: token } } : {})), { retry: false });
169
- yield InternalHttpClient_1.InternalHttpClient.post(target, req.body, config);
170
- });
171
- }
172
- /**
173
- * A phone another server drives, whose /control actions are forwarded there.
174
- * Decided by the phone's own row (its nodeId, else its exact host against this
175
- * server's own hosts), never by the Host header the caller used: a substring
176
- * match on that header forwarded a lab's own phone back to itself, with no
177
- * login, whenever the lab was reached as `localhost`.
178
- */
179
- function isOtherServersPhone(device) {
180
- if (!device.host)
181
- return false;
182
- const context = typedi_1.Container.get(PluginContext_1.PluginContext);
183
- return !(0, localDeviceHosts_1.isOwnDevice)((0, localDeviceHosts_1.localDeviceHosts)(context.pluginArgs, context.port), context.nodeId, device);
184
- }
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)());
185
118
  function getDeviceInfo(udid) {
186
119
  return __awaiter(this, void 0, void 0, function* () {
187
120
  const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
@@ -213,26 +146,12 @@ function getDeviceManagerForPlatform(platform) {
213
146
  });
214
147
  }
215
148
  router.post('/:udid/tap', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
216
- var _a, _b;
217
149
  const { udid } = req.params;
218
150
  const { x, y } = req.body;
219
151
  const device = yield getDeviceInfo(udid);
220
152
  if (!device)
221
153
  return res.status(404).send('Device not found');
222
154
  const manager = yield getDeviceManagerForPlatform(device.platform);
223
- if (isOtherServersPhone(device)) {
224
- const target = buildProxyUrl(device.host, req);
225
- if (!target)
226
- return res.status(400).send({ error: 'Unsafe device host' });
227
- logger_1.default.info(`Proxying tap for ${udid} to ${target}`);
228
- try {
229
- yield forwardControl(device, req, target);
230
- return res.status(200).send({ success: true });
231
- }
232
- catch (err) {
233
- 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);
234
- }
235
- }
236
155
  if (manager && manager.tap) {
237
156
  try {
238
157
  yield manager.tap(udid, x, y);
@@ -257,26 +176,12 @@ router.post('/:udid/tap', (req, res) => __awaiter(void 0, void 0, void 0, functi
257
176
  res.status(400).send('Manager not found or tap not supported');
258
177
  }));
259
178
  router.post('/:udid/swipe', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
260
- var _a, _b;
261
179
  const { udid } = req.params;
262
180
  const { x, y, endX, endY, duration = 1000 } = req.body;
263
181
  const device = yield getDeviceInfo(udid);
264
182
  if (!device)
265
183
  return res.status(404).send('Device not found');
266
184
  const manager = yield getDeviceManagerForPlatform(device.platform);
267
- if (isOtherServersPhone(device)) {
268
- const target = buildProxyUrl(device.host, req);
269
- if (!target)
270
- return res.status(400).send({ error: 'Unsafe device host' });
271
- logger_1.default.info(`Proxying swipe for ${udid} to ${target}`);
272
- try {
273
- yield forwardControl(device, req, target);
274
- return res.status(200).send({ success: true });
275
- }
276
- catch (err) {
277
- 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);
278
- }
279
- }
280
185
  if (manager && manager.swipe) {
281
186
  try {
282
187
  yield manager.swipe(udid, x, y, endX, endY, duration);
@@ -290,26 +195,12 @@ router.post('/:udid/swipe', (req, res) => __awaiter(void 0, void 0, void 0, func
290
195
  res.status(400).send('Manager not found or swipe not supported');
291
196
  }));
292
197
  router.post('/:udid/text', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
293
- var _a, _b;
294
198
  const { udid } = req.params;
295
199
  const { text } = req.body;
296
200
  const device = yield getDeviceInfo(udid);
297
201
  if (!device)
298
202
  return res.status(404).send('Device not found');
299
203
  const manager = yield getDeviceManagerForPlatform(device.platform);
300
- if (isOtherServersPhone(device)) {
301
- const target = buildProxyUrl(device.host, req);
302
- if (!target)
303
- return res.status(400).send({ error: 'Unsafe device host' });
304
- logger_1.default.info(`Proxying typeText for ${udid} to ${target}`);
305
- try {
306
- yield forwardControl(device, req, target);
307
- return res.status(200).send({ success: true });
308
- }
309
- catch (err) {
310
- 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);
311
- }
312
- }
313
204
  if (manager && manager.typeText) {
314
205
  try {
315
206
  yield manager.typeText(udid, text);
@@ -323,26 +214,12 @@ router.post('/:udid/text', (req, res) => __awaiter(void 0, void 0, void 0, funct
323
214
  res.status(400).send('Manager not found or typeText not supported');
324
215
  }));
325
216
  router.post('/:udid/keyevent', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
326
- var _a, _b;
327
217
  const { udid } = req.params;
328
218
  const { keyCode } = req.body;
329
219
  const device = yield getDeviceInfo(udid);
330
220
  if (!device)
331
221
  return res.status(404).send('Device not found');
332
222
  const manager = yield getDeviceManagerForPlatform(device.platform);
333
- if (isOtherServersPhone(device)) {
334
- const target = buildProxyUrl(device.host, req);
335
- if (!target)
336
- return res.status(400).send({ error: 'Unsafe device host' });
337
- logger_1.default.info(`Proxying keyevent for ${udid} to ${target}`);
338
- try {
339
- yield forwardControl(device, req, target);
340
- return res.status(200).send({ success: true });
341
- }
342
- catch (err) {
343
- 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);
344
- }
345
- }
346
223
  if (manager && manager.pressKey) {
347
224
  try {
348
225
  yield manager.pressKey(udid, keyCode);
@@ -426,26 +303,12 @@ router.post('/:udid/clipboard', (req, res) => __awaiter(void 0, void 0, void 0,
426
303
  res.status(400).send('Manager not found or setClipboard not supported');
427
304
  }));
428
305
  router.post('/:udid/touchAndHold', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
429
- var _a, _b;
430
306
  const { udid } = req.params;
431
307
  const { x, y, duration = 1000 } = req.body;
432
308
  const device = yield getDeviceInfo(udid);
433
309
  if (!device)
434
310
  return res.status(404).send('Device not found');
435
311
  const manager = yield getDeviceManagerForPlatform(device.platform);
436
- if (isOtherServersPhone(device)) {
437
- const target = buildProxyUrl(device.host, req);
438
- if (!target)
439
- return res.status(400).send({ error: 'Unsafe device host' });
440
- logger_1.default.info(`Proxying touchAndHold for ${udid} to ${target}`);
441
- try {
442
- yield forwardControl(device, req, target);
443
- return res.status(200).send({ success: true });
444
- }
445
- catch (err) {
446
- 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);
447
- }
448
- }
449
312
  if (manager && manager.touchAndHold) {
450
313
  try {
451
314
  yield manager.touchAndHold(udid, x, y, duration);
@@ -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
+ }