@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.
- package/README.md +11 -2
- package/lib/package.json +1 -1
- package/lib/public/assets/{AnnotationOverlay-CBX06bvL.js → AnnotationOverlay-BmTPCAds.js} +1 -1
- package/lib/public/assets/{ApiKeyGate-BmSAcEM3.js → ApiKeyGate-B5eB7f8B.js} +1 -1
- package/lib/public/assets/{BugReportButton-uuz-CkVn.js → BugReportButton-DKYsOdGV.js} +1 -1
- package/lib/public/assets/{DeviceMosaicView-Cx2RN3JN.js → DeviceMosaicView-9DUoZnQi.js} +1 -1
- package/lib/public/assets/{EmptyState-C0dkxCz7.js → EmptyState-lvyNiXev.js} +1 -1
- package/lib/public/assets/{FieldGroup-2PExFkKR.js → FieldGroup-BduMPM9K.js} +1 -1
- package/lib/public/assets/{FilterMenu-Cm5FJvYO.js → FilterMenu-BSqX6I94.js} +1 -1
- package/lib/public/assets/{Menu-BVz3Uew1.js → Menu-Ba5cT7jZ.js} +1 -1
- package/lib/public/assets/{Modal-Dv6O02UG.js → Modal-CceoMY7D.js} +1 -1
- package/lib/public/assets/{RecordingPage-Bs6SnpZh.js → RecordingPage-6T9dOidc.js} +1 -1
- package/lib/public/assets/{RecordingsPage-B-wya59w.js → RecordingsPage-qj6FdZXR.js} +1 -1
- package/lib/public/assets/{SegmentedControl-COm_aKoO.js → SegmentedControl-CSRuZ8TR.js} +1 -1
- package/lib/public/assets/{SettingCard-uGx1zPf5.js → SettingCard-Bp1kz4By.js} +1 -1
- package/lib/public/assets/{Table-BlPh-WXS.js → Table-LV7zFyZg.js} +1 -1
- package/lib/public/assets/{activity-Cg9lQJWD.js → activity-D17TUloi.js} +1 -1
- package/lib/public/assets/{ai-settings-CR5wqyw6.js → ai-settings-qvbhY89P.js} +1 -1
- package/lib/public/assets/api-keys-BfpvdG7G.js +1 -0
- package/lib/public/assets/apps-DMhpZuKd.css +1 -0
- package/lib/public/assets/apps-Dbnzd0fW.js +26 -0
- package/lib/public/assets/{arrow-left-B_gxyen1.js → arrow-left-DD1VjtC7.js} +1 -1
- package/lib/public/assets/{arrow-right-CDAnM3V1.js → arrow-right-D0pnM3ma.js} +1 -1
- package/lib/public/assets/{arrow-up-right-D_-mJPuq.js → arrow-up-right-BIC8JOV6.js} +1 -1
- package/lib/public/assets/{auth-shell-5hdy5w1n.js → auth-shell-DWHPXSnI.js} +2 -2
- package/lib/public/assets/{builds-page-DhnvFn8Z.js → builds-page-ClCNBUv_.js} +1 -1
- package/lib/public/assets/{button-B0ADzApc.js → button-xtAhYR-f.js} +1 -1
- package/lib/public/assets/{calendar-BPUQybnc.js → calendar-CupwJ7Bm.js} +1 -1
- package/lib/public/assets/{check-Cg3UPdZx.js → check-CyuEoKkd.js} +1 -1
- package/lib/public/assets/{chevron-left-BBDTngOm.js → chevron-left-DgX-F9xK.js} +1 -1
- package/lib/public/assets/{chevron-right-KfUn3lbq.js → chevron-right-BKJgJnU9.js} +1 -1
- package/lib/public/assets/{circle-check-OqbOWQkP.js → circle-check-CZdg8mMa.js} +1 -1
- package/lib/public/assets/{circle-x-BdD_O4_Y.js → circle-x-CZH6t9z-.js} +1 -1
- package/lib/public/assets/{clock-423jy-Nh.js → clock-Cd-t7xpe.js} +1 -1
- package/lib/public/assets/{copy-CXBBskEl.js → copy-C6zerX3I.js} +1 -1
- package/lib/public/assets/{device-explorer-NN0qUgr_.js → device-explorer-B1pI6b_M.js} +2 -2
- package/lib/public/assets/device-explorer-DV8rWRxL.css +1 -0
- package/lib/public/assets/{download-D-KpnIJY.js → download-x9WP7mcL.js} +1 -1
- package/lib/public/assets/{forgot-password-DOdyy69t.js → forgot-password-B0TEH4N-.js} +1 -1
- package/lib/public/assets/{funnel-D14CL_Wi.js → funnel-Cb40MfFN.js} +1 -1
- package/lib/public/assets/{index-C8lhFZZl.js → index-BR1wTIPC.js} +9 -9
- package/lib/public/assets/{index-v6FT1hxD.js → index-BvH_ekRy.js} +1 -1
- package/lib/public/assets/{index-Fa91L6sA.css → index-hymrhlEZ.css} +1 -1
- package/lib/public/assets/{input-BaQb3TNn.js → input-DejcCiTI.js} +1 -1
- package/lib/public/assets/{keycodes-CKO88csS.js → keycodes-Mavx5iGg.js} +1 -1
- package/lib/public/assets/{layers-CuUhwGxH.js → layers-BKqYLiHE.js} +1 -1
- package/lib/public/assets/{lock-DxrSyg_w.js → lock-LFsFhc17.js} +1 -1
- package/lib/public/assets/{login-CCVWZ6yV.js → login-x2pkzt-t.js} +1 -1
- package/lib/public/assets/{maintenance-settings-3oY1vwin.js → maintenance-settings-CYafcoV-.js} +1 -1
- package/lib/public/assets/{monitor-BIoWxb1z.js → monitor-_7_uJ8d9.js} +1 -1
- package/lib/public/assets/{mouse-pointer-2-D57-qGtl.js → mouse-pointer-2-BfLuURKN.js} +1 -1
- package/lib/public/assets/{network-6sG_tBbN.js → network-Z_zBhpsD.js} +1 -1
- package/lib/public/assets/{overview-B4pHRYlE.js → overview-BY5uv08S.js} +1 -1
- package/lib/public/assets/{page-header-D2FYSmC5.js → page-header-CP2UkykG.js} +1 -1
- package/lib/public/assets/{plus-DCxbMKBL.js → plus-BTD0dnQ0.js} +1 -1
- package/lib/public/assets/{profile-page-DzQsHQy9.js → profile-page-BhJZuCS7.js} +1 -1
- package/lib/public/assets/{reset-password-q6FdvFSe.js → reset-password-CMN0SO81.js} +1 -1
- package/lib/public/assets/{runbook-page-DislP3W6.js → runbook-page-CBpPX9_5.js} +1 -1
- package/lib/public/assets/{select-DacTBzGz.js → select-Uc9E5pye.js} +1 -1
- package/lib/public/assets/{selector-detail-page-DMdWaRAQ.js → selector-detail-page-BSyBnRuU.js} +1 -1
- package/lib/public/assets/{selector-health-DQsOvugw.js → selector-health-DzqxNkzm.js} +1 -1
- package/lib/public/assets/{selector-health-page--adBJmmT.js → selector-health-page-CyPLjoD1.js} +1 -1
- package/lib/public/assets/{session-detail-page-D1_Syg-V.js → session-detail-page-BZQZN1P-.js} +1 -1
- package/lib/public/assets/{settings-B2rCUObQ.js → settings-CkSWiZq8.js} +1 -1
- package/lib/public/assets/{status-pill-outline-CvHE1e2M.js → status-pill-outline-BFYni2qo.js} +1 -1
- package/lib/public/assets/{tablet-DVEtznJc.js → tablet-BSd-xSZK.js} +1 -1
- package/lib/public/assets/teams-PpoEQz31.js +1 -0
- package/lib/public/assets/{trash-2-Bg_Pbt3E.js → trash-2-BorJv5_u.js} +1 -1
- package/lib/public/assets/{upload-BceikXSx.js → upload-C3i7sAZl.js} +1 -1
- package/lib/public/assets/{use-password-reset-mode-BD8plGbG.js → use-password-reset-mode-CeZQ3EsH.js} +1 -1
- package/lib/public/assets/{users-BE5ChVjL.js → users-CEJ3fC78.js} +1 -1
- package/lib/public/assets/{users--omSbsNY.js → users-Drd7ifJx.js} +1 -1
- package/lib/public/assets/{video-off-n13wvZGN.js → video-off-CNzn6bPE.js} +1 -1
- package/lib/public/assets/{webhook-settings-kG35CJaN.js → webhook-settings-BFqX6QXV.js} +1 -1
- package/lib/public/assets/{zap-CpvPW5mB.js → zap-4yN8Jdfw.js} +1 -1
- package/lib/public/index.html +2 -2
- package/lib/schema.json +1 -1
- package/lib/src/XenonCapabilityManager.js +21 -50
- package/lib/src/app/index.js +8 -4
- package/lib/src/app/insertBeforeRoutes.js +57 -0
- package/lib/src/app/registerCommandAuth.js +67 -0
- package/lib/src/app/routers/apps.js +83 -5
- package/lib/src/app/routers/auth.js +1 -1
- package/lib/src/app/routers/capabilities.js +4 -0
- package/lib/src/app/routers/control.js +6 -1
- package/lib/src/app/routers/dashboard.js +44 -10
- package/lib/src/app/routers/grid.js +30 -52
- package/lib/src/app/swagger-docs.js +7 -4
- package/lib/src/app/swagger.js +3 -0
- package/lib/src/dashboard/event-manager.js +18 -21
- package/lib/src/dashboard/services/app-service.js +24 -2
- package/lib/src/data-service/queue-service.js +4 -3
- package/lib/src/device-utils.js +1 -1
- package/lib/src/generated/client/edge.js +5 -4
- package/lib/src/generated/client/index-browser.js +2 -1
- package/lib/src/generated/client/index.d.ts +413 -7
- package/lib/src/generated/client/index.js +5 -4
- package/lib/src/generated/client/package.json +1 -1
- package/lib/src/generated/client/schema.prisma +7 -0
- package/lib/src/generated/client/wasm.js +2 -1
- package/lib/src/interceptors/CommandInterceptor.js +5 -5
- package/lib/src/middleware/authMiddleware.js +44 -36
- package/lib/src/middleware/commandAuth.js +146 -0
- package/lib/src/middleware/commandCaller.js +142 -0
- package/lib/src/middleware/verifyCredential.js +124 -0
- package/lib/src/services/ServerManager.js +7 -0
- package/lib/src/services/SessionLifecycleService.js +143 -73
- package/lib/src/services/TeamService.js +6 -3
- package/lib/src/services/device-access/appVisibility.js +27 -0
- package/lib/src/services/device-access/queueVisibility.js +118 -0
- package/lib/src/services/healing/HealingOrchestrator.js +11 -3
- package/lib/src/services/lease/LeaseService.js +13 -4
- package/lib/src/services/lease/buildCapabilityBag.js +4 -1
- package/lib/src/services/lease/leaseSessionCaps.js +13 -69
- package/lib/src/services/recording/annotation-render.js +33 -1
- package/lib/src/services/recording/recordingSummary.js +6 -5
- package/lib/src/services/recording/recordingTiming.js +19 -5
- package/lib/src/services/session/appCapability.js +50 -0
- package/lib/src/services/session/sessionCredentials.js +105 -0
- package/lib/src/services/session/xenonOptions.js +71 -0
- package/lib/src/services/sessionTokenGate.js +19 -9
- package/lib/src/services/token/AppDownloadTicketService.js +73 -0
- package/lib/src/services/token/StreamTicketService.js +7 -17
- package/lib/src/services/token/singleUseLedger.js +41 -0
- package/lib/src/sessions/XenonSession.js +2 -2
- package/lib/test/helpers/loopbackServer.js +48 -0
- package/lib/test/integration/sdk-leases-router.spec.js +1 -1
- package/lib/test/integration/team-visibility-apps.spec.js +181 -0
- package/lib/test/integration/team-visibility-recordings.spec.js +4 -0
- package/lib/test/integration/team-visibility-sessions.spec.js +92 -13
- package/lib/test/unit/AppDownloadTicketService.spec.js +101 -0
- package/lib/test/unit/CapabilitiesRouter.spec.js +10 -0
- package/lib/test/unit/SessionTokenGate.spec.js +7 -2
- package/lib/test/unit/TeamService.test.js +22 -0
- package/lib/test/unit/XenonCapabilityManager.spec.js +11 -1
- package/lib/test/unit/aggregate-hotspots-session-scope.spec.js +202 -0
- package/lib/test/unit/annotation-render.spec.js +90 -0
- package/lib/test/unit/apps-team-visibility.spec.js +305 -0
- package/lib/test/unit/authMiddleware.appTicket.spec.js +201 -0
- package/lib/test/unit/cliargs-admin-only.spec.js +182 -0
- package/lib/test/unit/command-auth-appium-server.spec.js +184 -0
- package/lib/test/unit/command-auth.spec.js +410 -0
- package/lib/test/unit/command-caller.spec.js +254 -0
- package/lib/test/unit/event-manager-regression-hook.spec.js +7 -1
- package/lib/test/unit/extract-access-key-token-pair.test.js +47 -4
- package/lib/test/unit/healing-tier-gate.spec.js +24 -1
- package/lib/test/unit/insert-before-routes.spec.js +167 -0
- package/lib/test/unit/install-repository-app-team.spec.js +116 -0
- package/lib/test/unit/lease/LeaseService.spec.js +3 -1
- package/lib/test/unit/lease/allocate-lease-branch.spec.js +2 -2
- package/lib/test/unit/lease/buildCapabilityBag.spec.js +3 -2
- package/lib/test/unit/lease/lease-override.spec.js +1 -1
- package/lib/test/unit/lease/lease-session-ownership.spec.js +4 -4
- package/lib/test/unit/lease/lease-team-visibility.spec.js +1 -1
- package/lib/test/unit/lease/lease-token-log-filter.spec.js +1 -1
- package/lib/test/unit/lease/lease-token-oracle.spec.js +207 -0
- package/lib/test/unit/lease/session-lease-proof.spec.js +42 -26
- package/lib/test/unit/logcat-ws.spec.js +7 -4
- package/lib/test/unit/queue-requester.spec.js +211 -0
- package/lib/test/unit/queue-team-scope-routes.spec.js +209 -0
- package/lib/test/unit/queue-visibility.spec.js +179 -0
- package/lib/test/unit/recording-summary.spec.js +22 -0
- package/lib/test/unit/recording-timing.spec.js +27 -0
- package/lib/test/unit/session-app-resolution.spec.js +331 -0
- package/lib/test/unit/session-attribution-wiring.spec.js +134 -24
- package/lib/test/unit/session-credential-strip.spec.js +277 -0
- package/lib/test/unit/session-credentials.spec.js +149 -0
- package/lib/test/unit/session-identity.spec.js +2 -2
- package/lib/test/unit/stored-session-capabilities.spec.js +54 -0
- package/lib/test/unit/verify-credential.spec.js +138 -0
- package/lib/test/unit/xenon-options.spec.js +95 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/prisma/migrations/20260928120000_app_team/migration.sql +26 -0
- package/prisma/schema.prisma +7 -0
- package/schema.json +1 -1
- package/lib/public/assets/api-keys-BBS6ZY26.js +0 -1
- package/lib/public/assets/apps-DJMD5JOy.css +0 -1
- package/lib/public/assets/apps-DgfOFIlR.js +0 -21
- package/lib/public/assets/device-explorer-DTH5oU54.css +0 -1
- 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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
243
|
-
const
|
|
244
|
-
const
|
|
245
|
-
|
|
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
|
|
256
|
-
// used by the opt-in session-token gate (spec
|
|
257
|
-
// null if absent — callers combine this with the
|
|
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
|
-
|
|
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 `
|
|
275
|
-
// plugins document nested options and what most users
|
|
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
|
|
286
|
-
|
|
287
|
-
|
|
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
|
};
|
package/lib/src/app/index.js
CHANGED
|
@@ -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 =
|
|
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
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
88
|
-
|
|
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 `
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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);
|