@xenon-device-management/xenon 1.2.0 → 1.3.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 (90) hide show
  1. package/README.md +74 -0
  2. package/lib/package.json +1 -1
  3. package/lib/public/assets/{Layouts-D0WSzKOh.js → Layouts-D6IPfwoe.js} +1 -1
  4. package/lib/public/assets/{ai-settings-DQWDdNd7.js → ai-settings-CflyFKan.js} +1 -1
  5. package/lib/public/assets/{apps-1sLWHOGO.js → apps-Da4dvQ1J.js} +1 -1
  6. package/lib/public/assets/{badge-BiR1gmMm.js → badge-BNR9umdu.js} +1 -1
  7. package/lib/public/assets/{button-BVazt4Z1.js → button-hZFV1ypT.js} +1 -1
  8. package/lib/public/assets/{calendar-yMyP2_Nc.js → calendar-fehdBtun.js} +1 -1
  9. package/lib/public/assets/{clock-CsVplnJ2.js → clock-DrpxSvCL.js} +1 -1
  10. package/lib/public/assets/{cpu-DNC8n7kK.js → cpu-tuyMVZ4I.js} +1 -1
  11. package/lib/public/assets/{device-explorer-DFu8Gxj4.js → device-explorer-DOfRH3zm.js} +1 -1
  12. package/lib/public/assets/{index-S71J2rWg.js → index-BaTiUCeH.js} +18 -18
  13. package/lib/public/assets/{lock-BstCxnX6.js → lock-C6CoqSr2.js} +1 -1
  14. package/lib/public/assets/{maintenance-settings-BwfG9cu2.js → maintenance-settings-CM2oC7-i.js} +1 -1
  15. package/lib/public/assets/{mouse-pointer-2-CSn_Wnc9.js → mouse-pointer-2-CXdnjXIg.js} +1 -1
  16. package/lib/public/assets/{plus-DfjM7G6e.js → plus-B4B1Hukt.js} +1 -1
  17. package/lib/public/assets/{session-dashboard-C6ek4z65.js → session-dashboard-B5OPMTz5.js} +1 -1
  18. package/lib/public/assets/{settings-BDYP8ULf.js → settings-BTHP7fj3.js} +1 -1
  19. package/lib/public/assets/{trash-2-CZWUMK5b.js → trash-2-NJMZJ2Ol.js} +1 -1
  20. package/lib/public/assets/{useSocket-CliVeWS3.js → useSocket-Ct2wo7P2.js} +2 -2
  21. package/lib/public/assets/{webhook-settings-tPiwWf8y.js → webhook-settings-Cz35-QJ7.js} +1 -1
  22. package/lib/public/assets/{zap-ZrK5B58i.js → zap-CssSMAN5.js} +1 -1
  23. package/lib/public/index.html +1 -1
  24. package/lib/schema.json +85 -38
  25. package/lib/src/InternalHttpClient.js +69 -14
  26. package/lib/src/app/index.js +92 -24
  27. package/lib/src/app/routers/apikeys.js +33 -0
  28. package/lib/src/app/routers/apps.js +4 -0
  29. package/lib/src/app/routers/auth.js +36 -0
  30. package/lib/src/app/routers/config.js +4 -0
  31. package/lib/src/app/routers/control.js +61 -10
  32. package/lib/src/app/routers/dashboard.js +5 -6
  33. package/lib/src/app/routers/grid.js +30 -12
  34. package/lib/src/app/routers/processes.js +24 -0
  35. package/lib/src/app/routers/reservation.js +15 -0
  36. package/lib/src/app/routers/webhook.js +6 -3
  37. package/lib/src/auth/nodeSecret.js +33 -0
  38. package/lib/src/config.js +5 -0
  39. package/lib/src/data-service/prisma-store.js +17 -1
  40. package/lib/src/device-managers/AndroidDeviceManager.js +2 -2
  41. package/lib/src/device-managers/NodeDevices.js +8 -1
  42. package/lib/src/device-managers/ios/IOSDiscoveryService.js +7 -4
  43. package/lib/src/device-managers/ios/IOSStreamService.js +7 -0
  44. package/lib/src/device-managers/ios/WDAClient.js +2 -0
  45. package/lib/src/device-utils.js +29 -4
  46. package/lib/src/generated/client/edge.js +2 -2
  47. package/lib/src/generated/client/index.js +2 -2
  48. package/lib/src/generated/client/package.json +1 -1
  49. package/lib/src/generated/client/schema.prisma +3 -0
  50. package/lib/src/helpers/UniversalMjpegProxy.js +23 -0
  51. package/lib/src/index.js +10 -2
  52. package/lib/src/interceptors/CommandInterceptor.js +29 -0
  53. package/lib/src/interfaces/IPluginArgs.js +0 -1
  54. package/lib/src/logger.js +30 -2
  55. package/lib/src/logging/sessionContext.js +28 -0
  56. package/lib/src/middleware/apiKeyMiddleware.js +49 -0
  57. package/lib/src/middleware/csrfMiddleware.js +73 -0
  58. package/lib/src/middleware/nodeSecretMiddleware.js +38 -0
  59. package/lib/src/middleware/rateLimitMiddleware.js +68 -0
  60. package/lib/src/middleware/scopeGuard.js +41 -0
  61. package/lib/src/plugin.js +1 -1
  62. package/lib/src/services/AIService.js +43 -8
  63. package/lib/src/services/ApiKeyService.js +102 -0
  64. package/lib/src/services/CircuitBreaker.js +158 -0
  65. package/lib/src/services/CleanupService.js +137 -39
  66. package/lib/src/services/DeviceReconciler.js +102 -0
  67. package/lib/src/services/MetricsService.js +78 -0
  68. package/lib/src/services/PortAllocator.js +13 -0
  69. package/lib/src/services/ProcessMetricsService.js +99 -0
  70. package/lib/src/services/ProcessRegistry.js +123 -0
  71. package/lib/src/services/ServerManager.js +14 -2
  72. package/lib/src/services/SessionLifecycleService.js +80 -23
  73. package/lib/src/services/ShutdownCoordinator.js +89 -0
  74. package/lib/src/services/SocketClient.js +11 -0
  75. package/lib/src/services/SocketServer.js +109 -6
  76. package/lib/src/services/VideoPipelineService.js +2 -0
  77. package/lib/src/services/healing/HealingMetrics.js +63 -0
  78. package/lib/src/services/healing/HealingOrchestrator.js +32 -4
  79. package/lib/src/services/healing/OcrHealingProvider.js +7 -0
  80. package/lib/test/unit/ApiKeyService.test.js +101 -0
  81. package/lib/test/unit/PortAllocator.test.js +14 -0
  82. package/lib/test/unit/ProcessRegistry.test.js +70 -0
  83. package/lib/test/unit/apiKeyMiddleware.test.js +58 -0
  84. package/lib/test/unit/nodeSecretMiddleware.test.js +38 -0
  85. package/lib/test/unit/rateLimitMiddleware.test.js +37 -0
  86. package/lib/tsconfig.tsbuildinfo +1 -1
  87. package/package.json +2 -2
  88. package/prisma/migrations/20260423081701_add_session_indexes/migration.sql +8 -0
  89. package/prisma/schema.prisma +3 -0
  90. package/schema.json +85 -38
@@ -400,8 +400,8 @@ const config = {
400
400
  }
401
401
  }
402
402
  },
403
- "inlineSchema": "generator client {\n provider = \"prisma-client-js\"\n output = \"../src/generated/client\"\n binaryTargets = [\"native\", \"darwin\", \"darwin-arm64\", \"linux-musl-arm64-openssl-3.0.x\", \"linux-musl-openssl-3.0.x\", \"debian-openssl-3.0.x\"]\n}\n\ndatasource db {\n provider = \"sqlite\"\n url = env(\"DATABASE_URL\")\n}\n\nmodel Build {\n id String @id @default(uuid())\n name String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n sessions Session[]\n}\n\nmodel Session {\n id String @id\n build_id String?\n name String?\n status String @default(\"running\")\n desired_capabilities String\n session_capabilities String\n node_id String\n has_live_video Boolean\n video_recording_enabled Boolean @default(true)\n video_recording String?\n startTime DateTime @default(now())\n endTime DateTime?\n failure_reason String?\n is_profiling_available Boolean @default(false)\n device_info String?\n device_udid String\n device_platform String\n device_version String\n device_name String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n performance_trace String? @map(\"performance_trace\")\n failure_category String?\n ai_analysis String?\n tags String?\n trace_id String?\n last_heartbeat_at DateTime?\n heartbeat_pid Int?\n heartbeat_host String?\n Log Log[]\n Profiling Profiling[]\n build Build? @relation(fields: [build_id], references: [id])\n SessionLog SessionLog[]\n\n @@index([status, last_heartbeat_at])\n}\n\nmodel SessionLog {\n id String @id @default(uuid())\n session_id String\n command_name String?\n url String\n method String\n title String\n subtitle String?\n body String?\n response String\n screenshot String?\n is_success Boolean?\n is_error Boolean @default(false)\n is_healed Boolean @default(false)\n original_selector String?\n healed_selector String?\n healing_confidence Float?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n duration Int?\n span_id String?\n trace_id String?\n session Session @relation(fields: [session_id], references: [id])\n}\n\nmodel Log {\n id String @id @default(uuid())\n session_id String\n log_type String\n message String\n timestamp DateTime @default(now())\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n session Session @relation(fields: [session_id], references: [id])\n}\n\nmodel Profiling {\n id Int @id @default(autoincrement())\n session_id String\n cpu String?\n memory String?\n total_cpu_used String?\n total_memory_used String?\n raw_cpu_log String?\n raw_memory_log String?\n timestamp DateTime\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n session Session @relation(fields: [session_id], references: [id])\n}\n\nmodel App {\n id String @id @default(uuid())\n name String\n filename String\n filepath String\n mimetype String\n size Int\n packageName String?\n version String?\n platform String?\n md5 String? @unique\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel Device {\n udid String\n host String\n systemPort Int?\n proxyPort Int?\n proxyHost String?\n wdaLocalPort Int?\n name String? @default(\"unknown\")\n state String? @default(\"available\")\n sdk String? @default(\"unknown\")\n platform String? @default(\"unknown\")\n deviceType String? @default(\"real\")\n busy Boolean? @default(false)\n userBlocked Boolean? @default(false)\n realDevice Boolean? @default(true)\n session_id String?\n offline Boolean? @default(false)\n mjpegServerPort Int?\n lastCmdExecutedAt Float?\n totalUtilizationTimeMilliSec Float @default(0)\n sessionStartTime Float @default(0)\n newCommandTimeout Int?\n cloud String?\n derivedDataPath String?\n chromeDriverPath String?\n capability String?\n adbRemoteHost String?\n adbPort Int?\n nodeId String?\n screenWidth String?\n screenHeight String?\n dashboard_link String?\n total_session_count Int? @default(0)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n healthCheckError String?\n healthStatus String? @default(\"Healthy\")\n lastHealthCheckAt Float?\n batteryLevel Int?\n reservationReason String?\n reservedBy String?\n reservedUntil Float?\n storageFree String?\n tags String?\n thermalStatus String?\n sessionProgress String? @default(\"\")\n totalHealedCount Int? @default(0)\n ip String? @default(\"\")\n cpuArchitecture String?\n owningSessionId String? @map(\"owning_session_id\")\n lockedAt Float? @map(\"locked_at\")\n\n @@id([udid, host])\n @@index([owningSessionId])\n}\n\nmodel PendingSession {\n id Int @id @default(autoincrement())\n capability_id String @unique\n capability String\n createdAt Float\n}\n\nmodel CLIArgs {\n id Int @id @default(autoincrement())\n args String\n createdAt DateTime @default(now())\n}\n\nmodel WebhookConfig {\n id String @id @default(uuid())\n url String\n type String @default(\"slack\")\n events String\n active Boolean @default(true)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n payloadTemplate String?\n}\n\nmodel WebConfig {\n id String @id @default(\"global\")\n name String @unique\n value String\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel LocatorEtalon {\n id String @id @default(uuid())\n selector String @unique\n strategy String\n attributes String\n nodeName String\n lastSeen DateTime @default(now())\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel PortLease {\n port Int @id\n purpose String\n leasedToUdid String\n leasedToPid Int?\n leasedAt Float\n expiresAt Float\n\n @@index([purpose, expiresAt])\n @@index([leasedToUdid])\n @@index([expiresAt])\n}\n\nmodel ApiKey {\n id String @id @default(uuid())\n name String\n keyHash String @unique\n scopes String\n rateLimit Int @default(300)\n createdAt DateTime @default(now())\n revokedAt DateTime?\n lastUsedAt DateTime?\n}\n",
404
- "inlineSchemaHash": "63bca573ac32eb8f7ecdad65dacae428e37c45913b9c1d26de562bdd7fd2ad55",
403
+ "inlineSchema": "generator client {\n provider = \"prisma-client-js\"\n output = \"../src/generated/client\"\n binaryTargets = [\"native\", \"darwin\", \"darwin-arm64\", \"linux-musl-arm64-openssl-3.0.x\", \"linux-musl-openssl-3.0.x\", \"debian-openssl-3.0.x\"]\n}\n\ndatasource db {\n provider = \"sqlite\"\n url = env(\"DATABASE_URL\")\n}\n\nmodel Build {\n id String @id @default(uuid())\n name String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n sessions Session[]\n}\n\nmodel Session {\n id String @id\n build_id String?\n name String?\n status String @default(\"running\")\n desired_capabilities String\n session_capabilities String\n node_id String\n has_live_video Boolean\n video_recording_enabled Boolean @default(true)\n video_recording String?\n startTime DateTime @default(now())\n endTime DateTime?\n failure_reason String?\n is_profiling_available Boolean @default(false)\n device_info String?\n device_udid String\n device_platform String\n device_version String\n device_name String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n performance_trace String? @map(\"performance_trace\")\n failure_category String?\n ai_analysis String?\n tags String?\n trace_id String?\n last_heartbeat_at DateTime?\n heartbeat_pid Int?\n heartbeat_host String?\n Log Log[]\n Profiling Profiling[]\n build Build? @relation(fields: [build_id], references: [id])\n SessionLog SessionLog[]\n\n @@index([status, last_heartbeat_at])\n @@index([build_id])\n @@index([device_udid])\n @@index([device_platform, status])\n}\n\nmodel SessionLog {\n id String @id @default(uuid())\n session_id String\n command_name String?\n url String\n method String\n title String\n subtitle String?\n body String?\n response String\n screenshot String?\n is_success Boolean?\n is_error Boolean @default(false)\n is_healed Boolean @default(false)\n original_selector String?\n healed_selector String?\n healing_confidence Float?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n duration Int?\n span_id String?\n trace_id String?\n session Session @relation(fields: [session_id], references: [id])\n}\n\nmodel Log {\n id String @id @default(uuid())\n session_id String\n log_type String\n message String\n timestamp DateTime @default(now())\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n session Session @relation(fields: [session_id], references: [id])\n}\n\nmodel Profiling {\n id Int @id @default(autoincrement())\n session_id String\n cpu String?\n memory String?\n total_cpu_used String?\n total_memory_used String?\n raw_cpu_log String?\n raw_memory_log String?\n timestamp DateTime\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n session Session @relation(fields: [session_id], references: [id])\n}\n\nmodel App {\n id String @id @default(uuid())\n name String\n filename String\n filepath String\n mimetype String\n size Int\n packageName String?\n version String?\n platform String?\n md5 String? @unique\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel Device {\n udid String\n host String\n systemPort Int?\n proxyPort Int?\n proxyHost String?\n wdaLocalPort Int?\n name String? @default(\"unknown\")\n state String? @default(\"available\")\n sdk String? @default(\"unknown\")\n platform String? @default(\"unknown\")\n deviceType String? @default(\"real\")\n busy Boolean? @default(false)\n userBlocked Boolean? @default(false)\n realDevice Boolean? @default(true)\n session_id String?\n offline Boolean? @default(false)\n mjpegServerPort Int?\n lastCmdExecutedAt Float?\n totalUtilizationTimeMilliSec Float @default(0)\n sessionStartTime Float @default(0)\n newCommandTimeout Int?\n cloud String?\n derivedDataPath String?\n chromeDriverPath String?\n capability String?\n adbRemoteHost String?\n adbPort Int?\n nodeId String?\n screenWidth String?\n screenHeight String?\n dashboard_link String?\n total_session_count Int? @default(0)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n healthCheckError String?\n healthStatus String? @default(\"Healthy\")\n lastHealthCheckAt Float?\n batteryLevel Int?\n reservationReason String?\n reservedBy String?\n reservedUntil Float?\n storageFree String?\n tags String?\n thermalStatus String?\n sessionProgress String? @default(\"\")\n totalHealedCount Int? @default(0)\n ip String? @default(\"\")\n cpuArchitecture String?\n owningSessionId String? @map(\"owning_session_id\")\n lockedAt Float? @map(\"locked_at\")\n\n @@id([udid, host])\n @@index([owningSessionId])\n}\n\nmodel PendingSession {\n id Int @id @default(autoincrement())\n capability_id String @unique\n capability String\n createdAt Float\n}\n\nmodel CLIArgs {\n id Int @id @default(autoincrement())\n args String\n createdAt DateTime @default(now())\n}\n\nmodel WebhookConfig {\n id String @id @default(uuid())\n url String\n type String @default(\"slack\")\n events String\n active Boolean @default(true)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n payloadTemplate String?\n}\n\nmodel WebConfig {\n id String @id @default(\"global\")\n name String @unique\n value String\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel LocatorEtalon {\n id String @id @default(uuid())\n selector String @unique\n strategy String\n attributes String\n nodeName String\n lastSeen DateTime @default(now())\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel PortLease {\n port Int @id\n purpose String\n leasedToUdid String\n leasedToPid Int?\n leasedAt Float\n expiresAt Float\n\n @@index([purpose, expiresAt])\n @@index([leasedToUdid])\n @@index([expiresAt])\n}\n\nmodel ApiKey {\n id String @id @default(uuid())\n name String\n keyHash String @unique\n scopes String\n rateLimit Int @default(300)\n createdAt DateTime @default(now())\n revokedAt DateTime?\n lastUsedAt DateTime?\n}\n",
404
+ "inlineSchemaHash": "6864fff1fc9fe0ae6d8012e4e368ce177d5d16a754ba6305deaef00b7524ef05",
405
405
  "copyEngine": true
406
406
  }
407
407
  config.dirname = '/'
@@ -401,8 +401,8 @@ const config = {
401
401
  }
402
402
  }
403
403
  },
404
- "inlineSchema": "generator client {\n provider = \"prisma-client-js\"\n output = \"../src/generated/client\"\n binaryTargets = [\"native\", \"darwin\", \"darwin-arm64\", \"linux-musl-arm64-openssl-3.0.x\", \"linux-musl-openssl-3.0.x\", \"debian-openssl-3.0.x\"]\n}\n\ndatasource db {\n provider = \"sqlite\"\n url = env(\"DATABASE_URL\")\n}\n\nmodel Build {\n id String @id @default(uuid())\n name String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n sessions Session[]\n}\n\nmodel Session {\n id String @id\n build_id String?\n name String?\n status String @default(\"running\")\n desired_capabilities String\n session_capabilities String\n node_id String\n has_live_video Boolean\n video_recording_enabled Boolean @default(true)\n video_recording String?\n startTime DateTime @default(now())\n endTime DateTime?\n failure_reason String?\n is_profiling_available Boolean @default(false)\n device_info String?\n device_udid String\n device_platform String\n device_version String\n device_name String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n performance_trace String? @map(\"performance_trace\")\n failure_category String?\n ai_analysis String?\n tags String?\n trace_id String?\n last_heartbeat_at DateTime?\n heartbeat_pid Int?\n heartbeat_host String?\n Log Log[]\n Profiling Profiling[]\n build Build? @relation(fields: [build_id], references: [id])\n SessionLog SessionLog[]\n\n @@index([status, last_heartbeat_at])\n}\n\nmodel SessionLog {\n id String @id @default(uuid())\n session_id String\n command_name String?\n url String\n method String\n title String\n subtitle String?\n body String?\n response String\n screenshot String?\n is_success Boolean?\n is_error Boolean @default(false)\n is_healed Boolean @default(false)\n original_selector String?\n healed_selector String?\n healing_confidence Float?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n duration Int?\n span_id String?\n trace_id String?\n session Session @relation(fields: [session_id], references: [id])\n}\n\nmodel Log {\n id String @id @default(uuid())\n session_id String\n log_type String\n message String\n timestamp DateTime @default(now())\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n session Session @relation(fields: [session_id], references: [id])\n}\n\nmodel Profiling {\n id Int @id @default(autoincrement())\n session_id String\n cpu String?\n memory String?\n total_cpu_used String?\n total_memory_used String?\n raw_cpu_log String?\n raw_memory_log String?\n timestamp DateTime\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n session Session @relation(fields: [session_id], references: [id])\n}\n\nmodel App {\n id String @id @default(uuid())\n name String\n filename String\n filepath String\n mimetype String\n size Int\n packageName String?\n version String?\n platform String?\n md5 String? @unique\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel Device {\n udid String\n host String\n systemPort Int?\n proxyPort Int?\n proxyHost String?\n wdaLocalPort Int?\n name String? @default(\"unknown\")\n state String? @default(\"available\")\n sdk String? @default(\"unknown\")\n platform String? @default(\"unknown\")\n deviceType String? @default(\"real\")\n busy Boolean? @default(false)\n userBlocked Boolean? @default(false)\n realDevice Boolean? @default(true)\n session_id String?\n offline Boolean? @default(false)\n mjpegServerPort Int?\n lastCmdExecutedAt Float?\n totalUtilizationTimeMilliSec Float @default(0)\n sessionStartTime Float @default(0)\n newCommandTimeout Int?\n cloud String?\n derivedDataPath String?\n chromeDriverPath String?\n capability String?\n adbRemoteHost String?\n adbPort Int?\n nodeId String?\n screenWidth String?\n screenHeight String?\n dashboard_link String?\n total_session_count Int? @default(0)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n healthCheckError String?\n healthStatus String? @default(\"Healthy\")\n lastHealthCheckAt Float?\n batteryLevel Int?\n reservationReason String?\n reservedBy String?\n reservedUntil Float?\n storageFree String?\n tags String?\n thermalStatus String?\n sessionProgress String? @default(\"\")\n totalHealedCount Int? @default(0)\n ip String? @default(\"\")\n cpuArchitecture String?\n owningSessionId String? @map(\"owning_session_id\")\n lockedAt Float? @map(\"locked_at\")\n\n @@id([udid, host])\n @@index([owningSessionId])\n}\n\nmodel PendingSession {\n id Int @id @default(autoincrement())\n capability_id String @unique\n capability String\n createdAt Float\n}\n\nmodel CLIArgs {\n id Int @id @default(autoincrement())\n args String\n createdAt DateTime @default(now())\n}\n\nmodel WebhookConfig {\n id String @id @default(uuid())\n url String\n type String @default(\"slack\")\n events String\n active Boolean @default(true)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n payloadTemplate String?\n}\n\nmodel WebConfig {\n id String @id @default(\"global\")\n name String @unique\n value String\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel LocatorEtalon {\n id String @id @default(uuid())\n selector String @unique\n strategy String\n attributes String\n nodeName String\n lastSeen DateTime @default(now())\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel PortLease {\n port Int @id\n purpose String\n leasedToUdid String\n leasedToPid Int?\n leasedAt Float\n expiresAt Float\n\n @@index([purpose, expiresAt])\n @@index([leasedToUdid])\n @@index([expiresAt])\n}\n\nmodel ApiKey {\n id String @id @default(uuid())\n name String\n keyHash String @unique\n scopes String\n rateLimit Int @default(300)\n createdAt DateTime @default(now())\n revokedAt DateTime?\n lastUsedAt DateTime?\n}\n",
405
- "inlineSchemaHash": "63bca573ac32eb8f7ecdad65dacae428e37c45913b9c1d26de562bdd7fd2ad55",
404
+ "inlineSchema": "generator client {\n provider = \"prisma-client-js\"\n output = \"../src/generated/client\"\n binaryTargets = [\"native\", \"darwin\", \"darwin-arm64\", \"linux-musl-arm64-openssl-3.0.x\", \"linux-musl-openssl-3.0.x\", \"debian-openssl-3.0.x\"]\n}\n\ndatasource db {\n provider = \"sqlite\"\n url = env(\"DATABASE_URL\")\n}\n\nmodel Build {\n id String @id @default(uuid())\n name String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n sessions Session[]\n}\n\nmodel Session {\n id String @id\n build_id String?\n name String?\n status String @default(\"running\")\n desired_capabilities String\n session_capabilities String\n node_id String\n has_live_video Boolean\n video_recording_enabled Boolean @default(true)\n video_recording String?\n startTime DateTime @default(now())\n endTime DateTime?\n failure_reason String?\n is_profiling_available Boolean @default(false)\n device_info String?\n device_udid String\n device_platform String\n device_version String\n device_name String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n performance_trace String? @map(\"performance_trace\")\n failure_category String?\n ai_analysis String?\n tags String?\n trace_id String?\n last_heartbeat_at DateTime?\n heartbeat_pid Int?\n heartbeat_host String?\n Log Log[]\n Profiling Profiling[]\n build Build? @relation(fields: [build_id], references: [id])\n SessionLog SessionLog[]\n\n @@index([status, last_heartbeat_at])\n @@index([build_id])\n @@index([device_udid])\n @@index([device_platform, status])\n}\n\nmodel SessionLog {\n id String @id @default(uuid())\n session_id String\n command_name String?\n url String\n method String\n title String\n subtitle String?\n body String?\n response String\n screenshot String?\n is_success Boolean?\n is_error Boolean @default(false)\n is_healed Boolean @default(false)\n original_selector String?\n healed_selector String?\n healing_confidence Float?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n duration Int?\n span_id String?\n trace_id String?\n session Session @relation(fields: [session_id], references: [id])\n}\n\nmodel Log {\n id String @id @default(uuid())\n session_id String\n log_type String\n message String\n timestamp DateTime @default(now())\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n session Session @relation(fields: [session_id], references: [id])\n}\n\nmodel Profiling {\n id Int @id @default(autoincrement())\n session_id String\n cpu String?\n memory String?\n total_cpu_used String?\n total_memory_used String?\n raw_cpu_log String?\n raw_memory_log String?\n timestamp DateTime\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n session Session @relation(fields: [session_id], references: [id])\n}\n\nmodel App {\n id String @id @default(uuid())\n name String\n filename String\n filepath String\n mimetype String\n size Int\n packageName String?\n version String?\n platform String?\n md5 String? @unique\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel Device {\n udid String\n host String\n systemPort Int?\n proxyPort Int?\n proxyHost String?\n wdaLocalPort Int?\n name String? @default(\"unknown\")\n state String? @default(\"available\")\n sdk String? @default(\"unknown\")\n platform String? @default(\"unknown\")\n deviceType String? @default(\"real\")\n busy Boolean? @default(false)\n userBlocked Boolean? @default(false)\n realDevice Boolean? @default(true)\n session_id String?\n offline Boolean? @default(false)\n mjpegServerPort Int?\n lastCmdExecutedAt Float?\n totalUtilizationTimeMilliSec Float @default(0)\n sessionStartTime Float @default(0)\n newCommandTimeout Int?\n cloud String?\n derivedDataPath String?\n chromeDriverPath String?\n capability String?\n adbRemoteHost String?\n adbPort Int?\n nodeId String?\n screenWidth String?\n screenHeight String?\n dashboard_link String?\n total_session_count Int? @default(0)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n healthCheckError String?\n healthStatus String? @default(\"Healthy\")\n lastHealthCheckAt Float?\n batteryLevel Int?\n reservationReason String?\n reservedBy String?\n reservedUntil Float?\n storageFree String?\n tags String?\n thermalStatus String?\n sessionProgress String? @default(\"\")\n totalHealedCount Int? @default(0)\n ip String? @default(\"\")\n cpuArchitecture String?\n owningSessionId String? @map(\"owning_session_id\")\n lockedAt Float? @map(\"locked_at\")\n\n @@id([udid, host])\n @@index([owningSessionId])\n}\n\nmodel PendingSession {\n id Int @id @default(autoincrement())\n capability_id String @unique\n capability String\n createdAt Float\n}\n\nmodel CLIArgs {\n id Int @id @default(autoincrement())\n args String\n createdAt DateTime @default(now())\n}\n\nmodel WebhookConfig {\n id String @id @default(uuid())\n url String\n type String @default(\"slack\")\n events String\n active Boolean @default(true)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n payloadTemplate String?\n}\n\nmodel WebConfig {\n id String @id @default(\"global\")\n name String @unique\n value String\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel LocatorEtalon {\n id String @id @default(uuid())\n selector String @unique\n strategy String\n attributes String\n nodeName String\n lastSeen DateTime @default(now())\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel PortLease {\n port Int @id\n purpose String\n leasedToUdid String\n leasedToPid Int?\n leasedAt Float\n expiresAt Float\n\n @@index([purpose, expiresAt])\n @@index([leasedToUdid])\n @@index([expiresAt])\n}\n\nmodel ApiKey {\n id String @id @default(uuid())\n name String\n keyHash String @unique\n scopes String\n rateLimit Int @default(300)\n createdAt DateTime @default(now())\n revokedAt DateTime?\n lastUsedAt DateTime?\n}\n",
405
+ "inlineSchemaHash": "6864fff1fc9fe0ae6d8012e4e368ce177d5d16a754ba6305deaef00b7524ef05",
406
406
  "copyEngine": true
407
407
  }
408
408
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "name": "prisma-client-d4c43073a25210b44af705e38a5145f202638c15575b7a217b98d91bbb52c599",
2
+ "name": "prisma-client-b9d7255c28647be181530780d93fb8b58302c33ef3d6c27d829ee40419725e48",
3
3
  "main": "index.js",
4
4
  "types": "index.d.ts",
5
5
  "browser": "index-browser.js",
@@ -53,6 +53,9 @@ model Session {
53
53
  SessionLog SessionLog[]
54
54
 
55
55
  @@index([status, last_heartbeat_at])
56
+ @@index([build_id])
57
+ @@index([device_udid])
58
+ @@index([device_platform, status])
56
59
  }
57
60
 
58
61
  model SessionLog {
@@ -182,7 +182,25 @@ class UniversalMjpegProxy {
182
182
  });
183
183
  }
184
184
  broadcast(chunk) {
185
+ var _a, _b, _c;
185
186
  for (const client of this.clients) {
187
+ if (!client.writable || client.destroyed) {
188
+ this.clients.delete(client);
189
+ continue;
190
+ }
191
+ const socket = (_a = client.socket) !== null && _a !== void 0 ? _a : (_b = client.req) === null || _b === void 0 ? void 0 : _b.socket;
192
+ const backlog = (_c = socket === null || socket === void 0 ? void 0 : socket.writableLength) !== null && _c !== void 0 ? _c : 0;
193
+ if (backlog > UniversalMjpegProxy.MAX_CLIENT_BACKLOG_BYTES) {
194
+ logger_1.default.warn(`[MjpegProxy] Dropping lagging client (backlog=${backlog}B > ${UniversalMjpegProxy.MAX_CLIENT_BACKLOG_BYTES}B) on ${this.mjpegUrl}`);
195
+ try {
196
+ client.end();
197
+ }
198
+ catch (e) {
199
+ /* ignore */
200
+ }
201
+ this.clients.delete(client);
202
+ continue;
203
+ }
186
204
  try {
187
205
  client.write(chunk);
188
206
  }
@@ -271,3 +289,8 @@ exports.UniversalMjpegProxy = UniversalMjpegProxy;
271
289
  UniversalMjpegProxy.MAX_RETRIES = 10;
272
290
  UniversalMjpegProxy.BASE_RETRY_MS = 500;
273
291
  UniversalMjpegProxy.MAX_RETRY_MS = 10000;
292
+ // Drop a client whose kernel send buffer exceeds this. MJPEG frames must not be
293
+ // split mid-boundary, so we drop the whole client rather than partial chunks —
294
+ // otherwise a single paused browser tab silently queues frames in Node's heap
295
+ // until the process OOMs on a long session.
296
+ UniversalMjpegProxy.MAX_CLIENT_BACKLOG_BYTES = 4 * 1024 * 1024;
package/lib/src/index.js CHANGED
@@ -61,17 +61,25 @@ process.env.PATH = process.env.PATH + ':' + ffmpeg_1.path.replace(/ffmpeg$/g, ''
61
61
  * all spawned sidecar processes (go-ios, iproxy, tunnels) are killed.
62
62
  */
63
63
  const cleanup = () => __awaiter(void 0, void 0, void 0, function* () {
64
- logger_1.default.info('🚀 [Xenon] Shutdown signal received. Performing graceful cleanup...');
64
+ logger_1.default.info('🚀 [Xenon] Shutdown signal received. Performing graceful drain + cleanup...');
65
65
  try {
66
+ // Phase 1: drain active sessions so in-flight video gets archived,
67
+ // ports get released, and the DB rows don't stay "running" forever.
68
+ // Bounded so a hung driver can't hold up systemd (default 90s timeout).
69
+ const { ShutdownCoordinator } = yield Promise.resolve().then(() => __importStar(require('./services/ShutdownCoordinator')));
70
+ yield typedi_1.Container.get(ShutdownCoordinator).drain(15000);
66
71
  const { default: IOSStreamService } = yield Promise.resolve().then(() => __importStar(require('./device-managers/ios/IOSStreamService')));
67
72
  const { default: AndroidStreamService } = yield Promise.resolve().then(() => __importStar(require('./device-managers/android/AndroidStreamService')));
68
73
  const { stopAllTimers } = yield Promise.resolve().then(() => __importStar(require('./device-utils')));
69
74
  const { VideoPipelineService } = yield Promise.resolve().then(() => __importStar(require('./services/VideoPipelineService')));
70
- // Shutdown all independent MJPEG streams, tunnels, and video recordings
75
+ const { ProcessRegistry } = yield Promise.resolve().then(() => __importStar(require('./services/ProcessRegistry')));
76
+ // Phase 2: tear down infra (timers, sidecars, MJPEG streams).
71
77
  stopAllTimers();
72
78
  yield typedi_1.Container.get(IOSStreamService).cleanup();
73
79
  yield typedi_1.Container.get(AndroidStreamService).cleanup();
74
80
  yield typedi_1.Container.get(VideoPipelineService).cleanup();
81
+ // Phase 3: kill anything that's still running
82
+ yield typedi_1.Container.get(ProcessRegistry).terminateAll();
75
83
  logger_1.default.info('✅ [Xenon] Infrastructure components sanitized. Safe to exit.');
76
84
  }
77
85
  catch (err) {
@@ -61,6 +61,8 @@ const HealEtalonService_1 = require("../services/healing/HealEtalonService");
61
61
  const OmniVisionService_1 = require("../services/omni-vision/OmniVisionService");
62
62
  const AICommandService_1 = require("../services/AICommandService");
63
63
  const logger_1 = __importDefault(require("../logger"));
64
+ const sessionContext_1 = require("../logging/sessionContext");
65
+ const ProcessMetricsService_1 = require("../services/ProcessMetricsService");
64
66
  let CommandInterceptor = class CommandInterceptor {
65
67
  constructor() {
66
68
  this.log = logger_1.default.scope('CommandInterceptor');
@@ -68,6 +70,7 @@ let CommandInterceptor = class CommandInterceptor {
68
70
  }
69
71
  handle(next, driver, commandName, args, pluginArgs, isHub) {
70
72
  return __awaiter(this, void 0, void 0, function* () {
73
+ var _a;
71
74
  const IGNORED_COMMANDS = ['getScreenshot', 'stopRecordingScreen', 'startRecordingScreen'];
72
75
  if (IGNORED_COMMANDS.includes(commandName))
73
76
  return yield next();
@@ -79,6 +82,32 @@ let CommandInterceptor = class CommandInterceptor {
79
82
  'xenon.command.args': JSON.stringify(args),
80
83
  });
81
84
  }
85
+ // Wrap the rest of the work in an AsyncLocalStorage frame so logs emitted
86
+ // from any downstream service (healing, omni-vision, dashboard event
87
+ // manager) automatically carry session/command/trace attribution without
88
+ // those services having to accept a context parameter.
89
+ const spanCtx = span === null || span === void 0 ? void 0 : span.spanContext();
90
+ const cmdStart = Date.now();
91
+ try {
92
+ return yield sessionContext_1.sessionContext.run({
93
+ sessionId: sessionId || undefined,
94
+ udid: (_a = driver === null || driver === void 0 ? void 0 : driver.caps) === null || _a === void 0 ? void 0 : _a.udid,
95
+ commandName,
96
+ traceId: spanCtx === null || spanCtx === void 0 ? void 0 : spanCtx.traceId,
97
+ spanId: spanCtx === null || spanCtx === void 0 ? void 0 : spanCtx.spanId,
98
+ }, () => this.handleInContext(next, driver, commandName, args, pluginArgs, isHub, sessionId, span, tracingService));
99
+ }
100
+ finally {
101
+ // Aggregate counter — no per-session label, just fleet-wide throughput
102
+ // so Prom rate() gives commands/sec and the duration sum gives avg
103
+ // latency. Errors still count: a 429-hit dashboard poll is still hub
104
+ // work done.
105
+ typedi_1.Container.get(ProcessMetricsService_1.ProcessMetricsService).recordCommand(Date.now() - cmdStart);
106
+ }
107
+ });
108
+ }
109
+ handleInContext(next, driver, commandName, args, pluginArgs, isHub, sessionId, span, tracingService) {
110
+ return __awaiter(this, void 0, void 0, function* () {
82
111
  if (commandName === 'createSession' || commandName === 'deleteSession') {
83
112
  try {
84
113
  return yield next();
@@ -34,7 +34,6 @@ exports.DefaultPluginArgs = {
34
34
  bindHostOrIp: ip_1.default.address(),
35
35
  enableDashboard: false,
36
36
  bootedSimulators: false,
37
- bootedEmulators: false,
38
37
  healthCheckIntervalMs: 86400000,
39
38
  healthCheckSchedule: undefined,
40
39
  removeDevicesFromDatabaseBeforeRunningThePlugin: false,
package/lib/src/logger.js CHANGED
@@ -12,6 +12,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.XenonLogger = void 0;
13
13
  exports.redactSecrets = redactSecrets;
14
14
  const support_1 = require("@appium/support");
15
+ const sessionContext_1 = require("./logging/sessionContext");
15
16
  /**
16
17
  * Keys whose values must never appear in log output.
17
18
  * Case-insensitive substring matching is used so 'myApiKey' and 'API_KEY' both match.
@@ -133,20 +134,47 @@ class XenonLogger {
133
134
  logMessage(level, message, ...args) {
134
135
  const redactedMessage = redactSecrets(message);
135
136
  const redactedArgs = args.map((arg) => redactSecrets(arg));
137
+ const ctx = sessionContext_1.sessionContext.get();
136
138
  if (XenonLogger.isJsonLogging) {
137
139
  const logEntry = {
138
140
  timestamp: new Date().toISOString(),
139
141
  level,
140
142
  scope: this.context.trim() || 'root',
141
143
  message: this.format(redactedMessage),
142
- args: redactedArgs.length ? redactedArgs : undefined,
143
144
  };
145
+ if (ctx) {
146
+ if (ctx.sessionId)
147
+ logEntry.sessionId = ctx.sessionId;
148
+ if (ctx.udid)
149
+ logEntry.udid = ctx.udid;
150
+ if (ctx.requestId)
151
+ logEntry.requestId = ctx.requestId;
152
+ if (ctx.commandName)
153
+ logEntry.commandName = ctx.commandName;
154
+ if (ctx.traceId)
155
+ logEntry.traceId = ctx.traceId;
156
+ if (ctx.spanId)
157
+ logEntry.spanId = ctx.spanId;
158
+ }
159
+ if (redactedArgs.length)
160
+ logEntry.args = redactedArgs;
144
161
  this.baseLogger.info(JSON.stringify(logEntry));
145
162
  }
146
163
  else {
147
- this.baseLogger[level](`${this.context}${this.format(redactedMessage)}`, ...redactedArgs);
164
+ const ctxPrefix = this.buildTextPrefix(ctx);
165
+ this.baseLogger[level](`${this.context}${ctxPrefix}${this.format(redactedMessage)}`, ...redactedArgs);
148
166
  }
149
167
  }
168
+ // Emit a compact [s:shortId] marker when async context has a sessionId that
169
+ // isn't already baked into this.context (i.e. this logger wasn't created via
170
+ // withSession). Keeps console lines short while still giving grep-ability.
171
+ buildTextPrefix(ctx) {
172
+ if (!(ctx === null || ctx === void 0 ? void 0 : ctx.sessionId))
173
+ return '';
174
+ if (this.context.includes(ctx.sessionId))
175
+ return '';
176
+ return `[s:${ctx.sessionId.slice(0, 8)}] `;
177
+ }
150
178
  info(message, ...args) {
151
179
  this.logMessage('info', message, ...args);
152
180
  }
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sessionContext = void 0;
4
+ const async_hooks_1 = require("async_hooks");
5
+ class SessionContextHolder {
6
+ constructor() {
7
+ this.als = new async_hooks_1.AsyncLocalStorage();
8
+ }
9
+ // Open a new context frame. Inherits the parent frame's fields so child
10
+ // run() calls can add detail (e.g. command name) without losing session ids.
11
+ run(ctx, fn) {
12
+ const parent = this.als.getStore();
13
+ const merged = Object.assign(Object.assign({}, (parent !== null && parent !== void 0 ? parent : {})), ctx);
14
+ return this.als.run(merged, fn);
15
+ }
16
+ get() {
17
+ return this.als.getStore();
18
+ }
19
+ // Mutate the active frame. Useful when a value becomes known mid-request
20
+ // (e.g. sessionId resolved from a route param after the frame was opened).
21
+ update(patch) {
22
+ const store = this.als.getStore();
23
+ if (!store)
24
+ return;
25
+ Object.assign(store, patch);
26
+ }
27
+ }
28
+ exports.sessionContext = new SessionContextHolder();
@@ -0,0 +1,49 @@
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.apiKeyMiddleware = apiKeyMiddleware;
13
+ const typedi_1 = require("typedi");
14
+ const ApiKeyService_1 = require("../services/ApiKeyService");
15
+ const config_1 = require("../config");
16
+ const SESSION_COOKIE = 'xenon_dashboard_session';
17
+ function readSessionCookie(req) {
18
+ // cookie-parser isn't mounted globally, so parse the header inline.
19
+ const header = req.headers.cookie;
20
+ if (!header)
21
+ return undefined;
22
+ for (const part of header.split(';')) {
23
+ const eq = part.indexOf('=');
24
+ if (eq < 0)
25
+ continue;
26
+ if (part.slice(0, eq).trim() === SESSION_COOKIE) {
27
+ return decodeURIComponent(part.slice(eq + 1).trim());
28
+ }
29
+ }
30
+ return undefined;
31
+ }
32
+ function apiKeyMiddleware(req, res, next) {
33
+ return __awaiter(this, void 0, void 0, function* () {
34
+ if (config_1.config.authDisabled === true) {
35
+ req.apiKey = { id: 'auth-disabled', scopes: 'admin', rateLimit: 100000 };
36
+ return next();
37
+ }
38
+ const raw = req.headers['x-xenon-api-key'] || readSessionCookie(req);
39
+ if (!raw) {
40
+ return res.status(401).json({ error: 'missing API key' });
41
+ }
42
+ const row = yield typedi_1.Container.get(ApiKeyService_1.ApiKeyService).verify(raw);
43
+ if (!row) {
44
+ return res.status(401).json({ error: 'invalid or revoked API key' });
45
+ }
46
+ req.apiKey = { id: row.id, scopes: row.scopes, rateLimit: row.rateLimit };
47
+ next();
48
+ });
49
+ }
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.csrfMiddleware = csrfMiddleware;
7
+ const logger_1 = __importDefault(require("../logger"));
8
+ const config_1 = require("../config");
9
+ // Defense-in-depth CSRF for cookie-authed dashboard callers. The session
10
+ // cookie is already SameSite=Strict (see auth.ts), which blocks the common
11
+ // cross-origin form-submit vector on modern browsers. This middleware adds
12
+ // an Origin/Referer check so we're also covered against:
13
+ // - subdomain-takeover (SameSite considers *.example.com same-site)
14
+ // - reverse-proxy misconfigurations that strip SameSite enforcement
15
+ // - older browsers whose SameSite implementation has known gaps
16
+ //
17
+ // Header-authed callers (x-xenon-api-key, x-xenon-node-secret) are immune to
18
+ // CSRF by construction: a browser will not attach a custom header without an
19
+ // explicit CORS preflight, and the apiRouter's cors({origin:false}) already
20
+ // refuses preflights. Those requests pass through unchanged.
21
+ const STATE_CHANGING_METHODS = new Set(['POST', 'PUT', 'DELETE', 'PATCH']);
22
+ // Comma-separated list of allowed origins, intended for ops running the
23
+ // dashboard behind a reverse proxy on a different host than the Xenon API.
24
+ // Values can be full origins (https://dash.example.com) or bare hosts
25
+ // (dash.example.com) — either matches.
26
+ const ALLOWED_ORIGINS = (() => {
27
+ const raw = process.env.XENON_ALLOWED_ORIGINS || '';
28
+ return new Set(raw
29
+ .split(',')
30
+ .map((s) => s.trim())
31
+ .filter(Boolean));
32
+ })();
33
+ function sourceHostOf(source) {
34
+ if (!source)
35
+ return null;
36
+ try {
37
+ return new URL(source).host;
38
+ }
39
+ catch (_a) {
40
+ return null;
41
+ }
42
+ }
43
+ function csrfMiddleware(req, res, next) {
44
+ if (config_1.config.authDisabled === true)
45
+ return next();
46
+ if (!STATE_CHANGING_METHODS.has(req.method))
47
+ return next();
48
+ // Header-based auth: safe by construction (see block comment above).
49
+ if (req.headers['x-xenon-api-key'] || req.headers['x-xenon-node-secret']) {
50
+ return next();
51
+ }
52
+ // Cookie-authed (or unauthenticated) state-changer. Require Origin or
53
+ // Referer to match the Host we're being served from.
54
+ const host = req.headers.host;
55
+ const origin = req.headers.origin;
56
+ const referer = req.headers.referer;
57
+ const source = origin || referer;
58
+ if (!source) {
59
+ logger_1.default.warn(`[csrf] Blocked ${req.method} ${req.originalUrl}: no Origin/Referer header`);
60
+ return res.status(403).json({ error: 'CSRF: Origin or Referer header required' });
61
+ }
62
+ const sourceHost = sourceHostOf(source);
63
+ if (!sourceHost) {
64
+ logger_1.default.warn(`[csrf] Blocked ${req.method} ${req.originalUrl}: unparseable Origin/Referer=${source}`);
65
+ return res.status(403).json({ error: 'CSRF: invalid Origin/Referer' });
66
+ }
67
+ if (sourceHost === host)
68
+ return next();
69
+ if (ALLOWED_ORIGINS.has(source) || ALLOWED_ORIGINS.has(sourceHost))
70
+ return next();
71
+ logger_1.default.warn(`[csrf] Blocked ${req.method} ${req.originalUrl}: Origin/Referer host=${sourceHost} != Host=${host}`);
72
+ return res.status(403).json({ error: 'CSRF: Origin/Referer mismatch' });
73
+ }
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.nodeSecretMiddleware = nodeSecretMiddleware;
7
+ const logger_1 = __importDefault(require("../logger"));
8
+ const config_1 = require("../config");
9
+ const nodeSecret_1 = require("../auth/nodeSecret");
10
+ let lastWarnAt = 0;
11
+ function nodeSecretMiddleware(expected) {
12
+ return function (req, res, next) {
13
+ if (!expected) {
14
+ // Fail closed when API-key auth is also disabled — otherwise the route
15
+ // would be wide open. If auth is enabled, fall through to apiKeyMiddleware.
16
+ if (config_1.config.authDisabled === true) {
17
+ return res.status(503).json({
18
+ error: 'hub-node secret not configured while API-key auth is disabled; set --plugin-xenon-node-secret',
19
+ });
20
+ }
21
+ const now = Date.now();
22
+ if (now - lastWarnAt > 60000) {
23
+ logger_1.default.warn('[nodeSecret] node-secret not configured; hub-node channel falls back to API-key auth. Set --plugin-xenon-node-secret for defense in depth.');
24
+ lastWarnAt = now;
25
+ }
26
+ return next();
27
+ }
28
+ const got = req.headers['x-xenon-node-secret'] || '';
29
+ const outcome = (0, nodeSecret_1.validateNodeSecret)(got, {
30
+ current: expected,
31
+ previous: config_1.config.nodeSecretPrevious,
32
+ });
33
+ if (outcome === 'reject') {
34
+ return res.status(401).json({ error: 'invalid node secret' });
35
+ }
36
+ next();
37
+ };
38
+ }
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.rateLimitMiddleware = rateLimitMiddleware;
4
+ exports.__resetBucketsForTests = __resetBucketsForTests;
5
+ const buckets = new Map();
6
+ // Path patterns that should count against the 'heavy' bucket. Matches the
7
+ // AI / visual-healing / test-ai style routes where a single request can
8
+ // incur a multi-second LLM call.
9
+ const HEAVY_PATH_RE = /\/(test-ai|ai|omni|visual|heal|test-locator)(\b|\/|$)/i;
10
+ function categorize(req) {
11
+ const method = req.method.toUpperCase();
12
+ if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS')
13
+ return 'read';
14
+ if (HEAVY_PATH_RE.test(req.path))
15
+ return 'heavy';
16
+ return 'control';
17
+ }
18
+ function capacityFor(keyLimit, cat) {
19
+ if (cat === 'heavy')
20
+ return Math.max(10, Math.floor(keyLimit / 4));
21
+ return keyLimit;
22
+ }
23
+ function refill(b) {
24
+ const now = Date.now();
25
+ const elapsed = (now - b.lastRefill) / 1000;
26
+ b.tokens = Math.min(b.capacity, b.tokens + elapsed * b.refillPerSec);
27
+ b.lastRefill = now;
28
+ }
29
+ function rateLimitMiddleware() {
30
+ return function (req, res, next) {
31
+ const key = req.apiKey;
32
+ if (!key)
33
+ return next();
34
+ const category = categorize(req);
35
+ const bucketKey = `${key.id}:${category}`;
36
+ let bucket = buckets.get(bucketKey);
37
+ if (!bucket) {
38
+ const capacity = capacityFor(key.rateLimit, category);
39
+ bucket = {
40
+ tokens: capacity,
41
+ lastRefill: Date.now(),
42
+ capacity,
43
+ refillPerSec: capacity / 60,
44
+ };
45
+ buckets.set(bucketKey, bucket);
46
+ }
47
+ refill(bucket);
48
+ // Surfacing the category + remaining tokens makes 429s debuggable from
49
+ // the client side without access to server logs.
50
+ res.set('X-RateLimit-Category', category);
51
+ res.set('X-RateLimit-Remaining', String(Math.floor(bucket.tokens)));
52
+ res.set('X-RateLimit-Capacity', String(bucket.capacity));
53
+ if (bucket.tokens < 1) {
54
+ const retryAfter = Math.ceil((1 - bucket.tokens) / bucket.refillPerSec);
55
+ res.set('Retry-After', String(retryAfter));
56
+ return res.status(429).json({
57
+ error: 'rate limit exceeded',
58
+ category,
59
+ retryAfter,
60
+ });
61
+ }
62
+ bucket.tokens -= 1;
63
+ next();
64
+ };
65
+ }
66
+ function __resetBucketsForTests() {
67
+ buckets.clear();
68
+ }
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.scopeGuard = scopeGuard;
4
+ exports.mutationScopeGuard = mutationScopeGuard;
5
+ const typedi_1 = require("typedi");
6
+ const ApiKeyService_1 = require("../services/ApiKeyService");
7
+ // Full guard: denies unless the caller's API key has at least one of the
8
+ // required scopes (admin always satisfies). Use when GETs on the same
9
+ // resource should also be gated.
10
+ function scopeGuard(required) {
11
+ return function (req, res, next) {
12
+ const key = req.apiKey;
13
+ if (!key)
14
+ return res.status(401).json({ error: 'unauthenticated' });
15
+ const svc = typedi_1.Container.get(ApiKeyService_1.ApiKeyService);
16
+ const ok = svc.hasScope({
17
+ id: key.id,
18
+ name: '',
19
+ keyHash: '',
20
+ scopes: key.scopes,
21
+ rateLimit: key.rateLimit,
22
+ revokedAt: null,
23
+ }, required);
24
+ if (!ok)
25
+ return res.status(403).json({ error: 'insufficient scope' });
26
+ next();
27
+ };
28
+ }
29
+ // Mutation-only guard: GET/HEAD/OPTIONS pass through, POST/PUT/DELETE/PATCH
30
+ // go through scopeGuard. Lets us mount one middleware on a whole sub-router
31
+ // (e.g. /control) and keep all its mutations gated without touching the
32
+ // existing GET reads. Any authenticated API key can still list/query.
33
+ function mutationScopeGuard(required) {
34
+ const STATE_CHANGING = new Set(['POST', 'PUT', 'DELETE', 'PATCH']);
35
+ const guard = scopeGuard(required);
36
+ return function (req, res, next) {
37
+ if (!STATE_CHANGING.has(req.method))
38
+ return next();
39
+ return guard(req, res, next);
40
+ };
41
+ }
package/lib/src/plugin.js CHANGED
@@ -97,7 +97,7 @@ class XenonPlugin extends base_plugin_1.default {
97
97
  udid: driver.caps && driver.caps.udid ? driver.caps.udid : undefined,
98
98
  };
99
99
  if (this.pluginArgs.hub !== undefined) {
100
- yield new NodeDevices_1.default(this.pluginArgs.hub, this.pluginArgs.tlsRejectUnauthorized).unblockDevice(deviceFilter);
100
+ yield new NodeDevices_1.default(this.pluginArgs.hub, this.pluginArgs.tlsRejectUnauthorized, this.pluginArgs.nodeSecret).unblockDevice(deviceFilter);
101
101
  }
102
102
  else {
103
103
  yield (0, device_service_1.unblockDeviceMatchingFilter)(deviceFilter);