626 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			626 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| 'use strict';
 | |
| 
 | |
| var pkg = require('./package.json');
 | |
| 
 | |
| var ACME = require('@root/acme');
 | |
| var Greenlock = module.exports;
 | |
| var homedir = require('os').homedir();
 | |
| 
 | |
| var G = Greenlock;
 | |
| var U = require('./utils.js');
 | |
| var E = require('./errors.js');
 | |
| var P = require('./plugins.js');
 | |
| var A = require('./accounts.js');
 | |
| var C = require('./certificates.js');
 | |
| var UserEvents = require('./user-events.js');
 | |
| 
 | |
| var caches = {};
 | |
| 
 | |
| // { maintainerEmail, directoryUrl, subscriberEmail, store, challenges  }
 | |
| G.create = function(gconf) {
 | |
| 	var greenlock = {};
 | |
| 	if (!gconf) {
 | |
| 		gconf = {};
 | |
| 	}
 | |
| 
 | |
| 	if (!gconf.maintainerEmail) {
 | |
| 		throw E.NO_MAINTAINER('create');
 | |
| 	}
 | |
| 
 | |
| 	// TODO send welcome message with benefit info
 | |
| 	U._validMx(gconf.maintainerEmail).catch(function() {
 | |
| 		console.error(
 | |
| 			'invalid maintainer contact info:',
 | |
| 			gconf.maintainerEmail
 | |
| 		);
 | |
| 		// maybe a little harsh?
 | |
| 		process.exit(1);
 | |
| 	});
 | |
| 
 | |
| 	// TODO default servername is GLE only
 | |
| 
 | |
| 	if (!gconf.manager) {
 | |
| 		gconf.manager = 'greenlock-manager-fs';
 | |
| 	}
 | |
| 
 | |
| 	var Manager;
 | |
| 	if ('string' === typeof gconf.manager) {
 | |
| 		try {
 | |
| 			Manager = require(gconf.manager);
 | |
| 		} catch (e) {
 | |
| 			if ('MODULE_NOT_FOUND' !== e.code) {
 | |
| 				throw e;
 | |
| 			}
 | |
| 			console.error(e.code);
 | |
| 			console.error(e.message);
 | |
| 			console.error(gconf.manager);
 | |
| 			P._installSync(gconf.manager);
 | |
| 			Manager = require(gconf.manager);
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// minimal modification to the original object
 | |
| 	var defaults = G._defaults(gconf);
 | |
| 
 | |
| 	greenlock.manager = Manager.create(defaults);
 | |
| 	//console.log('debug greenlock.manager', Object.keys(greenlock.manager));
 | |
| 	greenlock._init = function() {
 | |
| 		var p;
 | |
| 		greenlock._init = function() {
 | |
| 			return p;
 | |
| 		};
 | |
| 		p = greenlock.manager.defaults().then(function(conf) {
 | |
| 			var changed = false;
 | |
| 			if (!conf.challenges) {
 | |
| 				changed = true;
 | |
| 				conf.challenges = defaults.challenges;
 | |
| 			}
 | |
| 			if (!conf.store) {
 | |
| 				changed = true;
 | |
| 				conf.store = defaults.store;
 | |
| 			}
 | |
| 			if (changed) {
 | |
| 				return greenlock.manager.defaults(conf);
 | |
| 			}
 | |
| 		});
 | |
| 		return p;
 | |
| 	};
 | |
| 
 | |
| 	// The goal here is to reduce boilerplate, such as error checking
 | |
| 	// and duration parsing, that a manager must implement
 | |
| 	greenlock.add = function(args) {
 | |
| 		return greenlock._init().then(function() {
 | |
| 			return greenlock._add(args);
 | |
| 		});
 | |
| 	};
 | |
| 	greenlock._add = function(args) {
 | |
| 		return Promise.resolve().then(function() {
 | |
| 			// durations
 | |
| 			if (args.renewOffset) {
 | |
| 				args.renewOffset = U._parseDuration(args.renewOffset);
 | |
| 			}
 | |
| 			if (args.renewStagger) {
 | |
| 				args.renewStagger = U._parseDuration(args.renewStagger);
 | |
| 			}
 | |
| 
 | |
| 			if (!args.subject) {
 | |
| 				throw E.NO_SUBJECT('add');
 | |
| 			}
 | |
| 
 | |
| 			if (!args.altnames) {
 | |
| 				args.altnames = [args.subject];
 | |
| 			}
 | |
| 			if ('string' === typeof args.altnames) {
 | |
| 				args.altnames = args.altnames.split(/[,\s]+/);
 | |
| 			}
 | |
| 			if (args.subject !== args.altnames[0]) {
 | |
| 				throw E.BAD_ORDER(
 | |
| 					'add',
 | |
| 					'(' + args.subject + ") '" + args.altnames.join("' '") + "'"
 | |
| 				);
 | |
| 			}
 | |
| 			args.altnames = args.altnames.map(U._encodeName);
 | |
| 
 | |
| 			if (
 | |
| 				!args.altnames.every(function(d) {
 | |
| 					return U._validName(d);
 | |
| 				})
 | |
| 			) {
 | |
| 				throw E.INVALID_HOSTNAME(
 | |
| 					'add',
 | |
| 					"'" + args.altnames.join("' '") + "'"
 | |
| 				);
 | |
| 			}
 | |
| 
 | |
| 			// at this point we know that subject is the first of altnames
 | |
| 			return Promise.all(
 | |
| 				args.altnames.map(function(d) {
 | |
| 					d = d.replace('*.', '');
 | |
| 					return U._validDomain(d);
 | |
| 				})
 | |
| 			).then(function() {
 | |
| 				if (!U._uniqueNames(args.altnames)) {
 | |
| 					throw E.NOT_UNIQUE(
 | |
| 						'add',
 | |
| 						"'" + args.altnames.join("' '") + "'"
 | |
| 					);
 | |
| 				}
 | |
| 
 | |
| 				return greenlock.manager.add(args);
 | |
| 			});
 | |
| 		});
 | |
| 	};
 | |
| 
 | |
| 	greenlock._notify = function(ev, params) {
 | |
| 		var mng = greenlock.manager;
 | |
| 
 | |
| 		if ('_' === String(ev)[0]) {
 | |
| 			if ('_cert_issue' === ev) {
 | |
| 				try {
 | |
| 					mng.update({
 | |
| 						subject: params.subject,
 | |
| 						renewAt: params.renewAt
 | |
| 					}).catch(function(e) {
 | |
| 						e.context = '_cert_issue';
 | |
| 						greenlock._notify('error', e);
 | |
| 					});
 | |
| 				} catch (e) {
 | |
| 					e.context = '_cert_issue';
 | |
| 					greenlock._notify('error', e);
 | |
| 				}
 | |
| 			}
 | |
| 			// trap internal events internally
 | |
| 			return;
 | |
| 		}
 | |
| 
 | |
| 		if (mng.notify || greenlock._defaults.notify) {
 | |
| 			try {
 | |
| 				var p = (mng.notify || greenlock._defaults.notify)(ev, params);
 | |
| 				if (p && p.catch) {
 | |
| 					p.catch(function(e) {
 | |
| 						console.error(
 | |
| 							"Promise Rejection on event '" + ev + "':"
 | |
| 						);
 | |
| 						console.error(e);
 | |
| 					});
 | |
| 				}
 | |
| 			} catch (e) {
 | |
| 				console.error("Thrown Exception on event '" + ev + "':");
 | |
| 				console.error(e);
 | |
| 			}
 | |
| 		} else {
 | |
| 			if (/error/i.test(ev)) {
 | |
| 				console.error("Error event '" + ev + "':");
 | |
| 				console.error(params);
 | |
| 				console.error(params.stack);
 | |
| 			}
 | |
| 		}
 | |
| 		/*
 | |
|      *'cert_issue', {
 | |
| 						options: args,
 | |
| 						subject: args.subject,
 | |
| 						altnames: args.altnames,
 | |
| 						account: account,
 | |
| 						email: email,
 | |
| 						pems: newPems
 | |
| 					}
 | |
|      */
 | |
| 
 | |
| 		if (-1 !== ['cert_issue', 'cert_renewal'].indexOf(ev)) {
 | |
| 			// We will notify all greenlock users of mandatory and security updates
 | |
| 			// We'll keep track of versions and os so we can make sure things work well
 | |
| 			// { name, version, email, domains, action, communityMember, telemetry }
 | |
| 			// TODO look at the other one
 | |
| 			UserEvents.notify({
 | |
| 				/*
 | |
|                 // maintainer should be only on pre-publish, or maybe install, I think
 | |
|                 maintainerEmail: greenlock._defaults._maintainerEmail,
 | |
|                 name: greenlock._defaults._packageAgent,
 | |
|                 version: greenlock._defaults._maintainerPackageVersion,
 | |
|                 //action: params.pems._type,
 | |
|                 domains: params.altnames,
 | |
|                 subscriberEmail: greenlock._defaults._subscriberEmail,
 | |
|                 // TODO enable for Greenlock Pro
 | |
|                 //customerEmail: args.customerEmail
 | |
|                 telemetry: greenlock._defaults.telemetry
 | |
|                 */
 | |
| 			});
 | |
| 		}
 | |
| 	};
 | |
| 
 | |
| 	greenlock._single = function(args) {
 | |
| 		if ('string' !== typeof args.servername) {
 | |
| 			return Promise.reject(new Error('no servername given'));
 | |
| 		}
 | |
| 		// www.example.com => *.example.com
 | |
| 		args.wildname =
 | |
| 			'*.' +
 | |
| 			args.servername
 | |
| 				.split('.')
 | |
| 				.slice(1)
 | |
| 				.join('.');
 | |
| 		if (
 | |
| 			args.servernames ||
 | |
| 			args.subject ||
 | |
| 			args.renewBefore ||
 | |
| 			args.issueBefore ||
 | |
| 			args.expiresBefore
 | |
| 		) {
 | |
| 			return Promise.reject(
 | |
| 				new Error(
 | |
| 					'bad arguments, did you mean to call greenlock.renew()?'
 | |
| 				)
 | |
| 			);
 | |
| 		}
 | |
| 		// duplicate, force, and others still allowed
 | |
| 		return Promise.resolve(args);
 | |
| 	};
 | |
| 
 | |
| 	greenlock.get = function(args) {
 | |
| 		return greenlock
 | |
| 			._single(args)
 | |
| 			.then(function() {
 | |
| 				args._includePems = true;
 | |
| 				return greenlock.renew(args);
 | |
| 			})
 | |
| 			.then(function(results) {
 | |
| 				if (!results || !results.length) {
 | |
| 					return null;
 | |
| 				}
 | |
| 
 | |
| 				// just get the first one
 | |
| 				var result = results[0];
 | |
| 
 | |
| 				// (there should be only one, ideally)
 | |
| 				if (results.length > 1) {
 | |
| 					var err = new Error(
 | |
| 						"a search for '" +
 | |
| 							args.servername +
 | |
| 							"' returned multiple certificates"
 | |
| 					);
 | |
| 					err.context = 'duplicate_certs';
 | |
| 					err.servername = args.servername;
 | |
| 					err.subjects = results.map(function(r) {
 | |
| 						return (r.site || {}).subject || 'N/A';
 | |
| 					});
 | |
| 
 | |
| 					greenlock._notify('warning', err);
 | |
| 				}
 | |
| 
 | |
| 				if (result.error) {
 | |
| 					return Promise.reject(result.error);
 | |
| 				}
 | |
| 
 | |
| 				// site for plugin options, such as http-01 challenge
 | |
| 				// pems for the obvious reasons
 | |
| 				return result;
 | |
| 			});
 | |
| 	};
 | |
| 
 | |
| 	greenlock._config = function(args) {
 | |
| 		return greenlock
 | |
| 			._single(args)
 | |
| 			.then(function() {
 | |
| 				return greenlock.manager.find(args);
 | |
| 			})
 | |
| 			.then(function(sites) {
 | |
| 				if (!sites || !sites.length) {
 | |
| 					return null;
 | |
| 				}
 | |
| 				var site = sites[0];
 | |
| 				site = JSON.parse(JSON.stringify(site));
 | |
| 				if (site.store && site.challenges) {
 | |
| 					return site;
 | |
| 				}
 | |
| 				return greenlock.manager.defaults().then(function(mconf) {
 | |
| 					if (!site.store) {
 | |
| 						site.store = mconf.store || greenlock._defaults.store;
 | |
| 					}
 | |
| 					if (!site.challenges) {
 | |
| 						site.challenges =
 | |
| 							mconf.challenges || greenlock._defaults.challenges;
 | |
| 					}
 | |
| 					return site;
 | |
| 				});
 | |
| 			});
 | |
| 	};
 | |
| 
 | |
| 	// needs to get info about the renewal, such as which store and challenge(s) to use
 | |
| 	greenlock.renew = function(args) {
 | |
| 		return greenlock.manager.defaults().then(function(mconf) {
 | |
| 			return greenlock._renew(mconf, args);
 | |
| 		});
 | |
| 	};
 | |
| 	greenlock._renew = function(mconf, args) {
 | |
| 		if (!args) {
 | |
| 			args = {};
 | |
| 		}
 | |
| 
 | |
| 		// durations
 | |
| 		if (args.renewOffset) {
 | |
| 			args.renewOffset = U._parseDuration(args.renewOffset);
 | |
| 		}
 | |
| 		if (args.renewStagger) {
 | |
| 			args.renewStagger = U._parseDuration(args.renewStagger);
 | |
| 		}
 | |
| 
 | |
| 		if (args.servername) {
 | |
| 			// this doesn't have to be the subject, it can be anything
 | |
| 			// however, not sure how useful this really is...
 | |
| 			args.servername = args.servername.toLowerCase();
 | |
| 		}
 | |
| 
 | |
| 		//console.log('greenlock._renew find', args);
 | |
| 		return greenlock.manager.find(args).then(function(sites) {
 | |
| 			// Note: the manager must guaranteed that these are mutable copies
 | |
| 			//console.log('greenlock._renew found', sites);
 | |
| 
 | |
| 			var renewedOrFailed = [];
 | |
| 
 | |
| 			function next() {
 | |
| 				var site = sites.shift();
 | |
| 				if (!site) {
 | |
| 					return Promise.resolve(null);
 | |
| 				}
 | |
| 
 | |
| 				var order = { site: site };
 | |
| 				renewedOrFailed.push(order);
 | |
| 				// TODO merge args + result?
 | |
| 				return greenlock
 | |
| 					._order(mconf, site)
 | |
| 					.then(function(pems) {
 | |
| 						if (args._includePems) {
 | |
| 							order.pems = pems;
 | |
| 						}
 | |
| 					})
 | |
| 					.catch(function(err) {
 | |
| 						order.error = err;
 | |
| 
 | |
| 						// For greenlock express serialization
 | |
| 						err.toJSON = errorToJSON;
 | |
| 						err.context = err.context || 'cert_order';
 | |
| 						err.subject = site.subject;
 | |
| 						if (args.servername) {
 | |
| 							err.servername = args.servername;
 | |
| 						}
 | |
| 						// for debugging, but not to be relied on
 | |
| 						err._site = site;
 | |
| 						// TODO err.context = err.context || 'renew_certificate'
 | |
| 						greenlock._notify('error', err);
 | |
| 					})
 | |
| 					.then(function() {
 | |
| 						return next();
 | |
| 					});
 | |
| 			}
 | |
| 
 | |
| 			return next().then(function() {
 | |
| 				return renewedOrFailed;
 | |
| 			});
 | |
| 		});
 | |
| 	};
 | |
| 
 | |
| 	greenlock._acme = function(args) {
 | |
| 		var packageAgent = greenlock._defaults.packageAgent || '';
 | |
| 		// because Greenlock_Express/v3.x Greenlock/v3 is redundant
 | |
| 		if (!/greenlock/i.test(packageAgent)) {
 | |
| 			packageAgent = (packageAgent + ' Greenlock/' + pkg.version).trim();
 | |
| 		}
 | |
| 		var acme = ACME.create({
 | |
| 			maintainerEmail: greenlock._defaults.maintainerEmail,
 | |
| 			packageAgent: packageAgent,
 | |
| 			notify: greenlock._notify,
 | |
| 			debug: greenlock._defaults.debug || args.debug
 | |
| 		});
 | |
| 		var dirUrl = args.directoryUrl || greenlock._defaults.directoryUrl;
 | |
| 
 | |
| 		var dir = caches[dirUrl];
 | |
| 
 | |
| 		// don't cache more than an hour
 | |
| 		if (dir && Date.now() - dir.ts < 1 * 60 * 60 * 1000) {
 | |
| 			return dir.promise;
 | |
| 		}
 | |
| 
 | |
| 		return acme
 | |
| 			.init(dirUrl)
 | |
| 			.then(function(/*meta*/) {
 | |
| 				caches[dirUrl] = {
 | |
| 					promise: Promise.resolve(acme),
 | |
| 					ts: Date.now()
 | |
| 				};
 | |
| 				return acme;
 | |
| 			})
 | |
| 			.catch(function(err) {
 | |
| 				// TODO
 | |
| 				// let's encrypt is possibly down for maintenaince...
 | |
| 				// this is a special kind of failure mode
 | |
| 				throw err;
 | |
| 			});
 | |
| 	};
 | |
| 
 | |
| 	greenlock.order = function(args) {
 | |
| 		return greenlock._init().then(function() {
 | |
| 			return greenlock.manager.defaults().then(function(mconf) {
 | |
| 				return greenlock._order(mconf, args);
 | |
| 			});
 | |
| 		});
 | |
| 	};
 | |
| 	greenlock._order = function(mconf, args) {
 | |
| 		// packageAgent, maintainerEmail
 | |
| 		return greenlock._acme(args).then(function(acme) {
 | |
| 			var storeConf = args.store || greenlock._defaults.store;
 | |
| 			return P._loadStore(storeConf).then(function(store) {
 | |
| 				return A._getOrCreate(
 | |
| 					greenlock,
 | |
| 					mconf,
 | |
| 					store.accounts,
 | |
| 					acme,
 | |
| 					args
 | |
| 				).then(function(account) {
 | |
| 					var challengeConfs =
 | |
| 						args.challenges ||
 | |
| 						mconf.challenges ||
 | |
| 						greenlock._defaults.challenges;
 | |
| 					return Promise.all(
 | |
| 						Object.keys(challengeConfs).map(function(typ01) {
 | |
| 							return P._loadChallenge(challengeConfs, typ01);
 | |
| 						})
 | |
| 					).then(function(arr) {
 | |
| 						var challenges = {};
 | |
| 						arr.forEach(function(el) {
 | |
| 							challenges[el._type] = el;
 | |
| 						});
 | |
| 						return C._getOrOrder(
 | |
| 							greenlock,
 | |
| 							mconf,
 | |
| 							store.certificates,
 | |
| 							acme,
 | |
| 							challenges,
 | |
| 							account,
 | |
| 							args
 | |
| 						).then(function(pems) {
 | |
| 							if (!pems.privkey) {
 | |
| 								throw new Error(
 | |
| 									'missing private key, which is kinda important'
 | |
| 								);
 | |
| 							}
 | |
| 							return pems;
 | |
| 						});
 | |
| 					});
 | |
| 				});
 | |
| 			});
 | |
| 		});
 | |
| 	};
 | |
| 
 | |
| 	greenlock._options = gconf;
 | |
| 	greenlock._defaults = defaults;
 | |
| 
 | |
| 	if (!gconf.onOrderFailure) {
 | |
| 		gconf.onOrderFailure = function(err) {
 | |
| 			G._onOrderFailure(gconf, err);
 | |
| 		};
 | |
| 	}
 | |
| 
 | |
| 	return greenlock;
 | |
| };
 | |
| 
 | |
| G._loadChallenge = P._loadChallenge;
 | |
| 
 | |
| G._defaults = function(opts) {
 | |
| 	var defaults = {};
 | |
| 
 | |
| 	// [ 'store', 'challenges' ]
 | |
| 	Object.keys(opts).forEach(function(k) {
 | |
| 		// manage is the only thing that is, potentially, not plain-old JSON
 | |
| 		if ('manage' === k && 'string' !== typeof opts[k]) {
 | |
| 			return;
 | |
| 		}
 | |
| 		defaults[k] = opts[k];
 | |
| 	});
 | |
| 
 | |
| 	if ('function' === typeof opts.notify) {
 | |
| 		defaults.notify = opts.notify;
 | |
| 	}
 | |
| 	if ('function' === typeof opts.find) {
 | |
| 		defaults.find = opts.find;
 | |
| 	}
 | |
| 
 | |
| 	if (!defaults.directoryUrl) {
 | |
| 		if (defaults.staging) {
 | |
| 			defaults.directoryUrl =
 | |
| 				'https://acme-staging-v02.api.letsencrypt.org/directory';
 | |
| 		} else {
 | |
| 			defaults.directoryUrl =
 | |
| 				'https://acme-v02.api.letsencrypt.org/directory';
 | |
| 		}
 | |
| 	} else {
 | |
| 		if (defaults.staging) {
 | |
| 			throw new Error('supply `directoryUrl` or `staging`, but not both');
 | |
| 		}
 | |
| 	}
 | |
| 	console.info('ACME Directory URL:', defaults.directoryUrl);
 | |
| 
 | |
| 	// Load the default store module
 | |
| 	if (!defaults.store) {
 | |
| 		defaults.store = {
 | |
| 			module: 'greenlock-store-fs',
 | |
| 			basePath: homedir + '/.config/greenlock/'
 | |
| 		};
 | |
| 	}
 | |
| 	P._loadSync(defaults.store.module);
 | |
| 	//defaults.store = store;
 | |
| 
 | |
| 	// Load the default challenge modules
 | |
| 	var challenges;
 | |
| 	if (!defaults.challenges) {
 | |
| 		defaults.challenges = {};
 | |
| 	}
 | |
| 	challenges = defaults.challenges;
 | |
| 
 | |
| 	// TODO provide http-01 when http-01 and/or(?) dns-01 don't exist
 | |
| 	if (!challenges['http-01'] && !challenges['dns-01']) {
 | |
| 		challenges['http-01'] = {
 | |
| 			module: 'acme-http-01-standalone'
 | |
| 		};
 | |
| 	}
 | |
| 
 | |
| 	if (challenges['http-01']) {
 | |
| 		if ('string' === typeof challenges['http-01'].module) {
 | |
| 			P._loadSync(challenges['http-01'].module);
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	if (challenges['dns-01']) {
 | |
| 		if ('string' === typeof challenges['dns-01'].module) {
 | |
| 			P._loadSync(challenges['dns-01'].module);
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	if (defaults.agreeToTerms === true || defaults.agreeTos === true) {
 | |
| 		defaults.agreeToTerms = function(tos) {
 | |
| 			return Promise.resolve(tos);
 | |
| 		};
 | |
| 	}
 | |
| 
 | |
| 	if (!defaults.renewOffset) {
 | |
| 		defaults.renewOffset = '-45d';
 | |
| 	}
 | |
| 	if (!defaults.renewStagger) {
 | |
| 		defaults.renewStagger = '3d';
 | |
| 	}
 | |
| 
 | |
| 	if (!defaults.accountKeyType) {
 | |
| 		defaults.accountKeyType = 'EC-P256';
 | |
| 	}
 | |
| 	if (!defaults.serverKeyType) {
 | |
| 		if (defaults.domainKeyType) {
 | |
| 			console.warn('use serverKeyType instead of domainKeyType');
 | |
| 			defaults.serverKeyType = defaults.domainKeyType;
 | |
| 		} else {
 | |
| 			defaults.serverKeyType = 'RSA-2048';
 | |
| 		}
 | |
| 	}
 | |
| 	if (defaults.domainKeypair) {
 | |
| 		console.warn('use serverKeypair instead of domainKeypair');
 | |
| 		defaults.serverKeypair =
 | |
| 			defaults.serverKeypair || defaults.domainKeypair;
 | |
| 	}
 | |
| 
 | |
| 	Object.defineProperty(defaults, 'domainKeypair', {
 | |
| 		write: false,
 | |
| 		get: function() {
 | |
| 			console.warn('use serverKeypair instead of domainKeypair');
 | |
| 			return defaults.serverKeypair;
 | |
| 		}
 | |
| 	});
 | |
| 
 | |
| 	return defaults;
 | |
| };
 | |
| 
 | |
| function errorToJSON(e) {
 | |
| 	var error = {};
 | |
| 	Object.getOwnPropertyNames(e).forEach(function(k) {
 | |
| 		error[k] = e[k];
 | |
| 	});
 | |
| 	return error;
 | |
| }
 |