@xenon-device-management/xenon 1.29.1 → 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 (181) hide show
  1. package/README.md +11 -2
  2. package/lib/package.json +1 -1
  3. package/lib/public/assets/{AnnotationOverlay-CBX06bvL.js → AnnotationOverlay-BmTPCAds.js} +1 -1
  4. package/lib/public/assets/{ApiKeyGate-BmSAcEM3.js → ApiKeyGate-B5eB7f8B.js} +1 -1
  5. package/lib/public/assets/{BugReportButton-uuz-CkVn.js → BugReportButton-DKYsOdGV.js} +1 -1
  6. package/lib/public/assets/{DeviceMosaicView-Cx2RN3JN.js → DeviceMosaicView-9DUoZnQi.js} +1 -1
  7. package/lib/public/assets/{EmptyState-C0dkxCz7.js → EmptyState-lvyNiXev.js} +1 -1
  8. package/lib/public/assets/{FieldGroup-2PExFkKR.js → FieldGroup-BduMPM9K.js} +1 -1
  9. package/lib/public/assets/{FilterMenu-Cm5FJvYO.js → FilterMenu-BSqX6I94.js} +1 -1
  10. package/lib/public/assets/{Menu-BVz3Uew1.js → Menu-Ba5cT7jZ.js} +1 -1
  11. package/lib/public/assets/{Modal-Dv6O02UG.js → Modal-CceoMY7D.js} +1 -1
  12. package/lib/public/assets/{RecordingPage-Bs6SnpZh.js → RecordingPage-6T9dOidc.js} +1 -1
  13. package/lib/public/assets/{RecordingsPage-B-wya59w.js → RecordingsPage-qj6FdZXR.js} +1 -1
  14. package/lib/public/assets/{SegmentedControl-COm_aKoO.js → SegmentedControl-CSRuZ8TR.js} +1 -1
  15. package/lib/public/assets/{SettingCard-uGx1zPf5.js → SettingCard-Bp1kz4By.js} +1 -1
  16. package/lib/public/assets/{Table-BlPh-WXS.js → Table-LV7zFyZg.js} +1 -1
  17. package/lib/public/assets/{activity-Cg9lQJWD.js → activity-D17TUloi.js} +1 -1
  18. package/lib/public/assets/{ai-settings-CR5wqyw6.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-B_gxyen1.js → arrow-left-DD1VjtC7.js} +1 -1
  23. package/lib/public/assets/{arrow-right-CDAnM3V1.js → arrow-right-D0pnM3ma.js} +1 -1
  24. package/lib/public/assets/{arrow-up-right-D_-mJPuq.js → arrow-up-right-BIC8JOV6.js} +1 -1
  25. package/lib/public/assets/{auth-shell-5hdy5w1n.js → auth-shell-DWHPXSnI.js} +2 -2
  26. package/lib/public/assets/{builds-page-DhnvFn8Z.js → builds-page-ClCNBUv_.js} +1 -1
  27. package/lib/public/assets/{button-B0ADzApc.js → button-xtAhYR-f.js} +1 -1
  28. package/lib/public/assets/{calendar-BPUQybnc.js → calendar-CupwJ7Bm.js} +1 -1
  29. package/lib/public/assets/{check-Cg3UPdZx.js → check-CyuEoKkd.js} +1 -1
  30. package/lib/public/assets/{chevron-left-BBDTngOm.js → chevron-left-DgX-F9xK.js} +1 -1
  31. package/lib/public/assets/{chevron-right-KfUn3lbq.js → chevron-right-BKJgJnU9.js} +1 -1
  32. package/lib/public/assets/{circle-check-OqbOWQkP.js → circle-check-CZdg8mMa.js} +1 -1
  33. package/lib/public/assets/{circle-x-BdD_O4_Y.js → circle-x-CZH6t9z-.js} +1 -1
  34. package/lib/public/assets/{clock-423jy-Nh.js → clock-Cd-t7xpe.js} +1 -1
  35. package/lib/public/assets/{copy-CXBBskEl.js → copy-C6zerX3I.js} +1 -1
  36. package/lib/public/assets/{device-explorer-NN0qUgr_.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-D-KpnIJY.js → download-x9WP7mcL.js} +1 -1
  39. package/lib/public/assets/{forgot-password-DOdyy69t.js → forgot-password-B0TEH4N-.js} +1 -1
  40. package/lib/public/assets/{funnel-D14CL_Wi.js → funnel-Cb40MfFN.js} +1 -1
  41. package/lib/public/assets/{index-C8lhFZZl.js → index-BR1wTIPC.js} +9 -9
  42. package/lib/public/assets/{index-v6FT1hxD.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-BaQb3TNn.js → input-DejcCiTI.js} +1 -1
  45. package/lib/public/assets/{keycodes-CKO88csS.js → keycodes-Mavx5iGg.js} +1 -1
  46. package/lib/public/assets/{layers-CuUhwGxH.js → layers-BKqYLiHE.js} +1 -1
  47. package/lib/public/assets/{lock-DxrSyg_w.js → lock-LFsFhc17.js} +1 -1
  48. package/lib/public/assets/{login-CCVWZ6yV.js → login-x2pkzt-t.js} +1 -1
  49. package/lib/public/assets/{maintenance-settings-3oY1vwin.js → maintenance-settings-CYafcoV-.js} +1 -1
  50. package/lib/public/assets/{monitor-BIoWxb1z.js → monitor-_7_uJ8d9.js} +1 -1
  51. package/lib/public/assets/{mouse-pointer-2-D57-qGtl.js → mouse-pointer-2-BfLuURKN.js} +1 -1
  52. package/lib/public/assets/{network-6sG_tBbN.js → network-Z_zBhpsD.js} +1 -1
  53. package/lib/public/assets/{overview-B4pHRYlE.js → overview-BY5uv08S.js} +1 -1
  54. package/lib/public/assets/{page-header-D2FYSmC5.js → page-header-CP2UkykG.js} +1 -1
  55. package/lib/public/assets/{plus-DCxbMKBL.js → plus-BTD0dnQ0.js} +1 -1
  56. package/lib/public/assets/{profile-page-DzQsHQy9.js → profile-page-BhJZuCS7.js} +1 -1
  57. package/lib/public/assets/{reset-password-q6FdvFSe.js → reset-password-CMN0SO81.js} +1 -1
  58. package/lib/public/assets/{runbook-page-DislP3W6.js → runbook-page-CBpPX9_5.js} +1 -1
  59. package/lib/public/assets/{select-DacTBzGz.js → select-Uc9E5pye.js} +1 -1
  60. package/lib/public/assets/{selector-detail-page-DMdWaRAQ.js → selector-detail-page-BSyBnRuU.js} +1 -1
  61. package/lib/public/assets/{selector-health-DQsOvugw.js → selector-health-DzqxNkzm.js} +1 -1
  62. package/lib/public/assets/{selector-health-page--adBJmmT.js → selector-health-page-CyPLjoD1.js} +1 -1
  63. package/lib/public/assets/{session-detail-page-D1_Syg-V.js → session-detail-page-BZQZN1P-.js} +1 -1
  64. package/lib/public/assets/{settings-B2rCUObQ.js → settings-CkSWiZq8.js} +1 -1
  65. package/lib/public/assets/{status-pill-outline-CvHE1e2M.js → status-pill-outline-BFYni2qo.js} +1 -1
  66. package/lib/public/assets/{tablet-DVEtznJc.js → tablet-BSd-xSZK.js} +1 -1
  67. package/lib/public/assets/teams-PpoEQz31.js +1 -0
  68. package/lib/public/assets/{trash-2-Bg_Pbt3E.js → trash-2-BorJv5_u.js} +1 -1
  69. package/lib/public/assets/{upload-BceikXSx.js → upload-C3i7sAZl.js} +1 -1
  70. package/lib/public/assets/{use-password-reset-mode-BD8plGbG.js → use-password-reset-mode-CeZQ3EsH.js} +1 -1
  71. package/lib/public/assets/{users-BE5ChVjL.js → users-CEJ3fC78.js} +1 -1
  72. package/lib/public/assets/{users--omSbsNY.js → users-Drd7ifJx.js} +1 -1
  73. package/lib/public/assets/{video-off-n13wvZGN.js → video-off-CNzn6bPE.js} +1 -1
  74. package/lib/public/assets/{webhook-settings-kG35CJaN.js → webhook-settings-BFqX6QXV.js} +1 -1
  75. package/lib/public/assets/{zap-CpvPW5mB.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 +8 -4
  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/capabilities.js +4 -0
  85. package/lib/src/app/routers/control.js +6 -1
  86. package/lib/src/app/routers/dashboard.js +44 -10
  87. package/lib/src/app/routers/grid.js +30 -52
  88. package/lib/src/app/swagger-docs.js +7 -4
  89. package/lib/src/app/swagger.js +3 -0
  90. package/lib/src/dashboard/event-manager.js +18 -21
  91. package/lib/src/dashboard/services/app-service.js +24 -2
  92. package/lib/src/data-service/queue-service.js +4 -3
  93. package/lib/src/device-utils.js +1 -1
  94. package/lib/src/generated/client/edge.js +5 -4
  95. package/lib/src/generated/client/index-browser.js +2 -1
  96. package/lib/src/generated/client/index.d.ts +413 -7
  97. package/lib/src/generated/client/index.js +5 -4
  98. package/lib/src/generated/client/package.json +1 -1
  99. package/lib/src/generated/client/schema.prisma +7 -0
  100. package/lib/src/generated/client/wasm.js +2 -1
  101. package/lib/src/interceptors/CommandInterceptor.js +5 -5
  102. package/lib/src/middleware/authMiddleware.js +44 -36
  103. package/lib/src/middleware/commandAuth.js +146 -0
  104. package/lib/src/middleware/commandCaller.js +142 -0
  105. package/lib/src/middleware/verifyCredential.js +124 -0
  106. package/lib/src/services/ServerManager.js +7 -0
  107. package/lib/src/services/SessionLifecycleService.js +143 -73
  108. package/lib/src/services/TeamService.js +6 -3
  109. package/lib/src/services/device-access/appVisibility.js +27 -0
  110. package/lib/src/services/device-access/queueVisibility.js +118 -0
  111. package/lib/src/services/healing/HealingOrchestrator.js +11 -3
  112. package/lib/src/services/lease/LeaseService.js +13 -4
  113. package/lib/src/services/lease/buildCapabilityBag.js +4 -1
  114. package/lib/src/services/lease/leaseSessionCaps.js +13 -69
  115. package/lib/src/services/recording/annotation-render.js +33 -1
  116. package/lib/src/services/recording/recordingSummary.js +6 -5
  117. package/lib/src/services/recording/recordingTiming.js +19 -5
  118. package/lib/src/services/session/appCapability.js +50 -0
  119. package/lib/src/services/session/sessionCredentials.js +105 -0
  120. package/lib/src/services/session/xenonOptions.js +71 -0
  121. package/lib/src/services/sessionTokenGate.js +19 -9
  122. package/lib/src/services/token/AppDownloadTicketService.js +73 -0
  123. package/lib/src/services/token/StreamTicketService.js +7 -17
  124. package/lib/src/services/token/singleUseLedger.js +41 -0
  125. package/lib/src/sessions/XenonSession.js +2 -2
  126. package/lib/test/helpers/loopbackServer.js +48 -0
  127. package/lib/test/integration/sdk-leases-router.spec.js +1 -1
  128. package/lib/test/integration/team-visibility-apps.spec.js +181 -0
  129. package/lib/test/integration/team-visibility-recordings.spec.js +4 -0
  130. package/lib/test/integration/team-visibility-sessions.spec.js +92 -13
  131. package/lib/test/unit/AppDownloadTicketService.spec.js +101 -0
  132. package/lib/test/unit/CapabilitiesRouter.spec.js +10 -0
  133. package/lib/test/unit/SessionTokenGate.spec.js +7 -2
  134. package/lib/test/unit/TeamService.test.js +22 -0
  135. package/lib/test/unit/XenonCapabilityManager.spec.js +11 -1
  136. package/lib/test/unit/aggregate-hotspots-session-scope.spec.js +202 -0
  137. package/lib/test/unit/annotation-render.spec.js +90 -0
  138. package/lib/test/unit/apps-team-visibility.spec.js +305 -0
  139. package/lib/test/unit/authMiddleware.appTicket.spec.js +201 -0
  140. package/lib/test/unit/cliargs-admin-only.spec.js +182 -0
  141. package/lib/test/unit/command-auth-appium-server.spec.js +184 -0
  142. package/lib/test/unit/command-auth.spec.js +410 -0
  143. package/lib/test/unit/command-caller.spec.js +254 -0
  144. package/lib/test/unit/event-manager-regression-hook.spec.js +7 -1
  145. package/lib/test/unit/extract-access-key-token-pair.test.js +47 -4
  146. package/lib/test/unit/healing-tier-gate.spec.js +24 -1
  147. package/lib/test/unit/insert-before-routes.spec.js +167 -0
  148. package/lib/test/unit/install-repository-app-team.spec.js +116 -0
  149. package/lib/test/unit/lease/LeaseService.spec.js +3 -1
  150. package/lib/test/unit/lease/allocate-lease-branch.spec.js +2 -2
  151. package/lib/test/unit/lease/buildCapabilityBag.spec.js +3 -2
  152. package/lib/test/unit/lease/lease-override.spec.js +1 -1
  153. package/lib/test/unit/lease/lease-session-ownership.spec.js +4 -4
  154. package/lib/test/unit/lease/lease-team-visibility.spec.js +1 -1
  155. package/lib/test/unit/lease/lease-token-log-filter.spec.js +1 -1
  156. package/lib/test/unit/lease/lease-token-oracle.spec.js +207 -0
  157. package/lib/test/unit/lease/session-lease-proof.spec.js +42 -26
  158. package/lib/test/unit/logcat-ws.spec.js +7 -4
  159. package/lib/test/unit/queue-requester.spec.js +211 -0
  160. package/lib/test/unit/queue-team-scope-routes.spec.js +209 -0
  161. package/lib/test/unit/queue-visibility.spec.js +179 -0
  162. package/lib/test/unit/recording-summary.spec.js +22 -0
  163. package/lib/test/unit/recording-timing.spec.js +27 -0
  164. package/lib/test/unit/session-app-resolution.spec.js +331 -0
  165. package/lib/test/unit/session-attribution-wiring.spec.js +134 -24
  166. package/lib/test/unit/session-credential-strip.spec.js +277 -0
  167. package/lib/test/unit/session-credentials.spec.js +149 -0
  168. package/lib/test/unit/session-identity.spec.js +2 -2
  169. package/lib/test/unit/stored-session-capabilities.spec.js +54 -0
  170. package/lib/test/unit/verify-credential.spec.js +138 -0
  171. package/lib/test/unit/xenon-options.spec.js +95 -0
  172. package/lib/tsconfig.tsbuildinfo +1 -1
  173. package/package.json +1 -1
  174. package/prisma/migrations/20260928120000_app_team/migration.sql +26 -0
  175. package/prisma/schema.prisma +7 -0
  176. package/schema.json +1 -1
  177. package/lib/public/assets/api-keys-BBS6ZY26.js +0 -1
  178. package/lib/public/assets/apps-DJMD5JOy.css +0 -1
  179. package/lib/public/assets/apps-DgfOFIlR.js +0 -21
  180. package/lib/public/assets/device-explorer-DTH5oU54.css +0 -1
  181. package/lib/public/assets/teams-3TqCn1pR.js +0 -1
@@ -49,7 +49,6 @@ exports.XENON_CAPABILITIES = void 0;
49
49
  exports.androidCapabilities = androidCapabilities;
50
50
  exports.iOSCapabilities = iOSCapabilities;
51
51
  exports.extractTeamCap = extractTeamCap;
52
- exports.extractAccessKeyCap = extractAccessKeyCap;
53
52
  exports.extractAccessKeyTokenPair = extractAccessKeyTokenPair;
54
53
  exports.extractSessionToken = extractSessionToken;
55
54
  exports.getXenonCapabilities = getXenonCapabilities;
@@ -58,6 +57,7 @@ const lodash_1 = __importDefault(require("lodash"));
58
57
  const typedi_1 = require("typedi");
59
58
  const PortAllocator_1 = require("./services/PortAllocator");
60
59
  const logger_1 = __importDefault(require("./logger"));
60
+ const xenonOptions_1 = require("./services/session/xenonOptions");
61
61
  var XENON_CAPABILITIES;
62
62
  (function (XENON_CAPABILITIES) {
63
63
  XENON_CAPABILITIES["BUILD_NAME"] = "build";
@@ -68,7 +68,6 @@ var XENON_CAPABILITIES;
68
68
  XENON_CAPABILITIES["LIVE_VIDEO"] = "live_video";
69
69
  XENON_CAPABILITIES["SCREENSHOT_ON_FAILURE"] = "screenshot_on_failure";
70
70
  XENON_CAPABILITIES["SCREENSHOT_ON_FAIL"] = "screenshotOnFailure";
71
- XENON_CAPABILITIES["XENON_OPTIONS"] = "xenon:options";
72
71
  XENON_CAPABILITIES["SAVE_DEVICE_LOGS"] = "saveDeviceLogs";
73
72
  XENON_CAPABILITIES["SAVE_LOGS"] = "save_device_logs";
74
73
  XENON_CAPABILITIES["SCREENSHOT_ON_EVERY_COMMAND"] = "screenshot_on_every_command";
@@ -221,58 +220,33 @@ function extractTeamCap(caps) {
221
220
  }
222
221
  return undefined;
223
222
  }
224
- function extractAccessKeyCap(caps) {
225
- var _a;
226
- const merged = Object.assign({}, ((_a = caps.firstMatch) === null || _a === void 0 ? void 0 : _a[0]) || {}, caps.alwaysMatch || {});
227
- const prefixes = ['xenon:', 'xe:', 'appium:', ''];
228
- const names = ['accessKey', 'access_key'];
229
- for (const prefix of prefixes) {
230
- for (const name of names) {
231
- const v = merged[prefix ? `${prefix}${name}` : name];
232
- if (typeof v === 'string' && v.length > 0)
233
- return v;
234
- }
235
- }
236
- return undefined;
237
- }
238
- // Returns the (accessKey, token) pair from df:options.{accessKey,token} or
239
- // equivalently df:options.{access_key,token}. Returns undefined if either
240
- // piece is missing — callers fall back to extractAccessKeyCap (legacy).
223
+ // Returns the API-key pair from xe:options.{accessKey,token} (or the
224
+ // xenon:options alias; xe:options wins field by field). Returns undefined if
225
+ // either piece is missing. This is the only key-pair credential a session can
226
+ // present; there is no flat `xenon:accessKey`.
241
227
  function extractAccessKeyTokenPair(caps) {
242
- var _a, _b, _c, _d;
243
- const merged = Object.assign({}, ((_a = caps.firstMatch) === null || _a === void 0 ? void 0 : _a[0]) || {}, caps.alwaysMatch || {});
244
- const dfOptions = (_c = (_b = merged['df:options']) !== null && _b !== void 0 ? _b : merged['xenon:df:options']) !== null && _c !== void 0 ? _c : merged['appium:df:options'];
245
- if (!dfOptions || typeof dfOptions !== 'object')
246
- return undefined;
247
- const accessKey = (_d = dfOptions.accessKey) !== null && _d !== void 0 ? _d : dfOptions.access_key;
248
- const token = dfOptions.token;
249
- if (typeof accessKey !== 'string' || typeof token !== 'string')
250
- return undefined;
251
- if (!accessKey || !token)
252
- return undefined;
253
- return { accessKey, token };
228
+ const options = (0, xenonOptions_1.xenonOptionsOf)(caps);
229
+ const accessKey = (0, xenonOptions_1.stringOption)(options, 'accessKey');
230
+ const token = (0, xenonOptions_1.stringOption)(options, 'token');
231
+ return accessKey && token ? { accessKey, token } : undefined;
254
232
  }
255
- // Returns the xenon:options.sessionToken JWT (hub-minted, aud 'xenon-session'),
256
- // used by the opt-in session-token gate (spec §3 item 6, risk R9). Returns
257
- // null if absent — callers combine this with the df:options access-key/token
258
- // pair to decide whether createSession is authorized.
233
+ // Returns the xe:options.sessionToken JWT (hub-minted, aud 'xenon-session'),
234
+ // or the xenon:options alias's, used by the opt-in session-token gate (spec
235
+ // §3 item 6, risk R9). Returns null if absent — callers combine this with the
236
+ // access-key/token pair to decide whether createSession is authorized.
259
237
  function extractSessionToken(caps) {
260
238
  var _a;
261
- const read = (obj) => {
262
- const xo = obj === null || obj === void 0 ? void 0 : obj['xenon:options'];
263
- const t = xo && typeof xo === 'object' ? xo.sessionToken : undefined;
264
- return typeof t === 'string' && t.length > 0 ? t : null;
265
- };
266
- const fm = Array.isArray(caps === null || caps === void 0 ? void 0 : caps.firstMatch) ? caps.firstMatch[0] : undefined;
267
- return (_a = read(caps === null || caps === void 0 ? void 0 : caps.alwaysMatch)) !== null && _a !== void 0 ? _a : read(fm);
239
+ return (_a = (0, xenonOptions_1.stringOption)((0, xenonOptions_1.xenonOptionsOf)(caps), 'sessionToken')) !== null && _a !== void 0 ? _a : null;
268
240
  }
269
241
  function getXenonCapabilities(caps) {
270
242
  var _a, _b;
271
243
  const mergedCapabilites = Object.assign({}, (_b = (_a = caps.firstMatch) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : {}, caps.alwaysMatch);
244
+ const xenonOptions = (0, xenonOptions_1.xenonOptionsIn)(mergedCapabilites);
272
245
  const getAnyCap = (snake, camel) => {
273
246
  // Strict prefix resolution: xe:, appium:, no-prefix — snake_case + camelCase fallbacks.
274
- // Also accept the namespaced `xenon:options.<name>` form, which is how other Appium
275
- // plugins document nested options and what most users naturally try first.
247
+ // Also accept the namespaced `xe:options.<name>` form (or its `xenon:options` alias),
248
+ // which is how other Appium plugins document nested options and what most users
249
+ // naturally try first.
276
250
  const prefixes = ['xe:', 'appium:', ''];
277
251
  const names = [snake, camel];
278
252
  for (const prefix of prefixes) {
@@ -282,12 +256,9 @@ function getXenonCapabilities(caps) {
282
256
  return mergedCapabilites[key];
283
257
  }
284
258
  }
285
- const xenonOptions = mergedCapabilites[XENON_CAPABILITIES.XENON_OPTIONS];
286
- if (xenonOptions && typeof xenonOptions === 'object') {
287
- for (const name of names) {
288
- if (xenonOptions[name] !== undefined)
289
- return xenonOptions[name];
290
- }
259
+ for (const name of names) {
260
+ if (xenonOptions[name] !== undefined)
261
+ return xenonOptions[name];
291
262
  }
292
263
  return undefined;
293
264
  };
@@ -55,7 +55,7 @@ const cors_1 = __importDefault(require("cors"));
55
55
  const async_lock_1 = __importDefault(require("async-lock"));
56
56
  const crypto_1 = __importDefault(require("crypto"));
57
57
  const InternalHttpClient_1 = require("../InternalHttpClient");
58
- const logger_1 = __importDefault(require("../logger"));
58
+ const logger_1 = __importStar(require("../logger"));
59
59
  const sessionContext_1 = require("../logging/sessionContext");
60
60
  const dashboard_1 = __importDefault(require("./routers/dashboard"));
61
61
  const grid_1 = __importDefault(require("./routers/grid"));
@@ -80,6 +80,8 @@ const capabilities_1 = require("./routers/capabilities");
80
80
  const projects_1 = require("./routers/projects");
81
81
  const audit_1 = require("./routers/audit");
82
82
  const authMiddleware_1 = require("../middleware/authMiddleware");
83
+ const roleGuard_1 = require("../middleware/roleGuard");
84
+ const scopeGuard_1 = require("../middleware/scopeGuard");
83
85
  const rateLimitMiddleware_1 = require("../middleware/rateLimitMiddleware");
84
86
  const csrfMiddleware_1 = require("../middleware/csrfMiddleware");
85
87
  const swagger_1 = require("./swagger");
@@ -254,9 +256,11 @@ function createRouter(pluginArgs) {
254
256
  // MCP audit ingest: the 2b gateway ships batched tool-call audit JSONL here,
255
257
  // authenticated as a service identity (roleGuard(MEMBER) + admin scope).
256
258
  apiRouter.use('/audit', (0, audit_1.auditRouter)());
257
- // Exposes plugin CLI args (may include host, hub URL, etc.) — auth-gated.
258
- apiRouter.get('/cliArgs', (_req, res) => __awaiter(this, void 0, void 0, function* () {
259
- res.json(yield (0, pluginArgs_1.getCLIArgs)());
259
+ // The stored Appium arguments: a database URL with its password, AI and
260
+ // cloud provider keys, the hub URL. Admin-only, like /processes, and
261
+ // redacted the way the startup log redacts them.
262
+ apiRouter.get('/cliArgs', (0, roleGuard_1.roleGuard)('ADMIN'), (0, scopeGuard_1.scopeGuard)(['admin']), (_req, res) => __awaiter(this, void 0, void 0, function* () {
263
+ res.json((0, logger_1.redactSecrets)(yield (0, pluginArgs_1.getCLIArgs)()));
260
264
  }));
261
265
  // Prometheus-style metrics — auth-gated to avoid operational recon.
262
266
  apiRouter.get('/metrics', (_req, res) => __awaiter(this, void 0, void 0, function* () {
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.routerStackOf = routerStackOf;
4
+ exports.insertBeforeRoutes = insertBeforeRoutes;
5
+ function isRouter(candidate) {
6
+ return !!candidate && Array.isArray(candidate.stack);
7
+ }
8
+ function findRouter(app) {
9
+ if (!app)
10
+ return undefined;
11
+ if (isRouter(app._router))
12
+ return { router: app._router, shape: 'express4' };
13
+ let router;
14
+ try {
15
+ // Express 4 defines `app.router` as a getter that throws a deprecation
16
+ // error. Express 5 defines it as the router itself.
17
+ router = app.router;
18
+ }
19
+ catch (_a) {
20
+ router = undefined;
21
+ }
22
+ if (isRouter(router))
23
+ return { router, shape: 'express5' };
24
+ return undefined;
25
+ }
26
+ /** The app's router stack, or undefined when neither Express shape has one. */
27
+ function routerStackOf(app) {
28
+ var _a;
29
+ return (_a = findRouter(app)) === null || _a === void 0 ? void 0 : _a.router.stack;
30
+ }
31
+ function insertBeforeRoutes(app, path, handler) {
32
+ const found = findRouter(app);
33
+ if (!found) {
34
+ return {
35
+ placed: false,
36
+ reason: 'no Express router found (neither app._router nor app.router has a stack)',
37
+ };
38
+ }
39
+ const { stack } = found.router;
40
+ const lengthBefore = stack.length;
41
+ app.use(path, handler);
42
+ const added = stack.splice(lengthBefore);
43
+ if (added.length !== 1) {
44
+ // app.use() added its layer somewhere other than the stack we found, or
45
+ // added more than one. Put back whatever it added and refuse to guess.
46
+ stack.push(...added);
47
+ return {
48
+ placed: false,
49
+ shape: found.shape,
50
+ reason: `app.use() added ${added.length} layers to the router stack, expected 1`,
51
+ };
52
+ }
53
+ const firstRoute = stack.findIndex((layer) => !!layer.route);
54
+ const index = firstRoute === -1 ? stack.length : firstRoute;
55
+ stack.splice(index, 0, added[0]);
56
+ return { placed: true, shape: found.shape, index };
57
+ }
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.normalizeBasePath = normalizeBasePath;
7
+ exports.sessionCommandPath = sessionCommandPath;
8
+ exports.registerCommandAuth = registerCommandAuth;
9
+ const typedi_1 = require("typedi");
10
+ const logger_1 = __importDefault(require("../logger"));
11
+ const config_1 = require("../config");
12
+ const SessionOwnerResolver_1 = require("../services/device-access/SessionOwnerResolver");
13
+ const commandAuth_1 = require("../middleware/commandAuth");
14
+ const commandCaller_1 = require("../middleware/commandCaller");
15
+ const insertBeforeRoutes_1 = require("./insertBeforeRoutes");
16
+ /**
17
+ * Appium's base-path rule (base-driver `normalizeBasePath`): drop one trailing
18
+ * '/', and add a leading '/' unless the path is empty. `cliArgs.basePath`
19
+ * reaches a plugin as the operator wrote it, while Appium normalises its own
20
+ * copy before building routes, so the guard must normalise the same way or it
21
+ * would miss the routes it is meant to sit in front of.
22
+ */
23
+ function normalizeBasePath(basePath) {
24
+ let normalized = typeof basePath === 'string' ? basePath : '';
25
+ normalized = normalized.replace(/\/$/, '');
26
+ if (normalized !== '' && !normalized.startsWith('/'))
27
+ normalized = `/${normalized}`;
28
+ return normalized;
29
+ }
30
+ /**
31
+ * Everything under `<basePath>/session/:sessionId`, every method. The create
32
+ * route (`POST <basePath>/session`) has no id segment and does not match.
33
+ */
34
+ function sessionCommandPath(basePath) {
35
+ return `${normalizeBasePath(basePath)}/session/:sessionId`;
36
+ }
37
+ /**
38
+ * Install per-command auth (commandAuth.ts) ahead of Appium's routes. Called
39
+ * from ServerManager.registerRoutes, which Appium runs after adding them.
40
+ *
41
+ * The middleware is always installed and reads the setting per request, so a
42
+ * server with the setting off pays one env check per session command and does
43
+ * no lookups. If it cannot be placed ahead of the routes the error is logged,
44
+ * and with the setting on (and auth enabled) the server refuses to start:
45
+ * an operator who asked for the check must not get a server that silently
46
+ * serves session commands without it.
47
+ */
48
+ function registerCommandAuth(app, cliArgs, overrides = {}) {
49
+ const deps = Object.assign({ enabled: () => (0, commandAuth_1.commandAuthEnabled)(), authDisabled: () => config_1.config.authDisabled === true, verifier: new commandCaller_1.CommandCallerVerifier(), ownerOf: (sessionId) => typedi_1.Container.get(SessionOwnerResolver_1.SessionOwnerResolver).ownerOf(sessionId), logger: logger_1.default.scope('CommandAuth') }, overrides);
50
+ const path = sessionCommandPath(cliArgs === null || cliArgs === void 0 ? void 0 : cliArgs.basePath);
51
+ const result = (0, insertBeforeRoutes_1.insertBeforeRoutes)(app, path, (0, commandAuth_1.createCommandAuthMiddleware)(deps));
52
+ const active = deps.enabled() && !deps.authDisabled();
53
+ if (!result.placed) {
54
+ deps.logger.error(`Per-command auth could not be placed ahead of Appium's routes for ${path}: ${result.reason}.`);
55
+ if (active) {
56
+ throw new Error('XENON_REQUIRE_COMMAND_AUTH is on, but the per-command check could not be placed ahead ' +
57
+ "of Appium's WebDriver routes, so it would never run. Refusing to start rather than " +
58
+ 'serve session commands unchecked.');
59
+ }
60
+ return result;
61
+ }
62
+ deps.logger.info(active
63
+ ? `Per-command auth is ON: every request under ${path} needs the session owner's ` +
64
+ 'credentials or an override admin (XENON_REQUIRE_COMMAND_AUTH).'
65
+ : `Per-command auth is off for ${path} (XENON_REQUIRE_COMMAND_AUTH not set, or auth disabled).`);
66
+ return result;
67
+ }
@@ -20,15 +20,22 @@ const logger_1 = __importDefault(require("../../logger"));
20
20
  const fs_extra_1 = __importDefault(require("fs-extra"));
21
21
  const scopeGuard_1 = require("../../middleware/scopeGuard");
22
22
  const roleGuard_1 = require("../../middleware/roleGuard");
23
+ const appVisibility_1 = require("../../services/device-access/appVisibility");
24
+ const prisma_1 = require("../../prisma");
23
25
  const router = (0, express_1.Router)();
24
26
  // MEMBER-tier baseline: device app visibility requires authenticated user
25
27
  router.use((0, roleGuard_1.roleGuard)('MEMBER'));
26
28
  // Uploading an APK/IPA or deleting one from the fleet requires devices scope.
27
29
  // App listings stay readable to any authenticated key.
28
30
  router.use((0, scopeGuard_1.mutationScopeGuard)(['devices']));
31
+ // Uploaded apps follow the device team rule on the app's team (canSeeApp):
32
+ // admins see every app, members shared apps and their teams'. An app the
33
+ // caller can't see answers every route exactly as an unknown id.
34
+ const UNKNOWN_APP = { error: 'App not found' };
29
35
  router.get('/', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
36
+ var _a;
30
37
  try {
31
- const apps = yield app_service_1.APP_SERVICE.getApps();
38
+ const apps = yield app_service_1.APP_SERVICE.getApps((_a = req.auth) === null || _a === void 0 ? void 0 : _a.teamIds);
32
39
  res.json(apps);
33
40
  }
34
41
  catch (err) {
@@ -57,15 +64,29 @@ router.get('/', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
57
64
  * is not joined onto a path.
58
65
  */
59
66
  exports.DOWNLOAD_OPTIONS = { dotfiles: 'allow' };
67
+ /**
68
+ * Whether this request may see the app. A download ticket (authMiddleware's
69
+ * app-ticket path) was team-checked when it was minted and is bound to one
70
+ * app, so it sees that app and nothing else; everyone else by the team rule.
71
+ */
72
+ function canRequestSeeApp(req, app) {
73
+ var _a, _b;
74
+ if (!app)
75
+ return false;
76
+ if (((_a = req.auth) === null || _a === void 0 ? void 0 : _a.kind) === 'app-ticket')
77
+ return req.auth.appId === app.id;
78
+ return (0, appVisibility_1.canSeeApp)(app, (_b = req.auth) === null || _b === void 0 ? void 0 : _b.teamIds);
79
+ }
60
80
  function downloadApp(req, res) {
61
81
  return __awaiter(this, void 0, void 0, function* () {
62
82
  try {
63
83
  const app = yield app_service_1.APP_SERVICE.getAppById(req.params.id);
64
- if (app && (yield fs_extra_1.default.exists(app.filepath))) {
84
+ // The ticket path sends with DOWNLOAD_OPTIONS too: it is this same call.
85
+ if (app && canRequestSeeApp(req, app) && (yield fs_extra_1.default.exists(app.filepath))) {
65
86
  res.download(app.filepath, app.filename, exports.DOWNLOAD_OPTIONS);
66
87
  }
67
88
  else {
68
- res.status(404).json({ error: 'App not found' });
89
+ res.status(404).json(UNKNOWN_APP);
69
90
  }
70
91
  }
71
92
  catch (err) {
@@ -75,7 +96,26 @@ function downloadApp(req, res) {
75
96
  });
76
97
  }
77
98
  router.get('/:id/download', downloadApp);
99
+ /**
100
+ * The team an upload or a move names: a string id, or null for the shared
101
+ * pool. Undefined, null and '' all mean shared; anything else that is not a
102
+ * string is refused.
103
+ */
104
+ function parseTeamId(value) {
105
+ if (value === undefined || value === null || value === '')
106
+ return { ok: true, teamId: null };
107
+ if (typeof value === 'string')
108
+ return { ok: true, teamId: value };
109
+ return { ok: false };
110
+ }
111
+ function teamExists(teamId) {
112
+ return __awaiter(this, void 0, void 0, function* () {
113
+ const team = yield prisma_1.prisma.team.findUnique({ where: { id: teamId }, select: { id: true } });
114
+ return !!team;
115
+ });
116
+ }
78
117
  router.post('/upload', (0, roleGuard_1.roleGuard)('ADMIN'), (req, res) => __awaiter(void 0, void 0, void 0, function* () {
118
+ var _a;
79
119
  if (!req.files || Object.keys(req.files).length === 0) {
80
120
  return res.status(400).json({ error: 'No files were uploaded.' });
81
121
  }
@@ -83,9 +123,18 @@ router.post('/upload', (0, roleGuard_1.roleGuard)('ADMIN'), (req, res) => __awai
83
123
  if (!appFile) {
84
124
  return res.status(400).json({ error: 'Field "app" is required.' });
85
125
  }
126
+ // Optional multipart field. Checked before anything is written, so a bad
127
+ // team stores nothing.
128
+ const team = parseTeamId((_a = req.body) === null || _a === void 0 ? void 0 : _a.teamId);
129
+ if (!team.ok)
130
+ return res.status(400).json({ error: 'teamId must be a string' });
86
131
  try {
87
- const app = yield app_service_1.APP_SERVICE.uploadApp(appFile);
88
- logger_1.default.audit('APP_UPLOAD', req.ip, { appId: app.id, name: app.name });
132
+ if (team.teamId && !(yield teamExists(team.teamId))) {
133
+ return res.status(400).json({ error: 'team not found' });
134
+ }
135
+ // An upload of bytes already stored returns that app, in its own team.
136
+ const app = yield app_service_1.APP_SERVICE.uploadApp(appFile, team.teamId);
137
+ logger_1.default.audit('APP_UPLOAD', req.ip, { appId: app.id, name: app.name, teamId: app.teamId });
89
138
  res.json(app);
90
139
  }
91
140
  catch (err) {
@@ -93,8 +142,37 @@ router.post('/upload', (0, roleGuard_1.roleGuard)('ADMIN'), (req, res) => __awai
93
142
  res.status(500).json({ error: err.message });
94
143
  }
95
144
  }));
145
+ /**
146
+ * Moves an app to a team, or back to the shared pool with `{ teamId: null }`.
147
+ * Shaped like PUT /grid/device/:udid/team: admin role and scope, 404 for an
148
+ * unknown team or app, `{ ok, updated }`.
149
+ */
150
+ router.put('/:id/team', (0, roleGuard_1.roleGuard)('ADMIN'), (0, scopeGuard_1.scopeGuard)(['admin']), (req, res) => __awaiter(void 0, void 0, void 0, function* () {
151
+ var _a;
152
+ const team = parseTeamId((_a = req.body) === null || _a === void 0 ? void 0 : _a.teamId);
153
+ if (!team.ok)
154
+ return res.status(400).json({ error: 'teamId must be a string or null' });
155
+ try {
156
+ if (team.teamId && !(yield teamExists(team.teamId))) {
157
+ return res.status(404).json({ error: 'team not found' });
158
+ }
159
+ const updated = yield app_service_1.APP_SERVICE.setAppTeam(req.params.id, team.teamId);
160
+ if (updated === 0)
161
+ return res.status(404).json(UNKNOWN_APP);
162
+ logger_1.default.audit('APP_TEAM', req.ip, { appId: req.params.id, teamId: team.teamId });
163
+ res.json({ ok: true, updated });
164
+ }
165
+ catch (err) {
166
+ logger_1.default.error(`Failed to move app ${req.params.id}: ${err.message}`);
167
+ res.status(500).json({ error: err.message });
168
+ }
169
+ }));
96
170
  router.delete('/:id', (0, roleGuard_1.roleGuard)('ADMIN'), (req, res) => __awaiter(void 0, void 0, void 0, function* () {
97
171
  try {
172
+ // Unknown and hidden alike: 404 with the unknown body, nothing deleted.
173
+ const app = yield app_service_1.APP_SERVICE.getAppById(req.params.id);
174
+ if (!canRequestSeeApp(req, app))
175
+ return res.status(404).json(UNKNOWN_APP);
98
176
  yield app_service_1.APP_SERVICE.deleteApp(req.params.id);
99
177
  logger_1.default.audit('APP_DELETE', req.ip, { appId: req.params.id });
100
178
  res.sendStatus(204);
@@ -64,7 +64,7 @@ function issueToken(auth, body) {
64
64
  roles: grant.roles,
65
65
  }, { audience, ttlSeconds: expiresIn });
66
66
  // Session-token capability (spec §3 item 6 / R9): a sibling credential the
67
- // client injects as `xenon:options.sessionToken` so the Appium createSession
67
+ // client injects as `xe:options.sessionToken` so the Appium createSession
68
68
  // interceptor can refuse tokenless direct-connect sessions when the gate is on.
69
69
  const sessionToken = yield svc.sign({ sub: auth.userId, teamId: (_c = auth.teamId) !== null && _c !== void 0 ? _c : null }, { audience: 'xenon-session', ttlSeconds: expiresIn });
70
70
  return { token, expiresIn, audience, scopes: grant.granular, sessionToken };
@@ -4,6 +4,7 @@ exports.buildCapabilities = buildCapabilities;
4
4
  exports.capabilitiesRouter = capabilitiesRouter;
5
5
  const express_1 = require("express");
6
6
  const sessionTokenGate_1 = require("../../services/sessionTokenGate");
7
+ const commandAuth_1 = require("../../middleware/commandAuth");
7
8
  // eslint-disable-next-line @typescript-eslint/no-var-requires
8
9
  const pkg = require('../../../package.json');
9
10
  function buildCapabilities() {
@@ -18,6 +19,9 @@ function buildCapabilities() {
18
19
  projects: true, // Task 8
19
20
  mcpScopedTokens: true, // Tasks 1-3: granular-claim minting available
20
21
  sessionTokenGate: (0, sessionTokenGate_1.sessionTokenGateEnabled)(), // Task 4: live XENON_REQUIRE_SESSION_TOKEN value
22
+ // Live XENON_REQUIRE_COMMAND_AUTH value: when true, every WebDriver
23
+ // session command must carry the x-xenon-* pair or a Bearer token.
24
+ commandAuth: (0, commandAuth_1.commandAuthEnabled)(),
21
25
  },
22
26
  };
23
27
  }
@@ -66,6 +66,7 @@ const fs_extra_1 = __importDefault(require("fs-extra"));
66
66
  const OmniVisionService_1 = require("../../services/omni-vision/OmniVisionService");
67
67
  const InspectorService_1 = require("../../services/InspectorService");
68
68
  const StreamTicketService_1 = require("../../services/token/StreamTicketService");
69
+ const appVisibility_1 = require("../../services/device-access/appVisibility");
69
70
  const PluginContext_1 = require("../../PluginContext");
70
71
  const streamType_1 = require("./streamType");
71
72
  const iosStreamPort_1 = require("./iosStreamPort");
@@ -496,6 +497,7 @@ router.post('/:udid/install', (req, res) => __awaiter(void 0, void 0, void 0, fu
496
497
  res.status(400).send('Manager not found or installApp not supported');
497
498
  }));
498
499
  router.post('/:udid/install-repository-app', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
500
+ var _a;
499
501
  const { udid } = req.params;
500
502
  const { appId } = req.body;
501
503
  const device = yield getDeviceInfo(udid);
@@ -504,8 +506,11 @@ router.post('/:udid/install-repository-app', (req, res) => __awaiter(void 0, voi
504
506
  try {
505
507
  const { APP_SERVICE } = yield Promise.resolve().then(() => __importStar(require('../../dashboard/services/app-service')));
506
508
  const app = yield APP_SERVICE.getAppById(appId);
507
- if (!app)
509
+ // Installing hands over the binary as a download does: another team's
510
+ // app answers as an unknown one.
511
+ if (!(0, appVisibility_1.canSeeApp)(app, (_a = req.auth) === null || _a === void 0 ? void 0 : _a.teamIds)) {
508
512
  return res.status(404).send('App not found in repository');
513
+ }
509
514
  const manager = yield getDeviceManagerForPlatform(device.platform);
510
515
  if (manager && manager.installApp) {
511
516
  // If it's a local file, pass the path.
@@ -374,7 +374,14 @@ function getHealingHotspots(request, response) {
374
374
  ? request.query.platform
375
375
  : null;
376
376
  const status = (_a = request.query.status) !== null && _a !== void 0 ? _a : 'active';
377
- 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
+ });
378
385
  return response.status(200).json({
379
386
  windowDays,
380
387
  totalScanned: agg.totalScanned,
@@ -417,9 +424,18 @@ function aggregateHotspots(opts) {
417
424
  };
418
425
  if (opts.tier)
419
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 = [];
420
430
  if (opts.platform || opts.buildId) {
421
- 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 } : {})));
422
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 };
423
439
  const rows = yield prisma_1.prisma.sessionLog.findMany({
424
440
  where,
425
441
  select: {
@@ -518,6 +534,11 @@ function aggregateHotspots(opts) {
518
534
  // then merge them into each hotspot. The default `status=active` filter
519
535
  // hides muted/pending/resolved selectors so the live list, CI gate, and
520
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.
521
542
  let stateMap = new Map();
522
543
  if (topHotspots.length > 0) {
523
544
  const states = yield prisma_1.prisma.selectorState.findMany({
@@ -573,6 +594,7 @@ function getHealingViolations(request, response) {
573
594
  platform,
574
595
  buildId,
575
596
  minHealCount,
597
+ sessionWhere: yield (0, sessionVisibility_1.visibleSessionWhere)(authOf(request)),
576
598
  });
577
599
  return response.status(200).json({
578
600
  windowDays,
@@ -599,6 +621,8 @@ function sendHealingDigest(request, response) {
599
621
  const minHealCount = Number.isFinite(minHealCountRaw)
600
622
  ? Math.min(Math.max(minHealCountRaw, 1), 1000)
601
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.
602
626
  const agg = yield aggregateHotspots({ windowDays, limit, minHealCount });
603
627
  const payload = {
604
628
  windowDays,
@@ -761,6 +785,7 @@ function getSelectorHealth(request, response) {
761
785
  windowDays: 365,
762
786
  limit: 1000,
763
787
  status: 'all',
788
+ sessionWhere: yield (0, sessionVisibility_1.visibleSessionWhere)(authOf(request)),
764
789
  });
765
790
  let rows = agg.hotspots;
766
791
  if (selectorFilter) {
@@ -783,7 +808,8 @@ function getSelectorHealth(request, response) {
783
808
  // LocatorEtalon.selector is unique, so a lookup by selector alone
784
809
  // already pins the row; the strategy check guards against treating a
785
810
  // stale/unrelated etalon (selector reused under a different strategy)
786
- // 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.
787
813
  const etalon = yield etalonService.getSignature(h.originalSelector);
788
814
  if (etalon && etalon.strategy === h.originalStrategy) {
789
815
  out.etalonAge = now - etalon.lastSeen;
@@ -846,6 +872,11 @@ function postSelectorStateAction(request, response) {
846
872
  // hotspot aggregator) so muted-but-no-recent-heal entries still surface.
847
873
  // Each row is enriched with `last_healed_at` from the most recent healed
848
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.
849
880
  function getMutedSelectors(request, response) {
850
881
  return __awaiter(this, void 0, void 0, function* () {
851
882
  var _a, _b;
@@ -858,13 +889,10 @@ function getMutedSelectors(request, response) {
858
889
  skip: offset,
859
890
  });
860
891
  const total = yield prisma_1.prisma.selectorState.count({ where: { status: 'muted' } });
892
+ const scope = yield visibleLogScope(request);
861
893
  const enriched = yield Promise.all(muted.map((s) => __awaiter(this, void 0, void 0, function* () {
862
894
  const last = yield prisma_1.prisma.sessionLog.findFirst({
863
- where: {
864
- original_strategy: s.original_strategy,
865
- original_selector: s.original_selector,
866
- is_healed: true,
867
- },
895
+ where: Object.assign({ original_strategy: s.original_strategy, original_selector: s.original_selector, is_healed: true }, scope),
868
896
  orderBy: { createdAt: 'desc' },
869
897
  select: { createdAt: true },
870
898
  });
@@ -883,7 +911,8 @@ function getMutedSelectors(request, response) {
883
911
  // Single-tuple state lookup. Strategy and value arrive URL-encoded since
884
912
  // XPath selectors contain `/` and `[`. Returns `{ state: null }` when no
885
913
  // row exists (intentional — null is meaningful: the selector is implicitly
886
- // 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.
887
916
  function getSelectorStateByTuple(request, response) {
888
917
  return __awaiter(this, void 0, void 0, function* () {
889
918
  var _a, _b;
@@ -1054,6 +1083,8 @@ function register(router) {
1054
1083
  router.get('/session/:sessionId/logs/debug', getDebugLogs);
1055
1084
  router.get('/session/:sessionId/profiling', getProfilingData);
1056
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.
1057
1088
  router.get('/healing/events', getRecentHealingEvents);
1058
1089
  router.get('/healing/summary', getHealingSummary);
1059
1090
  router.get('/healing/hotspots', getHealingHotspots);
@@ -1065,7 +1096,10 @@ function register(router) {
1065
1096
  router.post('/healing/digest/send', (0, roleGuard_1.roleGuard)('ADMIN'), (0, scopeGuard_1.scopeGuard)(['admin']), sendHealingDigest);
1066
1097
  // SelectorState lifecycle: state mutations require admin (they affect what
1067
1098
  // shows up in the live hotspot list, the CI gate, and the digest); the two
1068
- // 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.
1069
1103
  router.post('/healing/selector/state', (0, roleGuard_1.roleGuard)('ADMIN'), (0, scopeGuard_1.scopeGuard)(['admin']), postSelectorStateAction);
1070
1104
  router.get('/healing/state/muted', getMutedSelectors);
1071
1105
  router.get('/healing/state/:strategy/:value', getSelectorStateByTuple);