@xenon-device-management/xenon 1.29.0 → 2.0.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 (190) hide show
  1. package/README.md +11 -2
  2. package/lib/package.json +1 -1
  3. package/lib/public/assets/{AnnotationOverlay-Ne9IA8t9.js → AnnotationOverlay-BmTPCAds.js} +1 -1
  4. package/lib/public/assets/{ApiKeyGate-CwXbDjtR.js → ApiKeyGate-B5eB7f8B.js} +1 -1
  5. package/lib/public/assets/{BugReportButton-D8694Ndn.js → BugReportButton-DKYsOdGV.js} +1 -1
  6. package/lib/public/assets/{DeviceMosaicView-vqKa_a7b.js → DeviceMosaicView-9DUoZnQi.js} +1 -1
  7. package/lib/public/assets/{EmptyState-DqbEejWx.js → EmptyState-lvyNiXev.js} +1 -1
  8. package/lib/public/assets/{FieldGroup-BNKuUsHt.js → FieldGroup-BduMPM9K.js} +1 -1
  9. package/lib/public/assets/{FilterMenu-CLqoSOsz.js → FilterMenu-BSqX6I94.js} +1 -1
  10. package/lib/public/assets/{Menu-CU57vgsd.js → Menu-Ba5cT7jZ.js} +1 -1
  11. package/lib/public/assets/{Modal-Dt2whV22.js → Modal-CceoMY7D.js} +1 -1
  12. package/lib/public/assets/{RecordingPage-BipS3IoG.js → RecordingPage-6T9dOidc.js} +1 -1
  13. package/lib/public/assets/{RecordingsPage-DORtXeH5.js → RecordingsPage-qj6FdZXR.js} +1 -1
  14. package/lib/public/assets/{SegmentedControl-IX6aSFFO.js → SegmentedControl-CSRuZ8TR.js} +1 -1
  15. package/lib/public/assets/{SettingCard-DDp2N5zA.js → SettingCard-Bp1kz4By.js} +1 -1
  16. package/lib/public/assets/{Table-CiaA4Dni.js → Table-LV7zFyZg.js} +1 -1
  17. package/lib/public/assets/{activity-DzhwJvJO.js → activity-D17TUloi.js} +1 -1
  18. package/lib/public/assets/{ai-settings-BllB4M52.js → ai-settings-qvbhY89P.js} +1 -1
  19. package/lib/public/assets/api-keys-BfpvdG7G.js +1 -0
  20. package/lib/public/assets/apps-DMhpZuKd.css +1 -0
  21. package/lib/public/assets/apps-Dbnzd0fW.js +26 -0
  22. package/lib/public/assets/{arrow-left-poHDsOnw.js → arrow-left-DD1VjtC7.js} +1 -1
  23. package/lib/public/assets/{arrow-right-Bt8ka8uH.js → arrow-right-D0pnM3ma.js} +1 -1
  24. package/lib/public/assets/{arrow-up-right-C7zjEfIX.js → arrow-up-right-BIC8JOV6.js} +1 -1
  25. package/lib/public/assets/{auth-shell-DqNP66nv.js → auth-shell-DWHPXSnI.js} +2 -2
  26. package/lib/public/assets/{builds-page-BmoSSMe1.js → builds-page-ClCNBUv_.js} +1 -1
  27. package/lib/public/assets/{button-DujkWRKD.js → button-xtAhYR-f.js} +1 -1
  28. package/lib/public/assets/{calendar-3xj_SfAt.js → calendar-CupwJ7Bm.js} +1 -1
  29. package/lib/public/assets/{check-DAtcemi7.js → check-CyuEoKkd.js} +1 -1
  30. package/lib/public/assets/{chevron-left-BODVavmZ.js → chevron-left-DgX-F9xK.js} +1 -1
  31. package/lib/public/assets/{chevron-right-BtzJa9CV.js → chevron-right-BKJgJnU9.js} +1 -1
  32. package/lib/public/assets/{circle-check-CNLy00dn.js → circle-check-CZdg8mMa.js} +1 -1
  33. package/lib/public/assets/{circle-x-DwlxOXJV.js → circle-x-CZH6t9z-.js} +1 -1
  34. package/lib/public/assets/{clock-CthJSgmT.js → clock-Cd-t7xpe.js} +1 -1
  35. package/lib/public/assets/{copy-o_vwnvDl.js → copy-C6zerX3I.js} +1 -1
  36. package/lib/public/assets/{device-explorer-BGwZQSit.js → device-explorer-B1pI6b_M.js} +2 -2
  37. package/lib/public/assets/device-explorer-DV8rWRxL.css +1 -0
  38. package/lib/public/assets/{download-CH7KYHQ6.js → download-x9WP7mcL.js} +1 -1
  39. package/lib/public/assets/{forgot-password-Fo0NoMuQ.js → forgot-password-B0TEH4N-.js} +1 -1
  40. package/lib/public/assets/{funnel-DLq7lVeF.js → funnel-Cb40MfFN.js} +1 -1
  41. package/lib/public/assets/{index-SVMaJ9rC.js → index-BR1wTIPC.js} +9 -9
  42. package/lib/public/assets/{index-tbOE1tW_.js → index-BvH_ekRy.js} +1 -1
  43. package/lib/public/assets/{index-Fa91L6sA.css → index-hymrhlEZ.css} +1 -1
  44. package/lib/public/assets/{input-Cn9aCi1V.js → input-DejcCiTI.js} +1 -1
  45. package/lib/public/assets/{keycodes-DVNcuy4F.js → keycodes-Mavx5iGg.js} +1 -1
  46. package/lib/public/assets/{layers-DFA4vIXT.js → layers-BKqYLiHE.js} +1 -1
  47. package/lib/public/assets/{lock-DbVuQMlE.js → lock-LFsFhc17.js} +1 -1
  48. package/lib/public/assets/{login-kvBEtLEI.js → login-x2pkzt-t.js} +1 -1
  49. package/lib/public/assets/{maintenance-settings-9MSQd7-h.js → maintenance-settings-CYafcoV-.js} +1 -1
  50. package/lib/public/assets/{monitor-C2wiQCzm.js → monitor-_7_uJ8d9.js} +1 -1
  51. package/lib/public/assets/{mouse-pointer-2-BxOt4PTR.js → mouse-pointer-2-BfLuURKN.js} +1 -1
  52. package/lib/public/assets/{network-VOsv-B1C.js → network-Z_zBhpsD.js} +1 -1
  53. package/lib/public/assets/{overview-BxRv07jX.js → overview-BY5uv08S.js} +1 -1
  54. package/lib/public/assets/{page-header-DRYmsZ55.js → page-header-CP2UkykG.js} +1 -1
  55. package/lib/public/assets/{plus-BY8JP9c5.js → plus-BTD0dnQ0.js} +1 -1
  56. package/lib/public/assets/{profile-page-C0IMKJRO.js → profile-page-BhJZuCS7.js} +1 -1
  57. package/lib/public/assets/{reset-password-2OxcQdve.js → reset-password-CMN0SO81.js} +1 -1
  58. package/lib/public/assets/{runbook-page-ChzDYLnk.js → runbook-page-CBpPX9_5.js} +1 -1
  59. package/lib/public/assets/{select-DkLtE2T3.js → select-Uc9E5pye.js} +1 -1
  60. package/lib/public/assets/{selector-detail-page-9XKCgLLk.js → selector-detail-page-BSyBnRuU.js} +1 -1
  61. package/lib/public/assets/{selector-health-CZb7lL82.js → selector-health-DzqxNkzm.js} +1 -1
  62. package/lib/public/assets/{selector-health-page-CgNkXERq.js → selector-health-page-CyPLjoD1.js} +1 -1
  63. package/lib/public/assets/{session-detail-page-V68omLzd.js → session-detail-page-BZQZN1P-.js} +1 -1
  64. package/lib/public/assets/{settings-BTO5_h8Z.js → settings-CkSWiZq8.js} +1 -1
  65. package/lib/public/assets/{status-pill-outline-DteJiQpj.js → status-pill-outline-BFYni2qo.js} +1 -1
  66. package/lib/public/assets/{tablet-BvW2cqBO.js → tablet-BSd-xSZK.js} +1 -1
  67. package/lib/public/assets/teams-PpoEQz31.js +1 -0
  68. package/lib/public/assets/{trash-2-Ck4Hj9AT.js → trash-2-BorJv5_u.js} +1 -1
  69. package/lib/public/assets/{upload-DOZwUk1H.js → upload-C3i7sAZl.js} +1 -1
  70. package/lib/public/assets/{use-password-reset-mode-BkBlX2qd.js → use-password-reset-mode-CeZQ3EsH.js} +1 -1
  71. package/lib/public/assets/{users-yoXht7X_.js → users-CEJ3fC78.js} +1 -1
  72. package/lib/public/assets/{users-CkggsUeX.js → users-Drd7ifJx.js} +1 -1
  73. package/lib/public/assets/{video-off-DZG2o_Gl.js → video-off-CNzn6bPE.js} +1 -1
  74. package/lib/public/assets/{webhook-settings-D31jQ3yC.js → webhook-settings-BFqX6QXV.js} +1 -1
  75. package/lib/public/assets/{zap-nAiJWOGv.js → zap-4yN8Jdfw.js} +1 -1
  76. package/lib/public/index.html +2 -2
  77. package/lib/schema.json +1 -1
  78. package/lib/src/XenonCapabilityManager.js +21 -50
  79. package/lib/src/app/index.js +14 -9
  80. package/lib/src/app/insertBeforeRoutes.js +57 -0
  81. package/lib/src/app/registerCommandAuth.js +67 -0
  82. package/lib/src/app/routers/apps.js +83 -5
  83. package/lib/src/app/routers/auth.js +1 -1
  84. package/lib/src/app/routers/bug-report.js +12 -0
  85. package/lib/src/app/routers/capabilities.js +4 -0
  86. package/lib/src/app/routers/control.js +6 -1
  87. package/lib/src/app/routers/dashboard.js +116 -66
  88. package/lib/src/app/routers/grid.js +38 -57
  89. package/lib/src/app/routers/recordings.js +3 -1
  90. package/lib/src/app/swagger-docs.js +7 -4
  91. package/lib/src/app/swagger.js +3 -0
  92. package/lib/src/dashboard/event-manager.js +18 -21
  93. package/lib/src/dashboard/services/app-service.js +24 -2
  94. package/lib/src/data-service/device-service.js +2 -1
  95. package/lib/src/data-service/queue-service.js +4 -3
  96. package/lib/src/device-utils.js +1 -1
  97. package/lib/src/generated/client/edge.js +5 -4
  98. package/lib/src/generated/client/index-browser.js +2 -1
  99. package/lib/src/generated/client/index.d.ts +413 -7
  100. package/lib/src/generated/client/index.js +5 -4
  101. package/lib/src/generated/client/package.json +1 -1
  102. package/lib/src/generated/client/schema.prisma +7 -0
  103. package/lib/src/generated/client/wasm.js +2 -1
  104. package/lib/src/interceptors/CommandInterceptor.js +5 -5
  105. package/lib/src/middleware/authMiddleware.js +44 -36
  106. package/lib/src/middleware/commandAuth.js +146 -0
  107. package/lib/src/middleware/commandCaller.js +142 -0
  108. package/lib/src/middleware/verifyCredential.js +124 -0
  109. package/lib/src/services/ServerManager.js +7 -0
  110. package/lib/src/services/SessionLifecycleService.js +143 -73
  111. package/lib/src/services/TeamService.js +6 -3
  112. package/lib/src/services/device-access/appVisibility.js +27 -0
  113. package/lib/src/services/device-access/queueVisibility.js +118 -0
  114. package/lib/src/services/device-access/sessionVisibility.js +65 -0
  115. package/lib/src/services/healing/HealingOrchestrator.js +11 -3
  116. package/lib/src/services/lease/LeaseService.js +13 -4
  117. package/lib/src/services/lease/buildCapabilityBag.js +4 -1
  118. package/lib/src/services/lease/leaseSessionCaps.js +13 -69
  119. package/lib/src/services/recording/annotation-render.js +33 -1
  120. package/lib/src/services/recording/recordingSummary.js +6 -5
  121. package/lib/src/services/recording/recordingTiming.js +19 -5
  122. package/lib/src/services/session/appCapability.js +50 -0
  123. package/lib/src/services/session/sessionCredentials.js +105 -0
  124. package/lib/src/services/session/xenonOptions.js +71 -0
  125. package/lib/src/services/sessionTokenGate.js +19 -9
  126. package/lib/src/services/token/AppDownloadTicketService.js +73 -0
  127. package/lib/src/services/token/StreamTicketService.js +7 -17
  128. package/lib/src/services/token/singleUseLedger.js +41 -0
  129. package/lib/src/sessions/XenonSession.js +2 -2
  130. package/lib/test/helpers/loopbackServer.js +48 -0
  131. package/lib/test/integration/sdk-leases-router.spec.js +1 -1
  132. package/lib/test/integration/team-visibility-apps.spec.js +181 -0
  133. package/lib/test/integration/team-visibility-recordings.spec.js +4 -0
  134. package/lib/test/integration/team-visibility-sessions.spec.js +274 -0
  135. package/lib/test/unit/AppDownloadTicketService.spec.js +101 -0
  136. package/lib/test/unit/CapabilitiesRouter.spec.js +10 -0
  137. package/lib/test/unit/SessionTokenGate.spec.js +7 -2
  138. package/lib/test/unit/TeamService.test.js +22 -0
  139. package/lib/test/unit/XenonCapabilityManager.spec.js +11 -1
  140. package/lib/test/unit/aggregate-hotspots-session-scope.spec.js +202 -0
  141. package/lib/test/unit/annotation-render.spec.js +90 -0
  142. package/lib/test/unit/apps-team-visibility.spec.js +305 -0
  143. package/lib/test/unit/authMiddleware.appTicket.spec.js +201 -0
  144. package/lib/test/unit/bug-report/route.spec.js +49 -2
  145. package/lib/test/unit/cliargs-admin-only.spec.js +182 -0
  146. package/lib/test/unit/command-auth-appium-server.spec.js +184 -0
  147. package/lib/test/unit/command-auth.spec.js +410 -0
  148. package/lib/test/unit/command-caller.spec.js +254 -0
  149. package/lib/test/unit/event-manager-regression-hook.spec.js +7 -1
  150. package/lib/test/unit/extract-access-key-token-pair.test.js +47 -4
  151. package/lib/test/unit/grid-admin-only.spec.js +55 -0
  152. package/lib/test/unit/healing-tier-gate.spec.js +24 -1
  153. package/lib/test/unit/insert-before-routes.spec.js +167 -0
  154. package/lib/test/unit/install-repository-app-team.spec.js +116 -0
  155. package/lib/test/unit/lease/LeaseService.spec.js +3 -1
  156. package/lib/test/unit/lease/allocate-lease-branch.spec.js +2 -2
  157. package/lib/test/unit/lease/buildCapabilityBag.spec.js +3 -2
  158. package/lib/test/unit/lease/lease-override.spec.js +1 -1
  159. package/lib/test/unit/lease/lease-session-ownership.spec.js +4 -4
  160. package/lib/test/unit/lease/lease-team-visibility.spec.js +1 -1
  161. package/lib/test/unit/lease/lease-token-log-filter.spec.js +1 -1
  162. package/lib/test/unit/lease/lease-token-oracle.spec.js +207 -0
  163. package/lib/test/unit/lease/session-lease-proof.spec.js +42 -26
  164. package/lib/test/unit/logcat-ws.spec.js +7 -4
  165. package/lib/test/unit/queue-requester.spec.js +211 -0
  166. package/lib/test/unit/queue-team-scope-routes.spec.js +209 -0
  167. package/lib/test/unit/queue-visibility.spec.js +179 -0
  168. package/lib/test/unit/recording-summary.spec.js +22 -0
  169. package/lib/test/unit/recording-timing.spec.js +27 -0
  170. package/lib/test/unit/recordings-library-routes.spec.js +25 -0
  171. package/lib/test/unit/session-app-resolution.spec.js +331 -0
  172. package/lib/test/unit/session-assets.spec.js +167 -0
  173. package/lib/test/unit/session-attribution-wiring.spec.js +134 -24
  174. package/lib/test/unit/session-credential-strip.spec.js +277 -0
  175. package/lib/test/unit/session-credentials.spec.js +149 -0
  176. package/lib/test/unit/session-identity.spec.js +2 -2
  177. package/lib/test/unit/session-visibility.spec.js +109 -0
  178. package/lib/test/unit/stored-session-capabilities.spec.js +54 -0
  179. package/lib/test/unit/verify-credential.spec.js +138 -0
  180. package/lib/test/unit/xenon-options.spec.js +95 -0
  181. package/lib/tsconfig.tsbuildinfo +1 -1
  182. package/package.json +1 -1
  183. package/prisma/migrations/20260928120000_app_team/migration.sql +26 -0
  184. package/prisma/schema.prisma +7 -0
  185. package/schema.json +1 -1
  186. package/lib/public/assets/api-keys-bjXg97iu.js +0 -1
  187. package/lib/public/assets/apps-CQEjt22I.js +0 -21
  188. package/lib/public/assets/apps-DJMD5JOy.css +0 -1
  189. package/lib/public/assets/device-explorer-DTH5oU54.css +0 -1
  190. package/lib/public/assets/teams-8Qo7HmUv.js +0 -1
@@ -61,11 +61,26 @@ const roleGuard_1 = require("../../middleware/roleGuard");
61
61
  const build_export_1 = __importDefault(require("./build-export"));
62
62
  const NotificationService_1 = require("../../services/NotificationService");
63
63
  const SelectorStateService_1 = require("../../services/SelectorStateService");
64
- const device_service_1 = require("../../data-service/device-service");
64
+ const path_1 = __importDefault(require("path"));
65
+ const config_1 = require("../../config");
66
+ const sessionVisibility_1 = require("../../services/device-access/sessionVisibility");
65
67
  const HealEtalonService_1 = require("../../services/healing/HealEtalonService");
66
68
  const logger_1 = __importDefault(require("../../logger"));
67
69
  const MJPEG_PROXY_CACHE = new Map();
68
- //session guard
70
+ const authOf = (request) => request.auth;
71
+ /**
72
+ * Only the healed logs of sessions the caller may see (visibleSessionWhere),
73
+ * as a SessionLog `where` fragment; empty for an admin.
74
+ */
75
+ function visibleLogScope(request) {
76
+ return __awaiter(this, void 0, void 0, function* () {
77
+ const where = yield (0, sessionVisibility_1.visibleSessionWhere)(authOf(request));
78
+ return where ? { session: { is: where } } : {};
79
+ });
80
+ }
81
+ // Session guard, on every /session/:sessionId route: the session must exist
82
+ // and be one the caller may see (canSeeSession). Another team's session gets
83
+ // exactly the unknown-id answer, so it can't be told apart from none.
69
84
  function isValidSession(request, response, next) {
70
85
  return __awaiter(this, void 0, void 0, function* () {
71
86
  const sessionId = request.params.sessionId;
@@ -74,7 +89,7 @@ function isValidSession(request, response, next) {
74
89
  id: sessionId,
75
90
  },
76
91
  });
77
- if (!session) {
92
+ if (!session || !(yield (0, sessionVisibility_1.canSeeSession)(session, authOf(request)))) {
78
93
  return response.status(404).send({
79
94
  error: true,
80
95
  message: `Session with id ${sessionId} not found`,
@@ -108,50 +123,38 @@ function getSessions(request, response) {
108
123
  { tags: { contains: query } },
109
124
  ];
110
125
  }
126
+ // The team rule is part of the query, so `take` counts the caller's sessions.
127
+ const visible = yield (0, sessionVisibility_1.visibleSessionWhere)(authOf(request));
111
128
  const sessions = yield prisma_1.prisma.session.findMany({
112
129
  orderBy: {
113
130
  createdAt: 'desc',
114
131
  },
115
- where,
132
+ where: visible ? { AND: [where, visible] } : where,
116
133
  take: 500,
117
134
  });
118
- // Phase 4A: filter to sessions whose underlying device is visible.
119
- const auth = request.auth;
120
- const visible = yield (0, device_service_1.filterRowsByVisibleDevice)(sessions, auth === null || auth === void 0 ? void 0 : auth.teamIds, 'device_udid');
121
- return response.status(200).json(visible);
135
+ return response.status(200).json(sessions);
122
136
  });
123
137
  }
124
138
  function getBuilds(request, response) {
125
139
  return __awaiter(this, void 0, void 0, function* () {
140
+ // A build is listed if the caller may see at least one of its sessions, and
141
+ // its counts are over those sessions only: builds are shared by name, so
142
+ // one build can hold several teams' sessions. Admins get every session.
143
+ const visible = yield (0, sessionVisibility_1.visibleSessionWhere)(authOf(request));
126
144
  const builds = yield prisma_1.prisma.build.findMany({
127
145
  orderBy: {
128
146
  createdAt: 'desc',
129
147
  },
130
148
  include: {
131
- _count: {
132
- select: { sessions: true },
133
- },
134
- sessions: {
135
- select: {
149
+ sessions: Object.assign(Object.assign({}, (visible ? { where: visible } : {})), { select: {
136
150
  status: true,
137
151
  device_udid: true,
138
- },
139
- },
152
+ } }),
140
153
  },
141
154
  });
142
- // Phase 4A: a build is visible if at least one of its sessions runs on a
143
- // device the caller can see. Admins (teamIds undefined) skip the filter.
144
- const auth = request.auth;
145
- let visibleBuilds = builds;
146
- if ((auth === null || auth === void 0 ? void 0 : auth.teamIds) !== undefined) {
147
- const filtered = yield Promise.all(builds.map((b) => __awaiter(this, void 0, void 0, function* () {
148
- const visibleSessions = yield (0, device_service_1.filterRowsByVisibleDevice)(b.sessions, auth.teamIds, 'device_udid');
149
- return { build: b, hasVisible: visibleSessions.length > 0 };
150
- })));
151
- visibleBuilds = filtered.filter((x) => x.hasVisible).map((x) => x.build);
152
- }
155
+ const visibleBuilds = visible ? builds.filter((b) => b.sessions.length > 0) : builds;
153
156
  // Principal formatting: Add a flat summary object for the frontend
154
- const formattedBuilds = visibleBuilds.map((b) => (Object.assign(Object.assign({}, b), { sessionCount: b._count.sessions, passedCount: b.sessions.filter((s) => ['success', 'passed'].includes(s.status)).length, failedCount: b.sessions.filter((s) => s.status === 'failed').length, runningCount: b.sessions.filter((s) => s.status === 'running').length, sessions: undefined })));
157
+ const formattedBuilds = visibleBuilds.map((b) => (Object.assign(Object.assign({}, b), { _count: { sessions: b.sessions.length }, sessionCount: b.sessions.length, passedCount: b.sessions.filter((s) => ['success', 'passed'].includes(s.status)).length, failedCount: b.sessions.filter((s) => s.status === 'failed').length, runningCount: b.sessions.filter((s) => s.status === 'running').length, sessions: undefined })));
155
158
  return response.status(200).json(formattedBuilds);
156
159
  });
157
160
  }
@@ -161,21 +164,10 @@ function getSessionById(request, response) {
161
164
  const session = yield prisma_1.prisma.session.findUnique({
162
165
  where: { id: sessionId },
163
166
  });
167
+ // isValidSession has already refused an unknown or hidden session.
164
168
  if (!session) {
165
169
  return response.status(404).json({ error: true, message: 'Session not found' });
166
170
  }
167
- // Phase 4A: 404 if the session's device is not visible to the caller.
168
- const auth = request.auth;
169
- if ((auth === null || auth === void 0 ? void 0 : auth.teamIds) !== undefined) {
170
- const dev = yield prisma_1.prisma.device.findFirst({
171
- where: { udid: session.device_udid },
172
- select: { teamId: true },
173
- });
174
- const visible = dev && (dev.teamId === null || auth.teamIds.includes(dev.teamId));
175
- if (!visible) {
176
- return response.status(404).json({ error: true, message: 'Session not found' });
177
- }
178
- }
179
171
  return response.status(200).json(session);
180
172
  });
181
173
  }
@@ -233,9 +225,11 @@ function getRecentHealingEvents(request, response) {
233
225
  const limitRaw = parseInt(request.query.limit || '50', 10);
234
226
  const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(limitRaw, 1), 200) : 50;
235
227
  const sessionId = request.query.sessionId || undefined;
228
+ // Another team's session, even by ?sessionId=, reads as one with no heals.
229
+ const scope = yield visibleLogScope(request);
236
230
  const [rows, todayCount] = yield Promise.all([
237
231
  prisma_1.prisma.sessionLog.findMany({
238
- where: Object.assign({ is_healed: true }, (sessionId ? { session_id: sessionId } : {})),
232
+ where: Object.assign(Object.assign({ is_healed: true }, (sessionId ? { session_id: sessionId } : {})), scope),
239
233
  orderBy: { createdAt: 'desc' },
240
234
  take: limit,
241
235
  include: {
@@ -250,7 +244,7 @@ function getRecentHealingEvents(request, response) {
250
244
  },
251
245
  }),
252
246
  prisma_1.prisma.sessionLog.count({
253
- where: { is_healed: true, createdAt: { gte: startOfTodayUtc() } },
247
+ where: Object.assign({ is_healed: true, createdAt: { gte: startOfTodayUtc() } }, scope),
254
248
  }),
255
249
  ]);
256
250
  const events = rows.map((r) => {
@@ -309,13 +303,12 @@ function getHealingSummary(request, response) {
309
303
  since.setDate(since.getDate() - windowDays);
310
304
  const priorSince = new Date(since);
311
305
  priorSince.setDate(priorSince.getDate() - windowDays);
306
+ // Heal counts are over the caller's sessions. resolvedCount and pendingCount
307
+ // come from SelectorState, which has no team, so they stay fleet-wide.
308
+ const scope = yield visibleLogScope(request);
312
309
  const [currentRows, priorRows] = yield Promise.all([
313
310
  prisma_1.prisma.sessionLog.findMany({
314
- where: {
315
- is_healed: true,
316
- createdAt: { gte: since, lte: now },
317
- original_selector: { not: null },
318
- },
311
+ where: Object.assign({ is_healed: true, createdAt: { gte: since, lte: now }, original_selector: { not: null } }, scope),
319
312
  select: {
320
313
  session_id: true,
321
314
  original_selector: true,
@@ -323,11 +316,7 @@ function getHealingSummary(request, response) {
323
316
  },
324
317
  }),
325
318
  prisma_1.prisma.sessionLog.findMany({
326
- where: {
327
- is_healed: true,
328
- createdAt: { gte: priorSince, lt: since },
329
- original_selector: { not: null },
330
- },
319
+ where: Object.assign({ is_healed: true, createdAt: { gte: priorSince, lt: since }, original_selector: { not: null } }, scope),
331
320
  select: {
332
321
  session_id: true,
333
322
  original_selector: true,
@@ -385,7 +374,14 @@ function getHealingHotspots(request, response) {
385
374
  ? request.query.platform
386
375
  : null;
387
376
  const status = (_a = request.query.status) !== null && _a !== void 0 ? _a : 'active';
388
- const agg = yield aggregateHotspots({ windowDays, limit, tier, platform, status });
377
+ const agg = yield aggregateHotspots({
378
+ windowDays,
379
+ limit,
380
+ tier,
381
+ platform,
382
+ status,
383
+ sessionWhere: yield (0, sessionVisibility_1.visibleSessionWhere)(authOf(request)),
384
+ });
389
385
  return response.status(200).json({
390
386
  windowDays,
391
387
  totalScanned: agg.totalScanned,
@@ -428,9 +424,18 @@ function aggregateHotspots(opts) {
428
424
  };
429
425
  if (opts.tier)
430
426
  where.healing_tier = opts.tier;
427
+ // The platform and build filter, and the caller's sessions, under AND: the
428
+ // caller's rule is an OR, and a spread would let one OR overwrite another.
429
+ const sessionFilters = [];
431
430
  if (opts.platform || opts.buildId) {
432
- where.session = Object.assign(Object.assign({}, (opts.platform ? { device_platform: opts.platform } : {})), (opts.buildId ? { build_id: opts.buildId } : {}));
431
+ sessionFilters.push(Object.assign(Object.assign({}, (opts.platform ? { device_platform: opts.platform } : {})), (opts.buildId ? { build_id: opts.buildId } : {})));
433
432
  }
433
+ if (opts.sessionWhere)
434
+ sessionFilters.push(opts.sessionWhere);
435
+ if (sessionFilters.length === 1)
436
+ where.session = sessionFilters[0];
437
+ else if (sessionFilters.length > 1)
438
+ where.session = { AND: sessionFilters };
434
439
  const rows = yield prisma_1.prisma.sessionLog.findMany({
435
440
  where,
436
441
  select: {
@@ -529,6 +534,11 @@ function aggregateHotspots(opts) {
529
534
  // then merge them into each hotspot. The default `status=active` filter
530
535
  // hides muted/pending/resolved selectors so the live list, CI gate, and
531
536
  // webhook digest all see only "still actively breaking" selectors.
537
+ //
538
+ // The state is looked up only for selectors already in this (scoped) list,
539
+ // but the row is lab-wide and not scoped: `status`, `regression_count`,
540
+ // `clean_builds_count`, `last_event_at`, `resolved_at` and the
541
+ // `*_by_api_key` ids move with any team's heals, builds and admin actions.
532
542
  let stateMap = new Map();
533
543
  if (topHotspots.length > 0) {
534
544
  const states = yield prisma_1.prisma.selectorState.findMany({
@@ -584,6 +594,7 @@ function getHealingViolations(request, response) {
584
594
  platform,
585
595
  buildId,
586
596
  minHealCount,
597
+ sessionWhere: yield (0, sessionVisibility_1.visibleSessionWhere)(authOf(request)),
587
598
  });
588
599
  return response.status(200).json({
589
600
  windowDays,
@@ -610,6 +621,8 @@ function sendHealingDigest(request, response) {
610
621
  const minHealCount = Number.isFinite(minHealCountRaw)
611
622
  ? Math.min(Math.max(minHealCountRaw, 1), 1000)
612
623
  : 2;
624
+ // Every team's heals, on purpose: admin-only, and the digest goes to the
625
+ // lab's webhooks rather than back to one caller.
613
626
  const agg = yield aggregateHotspots({ windowDays, limit, minHealCount });
614
627
  const payload = {
615
628
  windowDays,
@@ -652,12 +665,10 @@ function getHealingSelectorDetail(request, response) {
652
665
  const windowDays = parseWindowDays(request.query.windowDays);
653
666
  const since = new Date();
654
667
  since.setDate(since.getDate() - windowDays);
668
+ // Only the caller's sessions: a selector healed only on phones they can't
669
+ // see reads as one with no heals.
655
670
  const rows = yield prisma_1.prisma.sessionLog.findMany({
656
- where: {
657
- is_healed: true,
658
- original_selector: value,
659
- createdAt: { gte: since },
660
- },
671
+ where: Object.assign({ is_healed: true, original_selector: value, createdAt: { gte: since } }, (yield visibleLogScope(request))),
661
672
  orderBy: { createdAt: 'desc' },
662
673
  take: 1000,
663
674
  include: {
@@ -774,6 +785,7 @@ function getSelectorHealth(request, response) {
774
785
  windowDays: 365,
775
786
  limit: 1000,
776
787
  status: 'all',
788
+ sessionWhere: yield (0, sessionVisibility_1.visibleSessionWhere)(authOf(request)),
777
789
  });
778
790
  let rows = agg.hotspots;
779
791
  if (selectorFilter) {
@@ -796,7 +808,8 @@ function getSelectorHealth(request, response) {
796
808
  // LocatorEtalon.selector is unique, so a lookup by selector alone
797
809
  // already pins the row; the strategy check guards against treating a
798
810
  // stale/unrelated etalon (selector reused under a different strategy)
799
- // as a match for this hotspot's (selector, strategy) tuple.
811
+ // as a match for this hotspot's (selector, strategy) tuple. Shared, not
812
+ // scoped: one etalon per selector, refreshed by any team's session.
800
813
  const etalon = yield etalonService.getSignature(h.originalSelector);
801
814
  if (etalon && etalon.strategy === h.originalStrategy) {
802
815
  out.etalonAge = now - etalon.lastSeen;
@@ -859,6 +872,11 @@ function postSelectorStateAction(request, response) {
859
872
  // hotspot aggregator) so muted-but-no-recent-heal entries still surface.
860
873
  // Each row is enriched with `last_healed_at` from the most recent healed
861
874
  // SessionLog row for that tuple (null if never healed).
875
+ //
876
+ // The mute is lab-wide state, so every caller gets the same rows and
877
+ // `muted_at`, `muted_by_api_key` and `regression_count`. `last_healed_at` is
878
+ // heal data, so it is the latest heal among the sessions the caller may see:
879
+ // null for a selector only another team has healed.
862
880
  function getMutedSelectors(request, response) {
863
881
  return __awaiter(this, void 0, void 0, function* () {
864
882
  var _a, _b;
@@ -871,13 +889,10 @@ function getMutedSelectors(request, response) {
871
889
  skip: offset,
872
890
  });
873
891
  const total = yield prisma_1.prisma.selectorState.count({ where: { status: 'muted' } });
892
+ const scope = yield visibleLogScope(request);
874
893
  const enriched = yield Promise.all(muted.map((s) => __awaiter(this, void 0, void 0, function* () {
875
894
  const last = yield prisma_1.prisma.sessionLog.findFirst({
876
- where: {
877
- original_strategy: s.original_strategy,
878
- original_selector: s.original_selector,
879
- is_healed: true,
880
- },
895
+ where: Object.assign({ original_strategy: s.original_strategy, original_selector: s.original_selector, is_healed: true }, scope),
881
896
  orderBy: { createdAt: 'desc' },
882
897
  select: { createdAt: true },
883
898
  });
@@ -896,7 +911,8 @@ function getMutedSelectors(request, response) {
896
911
  // Single-tuple state lookup. Strategy and value arrive URL-encoded since
897
912
  // XPath selectors contain `/` and `[`. Returns `{ state: null }` when no
898
913
  // row exists (intentional — null is meaningful: the selector is implicitly
899
- // active).
914
+ // active). The raw SelectorState row only, with no heal-derived field, so it
915
+ // answers the same for every caller: the row is lab-wide state.
900
916
  function getSelectorStateByTuple(request, response) {
901
917
  return __awaiter(this, void 0, void 0, function* () {
902
918
  var _a, _b;
@@ -913,6 +929,34 @@ function getSelectorStateByTuple(request, response) {
913
929
  return response.json({ state: row });
914
930
  });
915
931
  }
932
+ /** The session folders the dashboard shows (asset-manager.ts). Interceptor captures and recordings have their own rules. */
933
+ const SESSION_ASSET_KINDS = new Set(['screenshots', 'video', 'performance']);
934
+ /** One file name, no path and no leading dot. */
935
+ const SESSION_ASSET_FILE = /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/;
936
+ /**
937
+ * A session's stored screenshot, video or performance trace, stored as
938
+ * `<sessionId>/<kind>/<file>` under sessionAssetsPath. Behind isValidSession,
939
+ * so the caller must be able to see the session. Supports Range, for video
940
+ * seeking. This replaced an express.static mount outside the API's login.
941
+ */
942
+ function getSessionAsset(request, response) {
943
+ return __awaiter(this, void 0, void 0, function* () {
944
+ const { sessionId, kind, file } = request.params;
945
+ const notFound = () => response.status(404).json({ error: true, message: 'Session asset not found' });
946
+ if (!SESSION_ASSET_KINDS.has(kind) || !SESSION_ASSET_FILE.test(file))
947
+ return notFound();
948
+ response.sendFile(file, {
949
+ root: path_1.default.join(config_1.config.sessionAssetsPath, sessionId, kind),
950
+ headers: { 'Cache-Control': 'private, max-age=300' },
951
+ }, (err) => {
952
+ if (!err || response.headersSent)
953
+ return;
954
+ if (err.status === 416 || err.statusCode === 416)
955
+ return response.status(416).end();
956
+ notFound();
957
+ });
958
+ });
959
+ }
916
960
  function getProfilingData(request, response) {
917
961
  return __awaiter(this, void 0, void 0, function* () {
918
962
  const sessionId = request.params.sessionId;
@@ -1038,6 +1082,9 @@ function register(router) {
1038
1082
  router.get('/session/:sessionId/logs/device', getDeviceLogs);
1039
1083
  router.get('/session/:sessionId/logs/debug', getDebugLogs);
1040
1084
  router.get('/session/:sessionId/profiling', getProfilingData);
1085
+ router.get('/session/:sessionId/asset/:kind/:file', getSessionAsset);
1086
+ // The healing reads count only heals from sessions the caller may see
1087
+ // (visibleSessionWhere); the digest below counts every team's.
1041
1088
  router.get('/healing/events', getRecentHealingEvents);
1042
1089
  router.get('/healing/summary', getHealingSummary);
1043
1090
  router.get('/healing/hotspots', getHealingHotspots);
@@ -1049,7 +1096,10 @@ function register(router) {
1049
1096
  router.post('/healing/digest/send', (0, roleGuard_1.roleGuard)('ADMIN'), (0, scopeGuard_1.scopeGuard)(['admin']), sendHealingDigest);
1050
1097
  // SelectorState lifecycle: state mutations require admin (they affect what
1051
1098
  // shows up in the live hotspot list, the CI gate, and the digest); the two
1052
- // reads inherit the existing dashboard auth.
1099
+ // reads inherit the existing dashboard auth. Both reads stay global, not
1100
+ // team-scoped: a selector's mute or fix is one lab-wide row with no team
1101
+ // column, shared by every team whose tests use that selector. The one
1102
+ // heal-derived field, the muted list's `last_healed_at`, is the caller's.
1053
1103
  router.post('/healing/selector/state', (0, roleGuard_1.roleGuard)('ADMIN'), (0, scopeGuard_1.scopeGuard)(['admin']), postSelectorStateAction);
1054
1104
  router.get('/healing/state/muted', getMutedSelectors);
1055
1105
  router.get('/healing/state/:strategy/:value', getSelectorStateByTuple);
@@ -50,9 +50,10 @@ const queue_service_1 = require("../../data-service/queue-service");
50
50
  const InternalHttpClient_1 = require("../../InternalHttpClient");
51
51
  const lodash_1 = __importDefault(require("lodash"));
52
52
  const device_service_1 = require("../../data-service/device-service");
53
+ const queueVisibility_1 = require("../../services/device-access/queueVisibility");
53
54
  const scopeGuard_1 = require("../../middleware/scopeGuard");
54
55
  const roleGuard_1 = require("../../middleware/roleGuard");
55
- const logger_1 = __importDefault(require("../../logger"));
56
+ const logger_1 = __importStar(require("../../logger"));
56
57
  const device_managers_1 = require("../../device-managers");
57
58
  const typedi_1 = require("typedi");
58
59
  const prisma_1 = require("../../prisma");
@@ -204,38 +205,22 @@ function unBlockDevice(request, response) {
204
205
  response.status(200).send({ success: true });
205
206
  });
206
207
  }
207
- // Phase 4A: Pending sessions hold a JSON-decoded capability bag, not a row
208
- // from the Session model — they may carry `appium:udid` if the test runner
209
- // targeted a specific device, but most queue items are platform-only.
210
- // Strategy: rows with `appium:udid` get visibility-filtered against that
211
- // udid; rows without one pass through (the capability could still match any
212
- // device the caller can see, and there's no row-level ownership to enforce).
213
- function filterPendingByVisibleDevice(rows, teamIds) {
214
- return __awaiter(this, void 0, void 0, function* () {
215
- if (teamIds === undefined)
216
- return rows;
217
- const targeted = rows.filter((r) => typeof r['appium:udid'] === 'string' && r['appium:udid']);
218
- const untargeted = rows.filter((r) => !(typeof r['appium:udid'] === 'string' && r['appium:udid']));
219
- if (targeted.length === 0)
220
- return untargeted;
221
- const visibleTargeted = yield (0, device_service_1.filterRowsByVisibleDevice)(targeted, teamIds, 'appium:udid');
222
- return [...untargeted, ...visibleTargeted];
223
- });
224
- }
208
+ const queueCaller = (request) => request.auth;
225
209
  function getQueuedSessionLength(request, response) {
226
210
  return __awaiter(this, void 0, void 0, function* () {
227
- const auth = request.auth;
228
211
  const all = yield pendingStore.getAllPendingSessions();
229
- const visible = yield filterPendingByVisibleDevice(all, auth === null || auth === void 0 ? void 0 : auth.teamIds);
230
- response.json(visible.length);
212
+ response.json(all.length);
231
213
  });
232
214
  }
233
215
  function getQueuedSessionRequests(request, response) {
234
216
  return __awaiter(this, void 0, void 0, function* () {
235
- const auth = request.auth;
236
217
  const all = yield pendingStore.getAllPendingSessions();
237
- const visible = yield filterPendingByVisibleDevice(all, auth === null || auth === void 0 ? void 0 : auth.teamIds);
238
- response.json(visible);
218
+ const { visible } = yield (0, queueVisibility_1.partitionPendingForCaller)(all, queueCaller(request));
219
+ // A row is the capabilities the client sent. createSession takes the
220
+ // credentials out of xe:options before writing it, but a row queued by an
221
+ // older server can still hold the key and token that signed it. Those are
222
+ // nobody else's to read, a teammate's or an admin's.
223
+ response.json(visible.map((row) => (0, logger_1.redactSecrets)((0, queueVisibility_1.withoutRequester)(row))));
239
224
  });
240
225
  }
241
226
  function getNodes(request, response) {
@@ -260,39 +245,32 @@ function getNodes(request, response) {
260
245
  }
261
246
  function getQueueStatusById(request, response) {
262
247
  return __awaiter(this, void 0, void 0, function* () {
263
- const status = yield typedi_1.Container.get(queue_service_1.QueueService).getQueueStatus(request.params.capability_id);
264
- if (!status) {
265
- return response.status(404).json({ error: 'Pending session not found' });
266
- }
267
- // 404 the row if the underlying pending capability targets a device the
268
- // caller can't see. Capability-only (no `appium:udid`) requests stay
269
- // visible — there's no per-device ownership on them.
270
- const auth = request.auth;
271
- if ((auth === null || auth === void 0 ? void 0 : auth.teamIds) !== undefined) {
272
- const allPending = yield pendingStore.getAllPendingSessions();
273
- const target = allPending.find((s) => s.capability_id === request.params.capability_id);
274
- const targetUdid = target && typeof target['appium:udid'] === 'string' ? target['appium:udid'] : undefined;
275
- if (targetUdid) {
276
- const dev = yield prisma_1.prisma.device.findFirst({
277
- where: { udid: targetUdid },
278
- select: { teamId: true },
279
- });
280
- const visible = dev && (dev.teamId === null || auth.teamIds.includes(dev.teamId));
281
- if (!visible) {
282
- return response.status(404).json({ error: 'Pending session not found' });
283
- }
284
- }
248
+ const notFound = () => response.status(404).json({ error: 'Pending session not found' });
249
+ // A request the caller may not see answers exactly as an unknown id.
250
+ const caller = queueCaller(request);
251
+ if ((caller === null || caller === void 0 ? void 0 : caller.teamIds) !== undefined) {
252
+ const all = yield pendingStore.getAllPendingSessions();
253
+ const target = all.find((s) => s.capability_id === request.params.capability_id);
254
+ if (!target)
255
+ return notFound();
256
+ const { visible } = yield (0, queueVisibility_1.partitionPendingForCaller)([target], caller);
257
+ if (visible.length === 0)
258
+ return notFound();
285
259
  }
260
+ const status = yield typedi_1.Container.get(queue_service_1.QueueService).getQueueStatus(request.params.capability_id);
261
+ if (!status)
262
+ return notFound();
286
263
  response.json(status);
287
264
  });
288
265
  }
289
266
  function getQueueSummary(request, response) {
290
267
  return __awaiter(this, void 0, void 0, function* () {
291
- // Global counters / per-platform aggregates only — no per-device or
292
- // per-host breakdown. Admins and members see the same number; the team
293
- // filter doesn't apply directly.
294
- const summary = yield typedi_1.Container.get(queue_service_1.QueueService).getQueueSummary();
295
- response.json(summary);
268
+ // Counts only. `total` is the whole queue, like /queue/length; `otherCount`
269
+ // is how many of those the caller doesn't get in detail from /queue.
270
+ const all = yield pendingStore.getAllPendingSessions();
271
+ const { otherCount } = yield (0, queueVisibility_1.partitionPendingForCaller)(all, queueCaller(request));
272
+ const summary = yield typedi_1.Container.get(queue_service_1.QueueService).getQueueSummary(all);
273
+ response.json(Object.assign(Object.assign({}, summary), { otherCount }));
296
274
  });
297
275
  }
298
276
  function nodeAdbStatusOnOtherHost(currentHost, request, response) {
@@ -478,12 +456,15 @@ function register(router, pluginArgs) {
478
456
  router.get('/queue/status/:capability_id', getQueueStatusById);
479
457
  router.get('/queue/summary', getQueueSummary);
480
458
  router.get('/sessions/active', getActiveSessions);
481
- // debugging / observability
482
- router.get('/logs/requests', getRequestLogs);
483
- // node related routes
459
+ // debugging / observability. Admin-only, like /processes: the log holds
460
+ // every internal HTTP call, including control proxied to other nodes'
461
+ // phones (with what was typed) and forwarded new sessions (their ids).
462
+ router.get('/logs/requests', (0, roleGuard_1.roleGuard)('ADMIN'), (0, scopeGuard_1.scopeGuard)(['admin']), getRequestLogs);
463
+ // node related routes. The status reads list every attached phone, so
464
+ // they are admin-only too.
484
465
  router.get('/node', getNodes);
485
- router.get('/node/status', nodeAdbStatusOnThisHost);
486
- router.get('/node/:host/status', lodash_1.default.curry(nodeAdbStatusOnOtherHost)(pluginArgs.bindHostOrIp));
466
+ router.get('/node/status', (0, roleGuard_1.roleGuard)('ADMIN'), (0, scopeGuard_1.scopeGuard)(['admin']), nodeAdbStatusOnThisHost);
467
+ router.get('/node/:host/status', (0, roleGuard_1.roleGuard)('ADMIN'), (0, scopeGuard_1.scopeGuard)(['admin']), lodash_1.default.curry(nodeAdbStatusOnOtherHost)(pluginArgs.bindHostOrIp));
487
468
  // node status
488
469
  router.get('/status', (request, response) => {
489
470
  response.json({
@@ -464,7 +464,9 @@ router.get('/recordings/active', (req, res) => __awaiter(void 0, void 0, void 0,
464
464
  var _a;
465
465
  try {
466
466
  const actor = (0, actor_1.resolveActor)(req);
467
- const rows = yield typedi_1.Container.get(recording_store_1.RecordingStore).listActiveWithMarks();
467
+ // A group the caller holds a phone of can hold phones they can't see (an
468
+ // admin added them, or a phone moved team): leave those out, and their marks.
469
+ const rows = yield visibleRows(req, yield typedi_1.Container.get(recording_store_1.RecordingStore).listActiveWithMarks());
468
470
  const locks = new Map();
469
471
  for (const udid of new Set(rows.map((r) => r.device_udid))) {
470
472
  const d = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
@@ -414,9 +414,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
414
414
  * summary: Read the plugin CLI arguments in effect
415
415
  * description: |
416
416
  * Returns the resolved plugin arguments (host, hub URL, platform, intervals, etc.)
417
- * this process was started with. Sensitive fields (database URLs) are
418
- * redacted by the logger but the raw object is returned over the wire to
419
- * authenticated callers — do not expose this endpoint to untrusted networks.
417
+ * this process was started with. Requires the ADMIN role and `admin` scope.
418
+ * Values are redacted the way the logger redacts them: any key that looks
419
+ * secret (database URL, API key, token, password, auth…) reads
420
+ * `***REDACTED***`, and so do provider-key-shaped strings.
420
421
  * tags: [Health & Ops]
421
422
  * responses:
422
423
  * 200:
@@ -425,6 +426,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
425
426
  * application/json:
426
427
  * schema: { type: object, additionalProperties: true }
427
428
  * 401: { $ref: '#/components/responses/Unauthorized' }
429
+ * 403: { $ref: '#/components/responses/Forbidden' }
428
430
  * 429: { $ref: '#/components/responses/RateLimited' }
429
431
  */
430
432
  // =============================================================================
@@ -1107,7 +1109,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
1107
1109
  * description: |
1108
1110
  * Returns the queue position and ETA for a pending session keyed by the
1109
1111
  * capability id you submitted. 404 if the request has already been
1110
- * allocated or expired.
1112
+ * allocated or expired, or is one the caller can't see in
1113
+ * `GET /api/queue`: the same answer as for an unknown id.
1111
1114
  * tags: [Grid]
1112
1115
  * parameters:
1113
1116
  * - in: path
@@ -34,6 +34,9 @@ Xenon's high-performance API provides programmatic access to the core orchestrat
34
34
  ### Authentication
35
35
  Every endpoint authenticates with the per-user \`(x-xenon-access-key, x-xenon-token)\` pair. Each token carries one or more scopes — \`read\`, \`sessions\`, \`devices\`, or \`admin\` (admin always satisfies any scope check). Mint tokens at \`/profile\` → **API Tokens**; the access key is shown at the top of that page and is rotatable. Browser dashboards may use the \`xenon_dashboard_session\` cookie set by \`POST /api/auth/login\`.
36
36
 
37
+ ### Appium sessions
38
+ Appium's own \`POST /session\` is not under this API. A session authenticates with capabilities in \`xe:options\`, Xenon's capability namespace: \`accessKey\` + \`token\` (a token with the \`sessions\` scope), or a \`sessionToken\` minted by \`POST /api/auth/token\`. A leased device is named by \`leaseId\` + \`leaseToken\`, which the lease-create response's \`appiumCapabilities\` already carry. \`xenon:options\` is read as an alias (\`xe:options\` wins field by field); \`df:options\` is not read. Xenon removes the credentials before the driver or any stored record sees them.
39
+
37
40
  ### Rate limiting
38
41
  Every authenticated response carries \`X-RateLimit-Limit\`, \`X-RateLimit-Remaining\`, and \`X-RateLimit-Reset\` headers. Requests beyond the configured budget receive \`429 Too Many Requests\`.
39
42
  `,
@@ -52,9 +52,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
52
52
  };
53
53
  Object.defineProperty(exports, "__esModule", { value: true });
54
54
  exports.DASHBORD_EVENT_MANAGER = exports.DashboardEventManager = void 0;
55
+ exports.storedSessionCapabilities = storedSessionCapabilities;
55
56
  const SessionManager_1 = require("../sessions/SessionManager");
56
57
  const prisma_1 = require("../prisma");
57
- const logger_1 = __importDefault(require("../logger"));
58
+ const logger_1 = __importStar(require("../logger"));
58
59
  const session_service_1 = require("./services/session-service");
59
60
  const XenonCapabilityManager_1 = require("../XenonCapabilityManager");
60
61
  const lodash_1 = __importDefault(require("lodash"));
@@ -80,6 +81,21 @@ const types_1 = require("../services/healing/types");
80
81
  const SelectorStateService_1 = require("../services/SelectorStateService");
81
82
  const recording_store_1 = require("../services/recording/recording-store");
82
83
  const typedi_2 = require("typedi");
84
+ /**
85
+ * What a Session row keeps of the capabilities the driver returned. The row
86
+ * is served to the dashboard and the session API, so any secret-named value
87
+ * is redacted. createSession already takes Xenon's own credentials out of
88
+ * xe:options and xenon:options before the driver sees them; this also covers
89
+ * a token a client put where Xenon doesn't read it, which the driver hands
90
+ * back in its response. The response itself is not changed.
91
+ */
92
+ function storedSessionCapabilities(sessionResponse) {
93
+ const redacted = (0, logger_1.redactSecrets)(sessionResponse);
94
+ return {
95
+ desired_capabilities: JSON.stringify(redacted.desired || {}),
96
+ session_capabilities: JSON.stringify(lodash_1.default.omit(redacted, 'desired')),
97
+ };
98
+ }
83
99
  let DashboardEventManager = class DashboardEventManager {
84
100
  constructor() {
85
101
  // private SCREENSHOT_FOR_COMMANDS = ['click', 'setUrl', 'setValue', 'performActions'];
@@ -133,26 +149,7 @@ let DashboardEventManager = class DashboardEventManager {
133
149
  const sessionResponse = lodash_1.default.assign({}, session.getCapabilities());
134
150
  const tracingService = typedi_1.Container.get(TracingService_1.TracingService);
135
151
  const traceId = tracingService.getTraceId(session.getId());
136
- const createData = {
137
- id: session.getId(),
138
- build: build.id ? { connect: { id: build.id } } : undefined,
139
- name: capabilities[XenonCapabilityManager_1.XENON_CAPABILITIES.SESSION_NAME] || undefined,
140
- desired_capabilities: JSON.stringify(sessionResponse.desired || {}),
141
- session_capabilities: JSON.stringify(lodash_1.default.omit(sessionResponse, 'desired')),
142
- node_id: device.nodeId || '',
143
- has_live_video: session.getLiveVideoUrl() !== null,
144
- video_recording_enabled: capabilities[XenonCapabilityManager_1.XENON_CAPABILITIES.VIDEO_RECORDING] === true,
145
- is_profiling_available: is_profiling_available || isIosProfilingStarted,
146
- device_info: device_info ? JSON.stringify(device_info) : null,
147
- device_udid: device.udid || '',
148
- device_platform: device.platform || '',
149
- device_version: device.sdk || '',
150
- device_name: device.name,
151
- trace_id: traceId,
152
- status: 'running', // Principal Polish: Set status explicitly
153
- api_key_id: (_a = session.apiKeyId) !== null && _a !== void 0 ? _a : null,
154
- user_id: (_b = session.userId) !== null && _b !== void 0 ? _b : null,
155
- };
152
+ const createData = Object.assign(Object.assign({ id: session.getId(), build: build.id ? { connect: { id: build.id } } : undefined, name: capabilities[XenonCapabilityManager_1.XENON_CAPABILITIES.SESSION_NAME] || undefined }, storedSessionCapabilities(sessionResponse)), { node_id: device.nodeId || '', has_live_video: session.getLiveVideoUrl() !== null, video_recording_enabled: capabilities[XenonCapabilityManager_1.XENON_CAPABILITIES.VIDEO_RECORDING] === true, is_profiling_available: is_profiling_available || isIosProfilingStarted, device_info: device_info ? JSON.stringify(device_info) : null, device_udid: device.udid || '', device_platform: device.platform || '', device_version: device.sdk || '', device_name: device.name, trace_id: traceId, status: 'running', api_key_id: (_a = session.apiKeyId) !== null && _a !== void 0 ? _a : null, user_id: (_b = session.userId) !== null && _b !== void 0 ? _b : null });
156
153
  yield prisma_1.prisma.session.create({
157
154
  data: createData,
158
155
  });