postman-runtime 7.45.0 → 7.46.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.
@@ -136,13 +136,33 @@ _extractField = function (string, regexp) {
136
136
  * there can be more than one header with the same key. So need to loop over and check each one.
137
137
  *
138
138
  * @param {VariableList} headers -
139
+ * @param {String} selectedAlgorithm - The user opted algorithm (MD5, SHA-256 etc)
139
140
  * @private
140
141
  */
141
- function _getDigestAuthHeader (headers) {
142
- return headers.find(function (property) {
142
+ function _getDigestAuthHeader (headers, selectedAlgorithm) {
143
+ const digestAuthHeaders = headers.filter(function (property) {
143
144
  return (property.key.toLowerCase() === WWW_AUTHENTICATE) &&
144
145
  (_.startsWith(String(property.value).toLowerCase(), DIGEST_PREFIX.toLowerCase()));
145
146
  });
147
+
148
+ let headerWithMatchingOptedAlgorithm;
149
+
150
+ if (selectedAlgorithm) {
151
+ const targetAlgorithm = selectedAlgorithm.toLowerCase();
152
+
153
+ headerWithMatchingOptedAlgorithm = digestAuthHeaders.find(function (header) {
154
+ const headerValue = String(header.value).toLowerCase();
155
+
156
+ if (!headerValue.includes('algorithm=')) {
157
+ // This is an MD5 header. Ref: https://datatracker.ietf.org/doc/html/rfc7616
158
+ return targetAlgorithm === 'md5';
159
+ }
160
+
161
+ return headerValue.includes(`algorithm=${targetAlgorithm}`);
162
+ });
163
+ }
164
+
165
+ return headerWithMatchingOptedAlgorithm || digestAuthHeaders[0];
146
166
  }
147
167
 
148
168
  /**
@@ -314,6 +334,7 @@ module.exports = {
314
334
 
315
335
  var code,
316
336
  nonceCount,
337
+ algorithm,
317
338
  realm,
318
339
  nonce,
319
340
  qop,
@@ -323,7 +344,9 @@ module.exports = {
323
344
 
324
345
  code = response.code;
325
346
  nonceCount = auth.get('nonceCount');
326
- authHeader = _getDigestAuthHeader(response.headers);
347
+ algorithm = auth.get('algorithm');
348
+
349
+ authHeader = _getDigestAuthHeader(response.headers, algorithm);
327
350
 
328
351
  // If code is forbidden or unauthorized, and an auth header exists,
329
352
  // we can extract the realm & the nonce, and replay the request.
@@ -471,7 +471,7 @@ class Requester extends EventEmitter {
471
471
  header: responseHeaders,
472
472
  stream: resBody,
473
473
  responseTime: responseTime,
474
- downloadedBytes: history.execution.data[0].response.downloadedBytes
474
+ downloadedBytes: history.execution.data.at(-1).response.downloadedBytes
475
475
  });
476
476
 
477
477
  onComplete(RESPONSE_END, response, history);
@@ -15,7 +15,9 @@ var _ = require('lodash'),
15
15
  'request', 'response'],
16
16
 
17
17
  EXECUTION_REQUEST_EVENT_BASE = 'execution.request.',
18
+ EXECUTION_RUN_REQUEST_EVENT_BASE = 'execution.run_collection_request.',
18
19
  EXECUTION_RESPONSE_EVENT_BASE = 'execution.response.',
20
+ EXECUTION_RUN_REQUEST_RESPONSE_EVENT_BASE = 'execution.run_collection_request_response.',
19
21
  EXECUTION_ASSERTION_EVENT_BASE = 'execution.assertion.',
20
22
  EXECUTION_ERROR_EVENT_BASE = 'execution.error.',
21
23
  EXECUTION_COOKIES_EVENT_BASE = 'execution.cookies.',
@@ -32,6 +34,8 @@ var _ = require('lodash'),
32
34
  REQUEST_BODY_MODE_FILE = 'file',
33
35
  REQUEST_BODY_MODE_FORMDATA = 'formdata',
34
36
 
37
+ runNestedRequest = require('../nested-request'),
38
+
35
39
  getCookieDomain, // fn
36
40
  postProcessContext, // fn
37
41
  sanitizeFiles; // fn
@@ -287,17 +291,26 @@ module.exports = {
287
291
  assertionFailed = [],
288
292
  asyncScriptError,
289
293
 
294
+ isNestedRequest = this.state.nestedRequest !== undefined,
295
+
296
+ rootItemId = isNestedRequest ? this.state.nestedRequest.rootItemId : item.id,
297
+
290
298
  // create copy of cursor so we don't leak script ids outside `event.command`
291
299
  // and across scripts
292
- scriptCursor = _.clone(cursor);
300
+
301
+ // in the case of nested requests, we want to make sure any exceptions, consoles etc
302
+ // are tagged to the parent request's scripts
303
+ scriptCursor = _.clone(isNestedRequest ? this.state.nestedRequest.rootCursor : cursor);
293
304
 
294
305
  // store the execution id in script
295
306
  script._lastExecutionId = executionId; // please don't use it anywhere else!
296
307
 
297
- // if we can find an id on script or event we add them to the cursor
298
- // so logs and errors can be traced back to the script they came from
299
- event.id && (scriptCursor.eventId = event.id);
300
- event.script.id && (scriptCursor.scriptId = event.script.id);
308
+ if (!isNestedRequest) {
309
+ // if we can find an id on script or event we add them to the cursor
310
+ // so logs and errors can be traced back to the script they came from
311
+ event.id && (scriptCursor.eventId = event.id);
312
+ event.script.id && (scriptCursor.scriptId = event.script.id);
313
+ }
301
314
 
302
315
  // trigger the "beforeScript" callback
303
316
  this.triggers.beforeScript(null, scriptCursor, script, event, item);
@@ -394,11 +407,20 @@ module.exports = {
394
407
  }
395
408
  }.bind(this));
396
409
 
410
+ // Explicitly enable tracking for vault secrets here as this will
411
+ // not be sent to sandbox who otherwise takes care of mutation tracking
412
+ // This is important especially when dealing with nested requests, without this, the parent req might
413
+ // not have a pm.vault.<cmd> call and thus no mutations would bubble up and apply to the parent
414
+ if (vaultSecrets && vaultSecrets.enableTracking) {
415
+ vaultSecrets.enableTracking({ autoCompact: true });
416
+ }
417
+
397
418
  this.host.on(EXECUTION_VAULT_BASE + executionId, async function (id, cmd, ...args) {
398
419
  if (hasVaultAccess === undefined) {
399
420
  try {
400
421
  // eslint-disable-next-line require-atomic-updates
401
- hasVaultAccess = Boolean(await vaultSecrets?._?.allowScriptAccess(item.id));
422
+ hasVaultAccess = Boolean(this.state.nestedRequest?.hasVaultAccess ||
423
+ await vaultSecrets?._?.allowScriptAccess(rootItemId));
402
424
  }
403
425
  catch (_) {
404
426
  // eslint-disable-next-line require-atomic-updates
@@ -406,6 +428,10 @@ module.exports = {
406
428
  }
407
429
  }
408
430
 
431
+ if (isNestedRequest) {
432
+ this.state.nestedRequest.hasVaultAccess = hasVaultAccess;
433
+ }
434
+
409
435
  // Ensure error is string
410
436
  // TODO identify why error objects are not being serialized correctly
411
437
  const dispatch = (e, r) => { this.host.dispatch(EXECUTION_VAULT_BASE + executionId, id, e, r); };
@@ -418,10 +444,6 @@ module.exports = {
418
444
  return dispatch(`Invalid vault command: ${cmd}`);
419
445
  }
420
446
 
421
- // Explicitly enable tracking for vault secrets here as this will
422
- // not be sent to sandbox who otherwise takes care of mutation tracking
423
- vaultSecrets.enableTracking({ autoCompact: true });
424
-
425
447
  dispatch(null, vaultSecrets[cmd](...args));
426
448
  }.bind(this));
427
449
 
@@ -480,6 +502,9 @@ module.exports = {
480
502
  }.bind(this));
481
503
  }.bind(this));
482
504
 
505
+ this.host.on(EXECUTION_RUN_REQUEST_EVENT_BASE + executionId,
506
+ runNestedRequest({ executionId, isExecutionSkipped, vaultSecrets, item }).bind(this));
507
+
483
508
  this.host.on(EXECUTION_SKIP_REQUEST_EVENT_BASE + executionId, function () {
484
509
  skippedExecutions.add(executionId);
485
510
  shouldSkipExecution = true;
@@ -499,6 +524,9 @@ module.exports = {
499
524
  context: _.pick(payload.context, SAFE_CONTEXT_VARIABLES),
500
525
  resolvedPackages: resolvedPackages,
501
526
 
527
+ disabledAPIs: !_.get(this, 'options.script.requestResolver') ?
528
+ ['execution.runRequest'] : [],
529
+
502
530
  // legacy options
503
531
  legacy: {
504
532
  _itemId: item.id,
@@ -510,6 +538,7 @@ module.exports = {
510
538
  this.host.removeAllListeners(EXECUTION_REQUEST_EVENT_BASE + executionId);
511
539
  this.host.removeAllListeners(EXECUTION_ASSERTION_EVENT_BASE + executionId);
512
540
  this.host.removeAllListeners(EXECUTION_RESPONSE_EVENT_BASE + executionId);
541
+ this.host.removeAllListeners(EXECUTION_RUN_REQUEST_RESPONSE_EVENT_BASE + executionId);
513
542
  this.host.removeAllListeners(EXECUTION_COOKIES_EVENT_BASE + executionId);
514
543
  this.host.removeAllListeners(EXECUTION_ERROR_EVENT_BASE + executionId);
515
544
  this.host.removeAllListeners(EXECUTION_SKIP_REQUEST_EVENT_BASE + executionId);
@@ -567,11 +596,13 @@ module.exports = {
567
596
  result && result.request && (result.request = new sdk.Request(result.request));
568
597
 
569
598
  // vault secrets are not sent to sandbox, thus using the scope from run context.
570
- if (hasVaultAccess && vaultSecrets) {
571
- result.vaultSecrets = vaultSecrets;
572
-
599
+ if (vaultSecrets) {
573
600
  // Prevent mutations from being carry-forwarded to subsequent events
574
601
  vaultSecrets.disableTracking();
602
+
603
+ if (hasVaultAccess || this.state.nestedRequest?.hasVaultAccess) {
604
+ result.vaultSecrets = vaultSecrets;
605
+ }
575
606
  }
576
607
 
577
608
  // @note Since postman-sandbox@3.5.2, response object is not included in the execution
@@ -598,6 +629,12 @@ module.exports = {
598
629
  // now that this script is done executing, we trigger the event and move to the next script
599
630
  this.triggers.script(err || null, scriptCursor, result, script, event, item);
600
631
 
632
+ if (!isNestedRequest) {
633
+ // Reset for next invocation if there are subsequent executions of items in a single
634
+ // runner via `iterationCount` or multiple `items` passed to a collection.
635
+ delete this.state.nestedRequest;
636
+ }
637
+
601
638
  // move to next script and pass on the results for accumulation
602
639
  done(((stopOnScriptError || abortOnError || stopOnFailure) && err) ? err : null, _.assign({
603
640
  event,
@@ -127,7 +127,9 @@ module.exports = {
127
127
  // we procure the coordinates that we have to pick item and data from. the data is
128
128
  var coords = payload.static ? payload.coords : this.waterfall.whatnext(payload.coords),
129
129
  item = this.state.items[coords.position],
130
- delay;
130
+ delay,
131
+ isNestedRequest = this.state.nestedRequest !== undefined,
132
+ rootRequestCursor = isNestedRequest ? this.state.nestedRequest.rootCursor : coords;
131
133
 
132
134
  // if there is nothing to process, we bail out from here, even before we enter the iteration cycle
133
135
  if (coords.empty) {
@@ -165,7 +167,8 @@ module.exports = {
165
167
  this.queue('item', {
166
168
  item: item,
167
169
  coords: coords,
168
- data: getIterationData(this.state.data, coords.iteration),
170
+ data: getIterationData(this.state.data,
171
+ isNestedRequest ? rootRequestCursor.iteration : coords.iteration),
169
172
  environment: this.state.environment,
170
173
  globals: this.state.globals,
171
174
  vaultSecrets: this.state.vaultSecrets,
@@ -42,6 +42,9 @@ _.assign(Runner.prototype, {
42
42
  var runOptions = _.merge(_.omit(options,
43
43
  ['environment', 'globals', 'vaultSecrets', 'data']), this.options.run) || {};
44
44
 
45
+ // Ensure we have a default value for max invokable nested requests
46
+ !runOptions.maxInvokableNestedRequests && (runOptions.maxInvokableNestedRequests = 5);
47
+
45
48
  // start timeout sanitization
46
49
  !runOptions.timeout && (runOptions.timeout = {});
47
50
 
@@ -66,6 +69,24 @@ _.assign(Runner.prototype, {
66
69
  * @param {Object} [options.globals] -
67
70
  * @param {Object} [options.environment] -
68
71
  * @param {Object} [options.vaultSecrets] - Vault Secrets
72
+ * @param {Object} [options.nestedRequest] - State and options used for nested request set by parent request
73
+ * @param {Number} [options.nestedRequest.rootCursor] - The cursor of the root request that spun up this
74
+ * nested request runner. This is recursively passed down to keep track of which execution started the chain
75
+ * and modify cursors for all nested req events for reporters built on top of postman-runtime.
76
+ * @param {Number} [options.nestedRequest.rootItemId] - The id of the root item that spawned this nested request.
77
+ * Used by vault to get consent for root request and determine whether vault access check was performed even once
78
+ * throughout the chain.
79
+ * @param {Number} [options.nestedRequest.hasVaultAccess] - Mutated and set by any nested or parent request
80
+ * to indicate whether vault access check has been performed.
81
+ * @param {Number} [options.nestedRequest.invocationCount] - The number of requests currently accummulated
82
+ * by the nested request chain.
83
+ * @param {Object} [options.requester] - Options specific to the requester
84
+ * @param {Function} [options.script.requestResolver] - Resolver that receives an id from
85
+ * pm.execution.runRequest and returns the JSON for the request collection.
86
+ * Should return a postman-collection compatible collection JSON with `item` containing the request to run,
87
+ * `variable` array containing list of request-specific-collection variables and `event` with scripts to execute.
88
+ * @param {Number} [options.maxInvokableNestedRequests] - The maximum number of nested requests
89
+ * that a script can invoke, combined in total and recursively nested
69
90
  * @param {Number} [options.iterationCount] -
70
91
  * @param {CertificateList} [options.certificates] -
71
92
  * @param {ProxyConfigList} [options.proxies] -
@@ -119,6 +140,8 @@ _.assign(Runner.prototype, {
119
140
  environment: options.environment,
120
141
  globals: _.has(options, 'globals') ? options.globals : self.options.globals,
121
142
  vaultSecrets: options.vaultSecrets,
143
+ // Used for nested request executions
144
+ nestedRequest: options.nestedRequest,
122
145
  // @todo Move to item level to support Item and ItemGroup variables
123
146
  collectionVariables: collection.variables,
124
147
  localVariables: options.localVariables,
@@ -0,0 +1,197 @@
1
+ const _ = require('lodash'),
2
+ sdk = require('postman-collection'),
3
+ serialisedError = require('serialised-error'),
4
+
5
+ SYNCABLE_CONTEXT_VARIABLE_SCOPES = ['_variables', 'collectionVariables', 'environment', 'globals'],
6
+ EXECUTION_RUN_REQUEST_RESPONSE_EVENT_BASE = 'execution.run_collection_request_response.';
7
+
8
+ function runNestedRequest ({ executionId, isExecutionSkipped, vaultSecrets, item }) {
9
+ // Note: Don't forget to bind this function to the runner to make sure `this` can give you the right options & state
10
+ return function (cursor, eventId, requestId, requestToRunId, runRequestOptions = {}, runContext = {}) {
11
+ const self = this,
12
+ requestResolver = _.get(self, 'options.script.requestResolver'),
13
+ runRequestRespEvent = EXECUTION_RUN_REQUEST_RESPONSE_EVENT_BASE + eventId,
14
+ maxInvokableNestedRequests = _.get(self, 'options.maxInvokableNestedRequests');
15
+
16
+ function dispatchErrorToListener (err) {
17
+ const error = serialisedError(err);
18
+
19
+ delete error.stack;
20
+
21
+ return self.host.dispatch(EXECUTION_RUN_REQUEST_RESPONSE_EVENT_BASE + eventId,
22
+ requestId, error);
23
+ }
24
+
25
+ // Prepare nested request object for passing down to child request
26
+ // Have to keep object reference common so any changes made by nested executions is bubbled back to parent exec
27
+ self.state.nestedRequest = _.defaults(self.state.nestedRequest || {}, {
28
+ isNestedRequest: true,
29
+ rootCursor: cursor,
30
+ rootItemId: item.id,
31
+ invocationCount: 0
32
+ });
33
+
34
+ self.state.nestedRequest.invocationCount++;
35
+
36
+ // No more than maxInvokableNestedRequests runRequest calls per script or any of its nested request scripts
37
+ if (self.state.nestedRequest.invocationCount > maxInvokableNestedRequests) {
38
+ return dispatchErrorToListener(new Error('The maximum number of pm.execution.runRequest()' +
39
+ ' calls have been reached for this request.'));
40
+ }
41
+
42
+ // Should fetch the request from the consumer of postman-runtime & resolve scripts and variables
43
+ requestResolver(requestToRunId, function (err, collection) {
44
+ if (err) {
45
+ return dispatchErrorToListener(err);
46
+ }
47
+
48
+ if (!collection) {
49
+ return dispatchErrorToListener(new Error('Expected collection json with request item ' +
50
+ 'to invoke pm.execution.runRequest'));
51
+ }
52
+
53
+ // Prepare variables that have been set inside the parent's pre-req/post-res script
54
+ // so far and pass them to the runner for this request.
55
+ // This is important because for nested requests,
56
+ // variables set by the parent using pm.<variable-form>.set do not reflect
57
+ // in postman-runtime's scope immediately. They are present inside postman-sandbox
58
+ // till the parent request's script execution ends.
59
+ const globals = { values: runContext.globals || [] },
60
+ localVariables = { values: runContext._variables || [] },
61
+ environment = { values: runContext.environment || [] },
62
+ collectionVariables = { values: runContext.collectionVariables || [] };
63
+
64
+ const runner = require('.'),
65
+ variableOverrides = runRequestOptions.variables ?
66
+ Object.entries(runRequestOptions.variables)
67
+ .map(function ([key, value]) {
68
+ return { key, value };
69
+ }) :
70
+ [],
71
+ runnableRefRequestCollection = new sdk.Collection(collection),
72
+ mergedCollectionVariableList = new sdk.VariableList();
73
+
74
+ // Merge parent collection's variables with this collection's variables
75
+ // Why the separate statement? Because we want the referenced request's collection's variables to
76
+ // take precedence over the parent collection's variables if there are any conflicts.
77
+ mergedCollectionVariableList.populate(collectionVariables.values);
78
+ mergedCollectionVariableList.populate(runnableRefRequestCollection.variables.all());
79
+
80
+ runnableRefRequestCollection.variables = mergedCollectionVariableList;
81
+
82
+ // Merge local variables from parent requests & scope + nestedRequest.options.variables
83
+ localVariables.values = [...localVariables.values, ...variableOverrides];
84
+
85
+ // Why clone? Each runner execution needs to track and mutate its vault variables separately and propagate
86
+ // it back up and further down. We don't want to accidentally reset mutations between executions by sharing
87
+ // this scope
88
+ let clonedVaultSecrets;
89
+
90
+ if (vaultSecrets) {
91
+ clonedVaultSecrets = new sdk.VariableScope({
92
+ values: vaultSecrets.values,
93
+ prefix: vaultSecrets.prefix
94
+ });
95
+
96
+ clonedVaultSecrets._ = vaultSecrets._;
97
+ }
98
+
99
+ new runner().run(runnableRefRequestCollection,
100
+ {
101
+ ...self.state,
102
+ ...self.options,
103
+ entrypoint: {
104
+ lookupStrategy: 'idOrName',
105
+ execute: requestToRunId
106
+ },
107
+ iterationCount: 1,
108
+ globals: globals,
109
+ environment: environment,
110
+ localVariables: localVariables,
111
+ vaultSecrets: clonedVaultSecrets,
112
+ abortOnFailure: true,
113
+ host: {
114
+ // Reuse current run's sandbox host across nested executions
115
+ external: true,
116
+ instance: self.host
117
+ }
118
+ },
119
+ function (err, run) {
120
+ let exceptionForThisRequest = null,
121
+ responseForThisRequest = null,
122
+ variableMutationsFromThisExecution = {};
123
+
124
+ if (err) {
125
+ return self.host
126
+ .dispatch(EXECUTION_RUN_REQUEST_RESPONSE_EVENT_BASE + eventId,
127
+ requestId, err);
128
+ }
129
+ run.start({
130
+ script (_err, _cursor, result) {
131
+ // This is to sync changes to pm.variables, pm.environment & pm.globals
132
+ // that happened inside the nested request's script
133
+ // back to parent request's scripts still currently executing.
134
+
135
+ // collectionVariables don't need to be synced between parent and nested
136
+
137
+ // All other global variables defined by syntax like 'a=1'
138
+ // are anyway synced as the sandbox's common scope is shared across runs
139
+ if (result) {
140
+ SYNCABLE_CONTEXT_VARIABLE_SCOPES.forEach(function (type) {
141
+ if (!result[type]) {
142
+ return;
143
+ }
144
+
145
+ variableMutationsFromThisExecution[type] = [
146
+ ...(variableMutationsFromThisExecution[type] || []),
147
+ result[type].mutations
148
+ ];
149
+ });
150
+
151
+ if (clonedVaultSecrets && clonedVaultSecrets.mutations) {
152
+ const mutations = new sdk.MutationTracker(clonedVaultSecrets.mutations);
153
+
154
+ mutations.applyOn(vaultSecrets);
155
+ }
156
+ }
157
+ },
158
+ exception (_, err) {
159
+ if (err) {
160
+ exceptionForThisRequest = err;
161
+ }
162
+ },
163
+ response (err, _, response) {
164
+ if (isExecutionSkipped(executionId)) {
165
+ responseForThisRequest = null;
166
+ exceptionForThisRequest = null;
167
+
168
+ return;
169
+ }
170
+
171
+ if (err) {
172
+ exceptionForThisRequest = err;
173
+ }
174
+ if (response) {
175
+ responseForThisRequest = response;
176
+ }
177
+ },
178
+ done (err) {
179
+ let error = err || exceptionForThisRequest;
180
+
181
+ if (error) {
182
+ error = serialisedError(error);
183
+ delete error.stack;
184
+ }
185
+
186
+ return self.host.dispatch(runRequestRespEvent,
187
+ requestId, error || null,
188
+ responseForThisRequest,
189
+ { variableMutations: variableMutationsFromThisExecution });
190
+ }
191
+ });
192
+ });
193
+ });
194
+ };
195
+ }
196
+
197
+ module.exports = runNestedRequest;
package/lib/runner/run.js CHANGED
@@ -157,8 +157,14 @@ _.assign(Run.prototype, {
157
157
 
158
158
  // if there is nothing to process, exit
159
159
  if (!instruction) {
160
- // dispose the host before ending the run
161
- this.host && this.host.dispose();
160
+ const isNestedRequest = this.state.nestedRequest !== undefined;
161
+
162
+ // dispose the host before ending the run for the main container request
163
+ // - a nested request uses the parent's sandbox host, so only dispose it once the entire nested request
164
+ // execution chain is complete
165
+ if (!isNestedRequest && this.host) {
166
+ this.host.dispose();
167
+ }
162
168
 
163
169
  callback(null, this.state.cursor.current());
164
170
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postman-runtime",
3
- "version": "7.45.0",
3
+ "version": "7.46.0",
4
4
  "description": "Underlying library of executing Postman Collections",
5
5
  "author": "Postman Inc.",
6
6
  "license": "Apache-2.0",
@@ -54,10 +54,10 @@
54
54
  "node-forge": "1.3.1",
55
55
  "node-oauth1": "1.3.0",
56
56
  "performance-now": "2.1.0",
57
- "postman-collection": "5.1.0",
57
+ "postman-collection": "5.1.1",
58
58
  "postman-request": "2.88.1-postman.43",
59
- "postman-sandbox": "6.1.2",
60
- "postman-url-encoder": "3.0.7",
59
+ "postman-sandbox": "6.2.0",
60
+ "postman-url-encoder": "3.0.8",
61
61
  "serialised-error": "1.1.3",
62
62
  "strip-json-comments": "3.1.1",
63
63
  "uuid": "8.3.2"