@xenon-device-management/xenon 2.12.0 → 2.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/package.json +2 -2
- package/lib/public/assets/{AnnotationOverlay-Crkn72_3.js → AnnotationOverlay-CzbFYtPI.js} +1 -1
- package/lib/public/assets/{ApiKeyGate-BThVlj_j.js → ApiKeyGate-CA5KFHHJ.js} +1 -1
- package/lib/public/assets/{BugReportButton-Dwa0GnBh.js → BugReportButton-D-DSGg8C.js} +1 -1
- package/lib/public/assets/{DeviceMosaicView-Ccc-pOls.js → DeviceMosaicView-paDEFU4B.js} +1 -1
- package/lib/public/assets/{EmptyState-BeuTrlN_.js → EmptyState-BE_5f8Vw.js} +1 -1
- package/lib/public/assets/{FieldGroup-DBIaNxl1.js → FieldGroup-DqMf7Rf3.js} +1 -1
- package/lib/public/assets/{FilterMenu-BgSAS6iT.js → FilterMenu-BQY75Su3.js} +1 -1
- package/lib/public/assets/{Menu-GCbP6gYN.js → Menu-BiW3os9u.js} +1 -1
- package/lib/public/assets/{Modal-ByESeBRC.js → Modal-DfawTxih.js} +1 -1
- package/lib/public/assets/{RecordingPage-Bwyf3WMl.js → RecordingPage-BvemyRiy.js} +1 -1
- package/lib/public/assets/{RecordingsPage-7kM1rB3i.js → RecordingsPage-CXfw7wpd.js} +1 -1
- package/lib/public/assets/{SegmentedControl-B8tTU8kD.js → SegmentedControl-B7m9kpom.js} +1 -1
- package/lib/public/assets/{SettingCard-Clg_Ijbc.js → SettingCard-Cw6lDD0l.js} +1 -1
- package/lib/public/assets/{Table-CIaR0QWV.js → Table-DlEuFgv-.js} +1 -1
- package/lib/public/assets/{activity-Bengh9SD.js → activity-CnLvNwae.js} +1 -1
- package/lib/public/assets/{ai-settings-G9SymiPH.js → ai-settings-D7Te8o-a.js} +1 -1
- package/lib/public/assets/{api-keys-CrGlbfn-.js → api-keys-DphSVzBr.js} +1 -1
- package/lib/public/assets/{apps-DIbcGVlU.js → apps-vUfu15lX.js} +1 -1
- package/lib/public/assets/{arrow-left-BtJIXXMY.js → arrow-left-CLQWAiCY.js} +1 -1
- package/lib/public/assets/{arrow-right-zm-NagQh.js → arrow-right-K6A5e_Ee.js} +1 -1
- package/lib/public/assets/{arrow-up-right-DdU8YEYp.js → arrow-up-right-DUA_lq6f.js} +1 -1
- package/lib/public/assets/{auth-shell-CHmBFmOz.js → auth-shell-BYRHcZd6.js} +2 -2
- package/lib/public/assets/{builds-page-JkzWKRpU.js → builds-page-C0fTvIG8.js} +1 -1
- package/lib/public/assets/{button-CqhPBGRj.js → button-BxnHCCsX.js} +1 -1
- package/lib/public/assets/{calendar-C7eYYJTo.js → calendar-CqVsn28A.js} +1 -1
- package/lib/public/assets/{check-DpbIM4E0.js → check-R6V3bot0.js} +1 -1
- package/lib/public/assets/{chevron-right-Dl4X1PRz.js → chevron-right-BA2Fntu-.js} +1 -1
- package/lib/public/assets/{circle-check-Du3sjbfV.js → circle-check-D4EzyGzs.js} +1 -1
- package/lib/public/assets/{circle-x-BSSqhpXi.js → circle-x-Cf6AF2B2.js} +1 -1
- package/lib/public/assets/{clock-DMT60v1C.js → clock-EZfet0sS.js} +1 -1
- package/lib/public/assets/{copy-BKNyOehd.js → copy-Bc6P8v9V.js} +1 -1
- package/lib/public/assets/{device-explorer-CoPR8DW3.js → device-explorer-BFXpRq6d.js} +1 -1
- package/lib/public/assets/{download-BM6Xn22t.js → download-Bd8OGLzi.js} +1 -1
- package/lib/public/assets/{forgot-password-B8WqMqBT.js → forgot-password-CoE7Ugtx.js} +1 -1
- package/lib/public/assets/{index-DauQh6ie.js → index-BiL0Enl_.js} +1 -1
- package/lib/public/assets/{index-ClrpAMAT.js → index-CHOr4JCs.js} +2 -2
- package/lib/public/assets/{input-CyKdLnEx.js → input-CJ8z3IO5.js} +1 -1
- package/lib/public/assets/{line-chart-EIBXwYGo.js → line-chart-CPK0ObjV.js} +1 -1
- package/lib/public/assets/{list-checks-fWsgD9bI.js → list-checks-mXGHUVbB.js} +1 -1
- package/lib/public/assets/{lock-CVCe56TH.js → lock-BSXM1xvq.js} +1 -1
- package/lib/public/assets/{login-BJ8a7yVD.js → login-Ct2iW066.js} +1 -1
- package/lib/public/assets/{maintenance-settings-DcRmTLS6.js → maintenance-settings-DaOs00P6.js} +1 -1
- package/lib/public/assets/{monitor-Bw1YQZnL.js → monitor-D8WQK8md.js} +1 -1
- package/lib/public/assets/{mouse-pointer-2-C01jbqkO.js → mouse-pointer-2-Cw2tWzDX.js} +1 -1
- package/lib/public/assets/{network-CBGUjJDJ.js → network-Ba_uFxWo.js} +1 -1
- package/lib/public/assets/{overview-Cxe8aQ7C.js → overview-CxrZ7_6k.js} +1 -1
- package/lib/public/assets/{page-header-B92DKLiq.js → page-header-Cm1XL49f.js} +1 -1
- package/lib/public/assets/{play-Ck0L-0_m.js → play-C0CuKLXj.js} +1 -1
- package/lib/public/assets/{plus-Dq3tCy2N.js → plus-CIIvR_6Z.js} +1 -1
- package/lib/public/assets/{profile-page--dkDiKbr.js → profile-page-BZ_e-Bgw.js} +1 -1
- package/lib/public/assets/{recording-group-store-5BYIFFN9.js → recording-group-store-BWeeNs2_.js} +1 -1
- package/lib/public/assets/{reset-password-KYWlfjia.js → reset-password-lfNz8Rm1.js} +1 -1
- package/lib/public/assets/{runbook-page-mnsxgS1d.js → runbook-page-Cnec3VSx.js} +1 -1
- package/lib/public/assets/{select-BBOIZTYm.js → select-CZuCnold.js} +1 -1
- package/lib/public/assets/{selector-detail-redirect-CcI2j_Su.js → selector-detail-redirect-BG3pxFLw.js} +1 -1
- package/lib/public/assets/{selector-health-page-BdJb_x5L.js → selector-health-page-DsyL58tb.js} +1 -1
- package/lib/public/assets/{session-detail-page-BjLaJy8z.js → session-detail-page-ZZyGM413.js} +1 -1
- package/lib/public/assets/{settings-B6cpMhsx.js → settings-DGZ7B_AR.js} +1 -1
- package/lib/public/assets/{stat-tile-BU9e4s36.js → stat-tile-DFhv8JoI.js} +1 -1
- package/lib/public/assets/{tablet-hgbEwrWq.js → tablet-68J5f6zh.js} +1 -1
- package/lib/public/assets/{teams-uiiG4hZM.js → teams-eHSyEpbD.js} +1 -1
- package/lib/public/assets/{trash-2-NK_Iazmg.js → trash-2-CcSsv4fS.js} +1 -1
- package/lib/public/assets/{upload-BUX8TNFi.js → upload-BYf26K71.js} +1 -1
- package/lib/public/assets/{use-builds-data-DZszmFyl.js → use-builds-data-CHW-MhaP.js} +1 -1
- package/lib/public/assets/{use-password-reset-mode-BK4B9kRC.js → use-password-reset-mode-DozShXkk.js} +1 -1
- package/lib/public/assets/{users-BU4XBbMT.js → users-BpYnpOl2.js} +1 -1
- package/lib/public/assets/{users-BDh1xjad.js → users-CcSQRoK9.js} +1 -1
- package/lib/public/assets/{video-off-BOjNwT4R.js → video-off-dFKgps73.js} +1 -1
- package/lib/public/assets/{webhook-settings-BMzyUcx5.js → webhook-settings-DD1XjiOD.js} +1 -1
- package/lib/public/assets/{zap-DTWwVMUg.js → zap-COC2tZaH.js} +1 -1
- package/lib/public/index.html +1 -1
- package/lib/src/app/apiErrors.js +118 -0
- package/lib/src/app/index.js +6 -1
- package/lib/src/app/openapi/control.yaml +3129 -0
- package/lib/src/app/openapi/grid.yaml +2295 -0
- package/lib/src/app/openapi/identity.yaml +2168 -0
- package/lib/src/app/openapi/platform.yaml +2885 -0
- package/lib/src/app/openapi/sessions.yaml +3784 -0
- package/lib/src/app/routers/bug-report.js +4 -1
- package/lib/src/app/routers/config.js +6 -107
- package/lib/src/app/routers/control.js +117 -59
- package/lib/src/app/routers/dashboard.js +13 -7
- package/lib/src/app/routers/grid.js +54 -14
- package/lib/src/app/routers/profile.js +27 -13
- package/lib/src/app/routers/recordings.js +11 -5
- package/lib/src/app/routers/reservation.js +63 -15
- package/lib/src/app/routers/users.js +4 -0
- package/lib/src/app/routers/webhook.js +17 -8
- package/lib/src/app/swagger.js +259 -177
- package/lib/src/data-service/device-service.js +4 -1
- package/lib/src/data-service/deviceFieldOwners.js +1 -0
- package/lib/src/device-managers/AndroidDeviceManager.js +5 -2
- package/lib/src/device-managers/ios/WDAClient.js +32 -32
- package/lib/src/generated/client/edge.js +4 -3
- package/lib/src/generated/client/index-browser.js +1 -0
- package/lib/src/generated/client/index.d.ts +38 -0
- package/lib/src/generated/client/index.js +4 -3
- package/lib/src/generated/client/package.json +1 -1
- package/lib/src/generated/client/schema.prisma +2 -0
- package/lib/src/generated/client/wasm.js +1 -0
- package/lib/src/middleware/csrfMiddleware.js +13 -5
- package/lib/src/middleware/rateLimitMiddleware.js +19 -5
- package/lib/src/middleware/roleGuard.js +20 -0
- package/lib/src/services/AIService.js +13 -3
- package/lib/src/services/NotificationService.js +28 -28
- package/lib/src/services/bug-report/BugReportService.js +8 -2
- package/lib/src/services/lease/LeaseService.js +71 -20
- package/lib/src/services/omni-vision/OmniVisionService.js +14 -5
- package/lib/src/services/recording/RecordingOrchestrator.js +16 -2
- package/lib/test/helpers/expressRoutes.js +41 -0
- package/lib/test/integration/team-visibility-control.spec.js +2 -2
- package/lib/test/unit/access-scopes.spec.js +227 -0
- package/lib/test/unit/api-error-handling.spec.js +179 -0
- package/lib/test/unit/bug-report/route.spec.js +29 -0
- package/lib/test/unit/bug-report/service.spec.js +30 -0
- package/lib/test/unit/control-honest-answers.spec.js +134 -0
- package/lib/test/unit/device-allocation-routes.spec.js +232 -0
- package/lib/test/unit/healing-state-endpoints.spec.js +8 -5
- package/lib/test/unit/install-repository-app-team.spec.js +4 -1
- package/lib/test/unit/lease/LeaseService.spec.js +5 -4
- package/lib/test/unit/lease/lease-device-match.spec.js +161 -0
- package/lib/test/unit/lease/lease-session-ownership.spec.js +10 -9
- package/lib/test/unit/omni-vision-failures.spec.js +81 -0
- package/lib/test/unit/openapi-coverage.spec.js +114 -0
- package/lib/test/unit/profile-router.test.js +42 -0
- package/lib/test/unit/rateLimitMiddleware.test.js +49 -0
- package/lib/test/unit/recording-orchestrator.spec.js +80 -0
- package/lib/test/unit/recordings-library-routes.spec.js +37 -0
- package/lib/test/unit/reservation-team-visibility.spec.js +4 -3
- package/lib/test/unit/reset-link.test.js +2 -1
- package/lib/test/unit/stream-ticket-identity.spec.js +1 -1
- package/lib/test/unit/users-router.test.js +14 -1
- package/lib/test/unit/wda-client-failures.spec.js +90 -0
- package/lib/test/unit/webhook-delivery.spec.js +142 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/prisma/migrations/20261004120000_reservation_holder/migration.sql +2 -0
- package/prisma/schema.prisma +2 -0
- package/lib/src/app/swagger-docs.js +0 -1701
|
@@ -187,13 +187,36 @@ function registerNode(request, response) {
|
|
|
187
187
|
});
|
|
188
188
|
});
|
|
189
189
|
}
|
|
190
|
-
|
|
190
|
+
/**
|
|
191
|
+
* The phone a block or unblock names: exactly one row, by udid and host.
|
|
192
|
+
* Through 2.12 the body went to getDevice as a filter, so an empty body
|
|
193
|
+
* blocked the first phone in the store, and a udid alone the first row of it,
|
|
194
|
+
* whatever server it was on. Answers the refusal itself and returns
|
|
195
|
+
* undefined, or the row.
|
|
196
|
+
*/
|
|
197
|
+
function deviceToBlock(request, response) {
|
|
191
198
|
return __awaiter(this, void 0, void 0, function* () {
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
if (
|
|
195
|
-
|
|
199
|
+
var _a;
|
|
200
|
+
const { udid, host } = (_a = request.body) !== null && _a !== void 0 ? _a : {};
|
|
201
|
+
if (typeof udid !== 'string' || udid === '' || typeof host !== 'string' || host === '') {
|
|
202
|
+
response
|
|
203
|
+
.status(400)
|
|
204
|
+
.json({ success: false, error: 'bad_request', message: 'udid and host are required' });
|
|
205
|
+
return undefined;
|
|
196
206
|
}
|
|
207
|
+
const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid, host });
|
|
208
|
+
if (!device) {
|
|
209
|
+
response.status(404).json({ success: false, error: 'Device not found' });
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
return device;
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
function blockDevice(request, response) {
|
|
216
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
217
|
+
const device = yield deviceToBlock(request, response);
|
|
218
|
+
if (!device)
|
|
219
|
+
return;
|
|
197
220
|
try {
|
|
198
221
|
yield (0, device_service_1.userBlockDevice)(device.udid, device.host);
|
|
199
222
|
}
|
|
@@ -208,11 +231,9 @@ function blockDevice(request, response) {
|
|
|
208
231
|
}
|
|
209
232
|
function unBlockDevice(request, response) {
|
|
210
233
|
return __awaiter(this, void 0, void 0, function* () {
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
return response.status(404).json({ success: false, error: 'Device not found' });
|
|
215
|
-
}
|
|
234
|
+
const device = yield deviceToBlock(request, response);
|
|
235
|
+
if (!device)
|
|
236
|
+
return;
|
|
216
237
|
try {
|
|
217
238
|
yield (0, device_service_1.userUnblockDevice)(device.udid, device.host);
|
|
218
239
|
}
|
|
@@ -295,6 +316,7 @@ function getQueueSummary(request, response) {
|
|
|
295
316
|
}
|
|
296
317
|
function nodeAdbStatusOnOtherHost(currentHost, request, response) {
|
|
297
318
|
return __awaiter(this, void 0, void 0, function* () {
|
|
319
|
+
var _a;
|
|
298
320
|
const { host } = request.params;
|
|
299
321
|
// when host is this hub, return status from AndroidDeviceManager directly
|
|
300
322
|
// otherwise, forward request to the node
|
|
@@ -312,7 +334,9 @@ function nodeAdbStatusOnOtherHost(currentHost, request, response) {
|
|
|
312
334
|
}
|
|
313
335
|
else {
|
|
314
336
|
// find node url from database of devices
|
|
315
|
-
|
|
337
|
+
// The store's own host filter: findDevices hands its filter to Prisma
|
|
338
|
+
// as it is, and Prisma rejected the `$contains` that used to be here.
|
|
339
|
+
const devices = yield store.getDevices({ filterByHost: host });
|
|
316
340
|
if (devices.length === 0) {
|
|
317
341
|
response
|
|
318
342
|
.status(404)
|
|
@@ -323,7 +347,17 @@ function nodeAdbStatusOnOtherHost(currentHost, request, response) {
|
|
|
323
347
|
// remove wd/hub from url
|
|
324
348
|
const normalizedUrl = device.host.replace(/\/wd\/hub$/, '');
|
|
325
349
|
const url = `${normalizedUrl}/xenon/api/node/status`;
|
|
326
|
-
|
|
350
|
+
let result;
|
|
351
|
+
try {
|
|
352
|
+
result = yield InternalHttpClient_1.InternalHttpClient.get(url);
|
|
353
|
+
}
|
|
354
|
+
catch (err) {
|
|
355
|
+
response.status(502).json({
|
|
356
|
+
error: 'node_unreachable',
|
|
357
|
+
message: `Could not get ${normalizedUrl}'s status: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`,
|
|
358
|
+
});
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
327
361
|
response.json(result);
|
|
328
362
|
}
|
|
329
363
|
});
|
|
@@ -404,7 +438,6 @@ function getActiveSessions(request, response) {
|
|
|
404
438
|
return __awaiter(this, void 0, void 0, function* () {
|
|
405
439
|
const { SessionManager } = yield Promise.resolve().then(() => __importStar(require('../../sessions/SessionManager')));
|
|
406
440
|
const sessionManager = typedi_1.Container.get(SessionManager);
|
|
407
|
-
const stats = sessionManager.getStats();
|
|
408
441
|
const sessions = sessionManager.getAllSessions().map((s) => {
|
|
409
442
|
var _a, _b, _c;
|
|
410
443
|
return ({
|
|
@@ -418,8 +451,15 @@ function getActiveSessions(request, response) {
|
|
|
418
451
|
// Phase 4A: filter by visibility of the underlying device.
|
|
419
452
|
const auth = request.auth;
|
|
420
453
|
const visibleSessions = yield (0, device_service_1.filterRowsByVisibleDevice)(sessions, auth === null || auth === void 0 ? void 0 : auth.teamIds, 'deviceUdid');
|
|
454
|
+
// The counts are of the sessions listed, so a member's describe only what
|
|
455
|
+
// they may see. Through 2.12 they counted every team's sessions.
|
|
456
|
+
const byType = { local: 0, remote: 0, cloud: 0 };
|
|
457
|
+
for (const session of visibleSessions) {
|
|
458
|
+
const type = String(session.type).toLowerCase();
|
|
459
|
+
byType[type] = (byType[type] || 0) + 1;
|
|
460
|
+
}
|
|
421
461
|
response.json({
|
|
422
|
-
stats,
|
|
462
|
+
stats: { total: visibleSessions.length, byType },
|
|
423
463
|
sessions: visibleSessions,
|
|
424
464
|
});
|
|
425
465
|
});
|
|
@@ -22,15 +22,24 @@ const ROLE_SCOPES = {
|
|
|
22
22
|
ADMIN: ['devices', 'sessions', 'read'],
|
|
23
23
|
MEMBER: ['sessions', 'read'],
|
|
24
24
|
};
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
25
|
+
const KNOWN_SCOPES = ['read', 'sessions', 'devices', 'admin'];
|
|
26
|
+
function isScope(value) {
|
|
27
|
+
return KNOWN_SCOPES.includes(value);
|
|
28
|
+
}
|
|
29
|
+
/** Whether a scope list grants `scope`; `admin` grants every scope, as scopeGuard reads it. */
|
|
30
|
+
function grants(scopes, scope) {
|
|
31
|
+
return scopes.includes('admin') || scopes.includes(scope);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Whether a token with `requested` would reach past the caller's role or past
|
|
35
|
+
* the credential creating it. Through 2.12 only the role was checked, so a
|
|
36
|
+
* `read`-only key of an admin could mint itself a `devices,sessions` token.
|
|
37
|
+
* And a SUPER_ADMIN, whose role's list is just `admin`, could not ask for a
|
|
38
|
+
* narrower token than that.
|
|
39
|
+
*/
|
|
40
|
+
function widenedScope(requested, allowed, credential) {
|
|
41
|
+
const credentialScopes = credential.split(',').map((s) => s.trim());
|
|
42
|
+
return requested.some((r) => !grants(allowed, r) || !grants(credentialScopes, r));
|
|
34
43
|
}
|
|
35
44
|
function profileRouter() {
|
|
36
45
|
const r = (0, express_1.Router)();
|
|
@@ -102,7 +111,7 @@ function profileRouter() {
|
|
|
102
111
|
})));
|
|
103
112
|
}));
|
|
104
113
|
r.post('/tokens', (req, res) => __awaiter(this, void 0, void 0, function* () {
|
|
105
|
-
var _a, _b;
|
|
114
|
+
var _a, _b, _c;
|
|
106
115
|
const auth = requireAuth(req, res);
|
|
107
116
|
if (!auth)
|
|
108
117
|
return;
|
|
@@ -124,9 +133,14 @@ function profileRouter() {
|
|
|
124
133
|
expiresAtDate = d;
|
|
125
134
|
}
|
|
126
135
|
const allowed = (_a = ROLE_SCOPES[auth.role]) !== null && _a !== void 0 ? _a : ROLE_SCOPES.MEMBER;
|
|
136
|
+
if (scopes !== undefined && (!Array.isArray(scopes) || !scopes.every(isScope))) {
|
|
137
|
+
return res.status(400).json({ error: `scopes must be some of ${KNOWN_SCOPES.join(', ')}` });
|
|
138
|
+
}
|
|
127
139
|
const requested = scopes && scopes.length > 0 ? scopes : allowed;
|
|
128
|
-
if (widenedScope(requested, allowed)) {
|
|
129
|
-
return res
|
|
140
|
+
if (widenedScope(requested, allowed, String((_b = auth.scopes) !== null && _b !== void 0 ? _b : ''))) {
|
|
141
|
+
return res
|
|
142
|
+
.status(400)
|
|
143
|
+
.json({ error: 'cannot widen scopes beyond your role or the credential you are using' });
|
|
130
144
|
}
|
|
131
145
|
const { id, raw } = yield apiKeySvc.create({
|
|
132
146
|
name,
|
|
@@ -137,7 +151,7 @@ function profileRouter() {
|
|
|
137
151
|
return res.status(201).json({
|
|
138
152
|
id,
|
|
139
153
|
token: raw,
|
|
140
|
-
expiresAt: (
|
|
154
|
+
expiresAt: (_c = expiresAtDate === null || expiresAtDate === void 0 ? void 0 : expiresAtDate.toISOString()) !== null && _c !== void 0 ? _c : null,
|
|
141
155
|
});
|
|
142
156
|
}));
|
|
143
157
|
r.delete('/tokens/:id', (req, res) => __awaiter(this, void 0, void 0, function* () {
|
|
@@ -275,6 +275,11 @@ function summaryContext(req, all, visible) {
|
|
|
275
275
|
const recLog = logger_1.default.scope('RecordingsRouter');
|
|
276
276
|
const router = (0, express_1.Router)();
|
|
277
277
|
router.use((0, roleGuard_1.roleGuard)('MEMBER'));
|
|
278
|
+
// Every recording write needs the `devices` scope: it holds, records and marks
|
|
279
|
+
// phones. Through 2.12 only DELETE asked, so a `read`-only key could start and
|
|
280
|
+
// stop recordings. Mounted on /recordings only: this router sits at the API's
|
|
281
|
+
// root, and a router-wide guard would also catch the routes after it.
|
|
282
|
+
router.use('/recordings', (0, scopeGuard_1.mutationScopeGuard)(['devices']));
|
|
278
283
|
router.get('/recordings', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
|
|
279
284
|
const parsed = (0, recordingRequests_1.parseLibraryQuery)(req.query);
|
|
280
285
|
if (!parsed.ok)
|
|
@@ -559,8 +564,7 @@ router.get('/recordings/:groupId', (req, res) => __awaiter(void 0, void 0, void
|
|
|
559
564
|
}));
|
|
560
565
|
// Irreversible, so an API key needs `devices`, as on /control: owning the
|
|
561
566
|
// recording, or an admin role, is not enough for a read-only key.
|
|
562
|
-
|
|
563
|
-
router.delete('/recordings/:groupId', devicesScope, (req, res) => __awaiter(void 0, void 0, void 0, function* () {
|
|
567
|
+
router.delete('/recordings/:groupId', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
|
|
564
568
|
const { groupId } = req.params;
|
|
565
569
|
try {
|
|
566
570
|
const store = typedi_1.Container.get(recording_store_1.RecordingStore);
|
|
@@ -619,9 +623,11 @@ router.get('/recordings/:groupId/composite.mp4', (req, res) => __awaiter(void 0,
|
|
|
619
623
|
if (!compositePath) {
|
|
620
624
|
return res.status(404).json({ error: 'composite_not_found' });
|
|
621
625
|
}
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
626
|
+
// sendFile, as source.mp4: it answers Range requests (206, 416) so a
|
|
627
|
+
// player can seek. A piped stream sent `Accept-Ranges: bytes` and then the
|
|
628
|
+
// whole file, with 200, whatever was asked. DOWNLOAD_OPTIONS lets the
|
|
629
|
+
// path under ~/.cache through on Express 5 (see sourceMp4Handler).
|
|
630
|
+
return res.sendFile(path.resolve(compositePath), Object.assign(Object.assign({}, apps_1.DOWNLOAD_OPTIONS), { headers: { 'Content-Type': 'video/mp4' } }), (err) => handleSendError('composite.mp4', req, res, err));
|
|
625
631
|
}));
|
|
626
632
|
/**
|
|
627
633
|
* Videos-only zip (mp4 files). Prefer this over proof bundle for dashboard
|
|
@@ -14,6 +14,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
14
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
15
|
const express_1 = __importDefault(require("express"));
|
|
16
16
|
const device_service_1 = require("../../data-service/device-service");
|
|
17
|
+
const device_store_1 = require("../../data-service/device-store");
|
|
18
|
+
const actor_1 = require("../../services/device-access/actor");
|
|
17
19
|
const logger_1 = __importDefault(require("../../logger"));
|
|
18
20
|
const scopeGuard_1 = require("../../middleware/scopeGuard");
|
|
19
21
|
const roleGuard_1 = require("../../middleware/roleGuard");
|
|
@@ -36,16 +38,40 @@ router.use((0, roleGuard_1.roleGuard)('MEMBER'));
|
|
|
36
38
|
// GET listings remain open to any authenticated key.
|
|
37
39
|
router.use((0, scopeGuard_1.mutationScopeGuard)(['devices']));
|
|
38
40
|
/**
|
|
39
|
-
* The device a reservation route acts on
|
|
40
|
-
*
|
|
41
|
-
*
|
|
41
|
+
* The device a reservation route acts on: the row of that udid on that host,
|
|
42
|
+
* or undefined when there is none or the caller may not see it. Teams are a
|
|
43
|
+
* device boundary: another team's phone takes the same 404 as a udid that
|
|
44
|
+
* doesn't exist, so the answer never says that it exists. Through 2.12 the
|
|
45
|
+
* host was ignored when looking the phone up, then written to: a host with no
|
|
46
|
+
* such phone answered 200, and the reservation went nowhere.
|
|
42
47
|
*/
|
|
43
|
-
function findVisibleDevice(req, udid) {
|
|
48
|
+
function findVisibleDevice(req, udid, host) {
|
|
44
49
|
return __awaiter(this, void 0, void 0, function* () {
|
|
45
|
-
|
|
50
|
+
if (typeof udid !== 'string' || typeof host !== 'string')
|
|
51
|
+
return undefined;
|
|
52
|
+
const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid, host });
|
|
46
53
|
return device && (0, deviceVisibility_1.isDeviceVisible)(device.teamId, teamIdsOf(req)) ? device : undefined;
|
|
47
54
|
});
|
|
48
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Whether the caller is the user who took a device's reservation. One from
|
|
58
|
+
* before 2.13 names no user, so anyone is.
|
|
59
|
+
*/
|
|
60
|
+
function holdsReservation(req, device) {
|
|
61
|
+
return !device.reservedByUserId || (0, actor_1.resolveActor)(req).userId === device.reservedByUserId;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Whether the caller may release or extend a device's reservation: the user
|
|
65
|
+
* who took it, or an admin. Through 2.12 anyone who could see the phone could.
|
|
66
|
+
*/
|
|
67
|
+
function mayChangeReservation(req, device) {
|
|
68
|
+
return (0, actor_1.resolveActor)(req).isAdmin || holdsReservation(req, device);
|
|
69
|
+
}
|
|
70
|
+
const NOT_HOLDER = {
|
|
71
|
+
success: false,
|
|
72
|
+
error: 'not_reservation_holder',
|
|
73
|
+
message: 'Only the person who reserved this device, or an admin, can change the reservation.',
|
|
74
|
+
};
|
|
49
75
|
/** The caller's teams; undefined for an admin or an auth-disabled server. */
|
|
50
76
|
function teamIdsOf(req) {
|
|
51
77
|
var _a;
|
|
@@ -58,6 +84,8 @@ const DURATION_OPTIONS = {
|
|
|
58
84
|
'4h': 4 * 60 * 60 * 1000,
|
|
59
85
|
'8h': 8 * 60 * 60 * 1000,
|
|
60
86
|
};
|
|
87
|
+
const MIN_DURATION_MS = 60 * 1000; // 1 minute
|
|
88
|
+
const MAX_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
61
89
|
/**
|
|
62
90
|
* GET /api/reservation
|
|
63
91
|
* List all currently reserved devices
|
|
@@ -114,8 +142,6 @@ router.post('/', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
|
|
|
114
142
|
});
|
|
115
143
|
}
|
|
116
144
|
// Bounds check: refuse negative, zero, NaN, Infinity, and anything over 24h.
|
|
117
|
-
const MIN_DURATION_MS = 60 * 1000; // 1 minute
|
|
118
|
-
const MAX_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
119
145
|
if (!Number.isFinite(durationMs) ||
|
|
120
146
|
durationMs < MIN_DURATION_MS ||
|
|
121
147
|
durationMs > MAX_DURATION_MS) {
|
|
@@ -125,12 +151,15 @@ router.post('/', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
|
|
|
125
151
|
});
|
|
126
152
|
}
|
|
127
153
|
// Check if device exists
|
|
128
|
-
const device = yield findVisibleDevice(req, udid);
|
|
154
|
+
const device = yield findVisibleDevice(req, udid, host);
|
|
129
155
|
if (!device) {
|
|
130
156
|
return res.status(404).json({ success: false, error: 'Device not found' });
|
|
131
157
|
}
|
|
132
158
|
// Check if device is already reserved by someone else
|
|
133
|
-
|
|
159
|
+
// Renewing your own: the same user (with auth disabled every caller is
|
|
160
|
+
// one), under the same name.
|
|
161
|
+
if ((0, device_service_1.isDeviceReserved)(device) &&
|
|
162
|
+
!(holdsReservation(req, device) && device.reservedBy === reservedBy)) {
|
|
134
163
|
return res.status(409).json({
|
|
135
164
|
success: false,
|
|
136
165
|
error: `Device is already reserved by ${device.reservedBy} until ${new Date(device.reservedUntil).toISOString()}`,
|
|
@@ -143,7 +172,7 @@ router.post('/', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
|
|
|
143
172
|
error: 'Device is currently busy with an Appium session. Cannot reserve.',
|
|
144
173
|
});
|
|
145
174
|
}
|
|
146
|
-
yield (0, device_service_1.reserveDevice)(udid, host, reservedBy, durationMs, reason);
|
|
175
|
+
yield (0, device_service_1.reserveDevice)(udid, host, reservedBy, durationMs, reason, (0, actor_1.resolveActor)(req).userId);
|
|
147
176
|
const reservedUntil = Date.now() + durationMs;
|
|
148
177
|
res.json({
|
|
149
178
|
success: true,
|
|
@@ -171,13 +200,15 @@ router.delete('/:udid/:host', (req, res) => __awaiter(void 0, void 0, void 0, fu
|
|
|
171
200
|
try {
|
|
172
201
|
const { udid } = req.params;
|
|
173
202
|
const host = decodeURIComponent(req.params.host);
|
|
174
|
-
const device = yield findVisibleDevice(req, udid);
|
|
203
|
+
const device = yield findVisibleDevice(req, udid, host);
|
|
175
204
|
if (!device) {
|
|
176
205
|
return res.status(404).json({ success: false, error: 'Device not found' });
|
|
177
206
|
}
|
|
178
207
|
if (!(0, device_service_1.isDeviceReserved)(device)) {
|
|
179
208
|
return res.status(400).json({ success: false, error: 'Device is not reserved' });
|
|
180
209
|
}
|
|
210
|
+
if (!mayChangeReservation(req, device))
|
|
211
|
+
return res.status(403).json(NOT_HOLDER);
|
|
181
212
|
yield (0, device_service_1.releaseReservation)(udid, host);
|
|
182
213
|
res.json({
|
|
183
214
|
success: true,
|
|
@@ -199,13 +230,15 @@ router.post('/:udid/:host/extend', (req, res) => __awaiter(void 0, void 0, void
|
|
|
199
230
|
const { udid } = req.params;
|
|
200
231
|
const host = decodeURIComponent(req.params.host);
|
|
201
232
|
const { duration } = req.body;
|
|
202
|
-
const device = yield findVisibleDevice(req, udid);
|
|
233
|
+
const device = yield findVisibleDevice(req, udid, host);
|
|
203
234
|
if (!device) {
|
|
204
235
|
return res.status(404).json({ success: false, error: 'Device not found' });
|
|
205
236
|
}
|
|
206
237
|
if (!(0, device_service_1.isDeviceReserved)(device)) {
|
|
207
238
|
return res.status(400).json({ success: false, error: 'Device is not reserved' });
|
|
208
239
|
}
|
|
240
|
+
if (!mayChangeReservation(req, device))
|
|
241
|
+
return res.status(403).json(NOT_HOLDER);
|
|
209
242
|
// Parse duration
|
|
210
243
|
let extensionMs;
|
|
211
244
|
if (typeof duration === 'string' && DURATION_OPTIONS[duration]) {
|
|
@@ -220,9 +253,24 @@ router.post('/:udid/:host/extend', (req, res) => __awaiter(void 0, void 0, void
|
|
|
220
253
|
error: `Invalid duration. Use: ${Object.keys(DURATION_OPTIONS).join(', ')} or a number in milliseconds`,
|
|
221
254
|
});
|
|
222
255
|
}
|
|
223
|
-
// Extend from current expiry time
|
|
224
|
-
|
|
225
|
-
|
|
256
|
+
// Extend from current expiry time, to at most 24 hours from now, as a new
|
|
257
|
+
// reservation may last. Through 2.12 any number was accepted, a negative
|
|
258
|
+
// one included, so one call could hold a phone for years.
|
|
259
|
+
const now = Date.now();
|
|
260
|
+
const newExpiry = (device.reservedUntil || now) + extensionMs;
|
|
261
|
+
if (!Number.isFinite(extensionMs) || extensionMs < MIN_DURATION_MS) {
|
|
262
|
+
return res.status(400).json({
|
|
263
|
+
success: false,
|
|
264
|
+
error: `duration must be at least ${MIN_DURATION_MS}ms (1 minute)`,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
if (newExpiry - now > MAX_DURATION_MS) {
|
|
268
|
+
return res.status(400).json({
|
|
269
|
+
success: false,
|
|
270
|
+
error: 'A reservation can end at most 24 hours from now',
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
yield (0, device_service_1.reserveDevice)(udid, host, device.reservedBy, newExpiry - now, device.reservationReason, device.reservedByUserId);
|
|
226
274
|
res.json({
|
|
227
275
|
success: true,
|
|
228
276
|
message: `Reservation extended for device ${udid}`,
|
|
@@ -19,6 +19,7 @@ const typedi_1 = require("typedi");
|
|
|
19
19
|
const crypto_1 = __importDefault(require("crypto"));
|
|
20
20
|
const UserService_1 = require("../../services/UserService");
|
|
21
21
|
const roleGuard_1 = require("../../middleware/roleGuard");
|
|
22
|
+
const scopeGuard_1 = require("../../middleware/scopeGuard");
|
|
22
23
|
const userAuthorization_1 = require("../../services/identity/userAuthorization");
|
|
23
24
|
const prisma_1 = require("../../prisma");
|
|
24
25
|
const PasswordResetService_1 = require("../../services/PasswordResetService");
|
|
@@ -36,6 +37,9 @@ function makeTempPassword() {
|
|
|
36
37
|
function usersRouter() {
|
|
37
38
|
const r = (0, express_1.Router)();
|
|
38
39
|
r.use((0, roleGuard_1.roleGuard)('ADMIN'));
|
|
40
|
+
// And the admin scope, as /apikeys and /teams ask: an admin's `read`-only
|
|
41
|
+
// key or token is not a grant to manage accounts. Through 2.12 it was.
|
|
42
|
+
r.use((0, scopeGuard_1.scopeGuard)(['admin']));
|
|
39
43
|
const userSvc = typedi_1.Container.get(UserService_1.UserService);
|
|
40
44
|
function getAuth(req) {
|
|
41
45
|
return req.auth;
|
|
@@ -54,31 +54,40 @@ function deleteConfig(req, res) {
|
|
|
54
54
|
res.json({ success: true });
|
|
55
55
|
}
|
|
56
56
|
catch (err) {
|
|
57
|
+
// Prisma's "record to delete does not exist".
|
|
58
|
+
if ((err === null || err === void 0 ? void 0 : err.code) === 'P2025') {
|
|
59
|
+
return res.status(404).json({ error: 'not_found', message: 'Webhook not found' });
|
|
60
|
+
}
|
|
57
61
|
logger_1.default.error(`Failed to delete webhook config: ${err}`);
|
|
58
62
|
res.status(500).json({ error: 'Failed to delete configuration' });
|
|
59
63
|
}
|
|
60
64
|
});
|
|
61
65
|
}
|
|
66
|
+
// Sends a sample event the way the webhook's real events go out (its type
|
|
67
|
+
// and template), and says whether it was delivered.
|
|
62
68
|
function testWebhook(req, res) {
|
|
63
69
|
return __awaiter(this, void 0, void 0, function* () {
|
|
64
|
-
|
|
70
|
+
var _a, _b;
|
|
71
|
+
const { url, type, payloadTemplate } = (_a = req.body) !== null && _a !== void 0 ? _a : {};
|
|
72
|
+
if (!url || typeof url !== 'string') {
|
|
73
|
+
return res.status(400).json({ error: 'url is required' });
|
|
74
|
+
}
|
|
65
75
|
try {
|
|
66
|
-
yield typedi_1.Container.get(NotificationService_1.NotificationService).
|
|
67
|
-
udid: 'test-device-udid',
|
|
68
|
-
name: 'Test Device',
|
|
69
|
-
host: '127.0.0.1',
|
|
70
|
-
});
|
|
76
|
+
yield typedi_1.Container.get(NotificationService_1.NotificationService).sendTest(url, type || 'slack', payloadTemplate);
|
|
71
77
|
res.json({ success: true });
|
|
72
78
|
}
|
|
73
79
|
catch (err) {
|
|
74
|
-
res.status(
|
|
80
|
+
res.status(502).json({ error: 'delivery_failed', message: (_b = err === null || err === void 0 ? void 0 : err.message) !== null && _b !== void 0 ? _b : String(err) });
|
|
75
81
|
}
|
|
76
82
|
});
|
|
77
83
|
}
|
|
78
84
|
function register(parentRouter) {
|
|
79
85
|
const webhookRouterInstance = (0, express_1.Router)();
|
|
80
86
|
webhookRouterInstance.use((0, roleGuard_1.roleGuard)('ADMIN'));
|
|
81
|
-
|
|
87
|
+
// The list holds every webhook's URL, which is often its secret (Slack's
|
|
88
|
+
// are), so reading it needs the admin scope too. Through 2.12 an admin's
|
|
89
|
+
// `read`-only key could list them.
|
|
90
|
+
webhookRouterInstance.get('/', (0, scopeGuard_1.scopeGuard)(['admin']), getConfigs);
|
|
82
91
|
// Webhook mutations are admin-only: adding / removing / test-firing global
|
|
83
92
|
// webhooks is a fleet-wide config change.
|
|
84
93
|
webhookRouterInstance.post('/', (0, scopeGuard_1.scopeGuard)(['admin']), addConfig);
|