@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
package/lib/schema.json CHANGED
@@ -10,7 +10,8 @@
10
10
  "android",
11
11
  "both"
12
12
  ],
13
- "default": "both"
13
+ "default": "both",
14
+ "description": "Which mobile platform(s) Xenon should discover and orchestrate."
14
15
  },
15
16
  "androidDeviceType": {
16
17
  "title": "DeviceTypeToInclude",
@@ -20,14 +21,16 @@
20
21
  "real",
21
22
  "simulated"
22
23
  ],
23
- "default": "both"
24
+ "default": "both",
25
+ "description": "Which Android device kinds to include: physical devices, emulators, or both."
24
26
  },
25
27
  "simulators": {
26
28
  "type": "array",
27
29
  "items": {
28
30
  "$ref": "#/definitions/SimulatorConfig"
29
31
  },
30
- "default": []
32
+ "default": [],
33
+ "description": "Allow-list of iOS simulators (by name + sdk) to expose. Empty array means expose all discoverable simulators."
31
34
  },
32
35
  "iosDeviceType": {
33
36
  "title": "DeviceTypeToInclude1",
@@ -37,28 +40,34 @@
37
40
  "real",
38
41
  "simulated"
39
42
  ],
40
- "default": "both"
43
+ "default": "both",
44
+ "description": "Which iOS device kinds to include: physical devices, simulators, or both."
41
45
  },
42
46
  "hub": {
43
- "type": "string"
47
+ "type": "string",
48
+ "description": "URL of the Xenon hub this instance should register with as a node (e.g. http://hub.example:4723). Omit to run as a standalone hub."
44
49
  },
45
50
  "remoteMachineProxyIP": {
46
- "type": "string"
51
+ "type": "string",
52
+ "description": "Public host/URL that clients should use to reach this node when running behind a reverse proxy or NAT."
47
53
  },
48
54
  "adbRemote": {
49
55
  "type": "array",
50
56
  "items": {
51
57
  "type": "string"
52
58
  },
53
- "default": []
59
+ "default": [],
60
+ "description": "List of remote ADB hosts in host:port form (e.g. '192.168.1.50:5037') to discover Android devices on other machines."
54
61
  },
55
62
  "skipChromeDownload": {
56
63
  "type": "boolean",
57
- "default": true
64
+ "default": true,
65
+ "description": "Skip the automatic ChromeDriver download performed by uiautomator2. Leave true unless you specifically need Xenon to manage Chrome binaries."
58
66
  },
59
67
  "maxSessions": {
60
68
  "type": "number",
61
- "default": 8
69
+ "default": 8,
70
+ "description": "Maximum number of Appium sessions this node will run concurrently. Additional requests queue until a slot frees."
62
71
  },
63
72
  "cloud": {
64
73
  "type": "object",
@@ -77,7 +86,8 @@
77
86
  "items": {
78
87
  "$ref": "#/definitions/EmulatorConfig"
79
88
  },
80
- "default": []
89
+ "default": [],
90
+ "description": "Allow-list of Android emulator AVDs to expose. Empty array means expose all discoverable emulators."
81
91
  },
82
92
  "proxy": {
83
93
  "type": "object",
@@ -85,64 +95,79 @@
85
95
  },
86
96
  "deviceAvailabilityTimeoutMs": {
87
97
  "type": "number",
88
- "default": 300000
98
+ "default": 300000,
99
+ "description": "How long (ms) a session request waits for a free device before failing."
89
100
  },
90
101
  "deviceAvailabilityQueryIntervalMs": {
91
102
  "type": "number",
92
- "default": 10000
103
+ "default": 10000,
104
+ "description": "How often (ms) the session queue polls for a free device while waiting."
93
105
  },
94
106
  "sendNodeDevicesToHubIntervalMs": {
95
107
  "type": "number",
96
- "default": 30000
108
+ "default": 30000,
109
+ "description": "How often (ms) a node pushes its current device list to the hub. Only used when `hub` is set."
97
110
  },
98
111
  "checkStaleDevicesIntervalMs": {
99
112
  "type": "number",
100
- "default": 30000
113
+ "default": 30000,
114
+ "description": "How often (ms) the hub prunes devices from nodes that have stopped heartbeating."
101
115
  },
102
116
  "checkBlockedDevicesIntervalMs": {
103
117
  "type": "number",
104
- "default": 30000
118
+ "default": 30000,
119
+ "description": "How often (ms) to re-evaluate manually-blocked devices and the session reconciler that frees orphaned busy devices."
105
120
  },
106
121
  "newCommandTimeoutSec": {
107
122
  "type": "number",
108
- "default": 60
123
+ "default": 60,
124
+ "description": "Default Appium newCommandTimeout (seconds) applied when a client does not send one. Also drives the reconciler that releases devices idle past this threshold."
109
125
  },
110
126
  "bindHostOrIp": {
111
127
  "type": "string",
112
- "default": "127.0.0.1"
128
+ "default": "127.0.0.1",
129
+ "description": "Host/IP the Xenon REST and WebSocket server binds to. Set to 0.0.0.0 to expose on all interfaces."
113
130
  },
114
131
  "enableDashboard": {
115
132
  "type": "boolean",
116
- "default": false
133
+ "default": false,
134
+ "description": "Serve the React dashboard at /xenon/ and the Socket.io event stream."
117
135
  },
118
136
  "bootedSimulators": {
119
137
  "type": "boolean",
120
- "default": false
138
+ "default": false,
139
+ "description": "Only discover iOS simulators that are already booted. Recommended on machines with many installed simulators — avoids allocating WDA/MJPEG ports for shutdown sims (the WDA pool is 8100-8199, 100 ports)."
121
140
  },
122
141
  "bootedEmulators": {
123
142
  "type": "boolean",
124
- "default": false
143
+ "default": false,
144
+ "description": "Only discover Android emulators that are already booted."
125
145
  },
126
146
  "removeDevicesFromDatabaseBeforeRunningThePlugin": {
127
147
  "type": "boolean",
128
- "default": false
148
+ "default": false,
149
+ "description": "Wipe the persisted Device table at startup so discovery begins from a clean slate. Useful after hardware changes."
129
150
  },
130
151
  "healthCheckIntervalMs": {
131
152
  "type": "number",
132
- "default": 86400000
153
+ "default": 86400000,
154
+ "description": "Default interval (ms) between background device health checks. Overridden when `healthCheckSchedule` is set."
133
155
  },
134
156
  "healthCheckSchedule": {
135
- "type": "string"
157
+ "type": "string",
158
+ "description": "Cron expression for the device health-check job (e.g. '0 * * * *' for hourly). When set, takes precedence over `healthCheckIntervalMs`."
136
159
  },
137
160
  "databaseProvider": {
138
161
  "type": "string",
139
162
  "enum": [
140
163
  "sqlite",
141
164
  "postgresql"
142
- ]
165
+ ],
166
+ "description": "Database backend. Defaults to sqlite (file under ~/.cache/xenon). Use postgresql for multi-node hub deployments."
143
167
  },
144
168
  "databaseUrl": {
145
- "type": "string"
169
+ "type": "string",
170
+ "description": "Prisma-style database URL. For sqlite: `file:/path/to/xenon.db`. For postgres: `postgresql://user:pass@host/db`. Falls back to the DATABASE_URL env var."
146
171
  },
147
172
  "aiProvider": {
148
173
  "type": "string",
@@ -151,55 +176,77 @@
151
176
  "openai",
152
177
  "anthropic",
153
178
  "ollama"
154
- ]
179
+ ],
180
+ "description": "AI provider for the LLM healing tier and visual analysis. Also controlled by XENON_AI_PROVIDER."
155
181
  },
156
182
  "aiModel": {
157
- "type": "string"
183
+ "type": "string",
184
+ "description": "Override the default model for the selected `aiProvider` (e.g. 'gemini-1.5-pro', 'gpt-4o', 'claude-sonnet-4-6'). Falls back to XENON_AI_MODEL."
158
185
  },
159
186
  "aiBaseUrl": {
160
- "type": "string"
187
+ "type": "string",
188
+ "description": "Custom base URL for the AI provider (e.g. a local Ollama server or an OpenAI-compatible gateway). Falls back to XENON_AI_BASE_URL."
161
189
  },
162
190
  "geminiApiKey": {
163
- "type": "string"
191
+ "type": "string",
192
+ "description": "Gemini API key. Prefer setting XENON_GEMINI_API_KEY (or GEMINI_API_KEY) via environment instead of committing it to a config file."
164
193
  },
165
194
  "openaiApiKey": {
166
- "type": "string"
195
+ "type": "string",
196
+ "description": "OpenAI API key. Prefer setting XENON_OPENAI_API_KEY (or OPENAI_API_KEY) via environment."
167
197
  },
168
198
  "anthropicApiKey": {
169
- "type": "string"
199
+ "type": "string",
200
+ "description": "Anthropic API key. Prefer setting XENON_ANTHROPIC_API_KEY (or ANTHROPIC_API_KEY) via environment."
170
201
  },
171
202
  "enableSelfHealing": {
172
203
  "type": "boolean",
173
- "default": true
204
+ "default": true,
205
+ "description": "Enable the 5-tier self-healing pipeline (Native → Fuzzy XML → OCR → Visual AI → LLM) for failed findElement calls. Can also be toggled at runtime from the dashboard."
174
206
  },
175
207
  "buildCleanupDays": {
176
208
  "type": "number",
177
- "default": 30
209
+ "default": 30,
210
+ "description": "Builds/sessions older than this many days are purged by the cleanup job."
178
211
  },
179
212
  "buildCleanupMaxCount": {
180
213
  "type": "number",
181
- "default": 100
214
+ "default": 100,
215
+ "description": "Maximum number of builds to retain. Oldest-first eviction beyond this cap regardless of `buildCleanupDays`."
182
216
  },
183
217
  "buildCleanupSchedule": {
184
218
  "type": "string",
185
- "default": "0 0 * * *"
219
+ "default": "0 0 * * *",
220
+ "description": "Cron expression for the retention job. Default '0 0 * * *' runs at midnight."
186
221
  },
187
222
  "deleteBuildAssets": {
188
223
  "type": "boolean",
189
- "default": true
224
+ "default": true,
225
+ "description": "When true, the cleanup job also deletes session video recordings and screenshots from disk (not just DB rows)."
190
226
  },
191
227
  "sessionHeartbeatIntervalMs": {
192
228
  "type": "number",
193
- "default": 30000
229
+ "default": 30000,
230
+ "description": "How often (ms) each active session writes a heartbeat. The orphan sweeper uses ~3× this interval to detect abandoned sessions."
194
231
  },
195
232
  "enableJsonLogging": {
196
233
  "type": "boolean",
197
- "default": false
234
+ "default": false,
235
+ "description": "Emit structured JSON log lines instead of human-readable text. Recommended for shipping logs to a log aggregator."
198
236
  },
199
237
  "tlsRejectUnauthorized": {
200
238
  "type": "boolean",
201
239
  "default": true,
202
240
  "description": "Whether to verify TLS certificates for internal outgoing requests. Default is true. Set to false only for dev/test."
241
+ },
242
+ "authDisabled": {
243
+ "type": "boolean",
244
+ "default": false,
245
+ "description": "Disable API key authentication for all /xenon/api/* endpoints. Use only in local dev environments."
246
+ },
247
+ "nodeSecret": {
248
+ "type": "string",
249
+ "description": "Shared secret for hub-node channel authentication. Nodes must send this value in X-Xenon-Node-Secret header."
203
250
  }
204
251
  },
205
252
  "required": [
@@ -60,16 +60,31 @@ const logger_1 = __importDefault(require("./logger"));
60
60
  * - Correlation ID tracking
61
61
  */
62
62
  class InternalHttpClient {
63
- constructor(tlsRejectUnauthorized) {
63
+ constructor(tlsRejectUnauthorized, timeoutMs) {
64
64
  this.axiosInstance = axios_1.default.create({
65
65
  httpAgent: this.getHttpAgent(),
66
66
  httpsAgent: this.getHttpsAgent(tlsRejectUnauthorized),
67
- timeout: 30000,
67
+ timeout: InternalHttpClient.resolveTimeoutMs(timeoutMs),
68
68
  maxContentLength: Infinity,
69
69
  maxBodyLength: Infinity,
70
70
  });
71
71
  this.setupInterceptors();
72
72
  }
73
+ // Ops can shorten the global default via env var when a slow node is
74
+ // stalling session creation. Per-call override is still available via
75
+ // AxiosRequestConfig.timeout on individual post/get/etc calls.
76
+ static resolveTimeoutMs(explicit) {
77
+ if (typeof explicit === 'number' && explicit > 0)
78
+ return explicit;
79
+ const raw = process.env.XENON_HTTP_TIMEOUT_MS;
80
+ if (raw) {
81
+ const parsed = Number(raw);
82
+ if (Number.isFinite(parsed) && parsed > 0)
83
+ return parsed;
84
+ logger_1.default.warn(`[HTTP] Ignoring invalid XENON_HTTP_TIMEOUT_MS=${raw}, using 30000ms default`);
85
+ }
86
+ return 30000;
87
+ }
73
88
  getHttpAgent() {
74
89
  return new http_1.default.Agent({
75
90
  keepAlive: true,
@@ -151,30 +166,55 @@ class InternalHttpClient {
151
166
  catch (e) {
152
167
  /* ignore */
153
168
  }
154
- if (!config || config.retryCount === undefined) {
169
+ if (!config) {
170
+ return Promise.reject(error);
171
+ }
172
+ if (config.retryCount === undefined) {
155
173
  config.retryCount = 0;
156
174
  }
157
- const maxRetries = 2;
158
175
  const status = response === null || response === void 0 ? void 0 : response.status;
159
176
  // Log failed request
160
177
  if (!(config === null || config === void 0 ? void 0 : config.silent)) {
161
178
  logger_1.default.warn(`[HTTP ←] ${(_c = config === null || config === void 0 ? void 0 : config.method) === null || _c === void 0 ? void 0 : _c.toUpperCase()} ${config === null || config === void 0 ? void 0 : config.url} ` +
162
179
  `[${status || 'ERR'}] ${duration}ms - ${error.message}`);
163
180
  }
164
- // Don't retry client errors (4xx) except for occasional 429
165
- if (status && status < 500 && status !== 429) {
181
+ const decision = InternalHttpClient.classifyRetry(error);
182
+ if (!decision.retryable || config.retryCount >= InternalHttpClient.MAX_RETRIES) {
166
183
  return Promise.reject(error);
167
184
  }
168
- if (config.retryCount < maxRetries) {
169
- config.retryCount += 1;
170
- const backoff = config.retryCount * 1000;
171
- logger_1.default.warn(`[HTTP ↻] Retrying ${config.url} in ${backoff}ms (Attempt ${config.retryCount}/${maxRetries})...`);
172
- yield new Promise((resolve) => setTimeout(resolve, backoff));
173
- return this.axiosInstance(config);
174
- }
175
- return Promise.reject(error);
185
+ config.retryCount += 1;
186
+ const backoff = InternalHttpClient.computeBackoff(config.retryCount);
187
+ logger_1.default.warn(`[HTTP ↻] Retrying ${config.url} in ${backoff}ms (${decision.reason}, attempt ${config.retryCount}/${InternalHttpClient.MAX_RETRIES})`);
188
+ yield new Promise((resolve) => setTimeout(resolve, backoff));
189
+ return this.axiosInstance(config);
176
190
  }));
177
191
  }
192
+ static classifyRetry(error) {
193
+ const response = error.response;
194
+ if (response) {
195
+ const s = response.status;
196
+ if (s >= 500)
197
+ return { retryable: true, reason: `HTTP ${s}` };
198
+ if (s === 429)
199
+ return { retryable: true, reason: 'rate limited (429)' };
200
+ return { retryable: false, reason: `HTTP ${s}` };
201
+ }
202
+ // No response — network/transport error
203
+ const code = error.code || '';
204
+ if (InternalHttpClient.RETRYABLE_NETWORK_CODES.has(code)) {
205
+ return { retryable: true, reason: `network: ${code}` };
206
+ }
207
+ if (/timeout/i.test(error.message)) {
208
+ return { retryable: true, reason: 'request timeout' };
209
+ }
210
+ return { retryable: false, reason: code || error.message };
211
+ }
212
+ static computeBackoff(attempt) {
213
+ const base = Math.min(InternalHttpClient.BASE_RETRY_MS * Math.pow(2, attempt - 1), InternalHttpClient.MAX_RETRY_MS);
214
+ // 0-25% jitter to avoid thundering-herd on a recovering node
215
+ const jitter = Math.random() * base * 0.25;
216
+ return Math.floor(base + jitter);
217
+ }
178
218
  static getClient(tlsRejectUnauthorized) {
179
219
  if (tlsRejectUnauthorized !== undefined) {
180
220
  return new InternalHttpClient(tlsRejectUnauthorized).axiosInstance;
@@ -210,3 +250,18 @@ class InternalHttpClient {
210
250
  }
211
251
  }
212
252
  exports.InternalHttpClient = InternalHttpClient;
253
+ // Transient network failures worth retrying. Stalled/restarting node ports
254
+ // commonly surface as these codes; the node is usually back within seconds.
255
+ InternalHttpClient.RETRYABLE_NETWORK_CODES = new Set([
256
+ 'ECONNRESET',
257
+ 'ECONNREFUSED',
258
+ 'ETIMEDOUT',
259
+ 'ENOTFOUND',
260
+ 'EAI_AGAIN',
261
+ 'EPIPE',
262
+ 'ECONNABORTED',
263
+ 'ERR_NETWORK',
264
+ ]);
265
+ InternalHttpClient.MAX_RETRIES = 3;
266
+ InternalHttpClient.BASE_RETRY_MS = 500;
267
+ InternalHttpClient.MAX_RETRY_MS = 4000;
@@ -53,9 +53,11 @@ const package_json_1 = __importDefault(require("../../package.json"));
53
53
  const pluginArgs_1 = require("../data-service/pluginArgs");
54
54
  const cors_1 = __importDefault(require("cors"));
55
55
  const async_lock_1 = __importDefault(require("async-lock"));
56
+ const crypto_1 = __importDefault(require("crypto"));
56
57
  const InternalHttpClient_1 = require("../InternalHttpClient");
57
58
  const config_1 = require("../config");
58
- const logger_1 = __importStar(require("../logger"));
59
+ const logger_1 = __importDefault(require("../logger"));
60
+ const sessionContext_1 = require("../logging/sessionContext");
59
61
  const dashboard_1 = __importDefault(require("./routers/dashboard"));
60
62
  const grid_1 = __importDefault(require("./routers/grid"));
61
63
  const control_1 = __importDefault(require("./routers/control"));
@@ -63,14 +65,40 @@ const apps_1 = __importDefault(require("./routers/apps"));
63
65
  const webhook_1 = __importDefault(require("./routers/webhook"));
64
66
  const reservation_1 = __importDefault(require("./routers/reservation"));
65
67
  const config_2 = __importDefault(require("./routers/config"));
68
+ const apikeys_1 = require("./routers/apikeys");
69
+ const auth_1 = require("./routers/auth");
70
+ const processes_1 = require("./routers/processes");
71
+ const apiKeyMiddleware_1 = require("../middleware/apiKeyMiddleware");
72
+ const rateLimitMiddleware_1 = require("../middleware/rateLimitMiddleware");
73
+ const nodeSecretMiddleware_1 = require("../middleware/nodeSecretMiddleware");
74
+ const csrfMiddleware_1 = require("../middleware/csrfMiddleware");
66
75
  const swagger_1 = require("./swagger");
67
76
  const typedi_1 = require("typedi");
68
77
  const dashboardPluginUrl = null;
69
78
  const ASYNC_LOCK = new async_lock_1.default();
70
79
  const router = express_1.default.Router(), apiRouter = express_1.default.Router(), staticFilesRouter = express_1.default.Router();
71
- router.use((0, cors_1.default)());
72
- apiRouter.use((0, cors_1.default)());
80
+ // API is same-origin with the dashboard; block cross-origin browser callers.
81
+ // Non-browser clients (curl, CLI, SDKs) send no Origin header and are unaffected.
82
+ // Parent `router` has no cors() — if it did, its wildcard would leak through
83
+ // to apiRouter responses because cors({origin:false}) below doesn't strip
84
+ // headers set earlier in the chain. Static files keep permissive cors() so
85
+ // the dashboard bundle can be embedded from anywhere if needed.
86
+ apiRouter.use((0, cors_1.default)({ origin: false }));
73
87
  staticFilesRouter.use((0, cors_1.default)());
88
+ // Tag every API request with an AsyncLocalStorage frame so downstream logs
89
+ // (handlers, DB calls, outbound HTTP from InternalHttpClient) can attribute
90
+ // themselves to this request/session without plumbing IDs through every call.
91
+ // Regex picks up sessionId from /session/<id>/... paths so the context is
92
+ // populated before the individual handler runs.
93
+ const SESSION_ID_PATH_RE = /\/session\/([a-zA-Z0-9_-]{8,})(?:\/|$)/;
94
+ apiRouter.use((req, res, next) => {
95
+ const inboundId = req.headers['x-request-id'] || '';
96
+ const requestId = inboundId || crypto_1.default.randomUUID();
97
+ const match = SESSION_ID_PATH_RE.exec(req.path);
98
+ const sessionId = match ? match[1] : undefined;
99
+ res.setHeader('X-Request-Id', requestId);
100
+ sessionContext_1.sessionContext.run({ requestId, sessionId }, () => next());
101
+ });
74
102
  apiRouter.use((req, res, next) => {
75
103
  // Defensive Body Parsing Logic:
76
104
  // In some Appium versions, the global HTTP logger or other middleware drains the request stream
@@ -99,39 +127,39 @@ apiRouter.use((req, res, next) => {
99
127
  res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
100
128
  res.setHeader('Pragma', 'no-cache');
101
129
  res.setHeader('Expires', '0');
102
- // Redact secrets from request body to prevent them from leaking into external logs
103
- if (req.body && typeof req.body === 'object') {
104
- req.body = (0, logger_1.redactSecrets)(req.body);
105
- }
106
130
  next();
107
131
  });
108
- // Dashboard state cache - runs once and persists
132
+ // Dashboard state cache - runs once and persists on success.
133
+ // On failure we back off exponentially (1s → 2s → 4s → … capped at 30s)
134
+ // so a down dashboard doesn't turn every API request into a fresh ping.
109
135
  let dashboardPluginPromise = null;
136
+ let dashboardNextRetryAt = 0;
137
+ let dashboardRetryDelayMs = 1000;
138
+ const DASHBOARD_RETRY_MAX_MS = 30000;
110
139
  apiRouter.use((req, res, next) => __awaiter(void 0, void 0, void 0, function* () {
111
- if (dashboardPluginPromise === null) {
140
+ if (dashboardPluginPromise === null && Date.now() >= dashboardNextRetryAt) {
112
141
  dashboardPluginPromise = (() => __awaiter(void 0, void 0, void 0, function* () {
113
142
  const pingurl = `${req.protocol}://${req.get('host')}/dashboard/api/ping`;
114
143
  try {
115
144
  const response = yield InternalHttpClient_1.InternalHttpClient.get(pingurl, { silent: true });
116
- return response['pong'] ? `${req.protocol}://${req.get('host')}/dashboard` : '';
145
+ if (response && response['pong']) {
146
+ dashboardRetryDelayMs = 1000;
147
+ dashboardNextRetryAt = 0;
148
+ return `${req.protocol}://${req.get('host')}/dashboard`;
149
+ }
117
150
  }
118
151
  catch (err) {
119
- return '';
152
+ logger_1.default.warn(`[Xenon] Dashboard ping failed, retrying in ${dashboardRetryDelayMs}ms: ${(err === null || err === void 0 ? void 0 : err.message) || err}`);
120
153
  }
154
+ dashboardNextRetryAt = Date.now() + dashboardRetryDelayMs;
155
+ dashboardRetryDelayMs = Math.min(dashboardRetryDelayMs * 2, DASHBOARD_RETRY_MAX_MS);
156
+ dashboardPluginPromise = null;
157
+ return '';
121
158
  }))();
122
159
  }
123
- req['dashboard-plugin-url'] = yield dashboardPluginPromise;
160
+ req['dashboard-plugin-url'] = dashboardPluginPromise ? yield dashboardPluginPromise : '';
124
161
  return next();
125
162
  }));
126
- apiRouter.get('/cliArgs', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
127
- res.json(yield (0, pluginArgs_1.getCLIArgs)());
128
- }));
129
- apiRouter.get('/metrics', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
130
- const { MetricsService } = yield Promise.resolve().then(() => __importStar(require('../services/MetricsService')));
131
- const metrics = yield typedi_1.Container.get(MetricsService).getMetrics();
132
- res.set('Content-Type', 'text/plain');
133
- res.send(metrics);
134
- }));
135
163
  const findPublicPath = () => {
136
164
  const rootDir = path_1.default.resolve(__dirname, '..', '..');
137
165
  const searchPaths = [
@@ -156,10 +184,21 @@ const findPublicPath = () => {
156
184
  const publicPath = findPublicPath();
157
185
  logger_1.default.info(`[Xenon] Dashboard assets path: ${publicPath}`);
158
186
  logger_1.default.info(`[Xenon] Dashboard available at: /xenon/ (e.g. http://localhost:4723/xenon/)`);
159
- // Principal Security: Add permissive CSP and CORS for the dashboard
187
+ // CSP for the dashboard. Keeps 'unsafe-inline' (React/Vite inline styles),
188
+ // drops 'unsafe-eval' and the default-src wildcard to avoid full XSS-to-RCE.
189
+ // Google Fonts (fonts.googleapis.com for CSS, fonts.gstatic.com for woff2)
190
+ // are explicitly allowlisted because the bundled stylesheets @import them.
160
191
  router.use((req, res, next) => {
161
- res.setHeader('Content-Security-Policy', "default-src * 'unsafe-inline' 'unsafe-eval' data: blob:; frame-ancestors 'self';");
162
- res.setHeader('Access-Control-Allow-Origin', '*');
192
+ res.setHeader('Content-Security-Policy', [
193
+ "default-src 'self'",
194
+ "script-src 'self' 'unsafe-inline'",
195
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
196
+ "img-src 'self' data: blob:",
197
+ "media-src 'self' blob:",
198
+ "connect-src 'self' ws: wss: http: https:",
199
+ "font-src 'self' data: https://fonts.gstatic.com",
200
+ "frame-ancestors 'self'",
201
+ ].join('; ') + ';');
163
202
  next();
164
203
  });
165
204
  staticFilesRouter.use(express_1.default.static(publicPath, { index: false }));
@@ -168,6 +207,35 @@ router.use('/api', apiRouter);
168
207
  router.use('/session-recordings', express_1.default.static(config_1.config.sessionAssetsPath));
169
208
  router.use(staticFilesRouter);
170
209
  function createRouter(pluginArgs) {
210
+ // CSRF defense (runs before /health so even a hostile GET->POST confused-
211
+ // deputy has no soft target). Pass-through for GETs, for header-authed
212
+ // callers, and when authDisabled=true. Returns 403 for cookie-authed
213
+ // POST/PUT/DELETE/PATCH whose Origin/Referer doesn't match our Host.
214
+ apiRouter.use(csrfMiddleware_1.csrfMiddleware);
215
+ // Health endpoint: no auth, no rate limit
216
+ apiRouter.get('/health', (_req, res) => res.json({ ok: true }));
217
+ // Hub-node channel: node-secret auth instead of API key
218
+ apiRouter.use(['/register', '/unblock'], (0, nodeSecretMiddleware_1.nodeSecretMiddleware)(pluginArgs.nodeSecret || process.env.XENON_NODE_SECRET));
219
+ // Dashboard login: unauthenticated (rate-limited internally via separate IP logic)
220
+ apiRouter.use('/auth', (0, auth_1.authRouter)());
221
+ // All remaining /api/* requires API key + rate limit
222
+ apiRouter.use(apiKeyMiddleware_1.apiKeyMiddleware);
223
+ apiRouter.use((0, rateLimitMiddleware_1.rateLimitMiddleware)());
224
+ // Admin: API key management
225
+ apiRouter.use('/apikeys', (0, apikeys_1.apiKeysRouter)());
226
+ // Admin: running process snapshot (ops debugging)
227
+ apiRouter.use('/processes', (0, processes_1.processesRouter)());
228
+ // Exposes plugin CLI args (may include host, hub URL, etc.) — auth-gated.
229
+ apiRouter.get('/cliArgs', (_req, res) => __awaiter(this, void 0, void 0, function* () {
230
+ res.json(yield (0, pluginArgs_1.getCLIArgs)());
231
+ }));
232
+ // Prometheus-style metrics — auth-gated to avoid operational recon.
233
+ apiRouter.get('/metrics', (_req, res) => __awaiter(this, void 0, void 0, function* () {
234
+ const { MetricsService } = yield Promise.resolve().then(() => __importStar(require('../services/MetricsService')));
235
+ const metrics = yield typedi_1.Container.get(MetricsService).getMetrics();
236
+ res.set('Content-Type', 'text/plain');
237
+ res.send(metrics);
238
+ }));
171
239
  dashboard_1.default.register(apiRouter);
172
240
  grid_1.default.register(apiRouter, pluginArgs);
173
241
  control_1.default.register(apiRouter);
@@ -0,0 +1,33 @@
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.apiKeysRouter = apiKeysRouter;
13
+ const express_1 = require("express");
14
+ const typedi_1 = require("typedi");
15
+ const ApiKeyService_1 = require("../../services/ApiKeyService");
16
+ const scopeGuard_1 = require("../../middleware/scopeGuard");
17
+ function apiKeysRouter() {
18
+ const r = (0, express_1.Router)();
19
+ const svc = typedi_1.Container.get(ApiKeyService_1.ApiKeyService);
20
+ r.post('/', (0, scopeGuard_1.scopeGuard)(['admin']), (req, res) => __awaiter(this, void 0, void 0, function* () {
21
+ const { name, scopes, rateLimit } = req.body;
22
+ if (!name || !Array.isArray(scopes) || scopes.length === 0) {
23
+ return res.status(400).json({ error: 'name and scopes required' });
24
+ }
25
+ const { id, raw } = yield svc.create({ name, scopes, rateLimit });
26
+ res.json({ id, key: raw });
27
+ }));
28
+ r.delete('/:id', (0, scopeGuard_1.scopeGuard)(['admin']), (req, res) => __awaiter(this, void 0, void 0, function* () {
29
+ yield svc.revoke(req.params.id);
30
+ res.json({ ok: true });
31
+ }));
32
+ return r;
33
+ }
@@ -16,7 +16,11 @@ const express_1 = require("express");
16
16
  const app_service_1 = require("../../dashboard/services/app-service");
17
17
  const logger_1 = __importDefault(require("../../logger"));
18
18
  const fs_extra_1 = __importDefault(require("fs-extra"));
19
+ const scopeGuard_1 = require("../../middleware/scopeGuard");
19
20
  const router = (0, express_1.Router)();
21
+ // Uploading an APK/IPA or deleting one from the fleet requires devices scope.
22
+ // App listings stay readable to any authenticated key.
23
+ router.use((0, scopeGuard_1.mutationScopeGuard)(['devices']));
20
24
  router.get('/', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
21
25
  try {
22
26
  const apps = yield app_service_1.APP_SERVICE.getApps();
@@ -0,0 +1,36 @@
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.authRouter = authRouter;
13
+ const express_1 = require("express");
14
+ const typedi_1 = require("typedi");
15
+ const ApiKeyService_1 = require("../../services/ApiKeyService");
16
+ function authRouter() {
17
+ const r = (0, express_1.Router)();
18
+ const svc = typedi_1.Container.get(ApiKeyService_1.ApiKeyService);
19
+ r.post('/dashboard-session', (req, res) => __awaiter(this, void 0, void 0, function* () {
20
+ const { apiKey } = req.body;
21
+ if (!apiKey)
22
+ return res.status(400).json({ error: 'apiKey required' });
23
+ const row = yield svc.verify(apiKey);
24
+ if (!row)
25
+ return res.status(401).json({ error: 'invalid key' });
26
+ const isSecure = req.secure || req.headers['x-forwarded-proto'] === 'https';
27
+ res.cookie('xenon_dashboard_session', apiKey, {
28
+ httpOnly: true,
29
+ secure: isSecure,
30
+ sameSite: 'strict',
31
+ maxAge: 24 * 60 * 60 * 1000,
32
+ });
33
+ res.json({ ok: true, scopes: row.scopes });
34
+ }));
35
+ return r;
36
+ }