@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
@@ -0,0 +1,2885 @@
1
+ paths:
2
+ # ---------------------------------------------------------------- Health & Ops
3
+ /api/health:
4
+ get:
5
+ operationId: getServerHealth
6
+ summary: Check that the server is up
7
+ description: |
8
+ Liveness probe. Public: no credential, no rate limit. Answers `{ "ok": true }` as soon as
9
+ the API is serving requests. It checks nothing else (not the database, not the devices),
10
+ so use it for load-balancer and Kubernetes liveness checks, not readiness.
11
+ tags:
12
+ - Health & Ops
13
+ security: []
14
+ responses:
15
+ '200':
16
+ description: The server is up.
17
+ content:
18
+ application/json:
19
+ schema:
20
+ type: object
21
+ required:
22
+ - ok
23
+ properties:
24
+ ok:
25
+ type: boolean
26
+ example: true
27
+ example:
28
+ ok: true
29
+ /api/ping:
30
+ get:
31
+ operationId: pingServer
32
+ summary: Check a credential and read the server version
33
+ description: |
34
+ Answers `{ pong, version }` for any valid credential. Use it to confirm that a key pair,
35
+ bearer token or dashboard session is accepted, and to read the running Xenon version.
36
+ Counts against the `read` rate-limit budget.
37
+ tags:
38
+ - Health & Ops
39
+ responses:
40
+ '200':
41
+ description: The credential is valid.
42
+ content:
43
+ application/json:
44
+ schema:
45
+ type: object
46
+ required:
47
+ - pong
48
+ - version
49
+ properties:
50
+ pong:
51
+ type: boolean
52
+ example: true
53
+ version:
54
+ type: string
55
+ description: The running Xenon version.
56
+ example: 2.12.0
57
+ example:
58
+ pong: true
59
+ version: 2.12.0
60
+ '401':
61
+ $ref: '#/components/responses/Unauthorized'
62
+ '429':
63
+ $ref: '#/components/responses/RateLimited'
64
+ /api/metrics:
65
+ get:
66
+ operationId: getPrometheusMetrics
67
+ summary: Read server metrics in Prometheus format
68
+ description: |
69
+ Prometheus text exposition of the server's counters and gauges. Any valid credential may
70
+ read it (no role or scope beyond being signed in); point a scraper at it with a key pair
71
+ or bearer token.
72
+
73
+ Series:
74
+ - `xenon_sessions_total{status="started"|"success"|"failure"}`: persisted session counters.
75
+ - `xenon_sessions_active`: sessions this server tracks right now.
76
+ - `xenon_devices_total`, `xenon_devices_busy`, `xenon_devices_offline`.
77
+ - `xenon_healing_total{status="attempt"|"success"}`: persisted self-healing counters.
78
+ - Per-tier healing series (`xenon_heal_tier_attempts_total`, `..._successes_total`,
79
+ `..._failures_total`, `xenon_heal_tier_duration_seconds_sum`, labelled `tier` and
80
+ `name`), `xenon_heal_all_tiers_failed_total` and
81
+ `xenon_heal_tier_skipped_remaining_total`. These are kept in memory and start again
82
+ from zero when the server restarts.
83
+ tags:
84
+ - Health & Ops
85
+ responses:
86
+ '200':
87
+ description: The metrics, as Prometheus text.
88
+ content:
89
+ text/plain:
90
+ schema:
91
+ type: string
92
+ example: |
93
+ # HELP xenon_sessions_total Total number of sessions across all time
94
+ # TYPE xenon_sessions_total counter
95
+ xenon_sessions_total{status="started"} 1284
96
+ xenon_sessions_total{status="success"} 1190
97
+ xenon_sessions_total{status="failure"} 94
98
+ # HELP xenon_sessions_active Current active sessions in the hub
99
+ # TYPE xenon_sessions_active gauge
100
+ xenon_sessions_active 3
101
+ # HELP xenon_devices_total Total managed devices in the fleet
102
+ # TYPE xenon_devices_total gauge
103
+ xenon_devices_total 12
104
+ '401':
105
+ $ref: '#/components/responses/Unauthorized'
106
+ '429':
107
+ $ref: '#/components/responses/RateLimited'
108
+ # ----------------------------------------------------------------------- Admin
109
+ /api/cliArgs:
110
+ get:
111
+ operationId: listStoredCliArgs
112
+ summary: Read the Appium arguments the server stored
113
+ description: |
114
+ The plugin arguments this lab has stored, one object per stored entry, oldest first. They
115
+ include the database URL, AI and cloud provider settings and the hub URL, so this needs
116
+ the `ADMIN` role and the `admin` scope. Values are redacted the way the server log
117
+ redacts them: a value under a key that looks secret (password, token, API key, auth,
118
+ database URL, and similar) and any string shaped like a provider key reads
119
+ `***REDACTED***`.
120
+ tags:
121
+ - Admin
122
+ responses:
123
+ '200':
124
+ description: The stored arguments, redacted.
125
+ content:
126
+ application/json:
127
+ schema:
128
+ type: array
129
+ items:
130
+ type: object
131
+ additionalProperties: true
132
+ example:
133
+ - platform: both
134
+ hub: http://192.168.1.10:4723
135
+ bindHostOrIp: 192.168.1.20
136
+ databaseUrl: '***REDACTED***'
137
+ aiProvider: gemini
138
+ geminiApiKey: '***REDACTED***'
139
+ '401':
140
+ $ref: '#/components/responses/Unauthorized'
141
+ '403':
142
+ $ref: '#/components/responses/Forbidden'
143
+ '429':
144
+ $ref: '#/components/responses/RateLimited'
145
+ /api/processes:
146
+ get:
147
+ operationId: listSupervisedProcesses
148
+ summary: List the child processes Xenon supervises
149
+ description: |
150
+ A snapshot of the long-lived child processes this server has started and tracks:
151
+ WebDriverAgent, ffmpeg recorders, `adb reverse` forwards, iOS MJPEG forwarders, log
152
+ tailers and others. Useful when a host runs hot or a device seems wedged. `sessionId` and
153
+ `udid` are present only when the process belongs to one. Needs the `ADMIN` role and the
154
+ `admin` scope.
155
+ tags:
156
+ - Admin
157
+ responses:
158
+ '200':
159
+ description: The supervised processes.
160
+ content:
161
+ application/json:
162
+ schema:
163
+ type: array
164
+ items:
165
+ $ref: '#/components/schemas/PlatformProcess'
166
+ example:
167
+ - id: 6f1d2c3b-4a5e-4f60-8a7b-9c0d1e2f3a4b
168
+ udid: 00008110-00084CE80E51401E
169
+ kind: wda
170
+ pid: 48213
171
+ uptimeMs: 734120
172
+ - id: 0b9a8c7d-6e5f-4a3b-8c2d-1e0f9a8b7c6d
173
+ sessionId: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
174
+ udid: R5CT32ABCDE
175
+ kind: ffmpeg
176
+ pid: 48977
177
+ uptimeMs: 61500
178
+ '401':
179
+ $ref: '#/components/responses/Unauthorized'
180
+ '403':
181
+ $ref: '#/components/responses/Forbidden'
182
+ '429':
183
+ $ref: '#/components/responses/RateLimited'
184
+ # ---------------------------------------------------------------- Configuration
185
+ /api/config:
186
+ get:
187
+ operationId: getLabSettings
188
+ summary: Read the lab's settings and AI provider
189
+ description: |
190
+ The lab-wide settings stored in the database (health checks and build cleanup), merged
191
+ with the AI settings currently in effect. API keys are never returned: `geminiSet`,
192
+ `openaiSet` and `anthropicSet` say only whether one is configured. A setting never saved
193
+ is absent. Needs the `ADMIN` role or above, as the dashboard's settings pages do; through
194
+ 2.12 any signed-in user could read it.
195
+ tags:
196
+ - Configuration
197
+ responses:
198
+ '200':
199
+ description: The settings.
200
+ content:
201
+ application/json:
202
+ schema:
203
+ $ref: '#/components/schemas/PlatformSettings'
204
+ example:
205
+ healthCheckIntervalMs: 300000
206
+ buildCleanupDays: 30
207
+ aiProvider: gemini
208
+ aiModel: gemini-3-flash-preview
209
+ geminiModel: gemini-3-flash-preview
210
+ openaiModel: gpt-4o
211
+ anthropicModel: claude-sonnet-4-6
212
+ ollamaModel: llama3
213
+ geminiSet: true
214
+ openaiSet: false
215
+ anthropicSet: false
216
+ '401':
217
+ $ref: '#/components/responses/Unauthorized'
218
+ '429':
219
+ $ref: '#/components/responses/RateLimited'
220
+ '500':
221
+ description: The settings could not be read.
222
+ content:
223
+ application/json:
224
+ schema:
225
+ $ref: '#/components/schemas/Error'
226
+ example:
227
+ error: true
228
+ message: Can't reach database server
229
+ post:
230
+ operationId: updateLabSettings
231
+ summary: Change the lab's settings and AI provider
232
+ description: |
233
+ Saves the lab-wide settings and changes the AI provider settings. Every field is
234
+ optional; fields not sent are left as they are, and unknown fields are ignored.
235
+
236
+ - Health-check and build-cleanup fields are stored in the database.
237
+ - AI fields (`aiProvider`, `aiModel`, `aiBaseUrl` and the per-provider models) change the
238
+ running server only. They are not stored, so a restart returns to the values the
239
+ server was started with. API keys can't be set here; they come from the server's
240
+ environment.
241
+
242
+ Needs the `SUPER_ADMIN` role and the `admin` scope; an `ADMIN` gets `403` with a message
243
+ saying so (through 2.12 an `ADMIN` could). With the dashboard cookie the request must
244
+ come from the same host (`Origin` or `Referer`).
245
+ tags:
246
+ - Configuration
247
+ requestBody:
248
+ required: true
249
+ content:
250
+ application/json:
251
+ schema:
252
+ $ref: '#/components/schemas/PlatformSettingsUpdate'
253
+ example:
254
+ healthCheckIntervalMs: 300000
255
+ buildCleanupDays: 14
256
+ deleteBuildAssets: true
257
+ aiProvider: anthropic
258
+ anthropicModel: claude-sonnet-4-6
259
+ responses:
260
+ '200':
261
+ description: Saved.
262
+ content:
263
+ application/json:
264
+ schema:
265
+ $ref: '#/components/schemas/Success'
266
+ example:
267
+ success: true
268
+ '401':
269
+ $ref: '#/components/responses/Unauthorized'
270
+ '403':
271
+ $ref: '#/components/responses/PlatformSettingsRefused'
272
+ '429':
273
+ $ref: '#/components/responses/RateLimited'
274
+ '500':
275
+ description: The settings could not be saved.
276
+ content:
277
+ application/json:
278
+ schema:
279
+ $ref: '#/components/schemas/Error'
280
+ example:
281
+ error: true
282
+ message: Unique constraint failed on the fields (`id`)
283
+ /api/config/reset-metrics:
284
+ post:
285
+ operationId: resetDeviceHealCounts
286
+ summary: Reset every device's healed-selector count
287
+ description: |
288
+ Sets every device's healed-selector count (`totalHealedCount`) back to zero. Nothing else
289
+ is reset: the session and healing counters on `/api/metrics` are unchanged. Takes no
290
+ body. Needs the `SUPER_ADMIN` role and the `admin` scope, as every change under
291
+ `/api/config` does. With the dashboard cookie the request must come from the same host.
292
+ tags:
293
+ - Configuration
294
+ responses:
295
+ '200':
296
+ description: Reset.
297
+ content:
298
+ application/json:
299
+ schema:
300
+ $ref: '#/components/schemas/Success'
301
+ example:
302
+ success: true
303
+ '401':
304
+ $ref: '#/components/responses/Unauthorized'
305
+ '403':
306
+ $ref: '#/components/responses/PlatformSettingsRefused'
307
+ '429':
308
+ $ref: '#/components/responses/RateLimited'
309
+ '500':
310
+ description: The counts could not be reset.
311
+ content:
312
+ application/json:
313
+ schema:
314
+ $ref: '#/components/schemas/Error'
315
+ example:
316
+ error: true
317
+ message: Can't reach database server
318
+ /api/config/test-ai:
319
+ post:
320
+ operationId: testAiProviderConnection
321
+ summary: Test a connection to an AI provider
322
+ description: |
323
+ Sends a one-line prompt to an AI provider and says whether it answered. The provider,
324
+ model and base URL are taken from the body, falling back to the server's current
325
+ settings. The API key is the server's own (from its environment); a key in the body is
326
+ used only when the server has none for that provider. Nothing is saved.
327
+
328
+ The answer is `200` whether or not the connection worked: read `success`. A provider that
329
+ answers but is out of quota still counts as a success, with a note in `message`.
330
+
331
+ Needs the `SUPER_ADMIN` role and the `admin` scope. Counts against the `heavy` rate-limit
332
+ budget. With the dashboard cookie the request must come from the same host.
333
+ tags:
334
+ - Configuration
335
+ requestBody:
336
+ required: false
337
+ content:
338
+ application/json:
339
+ schema:
340
+ $ref: '#/components/schemas/PlatformAiTestRequest'
341
+ example:
342
+ aiProvider: ollama
343
+ aiModel: llama3
344
+ aiBaseUrl: http://localhost:11434
345
+ responses:
346
+ '200':
347
+ description: The result of the test.
348
+ content:
349
+ application/json:
350
+ schema:
351
+ $ref: '#/components/schemas/PlatformAiTestResult'
352
+ examples:
353
+ connected:
354
+ value:
355
+ success: true
356
+ message: Successfully connected to gemini!
357
+ failed:
358
+ value:
359
+ success: false
360
+ message: >-
361
+ Connection failed: OpenAI API Key missing. Please set XENON_OPENAI_API_KEY
362
+ environment variable.
363
+ '401':
364
+ $ref: '#/components/responses/Unauthorized'
365
+ '403':
366
+ $ref: '#/components/responses/Forbidden'
367
+ '429':
368
+ $ref: '#/components/responses/RateLimited'
369
+ # --------------------------------------------------------------------- Webhooks
370
+ /api/webhook:
371
+ get:
372
+ operationId: listWebhooks
373
+ summary: List the webhooks Xenon sends events to
374
+ description: |
375
+ Every configured webhook. `events` is stored and returned as a JSON-encoded string, not
376
+ an array. Needs the `ADMIN` role and the `admin` scope: the URLs are often secrets
377
+ (Slack's are).
378
+ tags:
379
+ - Webhooks
380
+ responses:
381
+ '200':
382
+ description: The webhooks.
383
+ content:
384
+ application/json:
385
+ schema:
386
+ type: array
387
+ items:
388
+ $ref: '#/components/schemas/PlatformWebhook'
389
+ example:
390
+ - id: 2d6f4a8e-1b3c-4d5e-9f60-7a8b9c0d1e2f
391
+ url: https://hooks.slack.com/services/T000/B000/XXXXXXXX
392
+ type: slack
393
+ events: '["device_offline","session_failed"]'
394
+ active: true
395
+ payloadTemplate: null
396
+ createdAt: '2026-09-12T08:14:03.000Z'
397
+ updatedAt: '2026-09-12T08:14:03.000Z'
398
+ '401':
399
+ $ref: '#/components/responses/Unauthorized'
400
+ '403':
401
+ $ref: '#/components/responses/Forbidden'
402
+ '429':
403
+ $ref: '#/components/responses/RateLimited'
404
+ '500':
405
+ description: The webhooks could not be read.
406
+ content:
407
+ application/json:
408
+ schema:
409
+ $ref: '#/components/schemas/Error'
410
+ example:
411
+ error: Failed to fetch configurations
412
+ post:
413
+ operationId: createWebhook
414
+ summary: Add a webhook
415
+ description: |
416
+ Adds a webhook, active at once. When one of its `events` happens Xenon POSTs to `url`:
417
+
418
+ - with `payloadTemplate`: the template, with each `{{key}}` (or `{{a.b}}`) replaced from
419
+ the event (`eventType` plus the event's fields), sent as JSON if the result parses as
420
+ JSON, otherwise as `{ "text": "<result>" }`;
421
+ - otherwise, `type: slack`: a Slack message with an attachment;
422
+ - otherwise: `{ "event": "<event>", "payload": { ... } }`.
423
+
424
+ Each delivery is one POST, never retried. A template whose result isn't JSON is sent
425
+ once, as text. A webhook that refuses an event or can't be reached doesn't fail the
426
+ event: the failure is logged on the server and the other webhooks still get it. Use
427
+ `POST /api/webhook/test` to see whether a webhook accepts deliveries.
428
+
429
+ Events are `device_offline`, `session_failed`, `device_new` and
430
+ `selector_health_digest`. Neither `url` nor the event names are checked. Needs the
431
+ `ADMIN` role and the `admin` scope. With the dashboard cookie the request must come from
432
+ the same host.
433
+ tags:
434
+ - Webhooks
435
+ requestBody:
436
+ required: true
437
+ content:
438
+ application/json:
439
+ schema:
440
+ $ref: '#/components/schemas/PlatformWebhookCreate'
441
+ example:
442
+ url: https://hooks.slack.com/services/T000/B000/XXXXXXXX
443
+ events:
444
+ - device_offline
445
+ - session_failed
446
+ type: slack
447
+ responses:
448
+ '200':
449
+ description: The webhook as stored.
450
+ content:
451
+ application/json:
452
+ schema:
453
+ $ref: '#/components/schemas/PlatformWebhook'
454
+ example:
455
+ id: 2d6f4a8e-1b3c-4d5e-9f60-7a8b9c0d1e2f
456
+ url: https://hooks.slack.com/services/T000/B000/XXXXXXXX
457
+ type: slack
458
+ events: '["device_offline","session_failed"]'
459
+ active: true
460
+ payloadTemplate: null
461
+ createdAt: '2026-10-04T09:30:12.000Z'
462
+ updatedAt: '2026-10-04T09:30:12.000Z'
463
+ '400':
464
+ description: '`url` or the `events` array is missing.'
465
+ content:
466
+ application/json:
467
+ schema:
468
+ $ref: '#/components/schemas/Error'
469
+ example:
470
+ error: 'Invalid parameters: url and events array required'
471
+ '401':
472
+ $ref: '#/components/responses/Unauthorized'
473
+ '403':
474
+ $ref: '#/components/responses/Forbidden'
475
+ '429':
476
+ $ref: '#/components/responses/RateLimited'
477
+ '500':
478
+ description: The webhook could not be saved.
479
+ content:
480
+ application/json:
481
+ schema:
482
+ $ref: '#/components/schemas/Error'
483
+ example:
484
+ error: Failed to save configuration
485
+ /api/webhook/{id}:
486
+ delete:
487
+ operationId: deleteWebhook
488
+ summary: Remove a webhook
489
+ description: |
490
+ Removes a webhook. Needs the `ADMIN` role and the `admin` scope. With the dashboard
491
+ cookie the request must come from the same host. An unknown id answers `404`.
492
+ tags:
493
+ - Webhooks
494
+ parameters:
495
+ - in: path
496
+ name: id
497
+ required: true
498
+ description: The webhook's id.
499
+ schema:
500
+ type: string
501
+ example: 2d6f4a8e-1b3c-4d5e-9f60-7a8b9c0d1e2f
502
+ responses:
503
+ '200':
504
+ description: Removed.
505
+ content:
506
+ application/json:
507
+ schema:
508
+ $ref: '#/components/schemas/Success'
509
+ example:
510
+ success: true
511
+ '401':
512
+ $ref: '#/components/responses/Unauthorized'
513
+ '403':
514
+ $ref: '#/components/responses/Forbidden'
515
+ '404':
516
+ description: No webhook has this id.
517
+ content:
518
+ application/json:
519
+ schema:
520
+ $ref: '#/components/schemas/Error'
521
+ example:
522
+ error: not_found
523
+ message: Webhook not found
524
+ '429':
525
+ $ref: '#/components/responses/RateLimited'
526
+ '500':
527
+ description: Not removed.
528
+ content:
529
+ application/json:
530
+ schema:
531
+ $ref: '#/components/schemas/Error'
532
+ example:
533
+ error: Failed to delete configuration
534
+ /api/webhook/test:
535
+ post:
536
+ operationId: testWebhook
537
+ summary: Send a test message to a URL
538
+ description: |
539
+ Sends a sample `device_new` event (a device named `Test Device`, udid
540
+ `test-device-udid`, host `127.0.0.1`) to `url` the way a webhook with this `type` and
541
+ `payloadTemplate` gets its real events (see `POST /api/webhook`): the filled-in
542
+ template if there is one, else a Slack message for `type: slack` (the default), else
543
+ `{ "event": "device_new", "payload": { ... } }`. No stored webhook is involved.
544
+
545
+ Answers `200` when `url` accepted the delivery (a 2xx), and `502 delivery_failed`, with
546
+ the reason, when it refused it or couldn't be reached. Needs the `ADMIN` role and the
547
+ `admin` scope. With the dashboard cookie the request must come from the same host.
548
+ tags:
549
+ - Webhooks
550
+ requestBody:
551
+ required: true
552
+ content:
553
+ application/json:
554
+ schema:
555
+ type: object
556
+ required:
557
+ - url
558
+ properties:
559
+ url:
560
+ type: string
561
+ format: uri
562
+ description: Where to send the test message.
563
+ example: https://hooks.slack.com/services/T000/B000/XXXXXXXX
564
+ type:
565
+ type: string
566
+ default: slack
567
+ description: >-
568
+ The webhook's type: `slack` for a Slack message, anything else for the
569
+ generic `{ event, payload }` body. Ignored when `payloadTemplate` is given.
570
+ example: slack
571
+ payloadTemplate:
572
+ type: string
573
+ nullable: true
574
+ description: >-
575
+ The webhook's template, filled in from the sample event as a real event's
576
+ would be.
577
+ example: '{"text": "{{eventType}}: {{name}} ({{udid}})"}'
578
+ example:
579
+ url: https://hooks.slack.com/services/T000/B000/XXXXXXXX
580
+ type: slack
581
+ responses:
582
+ '200':
583
+ description: Delivered; `url` answered with a 2xx.
584
+ content:
585
+ application/json:
586
+ schema:
587
+ $ref: '#/components/schemas/Success'
588
+ example:
589
+ success: true
590
+ '400':
591
+ description: '`url` is missing or not a string.'
592
+ content:
593
+ application/json:
594
+ schema:
595
+ $ref: '#/components/schemas/Error'
596
+ example:
597
+ error: url is required
598
+ '401':
599
+ $ref: '#/components/responses/Unauthorized'
600
+ '403':
601
+ $ref: '#/components/responses/Forbidden'
602
+ '429':
603
+ $ref: '#/components/responses/RateLimited'
604
+ '502':
605
+ description: "`url` refused the delivery (a non-2xx answer) or couldn't be reached."
606
+ content:
607
+ application/json:
608
+ schema:
609
+ $ref: '#/components/schemas/Error'
610
+ examples:
611
+ refused:
612
+ summary: The webhook refused it
613
+ value:
614
+ error: delivery_failed
615
+ message: Request failed with status code 404
616
+ unreachable:
617
+ summary: The webhook couldn't be reached
618
+ value:
619
+ error: delivery_failed
620
+ message: getaddrinfo ENOTFOUND hooks.example.invalid
621
+ # ----------------------------------------------------------------- Applications
622
+ /api/apps:
623
+ get:
624
+ operationId: listApps
625
+ summary: List the uploaded apps you can see
626
+ description: |
627
+ The uploaded app builds visible to the caller, newest first, each with its team. Admins
628
+ see every app; members see shared apps (`teamId` null) and their teams' apps. Any
629
+ signed-in user may call it.
630
+ tags:
631
+ - Applications
632
+ responses:
633
+ '200':
634
+ description: The apps.
635
+ content:
636
+ application/json:
637
+ schema:
638
+ type: array
639
+ items:
640
+ $ref: '#/components/schemas/PlatformAppWithTeam'
641
+ example:
642
+ - id: 9adc3f2e-7b1a-4c6d-8e9f-0a1b2c3d4e5f
643
+ name: checkout-2.4.1.apk
644
+ filename: checkout-2.4.1.apk
645
+ filepath: /home/lab/.cache/xenon/apps/9adc3f2e-7b1a-4c6d-8e9f-0a1b2c3d4e5f.apk
646
+ mimetype: application/vnd.android.package-archive
647
+ size: 48213377
648
+ packageName: com.example.checkout
649
+ version: 2.4.1
650
+ platform: android
651
+ md5: 5f4dcc3b5aa765d61d8327deb882cf99
652
+ teamId: 7e1c2a90-3b4d-4f5e-8a6b-1c2d3e4f5a6b
653
+ team:
654
+ id: 7e1c2a90-3b4d-4f5e-8a6b-1c2d3e4f5a6b
655
+ name: Payments
656
+ createdAt: '2026-09-30T14:02:11.000Z'
657
+ updatedAt: '2026-09-30T14:02:11.000Z'
658
+ '401':
659
+ $ref: '#/components/responses/Unauthorized'
660
+ '429':
661
+ $ref: '#/components/responses/RateLimited'
662
+ '500':
663
+ $ref: '#/components/responses/InternalError'
664
+ /api/apps/upload:
665
+ post:
666
+ operationId: uploadApp
667
+ summary: Upload an app build
668
+ description: |
669
+ Stores an app build (multipart field `app`), in a team's library or the shared pool.
670
+ `.apk` files are read for their package name and version, and marked `android`; `.ipa`
671
+ files are marked `ios`; any other file is stored with no platform.
672
+
673
+ Uploading bytes that are already stored (same MD5) stores nothing new and returns the
674
+ existing app as it is, in whatever team it is already in. Move it with
675
+ `PUT /api/apps/{id}/team`.
676
+
677
+ Needs the `ADMIN` role and the `devices` scope (or `admin`). With the dashboard cookie the
678
+ request must come from the same host.
679
+ tags:
680
+ - Applications
681
+ requestBody:
682
+ required: true
683
+ content:
684
+ multipart/form-data:
685
+ schema:
686
+ type: object
687
+ required:
688
+ - app
689
+ properties:
690
+ app:
691
+ type: string
692
+ format: binary
693
+ description: The app file (`.apk` or `.ipa`).
694
+ teamId:
695
+ type: string
696
+ description: The team whose members may see it. Empty or absent means the shared pool.
697
+ example: 7e1c2a90-3b4d-4f5e-8a6b-1c2d3e4f5a6b
698
+ responses:
699
+ '200':
700
+ description: The stored app (new, or the existing one with the same bytes).
701
+ content:
702
+ application/json:
703
+ schema:
704
+ $ref: '#/components/schemas/PlatformApp'
705
+ example:
706
+ id: 9adc3f2e-7b1a-4c6d-8e9f-0a1b2c3d4e5f
707
+ name: checkout-2.4.1.apk
708
+ filename: checkout-2.4.1.apk
709
+ filepath: /home/lab/.cache/xenon/apps/9adc3f2e-7b1a-4c6d-8e9f-0a1b2c3d4e5f.apk
710
+ mimetype: application/vnd.android.package-archive
711
+ size: 48213377
712
+ packageName: com.example.checkout
713
+ version: 2.4.1
714
+ platform: android
715
+ md5: 5f4dcc3b5aa765d61d8327deb882cf99
716
+ teamId: 7e1c2a90-3b4d-4f5e-8a6b-1c2d3e4f5a6b
717
+ createdAt: '2026-10-04T09:12:44.000Z'
718
+ updatedAt: '2026-10-04T09:12:44.000Z'
719
+ '400':
720
+ description: No file, no `app` field, a `teamId` that isn't a string, or an unknown team.
721
+ content:
722
+ application/json:
723
+ schema:
724
+ $ref: '#/components/schemas/Error'
725
+ examples:
726
+ noFile:
727
+ value:
728
+ error: No files were uploaded.
729
+ noAppField:
730
+ value:
731
+ error: Field "app" is required.
732
+ unknownTeam:
733
+ value:
734
+ error: team not found
735
+ '401':
736
+ $ref: '#/components/responses/Unauthorized'
737
+ '403':
738
+ $ref: '#/components/responses/Forbidden'
739
+ '429':
740
+ $ref: '#/components/responses/RateLimited'
741
+ '500':
742
+ $ref: '#/components/responses/InternalError'
743
+ /api/apps/{id}:
744
+ delete:
745
+ operationId: deleteApp
746
+ summary: Delete an uploaded app
747
+ description: |
748
+ Deletes the app's file and its record. Needs the `ADMIN` role and the `devices` scope (or
749
+ `admin`). With the dashboard cookie the request must come from the same host.
750
+ tags:
751
+ - Applications
752
+ parameters:
753
+ - $ref: '#/components/parameters/PlatformAppId'
754
+ responses:
755
+ '204':
756
+ description: Deleted.
757
+ '401':
758
+ $ref: '#/components/responses/Unauthorized'
759
+ '403':
760
+ $ref: '#/components/responses/Forbidden'
761
+ '404':
762
+ $ref: '#/components/responses/PlatformAppNotFound'
763
+ '429':
764
+ $ref: '#/components/responses/RateLimited'
765
+ '500':
766
+ $ref: '#/components/responses/InternalError'
767
+ /api/apps/{id}/download:
768
+ get:
769
+ operationId: downloadApp
770
+ summary: Download an uploaded app
771
+ description: |
772
+ The app file, as an attachment under its original file name. An app outside the caller's
773
+ teams answers `404`, exactly like an unknown id.
774
+
775
+ Besides the usual credentials, this accepts `?ticket=`: a single-use ticket bound to this
776
+ one app, valid for 10 minutes, which Xenon puts in the app URL it hands an Appium driver
777
+ (the driver downloads the app with no credentials). A ticket that is invalid, expired,
778
+ already used or for another app answers `401 invalid ticket`. Tickets can't be minted
779
+ through the API.
780
+ tags:
781
+ - Applications
782
+ parameters:
783
+ - $ref: '#/components/parameters/PlatformAppId'
784
+ - in: query
785
+ name: ticket
786
+ required: false
787
+ description: A single-use app download ticket, in place of a credential.
788
+ schema:
789
+ type: string
790
+ example: eyJhbGciOiJSUzI1NiIsImtpZCI6Inhlbm9uLTEifQ.eyJhdWQiOiJ4ZW5vbi1hcHAtZG93bmxvYWQifQ.c2ln
791
+ responses:
792
+ '200':
793
+ description: The app file.
794
+ headers:
795
+ Content-Disposition:
796
+ description: '`attachment; filename="<original name>"`.'
797
+ schema:
798
+ type: string
799
+ example: attachment; filename="checkout-2.4.1.apk"
800
+ content:
801
+ application/octet-stream:
802
+ schema:
803
+ type: string
804
+ format: binary
805
+ '401':
806
+ description: No valid credential, or an invalid ticket.
807
+ content:
808
+ application/json:
809
+ schema:
810
+ $ref: '#/components/schemas/Error'
811
+ examples:
812
+ unauthenticated:
813
+ value:
814
+ error: unauthenticated
815
+ invalidTicket:
816
+ value:
817
+ error: invalid ticket
818
+ '404':
819
+ $ref: '#/components/responses/PlatformAppNotFound'
820
+ '429':
821
+ $ref: '#/components/responses/RateLimited'
822
+ '500':
823
+ $ref: '#/components/responses/InternalError'
824
+ /api/apps/{id}/team:
825
+ put:
826
+ operationId: setAppTeam
827
+ summary: Move an app to a team or the shared pool
828
+ description: |
829
+ Sets which team's members may see the app. `teamId: null` (or an empty string, or no
830
+ `teamId`) moves it to the shared pool, which everyone sees. Needs the `ADMIN` role and the
831
+ `admin` scope. With the dashboard cookie the request must come from the same host.
832
+ tags:
833
+ - Applications
834
+ parameters:
835
+ - $ref: '#/components/parameters/PlatformAppId'
836
+ requestBody:
837
+ required: true
838
+ content:
839
+ application/json:
840
+ schema:
841
+ type: object
842
+ properties:
843
+ teamId:
844
+ type: string
845
+ nullable: true
846
+ description: The team's id, or null for the shared pool.
847
+ example: 7e1c2a90-3b4d-4f5e-8a6b-1c2d3e4f5a6b
848
+ example:
849
+ teamId: 7e1c2a90-3b4d-4f5e-8a6b-1c2d3e4f5a6b
850
+ responses:
851
+ '200':
852
+ description: Moved.
853
+ content:
854
+ application/json:
855
+ schema:
856
+ type: object
857
+ required:
858
+ - ok
859
+ - updated
860
+ properties:
861
+ ok:
862
+ type: boolean
863
+ example: true
864
+ updated:
865
+ type: integer
866
+ description: Apps changed (always 1 here).
867
+ example: 1
868
+ example:
869
+ ok: true
870
+ updated: 1
871
+ '400':
872
+ description: '`teamId` is neither a string nor null.'
873
+ content:
874
+ application/json:
875
+ schema:
876
+ $ref: '#/components/schemas/Error'
877
+ example:
878
+ error: teamId must be a string or null
879
+ '401':
880
+ $ref: '#/components/responses/Unauthorized'
881
+ '403':
882
+ $ref: '#/components/responses/Forbidden'
883
+ '404':
884
+ description: No such app, or no such team.
885
+ content:
886
+ application/json:
887
+ schema:
888
+ $ref: '#/components/schemas/Error'
889
+ examples:
890
+ unknownApp:
891
+ value:
892
+ error: App not found
893
+ unknownTeam:
894
+ value:
895
+ error: team not found
896
+ '429':
897
+ $ref: '#/components/responses/RateLimited'
898
+ '500':
899
+ $ref: '#/components/responses/InternalError'
900
+ # ------------------------------------------------------------------- Recordings
901
+ /api/recordings:
902
+ get:
903
+ operationId: listRecordings
904
+ summary: List recordings, newest first
905
+ description: |
906
+ The recordings library: one summary per recording (a group of one or more phones
907
+ recorded together), newest first, filtered and paged, with facets for the filter menus.
908
+
909
+ Visibility: admins see every recording. Others see the phones they can see (their teams'
910
+ and the shared pool), so a recording that mixes teams shows only those phones, and a
911
+ recording with none of them is left out. The person who started a recording also keeps
912
+ seeing their own phones that have since been unplugged.
913
+
914
+ `facets` count every recording the caller can see, before the filters. `total` counts
915
+ those that match the filters. Pass `nextCursor` back as `cursor` for the next page.
916
+ Any signed-in user may call it.
917
+ tags:
918
+ - Recordings
919
+ parameters:
920
+ - in: query
921
+ name: limit
922
+ required: false
923
+ description: Recordings per page, at most 200.
924
+ schema:
925
+ type: integer
926
+ minimum: 1
927
+ maximum: 200
928
+ default: 50
929
+ - in: query
930
+ name: cursor
931
+ required: false
932
+ description: '`nextCursor` from the previous page.'
933
+ schema:
934
+ type: string
935
+ example: 1759568400000_c3b2a1f0-5e4d-4c3b-9a8f-7e6d5c4b3a21
936
+ - in: query
937
+ name: udid
938
+ required: false
939
+ description: Only recordings that include this phone.
940
+ schema:
941
+ type: string
942
+ example: R5CT32ABCDE
943
+ - in: query
944
+ name: startedBy
945
+ required: false
946
+ description: Only recordings started by this user id, or `unknown` for those with no recorded starter.
947
+ schema:
948
+ type: string
949
+ example: 3f2a1b0c-9d8e-4f7a-b6c5-d4e3f2a1b0c9
950
+ - in: query
951
+ name: since
952
+ required: false
953
+ description: Only recordings that started at or after this time (ISO 8601).
954
+ schema:
955
+ type: string
956
+ format: date-time
957
+ example: '2026-09-27T00:00:00Z'
958
+ - in: query
959
+ name: q
960
+ required: false
961
+ description: Case-insensitive text matched against phone names, udids and bookmark labels.
962
+ schema:
963
+ type: string
964
+ example: login
965
+ responses:
966
+ '200':
967
+ description: A page of recordings.
968
+ content:
969
+ application/json:
970
+ schema:
971
+ $ref: '#/components/schemas/RecordingLibraryPage'
972
+ example:
973
+ recordings:
974
+ - groupId: c3b2a1f0-5e4d-4c3b-9a8f-7e6d5c4b3a21
975
+ startedAt: '2026-10-04T08:20:00.000Z'
976
+ endedAt: '2026-10-04T08:23:41.000Z'
977
+ durationMs: 221400
978
+ status: done
979
+ phones:
980
+ - recordingId: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
981
+ udid: R5CT32ABCDE
982
+ name: Galaxy S23
983
+ platform: android
984
+ status: STOPPED
985
+ offsetMs: -320
986
+ durationMs: 221080
987
+ failReason: null
988
+ annotationCount: 2
989
+ startedBy:
990
+ id: 3f2a1b0c-9d8e-4f7a-b6c5-d4e3f2a1b0c9
991
+ name: Priya Raman
992
+ bookmarkCount: 1
993
+ annotationCount: 2
994
+ keptUntil: '2026-11-03T08:20:00.000Z'
995
+ sizeBytes: 18342011
996
+ hasComposite: false
997
+ nextCursor: null
998
+ total: 1
999
+ facets:
1000
+ phones:
1001
+ - udid: R5CT32ABCDE
1002
+ name: Galaxy S23
1003
+ count: 1
1004
+ people:
1005
+ - id: 3f2a1b0c-9d8e-4f7a-b6c5-d4e3f2a1b0c9
1006
+ name: Priya Raman
1007
+ count: 1
1008
+ unknownCount: 0
1009
+ when:
1010
+ any: 1
1011
+ 24h: 1
1012
+ 7d: 1
1013
+ 30d: 1
1014
+ retention:
1015
+ days: 30
1016
+ maxCount: 100
1017
+ '400':
1018
+ description: A query parameter is invalid.
1019
+ content:
1020
+ application/json:
1021
+ schema:
1022
+ $ref: '#/components/schemas/Error'
1023
+ examples:
1024
+ limit:
1025
+ value:
1026
+ error: limit must be a whole number from 1
1027
+ since:
1028
+ value:
1029
+ error: since must be an ISO time
1030
+ '401':
1031
+ $ref: '#/components/responses/Unauthorized'
1032
+ '403':
1033
+ $ref: '#/components/responses/Forbidden'
1034
+ '429':
1035
+ $ref: '#/components/responses/RateLimited'
1036
+ '500':
1037
+ $ref: '#/components/responses/RecordingInternalError'
1038
+ post:
1039
+ operationId: startRecording
1040
+ summary: Start recording one or more phones
1041
+ description: |
1042
+ Starts one recording group: one video per phone and, for two or more phones, a
1043
+ side-by-side composite video. The phone's live stream is started if it isn't running.
1044
+ Each phone is held for the caller (as a live preview is) until the recording ends.
1045
+
1046
+ All or nothing up front: if any phone is busy, or the server's cap on simultaneous
1047
+ recordings would be exceeded, nothing starts (`409`). A phone is busy when it is already
1048
+ being recorded, held by another user, or running an Appium session. Your own preview hold
1049
+ doesn't count. After that, a phone whose capture fails to start is left out of
1050
+ `recordings` and the rest record on.
1051
+
1052
+ Members may record only phones they can see: if any is outside their teams, or unknown,
1053
+ the answer is `404` and nothing starts. Any signed-in user whose credential has the `devices` scope (or `admin`) may call it.
1054
+ With the dashboard cookie the request must come from the same host.
1055
+ tags:
1056
+ - Recordings
1057
+ requestBody:
1058
+ required: true
1059
+ content:
1060
+ application/json:
1061
+ schema:
1062
+ type: object
1063
+ required:
1064
+ - udids
1065
+ properties:
1066
+ udids:
1067
+ type: array
1068
+ minItems: 1
1069
+ items:
1070
+ type: string
1071
+ description: The phones to record.
1072
+ example:
1073
+ - R5CT32ABCDE
1074
+ - 00008110-00084CE80E51401E
1075
+ sessionId:
1076
+ type: string
1077
+ description: An Appium session to link the recordings to. It must be an existing session's id.
1078
+ example: 5b2e9c1a-0f3d-4e6a-8b7c-2d1e0f9a8b7c
1079
+ note:
1080
+ type: string
1081
+ description: Accepted, but not stored.
1082
+ example:
1083
+ udids:
1084
+ - R5CT32ABCDE
1085
+ - 00008110-00084CE80E51401E
1086
+ responses:
1087
+ '202':
1088
+ description: Recording started.
1089
+ content:
1090
+ application/json:
1091
+ schema:
1092
+ type: object
1093
+ required:
1094
+ - groupId
1095
+ - recordings
1096
+ - startedAt
1097
+ - compositeEnabled
1098
+ properties:
1099
+ groupId:
1100
+ type: string
1101
+ example: c3b2a1f0-5e4d-4c3b-9a8f-7e6d5c4b3a21
1102
+ recordings:
1103
+ type: array
1104
+ description: The phones now recording. A phone whose capture failed to start is not listed.
1105
+ items:
1106
+ $ref: '#/components/schemas/RecordingStarted'
1107
+ startedAt:
1108
+ type: string
1109
+ format: date-time
1110
+ compositeEnabled:
1111
+ type: boolean
1112
+ description: Whether a side-by-side composite is being recorded.
1113
+ example:
1114
+ groupId: c3b2a1f0-5e4d-4c3b-9a8f-7e6d5c4b3a21
1115
+ recordings:
1116
+ - id: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
1117
+ udid: R5CT32ABCDE
1118
+ status: RECORDING
1119
+ - id: 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d
1120
+ udid: 00008110-00084CE80E51401E
1121
+ status: RECORDING
1122
+ startedAt: '2026-10-04T08:20:00.000Z'
1123
+ compositeEnabled: true
1124
+ '400':
1125
+ description: '`udids` is missing, empty, or holds something other than non-empty strings.'
1126
+ content:
1127
+ application/json:
1128
+ schema:
1129
+ $ref: '#/components/schemas/Error'
1130
+ example:
1131
+ error: udids must be a non-empty array
1132
+ '401':
1133
+ $ref: '#/components/responses/Unauthorized'
1134
+ '403':
1135
+ $ref: '#/components/responses/Forbidden'
1136
+ '404':
1137
+ $ref: '#/components/responses/RecordingNotFound'
1138
+ '409':
1139
+ $ref: '#/components/responses/RecordingStartConflict'
1140
+ '429':
1141
+ $ref: '#/components/responses/RateLimited'
1142
+ '500':
1143
+ $ref: '#/components/responses/RecordingInternalError'
1144
+ /api/recordings/active:
1145
+ get:
1146
+ operationId: listMyActiveRecordings
1147
+ summary: List your recordings that are still running
1148
+ description: |
1149
+ The recording groups still running that the caller holds a phone of, so the Live
1150
+ devices page can pick them back up after a reload. A group is the caller's when they hold
1151
+ the preview hold on any of its phones; admins get only their own too. Phones the caller
1152
+ can't see are left out, with their marks. Only marks still on screen (not yet cleared)
1153
+ are returned.
1154
+
1155
+ `startedAt` is the group's t=0, which mark timecodes count from. `serverNow` lets the
1156
+ client correct for clock skew. Any signed-in user may call it.
1157
+ tags:
1158
+ - Recordings
1159
+ responses:
1160
+ '200':
1161
+ description: The caller's running recordings.
1162
+ content:
1163
+ application/json:
1164
+ schema:
1165
+ type: object
1166
+ required:
1167
+ - serverNow
1168
+ - groups
1169
+ properties:
1170
+ serverNow:
1171
+ type: integer
1172
+ description: The server's clock, epoch milliseconds.
1173
+ example: 1759566120000
1174
+ groups:
1175
+ type: array
1176
+ items:
1177
+ $ref: '#/components/schemas/RecordingActiveGroup'
1178
+ example:
1179
+ serverNow: 1759566120000
1180
+ groups:
1181
+ - groupId: c3b2a1f0-5e4d-4c3b-9a8f-7e6d5c4b3a21
1182
+ startedAt: '2026-10-04T08:20:00.000Z'
1183
+ recordings:
1184
+ - id: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
1185
+ udid: R5CT32ABCDE
1186
+ annotations:
1187
+ - recordingId: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
1188
+ shape: RECT
1189
+ geometry: '{"x":0.12,"y":0.4,"w":0.3,"h":0.08}'
1190
+ color: red
1191
+ text: null
1192
+ timecodeMs: 41250
1193
+ compositeEnabled: false
1194
+ '401':
1195
+ $ref: '#/components/responses/Unauthorized'
1196
+ '403':
1197
+ $ref: '#/components/responses/Forbidden'
1198
+ '429':
1199
+ $ref: '#/components/responses/RateLimited'
1200
+ '500':
1201
+ $ref: '#/components/responses/RecordingInternalError'
1202
+ /api/recordings/{groupId}:
1203
+ get:
1204
+ operationId: getRecording
1205
+ summary: Get one recording with its bookmarks and marks
1206
+ description: |
1207
+ One recording group: its stored rows (one per phone), its summary, and its bookmarks and
1208
+ marks, each sorted by timecode. Only the phones the caller can see are included; with
1209
+ none, the answer is `404`. Any signed-in user may call it.
1210
+ tags:
1211
+ - Recordings
1212
+ parameters:
1213
+ - $ref: '#/components/parameters/RecordingGroupId'
1214
+ responses:
1215
+ '200':
1216
+ description: The recording.
1217
+ content:
1218
+ application/json:
1219
+ schema:
1220
+ type: object
1221
+ required:
1222
+ - groupId
1223
+ - recordings
1224
+ - summary
1225
+ - bookmarks
1226
+ - annotations
1227
+ properties:
1228
+ groupId:
1229
+ type: string
1230
+ example: c3b2a1f0-5e4d-4c3b-9a8f-7e6d5c4b3a21
1231
+ recordings:
1232
+ type: array
1233
+ description: The stored rows of the phones the caller can see, with their bookmarks and marks as stored.
1234
+ items:
1235
+ $ref: '#/components/schemas/RecordingRow'
1236
+ summary:
1237
+ $ref: '#/components/schemas/RecordingSummary'
1238
+ bookmarks:
1239
+ type: array
1240
+ items:
1241
+ $ref: '#/components/schemas/RecordingBookmarkView'
1242
+ annotations:
1243
+ type: array
1244
+ items:
1245
+ $ref: '#/components/schemas/RecordingAnnotationView'
1246
+ example:
1247
+ groupId: c3b2a1f0-5e4d-4c3b-9a8f-7e6d5c4b3a21
1248
+ recordings:
1249
+ - id: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
1250
+ group_id: c3b2a1f0-5e4d-4c3b-9a8f-7e6d5c4b3a21
1251
+ device_udid: R5CT32ABCDE
1252
+ device_host: 127.0.0.1
1253
+ session_id: null
1254
+ started_at: '2026-10-04T08:20:00.000Z'
1255
+ ended_at: '2026-10-04T08:23:41.000Z'
1256
+ status: STOPPED
1257
+ file_path: /home/lab/.cache/xenon/assets/8f14e45f-ceea-467a-9b36-2f1c5d0e7a11/video/8f14e45f-ceea-467a-9b36-2f1c5d0e7a11.mp4
1258
+ duration_ms: 221080
1259
+ size_bytes: 18342011
1260
+ device_snapshot: null
1261
+ fail_reason: null
1262
+ started_by: 3f2a1b0c-9d8e-4f7a-b6c5-d4e3f2a1b0c9
1263
+ bookmarks:
1264
+ - id: 4c5d6e7f-8a9b-4c0d-9e1f-2a3b4c5d6e7f
1265
+ recording_id: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
1266
+ timecode_ms: 38000
1267
+ label: Login failed
1268
+ note: Spinner never stops
1269
+ created_at: '2026-10-04T08:20:38.000Z'
1270
+ annotations: []
1271
+ summary:
1272
+ groupId: c3b2a1f0-5e4d-4c3b-9a8f-7e6d5c4b3a21
1273
+ startedAt: '2026-10-04T08:20:00.000Z'
1274
+ endedAt: '2026-10-04T08:23:41.000Z'
1275
+ durationMs: 221400
1276
+ status: done
1277
+ phones:
1278
+ - recordingId: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
1279
+ udid: R5CT32ABCDE
1280
+ name: Galaxy S23
1281
+ platform: android
1282
+ status: STOPPED
1283
+ offsetMs: -320
1284
+ durationMs: 221080
1285
+ failReason: null
1286
+ annotationCount: 0
1287
+ startedBy:
1288
+ id: 3f2a1b0c-9d8e-4f7a-b6c5-d4e3f2a1b0c9
1289
+ name: Priya Raman
1290
+ bookmarkCount: 1
1291
+ annotationCount: 0
1292
+ keptUntil: '2026-11-03T08:20:00.000Z'
1293
+ sizeBytes: 18342011
1294
+ hasComposite: false
1295
+ bookmarks:
1296
+ - id: 4c5d6e7f-8a9b-4c0d-9e1f-2a3b4c5d6e7f
1297
+ recordingId: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
1298
+ timecodeMs: 38000
1299
+ label: Login failed
1300
+ note: Spinner never stops
1301
+ annotations: []
1302
+ '401':
1303
+ $ref: '#/components/responses/Unauthorized'
1304
+ '403':
1305
+ $ref: '#/components/responses/Forbidden'
1306
+ '404':
1307
+ $ref: '#/components/responses/RecordingNotFound'
1308
+ '429':
1309
+ $ref: '#/components/responses/RateLimited'
1310
+ '500':
1311
+ $ref: '#/components/responses/RecordingInternalError'
1312
+ delete:
1313
+ operationId: deleteRecording
1314
+ summary: Delete a recording
1315
+ description: |
1316
+ Deletes the recording's videos, bookmarks and marks, for the phones the caller can see.
1317
+ A phone on another team stays, with the composite video, until no phone of the recording
1318
+ is left; an admin sees every phone, so deletes the whole recording. Irreversible.
1319
+
1320
+ Only the person who started the recording, or an admin, may delete it (`403 not_owner`).
1321
+ A recording any of whose phones is still recording can't be deleted (`409`). An API key
1322
+ needs the `devices` scope (or `admin`). With the dashboard cookie the request must come
1323
+ from the same host. The checks run in the order `404`, `409`, `403`.
1324
+ tags:
1325
+ - Recordings
1326
+ parameters:
1327
+ - $ref: '#/components/parameters/RecordingGroupId'
1328
+ responses:
1329
+ '204':
1330
+ description: Deleted.
1331
+ '401':
1332
+ $ref: '#/components/responses/Unauthorized'
1333
+ '403':
1334
+ description: |
1335
+ Not the person who started it, or the credential lacks the `devices` scope, or a
1336
+ browser request failed the same-origin check.
1337
+ content:
1338
+ application/json:
1339
+ schema:
1340
+ $ref: '#/components/schemas/Error'
1341
+ examples:
1342
+ notOwner:
1343
+ value:
1344
+ error: not_owner
1345
+ message: Only the person who recorded it, or an admin, can delete it.
1346
+ scope:
1347
+ value:
1348
+ error: insufficient scope
1349
+ '404':
1350
+ $ref: '#/components/responses/RecordingNotFound'
1351
+ '409':
1352
+ description: A phone of this recording is still recording.
1353
+ content:
1354
+ application/json:
1355
+ schema:
1356
+ $ref: '#/components/schemas/Error'
1357
+ example:
1358
+ error: recording_in_progress
1359
+ message: Stop the recording on Live devices before deleting it.
1360
+ '429':
1361
+ $ref: '#/components/responses/RateLimited'
1362
+ '500':
1363
+ $ref: '#/components/responses/RecordingInternalError'
1364
+ /api/recordings/{groupId}/add-device:
1365
+ post:
1366
+ operationId: addPhoneToRecording
1367
+ summary: Add a phone to a running recording
1368
+ description: |
1369
+ Starts recording one more phone in an existing group. Its marks are placed on the group's
1370
+ timeline by how late it joined. It doesn't join the side-by-side composite, which keeps
1371
+ the phones it started with.
1372
+
1373
+ Like starting a recording, it is refused with `409` if the phone is busy or the server's
1374
+ recording cap is reached, and nothing starts. A member gets `404` for a group they can't
1375
+ see any phone of, or a phone outside their teams. Any signed-in user whose credential has the `devices` scope (or `admin`) may call it.
1376
+ With the dashboard cookie the request must come from the same host.
1377
+ tags:
1378
+ - Recordings
1379
+ parameters:
1380
+ - $ref: '#/components/parameters/RecordingGroupId'
1381
+ requestBody:
1382
+ required: true
1383
+ content:
1384
+ application/json:
1385
+ schema:
1386
+ type: object
1387
+ required:
1388
+ - udid
1389
+ properties:
1390
+ udid:
1391
+ type: string
1392
+ description: The phone to add.
1393
+ example: R5CT32ABCDE
1394
+ example:
1395
+ udid: R5CT32ABCDE
1396
+ responses:
1397
+ '201':
1398
+ description: The phone is recording.
1399
+ content:
1400
+ application/json:
1401
+ schema:
1402
+ type: object
1403
+ required:
1404
+ - recording
1405
+ properties:
1406
+ recording:
1407
+ $ref: '#/components/schemas/RecordingStarted'
1408
+ example:
1409
+ recording:
1410
+ id: 6e7f8a9b-0c1d-4e2f-8a3b-4c5d6e7f8a9b
1411
+ udid: R5CT32ABCDE
1412
+ status: RECORDING
1413
+ '400':
1414
+ description: '`udid` is missing or empty.'
1415
+ content:
1416
+ application/json:
1417
+ schema:
1418
+ $ref: '#/components/schemas/Error'
1419
+ example:
1420
+ error: udid must be a non-empty string
1421
+ '401':
1422
+ $ref: '#/components/responses/Unauthorized'
1423
+ '403':
1424
+ $ref: '#/components/responses/Forbidden'
1425
+ '404':
1426
+ $ref: '#/components/responses/RecordingNotFound'
1427
+ '409':
1428
+ $ref: '#/components/responses/RecordingStartConflict'
1429
+ '429':
1430
+ $ref: '#/components/responses/RateLimited'
1431
+ '500':
1432
+ $ref: '#/components/responses/RecordingInternalError'
1433
+ /api/recordings/{groupId}/stop:
1434
+ post:
1435
+ operationId: stopRecording
1436
+ summary: Stop a recording
1437
+ description: |
1438
+ Stops every phone of the group, the side-by-side composite first, and finishes each video
1439
+ separately: a phone whose video fails doesn't stop the others. Each phone is then let go
1440
+ (its preview hold released once nobody watches it).
1441
+
1442
+ Stopping is group-wide even for a caller who sees only some of its phones; the answer
1443
+ lists only the phones they can see. Each video's `status` is `STOPPED`, or `FAILED` when
1444
+ its file is missing or too small to play. `durationMs` is the video's own length.
1445
+
1446
+ A phone whose recording had already ended (its stream ended earlier, or the group was
1447
+ already stopped) is left as it is: its end, length and failure reason are kept, and the
1448
+ phone isn't let go a second time. It is reported with its stored `status`, `durationMs`
1449
+ and `sizeBytes`, so stopping a group again changes none of its videos. A phone whose
1450
+ stream is ending at that very moment is reported as it stands, and may still say
1451
+ `RECORDING`.
1452
+
1453
+ A member gets `404` for a group they can't see any phone of. Any signed-in user whose credential has the `devices` scope (or `admin`) may call it.
1454
+ With the dashboard cookie the request must come from the same host.
1455
+ tags:
1456
+ - Recordings
1457
+ parameters:
1458
+ - $ref: '#/components/parameters/RecordingGroupId'
1459
+ responses:
1460
+ '200':
1461
+ description: Stopped.
1462
+ content:
1463
+ application/json:
1464
+ schema:
1465
+ type: object
1466
+ required:
1467
+ - groupId
1468
+ - recordings
1469
+ properties:
1470
+ groupId:
1471
+ type: string
1472
+ example: c3b2a1f0-5e4d-4c3b-9a8f-7e6d5c4b3a21
1473
+ recordings:
1474
+ type: array
1475
+ items:
1476
+ $ref: '#/components/schemas/RecordingStopped'
1477
+ example:
1478
+ groupId: c3b2a1f0-5e4d-4c3b-9a8f-7e6d5c4b3a21
1479
+ recordings:
1480
+ - id: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
1481
+ udid: R5CT32ABCDE
1482
+ status: STOPPED
1483
+ durationMs: 221080
1484
+ sizeBytes: 18342011
1485
+ '401':
1486
+ $ref: '#/components/responses/Unauthorized'
1487
+ '403':
1488
+ $ref: '#/components/responses/Forbidden'
1489
+ '404':
1490
+ $ref: '#/components/responses/RecordingNotFound'
1491
+ '429':
1492
+ $ref: '#/components/responses/RateLimited'
1493
+ '500':
1494
+ $ref: '#/components/responses/RecordingInternalError'
1495
+ /api/recordings/{groupId}/bookmark:
1496
+ post:
1497
+ operationId: addRecordingBookmark
1498
+ summary: Add a bookmark to a phone's recording
1499
+ description: |
1500
+ Adds a labelled bookmark at a point on the group's timeline, on one phone's recording.
1501
+ The recording must belong to this group and be on a phone the caller can see, otherwise
1502
+ `404`. Dashboards watching the recording are told at once. Any signed-in user whose credential has the `devices` scope (or `admin`) may call it.
1503
+ With the dashboard cookie the request must come from the same host.
1504
+ tags:
1505
+ - Recordings
1506
+ parameters:
1507
+ - $ref: '#/components/parameters/RecordingGroupId'
1508
+ requestBody:
1509
+ required: true
1510
+ content:
1511
+ application/json:
1512
+ schema:
1513
+ type: object
1514
+ required:
1515
+ - recordingId
1516
+ - timecodeMs
1517
+ - label
1518
+ properties:
1519
+ recordingId:
1520
+ type: string
1521
+ description: The phone's recording (`recordings[].id`).
1522
+ example: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
1523
+ timecodeMs:
1524
+ type: integer
1525
+ description: Milliseconds from the group's t=0.
1526
+ example: 38000
1527
+ label:
1528
+ type: string
1529
+ example: Login failed
1530
+ note:
1531
+ type: string
1532
+ example: Spinner never stops
1533
+ responses:
1534
+ '201':
1535
+ description: The bookmark as stored.
1536
+ content:
1537
+ application/json:
1538
+ schema:
1539
+ $ref: '#/components/schemas/RecordingBookmarkRow'
1540
+ example:
1541
+ id: 4c5d6e7f-8a9b-4c0d-9e1f-2a3b4c5d6e7f
1542
+ recording_id: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
1543
+ timecode_ms: 38000
1544
+ label: Login failed
1545
+ note: Spinner never stops
1546
+ created_at: '2026-10-04T08:20:38.000Z'
1547
+ '400':
1548
+ description: A required field is missing or has the wrong type.
1549
+ content:
1550
+ application/json:
1551
+ schema:
1552
+ $ref: '#/components/schemas/Error'
1553
+ example:
1554
+ error: recordingId (string), timecodeMs (number) and label (string) are required
1555
+ '401':
1556
+ $ref: '#/components/responses/Unauthorized'
1557
+ '403':
1558
+ $ref: '#/components/responses/Forbidden'
1559
+ '404':
1560
+ $ref: '#/components/responses/RecordingNotFound'
1561
+ '429':
1562
+ $ref: '#/components/responses/RateLimited'
1563
+ '500':
1564
+ $ref: '#/components/responses/RecordingInternalError'
1565
+ /api/recordings/{groupId}/annotation:
1566
+ post:
1567
+ operationId: addRecordingAnnotation
1568
+ summary: Draw a mark on a phone's recording
1569
+ description: |
1570
+ Adds a mark (a box, circle, arrow, text or freehand stroke) at a point on the group's
1571
+ timeline, on one phone's recording. It stays on screen until the marks are cleared
1572
+ (`annotations/clear`), and is burned into the downloaded videos.
1573
+
1574
+ `geometry` is a JSON string with the mark's box in coordinates from 0 to 1 of the frame:
1575
+ `{"x":…,"y":…,"w":…,"h":…}`. `image` is optional: the mark as the browser drew it, a PNG
1576
+ data URL of at most 2 MiB decoded, burned in as drawn instead of the server's own drawing.
1577
+
1578
+ The recording must belong to this group and be on a phone the caller can see, otherwise
1579
+ `404`. Dashboards watching the recording are told at once. Any signed-in user whose credential has the `devices` scope (or `admin`) may call it.
1580
+ With the dashboard cookie the request must come from the same host.
1581
+ tags:
1582
+ - Recordings
1583
+ parameters:
1584
+ - $ref: '#/components/parameters/RecordingGroupId'
1585
+ requestBody:
1586
+ required: true
1587
+ content:
1588
+ application/json:
1589
+ schema:
1590
+ type: object
1591
+ required:
1592
+ - recordingId
1593
+ - timecodeMs
1594
+ - shape
1595
+ - geometry
1596
+ - color
1597
+ properties:
1598
+ recordingId:
1599
+ type: string
1600
+ example: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
1601
+ timecodeMs:
1602
+ type: integer
1603
+ description: Milliseconds from the group's t=0.
1604
+ example: 41250
1605
+ shape:
1606
+ type: string
1607
+ description: Any other value is drawn as a box.
1608
+ enum:
1609
+ - RECT
1610
+ - CIRCLE
1611
+ - ARROW
1612
+ - TEXT
1613
+ - FREEHAND
1614
+ example: RECT
1615
+ geometry:
1616
+ type: string
1617
+ description: JSON with `x`, `y`, `w`, `h`, each from 0 to 1 of the frame.
1618
+ example: '{"x":0.12,"y":0.4,"w":0.3,"h":0.08}'
1619
+ color:
1620
+ type: string
1621
+ example: red
1622
+ text:
1623
+ type: string
1624
+ description: The text of a `TEXT` mark.
1625
+ example: Wrong total
1626
+ author:
1627
+ type: string
1628
+ description: Stored with the mark.
1629
+ image:
1630
+ type: string
1631
+ description: The mark as drawn, `data:image/png;base64,…`, at most 2 MiB decoded.
1632
+ example: data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==
1633
+ responses:
1634
+ '201':
1635
+ description: The mark as stored.
1636
+ content:
1637
+ application/json:
1638
+ schema:
1639
+ $ref: '#/components/schemas/RecordingAnnotationRow'
1640
+ example:
1641
+ id: 9b8a7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d
1642
+ recording_id: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
1643
+ timecode_ms: 41250
1644
+ end_timecode_ms: null
1645
+ shape: RECT
1646
+ geometry: '{"x":0.12,"y":0.4,"w":0.3,"h":0.08}'
1647
+ color: red
1648
+ text: null
1649
+ author: null
1650
+ created_at: '2026-10-04T08:20:41.000Z'
1651
+ '400':
1652
+ description: A required field is missing or has the wrong type, or `image` is refused.
1653
+ content:
1654
+ application/json:
1655
+ schema:
1656
+ $ref: '#/components/schemas/Error'
1657
+ examples:
1658
+ fields:
1659
+ value:
1660
+ error: recordingId, timecodeMs, shape, geometry, color are required
1661
+ notPng:
1662
+ value:
1663
+ error: image is not a PNG
1664
+ tooLarge:
1665
+ value:
1666
+ error: image exceeds 2097152 bytes
1667
+ '401':
1668
+ $ref: '#/components/responses/Unauthorized'
1669
+ '403':
1670
+ $ref: '#/components/responses/Forbidden'
1671
+ '404':
1672
+ $ref: '#/components/responses/RecordingNotFound'
1673
+ '429':
1674
+ $ref: '#/components/responses/RateLimited'
1675
+ '500':
1676
+ $ref: '#/components/responses/RecordingInternalError'
1677
+ /api/recordings/{groupId}/annotations/clear:
1678
+ post:
1679
+ operationId: clearRecordingAnnotations
1680
+ summary: Clear the marks on screen at a point in time
1681
+ description: |
1682
+ Ends, at `timecodeMs`, every mark of the group still on screen that started at or before
1683
+ it. Marks are kept (they show up to that point in the video), not deleted. Marks that
1684
+ started later, or were already cleared, are left alone, so repeating a clear changes
1685
+ nothing. Only the marks on phones the caller can see are cleared; an admin's clear covers
1686
+ the whole group.
1687
+
1688
+ A member gets `404` for a group they can't see any phone of. Any signed-in user whose credential has the `devices` scope (or `admin`) may call it.
1689
+ With the dashboard cookie the request must come from the same host.
1690
+ tags:
1691
+ - Recordings
1692
+ parameters:
1693
+ - $ref: '#/components/parameters/RecordingGroupId'
1694
+ requestBody:
1695
+ required: true
1696
+ content:
1697
+ application/json:
1698
+ schema:
1699
+ type: object
1700
+ required:
1701
+ - timecodeMs
1702
+ properties:
1703
+ timecodeMs:
1704
+ type: number
1705
+ minimum: 0
1706
+ description: Milliseconds from the group's t=0. Rounded to a whole millisecond.
1707
+ example: 52000
1708
+ example:
1709
+ timecodeMs: 52000
1710
+ responses:
1711
+ '200':
1712
+ description: How many marks were ended.
1713
+ content:
1714
+ application/json:
1715
+ schema:
1716
+ type: object
1717
+ required:
1718
+ - cleared
1719
+ properties:
1720
+ cleared:
1721
+ type: integer
1722
+ example: 3
1723
+ example:
1724
+ cleared: 3
1725
+ '400':
1726
+ description: '`timecodeMs` is missing, negative or not a number.'
1727
+ content:
1728
+ application/json:
1729
+ schema:
1730
+ $ref: '#/components/schemas/Error'
1731
+ example:
1732
+ error: timecodeMs must be a finite number >= 0
1733
+ '401':
1734
+ $ref: '#/components/responses/Unauthorized'
1735
+ '403':
1736
+ $ref: '#/components/responses/Forbidden'
1737
+ '404':
1738
+ $ref: '#/components/responses/RecordingNotFound'
1739
+ '429':
1740
+ $ref: '#/components/responses/RateLimited'
1741
+ '500':
1742
+ $ref: '#/components/responses/RecordingInternalError'
1743
+ /api/recordings/{groupId}/composite.mp4:
1744
+ get:
1745
+ operationId: getRecordingCompositeVideo
1746
+ summary: Download the side-by-side video of a recording
1747
+ description: |
1748
+ The composite video of a recording of two or more phones, all phones side by side, with
1749
+ the marks burned in when that rendering works (otherwise without them).
1750
+
1751
+ `404 composite_not_found` when the recording has no composite (one phone, or the
1752
+ composite didn't start) and for a caller who can't see every phone in it, since it shows
1753
+ them all. Composites recorded before layouts were kept are served to admins only.
1754
+
1755
+ Supports `Range` requests so a player can seek (`206`, or `416` with `Content-Range:
1756
+ bytes */<size>`), and sends `ETag` and `Last-Modified`. Any signed-in user may call it.
1757
+ tags:
1758
+ - Recordings
1759
+ parameters:
1760
+ - $ref: '#/components/parameters/RecordingGroupId'
1761
+ - $ref: '#/components/parameters/RecordingRange'
1762
+ responses:
1763
+ '200':
1764
+ description: The composite video.
1765
+ headers:
1766
+ Accept-Ranges:
1767
+ schema:
1768
+ type: string
1769
+ example: bytes
1770
+ ETag:
1771
+ schema:
1772
+ type: string
1773
+ example: W/"117c3b7-19a8f2c4e10"
1774
+ Last-Modified:
1775
+ schema:
1776
+ type: string
1777
+ example: Sun, 04 Oct 2026 08:24:31 GMT
1778
+ content:
1779
+ video/mp4:
1780
+ schema:
1781
+ type: string
1782
+ format: binary
1783
+ '206':
1784
+ $ref: '#/components/responses/RecordingVideoPartial'
1785
+ '401':
1786
+ $ref: '#/components/responses/Unauthorized'
1787
+ '403':
1788
+ $ref: '#/components/responses/Forbidden'
1789
+ '404':
1790
+ description: >-
1791
+ No composite the caller may have, or its file went missing before it could be sent.
1792
+ content:
1793
+ application/json:
1794
+ schema:
1795
+ $ref: '#/components/schemas/Error'
1796
+ examples:
1797
+ notFound:
1798
+ value:
1799
+ error: composite_not_found
1800
+ noFile:
1801
+ value:
1802
+ error: video_not_found
1803
+ '416':
1804
+ $ref: '#/components/responses/RecordingRangeNotSatisfiable'
1805
+ '429':
1806
+ $ref: '#/components/responses/RateLimited'
1807
+ '500':
1808
+ description: The file could not be sent.
1809
+ content:
1810
+ application/json:
1811
+ schema:
1812
+ $ref: '#/components/schemas/Error'
1813
+ example:
1814
+ error: internal
1815
+ /api/recordings/{groupId}/video.mp4:
1816
+ get:
1817
+ operationId: downloadRecordingVideo
1818
+ summary: Download one phone's video, with marks
1819
+ description: |
1820
+ One phone's video as an attachment (`<udid>.mp4`), with its marks burned in when that
1821
+ rendering works. Pick the phone with `udid`; without it, this works only when the caller
1822
+ can see exactly one phone with a video in the group (otherwise use `videos.zip`).
1823
+
1824
+ Supports `Range` requests (`206`, or `416` with `Content-Range: bytes */<size>`). Only
1825
+ phones the caller can see are served. Any signed-in user may call it.
1826
+ tags:
1827
+ - Recordings
1828
+ parameters:
1829
+ - $ref: '#/components/parameters/RecordingGroupId'
1830
+ - in: query
1831
+ name: udid
1832
+ required: false
1833
+ description: The phone whose video to download.
1834
+ schema:
1835
+ type: string
1836
+ example: R5CT32ABCDE
1837
+ - $ref: '#/components/parameters/RecordingRange'
1838
+ responses:
1839
+ '200':
1840
+ $ref: '#/components/responses/RecordingVideoAttachment'
1841
+ '206':
1842
+ $ref: '#/components/responses/RecordingVideoPartial'
1843
+ '401':
1844
+ $ref: '#/components/responses/Unauthorized'
1845
+ '403':
1846
+ $ref: '#/components/responses/Forbidden'
1847
+ '404':
1848
+ description: No phone of the group the caller can see, or no video for the phone asked for.
1849
+ content:
1850
+ application/json:
1851
+ schema:
1852
+ $ref: '#/components/schemas/Error'
1853
+ examples:
1854
+ notFound:
1855
+ value:
1856
+ error: not_found
1857
+ noVideoForPhone:
1858
+ value:
1859
+ error: video_not_found
1860
+ message: No playable video for that device
1861
+ severalVideos:
1862
+ value:
1863
+ error: video_not_found
1864
+ message: Use videos.zip when the group has multiple recordings
1865
+ '416':
1866
+ $ref: '#/components/responses/RecordingRangeNotSatisfiable'
1867
+ '429':
1868
+ $ref: '#/components/responses/RateLimited'
1869
+ '500':
1870
+ $ref: '#/components/responses/RecordingInternalError'
1871
+ /api/recordings/{groupId}/source.mp4:
1872
+ get:
1873
+ operationId: streamRecordingSourceVideo
1874
+ summary: Stream one phone's video, without marks
1875
+ description: |
1876
+ One phone's video as recorded, with no marks burned in: what the recording page's player
1877
+ plays (it draws the marks itself). Supports `Range` requests so the player can seek
1878
+ (`206`, or `416` with `Content-Range: bytes */<size>`). With `download=1` it is sent as an
1879
+ attachment named `<udid>.mp4`.
1880
+
1881
+ The recording must belong to this group and be on a phone the caller can see, otherwise
1882
+ `404`. Any signed-in user may call it.
1883
+ tags:
1884
+ - Recordings
1885
+ parameters:
1886
+ - $ref: '#/components/parameters/RecordingGroupId'
1887
+ - in: query
1888
+ name: recordingId
1889
+ required: true
1890
+ description: The phone's recording (`recordings[].id`).
1891
+ schema:
1892
+ type: string
1893
+ example: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
1894
+ - in: query
1895
+ name: download
1896
+ required: false
1897
+ description: '`1` to send it as an attachment.'
1898
+ schema:
1899
+ type: string
1900
+ enum:
1901
+ - '1'
1902
+ - $ref: '#/components/parameters/RecordingRange'
1903
+ responses:
1904
+ '200':
1905
+ description: The video.
1906
+ headers:
1907
+ Accept-Ranges:
1908
+ schema:
1909
+ type: string
1910
+ example: bytes
1911
+ content:
1912
+ video/mp4:
1913
+ schema:
1914
+ type: string
1915
+ format: binary
1916
+ '206':
1917
+ $ref: '#/components/responses/RecordingVideoPartial'
1918
+ '400':
1919
+ description: '`recordingId` is missing.'
1920
+ content:
1921
+ application/json:
1922
+ schema:
1923
+ $ref: '#/components/schemas/Error'
1924
+ example:
1925
+ error: recordingId query param is required
1926
+ '401':
1927
+ $ref: '#/components/responses/Unauthorized'
1928
+ '403':
1929
+ $ref: '#/components/responses/Forbidden'
1930
+ '404':
1931
+ description: Not a recording of this group the caller can see, or its file is gone.
1932
+ content:
1933
+ application/json:
1934
+ schema:
1935
+ $ref: '#/components/schemas/Error'
1936
+ examples:
1937
+ notFound:
1938
+ value:
1939
+ error: not_found
1940
+ noFile:
1941
+ value:
1942
+ error: video_not_found
1943
+ '416':
1944
+ $ref: '#/components/responses/RecordingRangeNotSatisfiable'
1945
+ '429':
1946
+ $ref: '#/components/responses/RateLimited'
1947
+ '500':
1948
+ $ref: '#/components/responses/RecordingInternalError'
1949
+ /api/recordings/{groupId}/exports/annotated.mp4:
1950
+ get:
1951
+ operationId: exportAnnotatedRecordingVideo
1952
+ summary: Download one phone's video with marks burned in
1953
+ description: |
1954
+ One phone's video with its marks burned into the picture. Rendered on first request and
1955
+ then kept, so the first download of a recording with marks can take a while (it is
1956
+ usually prepared when the recording stops). A recording with no marks is served as
1957
+ recorded. Streamed whole: no `Range` support.
1958
+
1959
+ The recording must belong to this group and be on a phone the caller can see, otherwise
1960
+ `404`. Any signed-in user may call it.
1961
+ tags:
1962
+ - Recordings
1963
+ parameters:
1964
+ - $ref: '#/components/parameters/RecordingGroupId'
1965
+ - in: query
1966
+ name: recordingId
1967
+ required: true
1968
+ description: The phone's recording (`recordings[].id`).
1969
+ schema:
1970
+ type: string
1971
+ example: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
1972
+ responses:
1973
+ '200':
1974
+ description: The video with marks.
1975
+ content:
1976
+ video/mp4:
1977
+ schema:
1978
+ type: string
1979
+ format: binary
1980
+ '400':
1981
+ description: '`recordingId` is missing.'
1982
+ content:
1983
+ application/json:
1984
+ schema:
1985
+ $ref: '#/components/schemas/Error'
1986
+ example:
1987
+ error: recordingId query param is required
1988
+ '401':
1989
+ $ref: '#/components/responses/Unauthorized'
1990
+ '403':
1991
+ $ref: '#/components/responses/Forbidden'
1992
+ '404':
1993
+ $ref: '#/components/responses/RecordingNotFound'
1994
+ '429':
1995
+ $ref: '#/components/responses/RateLimited'
1996
+ '500':
1997
+ description: The video could not be rendered or read.
1998
+ content:
1999
+ application/json:
2000
+ schema:
2001
+ $ref: '#/components/schemas/Error'
2002
+ example:
2003
+ error: render_failed
2004
+ message: ffmpeg exited with code 1
2005
+ /api/recordings/{groupId}/videos.zip:
2006
+ get:
2007
+ operationId: downloadRecordingVideosZip
2008
+ summary: Download a recording's videos as a zip
2009
+ description: |
2010
+ A zip of the recording's videos: `<udid>.mp4` for each phone the caller can see that has
2011
+ a video, with its marks burned in when that rendering works, and `composite.mp4` when
2012
+ there is a composite the caller may have (see `composite.mp4`). Nothing else. Any
2013
+ signed-in user may call it.
2014
+ tags:
2015
+ - Recordings
2016
+ parameters:
2017
+ - $ref: '#/components/parameters/RecordingGroupId'
2018
+ responses:
2019
+ '200':
2020
+ description: The zip, streamed.
2021
+ headers:
2022
+ Content-Disposition:
2023
+ schema:
2024
+ type: string
2025
+ example: attachment; filename="videos-c3b2a1f0-5e4d-4c3b-9a8f-7e6d5c4b3a21.zip"
2026
+ content:
2027
+ application/zip:
2028
+ schema:
2029
+ type: string
2030
+ format: binary
2031
+ '401':
2032
+ $ref: '#/components/responses/Unauthorized'
2033
+ '403':
2034
+ $ref: '#/components/responses/Forbidden'
2035
+ '404':
2036
+ description: No phone of the group the caller can see, or none of them has a video.
2037
+ content:
2038
+ application/json:
2039
+ schema:
2040
+ $ref: '#/components/schemas/Error'
2041
+ examples:
2042
+ notFound:
2043
+ value:
2044
+ error: not_found
2045
+ noVideos:
2046
+ value:
2047
+ error: no_videos
2048
+ message: No playable videos in this group
2049
+ '429':
2050
+ $ref: '#/components/responses/RateLimited'
2051
+ '500':
2052
+ $ref: '#/components/responses/RecordingInternalError'
2053
+ /api/recordings/{groupId}/bundle.zip:
2054
+ get:
2055
+ operationId: downloadRecordingProofBundle
2056
+ summary: Download a recording's proof bundle
2057
+ description: |
2058
+ A self-contained zip of the recording, for the phones the caller can see:
2059
+
2060
+ - `manifest.json`: the group and each phone's recording (id, status, times, size);
2061
+ - `README.md`: a readable summary;
2062
+ - `composite.mp4`, when there is a composite the caller may have (marks burned in when
2063
+ that rendering works);
2064
+ - per phone, under `devices/<udid>/`: `video.mp4` (as recorded, no marks),
2065
+ `bookmarks.json`, `annotations.json` and `device.json`.
2066
+
2067
+ Streamed as it is built: an error after it has started cuts the download short. Any
2068
+ signed-in user may call it.
2069
+ tags:
2070
+ - Recordings
2071
+ parameters:
2072
+ - $ref: '#/components/parameters/RecordingGroupId'
2073
+ responses:
2074
+ '200':
2075
+ description: The proof bundle, streamed.
2076
+ headers:
2077
+ Content-Disposition:
2078
+ schema:
2079
+ type: string
2080
+ example: attachment; filename="proof-c3b2a1f0-5e4d-4c3b-9a8f-7e6d5c4b3a21.zip"
2081
+ content:
2082
+ application/zip:
2083
+ schema:
2084
+ type: string
2085
+ format: binary
2086
+ '401':
2087
+ $ref: '#/components/responses/Unauthorized'
2088
+ '403':
2089
+ $ref: '#/components/responses/Forbidden'
2090
+ '404':
2091
+ $ref: '#/components/responses/RecordingNotFound'
2092
+ '429':
2093
+ $ref: '#/components/responses/RateLimited'
2094
+ '500':
2095
+ $ref: '#/components/responses/RecordingInternalError'
2096
+
2097
+ components:
2098
+ parameters:
2099
+ PlatformAppId:
2100
+ in: path
2101
+ name: id
2102
+ required: true
2103
+ description: The app's id.
2104
+ schema:
2105
+ type: string
2106
+ example: 9adc3f2e-7b1a-4c6d-8e9f-0a1b2c3d4e5f
2107
+ RecordingGroupId:
2108
+ in: path
2109
+ name: groupId
2110
+ required: true
2111
+ description: The recording's group id.
2112
+ schema:
2113
+ type: string
2114
+ example: c3b2a1f0-5e4d-4c3b-9a8f-7e6d5c4b3a21
2115
+ RecordingRange:
2116
+ in: header
2117
+ name: Range
2118
+ required: false
2119
+ description: A byte range, for seeking or resuming.
2120
+ schema:
2121
+ type: string
2122
+ example: bytes=0-1048575
2123
+ responses:
2124
+ PlatformSettingsRefused:
2125
+ description: The caller isn't a `SUPER_ADMIN`, or the credential lacks the `admin` scope.
2126
+ content:
2127
+ application/json:
2128
+ schema:
2129
+ $ref: '#/components/schemas/Error'
2130
+ examples:
2131
+ notSuperAdmin:
2132
+ summary: Not a super admin
2133
+ value:
2134
+ error: Only a super admin can change the lab's settings.
2135
+ message: Only a super admin can change the lab's settings.
2136
+ scope:
2137
+ summary: The admin scope is missing
2138
+ value:
2139
+ error: insufficient scope
2140
+ PlatformAppNotFound:
2141
+ description: No such app, or one outside the caller's teams (the two answer the same).
2142
+ content:
2143
+ application/json:
2144
+ schema:
2145
+ $ref: '#/components/schemas/Error'
2146
+ example:
2147
+ error: App not found
2148
+ RecordingNotFound:
2149
+ description: |
2150
+ No such recording or phone, or none the caller can see (the two answer the same).
2151
+ content:
2152
+ application/json:
2153
+ schema:
2154
+ $ref: '#/components/schemas/Error'
2155
+ example:
2156
+ error: not_found
2157
+ RecordingInternalError:
2158
+ description: An unexpected server error.
2159
+ content:
2160
+ application/json:
2161
+ schema:
2162
+ $ref: '#/components/schemas/Error'
2163
+ example:
2164
+ error: internal
2165
+ message: Can't reach database server
2166
+ RecordingStartConflict:
2167
+ description: |
2168
+ Nothing was started: a phone is busy, or the server's cap on simultaneous recordings
2169
+ (`maxConcurrentRecordings`, 4 by default) would be exceeded.
2170
+ content:
2171
+ application/json:
2172
+ schema:
2173
+ $ref: '#/components/schemas/Error'
2174
+ examples:
2175
+ busy:
2176
+ value:
2177
+ error: device_busy
2178
+ busyDevices:
2179
+ - udid: R5CT32ABCDE
2180
+ reason: manual_other
2181
+ blockId: manual_3f2a1b0c-9d8e-4f7a-b6c5-d4e3f2a1b0c9_R5CT32ABCDE
2182
+ message: 1 of 2 selected devices are busy. Recording was not started.
2183
+ cap:
2184
+ value:
2185
+ error: concurrency_cap
2186
+ limit: 4
2187
+ active: 4
2188
+ message: Server-wide recording cap reached (4/4).
2189
+ RecordingVideoAttachment:
2190
+ description: The video, as an attachment.
2191
+ headers:
2192
+ Content-Disposition:
2193
+ schema:
2194
+ type: string
2195
+ example: attachment; filename="R5CT32ABCDE.mp4"
2196
+ Accept-Ranges:
2197
+ schema:
2198
+ type: string
2199
+ example: bytes
2200
+ content:
2201
+ video/mp4:
2202
+ schema:
2203
+ type: string
2204
+ format: binary
2205
+ RecordingVideoPartial:
2206
+ description: The requested byte range.
2207
+ headers:
2208
+ Content-Range:
2209
+ schema:
2210
+ type: string
2211
+ example: bytes 0-1048575/18342011
2212
+ content:
2213
+ video/mp4:
2214
+ schema:
2215
+ type: string
2216
+ format: binary
2217
+ RecordingRangeNotSatisfiable:
2218
+ description: The `Range` lies outside the file.
2219
+ headers:
2220
+ Content-Range:
2221
+ description: '`bytes */<size>`: the length to ask within.'
2222
+ schema:
2223
+ type: string
2224
+ example: bytes */18342011
2225
+ content:
2226
+ application/json:
2227
+ schema:
2228
+ $ref: '#/components/schemas/Error'
2229
+ example:
2230
+ error: range_not_satisfiable
2231
+ schemas:
2232
+ PlatformProcess:
2233
+ type: object
2234
+ required:
2235
+ - id
2236
+ - kind
2237
+ - uptimeMs
2238
+ properties:
2239
+ id:
2240
+ type: string
2241
+ example: 6f1d2c3b-4a5e-4f60-8a7b-9c0d1e2f3a4b
2242
+ sessionId:
2243
+ type: string
2244
+ description: The session it belongs to, if any.
2245
+ example: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
2246
+ udid:
2247
+ type: string
2248
+ description: The device it belongs to, if any.
2249
+ example: R5CT32ABCDE
2250
+ kind:
2251
+ type: string
2252
+ enum:
2253
+ - wda
2254
+ - ffmpeg
2255
+ - adb-reverse
2256
+ - ios-mjpeg
2257
+ - log-tailer
2258
+ - other
2259
+ pid:
2260
+ type: integer
2261
+ example: 48213
2262
+ uptimeMs:
2263
+ type: integer
2264
+ example: 734120
2265
+ PlatformSettings:
2266
+ type: object
2267
+ description: The lab's stored settings, merged with the AI settings in effect. A setting never saved is absent.
2268
+ properties:
2269
+ healthCheckIntervalMs:
2270
+ type: integer
2271
+ example: 300000
2272
+ healthCheckSchedule:
2273
+ type: string
2274
+ example: '*/5 * * * *'
2275
+ buildCleanupDays:
2276
+ type: integer
2277
+ example: 30
2278
+ buildCleanupMaxCount:
2279
+ type: integer
2280
+ example: 500
2281
+ buildCleanupSchedule:
2282
+ type: string
2283
+ example: 0 3 * * *
2284
+ deleteBuildAssets:
2285
+ type: boolean
2286
+ aiProvider:
2287
+ type: string
2288
+ example: gemini
2289
+ aiModel:
2290
+ type: string
2291
+ aiBaseUrl:
2292
+ type: string
2293
+ geminiModel:
2294
+ type: string
2295
+ openaiModel:
2296
+ type: string
2297
+ anthropicModel:
2298
+ type: string
2299
+ ollamaModel:
2300
+ type: string
2301
+ geminiSet:
2302
+ type: boolean
2303
+ description: Whether a Gemini API key is configured.
2304
+ openaiSet:
2305
+ type: boolean
2306
+ description: Whether an OpenAI API key is configured.
2307
+ anthropicSet:
2308
+ type: boolean
2309
+ description: Whether an Anthropic API key is configured.
2310
+ PlatformSettingsUpdate:
2311
+ type: object
2312
+ description: Any subset. Unknown fields are ignored.
2313
+ properties:
2314
+ healthCheckIntervalMs:
2315
+ type: integer
2316
+ example: 300000
2317
+ healthCheckSchedule:
2318
+ type: string
2319
+ description: A cron expression.
2320
+ example: '*/5 * * * *'
2321
+ buildCleanupDays:
2322
+ type: integer
2323
+ example: 30
2324
+ buildCleanupMaxCount:
2325
+ type: integer
2326
+ example: 500
2327
+ buildCleanupSchedule:
2328
+ type: string
2329
+ description: A cron expression.
2330
+ example: 0 3 * * *
2331
+ deleteBuildAssets:
2332
+ type: boolean
2333
+ aiProvider:
2334
+ type: string
2335
+ description: Not stored; until the server restarts.
2336
+ enum:
2337
+ - gemini
2338
+ - openai
2339
+ - anthropic
2340
+ - ollama
2341
+ aiModel:
2342
+ type: string
2343
+ description: Not stored; until the server restarts.
2344
+ aiBaseUrl:
2345
+ type: string
2346
+ description: Not stored; until the server restarts.
2347
+ geminiModel:
2348
+ type: string
2349
+ description: Not stored; until the server restarts.
2350
+ openaiModel:
2351
+ type: string
2352
+ description: Not stored; until the server restarts.
2353
+ anthropicModel:
2354
+ type: string
2355
+ description: Not stored; until the server restarts.
2356
+ ollamaModel:
2357
+ type: string
2358
+ description: Not stored; until the server restarts.
2359
+ PlatformAiTestRequest:
2360
+ type: object
2361
+ description: Every field is optional and falls back to the server's current setting.
2362
+ properties:
2363
+ aiProvider:
2364
+ type: string
2365
+ enum:
2366
+ - gemini
2367
+ - openai
2368
+ - anthropic
2369
+ - ollama
2370
+ aiModel:
2371
+ type: string
2372
+ example: gpt-4o
2373
+ aiBaseUrl:
2374
+ type: string
2375
+ description: For OpenAI-compatible endpoints and Ollama.
2376
+ example: http://localhost:11434
2377
+ geminiApiKey:
2378
+ type: string
2379
+ description: Used only when the server has no Gemini key of its own.
2380
+ openaiApiKey:
2381
+ type: string
2382
+ description: Used only when the server has no OpenAI key of its own.
2383
+ anthropicApiKey:
2384
+ type: string
2385
+ description: Used only when the server has no Anthropic key of its own.
2386
+ PlatformAiTestResult:
2387
+ type: object
2388
+ required:
2389
+ - success
2390
+ - message
2391
+ properties:
2392
+ success:
2393
+ type: boolean
2394
+ message:
2395
+ type: string
2396
+ PlatformWebhook:
2397
+ type: object
2398
+ properties:
2399
+ id:
2400
+ type: string
2401
+ example: 2d6f4a8e-1b3c-4d5e-9f60-7a8b9c0d1e2f
2402
+ url:
2403
+ type: string
2404
+ example: https://hooks.slack.com/services/T000/B000/XXXXXXXX
2405
+ type:
2406
+ type: string
2407
+ description: '`slack` sends a Slack message; anything else a generic JSON body.'
2408
+ example: slack
2409
+ events:
2410
+ type: string
2411
+ description: The subscribed events, as a JSON-encoded array.
2412
+ example: '["device_offline","session_failed"]'
2413
+ active:
2414
+ type: boolean
2415
+ payloadTemplate:
2416
+ type: string
2417
+ nullable: true
2418
+ createdAt:
2419
+ type: string
2420
+ format: date-time
2421
+ updatedAt:
2422
+ type: string
2423
+ format: date-time
2424
+ PlatformWebhookCreate:
2425
+ type: object
2426
+ required:
2427
+ - url
2428
+ - events
2429
+ properties:
2430
+ url:
2431
+ type: string
2432
+ format: uri
2433
+ description: Where to POST events.
2434
+ events:
2435
+ type: array
2436
+ description: The events to send.
2437
+ items:
2438
+ type: string
2439
+ enum:
2440
+ - device_offline
2441
+ - session_failed
2442
+ - device_new
2443
+ - selector_health_digest
2444
+ type:
2445
+ type: string
2446
+ description: '`slack` for a Slack message, anything else for `{ event, payload }`.'
2447
+ default: slack
2448
+ example: slack
2449
+ payloadTemplate:
2450
+ type: string
2451
+ description: A body template with `{{key}}` placeholders, used in place of the default body.
2452
+ example: '{"text":"{{eventType}} on {{udid}}"}'
2453
+ PlatformApp:
2454
+ type: object
2455
+ description: An uploaded app build.
2456
+ properties:
2457
+ id:
2458
+ type: string
2459
+ example: 9adc3f2e-7b1a-4c6d-8e9f-0a1b2c3d4e5f
2460
+ name:
2461
+ type: string
2462
+ example: checkout-2.4.1.apk
2463
+ filename:
2464
+ type: string
2465
+ example: checkout-2.4.1.apk
2466
+ filepath:
2467
+ type: string
2468
+ description: Where the file is stored on the server.
2469
+ mimetype:
2470
+ type: string
2471
+ example: application/vnd.android.package-archive
2472
+ size:
2473
+ type: integer
2474
+ description: Bytes.
2475
+ example: 48213377
2476
+ packageName:
2477
+ type: string
2478
+ nullable: true
2479
+ description: Read from an `.apk`; null for other files.
2480
+ example: com.example.checkout
2481
+ version:
2482
+ type: string
2483
+ nullable: true
2484
+ example: 2.4.1
2485
+ platform:
2486
+ type: string
2487
+ nullable: true
2488
+ enum:
2489
+ - android
2490
+ - ios
2491
+ md5:
2492
+ type: string
2493
+ nullable: true
2494
+ example: 5f4dcc3b5aa765d61d8327deb882cf99
2495
+ teamId:
2496
+ type: string
2497
+ nullable: true
2498
+ description: The team whose members see it; null is the shared pool.
2499
+ createdAt:
2500
+ type: string
2501
+ format: date-time
2502
+ updatedAt:
2503
+ type: string
2504
+ format: date-time
2505
+ PlatformAppWithTeam:
2506
+ allOf:
2507
+ - $ref: '#/components/schemas/PlatformApp'
2508
+ - type: object
2509
+ properties:
2510
+ team:
2511
+ type: object
2512
+ nullable: true
2513
+ properties:
2514
+ id:
2515
+ type: string
2516
+ name:
2517
+ type: string
2518
+ RecordingStarted:
2519
+ type: object
2520
+ required:
2521
+ - id
2522
+ - udid
2523
+ - status
2524
+ properties:
2525
+ id:
2526
+ type: string
2527
+ description: The phone's recording id.
2528
+ example: 8f14e45f-ceea-467a-9b36-2f1c5d0e7a11
2529
+ udid:
2530
+ type: string
2531
+ example: R5CT32ABCDE
2532
+ status:
2533
+ type: string
2534
+ enum:
2535
+ - RECORDING
2536
+ RecordingStopped:
2537
+ type: object
2538
+ required:
2539
+ - id
2540
+ - udid
2541
+ - status
2542
+ properties:
2543
+ id:
2544
+ type: string
2545
+ udid:
2546
+ type: string
2547
+ status:
2548
+ type: string
2549
+ description: >-
2550
+ `RECORDING` only for a phone whose stream was ending at the moment of the stop (its
2551
+ video is being finished separately).
2552
+ enum:
2553
+ - STOPPED
2554
+ - FAILED
2555
+ - RECORDING
2556
+ durationMs:
2557
+ type: integer
2558
+ description: The video's length.
2559
+ sizeBytes:
2560
+ type: integer
2561
+ description: Absent when there is no file.
2562
+ RecordingPhone:
2563
+ type: object
2564
+ description: One phone of a recording, as the library shows it.
2565
+ properties:
2566
+ recordingId:
2567
+ type: string
2568
+ udid:
2569
+ type: string
2570
+ name:
2571
+ type: string
2572
+ description: The phone's name, or its udid when unknown.
2573
+ platform:
2574
+ type: string
2575
+ nullable: true
2576
+ status:
2577
+ type: string
2578
+ enum:
2579
+ - RECORDING
2580
+ - STOPPED
2581
+ - FAILED
2582
+ - DISCARDED
2583
+ offsetMs:
2584
+ type: integer
2585
+ description: Where this phone's video starts on the group's timeline; negative if before t=0.
2586
+ durationMs:
2587
+ type: integer
2588
+ nullable: true
2589
+ failReason:
2590
+ type: string
2591
+ nullable: true
2592
+ example: source_ended
2593
+ annotationCount:
2594
+ type: integer
2595
+ RecordingSummary:
2596
+ type: object
2597
+ description: A recording group, summarized over the phones the caller can see.
2598
+ properties:
2599
+ groupId:
2600
+ type: string
2601
+ startedAt:
2602
+ type: string
2603
+ format: date-time
2604
+ description: The group's t=0, which marks and bookmarks count from.
2605
+ endedAt:
2606
+ type: string
2607
+ format: date-time
2608
+ nullable: true
2609
+ durationMs:
2610
+ type: integer
2611
+ nullable: true
2612
+ description: The timeline's length; null while recording.
2613
+ status:
2614
+ type: string
2615
+ description: '`recording` while any phone records, `failed` when every phone failed, else `done`.'
2616
+ enum:
2617
+ - recording
2618
+ - done
2619
+ - failed
2620
+ phones:
2621
+ type: array
2622
+ items:
2623
+ $ref: '#/components/schemas/RecordingPhone'
2624
+ startedBy:
2625
+ type: object
2626
+ nullable: true
2627
+ properties:
2628
+ id:
2629
+ type: string
2630
+ name:
2631
+ type: string
2632
+ bookmarkCount:
2633
+ type: integer
2634
+ annotationCount:
2635
+ type: integer
2636
+ keptUntil:
2637
+ type: string
2638
+ format: date-time
2639
+ description: When the retention sweep removes it.
2640
+ sizeBytes:
2641
+ type: integer
2642
+ hasComposite:
2643
+ type: boolean
2644
+ description: Whether `composite.mp4` would serve this caller.
2645
+ RecordingLibraryPage:
2646
+ type: object
2647
+ properties:
2648
+ recordings:
2649
+ type: array
2650
+ items:
2651
+ $ref: '#/components/schemas/RecordingSummary'
2652
+ nextCursor:
2653
+ type: string
2654
+ nullable: true
2655
+ description: Pass as `cursor` for the next page; null on the last.
2656
+ total:
2657
+ type: integer
2658
+ description: Recordings matching the filters, across all pages.
2659
+ facets:
2660
+ type: object
2661
+ description: Counts over every recording the caller can see, before the filters.
2662
+ properties:
2663
+ phones:
2664
+ type: array
2665
+ items:
2666
+ type: object
2667
+ properties:
2668
+ udid:
2669
+ type: string
2670
+ name:
2671
+ type: string
2672
+ count:
2673
+ type: integer
2674
+ people:
2675
+ type: array
2676
+ items:
2677
+ type: object
2678
+ properties:
2679
+ id:
2680
+ type: string
2681
+ name:
2682
+ type: string
2683
+ count:
2684
+ type: integer
2685
+ unknownCount:
2686
+ type: integer
2687
+ description: Recordings with no recorded starter.
2688
+ when:
2689
+ type: object
2690
+ properties:
2691
+ any:
2692
+ type: integer
2693
+ 24h:
2694
+ type: integer
2695
+ 7d:
2696
+ type: integer
2697
+ 30d:
2698
+ type: integer
2699
+ retention:
2700
+ type: object
2701
+ properties:
2702
+ days:
2703
+ type: integer
2704
+ description: Days a recording is kept.
2705
+ maxCount:
2706
+ type: integer
2707
+ description: Recordings kept at most.
2708
+ RecordingBookmarkRow:
2709
+ type: object
2710
+ description: A bookmark as stored.
2711
+ properties:
2712
+ id:
2713
+ type: string
2714
+ recording_id:
2715
+ type: string
2716
+ timecode_ms:
2717
+ type: integer
2718
+ label:
2719
+ type: string
2720
+ note:
2721
+ type: string
2722
+ nullable: true
2723
+ created_at:
2724
+ type: string
2725
+ format: date-time
2726
+ RecordingAnnotationRow:
2727
+ type: object
2728
+ description: A mark as stored.
2729
+ properties:
2730
+ id:
2731
+ type: string
2732
+ recording_id:
2733
+ type: string
2734
+ timecode_ms:
2735
+ type: integer
2736
+ end_timecode_ms:
2737
+ type: integer
2738
+ nullable: true
2739
+ description: When it was cleared; null while on screen.
2740
+ shape:
2741
+ type: string
2742
+ geometry:
2743
+ type: string
2744
+ color:
2745
+ type: string
2746
+ text:
2747
+ type: string
2748
+ nullable: true
2749
+ author:
2750
+ type: string
2751
+ nullable: true
2752
+ created_at:
2753
+ type: string
2754
+ format: date-time
2755
+ RecordingRow:
2756
+ type: object
2757
+ description: One phone's recording as stored.
2758
+ properties:
2759
+ id:
2760
+ type: string
2761
+ group_id:
2762
+ type: string
2763
+ device_udid:
2764
+ type: string
2765
+ device_host:
2766
+ type: string
2767
+ session_id:
2768
+ type: string
2769
+ nullable: true
2770
+ started_at:
2771
+ type: string
2772
+ format: date-time
2773
+ ended_at:
2774
+ type: string
2775
+ format: date-time
2776
+ nullable: true
2777
+ status:
2778
+ type: string
2779
+ enum:
2780
+ - RECORDING
2781
+ - STOPPED
2782
+ - FAILED
2783
+ - DISCARDED
2784
+ file_path:
2785
+ type: string
2786
+ description: Where the video is stored on the server.
2787
+ duration_ms:
2788
+ type: integer
2789
+ nullable: true
2790
+ size_bytes:
2791
+ type: integer
2792
+ nullable: true
2793
+ device_snapshot:
2794
+ type: string
2795
+ nullable: true
2796
+ fail_reason:
2797
+ type: string
2798
+ nullable: true
2799
+ started_by:
2800
+ type: string
2801
+ nullable: true
2802
+ description: The user who started it.
2803
+ bookmarks:
2804
+ type: array
2805
+ items:
2806
+ $ref: '#/components/schemas/RecordingBookmarkRow'
2807
+ annotations:
2808
+ type: array
2809
+ items:
2810
+ $ref: '#/components/schemas/RecordingAnnotationRow'
2811
+ RecordingBookmarkView:
2812
+ type: object
2813
+ properties:
2814
+ id:
2815
+ type: string
2816
+ recordingId:
2817
+ type: string
2818
+ timecodeMs:
2819
+ type: integer
2820
+ label:
2821
+ type: string
2822
+ note:
2823
+ type: string
2824
+ nullable: true
2825
+ RecordingAnnotationView:
2826
+ type: object
2827
+ properties:
2828
+ id:
2829
+ type: string
2830
+ recordingId:
2831
+ type: string
2832
+ timecodeMs:
2833
+ type: integer
2834
+ endTimecodeMs:
2835
+ type: integer
2836
+ nullable: true
2837
+ shape:
2838
+ type: string
2839
+ geometry:
2840
+ type: string
2841
+ color:
2842
+ type: string
2843
+ text:
2844
+ type: string
2845
+ nullable: true
2846
+ RecordingActiveGroup:
2847
+ type: object
2848
+ properties:
2849
+ groupId:
2850
+ type: string
2851
+ startedAt:
2852
+ type: string
2853
+ format: date-time
2854
+ description: The group's t=0.
2855
+ recordings:
2856
+ type: array
2857
+ items:
2858
+ type: object
2859
+ properties:
2860
+ id:
2861
+ type: string
2862
+ udid:
2863
+ type: string
2864
+ annotations:
2865
+ type: array
2866
+ description: Marks still on screen.
2867
+ items:
2868
+ type: object
2869
+ properties:
2870
+ recordingId:
2871
+ type: string
2872
+ shape:
2873
+ type: string
2874
+ geometry:
2875
+ type: string
2876
+ color:
2877
+ type: string
2878
+ text:
2879
+ type: string
2880
+ nullable: true
2881
+ timecodeMs:
2882
+ type: integer
2883
+ compositeEnabled:
2884
+ type: boolean
2885
+ description: Whether `composite.mp4` would serve this caller.