@unito/integration-cli 1.1.0 → 1.2.1
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/dist/boilerplate/package-lock.json +6 -6
- package/dist/boilerplate/package.json +1 -1
- package/dist/schemas/authorization.json +6 -0
- package/dist/src/commands/oauth2.js +0 -1
- package/dist/src/commands/publish.js +1 -2
- package/dist/src/commands/upgrade.js +2 -1
- package/dist/src/configurationTypes.d.ts +8 -0
- package/dist/src/configurationTypes.js +9 -1
- package/dist/src/services/oauth2.d.ts +2 -0
- package/dist/src/services/oauth2.js +14 -1
- package/dist/test/services/oauth2.test.js +88 -0
- package/oclif.manifest.json +2 -4
- package/package.json +1 -1
|
@@ -1731,9 +1731,9 @@
|
|
|
1731
1731
|
}
|
|
1732
1732
|
},
|
|
1733
1733
|
"node_modules/brace-expansion": {
|
|
1734
|
-
"version": "1.1.
|
|
1735
|
-
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.
|
|
1736
|
-
"integrity": "sha512-
|
|
1734
|
+
"version": "1.1.13",
|
|
1735
|
+
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
|
1736
|
+
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
|
1737
1737
|
"dev": true,
|
|
1738
1738
|
"license": "MIT",
|
|
1739
1739
|
"dependencies": {
|
|
@@ -3205,9 +3205,9 @@
|
|
|
3205
3205
|
}
|
|
3206
3206
|
},
|
|
3207
3207
|
"node_modules/path-to-regexp": {
|
|
3208
|
-
"version": "8.
|
|
3209
|
-
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.
|
|
3210
|
-
"integrity": "sha512-
|
|
3208
|
+
"version": "8.4.0",
|
|
3209
|
+
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz",
|
|
3210
|
+
"integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==",
|
|
3211
3211
|
"license": "MIT",
|
|
3212
3212
|
"funding": {
|
|
3213
3213
|
"type": "opencollective",
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"scripts": {
|
|
8
8
|
"compile": "tsc --project tsconfig.build.json",
|
|
9
9
|
"compile:watch": "tsc -w",
|
|
10
|
-
"dev": "tsx watch src/index.ts",
|
|
10
|
+
"dev": "NODE_ENV=development tsx watch src/index.ts",
|
|
11
11
|
"test": "NODE_ENV=test tsx --test --test-name-pattern=${ONLY:-.*} $(find test -type f -name '*.test.ts')",
|
|
12
12
|
"lint": "eslint --config eslint.config.mjs --fix src test && prettier --write src test",
|
|
13
13
|
"ci:test": "npm run test"
|
|
@@ -202,6 +202,12 @@
|
|
|
202
202
|
},
|
|
203
203
|
"responseContentType": {
|
|
204
204
|
"$ref": "#/$defs/authorizationResponseContentType"
|
|
205
|
+
},
|
|
206
|
+
"pkceCodeChallengeMethod": {
|
|
207
|
+
"type": "string",
|
|
208
|
+
"description": "The PKCE code challenge method (RFC 7636). When set, the OAuth2 flow will use Proof Key for Code Exchange.",
|
|
209
|
+
"enum": ["S256", "plain"],
|
|
210
|
+
"tsEnumNames": ["S256", "PLAIN"]
|
|
205
211
|
}
|
|
206
212
|
}
|
|
207
213
|
}
|
|
@@ -30,7 +30,6 @@ class Oauth2 extends core_1.Command {
|
|
|
30
30
|
description: 'the environment of the platform',
|
|
31
31
|
options: Object.values(globalConfiguration_1.Environment),
|
|
32
32
|
default: globalConfiguration_1.Environment.Production,
|
|
33
|
-
hidden: true,
|
|
34
33
|
})(),
|
|
35
34
|
reauth: core_1.Flags.boolean({
|
|
36
35
|
description: `triggers a new oauth2 flow to overwrite the test account's current credentials`,
|
|
@@ -28,9 +28,8 @@ class Publish extends baseCommand_1.BaseCommand {
|
|
|
28
28
|
default: GlobalConfiguration.Environment.Production,
|
|
29
29
|
})(),
|
|
30
30
|
'registry-only': core_1.Flags.boolean({
|
|
31
|
-
description: '
|
|
31
|
+
description: 'only update the registry without publishing code',
|
|
32
32
|
default: false,
|
|
33
|
-
hidden: true,
|
|
34
33
|
exclusive: ['preview', 'live-preview'],
|
|
35
34
|
}),
|
|
36
35
|
preview: core_1.Flags.boolean({
|
|
@@ -53,7 +53,8 @@ class Upgrade extends baseCommand_1.BaseCommand {
|
|
|
53
53
|
//
|
|
54
54
|
core_1.ux.action.start(`${latestVersion ? 'Validating' : 'Upgrading'} the package`, undefined, { stdout: true });
|
|
55
55
|
// To make sure we have a clean version of the CLI, with updated packages, we force the re-installation.
|
|
56
|
-
|
|
56
|
+
// --min-release-age=0 bypasses the npm release age safeguard for this internal trusted package.
|
|
57
|
+
child_process_1.default.execSync(`npm install --force --min-release-age=0 ${npmOptions} ${packageName}`, {
|
|
57
58
|
...execOptions,
|
|
58
59
|
stdio: ['ignore', 'ignore', 'inherit'],
|
|
59
60
|
});
|
|
@@ -168,6 +168,7 @@ export interface Oauth2 {
|
|
|
168
168
|
refreshRequestParameters?: AuthorizationRequestParameters;
|
|
169
169
|
requestContentType?: RequestContentType;
|
|
170
170
|
responseContentType?: RequestContentType;
|
|
171
|
+
pkceCodeChallengeMethod?: PkceCodeChallengeMethod;
|
|
171
172
|
}
|
|
172
173
|
/**
|
|
173
174
|
* The extra information used to communicate with the provider
|
|
@@ -209,6 +210,13 @@ export declare enum RequestContentType {
|
|
|
209
210
|
URL_ENCODED = "application/x-www-form-urlencoded",
|
|
210
211
|
JSON = "application/json"
|
|
211
212
|
}
|
|
213
|
+
/**
|
|
214
|
+
* The PKCE code challenge method (RFC 7636). When set, the OAuth2 flow will use Proof Key for Code Exchange.
|
|
215
|
+
*/
|
|
216
|
+
export declare enum PkceCodeChallengeMethod {
|
|
217
|
+
S256 = "S256",
|
|
218
|
+
PLAIN = "plain"
|
|
219
|
+
}
|
|
212
220
|
export declare enum Type {
|
|
213
221
|
BOOLEAN = "boolean",
|
|
214
222
|
NUMBER = "number",
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* and run json-schema-to-typescript to regenerate this file.
|
|
7
7
|
*/
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
-
exports.Format = exports.Type = exports.RequestContentType = exports.GrantType = exports.Method = void 0;
|
|
9
|
+
exports.Format = exports.Type = exports.PkceCodeChallengeMethod = exports.RequestContentType = exports.GrantType = exports.Method = void 0;
|
|
10
10
|
/**
|
|
11
11
|
* The method of authorization
|
|
12
12
|
*/
|
|
@@ -33,6 +33,14 @@ var RequestContentType;
|
|
|
33
33
|
RequestContentType["URL_ENCODED"] = "application/x-www-form-urlencoded";
|
|
34
34
|
RequestContentType["JSON"] = "application/json";
|
|
35
35
|
})(RequestContentType || (exports.RequestContentType = RequestContentType = {}));
|
|
36
|
+
/**
|
|
37
|
+
* The PKCE code challenge method (RFC 7636). When set, the OAuth2 flow will use Proof Key for Code Exchange.
|
|
38
|
+
*/
|
|
39
|
+
var PkceCodeChallengeMethod;
|
|
40
|
+
(function (PkceCodeChallengeMethod) {
|
|
41
|
+
PkceCodeChallengeMethod["S256"] = "S256";
|
|
42
|
+
PkceCodeChallengeMethod["PLAIN"] = "plain";
|
|
43
|
+
})(PkceCodeChallengeMethod || (exports.PkceCodeChallengeMethod = PkceCodeChallengeMethod = {}));
|
|
36
44
|
var Type;
|
|
37
45
|
(function (Type) {
|
|
38
46
|
Type["BOOLEAN"] = "boolean";
|
|
@@ -41,6 +41,8 @@ declare class OAuth2Service {
|
|
|
41
41
|
private refreshRequestParameters;
|
|
42
42
|
private credentialPayload;
|
|
43
43
|
private legacyRedirectUrl;
|
|
44
|
+
private pkceCodeChallengeMethod;
|
|
45
|
+
private codeVerifier;
|
|
44
46
|
/**
|
|
45
47
|
* Constructs an instance of OAuthHelper.
|
|
46
48
|
* @param clientId The client ID for your OAuth application.
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.HTML_SUCCESS_MSG = exports.HTML_ERROR_MSG = exports.open = void 0;
|
|
4
4
|
const tslib_1 = require("tslib");
|
|
5
|
+
const node_crypto_1 = tslib_1.__importDefault(require("node:crypto"));
|
|
5
6
|
const express_1 = tslib_1.__importDefault(require("express"));
|
|
6
7
|
const cors_1 = tslib_1.__importDefault(require("cors"));
|
|
7
8
|
const openUrl = tslib_1.__importStar(require("openurl"));
|
|
@@ -33,6 +34,8 @@ class OAuth2Service {
|
|
|
33
34
|
refreshRequestParameters;
|
|
34
35
|
credentialPayload;
|
|
35
36
|
legacyRedirectUrl;
|
|
37
|
+
pkceCodeChallengeMethod;
|
|
38
|
+
codeVerifier;
|
|
36
39
|
/**
|
|
37
40
|
* Constructs an instance of OAuthHelper.
|
|
38
41
|
* @param clientId The client ID for your OAuth application.
|
|
@@ -42,7 +45,7 @@ class OAuth2Service {
|
|
|
42
45
|
* @param providerTokenUrl The URL for the token endpoint of the provider.
|
|
43
46
|
*/
|
|
44
47
|
constructor(authorizationInfo, environment = globalConfiguration_1.Environment.Production, credentialPayload) {
|
|
45
|
-
const { clientId, clientSecret, authorizationUrl, scopes, tokenUrl, grantType, requestContentType, refreshRequestParameters, tokenRequestParameters, legacyRedirectUrl, } = authorizationInfo;
|
|
48
|
+
const { clientId, clientSecret, authorizationUrl, scopes, tokenUrl, grantType, requestContentType, refreshRequestParameters, tokenRequestParameters, legacyRedirectUrl, pkceCodeChallengeMethod, } = authorizationInfo;
|
|
46
49
|
this.clientId = clientId;
|
|
47
50
|
this.clientSecret = clientSecret;
|
|
48
51
|
this.providerAuthorizationUrl = authorizationUrl;
|
|
@@ -53,6 +56,7 @@ class OAuth2Service {
|
|
|
53
56
|
this.tokenRequestParameters = tokenRequestParameters;
|
|
54
57
|
this.refreshRequestParameters = refreshRequestParameters;
|
|
55
58
|
this.legacyRedirectUrl = legacyRedirectUrl;
|
|
59
|
+
this.pkceCodeChallengeMethod = pkceCodeChallengeMethod;
|
|
56
60
|
this.environment = environment;
|
|
57
61
|
this.credentialPayload = credentialPayload;
|
|
58
62
|
if (!Object.values(configurationTypes_1.RequestContentType).includes(this.requestContentType)) {
|
|
@@ -94,6 +98,14 @@ class OAuth2Service {
|
|
|
94
98
|
authorizationParams.set('state', state);
|
|
95
99
|
authorizationParams.set('response_type', 'code');
|
|
96
100
|
authorizationParams.set('redirect_uri', this.redirectUri);
|
|
101
|
+
if (this.grantType === configurationTypes_1.GrantType.AUTHORIZATION_CODE && this.pkceCodeChallengeMethod) {
|
|
102
|
+
this.codeVerifier = node_crypto_1.default.randomBytes(32).toString('base64url');
|
|
103
|
+
const codeChallenge = this.pkceCodeChallengeMethod === 'S256'
|
|
104
|
+
? node_crypto_1.default.createHash('sha256').update(this.codeVerifier).digest('base64url')
|
|
105
|
+
: this.codeVerifier;
|
|
106
|
+
authorizationParams.set('code_challenge', codeChallenge);
|
|
107
|
+
authorizationParams.set('code_challenge_method', this.pkceCodeChallengeMethod);
|
|
108
|
+
}
|
|
97
109
|
const delimiter = this.providerAuthorizationUrl.includes('?') ? '&' : '?';
|
|
98
110
|
const authorizationUrlTemplate = `${this.providerAuthorizationUrl}${delimiter}${authorizationParams.toString()}`;
|
|
99
111
|
const authorizationUrl = (0, template_1.expandTemplate)(authorizationUrlTemplate, {
|
|
@@ -139,6 +151,7 @@ class OAuth2Service {
|
|
|
139
151
|
redirect_uri: this.redirectUri,
|
|
140
152
|
...(this.clientId && { client_id: this.clientId }),
|
|
141
153
|
...(this.clientSecret && { client_secret: this.clientSecret }),
|
|
154
|
+
...(this.grantType === configurationTypes_1.GrantType.AUTHORIZATION_CODE && this.codeVerifier && { code_verifier: this.codeVerifier }),
|
|
142
155
|
};
|
|
143
156
|
if (this.grantType === configurationTypes_1.GrantType.JWT_BEARER && this.clientSecret) {
|
|
144
157
|
tokenRequestPayload.client_assertion = this.clientSecret;
|
|
@@ -73,6 +73,57 @@ describe('OAuth2Helper', () => {
|
|
|
73
73
|
sinon_1.default.assert.calledOnce(openSpy);
|
|
74
74
|
sinon_1.default.assert.calledWith(openSpy, 'https://pro-fooValue-der.com/oauth/authorize?query1=barValue&client_id=your-client-id&scope=scope1+scope2&state=eyJjbGlDYWxsYmFja1VybCI6Ii9vYXV0aDIvY2FsbGJhY2sifQ%3D%3D&response_type=code&redirect_uri=https%3A%2F%2Fintegrations-platform.unito.io%2Fcredentials%2Fnew%2Foauth2%2Fcallback');
|
|
75
75
|
});
|
|
76
|
+
describe('with pkceCodeChallengeMethod', () => {
|
|
77
|
+
it('adds code_challenge and code_challenge_method to authorization URL for S256', async () => {
|
|
78
|
+
const authorizationInfo = {
|
|
79
|
+
clientId: 'your-client-id',
|
|
80
|
+
clientSecret: 'your-client-secret',
|
|
81
|
+
authorizationUrl: 'https://provider.com/oauth/authorize',
|
|
82
|
+
scopes: [{ name: 'scope1' }],
|
|
83
|
+
tokenUrl: 'https://provider.com/oauth/token',
|
|
84
|
+
grantType: configurationTypes_1.GrantType.AUTHORIZATION_CODE,
|
|
85
|
+
requestContentType: configurationTypes_1.RequestContentType.URL_ENCODED,
|
|
86
|
+
pkceCodeChallengeMethod: configurationTypes_1.PkceCodeChallengeMethod.S256,
|
|
87
|
+
};
|
|
88
|
+
const pkceHelper = new oauth2_1.default(authorizationInfo, globalConfiguration_1.Environment.Production);
|
|
89
|
+
sinon_1.default.stub(pkceHelper, 'startServer').resolves('http://localhost:5050');
|
|
90
|
+
sinon_1.default.stub(pkceHelper, 'stopServer');
|
|
91
|
+
await pkceHelper.authorize();
|
|
92
|
+
sinon_1.default.assert.calledOnce(openSpy);
|
|
93
|
+
const url = new URL(openSpy.getCall(0).args[0]);
|
|
94
|
+
strict_1.default.equal(url.searchParams.get('code_challenge_method'), 'S256');
|
|
95
|
+
strict_1.default.ok(url.searchParams.get('code_challenge'), 'code_challenge should be present');
|
|
96
|
+
strict_1.default.ok(url.searchParams.get('code_challenge').length > 0, 'code_challenge should not be empty');
|
|
97
|
+
});
|
|
98
|
+
it('uses verifier directly as challenge for plain method', async () => {
|
|
99
|
+
const authorizationInfo = {
|
|
100
|
+
clientId: 'your-client-id',
|
|
101
|
+
clientSecret: 'your-client-secret',
|
|
102
|
+
authorizationUrl: 'https://provider.com/oauth/authorize',
|
|
103
|
+
scopes: [{ name: 'scope1' }],
|
|
104
|
+
tokenUrl: 'https://provider.com/oauth/token',
|
|
105
|
+
grantType: configurationTypes_1.GrantType.AUTHORIZATION_CODE,
|
|
106
|
+
requestContentType: configurationTypes_1.RequestContentType.URL_ENCODED,
|
|
107
|
+
pkceCodeChallengeMethod: configurationTypes_1.PkceCodeChallengeMethod.PLAIN,
|
|
108
|
+
};
|
|
109
|
+
const pkceHelper = new oauth2_1.default(authorizationInfo, globalConfiguration_1.Environment.Production);
|
|
110
|
+
sinon_1.default.stub(pkceHelper, 'startServer').resolves('http://localhost:5050');
|
|
111
|
+
sinon_1.default.stub(pkceHelper, 'stopServer');
|
|
112
|
+
await pkceHelper.authorize();
|
|
113
|
+
sinon_1.default.assert.calledOnce(openSpy);
|
|
114
|
+
const url = new URL(openSpy.getCall(0).args[0]);
|
|
115
|
+
strict_1.default.equal(url.searchParams.get('code_challenge_method'), 'plain');
|
|
116
|
+
// For plain method, the code_verifier stored on the instance should equal the code_challenge
|
|
117
|
+
strict_1.default.equal(url.searchParams.get('code_challenge'), pkceHelper['codeVerifier']);
|
|
118
|
+
});
|
|
119
|
+
it('does not add PKCE params when codeChallengeMethod is not set', async () => {
|
|
120
|
+
await oauth2Helper.authorize();
|
|
121
|
+
sinon_1.default.assert.calledOnce(openSpy);
|
|
122
|
+
const url = new URL(openSpy.getCall(0).args[0]);
|
|
123
|
+
strict_1.default.equal(url.searchParams.get('code_challenge'), null);
|
|
124
|
+
strict_1.default.equal(url.searchParams.get('code_challenge_method'), null);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
76
127
|
describe('with legacyRedirectUrl', () => {
|
|
77
128
|
it('uses legacyRedirectUrl as redirect_uri and adds redirectToIPS to state', async () => {
|
|
78
129
|
oauth2Helper['legacyRedirectUrl'] = 'http://localhost:3000/api/auth/notion/callback';
|
|
@@ -318,6 +369,43 @@ describe('OAuth2Helper', () => {
|
|
|
318
369
|
sinon_1.default.assert.calledOnce(res.send);
|
|
319
370
|
sinon_1.default.assert.calledWith(res.send, oauth2Namespace.HTML_ERROR_MSG('Bad request'));
|
|
320
371
|
});
|
|
372
|
+
describe('with PKCE', () => {
|
|
373
|
+
it('includes code_verifier in token request body when codeChallengeMethod is set', async () => {
|
|
374
|
+
const authorizationInfo = {
|
|
375
|
+
clientId: 'your-client-id',
|
|
376
|
+
clientSecret: 'your-client-secret',
|
|
377
|
+
authorizationUrl: 'https://provider.com/oauth/authorize',
|
|
378
|
+
scopes: [{ name: 'scope1' }],
|
|
379
|
+
tokenUrl: 'https://provider.com/oauth/token',
|
|
380
|
+
grantType: configurationTypes_1.GrantType.AUTHORIZATION_CODE,
|
|
381
|
+
requestContentType: configurationTypes_1.RequestContentType.URL_ENCODED,
|
|
382
|
+
pkceCodeChallengeMethod: configurationTypes_1.PkceCodeChallengeMethod.S256,
|
|
383
|
+
};
|
|
384
|
+
const pkceHelper = new oauth2_1.default(authorizationInfo, globalConfiguration_1.Environment.Production);
|
|
385
|
+
sinon_1.default.stub(pkceHelper, 'startServer').resolves('http://localhost:5050');
|
|
386
|
+
sinon_1.default.stub(pkceHelper, 'stopServer');
|
|
387
|
+
// authorize() generates the code_verifier
|
|
388
|
+
await pkceHelper.authorize();
|
|
389
|
+
const storedVerifier = pkceHelper['codeVerifier'];
|
|
390
|
+
strict_1.default.ok(storedVerifier, 'codeVerifier should be set after authorize()');
|
|
391
|
+
const code = 'test-code';
|
|
392
|
+
const req = { query: { code } };
|
|
393
|
+
const res = { send: sinon_1.default.stub(), setHeader: sinon_1.default.stub() };
|
|
394
|
+
await pkceHelper['handleCallback'](req, res);
|
|
395
|
+
sinon_1.default.assert.calledOnce(fetchStub);
|
|
396
|
+
const body = new URLSearchParams(fetchStub.getCall(0).args[1].body);
|
|
397
|
+
strict_1.default.equal(body.get('code_verifier'), storedVerifier);
|
|
398
|
+
});
|
|
399
|
+
it('does not include code_verifier when codeChallengeMethod is not set', async () => {
|
|
400
|
+
const code = 'test-code';
|
|
401
|
+
const req = { query: { code } };
|
|
402
|
+
const res = { send: sinon_1.default.stub(), setHeader: sinon_1.default.stub() };
|
|
403
|
+
await oauth2Helper['handleCallback'](req, res);
|
|
404
|
+
sinon_1.default.assert.calledOnce(fetchStub);
|
|
405
|
+
const body = new URLSearchParams(fetchStub.getCall(0).args[1].body);
|
|
406
|
+
strict_1.default.equal(body.get('code_verifier'), null);
|
|
407
|
+
});
|
|
408
|
+
});
|
|
321
409
|
describe('with legacyRedirectUrl', () => {
|
|
322
410
|
it('uses legacyRedirectUrl as redirect_uri in token request', async () => {
|
|
323
411
|
oauth2Helper['legacyRedirectUrl'] = 'http://localhost:3000/api/auth/notion/callback';
|
package/oclif.manifest.json
CHANGED
|
@@ -562,7 +562,6 @@
|
|
|
562
562
|
},
|
|
563
563
|
"environment": {
|
|
564
564
|
"description": "the environment of the platform",
|
|
565
|
-
"hidden": true,
|
|
566
565
|
"name": "environment",
|
|
567
566
|
"default": "production",
|
|
568
567
|
"hasDynamicHelp": false,
|
|
@@ -635,12 +634,11 @@
|
|
|
635
634
|
"type": "option"
|
|
636
635
|
},
|
|
637
636
|
"registry-only": {
|
|
638
|
-
"description": "
|
|
637
|
+
"description": "only update the registry without publishing code",
|
|
639
638
|
"exclusive": [
|
|
640
639
|
"preview",
|
|
641
640
|
"live-preview"
|
|
642
641
|
],
|
|
643
|
-
"hidden": true,
|
|
644
642
|
"name": "registry-only",
|
|
645
643
|
"allowNo": false,
|
|
646
644
|
"type": "boolean"
|
|
@@ -1007,5 +1005,5 @@
|
|
|
1007
1005
|
]
|
|
1008
1006
|
}
|
|
1009
1007
|
},
|
|
1010
|
-
"version": "1.1
|
|
1008
|
+
"version": "1.2.1"
|
|
1011
1009
|
}
|