publish #2
1
AUTHORS
1
AUTHORS
|
@ -1 +1,2 @@
|
|||
AJ ONeal <coolaj86@gmail.com> (https://coolaj86.com/)
|
||||
Hitesh Walia <hiteshwar.walia@gmail.com>
|
||||
|
|
108
README.md
108
README.md
|
@ -1,3 +1,111 @@
|
|||
# acme-dns-01-route53
|
||||
|
||||
Amazon AWS Route53 DNS + Let's Encrypt for Node.js - ACME dns-01 challenges w/ ACME.js and Greenlock.js
|
||||
|
||||
|
||||
# Features
|
||||
|
||||
- Compatible
|
||||
- Let’s Encrypt v2.1 / ACME draft 18 (2019)
|
||||
- DNSimple v2 API
|
||||
- ACME.js, Greenlock.js, and others
|
||||
- Quality
|
||||
- node v6 compatible VanillaJS
|
||||
|
||||
# Install
|
||||
|
||||
```js
|
||||
npm install --save acme-dns-01-route53
|
||||
```
|
||||
|
||||
AWS API keys:
|
||||
|
||||
- Login to your account at: https://console.aws.amazon.com
|
||||
- Go to Services > IAM > Users, create a new user and assign AmazonRoute53FullAccess policy.
|
||||
- Click *Securty credentials* tab and then click *Create access key*.
|
||||
|
||||
# Usage
|
||||
|
||||
First you create an instance with your credentials:
|
||||
|
||||
```js
|
||||
var dns01 = require('acme-dns-01-route53').create({
|
||||
key: 'your_key',
|
||||
secret: 'your_secret'
|
||||
});
|
||||
```
|
||||
|
||||
Then you can use it with any compatible ACME library, such as Greenlock.js or ACME.js.
|
||||
|
||||
## Greenlock.js
|
||||
|
||||
```js
|
||||
var Greenlock = require('greenlock-express');
|
||||
var greenlock = Greenlock.create({
|
||||
challenges: {
|
||||
'dns-01': dns01
|
||||
// ...
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
See [Greenlock Express](https://git.rootprojects.org/root/greenlock-express.js) and/or [Greenlock.js](https://git.rootprojects.org/root/greenlock.js) documentation for more details.
|
||||
|
||||
## ACME.js
|
||||
|
||||
```js
|
||||
// TODO
|
||||
```
|
||||
|
||||
See the [ACME.js](https://git.rootprojects.org/root/acme-v2.js) for more details.
|
||||
|
||||
## Build your own
|
||||
|
||||
There are only 5 methods:
|
||||
|
||||
- `init(config)`
|
||||
- `zones(opts)`
|
||||
- `set(opts)`
|
||||
- `get(opts)`
|
||||
- `remove(opts)`
|
||||
|
||||
```js
|
||||
dns01
|
||||
.set({
|
||||
identifier: { value: 'foo.example.co.uk' },
|
||||
wildcard: false,
|
||||
dnsZone: 'example.co.uk',
|
||||
dnsPrefix: '_acme-challenge.foo',
|
||||
dnsAuthorization: 'xxx_secret_xxx'
|
||||
})
|
||||
.then(function() {
|
||||
console.log('TXT record set');
|
||||
})
|
||||
.catch(function() {
|
||||
console.log('Failed to set TXT record');
|
||||
});
|
||||
```
|
||||
|
||||
See acme-dns-01-test for more implementation details.
|
||||
|
||||
# Tests
|
||||
|
||||
```bash
|
||||
# node ./test.js domain-zone key secret
|
||||
node ./test.js example.com xxxxx yyyyy
|
||||
```
|
||||
|
||||
# Authors
|
||||
|
||||
- AJ ONeal
|
||||
- Hitesh Walia
|
||||
|
||||
See AUTHORS for contact info.
|
||||
|
||||
# Legal
|
||||
|
||||
[acme-dns-01-dnsimple.js](https://git.coolaj86.com/coolaj86/acme-dns-01-dnsimple.js) | MPL-2.0 | [Terms of Use](https://therootcompany.com/legal/#terms) | [Privacy Policy](https://therootcompany.com/legal/#privacy)
|
||||
|
||||
Copyright 2019 Hitesh Walia
|
||||
Copyright 2019 AJ ONeal
|
||||
Copyright 2019 The Root Group LLC
|
||||
|
|
452
lib/index.js
452
lib/index.js
|
@ -5,14 +5,66 @@ var xml2js = require('xml2js');
|
|||
var parseString = xml2js.parseString;
|
||||
parseString = require('util').promisify(parseString);
|
||||
var builder = new xml2js.Builder();
|
||||
|
||||
var request = require('@root/request');
|
||||
//request.debug = false;
|
||||
var request;
|
||||
|
||||
var defaults = {
|
||||
baseUri: '/2013-04-01/'
|
||||
};
|
||||
|
||||
function setWeightedPolicy(XmlObject, isWildcard) {
|
||||
if (isWildcard) {
|
||||
XmlObject.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].ResourceRecordSet[0].SetIdentifier = [
|
||||
'wildcard'
|
||||
];
|
||||
} else {
|
||||
XmlObject.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].ResourceRecordSet[0].SetIdentifier = [
|
||||
'subdomain'
|
||||
];
|
||||
}
|
||||
|
||||
XmlObject.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].ResourceRecordSet[0].Weight = [
|
||||
1
|
||||
];
|
||||
}
|
||||
|
||||
// used to build XML payload
|
||||
var XmlParameters = {
|
||||
ChangeResourceRecordSetsRequest: {
|
||||
$: {
|
||||
xmlns: ''
|
||||
},
|
||||
ChangeBatch: [
|
||||
{
|
||||
Changes: [
|
||||
{
|
||||
Change: [
|
||||
{
|
||||
Action: [''],
|
||||
ResourceRecordSet: [
|
||||
{
|
||||
Type: ['TXT'],
|
||||
Name: [''],
|
||||
ResourceRecords: [
|
||||
{
|
||||
ResourceRecord: [
|
||||
{
|
||||
Value: ['']
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
TTL: [300]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
module.exports.create = function(config) {
|
||||
var baseUri = defaults.baseUri.replace(/\/$/, '');
|
||||
var key = config.key;
|
||||
|
@ -26,28 +78,29 @@ module.exports.create = function(config) {
|
|||
secret: secret,
|
||||
query: query
|
||||
};
|
||||
|
||||
if(method === 'POST') {
|
||||
options.headers = { 'Content-Type': 'application/xml' }
|
||||
}
|
||||
|
||||
var url = v4.createPresignedURL(method, 'route53.amazonaws.com', path, 'route53', body, options);
|
||||
//console.log('url: ' + url + '\n');
|
||||
|
||||
var parameters = {
|
||||
method: method,
|
||||
url: url
|
||||
method: method
|
||||
};
|
||||
|
||||
if(method === 'POST') {
|
||||
parameters.headers = { 'Content-Type': 'application/xml' }
|
||||
if (method === 'POST') {
|
||||
options.headers = { 'Content-Type': 'application/xml' };
|
||||
parameters.headers = { 'Content-Type': 'application/xml' };
|
||||
}
|
||||
|
||||
if(body) {
|
||||
if (body) {
|
||||
parameters.body = body;
|
||||
}
|
||||
|
||||
// console.log(method, path, query);
|
||||
var url = v4.createPresignedURL(
|
||||
method,
|
||||
'route53.amazonaws.com',
|
||||
path,
|
||||
'route53',
|
||||
body,
|
||||
options
|
||||
);
|
||||
|
||||
parameters.url = url;
|
||||
|
||||
return request(parameters).then(function(response) {
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
|
@ -67,263 +120,174 @@ module.exports.create = function(config) {
|
|||
});
|
||||
}
|
||||
|
||||
/*
|
||||
api('GET', baseUri + '/' + 'hostedzone').then(function(response){
|
||||
console.log('######## RESPONSE #########');
|
||||
|
||||
});
|
||||
*/
|
||||
|
||||
return {
|
||||
init: function(options) {
|
||||
request = options.request;
|
||||
return null;
|
||||
},
|
||||
zones: function(data) {
|
||||
//console.info('List Zones', data);
|
||||
return api('GET', baseUri + '/' + 'hostedzone').then(function(response){
|
||||
return parseString(response.body).then(function(body){
|
||||
var zones = body.ListHostedZonesResponse.HostedZones[0].HostedZone.map(function(element) {
|
||||
return element.Name[0].replace(/\.$/, ''); // all route 53 domains terminate with a dot
|
||||
});
|
||||
return api('GET', baseUri + '/' + 'hostedzone').then(function(
|
||||
response
|
||||
) {
|
||||
return parseString(response.body).then(function(body) {
|
||||
var zones = body.ListHostedZonesResponse.HostedZones[0].HostedZone.map(
|
||||
function(element) {
|
||||
return element.Name[0].replace(/\.$/, ''); // all route 53 domains terminate with a dot
|
||||
}
|
||||
);
|
||||
|
||||
return zones;
|
||||
});
|
||||
});
|
||||
|
||||
},
|
||||
set: function(data) {
|
||||
// console.info('Add TXT', data);
|
||||
// console.log('data: ' + JSON.stringify(data, null, 2));
|
||||
var challenge = data.challenge;
|
||||
|
||||
if (!challenge.dnsZone) {
|
||||
throw new Error('No matching zone for ' + challenge.dnsHost);
|
||||
}
|
||||
|
||||
return api('GET', baseUri + '/' + 'hostedzonesbyname', null, 'dnsname=' + challenge.dnsZone + '&maxitems=1')
|
||||
.then(function(response){
|
||||
return parseString(response.body).then(function(body){
|
||||
return body;
|
||||
});
|
||||
})
|
||||
.then(function(body){
|
||||
var zoneID = body.ListHostedZonesByNameResponse.HostedZones[0].HostedZone[0].Id[0];
|
||||
var xmlns = body.ListHostedZonesByNameResponse.$.xmlns;
|
||||
return api(
|
||||
'GET',
|
||||
baseUri + '/' + 'hostedzonesbyname',
|
||||
null,
|
||||
'dnsname=' + challenge.dnsZone + '&maxitems=1'
|
||||
)
|
||||
.then(function(response) {
|
||||
return parseString(response.body).then(function(body) {
|
||||
return body;
|
||||
});
|
||||
})
|
||||
.then(function(body) {
|
||||
var zoneID =
|
||||
body.ListHostedZonesByNameResponse.HostedZones[0]
|
||||
.HostedZone[0].Id[0];
|
||||
var xmlns = body.ListHostedZonesByNameResponse.$.xmlns;
|
||||
|
||||
var parameters = {
|
||||
'ChangeResourceRecordSetsRequest': {
|
||||
'$': {
|
||||
'xmlns': xmlns
|
||||
},
|
||||
'ChangeBatch': [
|
||||
{
|
||||
'Changes': [
|
||||
{
|
||||
'Change': [
|
||||
{
|
||||
'Action': [
|
||||
'CREATE'
|
||||
],
|
||||
'ResourceRecordSet': [
|
||||
{
|
||||
'Type': [
|
||||
'TXT'
|
||||
],
|
||||
'Name': [
|
||||
challenge.dnsHost // AWS requires FQDN
|
||||
],
|
||||
'ResourceRecords': [
|
||||
{
|
||||
'ResourceRecord': [
|
||||
{
|
||||
'Value': [
|
||||
'"' + challenge.dnsAuthorization + '"' // value must be surrounded by double quotes
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
'TTL': [
|
||||
300
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
// By default AWS creates record sets with simple routing policy, so duplicates are not allowed.
|
||||
// A workaround is put the record sets in different weighted policies.
|
||||
if(challenge.identifier.value.startsWith('foo')) {
|
||||
if(challenge.wildcard) {
|
||||
|
||||
parameters.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].ResourceRecordSet[0].Weight = [
|
||||
1
|
||||
];
|
||||
|
||||
parameters.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].ResourceRecordSet[0].SetIdentifier = [
|
||||
'wildcard'
|
||||
];
|
||||
} else {
|
||||
parameters.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].ResourceRecordSet[0].Weight = [
|
||||
1
|
||||
];
|
||||
|
||||
parameters.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].ResourceRecordSet[0].SetIdentifier = [
|
||||
'subdomain'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
var xml = builder.buildObject(parameters);
|
||||
//console.log('xml: ' + xml);
|
||||
|
||||
return api('POST', baseUri + zoneID + '/rrset/', xml);
|
||||
|
||||
});
|
||||
var parameters = JSON.parse(JSON.stringify(XmlParameters));
|
||||
parameters.ChangeResourceRecordSetsRequest.$.xmlns = xmlns;
|
||||
parameters.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].Action[0] =
|
||||
'CREATE';
|
||||
parameters.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].ResourceRecordSet[0].Name[0] =
|
||||
challenge.dnsHost; // AWS requires FQDN
|
||||
parameters.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].ResourceRecordSet[0].ResourceRecords[0].ResourceRecord[0].Value[0] =
|
||||
'"' + challenge.dnsAuthorization + '"'; // value must be surrounded by double quotes
|
||||
|
||||
// By default AWS creates record sets with simple routing policy, so duplicates are not allowed.
|
||||
// A workaround is put the record sets in different weighted policies.
|
||||
if (challenge.identifier.value.startsWith('foo')) {
|
||||
setWeightedPolicy(parameters, challenge.wildcard);
|
||||
}
|
||||
|
||||
var xml = builder.buildObject(parameters);
|
||||
return api('POST', baseUri + zoneID + '/rrset/', xml);
|
||||
});
|
||||
},
|
||||
get: function(data) {
|
||||
// console.info('List TXT', data);
|
||||
var challenge = data.challenge;
|
||||
|
||||
return api('GET', baseUri + '/' + 'hostedzonesbyname', null, 'dnsname=' + challenge.dnsZone + '&maxitems=1')
|
||||
.then(function(response){
|
||||
return parseString(response.body).then(function(body){
|
||||
return body;
|
||||
});
|
||||
})
|
||||
.then(function(body){
|
||||
var zoneID = body.ListHostedZonesByNameResponse.HostedZones[0].HostedZone[0].Id[0];
|
||||
// console.log('zoneID: ' + zoneID);
|
||||
return zoneID;
|
||||
})
|
||||
.then(function(zoneID){
|
||||
// GET /2013-04-01/hostedzone/Id/rrset?identifier=StartRecordIdentifier&maxitems=MaxItems&name=StartRecordName&type=StartRecordType HTTP/1.1
|
||||
|
||||
return api('GET', baseUri + '/' + zoneID + '/rrset', null, 'name=' + challenge.dnsPrefix + '.' + challenge.dnsZone + '.' + '&type=TXT')
|
||||
.then(function(response){
|
||||
return parseString(response.body).then(function(body){
|
||||
if (body.ListResourceRecordSetsResponse.ResourceRecordSets[0] === '') return null;
|
||||
|
||||
var record = body.ListResourceRecordSetsResponse.ResourceRecordSets[0].ResourceRecordSet.filter(function(element){
|
||||
return (
|
||||
element.ResourceRecords[0].ResourceRecord[0].Value[0].replace(/\"/g, '') === challenge.dnsAuthorization &&
|
||||
element.Name[0].includes(challenge.dnsPrefix)
|
||||
);
|
||||
})[0];
|
||||
|
||||
if (record) {
|
||||
return { dnsAuthorization: record.ResourceRecords[0].ResourceRecord[0].Value[0].replace(/\"/g, '') };
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
},
|
||||
remove: function(data) {
|
||||
// console.info('Remove TXT', data);
|
||||
|
||||
var challenge = data.challenge;
|
||||
|
||||
return api('GET', baseUri + '/' + 'hostedzonesbyname', null, 'dnsname=' + challenge.dnsZone + '&maxitems=1')
|
||||
.then(function(response){
|
||||
return parseString(response.body).then(function(body){
|
||||
return body;
|
||||
});
|
||||
})
|
||||
.then(function(body){
|
||||
var zoneID = body.ListHostedZonesByNameResponse.HostedZones[0].HostedZone[0].Id[0];
|
||||
var xmlns = body.ListHostedZonesByNameResponse.$.xmlns;
|
||||
|
||||
var parameters = {
|
||||
'ChangeResourceRecordSetsRequest': {
|
||||
'$': {
|
||||
'xmlns': xmlns
|
||||
},
|
||||
'ChangeBatch': [
|
||||
{
|
||||
'Changes': [
|
||||
{
|
||||
'Change': [
|
||||
{
|
||||
'Action': [
|
||||
'DELETE'
|
||||
],
|
||||
'ResourceRecordSet': [
|
||||
{
|
||||
'Type': [
|
||||
'TXT'
|
||||
],
|
||||
'Name': [
|
||||
challenge.dnsHost // AWS requires FQDN
|
||||
],
|
||||
'ResourceRecords': [
|
||||
{
|
||||
'ResourceRecord': [
|
||||
{
|
||||
'Value': [
|
||||
'"' + challenge.dnsAuthorization + '"' // value must be surrounded by double quotes
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
'TTL': [
|
||||
300
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
if(challenge.identifier.value.startsWith('foo')) {
|
||||
if(challenge.wildcard) {
|
||||
parameters.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].ResourceRecordSet[0].Weight = [
|
||||
1
|
||||
];
|
||||
|
||||
parameters.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].ResourceRecordSet[0].SetIdentifier = [
|
||||
'wildcard'
|
||||
];
|
||||
} else {
|
||||
parameters.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].ResourceRecordSet[0].Weight = [
|
||||
1
|
||||
];
|
||||
|
||||
parameters.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].ResourceRecordSet[0].SetIdentifier = [
|
||||
'subdomain'
|
||||
];
|
||||
}
|
||||
return api(
|
||||
'GET',
|
||||
baseUri + '/' + 'hostedzonesbyname',
|
||||
null,
|
||||
'dnsname=' + challenge.dnsZone + '&maxitems=1'
|
||||
)
|
||||
.then(function(response) {
|
||||
return parseString(response.body).then(function(body) {
|
||||
return body;
|
||||
});
|
||||
})
|
||||
.then(function(body) {
|
||||
var zoneID =
|
||||
body.ListHostedZonesByNameResponse.HostedZones[0]
|
||||
.HostedZone[0].Id[0];
|
||||
return zoneID;
|
||||
})
|
||||
.then(function(zoneID) {
|
||||
return api(
|
||||
'GET',
|
||||
baseUri + '/' + zoneID + '/rrset',
|
||||
null,
|
||||
'name=' +
|
||||
challenge.dnsPrefix +
|
||||
'.' +
|
||||
challenge.dnsZone +
|
||||
'.' +
|
||||
'&type=TXT'
|
||||
).then(function(response) {
|
||||
return parseString(response.body).then(function(body) {
|
||||
if (
|
||||
body.ListResourceRecordSetsResponse
|
||||
.ResourceRecordSets[0] === ''
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var xml = builder.buildObject(parameters);
|
||||
// console.log('\n', xml, '\n');
|
||||
var record = body.ListResourceRecordSetsResponse.ResourceRecordSets[0].ResourceRecordSet.filter(
|
||||
function(element) {
|
||||
return (
|
||||
element.ResourceRecords[0].ResourceRecord[0].Value[0].replace(
|
||||
/\"/g,
|
||||
''
|
||||
) === challenge.dnsAuthorization &&
|
||||
element.Name[0].includes(
|
||||
challenge.dnsPrefix
|
||||
)
|
||||
);
|
||||
}
|
||||
)[0];
|
||||
|
||||
return api('POST', baseUri + zoneID + '/rrset/', xml);
|
||||
if (record) {
|
||||
return {
|
||||
dnsAuthorization: record.ResourceRecords[0].ResourceRecord[0].Value[0].replace(
|
||||
/\"/g,
|
||||
''
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
remove: function(data) {
|
||||
var challenge = data.challenge;
|
||||
|
||||
return api(
|
||||
'GET',
|
||||
baseUri + '/' + 'hostedzonesbyname',
|
||||
null,
|
||||
'dnsname=' + challenge.dnsZone + '&maxitems=1'
|
||||
)
|
||||
.then(function(response) {
|
||||
return parseString(response.body).then(function(body) {
|
||||
return body;
|
||||
});
|
||||
})
|
||||
.then(function(body) {
|
||||
var zoneID =
|
||||
body.ListHostedZonesByNameResponse.HostedZones[0]
|
||||
.HostedZone[0].Id[0];
|
||||
var xmlns = body.ListHostedZonesByNameResponse.$.xmlns;
|
||||
|
||||
var parameters = JSON.parse(JSON.stringify(XmlParameters));
|
||||
parameters.ChangeResourceRecordSetsRequest.$.xmlns = xmlns;
|
||||
parameters.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].Action[0] =
|
||||
'DELETE';
|
||||
parameters.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].ResourceRecordSet[0].Name[0] =
|
||||
challenge.dnsHost; // AWS requires FQDN
|
||||
parameters.ChangeResourceRecordSetsRequest.ChangeBatch[0].Changes[0].Change[0].ResourceRecordSet[0].ResourceRecords[0].ResourceRecord[0].Value[0] =
|
||||
'"' + challenge.dnsAuthorization + '"'; // value must be surrounded by double quotes
|
||||
|
||||
// need to add weight and setidentifier to delete record set
|
||||
if (challenge.identifier.value.startsWith('foo')) {
|
||||
setWeightedPolicy(parameters, challenge.wildcard);
|
||||
}
|
||||
|
||||
var xml = builder.buildObject(parameters);
|
||||
return api('POST', baseUri + zoneID + '/rrset/', xml);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
};
|
||||
};
|
||||
|
|
|
@ -19,7 +19,10 @@
|
|||
"acme",
|
||||
"greenlock"
|
||||
],
|
||||
"author": "AJ ONeal <coolaj86@gmail.com> (https://coolaj86.com/)",
|
||||
"author": "Hitesh Walia <hiteshwar.walia@gmail.com",
|
||||
"contributors": [
|
||||
"AJ ONeal <coolaj86@gmail.com> (https://coolaj86.com/)"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"dotenv": "^8.1.0"
|
||||
|
|
Loading…
Reference in New Issue