@xenon-device-management/xenon 2.12.0 → 2.13.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 (140) hide show
  1. package/lib/package.json +2 -2
  2. package/lib/public/assets/{AnnotationOverlay-Crkn72_3.js → AnnotationOverlay-CzbFYtPI.js} +1 -1
  3. package/lib/public/assets/{ApiKeyGate-BThVlj_j.js → ApiKeyGate-CA5KFHHJ.js} +1 -1
  4. package/lib/public/assets/{BugReportButton-Dwa0GnBh.js → BugReportButton-D-DSGg8C.js} +1 -1
  5. package/lib/public/assets/{DeviceMosaicView-Ccc-pOls.js → DeviceMosaicView-paDEFU4B.js} +1 -1
  6. package/lib/public/assets/{EmptyState-BeuTrlN_.js → EmptyState-BE_5f8Vw.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-DBIaNxl1.js → FieldGroup-DqMf7Rf3.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-BgSAS6iT.js → FilterMenu-BQY75Su3.js} +1 -1
  9. package/lib/public/assets/{Menu-GCbP6gYN.js → Menu-BiW3os9u.js} +1 -1
  10. package/lib/public/assets/{Modal-ByESeBRC.js → Modal-DfawTxih.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-Bwyf3WMl.js → RecordingPage-BvemyRiy.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-7kM1rB3i.js → RecordingsPage-CXfw7wpd.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-B8tTU8kD.js → SegmentedControl-B7m9kpom.js} +1 -1
  14. package/lib/public/assets/{SettingCard-Clg_Ijbc.js → SettingCard-Cw6lDD0l.js} +1 -1
  15. package/lib/public/assets/{Table-CIaR0QWV.js → Table-DlEuFgv-.js} +1 -1
  16. package/lib/public/assets/{activity-Bengh9SD.js → activity-CnLvNwae.js} +1 -1
  17. package/lib/public/assets/{ai-settings-G9SymiPH.js → ai-settings-D7Te8o-a.js} +1 -1
  18. package/lib/public/assets/{api-keys-CrGlbfn-.js → api-keys-DphSVzBr.js} +1 -1
  19. package/lib/public/assets/{apps-DIbcGVlU.js → apps-vUfu15lX.js} +1 -1
  20. package/lib/public/assets/{arrow-left-BtJIXXMY.js → arrow-left-CLQWAiCY.js} +1 -1
  21. package/lib/public/assets/{arrow-right-zm-NagQh.js → arrow-right-K6A5e_Ee.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-DdU8YEYp.js → arrow-up-right-DUA_lq6f.js} +1 -1
  23. package/lib/public/assets/{auth-shell-CHmBFmOz.js → auth-shell-BYRHcZd6.js} +2 -2
  24. package/lib/public/assets/{builds-page-JkzWKRpU.js → builds-page-C0fTvIG8.js} +1 -1
  25. package/lib/public/assets/{button-CqhPBGRj.js → button-BxnHCCsX.js} +1 -1
  26. package/lib/public/assets/{calendar-C7eYYJTo.js → calendar-CqVsn28A.js} +1 -1
  27. package/lib/public/assets/{check-DpbIM4E0.js → check-R6V3bot0.js} +1 -1
  28. package/lib/public/assets/{chevron-right-Dl4X1PRz.js → chevron-right-BA2Fntu-.js} +1 -1
  29. package/lib/public/assets/{circle-check-Du3sjbfV.js → circle-check-D4EzyGzs.js} +1 -1
  30. package/lib/public/assets/{circle-x-BSSqhpXi.js → circle-x-Cf6AF2B2.js} +1 -1
  31. package/lib/public/assets/{clock-DMT60v1C.js → clock-EZfet0sS.js} +1 -1
  32. package/lib/public/assets/{copy-BKNyOehd.js → copy-Bc6P8v9V.js} +1 -1
  33. package/lib/public/assets/{device-explorer-CoPR8DW3.js → device-explorer-BFXpRq6d.js} +1 -1
  34. package/lib/public/assets/{download-BM6Xn22t.js → download-Bd8OGLzi.js} +1 -1
  35. package/lib/public/assets/{forgot-password-B8WqMqBT.js → forgot-password-CoE7Ugtx.js} +1 -1
  36. package/lib/public/assets/{index-DauQh6ie.js → index-BiL0Enl_.js} +1 -1
  37. package/lib/public/assets/{index-ClrpAMAT.js → index-CHOr4JCs.js} +2 -2
  38. package/lib/public/assets/{input-CyKdLnEx.js → input-CJ8z3IO5.js} +1 -1
  39. package/lib/public/assets/{line-chart-EIBXwYGo.js → line-chart-CPK0ObjV.js} +1 -1
  40. package/lib/public/assets/{list-checks-fWsgD9bI.js → list-checks-mXGHUVbB.js} +1 -1
  41. package/lib/public/assets/{lock-CVCe56TH.js → lock-BSXM1xvq.js} +1 -1
  42. package/lib/public/assets/{login-BJ8a7yVD.js → login-Ct2iW066.js} +1 -1
  43. package/lib/public/assets/{maintenance-settings-DcRmTLS6.js → maintenance-settings-DaOs00P6.js} +1 -1
  44. package/lib/public/assets/{monitor-Bw1YQZnL.js → monitor-D8WQK8md.js} +1 -1
  45. package/lib/public/assets/{mouse-pointer-2-C01jbqkO.js → mouse-pointer-2-Cw2tWzDX.js} +1 -1
  46. package/lib/public/assets/{network-CBGUjJDJ.js → network-Ba_uFxWo.js} +1 -1
  47. package/lib/public/assets/{overview-Cxe8aQ7C.js → overview-CxrZ7_6k.js} +1 -1
  48. package/lib/public/assets/{page-header-B92DKLiq.js → page-header-Cm1XL49f.js} +1 -1
  49. package/lib/public/assets/{play-Ck0L-0_m.js → play-C0CuKLXj.js} +1 -1
  50. package/lib/public/assets/{plus-Dq3tCy2N.js → plus-CIIvR_6Z.js} +1 -1
  51. package/lib/public/assets/{profile-page--dkDiKbr.js → profile-page-BZ_e-Bgw.js} +1 -1
  52. package/lib/public/assets/{recording-group-store-5BYIFFN9.js → recording-group-store-BWeeNs2_.js} +1 -1
  53. package/lib/public/assets/{reset-password-KYWlfjia.js → reset-password-lfNz8Rm1.js} +1 -1
  54. package/lib/public/assets/{runbook-page-mnsxgS1d.js → runbook-page-Cnec3VSx.js} +1 -1
  55. package/lib/public/assets/{select-BBOIZTYm.js → select-CZuCnold.js} +1 -1
  56. package/lib/public/assets/{selector-detail-redirect-CcI2j_Su.js → selector-detail-redirect-BG3pxFLw.js} +1 -1
  57. package/lib/public/assets/{selector-health-page-BdJb_x5L.js → selector-health-page-DsyL58tb.js} +1 -1
  58. package/lib/public/assets/{session-detail-page-BjLaJy8z.js → session-detail-page-ZZyGM413.js} +1 -1
  59. package/lib/public/assets/{settings-B6cpMhsx.js → settings-DGZ7B_AR.js} +1 -1
  60. package/lib/public/assets/{stat-tile-BU9e4s36.js → stat-tile-DFhv8JoI.js} +1 -1
  61. package/lib/public/assets/{tablet-hgbEwrWq.js → tablet-68J5f6zh.js} +1 -1
  62. package/lib/public/assets/{teams-uiiG4hZM.js → teams-eHSyEpbD.js} +1 -1
  63. package/lib/public/assets/{trash-2-NK_Iazmg.js → trash-2-CcSsv4fS.js} +1 -1
  64. package/lib/public/assets/{upload-BUX8TNFi.js → upload-BYf26K71.js} +1 -1
  65. package/lib/public/assets/{use-builds-data-DZszmFyl.js → use-builds-data-CHW-MhaP.js} +1 -1
  66. package/lib/public/assets/{use-password-reset-mode-BK4B9kRC.js → use-password-reset-mode-DozShXkk.js} +1 -1
  67. package/lib/public/assets/{users-BU4XBbMT.js → users-BpYnpOl2.js} +1 -1
  68. package/lib/public/assets/{users-BDh1xjad.js → users-CcSQRoK9.js} +1 -1
  69. package/lib/public/assets/{video-off-BOjNwT4R.js → video-off-dFKgps73.js} +1 -1
  70. package/lib/public/assets/{webhook-settings-BMzyUcx5.js → webhook-settings-DD1XjiOD.js} +1 -1
  71. package/lib/public/assets/{zap-DTWwVMUg.js → zap-COC2tZaH.js} +1 -1
  72. package/lib/public/index.html +1 -1
  73. package/lib/src/app/apiErrors.js +118 -0
  74. package/lib/src/app/index.js +6 -1
  75. package/lib/src/app/openapi/control.yaml +3129 -0
  76. package/lib/src/app/openapi/grid.yaml +2295 -0
  77. package/lib/src/app/openapi/identity.yaml +2168 -0
  78. package/lib/src/app/openapi/platform.yaml +2885 -0
  79. package/lib/src/app/openapi/sessions.yaml +3784 -0
  80. package/lib/src/app/routers/bug-report.js +4 -1
  81. package/lib/src/app/routers/config.js +6 -107
  82. package/lib/src/app/routers/control.js +117 -59
  83. package/lib/src/app/routers/dashboard.js +13 -7
  84. package/lib/src/app/routers/grid.js +54 -14
  85. package/lib/src/app/routers/profile.js +27 -13
  86. package/lib/src/app/routers/recordings.js +11 -5
  87. package/lib/src/app/routers/reservation.js +63 -15
  88. package/lib/src/app/routers/users.js +4 -0
  89. package/lib/src/app/routers/webhook.js +17 -8
  90. package/lib/src/app/swagger.js +259 -177
  91. package/lib/src/data-service/device-service.js +4 -1
  92. package/lib/src/data-service/deviceFieldOwners.js +1 -0
  93. package/lib/src/device-managers/AndroidDeviceManager.js +5 -2
  94. package/lib/src/device-managers/ios/WDAClient.js +32 -32
  95. package/lib/src/generated/client/edge.js +4 -3
  96. package/lib/src/generated/client/index-browser.js +1 -0
  97. package/lib/src/generated/client/index.d.ts +38 -0
  98. package/lib/src/generated/client/index.js +4 -3
  99. package/lib/src/generated/client/package.json +1 -1
  100. package/lib/src/generated/client/schema.prisma +2 -0
  101. package/lib/src/generated/client/wasm.js +1 -0
  102. package/lib/src/middleware/csrfMiddleware.js +13 -5
  103. package/lib/src/middleware/rateLimitMiddleware.js +19 -5
  104. package/lib/src/middleware/roleGuard.js +20 -0
  105. package/lib/src/services/AIService.js +13 -3
  106. package/lib/src/services/NotificationService.js +28 -28
  107. package/lib/src/services/bug-report/BugReportService.js +8 -2
  108. package/lib/src/services/lease/LeaseService.js +71 -20
  109. package/lib/src/services/omni-vision/OmniVisionService.js +14 -5
  110. package/lib/src/services/recording/RecordingOrchestrator.js +16 -2
  111. package/lib/test/helpers/expressRoutes.js +41 -0
  112. package/lib/test/integration/team-visibility-control.spec.js +2 -2
  113. package/lib/test/unit/access-scopes.spec.js +227 -0
  114. package/lib/test/unit/api-error-handling.spec.js +179 -0
  115. package/lib/test/unit/bug-report/route.spec.js +29 -0
  116. package/lib/test/unit/bug-report/service.spec.js +30 -0
  117. package/lib/test/unit/control-honest-answers.spec.js +134 -0
  118. package/lib/test/unit/device-allocation-routes.spec.js +232 -0
  119. package/lib/test/unit/healing-state-endpoints.spec.js +8 -5
  120. package/lib/test/unit/install-repository-app-team.spec.js +4 -1
  121. package/lib/test/unit/lease/LeaseService.spec.js +5 -4
  122. package/lib/test/unit/lease/lease-device-match.spec.js +161 -0
  123. package/lib/test/unit/lease/lease-session-ownership.spec.js +10 -9
  124. package/lib/test/unit/omni-vision-failures.spec.js +81 -0
  125. package/lib/test/unit/openapi-coverage.spec.js +114 -0
  126. package/lib/test/unit/profile-router.test.js +42 -0
  127. package/lib/test/unit/rateLimitMiddleware.test.js +49 -0
  128. package/lib/test/unit/recording-orchestrator.spec.js +80 -0
  129. package/lib/test/unit/recordings-library-routes.spec.js +37 -0
  130. package/lib/test/unit/reservation-team-visibility.spec.js +4 -3
  131. package/lib/test/unit/reset-link.test.js +2 -1
  132. package/lib/test/unit/stream-ticket-identity.spec.js +1 -1
  133. package/lib/test/unit/users-router.test.js +14 -1
  134. package/lib/test/unit/wda-client-failures.spec.js +90 -0
  135. package/lib/test/unit/webhook-delivery.spec.js +142 -0
  136. package/lib/tsconfig.tsbuildinfo +1 -1
  137. package/package.json +2 -2
  138. package/prisma/migrations/20261004120000_reservation_holder/migration.sql +2 -0
  139. package/prisma/schema.prisma +2 -0
  140. package/lib/src/app/swagger-docs.js +0 -1701
@@ -7,97 +7,220 @@ exports.swaggerSpec = void 0;
7
7
  exports.setupSwagger = setupSwagger;
8
8
  const swagger_jsdoc_1 = __importDefault(require("swagger-jsdoc"));
9
9
  const swagger_ui_express_1 = __importDefault(require("swagger-ui-express"));
10
+ const package_json_1 = __importDefault(require("../../package.json"));
11
+ /**
12
+ * The API reference served at /xenon/api-docs (and as JSON at
13
+ * /xenon/api-docs.json). The shared parts live here: the introduction,
14
+ * authentication, the error and rate-limit responses, and the tags. Each
15
+ * area's paths live in src/app/openapi/<area>.yaml.
16
+ *
17
+ * test/unit/openapi-coverage.spec.ts keeps it whole: every route the server
18
+ * serves must be documented, every documented route must be served, and the
19
+ * result must be valid OpenAPI 3.
20
+ */
21
+ const errorExample = (error, message) => ({
22
+ 'application/json': {
23
+ schema: { $ref: '#/components/schemas/Error' },
24
+ example: message ? { error, message } : { error },
25
+ },
26
+ });
10
27
  const swaggerDefinition = {
11
- openapi: '3.0.0',
12
- servers: [
13
- {
14
- url: '/xenon',
15
- description: 'Local Grid Node Registry',
16
- },
17
- ],
28
+ openapi: '3.0.3',
29
+ servers: [{ url: '/xenon', description: 'This Xenon server' }],
18
30
  info: {
19
31
  title: 'Xenon API',
20
- version: '1.0.0',
32
+ version: package_json_1.default.version,
21
33
  description: `
22
- # Xenon Edge • Intelligent Infrastructure API
23
- **The foundation for autonomous mobile device orchestration.**
34
+ The REST API of a Xenon server: the devices in your lab, the Appium sessions that run on them, live device control, recordings, selector health and administration. The dashboard uses this same API.
35
+
36
+ This reference describes version **${package_json_1.default.version}**. Every path below is relative to \`/xenon\`, so \`/api/devices\` is \`https://<your-xenon-host>/xenon/api/devices\`. The raw OpenAPI document is at [\`/xenon/api-docs.json\`](/xenon/api-docs.json).
37
+
38
+ ## Authentication
39
+
40
+ Every endpoint needs a credential, except the few marked as public (health checks, sign-in, password reset and the JWKS). The four ways to authenticate are:
41
+
42
+ | Credential | How to send it | Typical use |
43
+ |---|---|---|
44
+ | **Access key and API token** | \`x-xenon-access-key\` and \`x-xenon-token\` headers | Scripts, CI, SDKs, a node reporting to its hub |
45
+ | **Bearer token** | \`Authorization: Bearer <jwt>\` | Short-lived access; mint one with \`POST /api/auth/token\` (audience \`xenon-rest\`, 1 hour) |
46
+ | **Dashboard session** | \`xenon_dashboard_session\` cookie, set by \`POST /api/auth/login\` | The browser dashboard |
47
+ | **Hub token** | \`x-xenon-hub-token\` header | Hub to node only; Xenon sends it itself |
48
+
49
+ Find your access key and create API tokens on your profile page (\`/xenon/profile\`, **API tokens**).
50
+
51
+ **Scopes.** An API token carries scopes: \`read\`, \`sessions\`, \`devices\` and \`admin\`. \`admin\` satisfies any scope. A signed-in dashboard user gets the scopes of their role: members have \`devices\`, \`sessions\` and \`read\`; admins also have \`admin\`. A request without the scope it needs gets \`403\`.
52
+
53
+ **Roles.** Some endpoints also need a role: \`MEMBER\`, \`ADMIN\` or \`SUPER_ADMIN\`. Each says so in its description.
54
+
55
+ **Teams.** A member sees only their teams' devices and the shared pool, and the sessions, apps and selectors that go with them. Something outside your teams answers exactly as if it didn't exist: \`404\`.
56
+
57
+ **Same-origin check.** A state-changing request (POST, PUT, PATCH, DELETE) must carry an \`Origin\` or \`Referer\` header from the same host as the server, or it gets \`403\`. Requests sent with the access key and token headers, an \`Authorization: Bearer\` token, or a hub token are exempt: a browser can't send those headers from another site. So only a script that calls a public endpoint such as \`POST /api/auth/login\` needs to send an \`Origin\` header.
58
+
59
+ ## Errors
60
+
61
+ Errors are JSON with an \`error\` field. An unexpected server error is \`500\` with \`{ "error": "internal", "message": "Internal server error" }\`; the details go to the server's log. Newer endpoints put a stable, machine-readable code there (\`not_found\`, \`device_held_by_another_user\`) with a human-readable \`message\`. Older ones put the message itself in \`error\`, or \`true\` with a \`message\`. Branch on the HTTP status first.
62
+
63
+ ## Rate limits
24
64
 
25
- Xenon's high-performance API provides programmatic access to the core orchestration engine, enabling complex automation workflows for distributed device farms.
65
+ Every request with a credential is rate limited, except single-use tickets. An access key has its own budgets; a signed-in user's dashboard sessions and bearer tokens share one set of budgets per user (300 a minute). Each has three request budgets, refilled every minute:
66
+ - \`read\`: GET requests (ten times the budget for a signed-in user or a bearer token, since a dashboard page loads many images at once);
67
+ - \`heavy\`: AI, healing and visual endpoints (a quarter of the budget, at least 10);
68
+ - \`control\`: everything else.
26
69
 
27
- ### Key Capabilities:
28
- * **Elastic Device Grid**: Real-time discovery and state management of physical nodes.
29
- * **Autonomous Sessions**: Self-healing Appium session management with automated retries.
30
- * **Deep Control**: Direct low-latency interaction logic (Input injection, thermal monitoring).
31
- * **Security Protocol**: Industrial-grade isolation for sensitive test environments.
70
+ Each such response carries \`X-RateLimit-Category\`, \`X-RateLimit-Remaining\` and \`X-RateLimit-Capacity\`. Past the budget the answer is \`429\` with \`Retry-After\` in seconds.
32
71
 
33
- ---
34
- ### Authentication
35
- Every endpoint authenticates with the per-user \`(x-xenon-access-key, x-xenon-token)\` pair. Each token carries one or more scopes — \`read\`, \`sessions\`, \`devices\`, or \`admin\` (admin always satisfies any scope check). Mint tokens at \`/profile\` → **API Tokens**; the access key is shown at the top of that page and is rotatable. Browser dashboards may use the \`xenon_dashboard_session\` cookie set by \`POST /api/auth/login\`.
72
+ ## Appium sessions
36
73
 
37
- ### Appium sessions
38
- Appium's own \`POST /session\` is not under this API. A session authenticates with capabilities in \`xe:options\`, Xenon's capability namespace: \`accessKey\` + \`token\` (a token with the \`sessions\` scope), or a \`sessionToken\` minted by \`POST /api/auth/token\`. A leased device is named by \`leaseId\` + \`leaseToken\`, which the lease-create response's \`appiumCapabilities\` already carry. \`xenon:options\` is read as an alias (\`xe:options\` wins field by field); \`df:options\` is not read. Xenon removes the credentials before the driver or any stored record sees them.
74
+ Appium's WebDriver API (\`POST /session\` and the session's commands, under Appium's own base path) is not part of this reference. A session authenticates in its capabilities, under \`xe:options\`, with one of:
75
+ - \`accessKey\` and \`token\`;
76
+ - \`sessionToken\`, minted by \`POST /api/auth/token\`.
39
77
 
40
- ### Rate limiting
41
- Every authenticated response carries \`X-RateLimit-Limit\`, \`X-RateLimit-Remaining\`, and \`X-RateLimit-Reset\` headers. Requests beyond the configured budget receive \`429 Too Many Requests\`.
42
- `,
78
+ A leased device also takes \`leaseId\` and \`leaseToken\`, which the lease's \`appiumCapabilities\` already carry. Xenon removes these credentials before the driver or any stored record sees them.
79
+
80
+ ## Live streams
81
+
82
+ Device previews and live logs use WebSockets, outside this reference:
83
+ - \`/xenon/api/control/{udid}/stream/h264\`
84
+ - \`/xenon/api/control/{udid}/logcat\`
85
+
86
+ Both are opened with a ticket from \`POST /api/control/{udid}/stream/ticket\`. Socket.IO at \`/socket.io/\` carries the dashboard's live events.
87
+ `,
43
88
  contact: {
44
- name: 'Xenon Architecture Team',
45
- url: 'https://github.com/xenon-platform/xenon',
89
+ name: 'Xenon',
90
+ url: 'https://github.com/Rabindra184/xenon',
46
91
  },
47
92
  license: {
48
- name: 'ISC License',
93
+ name: 'ISC',
49
94
  url: 'https://opensource.org/licenses/ISC',
50
95
  },
51
96
  },
52
97
  tags: [
53
- { name: 'Health & Ops', description: 'Liveness, version, metrics, runtime args' },
54
- { name: 'Authentication', description: 'Browser dashboard login' },
55
- { name: 'Devices', description: 'Real-time grid discovery' },
56
- { name: 'Sessions', description: 'Orchestration & Logs' },
57
- { name: 'Builds', description: 'Telemetry tracking' },
58
- { name: 'Control', description: 'Direct interaction engine' },
59
- { name: 'Reservations', description: 'Resource locking' },
60
- { name: 'Applications', description: 'Artifact management' },
61
- { name: 'Grid', description: 'Cluster topology' },
62
- { name: 'Webhooks', description: 'Event propagation' },
63
- { name: 'Configuration', description: 'Edge node parameters' },
64
- { name: 'Selector Health', description: 'Heal-rate analytics & lifecycle' },
65
- { name: 'Network Interceptor', description: 'Per-session HTTP capture, mocks, HAR export' },
66
- { name: 'Admin', description: 'API keys, teams, process snapshots' },
67
- { name: 'Hub-Node', description: 'Hub↔node device registration channel' },
98
+ { name: 'Health & Ops', description: 'Liveness, version, metrics and server information.' },
99
+ { name: 'Authentication', description: 'Sign-in, sign-out, password reset and tokens.' },
100
+ { name: 'Profile', description: 'Your own account: API tokens and access key.' },
101
+ { name: 'Users', description: 'User accounts. Admin only.' },
102
+ { name: 'Teams', description: 'Teams, their members and the devices they own.' },
103
+ { name: 'API Keys', description: 'Every API key in the lab. Admin only.' },
104
+ { name: 'Projects', description: 'Projects that group work in the lab.' },
105
+ { name: 'Audit', description: 'Audit events sent in by Xenon services.' },
106
+ { name: 'Devices', description: 'The devices in the lab, and blocking them for maintenance.' },
107
+ {
108
+ name: 'Control',
109
+ description: 'Drive one device: input, screenshots, live preview, apps, logs and inspection.',
110
+ },
111
+ { name: 'Reservations', description: 'Reserving a device for a person for a while.' },
112
+ {
113
+ name: 'Leases',
114
+ description: 'Claiming devices from code (SDKs, MCP tools), with a heartbeat.',
115
+ },
116
+ { name: 'Queue', description: 'Sessions waiting for a free device.' },
117
+ {
118
+ name: 'Hub-Node',
119
+ description: 'How nodes report their devices to a hub, and what a node answers its hub.',
120
+ },
121
+ {
122
+ name: 'Sessions',
123
+ description: 'Appium sessions: their history, commands, logs, assets and performance.',
124
+ },
125
+ { name: 'Builds', description: 'Groups of sessions from one test run.' },
126
+ {
127
+ name: 'Selector Health',
128
+ description: 'Selectors your tests found only with self-healing, and their fixes.',
129
+ },
130
+ {
131
+ name: 'Network Interceptor',
132
+ description: "A session's captured HTTP traffic, mocks and HAR export.",
133
+ },
134
+ {
135
+ name: 'Recordings',
136
+ description: 'Recordings of one or more devices, with marks, bookmarks and proof bundles.',
137
+ },
138
+ { name: 'Applications', description: 'Uploaded app builds and which team sees them.' },
139
+ { name: 'Webhooks', description: 'Where Xenon sends its events.' },
140
+ { name: 'Configuration', description: 'Server settings, including AI providers.' },
141
+ { name: 'Admin', description: 'Operations views for administrators.' },
68
142
  ],
69
143
  components: {
70
144
  schemas: {
71
- Device: {
145
+ Error: {
72
146
  type: 'object',
147
+ description: 'An error. `error` is a machine-readable code on newer endpoints, otherwise the message itself (or `true`, with `message`).',
148
+ required: ['error'],
73
149
  properties: {
74
- udid: {
75
- type: 'string',
76
- description: 'Unique identity',
77
- example: '00008110-00084CE80E51401E',
150
+ error: {
151
+ oneOf: [{ type: 'string' }, { type: 'boolean', enum: [true] }],
152
+ example: 'not_found',
78
153
  },
154
+ message: { type: 'string', example: 'Device not found' },
155
+ },
156
+ additionalProperties: true,
157
+ },
158
+ Success: {
159
+ type: 'object',
160
+ properties: { success: { type: 'boolean', example: true } },
161
+ },
162
+ Device: {
163
+ type: 'object',
164
+ description: 'A device in the lab, as the device list returns it.',
165
+ properties: {
166
+ udid: { type: 'string', example: '00008110-00084CE80E51401E' },
79
167
  name: { type: 'string', example: 'iPhone 14 Pro' },
80
168
  platform: { type: 'string', enum: ['ios', 'android'] },
81
- host: { type: 'string', example: '192.168.1.100' },
169
+ host: {
170
+ type: 'string',
171
+ description: 'The server the device is attached to.',
172
+ example: 'http://192.168.1.100:4723',
173
+ },
174
+ nodeId: {
175
+ type: 'string',
176
+ nullable: true,
177
+ description: 'The node that reported it, on a hub.',
178
+ },
82
179
  busy: { type: 'boolean' },
83
- session_id: { type: 'string', nullable: true },
84
- state: { type: 'string' },
180
+ session_id: {
181
+ type: 'string',
182
+ nullable: true,
183
+ description: 'The Appium session on it, or a preview or recording hold (`manual_<userId>_<udid>`).',
184
+ },
185
+ state: { type: 'string', example: 'device' },
85
186
  sdk: { type: 'string', example: '17.0' },
86
- deviceType: { type: 'string' },
187
+ deviceType: { type: 'string', enum: ['real', 'simulator', 'emulator'] },
188
+ realDevice: { type: 'boolean' },
189
+ offline: { type: 'boolean' },
190
+ userBlocked: { type: 'boolean', description: 'Blocked for maintenance.' },
191
+ teamId: {
192
+ type: 'string',
193
+ nullable: true,
194
+ description: 'The owning team; null is the shared pool.',
195
+ },
196
+ reservedBy: { type: 'string', nullable: true },
197
+ reservedByUserId: {
198
+ type: 'string',
199
+ nullable: true,
200
+ description: 'The user who took the reservation; null before 2.13.',
201
+ },
202
+ reservedUntil: { type: 'integer', nullable: true, description: 'Epoch milliseconds.' },
203
+ healthStatus: { type: 'string', example: 'Healthy' },
204
+ batteryLevel: { type: 'integer', nullable: true },
205
+ screenWidth: { type: 'string', nullable: true },
206
+ screenHeight: { type: 'string', nullable: true },
87
207
  },
208
+ additionalProperties: true,
88
209
  },
89
- // ... (rest of schemas remain same for internal completeness)
90
210
  Session: {
91
211
  type: 'object',
92
212
  properties: {
93
213
  id: { type: 'string' },
94
- name: { type: 'string' },
95
- status: { type: 'string', enum: ['running', 'success', 'failed'] },
214
+ name: { type: 'string', nullable: true },
215
+ status: { type: 'string', example: 'success' },
216
+ build_id: { type: 'string', nullable: true },
96
217
  device_udid: { type: 'string' },
97
218
  device_name: { type: 'string' },
98
219
  device_platform: { type: 'string' },
99
220
  createdAt: { type: 'string', format: 'date-time' },
221
+ updatedAt: { type: 'string', format: 'date-time' },
100
222
  },
223
+ additionalProperties: true,
101
224
  },
102
225
  Build: {
103
226
  type: 'object',
@@ -110,95 +233,7 @@ Every authenticated response carries \`X-RateLimit-Limit\`, \`X-RateLimit-Remain
110
233
  runningCount: { type: 'integer' },
111
234
  createdAt: { type: 'string', format: 'date-time' },
112
235
  },
113
- },
114
- Reservation: {
115
- type: 'object',
116
- properties: {
117
- udid: { type: 'string' },
118
- host: { type: 'string' },
119
- reservedBy: { type: 'string' },
120
- reservedUntil: { type: 'integer' },
121
- reservationReason: { type: 'string' },
122
- remainingMs: { type: 'integer' },
123
- },
124
- },
125
- App: {
126
- type: 'object',
127
- properties: {
128
- id: { type: 'string' },
129
- name: { type: 'string' },
130
- filename: { type: 'string' },
131
- filepath: { type: 'string' },
132
- platform: { type: 'string', enum: ['ios', 'android'] },
133
- uploadedAt: { type: 'string', format: 'date-time' },
134
- },
135
- },
136
- WebhookConfig: {
137
- type: 'object',
138
- properties: {
139
- id: { type: 'string' },
140
- url: { type: 'string', format: 'uri' },
141
- events: { type: 'array', items: { type: 'string' } },
142
- type: { type: 'string', enum: ['slack', 'webhook'] },
143
- },
144
- },
145
- LocatorSuggestion: {
146
- type: 'object',
147
- properties: {
148
- strategy: { type: 'string' },
149
- value: { type: 'string' },
150
- priority: { type: 'integer' },
151
- isUnique: { type: 'boolean' },
152
- },
153
- },
154
- InspectorNode: {
155
- type: 'object',
156
- properties: {
157
- id: { type: 'string' },
158
- type: { type: 'string' },
159
- text: { type: 'string' },
160
- path: { type: 'string' },
161
- rect: {
162
- type: 'object',
163
- properties: {
164
- x: { type: 'number' },
165
- y: { type: 'number' },
166
- width: { type: 'number' },
167
- height: { type: 'number' },
168
- },
169
- },
170
- attributes: { type: 'object', additionalProperties: true },
171
- locators: {
172
- type: 'array',
173
- items: { $ref: '#/components/schemas/LocatorSuggestion' },
174
- },
175
- children: {
176
- type: 'array',
177
- items: { $ref: '#/components/schemas/InspectorNode' },
178
- },
179
- },
180
- },
181
- InspectorSnapshot: {
182
- type: 'object',
183
- properties: {
184
- udid: { type: 'string' },
185
- platform: { type: 'string' },
186
- screenshot: { type: 'string', description: 'Base64 encoded screenshot' },
187
- root: { $ref: '#/components/schemas/InspectorNode' },
188
- },
189
- },
190
- Error: {
191
- type: 'object',
192
- properties: {
193
- error: { type: 'boolean', example: true },
194
- message: { type: 'string' },
195
- },
196
- },
197
- Success: {
198
- type: 'object',
199
- properties: {
200
- success: { type: 'boolean', example: true },
201
- },
236
+ additionalProperties: true,
202
237
  },
203
238
  },
204
239
  securitySchemes: {
@@ -206,77 +241,107 @@ Every authenticated response carries \`X-RateLimit-Limit\`, \`X-RateLimit-Remain
206
241
  type: 'apiKey',
207
242
  in: 'header',
208
243
  name: 'x-xenon-access-key',
209
- description: 'Per-user access key (`xen_…`). Paired with `x-xenon-token` for programmatic + hub-node calls. Visible to its owner at `/profile`; rotate via the **Rotate** button there.',
244
+ description: 'Your access key (`xen_…`), always sent together with `x-xenon-token`. Shown on your profile page, where you can also rotate it.',
210
245
  },
211
246
  TokenAuth: {
212
247
  type: 'apiKey',
213
248
  in: 'header',
214
249
  name: 'x-xenon-token',
215
- description: 'Per-user API token, paired with `x-xenon-access-key`. Mint via `/profile` → API Tokens. Carries the token-specific scopes, not the user\'s broader role.',
250
+ description: 'An API token from your profile page, sent together with `x-xenon-access-key`. It carries its own scopes.',
251
+ },
252
+ BearerAuth: {
253
+ type: 'http',
254
+ scheme: 'bearer',
255
+ bearerFormat: 'JWT',
256
+ description: 'A token from `POST /api/auth/token` (audience `xenon-rest`), valid for one hour. Verified against `/api/auth/jwks.json`; revoking its user takes effect at once.',
257
+ },
258
+ CookieAuth: {
259
+ type: 'apiKey',
260
+ in: 'cookie',
261
+ name: 'xenon_dashboard_session',
262
+ description: 'The dashboard session cookie set by `POST /api/auth/login`. State-changing requests also need a same-host `Origin` or `Referer`.',
263
+ },
264
+ HubToken: {
265
+ type: 'apiKey',
266
+ in: 'header',
267
+ name: 'x-xenon-hub-token',
268
+ description: "A hub's short-lived signed token, verified by the node against the hub's JWKS. Used only between a hub and its nodes.",
216
269
  },
217
270
  },
218
271
  headers: {
219
- 'X-RateLimit-Limit': {
220
- description: 'Maximum requests allowed in the current window for the matched bucket (read / heavy / control).',
221
- schema: { type: 'integer' },
272
+ 'X-RateLimit-Category': {
273
+ description: 'The budget this request counted against: `read`, `heavy` or `control`.',
274
+ schema: { type: 'string', enum: ['read', 'heavy', 'control'] },
222
275
  },
223
276
  'X-RateLimit-Remaining': {
224
- description: 'Remaining requests in the current window.',
277
+ description: 'Requests left in that budget.',
225
278
  schema: { type: 'integer' },
226
279
  },
227
- 'X-RateLimit-Reset': {
228
- description: 'Unix epoch seconds at which the window resets.',
280
+ 'X-RateLimit-Capacity': {
281
+ description: "The budget's size per minute.",
282
+ schema: { type: 'integer' },
283
+ },
284
+ 'Retry-After': {
285
+ description: 'Seconds until a request in this budget will be accepted.',
229
286
  schema: { type: 'integer' },
230
287
  },
231
288
  },
232
289
  responses: {
290
+ BadRequest: {
291
+ description: 'The request is malformed or a field is missing or invalid.',
292
+ content: errorExample('bad_request', 'udid is required'),
293
+ },
233
294
  Unauthorized: {
234
- description: 'Missing or invalid API key / node secret.',
235
- content: {
236
- 'application/json': {
237
- schema: { $ref: '#/components/schemas/Error' },
238
- example: { error: true, message: 'Unauthorized' },
239
- },
240
- },
295
+ description: 'No credential, or one that is invalid, expired or revoked.',
296
+ content: errorExample('unauthenticated'),
241
297
  },
242
298
  Forbidden: {
243
- description: "API key lacks the required scope, or CSRF check failed for a browser caller.",
244
- content: {
245
- 'application/json': {
246
- schema: { $ref: '#/components/schemas/Error' },
247
- example: { error: true, message: 'Forbidden: scope `admin` required' },
248
- },
249
- },
299
+ description: 'The credential is valid but lacks the scope or role this needs, or a browser request failed the same-origin check.',
300
+ content: errorExample('insufficient scope'),
301
+ },
302
+ NotFound: {
303
+ description: "It doesn't exist, or it is outside your teams (the two answer the same).",
304
+ content: errorExample('not_found', 'Not found'),
305
+ },
306
+ Conflict: {
307
+ description: 'It conflicts with the current state, for example a device held by another user.',
308
+ content: errorExample('device_held_by_another_user', 'This device is in use by another user.'),
250
309
  },
251
310
  RateLimited: {
252
- description: 'Per-key rate limit exceeded for this bucket.',
311
+ description: "The API key's budget for the request's category is spent. Only requests made with an access key and token are limited.",
253
312
  headers: {
254
- 'X-RateLimit-Limit': { $ref: '#/components/headers/X-RateLimit-Limit' },
313
+ 'X-RateLimit-Category': { $ref: '#/components/headers/X-RateLimit-Category' },
255
314
  'X-RateLimit-Remaining': { $ref: '#/components/headers/X-RateLimit-Remaining' },
256
- 'X-RateLimit-Reset': { $ref: '#/components/headers/X-RateLimit-Reset' },
315
+ 'X-RateLimit-Capacity': { $ref: '#/components/headers/X-RateLimit-Capacity' },
316
+ 'Retry-After': { $ref: '#/components/headers/Retry-After' },
257
317
  },
258
318
  content: {
259
319
  'application/json': {
260
320
  schema: { $ref: '#/components/schemas/Error' },
261
- example: { error: true, message: 'Too Many Requests' },
321
+ example: { error: 'rate limit exceeded', category: 'control', retryAfter: 2 },
262
322
  },
263
323
  },
264
324
  },
325
+ ServiceUnavailable: {
326
+ description: 'A dependency needed to answer could not be reached; try again.',
327
+ content: errorExample('internal', 'Service unavailable'),
328
+ },
329
+ InternalError: {
330
+ description: 'An unexpected server error.',
331
+ content: errorExample('internal', 'Internal server error'),
332
+ },
265
333
  },
266
334
  },
267
- security: [{ AccessKeyAuth: [], TokenAuth: [] }],
335
+ security: [{ AccessKeyAuth: [], TokenAuth: [] }, { BearerAuth: [] }, { CookieAuth: [] }],
268
336
  };
269
337
  const path_1 = __importDefault(require("path"));
270
- // @ts-ignore - Types will be available after package install
271
338
  const options = {
272
339
  swaggerDefinition,
273
340
  apis: [
274
- // Load from compiled JS (execution runtime)
275
- path_1.default.join(__dirname, 'swagger-docs.js'),
276
- path_1.default.join(__dirname, 'routers', '*.js'),
277
- // Fallback to TS source (development environment)
278
- path_1.default.join(__dirname, '..', '..', '..', 'src', 'app', 'swagger-docs.ts'),
279
- path_1.default.join(__dirname, '..', '..', '..', 'src', 'app', 'routers', '*.ts'),
341
+ // One YAML file per API area: src/app/openapi, copied to lib/src/app/openapi
342
+ // by build:copy. YAML, not JSDoc in a .ts file: tsc dropped most of those
343
+ // free-standing comments, and the served page lost 54 of its 100 paths.
344
+ path_1.default.join(__dirname, 'openapi', '*.yaml'),
280
345
  ],
281
346
  };
282
347
  const swaggerSpec = (0, swagger_jsdoc_1.default)(options);
@@ -425,6 +490,23 @@ function setupSwagger(app, basePath = '/xenon') {
425
490
  .swagger-ui .dialog-ux .modal-ux-header h3 { font-family: 'Outfit', sans-serif; font-size: 28px; color: #fff !important; margin-bottom: 16px; }
426
491
  .swagger-ui .dialog-ux .modal-ux-content h4 { color: var(--xenon-text-dim) !important; margin-top: 24px; }
427
492
 
493
+ /* Readability. The title's gradient text left the version badges blank, and
494
+ Swagger UI's own light section bars and dim greys sat on the dark page. */
495
+ .swagger-ui .info .title small, .swagger-ui .info .title small pre { -webkit-text-fill-color: #020617; color: #020617 !important; font-family: 'JetBrains Mono', monospace; }
496
+ .swagger-ui .info .markdown h1, .swagger-ui .info .markdown h2, .swagger-ui .info .markdown h3 { color: var(--xenon-text) !important; font-family: 'Outfit', sans-serif; margin-top: 40px; }
497
+ .swagger-ui .markdown p, .swagger-ui .markdown li, .swagger-ui .renderedMarkdown p, .swagger-ui .renderedMarkdown li { color: #cbd5e1 !important; }
498
+ .swagger-ui .markdown table td, .swagger-ui .markdown table th { color: #cbd5e1 !important; border-color: var(--xenon-border-bright) !important; padding: 8px 12px; vertical-align: top; }
499
+ .swagger-ui .markdown strong, .swagger-ui .renderedMarkdown strong { color: var(--xenon-text) !important; }
500
+ .swagger-ui .markdown a, .swagger-ui .info a { color: var(--xenon-blue) !important; }
501
+ .swagger-ui .opblock .opblock-section-header { background: var(--xenon-surface-hover) !important; box-shadow: none !important; border-top: 1px solid var(--xenon-border-bright); }
502
+ .swagger-ui .opblock .opblock-section-header h4, .swagger-ui .opblock .opblock-section-header label, .swagger-ui .opblock-title_normal { color: var(--xenon-text) !important; }
503
+ .swagger-ui .opblock-description-wrapper p, .swagger-ui .opblock-description-wrapper li, .swagger-ui .response-col_description, .swagger-ui .parameter__name, .swagger-ui .parameter__type, .swagger-ui .parameter__in, .swagger-ui table thead tr td, .swagger-ui table thead tr th, .swagger-ui .response-col_status, .swagger-ui .responses-inner h4, .swagger-ui .responses-inner h5, .swagger-ui .tab li, .swagger-ui .opblock-summary-description { color: #cbd5e1 !important; }
504
+ .swagger-ui .dialog-ux .modal-ux-content p, .swagger-ui .dialog-ux .modal-ux-content label, .swagger-ui .dialog-ux .modal-ux-content h4, .swagger-ui .dialog-ux .modal-ux-content h6 { color: #cbd5e1 !important; }
505
+ .swagger-ui .model, .swagger-ui .model-title, .swagger-ui .property-row td { color: #cbd5e1 !important; }
506
+ .swagger-ui .info h1, .swagger-ui .info h2, .swagger-ui .info h3, .swagger-ui .info h4 { color: var(--xenon-text) !important; font-family: 'Outfit', sans-serif; }
507
+ .swagger-ui .info table td, .swagger-ui .info table th, .swagger-ui .info li { color: #cbd5e1 !important; }
508
+ .swagger-ui table.headers td, .swagger-ui .header-row td, .swagger-ui .headers-wrapper td { color: #cbd5e1 !important; }
509
+
428
510
  /* Custom Scrollbar */
429
511
  ::-webkit-scrollbar { width: 10px; height: 10px; }
430
512
  ::-webkit-scrollbar-track { background: var(--xenon-bg); }
@@ -454,13 +454,15 @@ function emitUnblocked(device) {
454
454
  * @param reservedBy Username or identifier
455
455
  * @param durationMs Duration in milliseconds
456
456
  * @param reason Optional reservation reason
457
+ * @param reservedByUserId The user taking it, who alone (or an admin) may release or extend it
457
458
  */
458
- function reserveDevice(udid, host, reservedBy, durationMs, reason) {
459
+ function reserveDevice(udid, host, reservedBy, durationMs, reason, reservedByUserId) {
459
460
  return __awaiter(this, void 0, void 0, function* () {
460
461
  const reservedUntil = Date.now() + durationMs;
461
462
  logger_1.default.info(`Reserving device ${udid} for ${reservedBy} until ${new Date(reservedUntil).toISOString()}`);
462
463
  yield store.updateDevice(udid, host, {
463
464
  reservedBy,
465
+ reservedByUserId: reservedByUserId !== null && reservedByUserId !== void 0 ? reservedByUserId : null,
464
466
  reservedUntil,
465
467
  reservationReason: reason,
466
468
  });
@@ -476,6 +478,7 @@ function releaseReservation(udid, host) {
476
478
  logger_1.default.info(`Releasing reservation for device ${udid}`);
477
479
  yield store.updateDevice(udid, host, {
478
480
  reservedBy: null,
481
+ reservedByUserId: null,
479
482
  reservedUntil: null,
480
483
  reservationReason: null,
481
484
  });
@@ -74,6 +74,7 @@ const OWNER = {
74
74
  userBlocked: 'runtime',
75
75
  reservationReason: 'runtime',
76
76
  reservedBy: 'runtime',
77
+ reservedByUserId: 'runtime',
77
78
  reservedUntil: 'runtime',
78
79
  teamId: 'runtime',
79
80
  tags: 'runtime',
@@ -1043,9 +1043,12 @@ let AndroidDeviceManager = class AndroidDeviceManager {
1043
1043
  }
1044
1044
  getLogs(udid) {
1045
1045
  return __awaiter(this, void 0, void 0, function* () {
1046
+ // A failure throws. It used to come back as the log text itself, so
1047
+ // device control answered 200 with the error as a "line", and the
1048
+ // session log stored it as one.
1046
1049
  const { adbInstance } = yield this.getAdb();
1047
1050
  if (!adbInstance)
1048
- return 'ADB is not available';
1051
+ throw new Error('ADB is not available');
1049
1052
  try {
1050
1053
  // Get last 500 lines of logcat
1051
1054
  return yield adbInstance.adbExec([
@@ -1062,7 +1065,7 @@ let AndroidDeviceManager = class AndroidDeviceManager {
1062
1065
  }
1063
1066
  catch (err) {
1064
1067
  logger_1.default.warn(`Failed to fetch Android logs for ${udid}: ${err instanceof Error ? err.message : err}`);
1065
- return `Failed to fetch logs: ${err instanceof Error ? err.message : err}`;
1068
+ throw err;
1066
1069
  }
1067
1070
  });
1068
1071
  }