diff --git a/x-pack/index.js b/x-pack/index.js index ba26c8fcf416..f20e87de9898 100644 --- a/x-pack/index.js +++ b/x-pack/index.js @@ -21,6 +21,7 @@ import { licenseManagement } from './plugins/license_management'; import { cloud } from './plugins/cloud'; import { indexManagement } from './plugins/index_management'; import { consoleExtensions } from './plugins/console_extensions'; +import { notifications } from './plugins/notifications'; module.exports = function (kibana) { return [ @@ -40,6 +41,7 @@ module.exports = function (kibana) { licenseManagement(kibana), cloud(kibana), indexManagement(kibana), - consoleExtensions(kibana) + consoleExtensions(kibana), + notifications(kibana), ]; }; diff --git a/x-pack/package.json b/x-pack/package.json index 25bee9e5cbb2..099b1809e0f7 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -82,6 +82,7 @@ "@elastic/numeral": "2.3.2", "@kbn/datemath": "link:../packages/kbn-datemath", "@kbn/ui-framework": "link:../packages/kbn-ui-framework", + "@slack/client": "^4.2.2", "angular-paging": "2.2.1", "angular-resource": "1.4.9", "angular-sanitize": "1.4.9", @@ -122,6 +123,7 @@ "moment-duration-format": "^1.3.0", "moment-timezone": "^0.5.14", "ngreact": "^0.5.1", + "nodemailer": "^4.6.4", "object-hash": "1.2.0", "path-match": "1.2.4", "pdfmake": "0.1.33", diff --git a/x-pack/plugins/notifications/config.js b/x-pack/plugins/notifications/config.js new file mode 100644 index 000000000000..9234e6ee5f22 --- /dev/null +++ b/x-pack/plugins/notifications/config.js @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * User-configurable settings for xpack.notifications via configuration schema + * + * @param {Object} Joi - HapiJS Joi module that allows for schema validation + * @return {Object} config schema + */ +export const config = (Joi) => { + const { array, boolean, number, object, string } = Joi; + + return object({ + enabled: boolean().default(true), + email: object({ + enabled: boolean().default(false), + smtp: object({ + host: string().default('localhost'), + port: number().default(25), + require_tls: boolean().default(false), + pool: boolean().default(false), + auth: object({ + username: string(), + password: string() + }).default(), + }).default(), + defaults: object({ + from: string(), + to: array().single().items(string()), + cc: array().single().items(string()), + bcc: array().single().items(string()), + }).default(), + }).default(), + slack: object({ + enabled: boolean().default(false), + token: string().required(), + defaults: object({ + channel: string(), + as_user: boolean().default(false), + icon_emoji: string(), + icon_url: string(), + link_names: boolean().default(true), + mrkdwn: boolean().default(true), + unfurl_links: boolean().default(true), + unfurl_media: boolean().default(true), + username: string(), + }).default(), + }) + }).default(); +}; diff --git a/x-pack/plugins/notifications/index.js b/x-pack/plugins/notifications/index.js new file mode 100644 index 000000000000..e6de33e9685e --- /dev/null +++ b/x-pack/plugins/notifications/index.js @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { resolve } from 'path'; +import { init } from './init'; +import { config } from './config'; + +/** + * Invokes plugin modules to instantiate the Notification plugin for Kibana + * + * @param kibana {Object} Kibana plugin instance + * @return {Object} Notification Kibana plugin object + */ +export const notifications = (kibana) => new kibana.Plugin({ + require: ['kibana', 'xpack_main'], + id: 'notifications', + configPrefix: 'xpack.notifications', + publicDir: resolve(__dirname, 'public'), + init, + config, +}); diff --git a/x-pack/plugins/notifications/init.js b/x-pack/plugins/notifications/init.js new file mode 100644 index 000000000000..67c34b336f73 --- /dev/null +++ b/x-pack/plugins/notifications/init.js @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + notificationService, + createEmailAction, + createSlackAction, + LoggerAction, +} from './server'; +import { notificationServiceSendRoute } from './server/routes/api/v1/notifications'; + +/** + * Initialize the Action Service with various actions provided by X-Pack, when configured. + * + * @param server {Object} HapiJS server instance + */ +export function init(server) { + const config = server.config(); + + // the logger + notificationService.setAction(new LoggerAction({ server })); + + if (config.get('xpack.notifications.email.enabled')) { + notificationService.setAction(createEmailAction(server)); + } + + if (config.get('xpack.notifications.slack.enabled')) { + notificationService.setAction(createSlackAction(server)); + } + + notificationServiceSendRoute(server, notificationService); + + // expose the notification service for other plugins + server.expose('notificationService', notificationService); +} diff --git a/x-pack/plugins/notifications/server/email/create_email_action.js b/x-pack/plugins/notifications/server/email/create_email_action.js new file mode 100644 index 000000000000..5e3b63e4b7cd --- /dev/null +++ b/x-pack/plugins/notifications/server/email/create_email_action.js @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EmailAction } from './email_action'; + +/** + * Create a Nodemailer transporter options object from the config. + * + * @param {Object} config The server configuration. + * @return {Object} An object that configures Nodemailer. + */ +export function optionsFromConfig(config) { + return { + host: config.get('xpack.notifications.email.smtp.host'), + port: config.get('xpack.notifications.email.smtp.port'), + requireTLS: config.get('xpack.notifications.email.smtp.require_tls'), + pool: config.get('xpack.notifications.email.smtp.pool'), + auth: { + user: config.get('xpack.notifications.email.smtp.auth.username'), + pass: config.get('xpack.notifications.email.smtp.auth.password'), + }, + }; +} + +/** + * Create a Nodemailer defaults object from the config. + * + * Defaults include things like the default "from" email address. + * + * @param {Object} config The server configuration. + * @return {Object} An object that configures Nodemailer on a per-message basis. + */ +export function defaultsFromConfig(config) { + return { + from: config.get('xpack.notifications.email.defaults.from'), + to: config.get('xpack.notifications.email.defaults.to'), + cc: config.get('xpack.notifications.email.defaults.cc'), + bcc: config.get('xpack.notifications.email.defaults.bcc'), + }; +} + +/** + * Create a new Email Action based on the configuration. + * + * @param {Object} server The server object. + * @return {EmailAction} A new email action based on the kibana.yml configuration. + */ +export function createEmailAction(server, { _options = optionsFromConfig, _defaults = defaultsFromConfig } = { }) { + const config = server.config(); + + const options = _options(config); + const defaults = _defaults(config); + + return new EmailAction({ server, options, defaults }); +} diff --git a/x-pack/plugins/notifications/server/email/create_email_action.test.js b/x-pack/plugins/notifications/server/email/create_email_action.test.js new file mode 100644 index 000000000000..fb9d46cdbe71 --- /dev/null +++ b/x-pack/plugins/notifications/server/email/create_email_action.test.js @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EmailAction } from './email_action'; +import { + createEmailAction, + defaultsFromConfig, + optionsFromConfig, +} from './create_email_action'; + +describe('create_email_action', () => { + + test('optionsFromConfig uses config without modification', () => { + const get = key => { + const suffixes = [ + 'host', + 'port', + 'require_tls', + 'pool', + 'auth.username', + 'auth.password', + ]; + const value = suffixes.find(suffix => { + return `xpack.notifications.email.smtp.${suffix}` === key; + }); + + if (value === undefined) { + throw new Error(`Unknown config key used ${key}`); + } + + return value; + }; + + expect(optionsFromConfig({ get })).toEqual({ + host: 'host', + port: 'port', + requireTLS: 'require_tls', + pool: 'pool', + auth: { + user: 'auth.username', + pass: 'auth.password', + }, + }); + }); + + test('defaultsFromConfig uses config without modification', () => { + const get = key => { + const suffixes = [ + 'from', + 'to', + 'cc', + 'bcc', + ]; + const value = suffixes.find(suffix => { + return `xpack.notifications.email.defaults.${suffix}` === key; + }); + + if (value === undefined) { + throw new Error(`Unknown config key used ${key}`); + } + + return value; + }; + + expect(defaultsFromConfig({ get })).toEqual({ + from: 'from', + to: 'to', + cc: 'cc', + bcc: 'bcc', + }); + }); + + test('createEmailAction', async () => { + const config = { }; + const server = { config: jest.fn().mockReturnValue(config) }; + const _options = jest.fn().mockReturnValue({ options: true }); + const defaults = { defaults: true }; + const _defaults = jest.fn().mockReturnValue(defaults); + + const action = createEmailAction(server, { _options, _defaults }); + + expect(action instanceof EmailAction).toBe(true); + expect(action.defaults).toBe(defaults); + + expect(server.config).toHaveBeenCalledTimes(1); + expect(server.config).toHaveBeenCalledWith(); + expect(_options).toHaveBeenCalledTimes(1); + expect(_options).toHaveBeenCalledWith(config); + expect(_defaults).toHaveBeenCalledTimes(1); + expect(_defaults).toHaveBeenCalledWith(config); + }); + +}); diff --git a/x-pack/plugins/notifications/server/email/email_action.js b/x-pack/plugins/notifications/server/email/email_action.js new file mode 100644 index 000000000000..a30b88a798d2 --- /dev/null +++ b/x-pack/plugins/notifications/server/email/email_action.js @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import nodemailer from 'nodemailer'; + +import { Action, ActionResult } from '../'; + +export const EMAIL_ACTION_ID = 'xpack-notifications-email'; + +/** + * Email Action enables generic sending of emails, when configured. + */ +export class EmailAction extends Action { + + /** + * Create a new Action capable of sending emails. + * + * @param {Object} server Kibana server object. + * @param {Object} options Configuration options for Nodemailer. + * @param {Object} defaults Default fields used when sending emails. + * @param {Object} _nodemailer Exposed for tests. + */ + constructor({ server, options, defaults = { }, _nodemailer = nodemailer }) { + super({ server, id: EMAIL_ACTION_ID, name: 'Email' }); + + this.transporter = _nodemailer.createTransport(options, defaults); + this.defaults = defaults; + } + + getMissingFields(notification) { + const missingFields = []; + + if (!Boolean(this.defaults.to) && !Boolean(notification.to)) { + missingFields.push({ + field: 'to', + name: 'To', + type: 'email', + }); + } + + if (!Boolean(this.defaults.from) && !Boolean(notification.from)) { + missingFields.push({ + field: 'from', + name: 'From', + type: 'email', + }); + } + + if (!Boolean(notification.subject)) { + missingFields.push({ + field: 'subject', + name: 'Subject', + type: 'text', + }); + } + + if (!Boolean(notification.markdown)) { + missingFields.push({ + field: 'markdown', + name: 'Body', + type: 'markdown', + }); + } + + return missingFields; + } + + async doPerformHealthCheck() { + // this responds with a boolean 'true' response, otherwise throws an Error + const response = await this.transporter.verify(); + + return new ActionResult({ + message: `Email action SMTP configuration has been verified.`, + response: { + verified: response + }, + }); + } + + async doPerformAction(notification) { + // Note: This throws an Error upon failure + const response = await this.transporter.sendMail({ + // email routing + from: notification.from, + to: notification.to, + cc: notification.cc, + bcc: notification.bcc, + // email content + subject: notification.subject, + html: notification.markdown, + text: notification.markdown, + }); + + return new ActionResult({ + message: `Sent email for '${notification.subject}'.`, + response, + }); + } + +} diff --git a/x-pack/plugins/notifications/server/email/email_action.test.js b/x-pack/plugins/notifications/server/email/email_action.test.js new file mode 100644 index 000000000000..90ef5a3f59a7 --- /dev/null +++ b/x-pack/plugins/notifications/server/email/email_action.test.js @@ -0,0 +1,166 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ActionResult } from '../'; +import { EMAIL_ACTION_ID, EmailAction } from './email_action'; + +describe('EmailAction', () => { + + const server = { }; + const options = { options: true }; + const defaults = { defaults: true }; + const transporter = { + // see beforeEach + }; + const _nodemailer = { + // see beforeEach + }; + + let action; + + beforeEach(() => { + transporter.verify = jest.fn(); + transporter.sendMail = jest.fn(); + _nodemailer.createTransport = jest.fn().mockReturnValue(transporter); + + action = new EmailAction({ server, options, defaults, _nodemailer }); + }); + + test('id and name to be from constructor', () => { + expect(action.getId()).toBe(EMAIL_ACTION_ID); + expect(action.getName()).toBe('Email'); + expect(action.transporter).toBe(transporter); + + expect(_nodemailer.createTransport).toHaveBeenCalledTimes(1); + expect(_nodemailer.createTransport).toHaveBeenCalledWith(options, defaults); + }); + + describe('getMissingFields', () => { + + test('returns missing fields', () => { + const to = { field: 'to', name: 'To', type: 'email' }; + const from = { field: 'from', name: 'From', type: 'email' }; + const subject = { field: 'subject', name: 'Subject', type: 'text' }; + const markdown = { field: 'markdown', name: 'Body', type: 'markdown' }; + + const missing = [ + { defaults: { }, notification: { }, missing: [ to, from, subject, markdown, ], }, + { defaults: { }, notification: { from: 'b@c.co', subject: 'subject', markdown: 'body', }, missing: [ to, ], }, + { defaults: { from: 'b@c.co', }, notification: { subject: 'subject', markdown: 'body', }, missing: [ to, ], }, + { defaults: { }, notification: { to: 'a@b.co', subject: 'subject', markdown: 'body', }, missing: [ from, ], }, + { defaults: { to: 'a@b.co', }, notification: { subject: 'subject', markdown: 'body', }, missing: [ from, ], }, + { defaults: { }, notification: { to: 'a@b.co', from: 'b@c.co', markdown: 'body', }, missing: [ subject, ], }, + { defaults: { }, notification: { to: 'a@b.co', from: 'b@c.co', subject: 'subject', }, missing: [ markdown, ], }, + ]; + + missing.forEach(check => { + const newDefaultsAction = new EmailAction({ server, defaults: check.defaults, _nodemailer }); + + expect(newDefaultsAction.getMissingFields(check.notification)).toEqual(check.missing); + }); + }); + + test('returns [] when all fields exist', () => { + const exists = [ + { defaults: { }, notification: { to: 'a@b.co', from: 'b@c.co', subject: 'subject', markdown: 'body', }, }, + { defaults: { to: 'a@b.co', }, notification: { from: 'b@c.co', subject: 'subject', markdown: 'body', }, }, + { defaults: { from: 'b@c.co', }, notification: { to: 'a@b.co', subject: 'subject', markdown: 'body', }, }, + { defaults: { to: 'a@b.co', from: 'b@c.co', }, notification: { subject: 'subject', markdown: 'body', }, }, + ]; + + exists.forEach(check => { + const newDefaultsAction = new EmailAction({ server, defaults: check.defaults, _nodemailer }); + + expect(newDefaultsAction.getMissingFields(check.notification)).toEqual([]); + }); + }); + + }); + + describe('doPerformHealthCheck', () => { + + test('rethrows Error for failure', async () => { + const error = new Error('TEST - expected'); + + transporter.verify.mockRejectedValue(error); + + await expect(action.doPerformHealthCheck()) + .rejects + .toThrow(error); + + expect(transporter.verify).toHaveBeenCalledTimes(1); + expect(transporter.verify).toHaveBeenCalledWith(); + }); + + test('returns ActionResult for success', async () => { + transporter.verify.mockResolvedValue(true); + + const result = await action.doPerformHealthCheck(); + + expect(result instanceof ActionResult).toBe(true); + expect(result.isOk()).toBe(true); + expect(result.getMessage()).toMatch('Email action SMTP configuration has been verified.'); + expect(result.getResponse()).toEqual({ + verified: true + }); + + expect(transporter.verify).toHaveBeenCalledTimes(1); + expect(transporter.verify).toHaveBeenCalledWith(); + }); + + }); + + describe('doPerformAction', () => { + const email = { subject: 'email', markdown: 'body' }; + + test('rethrows Error for failure', async () => { + const error = new Error('TEST - expected'); + + transporter.sendMail.mockRejectedValue(error); + + await expect(action.doPerformAction(email)) + .rejects + .toThrow(error); + + expect(transporter.sendMail).toHaveBeenCalledTimes(1); + expect(transporter.sendMail).toHaveBeenCalledWith({ + to: undefined, + from: undefined, + cc: undefined, + bcc: undefined, + subject: email.subject, + html: email.markdown, + text: email.markdown, + }); + }); + + test('returns ActionResult for success', async () => { + const response = { fake: true }; + + transporter.sendMail.mockResolvedValue(response); + + const result = await action.doPerformAction(email); + + expect(result instanceof ActionResult).toBe(true); + expect(result.isOk()).toBe(true); + expect(result.getMessage()).toMatch(`Sent email for '${email.subject}'.`); + expect(result.getResponse()).toBe(response); + + expect(transporter.sendMail).toHaveBeenCalledTimes(1); + expect(transporter.sendMail).toHaveBeenCalledWith({ + to: undefined, + from: undefined, + cc: undefined, + bcc: undefined, + subject: email.subject, + html: email.markdown, + text: email.markdown, + }); + }); + + }); + +}); diff --git a/x-pack/plugins/notifications/server/email/index.js b/x-pack/plugins/notifications/server/email/index.js new file mode 100644 index 000000000000..ff784e71bab7 --- /dev/null +++ b/x-pack/plugins/notifications/server/email/index.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { createEmailAction } from './create_email_action'; diff --git a/x-pack/plugins/notifications/server/index.js b/x-pack/plugins/notifications/server/index.js new file mode 100644 index 000000000000..d877b5bb7043 --- /dev/null +++ b/x-pack/plugins/notifications/server/index.js @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + notificationService, + Action, + ActionResult, +} from './service'; +export { createEmailAction } from './email'; +export { createSlackAction } from './slack'; +export { LoggerAction } from './logger'; diff --git a/x-pack/plugins/notifications/server/logger/index.js b/x-pack/plugins/notifications/server/logger/index.js new file mode 100644 index 000000000000..c9d4686ad4ef --- /dev/null +++ b/x-pack/plugins/notifications/server/logger/index.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { LoggerAction } from './logger_action'; diff --git a/x-pack/plugins/notifications/server/logger/logger_action.js b/x-pack/plugins/notifications/server/logger/logger_action.js new file mode 100644 index 000000000000..189f3638677c --- /dev/null +++ b/x-pack/plugins/notifications/server/logger/logger_action.js @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Action, ActionResult } from '../'; + +export const LOGGER_ACTION_ID = 'xpack-notifications-logger'; + +/** + * Logger Action enables generic logging of information into Kibana's logs. + * + * This is mostly useful for debugging. + */ +export class LoggerAction extends Action { + + constructor({ server }) { + super({ server, id: LOGGER_ACTION_ID, name: 'Log' }); + } + + getMissingFields() { + return []; + } + + async doPerformHealthCheck() { + return new ActionResult({ + message: `Logger action is always usable.`, + response: { }, + }); + } + + async doPerformAction(notification) { + this.server.log([LOGGER_ACTION_ID, 'info'], notification); + + return new ActionResult({ + message: 'Logged data returned as response.', + response: notification + }); + } + +} diff --git a/x-pack/plugins/notifications/server/logger/logger_action.test.js b/x-pack/plugins/notifications/server/logger/logger_action.test.js new file mode 100644 index 000000000000..894399c0dc8b --- /dev/null +++ b/x-pack/plugins/notifications/server/logger/logger_action.test.js @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ActionResult } from '../'; +import { LOGGER_ACTION_ID, LoggerAction } from './logger_action'; + +describe('LoggerAction', () => { + + const action = new LoggerAction({ server: { } }); + + test('id and name to be from constructor', () => { + expect(action.id).toBe(LOGGER_ACTION_ID); + expect(action.name).toBe('Log'); + }); + + test('getMissingFields to return []', () => { + expect(action.getMissingFields()).toEqual([]); + }); + + test('doPerformHealthCheck returns ActionResult', async () => { + const result = await action.doPerformHealthCheck(); + + expect(result instanceof ActionResult).toBe(true); + expect(result.isOk()).toBe(true); + expect(result.getMessage()).toMatch('Logger action is always usable.'); + expect(result.getResponse()).toEqual({ }); + }); + + test('doPerformAction logs and returns ActionResult', async () => { + const notification = { fake: true }; + + const logger = jest.fn(); + const server = { log: logger }; + const action = new LoggerAction({ server }); + + const result = await action.doPerformAction(notification); + + expect(result instanceof ActionResult).toBe(true); + expect(result.isOk()).toBe(true); + expect(result.getMessage()).toMatch('Logged data returned as response.'); + expect(result.getResponse()).toBe(notification); + + expect(logger).toHaveBeenCalledTimes(1); + expect(logger).toHaveBeenCalledWith([LOGGER_ACTION_ID, 'info'], notification); + }); + +}); diff --git a/x-pack/plugins/notifications/server/routes/api/v1/notifications/index.js b/x-pack/plugins/notifications/server/routes/api/v1/notifications/index.js new file mode 100644 index 000000000000..dd754c454ee2 --- /dev/null +++ b/x-pack/plugins/notifications/server/routes/api/v1/notifications/index.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { notificationServiceSendRoute } from './notify'; diff --git a/x-pack/plugins/notifications/server/routes/api/v1/notifications/notify.js b/x-pack/plugins/notifications/server/routes/api/v1/notifications/notify.js new file mode 100644 index 000000000000..ad11190729ef --- /dev/null +++ b/x-pack/plugins/notifications/server/routes/api/v1/notifications/notify.js @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Joi from 'joi'; +import { wrap } from 'boom'; + +/** + * Check the incoming request parameters to see if the action should be allowed to fire. + * + * @param {Object|null} action The action selected by the user. + * @param {String} actionId The ID of the requested action from the user. + * @param {Object} data The incoming data from the user. + * @returns {Object|null} The error object, or null if no error. + */ +export function checkForErrors(action, actionId, data) { + if (action === null) { + return { + message: `Unrecognized action: '${actionId}'.`, + }; + } else { + let validLicense = false; + + try { + validLicense = action.isLicenseValid(); + } catch (e) { + // validLicense === false + } + + if (validLicense === false) { + return { + message: `Unable to perform '${action.name}' action due to the current license.`, + }; + } + } + + const fields = action.getMissingFields(data); + + if (fields.length !== 0) { + return { + message: `Unable to perform '${action.name}' action due to missing required fields.`, + fields + }; + } + + return null; +} + +/** + * Attempt to send the {@code data} as a notification. + * + * @param {Object} server Kibana server object. + * @param {NotificationService} notificationService The notification service singleton. + * @param {String} actionId The specified action's ID. + * @param {Function} data The notification data to send via the specified action. + * @param {Function} reply The response function from the server route. + * @param {Function} _checkForErrors Exposed for testing. + */ +export async function sendNotification(server, notificationService, actionId, data, reply, { _checkForErrors = checkForErrors } = { }) { + const action = notificationService.getActionForId(actionId); + const error = _checkForErrors(action, actionId, data); + + if (error === null) { + return action.performAction(data) + .then(result => reply(result.toJson())) + .catch(err => reply(wrap(err))); // by API definition, this should never happen as performAction isn't allow to throw errrors + } + + server.log(['actions', 'error'], error.message); + + reply({ + status_code: 400, + ok: false, + message: `Error: ${error.message}`, + error, + }); +} + +/** + * Notification Service route to perform actions (aka send data). + */ +export function notificationServiceSendRoute(server, notificationService) { + server.route({ + method: 'POST', + path: '/api/notifications/v1/notify', + config: { + validate: { + payload: Joi.object({ + action: Joi.string().required(), + data: Joi.object({ + from: Joi.string(), + to: Joi.string(), + subject: Joi.string().required(), + markdown: Joi.string(), + }).required() + }) + } + }, + handler: (req, reply) => { + const actionId = req.payload.action; + const data = req.payload.data; + + sendNotification(server, notificationService, actionId, data, reply); + }, + }); +} diff --git a/x-pack/plugins/notifications/server/routes/api/v1/notifications/notify.test.js b/x-pack/plugins/notifications/server/routes/api/v1/notifications/notify.test.js new file mode 100644 index 000000000000..55e272b38ce4 --- /dev/null +++ b/x-pack/plugins/notifications/server/routes/api/v1/notifications/notify.test.js @@ -0,0 +1,170 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { checkForErrors, sendNotification } from './notify'; +import { wrap } from 'boom'; + +describe('notifications/routes/send', () => { + + const id = 'notifications-test'; + const notification = { fake: true }; + + describe('checkForErrors', () => { + + it('returns unrecognized action for null action', () => { + expect(checkForErrors(null, id, { })).toEqual({ + message: `Unrecognized action: '${id}'.`, + }); + }); + + it('returns invalid license if license check throws an error', () => { + const action = { + name: 'Test Action', + isLicenseValid: () => { + throw new Error(); + }, + }; + + expect(checkForErrors(action, id, { })).toEqual({ + message: `Unable to perform '${action.name}' action due to the current license.`, + }); + }); + + it('returns invalid license if license is invalid', () => { + const action = { + name: 'Test Action', + isLicenseValid: () => false, + }; + + expect(checkForErrors(action, id, { })).toEqual({ + message: `Unable to perform '${action.name}' action due to the current license.`, + }); + }); + + it('returns fields related to missing data', () => { + const fields = [ { field: 1 } ]; + const action = { + name: 'Test Action', + isLicenseValid: () => true, + getMissingFields: (data) => { + expect(data).toBe(notification); + + return fields; + }, + }; + + const error = checkForErrors(action, id, notification); + + expect(error).toEqual({ + message: `Unable to perform '${action.name}' action due to missing required fields.`, + fields + }); + }); + + it('returns null if action is usable', () => { + const notification = { fake: true }; + const action = { + name: 'Test Action', + isLicenseValid: () => true, + getMissingFields: (data) => { + expect(data).toBe(notification); + + return []; + }, + }; + + expect(checkForErrors(action, id, notification)).toBeNull(); + }); + + }); + + describe('sendNotification', () => { + + it('replies with error object for bad request', async () => { + const error = { + message: 'TEST - expected', + fields: [ { fake: 1 } ], + }; + const action = { }; + const server = { + log: jest.fn(), + }; + const notificationService = { + getActionForId: jest.fn().mockReturnValue(action), + }; + const checkForErrors = jest.fn().mockReturnValue(error); + const reply = jest.fn(); + + await sendNotification(server, notificationService, id, notification, reply, { _checkForErrors: checkForErrors }); + + expect(notificationService.getActionForId).toHaveBeenCalledTimes(1); + expect(notificationService.getActionForId).toHaveBeenCalledWith(id); + expect(checkForErrors).toHaveBeenCalledTimes(1); + expect(checkForErrors).toHaveBeenCalledWith(action, id, notification); + expect(server.log).toHaveBeenCalledTimes(1); + expect(server.log).toHaveBeenCalledWith(['actions', 'error'], error.message); + + expect(reply).toHaveBeenCalledTimes(1); + expect(reply).toHaveBeenCalledWith({ + status_code: 400, + ok: false, + message: `Error: ${error.message}`, + error, + }); + }); + + it('replies with action result JSON', async () => { + const response = { ok: true, message: 'Test' }; + const result = { + toJson: () => response, + }; + const action = { + performAction: jest.fn().mockReturnValue(Promise.resolve(result)) + }; + const server = { }; + const notificationService = { + getActionForId: jest.fn().mockReturnValue(action), + }; + const checkForErrors = jest.fn().mockReturnValue(null); + const reply = jest.fn(); + + await sendNotification(server, notificationService, id, notification, reply, { _checkForErrors: checkForErrors }); + + expect(notificationService.getActionForId).toHaveBeenCalledTimes(1); + expect(notificationService.getActionForId).toHaveBeenCalledWith(id); + expect(checkForErrors).toHaveBeenCalledTimes(1); + expect(checkForErrors).toHaveBeenCalledWith(action, id, notification); + + expect(reply).toHaveBeenCalledTimes(1); + expect(reply).toHaveBeenCalledWith(response); + }); + + it('replies with unexpected result error', async () => { + const error = new Error(); + const action = { + performAction: jest.fn().mockReturnValue(Promise.reject(error)) + }; + const server = { }; + const notificationService = { + getActionForId: jest.fn().mockReturnValue(action), + }; + const checkForErrors = jest.fn().mockReturnValue(null); + const reply = jest.fn(); + + await sendNotification(server, notificationService, id, notification, reply, { _checkForErrors: checkForErrors }); + + expect(notificationService.getActionForId).toHaveBeenCalledTimes(1); + expect(notificationService.getActionForId).toHaveBeenCalledWith(id); + expect(checkForErrors).toHaveBeenCalledTimes(1); + expect(checkForErrors).toHaveBeenCalledWith(action, id, notification); + + expect(reply).toHaveBeenCalledTimes(1); + expect(reply).toHaveBeenCalledWith(wrap(error)); + }); + + }); + +}); diff --git a/x-pack/plugins/notifications/server/service/README.md b/x-pack/plugins/notifications/server/service/README.md new file mode 100644 index 000000000000..3c8e77f36352 --- /dev/null +++ b/x-pack/plugins/notifications/server/service/README.md @@ -0,0 +1,575 @@ +# Notification Service / Actions + +Use this service to send notifications to users. An example of a notification is sending an email or +explicitly adding something to the Kibana log. + +Notifications are inherently asynchronous actions because of the likelihood that any notification is +interacting with a remote server or service. + +## Referencing the Notification Service + +Note: Both of these may change in the future. + +### Server Side + +```js +const notificationService = server.plugins.notifications.notificationService; + +const action = notificationService.getActionForId('xpack-notifications-logger'); +const result = action.performAction({ + arbitrary: 'payload', + can: 'have multiple', + fields: [ 1, 2, 3 ] +}); +``` + +### HTTP + +```http +POST /api/notifications/v1/notify +{ + "action": "xpack-notifications-logger", + "data": { + "arbitrary": "payload", + "can": "have multiple", + "fields": [ 1, 2, 3 ] + } +} +``` + +## Interfaces + +There are two interfaces that are important from this package. `NotificationService`, which is exposed as +a singleton from the plugin: `server.plugins.notifications.notificationService`. And `Action`, which +provides an abstract Javascript `class` to implement new `Action`s. + +### NotificationService Interface + +The `NotificationService` currently has four methods defined with very distinct purposes: + +1. `setAction` is intended for plugin authors to add actions that do not exist with the basic notifications +service. +2. `removeAction` is intended for replacing existing plugins (e.g., augmenting another action). +3. `getActionForId` enables explicitly fetching an action by its known ID. +4. `getActionsForData` enables discovering compatible actions given an arbitrary set of data. + +Note: Mutating the Notification Service should generally only be done based on very specific reasons, +such as plugin initialization or the user dynamically configuring a service's availability (e.g., +if we support a secure data store, the user could theoretically provide authentication details for an email +server). + +It is also possible that the user will want to configure multiple variants of the same action, such as +multiple email notifications with differing defaults. In that case, the action's ID may need to be +reconsidered. + +#### `setAction()` + +This is the only way to add an `Action` instance. Instances are expected to be extensions of the `Action` +class defined here. If the provided action already exists, then the old one is removed. + +##### Syntax + +```js +notificationService.setAction(action); +``` + +###### Parameters + +| Field | Type | Description | +|-------|------|-------------| +| `action` | Action | The unique action to support. | + +###### Returns + +Nothing. + +##### Example + +Create a simple logging action that can be triggered generically. + +```js +class LoggerAction extends Action { + + constructor({ server }) { + super({ server, id: 'xpack-notifications-logger', name: 'Log' }); + } + + getMissingFields() { + return []; + } + + async doPerformHealthCheck() { + return new ActionResult({ + message: `Logger action is always usable.`, + response: { }, + }); + } + + async doPerformAction(notification) { + this.server.log(['logger', 'info'], notification); + + return new ActionResult({ + message: 'Logged data returned as response.', + response: notification + }); + } + +} + +// It's possible that someone may choose to make the LoggerAction's log level configurable, so +// replacing it could be done by re-setting it, which means any follow-on usage would use the new level +// (or, possibly, you could create different actions entirely for different levels) +notificationService.setAction(new LoggerAction({ server })); +``` + +#### `removeAction()` + +Remove an action that has been set. + +##### Syntax + +```js +const action = notificationService.removeAction(actionId); +``` + +###### Parameters + +| Field | Type | Description | +|-------|------|-------------| +| `id` | String | ID of the action to remove. | + +###### Returns + +| Type | Description | +|------|-------------| +| Action \| null | The action that was removed. `null` otherwise. | + +##### Example + +```js +const action = notificationService.removeAction('xpack-notifications-logger'); + +if (action !== null) { + // removed; otherwise it didn't exist (maybe it was already removed) +} +``` + +#### `getActionForId()` + +Retrieve a specific `Action` from the Notification Service. + +##### Syntax + +```js +const action = notificationService.getActionForId(actionId); +``` + +###### Parameters + +| Field | Type | Description | +|-------|------|-------------| +| `id` | String | ID of the action to retrieve. | + +###### Returns + +| Type | Description | +|------|-------------| +| Action \| null | The action that was requested. `null` otherwise. | + +##### Example + +```js +// In this case, the ID is known from the earlier example +const action = notificationService.getActionForId('xpack-notifications-logger'); + +if (action !== null) { + // otherwise it didn't exist +} +``` + +#### `getActionsForData()` + +Retrieve any `Action`s from the Notification Service that accept the supplied data, which is useful for +discovery. + +##### Syntax + +```js +const actions = notificationService.getActionsForData(notification); +``` + +###### Parameters + +| Field | Type | Description | +|-------|------|-------------| +| `notification` | Object | Payload to send notification. | + +###### Returns + +| Type | Description | +|------|-------------| +| Action[] | The actions that accept a subset of the data. Empty array otherwise. | + +##### Example + +```js +// In this case, the ID is known from the earlier example +const actions = notificationService.getActionsForData({ + arbitrary: 'payload', + can: 'have multiple', + fields: [ 1, 2, 3 ] +}); + +if (action.length !== 0) { + // otherwise nothing matches +} +``` + +### Action Interface + +From the perspective of developers that want to make use of `Action`s, there are three relevant methods: + +1. `getMissingFields` provides an array of fields as well as the expected data type that did not exist in the +supplied data. +2. `performHealthCheck` attempts to perform a health check against the actions backing service. +3. `performAction` attempts to perform the purpose of the action (e.g., send the email) using the supplied +data. + +For developers to create new `Action`s, there are three related methods: + +1. `getMissingFields` provides an array of fields as well as the expected data type that did not exist in the +supplied data. +2. `doPerformHealthCheck` attempts to perform a health check against the actions backing service. + - `performHealthCheck` invokes this method and wraps it in order to catch `Error`s. +3. `doPerformAction` attempts to perform the purpose of the action (e.g., send the email) using the supplied +data. + - `performAction` invokes this method and wraps it in order to catch `Error`s. + +Every method, excluding `getMissingFields`, is asynchronous. + +#### `getMissingFields()` + +This method enables the building of "Sharing"-style UIs that allow the same payload to be shared across +many different actions. This is the same approach taken in iOS and Android sharing frameworks. + +##### Syntax + +```js +action.getMissingFields({ + arbitrary: 'payload', + can: 'have multiple', + fields: [ 1, 2, 3 ] +}); +``` + +###### Parameters + +| Field | Type | Description | +|-------|------|-------------| +| `notification` | Object | The data that you want to try to use with the action. | + +###### Returns + +| Type | Description | +|------|-------------| +| Object[] | The fields that were not present in the `notification` object. Empty array otherwise. | + +The object definition should match: + +| Field | Type | Description | +|-------|------|-------------| +| `field` | String | The JSON field name that was expected. | +| `name` | String | The user-readable name of the field (e.g., for a generated UI). | +| `type` | String | The type of data (`email`, `text`, `markdown`, `number`, `date`, `boolean`). | + +NOTE: This method _never_ throws an `Error`. + +##### Example + +Create a simple action whose fields can be checked automatically. + +```js +// Action Users +const action = notificationService.getActionForId(actionId); +const fields = action.getMissingFields({ + arbitrary: 'payload', + can: 'have multiple', + fields: [ 1, 2, 3 ] +}); + +if (fields.length !== 0) { + // there's some fields missing so this action should not be used yet +} + +// Action Creators +class FakeAction extends Action { + + constructor({ server, defaults = { } }) { + super({ server, id: 'xpack-notifications-fake', name: 'Fake' }); + + this.defaults = defaults; + } + + getMissingFields(notification) { + const missingFields = []; + + if (!Boolean(this.defaults.to) && !Boolean(notification.to)) { + missingFields.push({ + field: 'to', + name: 'To', + type: 'email', + }); + } + + return missingFields; + } + + // ... other methods ... + +} +``` + +#### `performHealthCheck()` + +This method enables the health status of third party services to be polled. The current approach only allows +an `Action`'s health to be in a boolean state: either it's up and expected to work, or it's down. + +The health check is interesting because some third party services that we anticipate supporting are +inherently untrustworthy when it comes to supporting health checks (e.g., email servers). Therefore, it +should not be expected that the health check will block usage of actions -- only provide feedback to the +user, which we can ideally provide in a future notification center within the UI. + +##### Syntax + +```js +const result = await action.performHealthCheck(); +``` + +###### Parameters + +None. + +###### Returns + +| Type | Description | +|------|-------------| +| ActionResult | The result of the health check. | + +An `ActionResult` defines a few methods: + +| Method | Type | Description | +|-------|------|-------------| +| `isOk()` | Boolean | `true` if there was no error and it is believed to be "up". | +| `getError()` | Object \| undefined | JSON error object. | +| `getResponse()` | Object \| undefined | JSON response from the server. If the server returns a field, it should be wrapped. | +| `getMessage()` | String | Human readable message describing the state. | + +NOTE: This method _never_ throws an `Error`. + +##### Example + +Create a simple action whose fields can be checked automatically. + +```js +const action = notificationService.getActionForId(actionId); +const result = await action.performHealthCheck(); + +if (result.isOk()) { + // theoretically the action is in a usable state +} else { + // theoretically the action is not in a usable state (but some services may have broken health checks!) +} +``` + +#### `performAction()` + +This method enables the actual usage of the `Action` for action users. + +##### Syntax + +```js +const result = await action.performAction({ + arbitrary: 'payload', + can: 'have multiple', + fields: [ 1, 2, 3 ] +}); +``` + +###### Parameters + +| Field | Type | Description | +|-------|------|-------------| +| `notification` | Object | The data that you want to try to use with the action. | + +###### Returns + +| Type | Description | +|------|-------------| +| ActionResult | The result of the health check. | + +An `ActionResult` defines a few methods: + +| Method | Type | Description | +|-------|------|-------------| +| `isOk()` | Boolean | `true` if there was no error and it is believed to be "up". | +| `getError()` | Object \| undefined | JSON error object. | +| `getResponse()` | Object \| undefined | JSON response from the server. If the server returns a field, it should be wrapped. | +| `getMessage()` | String | Human readable message describing the state. | + +NOTE: This method _never_ throws an `Error`. + +##### Example + +Create a simple action whose fields can be checked automatically. + +```js +const action = notificationService.getActionForId(actionId); +const result = await action.performAction({ + arbitrary: 'payload', + can: 'have multiple', + fields: [ 1, 2, 3 ] +}); + +if (result.isOk()) { + // theoretically the action is in a usable state +} else { + // theoretically the action is not in a usable state (but some services may have broken health checks!) +} +``` + +#### `doPerformHealthCheck()` + +This method is for `Action` creators. This performs the actual work to check the health of the action's +associated service as best as possible. + +This method should be thought of as a `protected` method only and it should never be called directly +outside of tests. + +##### Syntax + +Do not call this method directly outside of tests. + +```js +const result = await action.doPerformHealthCheck(); +``` + +###### Parameters + +None. + +###### Returns + +| Type | Description | +|------|-------------| +| ActionResult | The result of the health check. | + +An `ActionResult` defines a few methods: + +| Method | Type | Description | +|-------|------|-------------| +| `isOk()` | Boolean | `true` if there was no error and it is believed to be "up". | +| `getError()` | Object \| undefined | JSON error object. | +| `getResponse()` | Object \| undefined | JSON response from the server. If the server returns a field, it should be wrapped. | +| `getMessage()` | String | Human readable message describing the state. | + +NOTE: This method can throw an `Error` in lieu of returning an `ActionResult`. + +##### Example + +Create a simple action whose health status can be checked automatically. + +```js +class FakeAction extends Action { + + constructor({ server }) { + super({ server, id: 'xpack-notifications-fake', name: 'Fake' }); + } + + async doPerformHealthCheck() { + // this responds with a boolean 'true' response, otherwise throws an Error + const response = await this.transporter.verify(); + + return new ActionResult({ + message: `Fake action configuration has been verified.`, + response: { + verified: true + }, + }); + } + + // ... other methods ... + +} +``` + +#### `doPerformAction()` + +This method is for `Action` creators. This performs the actual function of the action. + +This method should be thought of as a `protected` method only and it should never be called directly +outside of tests. + +##### Syntax + +Do not call this method directly outside of tests. + +```js +const result = await action.doPerformAction(); +``` + +###### Parameters + +| Field | Type | Description | +|-------|------|-------------| +| `notification` | Object | The data that you want to try to use with the action. | + +###### Returns + +| Type | Description | +|------|-------------| +| ActionResult | The result of the health check. | + +An `ActionResult` defines a few methods: + +| Method | Type | Description | +|-------|------|-------------| +| `isOk()` | Boolean | `true` if there was no error and it is believed to be "up". | +| `getError()` | Object \| undefined | JSON error object. | +| `getResponse()` | Object \| undefined | JSON response from the server. If the server returns a field, it should be wrapped. | +| `getMessage()` | String | Human readable message describing the state. | + +NOTE: This method can throw an `Error` in lieu of returning an `ActionResult`. + +##### Example + +Create a simple action whose health status can be checked automatically. + +```js +class FakeAction extends Action { + + constructor({ server }) { + super({ server, id: 'xpack-notifications-fake', name: 'Fake' }); + } + + async doPerformAction(notification) { + // Note: This throws an Error upon failure + const response = await this.transporter.sendMail({ + from: notification.from, + to: notification.to, + cc: notification.cc, + bcc: notification.bcc, + subject: notification.subject, + html: notification.markdown, + text: notification.markdown, + }); + + return new ActionResult({ + message: `Success! Sent email for '${notification.subject}'.`, + response, + }); + } + + // ... other methods ... + +} +``` diff --git a/x-pack/plugins/notifications/server/service/action.js b/x-pack/plugins/notifications/server/service/action.js new file mode 100644 index 000000000000..6077ff6e2b41 --- /dev/null +++ b/x-pack/plugins/notifications/server/service/action.js @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ActionResult } from './action_result'; + +/** + * Actions represent a singular, generic "action", such as "Send to Email". + * + * Note: Implementations of Action are inherently server-side operations. It may or may not be desiable to fire + * these actions from the UI (triggering a server-side call), but these should be called for such a purpose. + */ +export class Action { + + /** + * Create a new Action. + * + * The suggested ID is the name of the plugin that provides it, and the unique portion. + * For example: "core-email" if core provided email. + * + * @param {Object} server The Kibana server object. + * @param {String} id The unique identifier for the action. + * @param {String} name User-friendly name for the action. + */ + constructor({ server, id, name }) { + this.server = server; + this.id = id; + this.name = name; + } + + /** + * Get the unique ID of the Action. + * + * @return {String} + */ + getId() { + return this.id; + } + + /** + * Get the user-friendly name of the Action. + * + * @return {String} + */ + getName() { + return this.name; + } + + /** + * Determine if this action can use the {@code notification}. This is useful if you use the action service + * generically, such as using it from a generic UI. + * + * This is intended to be a simple check of the {@code notification}, rather than an asynchronous action. + * + * @param {Object} notification The notification data to use. + * @return {Array} Array defining missing fields. Empty if none. + */ + getMissingFields() { + return []; + } + + /** + * Implementers must override to perform the health check. + * + * This should not be called directly outside of tests to ensure that any error is wrapped properly. + * + * Note: Some services do not provide consistent, reliable health checks, such as email. As such, + * implementers must weigh the nature of false negatives versus the utility of having this check. + * + * @return {Promise} The result of the health check, which must be an {@code ActionResult}. + * @throws {Error} if there is an unexpected failure occurs. + */ + async doPerformHealthCheck() { + throw new Error(`[doPerformHealthCheck] is not implemented for '${this.name}' action.`); + } + + /** + * Verify that the action can be used to the best of the ability of the service that it is using. + * + * @return {Promise} Always an {@code ActionResult}. + */ + async performHealthCheck() { + try { + return await this.doPerformHealthCheck(); + } catch (error) { + return new ActionResult({ + message: `Unable to perform '${this.name}' health check: ${error.message}.`, + error + }); + } + } + + /** + * Implementers must override to perform the action using the {@code notification}. + * + * This should not be called directly to ensure that any error is wrapped properly. + * + * @param {Object} notification The notification data to use. + * @return {Promise} The result of the action, which must be a {@code ActionResult}. + * @throws {Error} if the method is not implemented or an unexpected failure occurs. + */ + async doPerformAction(notification) { + throw new Error(`[doPerformAction] is not implemented for '${this.name}' action: ${JSON.stringify(notification)}`); + } + + /** + * Check to see if the current license allows actions. + * + * @return {Boolean} true when it is usable + * @throws {Error} if there is an unexpected issue checking the license + */ + isLicenseValid() { + return this.server.plugins.xpack_main.info.license.isNotBasic(); + } + + /** + * Perform the action using the {@code notification}. + * + * Actions automatically fail if the license check fails. + * + * Note to implementers: override doPerformAction instead of this method to help guarantee proper handling. + * + * @param {Object} notification The notification data to use. + * @return {Promise} The result of the action, which must be a {@code ActionResult}. + */ + async performAction(notification) { + try { + if (!this.isLicenseValid()) { + throw new Error(`The current license does not allow '${this.name}' action.`); + } + + return await this.doPerformAction(notification); + } catch (error) { + return new ActionResult({ + message: `Unable to perform '${this.name}' action: ${error.message}.`, + error + }); + } + } + +} diff --git a/x-pack/plugins/notifications/server/service/action.test.js b/x-pack/plugins/notifications/server/service/action.test.js new file mode 100644 index 000000000000..a6fa5b281313 --- /dev/null +++ b/x-pack/plugins/notifications/server/service/action.test.js @@ -0,0 +1,242 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Action } from './action'; +import { ActionResult } from './action_result'; + +describe('Action', () => { + + const server = { }; + const id = 'notifications-test'; + const unimplementedName = 'Unimplemented'; + const throwsErrorName = 'Throws Error'; + const passThruName = 'Test Action'; + const action = new Action({ server, id, name: unimplementedName }); + const notification = { + fake: true, + }; + + test('id and name to be from constructor', () => { + expect(action.server).toBe(server); + expect(action.getId()).toBe(id); + expect(action.getName()).toBe(unimplementedName); + }); + + test('getMissingFields returns an empty array', () => { + expect(action.getMissingFields()).toEqual([]); + expect(action.getMissingFields(notification)).toEqual([]); + }); + + test('doPerformHealthChecks throws error indicating that it is not implemented', async () => { + await expect(action.doPerformHealthCheck()) + .rejects + .toThrow(`[doPerformHealthCheck] is not implemented for '${unimplementedName}' action.`); + }); + + describe('performHealthChecks', () => { + + class ThrowsErrorHealthCheckAction extends Action { + constructor() { + super({ server: { }, id, name: throwsErrorName }); + } + + async doPerformHealthCheck() { + throw new Error('TEST - expected'); + } + } + + class PassThruHealthCheckAction extends Action { + constructor(result) { + super({ server: { }, id, name: passThruName }); + this.result = result; + } + + async doPerformHealthCheck() { + return this.result; + } + } + + test('runs against unimplemented doPerformHealthChecks', async () => { + const result = await action.performHealthCheck(); + + expect(result instanceof ActionResult).toBe(true); + expect(result.isOk()).toBe(false); + expect(result.getMessage()) + .toMatch(new RegExp(`^Unable to perform '${unimplementedName}' health check: \\[doPerformHealthCheck\\] is not.*`)); + }); + + test('runs against failing doPerformHealthChecks', async () => { + const failAction = new ThrowsErrorHealthCheckAction(); + const result = await failAction.performHealthCheck(); + + expect(result instanceof ActionResult).toBe(true); + expect(result.isOk()).toBe(false); + expect(result.getMessage()) + .toMatch(new RegExp(`^Unable to perform '${throwsErrorName}' health check: TEST - expected`)); + }); + + test('runs against succeeding result', async () => { + const expectedResult = new ActionResult({ message: 'Blah', response: { ok: true } }); + const succeedsAction = new PassThruHealthCheckAction(expectedResult); + const result = await succeedsAction.performHealthCheck(); + + expect(result).toBe(expectedResult); + }); + + }); + + test('doPerformAction throws error indicating that it is not implemented', async () => { + await expect(action.doPerformAction(notification)) + .rejects + .toThrow(`[doPerformAction] is not implemented for '${unimplementedName}' action: {"fake":true}`); + }); + + describe('isLicenseValid', () => { + + test('server variable is not exposed as expected', () => { + expect(() => action.isLicenseValid()).toThrow(Error); + }); + + test('returns false is license is not valid', () => { + const unlicensedServer = { + plugins: { + xpack_main: { + info: { + license: { + isNotBasic: () => false + } + } + } + } + }; + const unlicensedAction = new Action({ server: unlicensedServer, id, name: unimplementedName }); + + expect(unlicensedAction.isLicenseValid()).toBe(false); + }); + + test('returns true is license is not valid', () => { + const licensedServer = { + plugins: { + xpack_main: { + info: { + license: { + isNotBasic: () => true + } + } + } + } + }; + const licensedAction = new Action({ server: licensedServer, id, name: unimplementedName }); + + expect(licensedAction.isLicenseValid()).toBe(true); + }); + + }); + + describe('performAction', () => { + + // valid license + const server = { + plugins: { + xpack_main: { + info: { + license: { + isNotBasic: () => true + } + } + } + } + }; + + class ThrowsErrorAction extends Action { + constructor() { + super({ server, id, name: throwsErrorName }); + } + + async doPerformAction() { + throw new Error('TEST - expected'); + } + } + + class PassThruAction extends Action { + constructor(result) { + super({ server, id, name: passThruName }); + this.result = result; + } + + async doPerformAction() { + return this.result; + } + } + + describe('fails license check', () => { + + // handles the case when xpack_main definitions change + test('because of bad reference', async () => { + // server is an empty object, so a reference fails early in the chain (mostly a way to give devs a way to find this error) + const result = await action.performAction(notification); + + expect(result instanceof ActionResult).toBe(true); + expect(result.isOk()).toBe(false); + }); + + test('because license is invalid or basic', async () => { + const unlicensedServer = { + plugins: { + xpack_main: { + info: { + license: { + isNotBasic: () => false + } + } + } + } + }; + const unlicensedAction = new Action({ server: unlicensedServer, id, name: unimplementedName }); + const result = await unlicensedAction.performAction(notification); + + expect(result instanceof ActionResult).toBe(true); + expect(result.isOk()).toBe(false); + expect(result.getMessage()) + .toMatch( + `Unable to perform '${unimplementedName}' action: ` + + `The current license does not allow '${unimplementedName}' action.` + ); + }); + + }); + + test('runs against unimplemented doPerformAction', async () => { + const licensedAction = new Action({ server, id, name: unimplementedName }); + const result = await licensedAction.performAction(notification); + + expect(result instanceof ActionResult).toBe(true); + expect(result.isOk()).toBe(false); + expect(result.getMessage()) + .toMatch(new RegExp(`^Unable to perform '${unimplementedName}' action: \\[doPerformAction\\] is not.*`)); + }); + + test('runs against failing doPerformAction', async () => { + const failAction = new ThrowsErrorAction(); + const result = await failAction.performAction(notification); + + expect(result instanceof ActionResult).toBe(true); + expect(result.isOk()).toBe(false); + expect(result.getMessage()) + .toMatch(new RegExp(`^Unable to perform '${throwsErrorName}' action: TEST - expected`)); + }); + + test('runs against succeeding result', async () => { + const expectedResult = new ActionResult({ message: 'Blah', response: { ok: true } }); + const succeedsAction = new PassThruAction(expectedResult); + const result = await succeedsAction.performAction(notification); + + expect(result).toBe(expectedResult); + }); + + }); + +}); diff --git a/x-pack/plugins/notifications/server/service/action_result.js b/x-pack/plugins/notifications/server/service/action_result.js new file mode 100644 index 000000000000..35507045c898 --- /dev/null +++ b/x-pack/plugins/notifications/server/service/action_result.js @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * Action Results represent generic, predictable responses from Actions. + */ +export class ActionResult { + + /** + * Create a new Action Result. + * + * Success is determined by the existence of an error. + * + * @param {String} message The message to display about the result, presumably in a Toast. + * @param {Object|undefined} response The response from the "other" side. + * @param {Object|undefined} error The error, if any. + */ + constructor({ message, response, error }) { + this.message = message; + this.response = response; + this.error = error; + this.ok = !Boolean(error); + } + + /** + * Get the error caused by the action. + * + * @returns {Object|undefined} The error response, or {@code undefined} if no error. + */ + getError() { + return this.error; + } + + /** + * Get the message displayable to the user. + * + * @returns {String} The message. + */ + getMessage() { + return this.message; + } + + /** + * The raw JSON response from the action. + * + * @returns {Object|undefined} The JSON response. + */ + getResponse() { + return this.response; + } + + /** + * Determine if the action succeeded. + * + * @returns {Boolean} {@code true} for success. + */ + isOk() { + return this.ok; + } + + toJson() { + return { + ok: this.ok, + error: this.error, + message: this.message, + response: this.response, + }; + } + +} diff --git a/x-pack/plugins/notifications/server/service/action_result.test.js b/x-pack/plugins/notifications/server/service/action_result.test.js new file mode 100644 index 000000000000..128ddb1dd8b9 --- /dev/null +++ b/x-pack/plugins/notifications/server/service/action_result.test.js @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ActionResult } from './action_result'; + +describe('ActionResult', () => { + + const message = 'this is a message'; + const response = { other: { side: { response: true } } }; + const error = { message: `Error: ${message}` }; + + const okResult = new ActionResult({ message, response }); + const notOkResult = new ActionResult({ message, response, error }); + + test('getError returns supplied error or undefined', () => { + expect(okResult.getError()).toBeUndefined(); + expect(notOkResult.getError()).toBe(error); + }); + + test('getMessage returns supplied message', () => { + expect(okResult.getMessage()).toBe(message); + expect(notOkResult.getMessage()).toBe(message); + }); + + test('getResponse returns supplied response', () => { + expect(okResult.getResponse()).toBe(response); + expect(notOkResult.getResponse()).toBe(response); + }); + + test('isOk returns based on having an error', () => { + expect(okResult.isOk()).toBe(true); + expect(notOkResult.isOk()).toBe(false); + }); + + test('toJson', () => { + expect(okResult.toJson()).toEqual({ + ok: true, + error: undefined, + message, + response, + }); + + expect(notOkResult.toJson()).toEqual({ + ok: false, + error, + message, + response, + }); + }); + +}); diff --git a/x-pack/plugins/notifications/server/service/index.js b/x-pack/plugins/notifications/server/service/index.js new file mode 100644 index 000000000000..dd64e16abc71 --- /dev/null +++ b/x-pack/plugins/notifications/server/service/index.js @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { notificationService } from './notification_service'; +export { Action } from './action'; +export { ActionResult } from './action_result'; diff --git a/x-pack/plugins/notifications/server/service/notification_service.js b/x-pack/plugins/notifications/server/service/notification_service.js new file mode 100644 index 000000000000..5804532d8be6 --- /dev/null +++ b/x-pack/plugins/notifications/server/service/notification_service.js @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * Notification Service represents a service that contains generic "actions", such as "Send to Email" + * that are added at startup by plugins to enable notifications to be sent either by the user manually + * or via the some scheduled / automated task. + */ +export class NotificationService { + + constructor() { + this.actions = []; + } + + /** + * Add a new action to the action service. + * + * @param {Action} action An implementation of Action. + */ + setAction = (action) => { + this.removeAction(action.id); + + this.actions.push(action); + } + + /** + * Remove an existing action from the action service. + * + * @param {String} id The ID of the action to remove. + * @return {Action} The action that was removed, or null. + */ + removeAction = (id) => { + const index = this.actions.findIndex(action => action.id === id); + + if (index !== -1) { + const removedActions = this.actions.splice(index, 1); + return removedActions[0]; + } + + return null; + } + + /** + * Get action with the specified {@code id}, if any. + * + * This is useful when you know that an action is provided, such as one provided by your own plugin, + * and you want to use it to handle things in a consistent way. + * + * @param {String} id The ID of the Action. + * @return {Action} The Action that matches the ID, or null. + */ + getActionForId = (id) => { + const index = this.actions.findIndex(action => action.id === id); + + if (index !== -1) { + return this.actions[index]; + } + + return null; + } + + /** + * Get actions that will accept the {@code data}. + * + * @param {Object} data The data object to pass to actions. + * @return {Array} An array of Actions that can be used with the data, if any. Empty if none. + */ + getActionsForData = (data) => { + return this.actions.filter(action => { + try { + return action.getMissingFields(data).length === 0; + } catch (err) { + return false; + } + }); + } + +} + +/** + * A singleton reference to the notification service intended to be used across Kibana. + */ +export const notificationService = new NotificationService(); diff --git a/x-pack/plugins/notifications/server/service/notification_service.test.js b/x-pack/plugins/notifications/server/service/notification_service.test.js new file mode 100644 index 000000000000..903d7ea0c5d9 --- /dev/null +++ b/x-pack/plugins/notifications/server/service/notification_service.test.js @@ -0,0 +1,134 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Action } from './action'; +import { NotificationService } from './notification_service'; + +class TestAction extends Action { + constructor({ server, id }) { + super({ server, id, name: 'TestAction' }); + } + + getMissingFields() { + return []; + } +} + +// always returns a missing field +class MissingFieldTestAction extends Action { + constructor({ server, id }) { + super({ server, id, name: 'MissingFieldTestAction' }); + } + + getMissingFields() { + return [ { field: 'subject', name: 'Subject', type: 'text' } ]; + } +} + +describe('NotificationService', () => { + + const server = { }; + const actionId = 'notifications-test'; + const action = new TestAction({ server, id: actionId }); + + let notificationService; + + beforeEach(() => { + notificationService = new NotificationService(); + }); + + test('initializes with no default actions', () => { + expect(notificationService.actions).toEqual([]); + }); + + describe('setAction', () => { + + test('adds the action', () => { + notificationService.setAction(action); + + expect(notificationService.actions[0]).toBe(action); + }); + + test('removes any action with the same ID first, then adds the action', () => { + notificationService.setAction({ id: actionId }); + notificationService.setAction(action); + + expect(notificationService.actions).toHaveLength(1); + expect(notificationService.actions[0]).toBe(action); + }); + + }); + + describe('removeAction', () => { + + test('returns null if the action does not exist', () => { + expect(notificationService.removeAction(actionId)).toBe(null); + + notificationService.setAction(action); + + expect(notificationService.removeAction('not-' + actionId)).toBe(null); + expect(notificationService.actions[0]).toBe(action); + }); + + test('returns the removed action', () => { + notificationService.setAction(action); + + expect(notificationService.removeAction(actionId)).toBe(action); + expect(notificationService.actions).toEqual([]); + }); + + }); + + describe('getActionForId', () => { + + test('returns null if the action does not exist', () => { + expect(notificationService.getActionForId(actionId)).toBe(null); + + notificationService.setAction(action); + + expect(notificationService.getActionForId('not-' + actionId)).toBe(null); + expect(notificationService.actions[0]).toBe(action); + }); + + test('returns the action', () => { + notificationService.setAction(action); + + expect(notificationService.getActionForId(actionId)).toBe(action); + expect(notificationService.actions[0]).toBe(action); + }); + + }); + + describe('getActionsForData', () => { + + test('returns [] if no corresponding action exists', () => { + expect(notificationService.getActionsForData({})).toEqual([]); + + notificationService.setAction(new MissingFieldTestAction({ server, id: 'always-missing' })); + + expect(notificationService.getActionsForData({})).toEqual([]); + expect(notificationService.actions).toHaveLength(1); + }); + + test('returns the actions that match', () => { + notificationService.setAction(action); + + expect(notificationService.getActionsForData({})).toEqual([ action ]); + expect(notificationService.actions[0]).toBe(action); + + const otherActionId = 'other-' + actionId; + + notificationService.setAction(new MissingFieldTestAction({ server, id: 'always-missing' })); + notificationService.setAction(new TestAction({ server, id: otherActionId })); + + const actions = notificationService.getActionsForData({}); + + expect(actions.map(action => action.id)).toEqual([ actionId, otherActionId ]); + }); + + }); + +}); diff --git a/x-pack/plugins/notifications/server/slack/create_slack_action.js b/x-pack/plugins/notifications/server/slack/create_slack_action.js new file mode 100644 index 000000000000..ecffcadc9b4d --- /dev/null +++ b/x-pack/plugins/notifications/server/slack/create_slack_action.js @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SlackAction } from './slack_action'; + +/** + * Create a Slack options object from the config. + * + * @param {Object} config The server configuration. + * @return {Object} An object that configures Slack. + */ +export function optionsFromConfig(config) { + return { + token: config.get('xpack.notifications.slack.token') + }; +} + +/** + * Create a Slack defaults object from the config. + * + * Defaults include things like the default channel that messages are posted to. + * + * @param {Object} config The server configuration. + * @return {Object} An object that configures Slack on a per-message basis. + */ +export function defaultsFromConfig(config) { + return { + channel: config.get('xpack.notifications.slack.defaults.channel'), + as_user: config.get('xpack.notifications.slack.defaults.as_user'), + icon_emoji: config.get('xpack.notifications.slack.defaults.icon_emoji'), + icon_url: config.get('xpack.notifications.slack.defaults.icon_url'), + link_names: config.get('xpack.notifications.slack.defaults.link_names'), + mrkdwn: config.get('xpack.notifications.slack.defaults.mrkdwn'), + unfurl_links: config.get('xpack.notifications.slack.defaults.unfurl_links'), + unfurl_media: config.get('xpack.notifications.slack.defaults.unfurl_media'), + username: config.get('xpack.notifications.slack.defaults.username'), + }; +} + +/** + * Create a new Slack Action based on the configuration. + * + * @param {Object} server The server object. + * @return {SlackAction} A new Slack Action based on the kibana.yml configuration. + */ +export function createSlackAction(server, { _options = optionsFromConfig, _defaults = defaultsFromConfig } = { }) { + const config = server.config(); + + const options = _options(config); + const defaults = _defaults(config); + + return new SlackAction({ server, options, defaults }); +} diff --git a/x-pack/plugins/notifications/server/slack/create_slack_action.test.js b/x-pack/plugins/notifications/server/slack/create_slack_action.test.js new file mode 100644 index 000000000000..68d669985568 --- /dev/null +++ b/x-pack/plugins/notifications/server/slack/create_slack_action.test.js @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SlackAction } from './slack_action'; +import { + createSlackAction, + defaultsFromConfig, + optionsFromConfig, +} from './create_slack_action'; + +describe('create_slack_action', () => { + + test('optionsFromConfig uses config without modification', () => { + const get = key => { + const suffixes = [ + 'token', + ]; + const value = suffixes.find(suffix => { + return `xpack.notifications.slack.${suffix}` === key; + }); + + if (value === undefined) { + throw new Error(`Unknown config key used ${key}`); + } + + return value; + }; + + expect(optionsFromConfig({ get })).toEqual({ + token: 'token', + }); + }); + + test('defaultsFromConfig uses config without modification', () => { + const get = key => { + const suffixes = [ + 'channel', + 'as_user', + 'icon_emoji', + 'icon_url', + 'link_names', + 'mrkdwn', + 'unfurl_links', + 'unfurl_media', + 'username', + ]; + const value = suffixes.find(suffix => { + return `xpack.notifications.slack.defaults.${suffix}` === key; + }); + + if (value === undefined) { + throw new Error(`Unknown config key used ${key}`); + } + + return value; + }; + + expect(defaultsFromConfig({ get })).toEqual({ + channel: 'channel', + as_user: 'as_user', + icon_emoji: 'icon_emoji', + icon_url: 'icon_url', + link_names: 'link_names', + mrkdwn: 'mrkdwn', + unfurl_links: 'unfurl_links', + unfurl_media: 'unfurl_media', + username: 'username', + }); + }); + + test('createSlackAction', async () => { + const config = { }; + const server = { config: jest.fn().mockReturnValue(config) }; + const _options = jest.fn().mockReturnValue({ options: true }); + const defaults = { defaults: true }; + const _defaults = jest.fn().mockReturnValue(defaults); + + const action = createSlackAction(server, { _options, _defaults }); + + expect(action instanceof SlackAction).toBe(true); + expect(action.defaults).toBe(defaults); + + expect(server.config).toHaveBeenCalledTimes(1); + expect(server.config).toHaveBeenCalledWith(); + expect(_options).toHaveBeenCalledTimes(1); + expect(_options).toHaveBeenCalledWith(config); + expect(_defaults).toHaveBeenCalledTimes(1); + expect(_defaults).toHaveBeenCalledWith(config); + }); + +}); diff --git a/x-pack/plugins/notifications/server/slack/index.js b/x-pack/plugins/notifications/server/slack/index.js new file mode 100644 index 000000000000..da7cd204e888 --- /dev/null +++ b/x-pack/plugins/notifications/server/slack/index.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { createSlackAction } from './create_slack_action'; diff --git a/x-pack/plugins/notifications/server/slack/slack_action.js b/x-pack/plugins/notifications/server/slack/slack_action.js new file mode 100644 index 000000000000..236ce1af9c36 --- /dev/null +++ b/x-pack/plugins/notifications/server/slack/slack_action.js @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { WebClient } from '@slack/client'; + +import { Action, ActionResult } from '../'; + +export const SLACK_ACTION_ID = 'xpack-notifications-slack'; + +/** + * Create a new Slack {@code WebClient}. + * + * Currently the only option expected is {@code token}. + * + * @param {Object} options Slack API options. + * @returns {WebClient} Always. + */ +export function webClientCreator(options) { + return new WebClient(options.token); +} + +/** + * Slack Action enables generic sending of Slack messages. + */ +export class SlackAction extends Action { + + /** + * Create a new Action capable of sending Slack messages. + * + * @param {Object} server Kibana server object. + * @param {Object} options Configuration options for the Slack WebClient. Currently only expect "token" field. + * @param {Object} defaults Default fields used when sending messages. + * @param {Function} _webClientCreator Exposed for tests. + */ + constructor({ server, options, defaults = { }, _webClientCreator = webClientCreator }) { + super({ server, id: SLACK_ACTION_ID, name: 'Slack' }); + + this.client = _webClientCreator(options); + this.defaults = defaults; + } + + getMissingFields(data) { + const missingFields = []; + + if (!Boolean(this.defaults.channel) && !Boolean(data.channel)) { + missingFields.push({ + field: 'channel', + name: 'Channel', + type: 'text', + }); + } + + if (!Boolean(data.subject)) { + missingFields.push({ + field: 'subject', + name: 'Message', + type: 'markdown', + }); + } + + return missingFields; + } + + async doPerformHealthCheck() { + const response = await this.client.api.test(); + + if (response.ok) { + return new ActionResult({ + message: `Slack action configuration has been verified.`, + response, + }); + } + + return new ActionResult({ + message: `Slack action configuration could not be verified.`, + response, + error: response.error || { message: 'Unknown Error' }, + }); + } + + /** + * Render the message based on whether or not a {@code markdown} body was supplied. + */ + renderMessage({ subject, markdown }) { + const attachments = []; + + if (markdown) { + attachments.push({ text: markdown }); + } + + return { text: subject, attachments }; + } + + async doPerformAction({ subject, markdown, channel }) { + // NOTE: When we want to support files, then we should look into using client.files.upload({ ... }) + // without _also_ sending chat message because the file upload endpoint supports chat behavior + // in addition to files, but the reverse is not true. + const slackChannel = channel || this.defaults.channel; + + const response = await this.client.chat.postMessage({ + ...this.defaults, + ...this.renderMessage({ subject, markdown }), + channel: slackChannel, + }); + + return new ActionResult({ + message: `Posted Slack message to channel '${slackChannel}'.`, + response, + error: response.error, + }); + } + +} diff --git a/x-pack/plugins/notifications/server/slack/slack_action.test.js b/x-pack/plugins/notifications/server/slack/slack_action.test.js new file mode 100644 index 000000000000..833047a580ab --- /dev/null +++ b/x-pack/plugins/notifications/server/slack/slack_action.test.js @@ -0,0 +1,277 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { WebClient } from '@slack/client'; +import { ActionResult } from '../'; +import { + SLACK_ACTION_ID, + SlackAction, + webClientCreator, +} from './slack_action'; + +describe('SlackAction', () => { + + const server = { }; + const options = { options: true }; + const defaults = { defaults: true }; + const client = { + api: { + // see beforeEach + }, + chat: { + // see beforeEach + } + }; + let _webClientCreator; + + let action; + + beforeEach(() => { + client.api.test = jest.fn(); + client.chat.postMessage = jest.fn(); + _webClientCreator = jest.fn().mockReturnValue(client); + + action = new SlackAction({ server, options, defaults, _webClientCreator }); + }); + + test('webClientCreator creates a WebClient', () => { + expect(webClientCreator('faketoken') instanceof WebClient).toBe(true); + }); + + test('id and name to be from constructor', () => { + expect(action.getId()).toBe(SLACK_ACTION_ID); + expect(action.getName()).toBe('Slack'); + expect(action.client).toBe(client); + + expect(_webClientCreator).toHaveBeenCalledTimes(1); + expect(_webClientCreator).toHaveBeenCalledWith(options); + }); + + describe('getMissingFields', () => { + + test('returns missing fields', () => { + const channel = { field: 'channel', name: 'Channel', type: 'text' }; + const subject = { field: 'subject', name: 'Message', type: 'markdown' }; + + const missing = [ + { defaults: { }, notification: { }, missing: [ channel, subject, ], }, + { defaults: { }, notification: { channel: '#kibana', }, missing: [ subject, ], }, + { defaults: { channel: '#kibana', }, notification: { }, missing: [ subject, ], }, + { defaults: { }, notification: { subject: 'subject', }, missing: [ channel, ], }, + ]; + + missing.forEach(check => { + const newDefaultsAction = new SlackAction({ server, options, defaults: check.defaults, _webClientCreator }); + + expect(newDefaultsAction.getMissingFields(check.notification)).toEqual(check.missing); + }); + }); + + test('returns [] when all fields exist', () => { + const exists = [ + { defaults: { }, notification: { channel: '#kibana', subject: 'subject', }, }, + { defaults: { channel: '#kibana', }, notification: { subject: 'subject', }, }, + ]; + + exists.forEach(check => { + const newDefaultsAction = new SlackAction({ server, options, defaults: check.defaults, _webClientCreator }); + + expect(newDefaultsAction.getMissingFields(check.notification)).toEqual([]); + }); + }); + + }); + + describe('doPerformHealthCheck', () => { + + test('rethrows Error for failure', async () => { + const error = new Error('TEST - expected'); + + client.api.test.mockRejectedValue(error); + + await expect(action.doPerformHealthCheck()) + .rejects + .toThrow(error); + + expect(client.api.test).toHaveBeenCalledTimes(1); + expect(client.api.test).toHaveBeenCalledWith(); + }); + + test('returns ActionResult if not ok with error', async () => { + const response = { ok: false, error: { expected: true } }; + + client.api.test.mockResolvedValue(response); + + const result = await action.doPerformHealthCheck(); + + expect(result instanceof ActionResult).toBe(true); + expect(result.isOk()).toBe(false); + expect(result.getMessage()).toMatch('Slack action configuration could not be verified.'); + expect(result.getResponse()).toBe(response); + expect(result.getError()).toBe(response.error); + + expect(client.api.test).toHaveBeenCalledTimes(1); + expect(client.api.test).toHaveBeenCalledWith(); + }); + + test('returns ActionResult if not ok with default error', async () => { + const response = { ok: false }; + + client.api.test.mockResolvedValue(response); + + const result = await action.doPerformHealthCheck(); + + expect(result instanceof ActionResult).toBe(true); + expect(result.isOk()).toBe(false); + expect(result.getMessage()).toMatch('Slack action configuration could not be verified.'); + expect(result.getResponse()).toBe(response); + expect(result.getError()).toEqual({ message: 'Unknown Error' }); + + expect(client.api.test).toHaveBeenCalledTimes(1); + expect(client.api.test).toHaveBeenCalledWith(); + }); + + test('returns ActionResult for success', async () => { + const response = { ok: true }; + + client.api.test.mockResolvedValue(response); + + const result = await action.doPerformHealthCheck(); + + expect(result instanceof ActionResult).toBe(true); + expect(result.isOk()).toBe(true); + expect(result.getMessage()).toMatch('Slack action configuration has been verified.'); + expect(result.getResponse()).toBe(response); + + expect(client.api.test).toHaveBeenCalledTimes(1); + expect(client.api.test).toHaveBeenCalledWith(); + }); + + }); + + describe('renderMessage', () => { + + test('does not contain attachments', () => { + const message = { subject: 'subject' }; + const response = action.renderMessage(message); + + expect(response).toMatchObject({ + text: message.subject, + attachments: [ ] + }); + }); + + test('contains attachments', () => { + const message = { subject: 'subject', markdown: 'markdown' }; + const response = action.renderMessage(message); + + expect(response).toMatchObject({ + text: message.subject, + attachments: [ + { + text: message.markdown + } + ] + }); + }); + + }); + + describe('doPerformAction', () => { + const message = { channel: '#kibana', subject: 'subject', markdown: 'body', }; + + test('rethrows Error for failure', async () => { + const error = new Error('TEST - expected'); + + client.chat.postMessage.mockRejectedValue(error); + + await expect(action.doPerformAction(message)) + .rejects + .toThrow(error); + + expect(client.chat.postMessage).toHaveBeenCalledTimes(1); + expect(client.chat.postMessage).toHaveBeenCalledWith({ + ...defaults, + ...action.renderMessage(message), + channel: message.channel, + }); + }); + + test('returns ActionResult for failure without Error', async () => { + const response = { fake: true, error: { expected: true } }; + + client.chat.postMessage.mockResolvedValue(response); + + const result = await action.doPerformAction(message); + + expect(result instanceof ActionResult).toBe(true); + expect(result.isOk()).toBe(false); + expect(result.getMessage()).toMatch(`Posted Slack message to channel '${message.channel}'.`); + expect(result.getResponse()).toBe(response); + expect(result.getError()).toBe(response.error); + + expect(client.chat.postMessage).toHaveBeenCalledTimes(1); + expect(client.chat.postMessage).toHaveBeenCalledWith({ + ...defaults, + ...action.renderMessage(message), + channel: message.channel, + }); + }); + + + test('returns ActionResult for success', async () => { + const response = { fake: true }; + + client.chat.postMessage.mockResolvedValue(response); + + const result = await action.doPerformAction(message); + + expect(result instanceof ActionResult).toBe(true); + expect(result.isOk()).toBe(true); + expect(result.getMessage()).toMatch(`Posted Slack message to channel '${message.channel}'.`); + expect(result.getResponse()).toBe(response); + + expect(client.chat.postMessage).toHaveBeenCalledTimes(1); + expect(client.chat.postMessage).toHaveBeenCalledWith({ + ...defaults, + ...action.renderMessage(message), + channel: message.channel, + }); + }); + + test('returns ActionResult for success with default channel', async () => { + const response = { fake: false }; + + client.chat.postMessage.mockResolvedValue(response); + + const channelDefaults = { + ...defaults, + channel: '#kibana', + }; + const noChannelMessage = { + ...message, + channel: undefined, + }; + const newDefaultsAction = new SlackAction({ server, options, defaults: channelDefaults, _webClientCreator }); + + const result = await newDefaultsAction.doPerformAction(noChannelMessage); + + expect(result instanceof ActionResult).toBe(true); + expect(result.isOk()).toBe(true); + expect(result.getMessage()).toMatch(`Posted Slack message to channel '${channelDefaults.channel}'.`); + expect(result.getResponse()).toBe(response); + + expect(client.chat.postMessage).toHaveBeenCalledTimes(1); + expect(client.chat.postMessage).toHaveBeenCalledWith({ + ...defaults, + ...action.renderMessage(noChannelMessage), + channel: channelDefaults.channel, + }); + }); + + }); + +}); diff --git a/x-pack/plugins/xpack_main/server/lib/xpack_info_license.js b/x-pack/plugins/xpack_main/server/lib/xpack_info_license.js index 8ba81d24b3b3..b87bae9e403d 100644 --- a/x-pack/plugins/xpack_main/server/lib/xpack_info_license.js +++ b/x-pack/plugins/xpack_main/server/lib/xpack_info_license.js @@ -39,6 +39,9 @@ export class XPackInfoLicense { /** * Returns license expiration date in ms. + * + * Note: A basic license created after 6.3 will have no expiration, thus returning undefined. + * * @returns {number|undefined} */ getExpiryDateInMillis() { @@ -47,7 +50,7 @@ export class XPackInfoLicense { /** * Checks if the license is represented in a specified license list. - * @param candidateLicenses List of the licenses to check against. + * @param {String} candidateLicenses List of the licenses to check against. * @returns {boolean} */ isOneOf(candidateLicenses) { @@ -65,4 +68,46 @@ export class XPackInfoLicense { getType() { return get(this._getRawLicense(), 'type'); } + + /** + * Returns mode of the license (basic, gold etc.). This is the "effective" type of the license. + * @returns {string|undefined} + */ + getMode() { + return get(this._getRawLicense(), 'mode'); + } + + /** + * Determine if the current license is active and the supplied {@code type}. + * + * @param {Function} typeChecker The license type checker. + * @returns {boolean} + */ + isActiveLicense(typeChecker) { + const license = this._getRawLicense(); + + return get(license, 'status') === 'active' && typeChecker(get(license, 'mode')); + } + + /** + * Determine if the license is an active, basic license. + * + * Note: This also verifies that the license is active. Therefore it is not safe to assume that !isBasic() === isNotBasic(). + * + * @returns {boolean} + */ + isBasic() { + return this.isActiveLicense(mode => mode === 'basic'); + } + + /** + * Determine if the license is an active, non-basic license (e.g., standard, gold, platinum, or trial). + * + * Note: This also verifies that the license is active. Therefore it is not safe to assume that !isBasic() === isNotBasic(). + * + * @returns {boolean} + */ + isNotBasic() { + return this.isActiveLicense(mode => mode !== 'basic'); + } } diff --git a/x-pack/plugins/xpack_main/server/lib/xpack_info_license.test.js b/x-pack/plugins/xpack_main/server/lib/xpack_info_license.test.js new file mode 100644 index 000000000000..300110744e97 --- /dev/null +++ b/x-pack/plugins/xpack_main/server/lib/xpack_info_license.test.js @@ -0,0 +1,183 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { XPackInfoLicense } from './xpack_info_license'; + +function getXPackInfoLicense(getRawLicense) { + return new XPackInfoLicense(getRawLicense); +} + +describe('XPackInfoLicense', () => { + + const xpackInfoLicenseUndefined = getXPackInfoLicense(() => { }); + let xpackInfoLicense; + let getRawLicense; + + beforeEach(() => { + getRawLicense = jest.fn(); + xpackInfoLicense = getXPackInfoLicense(getRawLicense); + }); + + test('getUid returns uid field', () => { + const uid = 'abc123'; + + getRawLicense.mockReturnValue({ uid }); + + expect(xpackInfoLicense.getUid()).toBe(uid); + expect(getRawLicense).toHaveBeenCalledTimes(1); + + expect(xpackInfoLicenseUndefined.getUid()).toBe(undefined); + }); + + test('isActive returns true if status is active', () => { + getRawLicense.mockReturnValue({ status: 'active' }); + + expect(xpackInfoLicense.isActive()).toBe(true); + expect(getRawLicense).toHaveBeenCalledTimes(1); + }); + + test('isActive returns false if status is not active', () => { + getRawLicense.mockReturnValue({ status: 'aCtIvE' }); // needs to match exactly + + expect(xpackInfoLicense.isActive()).toBe(false); + expect(getRawLicense).toHaveBeenCalledTimes(1); + + expect(xpackInfoLicenseUndefined.isActive()).toBe(false); + }); + + test('getExpiryDateInMillis returns expiry_date_in_millis', () => { + getRawLicense.mockReturnValue({ expiry_date_in_millis: 123 }); + + expect(xpackInfoLicense.getExpiryDateInMillis()).toBe(123); + expect(getRawLicense).toHaveBeenCalledTimes(1); + + expect(xpackInfoLicenseUndefined.getExpiryDateInMillis()).toBe(undefined); + }); + + test('isOneOf returns true of the mode includes one of the types', () => { + getRawLicense.mockReturnValue({ mode: 'platinum' }); + + expect(xpackInfoLicense.isOneOf('platinum')).toBe(true); + expect(getRawLicense).toHaveBeenCalledTimes(1); + + expect(xpackInfoLicense.isOneOf([ 'platinum' ])).toBe(true); + expect(getRawLicense).toHaveBeenCalledTimes(2); + expect(xpackInfoLicense.isOneOf([ 'gold', 'platinum' ])).toBe(true); + expect(getRawLicense).toHaveBeenCalledTimes(3); + expect(xpackInfoLicense.isOneOf([ 'platinum', 'gold' ])).toBe(true); + expect(getRawLicense).toHaveBeenCalledTimes(4); + expect(xpackInfoLicense.isOneOf([ 'basic', 'gold' ])).toBe(false); + expect(getRawLicense).toHaveBeenCalledTimes(5); + expect(xpackInfoLicense.isOneOf([ 'basic' ])).toBe(false); + expect(getRawLicense).toHaveBeenCalledTimes(6); + + expect(xpackInfoLicenseUndefined.isOneOf([ 'platinum', 'gold' ])).toBe(false); + }); + + test('getType returns the type', () => { + getRawLicense.mockReturnValue({ type: 'basic' }); + + expect(xpackInfoLicense.getType()).toBe('basic'); + expect(getRawLicense).toHaveBeenCalledTimes(1); + + getRawLicense.mockReturnValue({ type: 'gold' }); + + expect(xpackInfoLicense.getType()).toBe('gold'); + expect(getRawLicense).toHaveBeenCalledTimes(2); + + expect(xpackInfoLicenseUndefined.getType()).toBe(undefined); + }); + + test('getMode returns the mode', () => { + getRawLicense.mockReturnValue({ mode: 'basic' }); + + expect(xpackInfoLicense.getMode()).toBe('basic'); + expect(getRawLicense).toHaveBeenCalledTimes(1); + + getRawLicense.mockReturnValue({ mode: 'gold' }); + + expect(xpackInfoLicense.getMode()).toBe('gold'); + expect(getRawLicense).toHaveBeenCalledTimes(2); + + expect(xpackInfoLicenseUndefined.getMode()).toBe(undefined); + }); + + test('isActiveLicense returns the true if active and typeChecker matches', () => { + const expectAbc123 = type => type === 'abc123'; + + getRawLicense.mockReturnValue({ status: 'active', mode: 'abc123' }); + + expect(xpackInfoLicense.isActiveLicense(expectAbc123)).toBe(true); + expect(getRawLicense).toHaveBeenCalledTimes(1); + + getRawLicense.mockReturnValue({ status: 'NOTactive', mode: 'abc123' }); + + expect(xpackInfoLicense.isActiveLicense(expectAbc123)).toBe(false); + expect(getRawLicense).toHaveBeenCalledTimes(2); + + getRawLicense.mockReturnValue({ status: 'NOTactive', mode: 'NOTabc123' }); + + expect(xpackInfoLicense.isActiveLicense(expectAbc123)).toBe(false); + expect(getRawLicense).toHaveBeenCalledTimes(3); + + getRawLicense.mockReturnValue({ status: 'active', mode: 'NOTabc123' }); + + expect(xpackInfoLicense.isActiveLicense(expectAbc123)).toBe(false); + expect(getRawLicense).toHaveBeenCalledTimes(4); + + expect(xpackInfoLicenseUndefined.isActive(expectAbc123)).toBe(false); + }); + + test('isBasic returns the true if active and basic', () => { + getRawLicense.mockReturnValue({ status: 'active', mode: 'basic' }); + + expect(xpackInfoLicense.isBasic()).toBe(true); + expect(getRawLicense).toHaveBeenCalledTimes(1); + + getRawLicense.mockReturnValue({ status: 'NOTactive', mode: 'gold' }); + + expect(xpackInfoLicense.isBasic()).toBe(false); + expect(getRawLicense).toHaveBeenCalledTimes(2); + + getRawLicense.mockReturnValue({ status: 'NOTactive', mode: 'trial' }); + + expect(xpackInfoLicense.isBasic()).toBe(false); + expect(getRawLicense).toHaveBeenCalledTimes(3); + + getRawLicense.mockReturnValue({ status: 'active', mode: 'platinum' }); + + expect(xpackInfoLicense.isBasic()).toBe(false); + expect(getRawLicense).toHaveBeenCalledTimes(4); + + expect(xpackInfoLicenseUndefined.isBasic()).toBe(false); + }); + + + test('isNotBasic returns the true if active and not basic', () => { + getRawLicense.mockReturnValue({ status: 'active', mode: 'platinum' }); + + expect(xpackInfoLicense.isNotBasic()).toBe(true); + expect(getRawLicense).toHaveBeenCalledTimes(1); + + getRawLicense.mockReturnValue({ status: 'NOTactive', mode: 'gold' }); + + expect(xpackInfoLicense.isNotBasic()).toBe(false); + expect(getRawLicense).toHaveBeenCalledTimes(2); + + getRawLicense.mockReturnValue({ status: 'NOTactive', mode: 'trial' }); + + expect(xpackInfoLicense.isNotBasic()).toBe(false); + expect(getRawLicense).toHaveBeenCalledTimes(3); + + getRawLicense.mockReturnValue({ status: 'active', mode: 'basic' }); + + expect(xpackInfoLicense.isNotBasic()).toBe(false); + expect(getRawLicense).toHaveBeenCalledTimes(4); + + expect(xpackInfoLicenseUndefined.isNotBasic()).toBe(false); + }); + +}); diff --git a/x-pack/yarn.lock b/x-pack/yarn.lock index b4626e040863..9b2730b02f54 100644 --- a/x-pack/yarn.lock +++ b/x-pack/yarn.lock @@ -70,20 +70,120 @@ version "0.0.0" uid "" +"@sindresorhus/is@^0.7.0": + version "0.7.0" + resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.7.0.tgz#9a06f4f137ee84d7df0460c1fdb1135ffa6c50fd" + "@sinonjs/formatio@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@sinonjs/formatio/-/formatio-2.0.0.tgz#84db7e9eb5531df18a8c5e0bfb6e449e55e654b2" dependencies: samsam "1.3.0" +"@slack/client@^4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@slack/client/-/client-4.2.2.tgz#f997f39780bbff9c2128816e8377230a5f6bd0d5" + dependencies: + "@types/delay" "^2.0.1" + "@types/form-data" "^2.2.1" + "@types/got" "^7.1.7" + "@types/is-stream" "^1.1.0" + "@types/loglevel" "^1.5.3" + "@types/node" "^9.4.7" + "@types/p-cancelable" "^0.3.0" + "@types/p-queue" "^2.3.1" + "@types/p-retry" "^1.0.1" + "@types/retry" "^0.10.2" + "@types/url-join" "^0.8.2" + "@types/ws" "^4.0.1" + delay "^2.0.0" + eventemitter3 "^3.0.0" + finity "^0.5.4" + form-data "^2.3.1" + got "^8.0.3" + is-stream "^1.1.0" + loglevel "^1.6.1" + object.entries "^1.0.4" + object.getownpropertydescriptors "^2.0.3" + object.values "^1.0.4" + p-cancelable "^0.3.0" + p-queue "^2.3.0" + p-retry "^1.0.0" + retry "^0.10.1" + url-join "^4.0.0" + ws "^4.1.0" + +"@types/delay@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@types/delay/-/delay-2.0.1.tgz#61bcf318a74b61e79d1658fbf054f984c90ef901" + +"@types/events@*": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@types/events/-/events-1.2.0.tgz#81a6731ce4df43619e5c8c945383b3e62a89ea86" + +"@types/form-data@^2.2.1": + version "2.2.1" + resolved "https://registry.yarnpkg.com/@types/form-data/-/form-data-2.2.1.tgz#ee2b3b8eaa11c0938289953606b745b738c54b1e" + dependencies: + "@types/node" "*" + +"@types/got@^7.1.7": + version "7.1.8" + resolved "https://registry.yarnpkg.com/@types/got/-/got-7.1.8.tgz#c5f421b25770689bf8948b1241f710d71a00d7dd" + dependencies: + "@types/node" "*" + +"@types/is-stream@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@types/is-stream/-/is-stream-1.1.0.tgz#b84d7bb207a210f2af9bed431dc0fbe9c4143be1" + dependencies: + "@types/node" "*" + "@types/jest@^22.2.3": version "22.2.3" resolved "https://registry.yarnpkg.com/@types/jest/-/jest-22.2.3.tgz#0157c0316dc3722c43a7b71de3fdf3acbccef10d" +"@types/loglevel@^1.5.3": + version "1.5.3" + resolved "https://registry.yarnpkg.com/@types/loglevel/-/loglevel-1.5.3.tgz#adfce55383edc5998a2170ad581b3e23d6adb5b8" + "@types/node@*": version "9.3.0" resolved "https://registry.yarnpkg.com/@types/node/-/node-9.3.0.tgz#3a129cda7c4e5df2409702626892cb4b96546dd5" +"@types/node@^9.4.7": + version "9.6.18" + resolved "https://registry.yarnpkg.com/@types/node/-/node-9.6.18.tgz#092e13ef64c47e986802c9c45a61c1454813b31d" + +"@types/p-cancelable@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@types/p-cancelable/-/p-cancelable-0.3.0.tgz#3e4fcc54a3dfd81d0f5b93546bb68d0df50553bb" + +"@types/p-queue@^2.3.1": + version "2.3.1" + resolved "https://registry.yarnpkg.com/@types/p-queue/-/p-queue-2.3.1.tgz#2fb251e46e884e31c4bd1bf58f0e188972353ff4" + +"@types/p-retry@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@types/p-retry/-/p-retry-1.0.1.tgz#2302bc3da425014208c8a9b68293d37325124785" + dependencies: + "@types/retry" "*" + +"@types/retry@*", "@types/retry@^0.10.2": + version "0.10.2" + resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.10.2.tgz#bd1740c4ad51966609b058803ee6874577848b37" + +"@types/url-join@^0.8.2": + version "0.8.2" + resolved "https://registry.yarnpkg.com/@types/url-join/-/url-join-0.8.2.tgz#1181ecbe1d97b7034e0ea1e35e62e86cc26b422d" + +"@types/ws@^4.0.1": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-4.0.2.tgz#b29037627dd7ba31ec49a4f1584840422efb856f" + dependencies: + "@types/events" "*" + "@types/node" "*" + abab@^1.0.3, abab@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/abab/-/abab-1.0.4.tgz#5faad9c2c07f60dd76770f71cf025b62a63cfd4e" @@ -402,6 +502,10 @@ astral-regex@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9" +async-limiter@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.0.tgz#78faed8c3d074ab81f22b4e985d79e8738f720f8" + async@2.4.0: version "2.4.0" resolved "https://registry.yarnpkg.com/async/-/async-2.4.0.tgz#4990200f18ea5b837c2cc4f8c031a6985c385611" @@ -1110,6 +1214,18 @@ cache-base@^1.0.1: union-value "^1.0.0" unset-value "^1.0.0" +cacheable-request@^2.1.1: + version "2.1.4" + resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-2.1.4.tgz#0d808801b6342ad33c91df9d0b44dc09b91e5c3d" + dependencies: + clone-response "1.0.2" + get-stream "3.0.0" + http-cache-semantics "3.8.1" + keyv "3.0.0" + lowercase-keys "1.0.0" + normalize-url "2.0.1" + responselike "1.0.2" + call@3.x.x: version "3.0.4" resolved "https://registry.yarnpkg.com/call/-/call-3.0.4.tgz#e380f2f2a491330aa79085355f8be080877d559e" @@ -1298,6 +1414,12 @@ clone-buffer@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/clone-buffer/-/clone-buffer-1.0.0.tgz#e3e25b207ac4e701af721e2cb5a16792cac3dc58" +clone-response@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.2.tgz#d1dc973920314df67fbeb94223b4ee350239e96b" + dependencies: + mimic-response "^1.0.0" + clone-stats@^0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-0.0.1.tgz#b88f94a82cf38b8791d58046ea4029ad88ca99d1" @@ -1744,6 +1866,12 @@ decode-uri-component@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" +decompress-response@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-3.3.0.tgz#80a4dd323748384bfa248083622aedec982adff3" + dependencies: + mimic-response "^1.0.0" + dedent@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" @@ -1821,6 +1949,12 @@ del@^2.2.2: pinkie-promise "^2.0.0" rimraf "^2.2.8" +delay@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/delay/-/delay-2.0.0.tgz#9112eadc03e4ec7e00297337896f273bbd91fae5" + dependencies: + p-defer "^1.0.0" + delayed-stream@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" @@ -1942,6 +2076,10 @@ duplexer2@0.0.2, duplexer2@~0.0.2: dependencies: readable-stream "~1.1.9" +duplexer3@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2" + duplexify@^3.5.3: version "3.5.4" resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.5.4.tgz#4bb46c1796eabebeec4ca9a2e66b808cb7a3d8b4" @@ -2159,6 +2297,10 @@ esutils@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/esutils/-/esutils-1.0.0.tgz#8151d358e20c8acc7fb745e7472c0025fe496570" +eventemitter3@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.0.tgz#090b4d6cdbd645ed10bf750d4b5407942d7ba163" + exec-sh@^0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.2.1.tgz#163b98a6e89e6b65b47c2a28d215bc1f63989c38" @@ -2448,6 +2590,10 @@ fined@^1.0.1: object.pick "^1.2.0" parse-filepath "^1.0.1" +finity@^0.5.4: + version "0.5.4" + resolved "https://registry.yarnpkg.com/finity/-/finity-0.5.4.tgz#f2a8a9198e8286467328ec32c8bfcc19a2229c11" + first-chunk-stream@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/first-chunk-stream/-/first-chunk-stream-1.0.0.tgz#59bfb50cd905f60d7c394cd3d9acaab4e6ad934e" @@ -2551,6 +2697,13 @@ fragment-cache@^0.2.1: dependencies: map-cache "^0.2.2" +from2@^2.1.1: + version "2.3.0" + resolved "https://registry.yarnpkg.com/from2/-/from2-2.3.0.tgz#8bfb5502bde4a4d36cfdeea007fcca21d7e382af" + dependencies: + inherits "^2.0.1" + readable-stream "^2.0.0" + from@^0.1.3: version "0.1.7" resolved "https://registry.yarnpkg.com/from/-/from-0.1.7.tgz#83c60afc58b9c56997007ed1a768b3ab303a44fe" @@ -2641,7 +2794,7 @@ get-stdin@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe" -get-stream@^3.0.0: +get-stream@3.0.0, get-stream@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14" @@ -2859,6 +3012,28 @@ good-listener@^1.2.2: dependencies: delegate "^3.1.2" +got@^8.0.3: + version "8.3.1" + resolved "https://registry.yarnpkg.com/got/-/got-8.3.1.tgz#093324403d4d955f5a16a7a8d39955d055ae10ed" + dependencies: + "@sindresorhus/is" "^0.7.0" + cacheable-request "^2.1.1" + decompress-response "^3.3.0" + duplexer3 "^0.1.4" + get-stream "^3.0.0" + into-stream "^3.1.0" + is-retry-allowed "^1.1.0" + isurl "^1.0.0-alpha5" + lowercase-keys "^1.0.0" + mimic-response "^1.0.0" + p-cancelable "^0.4.0" + p-timeout "^2.0.1" + pify "^3.0.0" + safe-buffer "^5.1.1" + timed-out "^4.0.1" + url-parse-lax "^3.0.0" + url-to-options "^1.0.1" + graceful-fs@^3.0.0: version "3.0.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-3.0.11.tgz#7613c778a1afea62f25c630a086d7f3acbbdd818" @@ -3100,10 +3275,20 @@ has-gulplog@^0.1.0: dependencies: sparkles "^1.0.0" +has-symbol-support-x@^1.4.1: + version "1.4.2" + resolved "https://registry.yarnpkg.com/has-symbol-support-x/-/has-symbol-support-x-1.4.2.tgz#1409f98bc00247da45da67cee0a36f282ff26455" + has-symbols@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.0.tgz#ba1a8f1af2a0fc39650f5c850367704122063b44" +has-to-string-tag-x@^1.2.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/has-to-string-tag-x/-/has-to-string-tag-x-1.4.1.tgz#a045ab383d7b4b2012a00148ab0aa5f290044d4d" + dependencies: + has-symbol-support-x "^1.4.1" + has-unicode@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" @@ -3247,6 +3432,10 @@ htmlparser2@^3.9.1: inherits "^2.0.1" readable-stream "^2.0.2" +http-cache-semantics@3.8.1: + version "3.8.1" + resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-3.8.1.tgz#39b0e16add9b605bf0a9ef3d9daaf4843b4cacd2" + http-errors@~1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.4.0.tgz#6c0242dea6b3df7afda153c71089b31c6e82aabf" @@ -3361,6 +3550,13 @@ interpret@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.1.0.tgz#7ed1b1410c6a0e0f78cf95d3b8440c63f78b8614" +into-stream@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/into-stream/-/into-stream-3.1.0.tgz#96fb0a936c12babd6ff1752a17d05616abd094c6" + dependencies: + from2 "^2.1.1" + p-is-promise "^1.1.0" + invariant@^2.0.0, invariant@^2.2.0, invariant@^2.2.1, invariant@^2.2.2: version "2.2.2" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.2.tgz#9e1f56ac0acdb6bf303306f338be3b204ae60360" @@ -3534,7 +3730,7 @@ is-number@^3.0.0: dependencies: kind-of "^3.0.2" -is-object@~1.0.1: +is-object@^1.0.1, is-object@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/is-object/-/is-object-1.0.1.tgz#8952688c5ec2ffd6b03ecc85e769e02903083470" @@ -3560,6 +3756,10 @@ is-path-inside@^1.0.0: dependencies: path-is-inside "^1.0.1" +is-plain-obj@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" + is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" @@ -3590,6 +3790,10 @@ is-relative@^1.0.0: dependencies: is-unc-path "^1.0.0" +is-retry-allowed@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-retry-allowed/-/is-retry-allowed-1.1.0.tgz#11a060568b67339444033d0125a61a20d564fb34" + is-stream@^1.0.1, is-stream@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" @@ -3728,6 +3932,13 @@ istanbul-reports@^1.1.3: dependencies: handlebars "^4.0.3" +isurl@^1.0.0-alpha5: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isurl/-/isurl-1.0.0.tgz#b27f4f49f3cdaa3ea44a0a5b7f3462e6edc39d67" + dependencies: + has-to-string-tag-x "^1.2.0" + is-object "^1.0.1" + items@2.x.x: version "2.1.1" resolved "https://registry.yarnpkg.com/items/-/items-2.1.1.tgz#8bd16d9c83b19529de5aea321acaada78364a198" @@ -4105,6 +4316,10 @@ jsesc@~0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" +json-buffer@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898" + json-schema-traverse@^0.3.0: version "0.3.1" resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz#349a6d44c53a51de89b40805c5d5e59b417d3340" @@ -4156,6 +4371,12 @@ keymirror@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/keymirror/-/keymirror-0.1.1.tgz#918889ea13f8d0a42e7c557250eee713adc95c35" +keyv@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.0.0.tgz#44923ba39e68b12a7cec7df6c3268c031f2ef373" + dependencies: + json-buffer "3.0.0" + kilt@2.x.x: version "2.0.2" resolved "https://registry.yarnpkg.com/kilt/-/kilt-2.0.2.tgz#04d7183c298a1232efddf7ddca5959a8f6301e20" @@ -4512,6 +4733,10 @@ lodash@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/lodash/-/lodash-1.0.2.tgz#8f57560c83b59fc270bd3d561b690043430e2551" +loglevel@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.6.1.tgz#e0fc95133b6ef276cdc8887cdaf24aa6f156f8fa" + lolex@^2.2.0, lolex@^2.3.2: version "2.6.0" resolved "https://registry.yarnpkg.com/lolex/-/lolex-2.6.0.tgz#cf9166f3c9dece3cdeb5d6b01fce50f14a1203e3" @@ -4533,6 +4758,14 @@ loud-rejection@^1.0.0: currently-unhandled "^0.4.1" signal-exit "^3.0.0" +lowercase-keys@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.0.tgz#4e3366b39e7f5457e35f1324bdf6f88d0bfc7306" + +lowercase-keys@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f" + lowlight@~1.9.1: version "1.9.1" resolved "https://registry.yarnpkg.com/lowlight/-/lowlight-1.9.1.tgz#ed7c3dffc36f8c1f263735c0fe0c907847c11250" @@ -4677,6 +4910,10 @@ mimic-fn@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.1.0.tgz#e667783d92e89dbd342818b5230b9d62a672ad18" +mimic-response@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.0.tgz#df3d3652a73fded6b9b0b24146e6fd052353458e" + mimos@3.x.x: version "3.0.3" resolved "https://registry.yarnpkg.com/mimos/-/mimos-3.0.3.tgz#b9109072ad378c2b72f6a0101c43ddfb2b36641f" @@ -4939,6 +5176,10 @@ node-pre-gyp@^0.6.39: tar "^2.2.1" tar-pack "^3.4.0" +nodemailer@^4.6.4: + version "4.6.4" + resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-4.6.4.tgz#f0d72d0c6a6ec5f4369fa8f4bf5127a31baa2014" + nomnom@~1.6.2: version "1.6.2" resolved "https://registry.yarnpkg.com/nomnom/-/nomnom-1.6.2.tgz#84a66a260174408fc5b77a18f888eccc44fb6971" @@ -4968,6 +5209,14 @@ normalize-path@^2.0.0, normalize-path@^2.0.1, normalize-path@^2.1.1: dependencies: remove-trailing-separator "^1.0.1" +normalize-url@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-2.0.1.tgz#835a9da1551fa26f70e92329069a23aa6574d7e6" + dependencies: + prepend-http "^2.0.0" + query-string "^5.0.1" + sort-keys "^2.0.0" + now-and-later@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/now-and-later/-/now-and-later-2.0.0.tgz#bc61cbb456d79cb32207ce47ca05136ff2e7d6ee" @@ -5207,10 +5456,26 @@ osenv@^0.1.4: os-homedir "^1.0.0" os-tmpdir "^1.0.0" +p-cancelable@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-0.3.0.tgz#b9e123800bcebb7ac13a479be195b507b98d30fa" + +p-cancelable@^0.4.0: + version "0.4.1" + resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-0.4.1.tgz#35f363d67d52081c8d9585e37bcceb7e0bbcb2a0" + +p-defer@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-defer/-/p-defer-1.0.0.tgz#9f6eb182f6c9aa8cd743004a7d4f96b196b0fb0c" + p-finally@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" +p-is-promise@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/p-is-promise/-/p-is-promise-1.1.0.tgz#9c9456989e9f6588017b0434d56097675c3da05e" + p-limit@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.2.0.tgz#0e92b6bedcb59f022c13d0f1949dc82d15909f1c" @@ -5223,6 +5488,22 @@ p-locate@^2.0.0: dependencies: p-limit "^1.1.0" +p-queue@^2.3.0: + version "2.4.2" + resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-2.4.2.tgz#03609826682b743be9a22dba25051bd46724fc34" + +p-retry@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-1.0.0.tgz#3927332a4b7d70269b535515117fc547da1a6968" + dependencies: + retry "^0.10.0" + +p-timeout@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-2.0.1.tgz#d8dd1979595d2dc0139e1fe46b8b646cb3cdf038" + dependencies: + p-finally "^1.0.0" + p-try@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3" @@ -5383,6 +5664,10 @@ pify@^2.0.0: version "2.3.0" resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" +pify@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176" + pinkie-promise@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa" @@ -5483,6 +5768,10 @@ prelude-ls@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" +prepend-http@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" + preserve@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b" @@ -5620,6 +5909,14 @@ qs@~6.4.0: version "6.4.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233" +query-string@^5.0.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/query-string/-/query-string-5.1.1.tgz#a78c012b71c17e05f2e3fa2319dd330682efb3cb" + dependencies: + decode-uri-component "^0.2.0" + object-assign "^4.1.0" + strict-uri-encode "^1.0.0" + quote-stream@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/quote-stream/-/quote-stream-1.0.2.tgz#84963f8c9c26b942e153feeb53aae74652b7e0b2" @@ -6272,6 +6569,12 @@ resolve@^1.1.5, resolve@^1.1.6, resolve@^1.1.7: dependencies: path-parse "^1.0.5" +responselike@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7" + dependencies: + lowercase-keys "^1.0.0" + restore-cursor@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541" @@ -6289,6 +6592,10 @@ ret@~0.1.10: version "0.1.15" resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" +retry@^0.10.0, retry@^0.10.1: + version "0.10.1" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.10.1.tgz#e76388d217992c252750241d3d3956fed98d8ff4" + right-align@^0.1.1: version "0.1.3" resolved "https://registry.yarnpkg.com/right-align/-/right-align-0.1.3.tgz#61339b722fe6a3515689210d24e14c96148613ef" @@ -6546,6 +6853,12 @@ sntp@2.x.x: dependencies: hoek "4.x.x" +sort-keys@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-2.0.0.tgz#658535584861ec97d730d6cf41822e1f56684128" + dependencies: + is-plain-obj "^1.0.0" + source-map-resolve@^0.3.0: version "0.3.1" resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.3.1.tgz#610f6122a445b8dd51535a2a71b783dfc1248761" @@ -6720,6 +7033,10 @@ stream-to-observable@0.2.0: dependencies: any-observable "^0.2.0" +strict-uri-encode@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" + string-length@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/string-length/-/string-length-2.0.0.tgz#d40dbb686a3ace960c1cffca562bf2c45f8363ed" @@ -7051,6 +7368,10 @@ time-stamp@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/time-stamp/-/time-stamp-1.1.0.tgz#764a5a11af50561921b133f3b44e618687e0f5c3" +timed-out@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-4.0.1.tgz#f32eacac5a175bea25d7fab565ab3ed8741ef56f" + tiny-emitter@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.0.2.tgz#82d27468aca5ade8e5fd1e6d22b57dd43ebdfb7c" @@ -7302,6 +7623,20 @@ urix@^0.1.0, urix@~0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" +url-join@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/url-join/-/url-join-4.0.0.tgz#4d3340e807d3773bda9991f8305acdcc2a665d2a" + +url-parse-lax@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-3.0.0.tgz#16b5cafc07dbe3676c1b1999177823d6503acb0c" + dependencies: + prepend-http "^2.0.0" + +url-to-options@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/url-to-options/-/url-to-options-1.0.1.tgz#1505a03a289a48cbd7a434efbaeec5055f5633a9" + use@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/use/-/use-2.0.2.tgz#ae28a0d72f93bf22422a18a2e379993112dec8e8" @@ -7598,6 +7933,13 @@ ws@2.0.x: dependencies: ultron "~1.1.0" +ws@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-4.1.0.tgz#a979b5d7d4da68bf54efe0408967c324869a7289" + dependencies: + async-limiter "~1.0.0" + safe-buffer "~5.1.0" + xml-crypto@^0.10.1: version "0.10.1" resolved "https://registry.yarnpkg.com/xml-crypto/-/xml-crypto-0.10.1.tgz#f832f74ccf56f24afcae1163a1fcab44d96774a8" diff --git a/yarn.lock b/yarn.lock index a448b1283b97..830c52b2e68e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -178,12 +178,53 @@ dependencies: any-observable "^0.3.0" +"@sindresorhus/is@^0.7.0": + version "0.7.0" + resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.7.0.tgz#9a06f4f137ee84d7df0460c1fdb1135ffa6c50fd" + "@sinonjs/formatio@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@sinonjs/formatio/-/formatio-2.0.0.tgz#84db7e9eb5531df18a8c5e0bfb6e449e55e654b2" dependencies: samsam "1.3.0" +"@slack/client@^4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@slack/client/-/client-4.2.2.tgz#f997f39780bbff9c2128816e8377230a5f6bd0d5" + dependencies: + "@types/delay" "^2.0.1" + "@types/form-data" "^2.2.1" + "@types/got" "^7.1.7" + "@types/is-stream" "^1.1.0" + "@types/loglevel" "^1.5.3" + "@types/node" "^9.4.7" + "@types/p-cancelable" "^0.3.0" + "@types/p-queue" "^2.3.1" + "@types/p-retry" "^1.0.1" + "@types/retry" "^0.10.2" + "@types/url-join" "^0.8.2" + "@types/ws" "^4.0.1" + delay "^2.0.0" + eventemitter3 "^3.0.0" + finity "^0.5.4" + form-data "^2.3.1" + got "^8.0.3" + is-stream "^1.1.0" + loglevel "^1.6.1" + object.entries "^1.0.4" + object.getownpropertydescriptors "^2.0.3" + object.values "^1.0.4" + p-cancelable "^0.3.0" + p-queue "^2.3.0" + p-retry "^1.0.0" + retry "^0.10.1" + url-join "^4.0.0" + ws "^4.1.0" + +"@types/delay@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@types/delay/-/delay-2.0.1.tgz#61bcf318a74b61e79d1658fbf054f984c90ef901" + "@types/eslint@^4.16.2": version "4.16.2" resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-4.16.2.tgz#30f4f026019eb78a6ef12f276b75cd16ea2afb27" @@ -205,6 +246,12 @@ dependencies: "@types/node" "*" +"@types/form-data@^2.2.1": + version "2.2.1" + resolved "https://registry.yarnpkg.com/@types/form-data/-/form-data-2.2.1.tgz#ee2b3b8eaa11c0938289953606b745b738c54b1e" + dependencies: + "@types/node" "*" + "@types/getopts@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@types/getopts/-/getopts-2.0.0.tgz#8a603370cb367d3192bd8012ad39ab2320b5b476" @@ -217,6 +264,18 @@ "@types/minimatch" "*" "@types/node" "*" +"@types/got@^7.1.7": + version "7.1.8" + resolved "https://registry.yarnpkg.com/@types/got/-/got-7.1.8.tgz#c5f421b25770689bf8948b1241f710d71a00d7dd" + dependencies: + "@types/node" "*" + +"@types/is-stream@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@types/is-stream/-/is-stream-1.1.0.tgz#b84d7bb207a210f2af9bed431dc0fbe9c4143be1" + dependencies: + "@types/node" "*" + "@types/json-schema@*": version "6.0.1" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-6.0.1.tgz#a761975746f1c1b2579c62e3a4b5e88f986f7e2e" @@ -231,6 +290,10 @@ dependencies: "@types/node" "*" +"@types/loglevel@^1.5.3": + version "1.5.3" + resolved "https://registry.yarnpkg.com/@types/loglevel/-/loglevel-1.5.3.tgz#adfce55383edc5998a2170ad581b3e23d6adb5b8" + "@types/minimatch@*": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" @@ -243,6 +306,39 @@ version "9.4.7" resolved "https://registry.yarnpkg.com/@types/node/-/node-9.4.7.tgz#57d81cd98719df2c9de118f2d5f3b1120dcd7275" +"@types/node@^9.4.7": + version "9.6.18" + resolved "https://registry.yarnpkg.com/@types/node/-/node-9.6.18.tgz#092e13ef64c47e986802c9c45a61c1454813b31d" + +"@types/p-cancelable@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@types/p-cancelable/-/p-cancelable-0.3.0.tgz#3e4fcc54a3dfd81d0f5b93546bb68d0df50553bb" + +"@types/p-queue@^2.3.1": + version "2.3.1" + resolved "https://registry.yarnpkg.com/@types/p-queue/-/p-queue-2.3.1.tgz#2fb251e46e884e31c4bd1bf58f0e188972353ff4" + +"@types/p-retry@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@types/p-retry/-/p-retry-1.0.1.tgz#2302bc3da425014208c8a9b68293d37325124785" + dependencies: + "@types/retry" "*" + +"@types/retry@*", "@types/retry@^0.10.2": + version "0.10.2" + resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.10.2.tgz#bd1740c4ad51966609b058803ee6874577848b37" + +"@types/url-join@^0.8.2": + version "0.8.2" + resolved "https://registry.yarnpkg.com/@types/url-join/-/url-join-0.8.2.tgz#1181ecbe1d97b7034e0ea1e35e62e86cc26b422d" + +"@types/ws@^4.0.1": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-4.0.2.tgz#b29037627dd7ba31ec49a4f1584840422efb856f" + dependencies: + "@types/events" "*" + "@types/node" "*" + JSONStream@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.1.1.tgz#c98bfd88c8f1e1e8694e53c5baa6c8691553e59a" @@ -2075,6 +2171,18 @@ cache-loader@1.0.3: loader-utils "^1.1.0" mkdirp "^0.5.1" +cacheable-request@^2.1.1: + version "2.1.4" + resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-2.1.4.tgz#0d808801b6342ad33c91df9d0b44dc09b91e5c3d" + dependencies: + clone-response "1.0.2" + get-stream "3.0.0" + http-cache-semantics "3.8.1" + keyv "3.0.0" + lowercase-keys "1.0.0" + normalize-url "2.0.1" + responselike "1.0.2" + call-me-maybe@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.1.tgz#26d208ea89e37b5cbde60250a15f031c16a4d66b" @@ -2474,6 +2582,12 @@ clone-buffer@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/clone-buffer/-/clone-buffer-1.0.0.tgz#e3e25b207ac4e701af721e2cb5a16792cac3dc58" +clone-response@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.2.tgz#d1dc973920314df67fbeb94223b4ee350239e96b" + dependencies: + mimic-response "^1.0.0" + clone-stats@^0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-0.0.1.tgz#b88f94a82cf38b8791d58046ea4029ad88ca99d1" @@ -3406,6 +3520,12 @@ decode-uri-component@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" +decompress-response@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-3.3.0.tgz#80a4dd323748384bfa248083622aedec982adff3" + dependencies: + mimic-response "^1.0.0" + decompress-tar@^4.0.0, decompress-tar@^4.1.0, decompress-tar@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/decompress-tar/-/decompress-tar-4.1.1.tgz#718cbd3fcb16209716e70a26b84e7ba4592e5af1" @@ -3535,6 +3655,12 @@ del@^3.0.0: pify "^3.0.0" rimraf "^2.2.8" +delay@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/delay/-/delay-2.0.0.tgz#9112eadc03e4ec7e00297337896f273bbd91fae5" + dependencies: + p-defer "^1.0.0" + delayed-stream@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" @@ -4417,6 +4543,10 @@ eventemitter3@1.x.x: version "1.2.0" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-1.2.0.tgz#1c86991d816ad1e504750e73874224ecf3bec508" +eventemitter3@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.0.tgz#090b4d6cdbd645ed10bf750d4b5407942d7ba163" + events@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924" @@ -4850,6 +4980,10 @@ findup-sync@~0.3.0: dependencies: glob "~5.0.0" +finity@^0.5.4: + version "0.5.4" + resolved "https://registry.yarnpkg.com/finity/-/finity-0.5.4.tgz#f2a8a9198e8286467328ec32c8bfcc19a2229c11" + flat-cache@^1.2.1: version "1.3.0" resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-1.3.0.tgz#d3030b32b38154f4e3b7e9c709f490f7ef97c481" @@ -4958,6 +5092,13 @@ fragment-cache@^0.2.1: dependencies: map-cache "^0.2.2" +from2@^2.1.1: + version "2.3.0" + resolved "https://registry.yarnpkg.com/from2/-/from2-2.3.0.tgz#8bfb5502bde4a4d36cfdeea007fcca21d7e382af" + dependencies: + inherits "^2.0.1" + readable-stream "^2.0.0" + from@^0.1.3, from@~0: version "0.1.7" resolved "https://registry.yarnpkg.com/from/-/from-0.1.7.tgz#83c60afc58b9c56997007ed1a768b3ab303a44fe" @@ -5106,6 +5247,10 @@ get-stdin@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-5.0.1.tgz#122e161591e21ff4c52530305693f20e6393a398" +get-stream@3.0.0, get-stream@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14" + get-stream@^2.2.0: version "2.3.1" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-2.3.1.tgz#5f38f93f346009666ee0150a054167f91bdd95de" @@ -5113,10 +5258,6 @@ get-stream@^2.2.0: object-assign "^4.0.1" pinkie-promise "^2.0.0" -get-stream@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14" - get-value@^2.0.3, get-value@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" @@ -5416,6 +5557,28 @@ got@^6.3.0, got@^6.7.1: unzip-response "^2.0.1" url-parse-lax "^1.0.0" +got@^8.0.3: + version "8.3.1" + resolved "https://registry.yarnpkg.com/got/-/got-8.3.1.tgz#093324403d4d955f5a16a7a8d39955d055ae10ed" + dependencies: + "@sindresorhus/is" "^0.7.0" + cacheable-request "^2.1.1" + decompress-response "^3.3.0" + duplexer3 "^0.1.4" + get-stream "^3.0.0" + into-stream "^3.1.0" + is-retry-allowed "^1.1.0" + isurl "^1.0.0-alpha5" + lowercase-keys "^1.0.0" + mimic-response "^1.0.0" + p-cancelable "^0.4.0" + p-timeout "^2.0.1" + pify "^3.0.0" + safe-buffer "^5.1.1" + timed-out "^4.0.1" + url-parse-lax "^3.0.0" + url-to-options "^1.0.1" + graceful-fs@4.X, graceful-fs@^4.0.0, graceful-fs@^4.1.10, graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.4, graceful-fs@^4.1.6: version "4.1.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658" @@ -5886,6 +6049,10 @@ htmlparser2@^3.9.1: inherits "^2.0.1" readable-stream "^2.0.2" +http-cache-semantics@3.8.1: + version "3.8.1" + resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-3.8.1.tgz#39b0e16add9b605bf0a9ef3d9daaf4843b4cacd2" + http-errors@1.6.2, http-errors@~1.6.2: version "1.6.2" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.2.tgz#0a002cc85707192a7e7946ceedc11155f60ec736" @@ -6179,6 +6346,13 @@ interpret@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.1.0.tgz#7ed1b1410c6a0e0f78cf95d3b8440c63f78b8614" +into-stream@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/into-stream/-/into-stream-3.1.0.tgz#96fb0a936c12babd6ff1752a17d05616abd094c6" + dependencies: + from2 "^2.1.1" + p-is-promise "^1.1.0" + invariant@^2.0.0, invariant@^2.2.0, invariant@^2.2.1, invariant@^2.2.2: version "2.2.4" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" @@ -6541,7 +6715,7 @@ is-resolvable@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-resolvable/-/is-resolvable-1.1.0.tgz#fb18f87ce1feb925169c9a407c19318a3206ed88" -is-retry-allowed@^1.0.0: +is-retry-allowed@^1.0.0, is-retry-allowed@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-retry-allowed/-/is-retry-allowed-1.1.0.tgz#11a060568b67339444033d0125a61a20d564fb34" @@ -7267,6 +7441,10 @@ jsesc@~0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" +json-buffer@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898" + json-loader@^0.5.4: version "0.5.7" resolved "https://registry.yarnpkg.com/json-loader/-/json-loader-0.5.7.tgz#dca14a70235ff82f0ac9a3abeb60d337a365185d" @@ -7472,6 +7650,12 @@ keymirror@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/keymirror/-/keymirror-0.1.1.tgz#918889ea13f8d0a42e7c557250eee713adc95c35" +keyv@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.0.0.tgz#44923ba39e68b12a7cec7df6c3268c031f2ef373" + dependencies: + json-buffer "3.0.0" + kilt@2.x.x: version "2.0.2" resolved "https://registry.yarnpkg.com/kilt/-/kilt-2.0.2.tgz#04d7183c298a1232efddf7ddca5959a8f6301e20" @@ -8094,6 +8278,10 @@ log4js@^0.6.31: readable-stream "~1.0.2" semver "~4.3.3" +loglevel@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.6.1.tgz#e0fc95133b6ef276cdc8887cdaf24aa6f156f8fa" + lolex@^2.2.0, lolex@^2.3.2: version "2.6.0" resolved "https://registry.yarnpkg.com/lolex/-/lolex-2.6.0.tgz#cf9166f3c9dece3cdeb5d6b01fce50f14a1203e3" @@ -8115,7 +8303,7 @@ loud-rejection@^1.0.0: currently-unhandled "^0.4.1" signal-exit "^3.0.0" -lowercase-keys@^1.0.0: +lowercase-keys@1.0.0, lowercase-keys@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.0.tgz#4e3366b39e7f5457e35f1324bdf6f88d0bfc7306" @@ -8383,6 +8571,10 @@ mimic-fn@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022" +mimic-response@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.0.tgz#df3d3652a73fded6b9b0b24146e6fd052353458e" + mimos@3.x.x: version "3.0.3" resolved "https://registry.yarnpkg.com/mimos/-/mimos-3.0.3.tgz#b9109072ad378c2b72f6a0101c43ddfb2b36641f" @@ -8727,6 +8919,10 @@ node-pre-gyp@^0.6.39: tar "^2.2.1" tar-pack "^3.4.0" +nodemailer@^4.6.4: + version "4.6.4" + resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-4.6.4.tgz#f0d72d0c6a6ec5f4369fa8f4bf5127a31baa2014" + nomnom@~1.6.2: version "1.6.2" resolved "https://registry.yarnpkg.com/nomnom/-/nomnom-1.6.2.tgz#84a66a260174408fc5b77a18f888eccc44fb6971" @@ -8778,6 +8974,14 @@ normalize-range@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" +normalize-url@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-2.0.1.tgz#835a9da1551fa26f70e92329069a23aa6574d7e6" + dependencies: + prepend-http "^2.0.0" + query-string "^5.0.1" + sort-keys "^2.0.0" + normalize-url@^1.4.0: version "1.9.1" resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-1.9.1.tgz#2cc0d66b31ea23036458436e3620d85954c66c3c" @@ -9045,10 +9249,26 @@ osenv@^0.1.0, osenv@^0.1.4: os-homedir "^1.0.0" os-tmpdir "^1.0.0" +p-cancelable@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-0.3.0.tgz#b9e123800bcebb7ac13a479be195b507b98d30fa" + +p-cancelable@^0.4.0: + version "0.4.1" + resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-0.4.1.tgz#35f363d67d52081c8d9585e37bcceb7e0bbcb2a0" + +p-defer@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-defer/-/p-defer-1.0.0.tgz#9f6eb182f6c9aa8cd743004a7d4f96b196b0fb0c" + p-finally@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" +p-is-promise@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/p-is-promise/-/p-is-promise-1.1.0.tgz#9c9456989e9f6588017b0434d56097675c3da05e" + p-limit@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.2.0.tgz#0e92b6bedcb59f022c13d0f1949dc82d15909f1c" @@ -9065,6 +9285,22 @@ p-map@^1.1.1: version "1.2.0" resolved "https://registry.yarnpkg.com/p-map/-/p-map-1.2.0.tgz#e4e94f311eabbc8633a1e79908165fca26241b6b" +p-queue@^2.3.0: + version "2.4.2" + resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-2.4.2.tgz#03609826682b743be9a22dba25051bd46724fc34" + +p-retry@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-1.0.0.tgz#3927332a4b7d70269b535515117fc547da1a6968" + dependencies: + retry "^0.10.0" + +p-timeout@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-2.0.1.tgz#d8dd1979595d2dc0139e1fe46b8b646cb3cdf038" + dependencies: + p-finally "^1.0.0" + p-try@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3" @@ -9734,6 +9970,10 @@ prepend-http@^1.0.0, prepend-http@^1.0.1: version "1.0.4" resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc" +prepend-http@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" + preserve@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b" @@ -9944,6 +10184,14 @@ query-string@^4.1.0: object-assign "^4.1.0" strict-uri-encode "^1.0.0" +query-string@^5.0.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/query-string/-/query-string-5.1.1.tgz#a78c012b71c17e05f2e3fa2319dd330682efb3cb" + dependencies: + decode-uri-component "^0.2.0" + object-assign "^4.1.0" + strict-uri-encode "^1.0.0" + querystring-browser@1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/querystring-browser/-/querystring-browser-1.0.4.tgz#f2e35881840a819bc7b1bf597faf0979e6622dc6" @@ -10834,6 +11082,12 @@ resolve@~0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/resolve/-/resolve-0.3.1.tgz#34c63447c664c70598d1c9b126fc43b2a24310a4" +responselike@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7" + dependencies: + lowercase-keys "^1.0.0" + restore-cursor@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541" @@ -10858,6 +11112,10 @@ ret@~0.1.10: version "0.1.15" resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" +retry@^0.10.0, retry@^0.10.1: + version "0.10.1" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.10.1.tgz#e76388d217992c252750241d3d3956fed98d8ff4" + right-align@^0.1.1: version "0.1.3" resolved "https://registry.yarnpkg.com/right-align/-/right-align-0.1.3.tgz#61339b722fe6a3515689210d24e14c96148613ef" @@ -11301,6 +11559,12 @@ sort-keys@^1.0.0: dependencies: is-plain-obj "^1.0.0" +sort-keys@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-2.0.0.tgz#658535584861ec97d730d6cf41822e1f56684128" + dependencies: + is-plain-obj "^1.0.0" + source-list-map@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.0.tgz#aaa47403f7b245a92fbc97ea08f250d6087ed085" @@ -12025,7 +12289,7 @@ timed-out@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-2.0.0.tgz#f38b0ae81d3747d628001f41dafc652ace671c0a" -timed-out@^4.0.0: +timed-out@^4.0.0, timed-out@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-4.0.1.tgz#f32eacac5a175bea25d7fab565ab3ed8741ef56f" @@ -12623,6 +12887,10 @@ urix@^0.1.0, urix@~0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" +url-join@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/url-join/-/url-join-4.0.0.tgz#4d3340e807d3773bda9991f8305acdcc2a665d2a" + url-loader@0.5.9: version "0.5.9" resolved "https://registry.yarnpkg.com/url-loader/-/url-loader-0.5.9.tgz#cc8fea82c7b906e7777019250869e569e995c295" @@ -12636,6 +12904,12 @@ url-parse-lax@^1.0.0: dependencies: prepend-http "^1.0.1" +url-parse-lax@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-3.0.0.tgz#16b5cafc07dbe3676c1b1999177823d6503acb0c" + dependencies: + prepend-http "^2.0.0" + url-regex@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/url-regex/-/url-regex-3.2.0.tgz#dbad1e0c9e29e105dd0b1f09f6862f7fdb482724" @@ -13383,7 +13657,7 @@ ws@2.0.x: dependencies: ultron "~1.1.0" -ws@^4.0.0: +ws@^4.0.0, ws@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/ws/-/ws-4.1.0.tgz#a979b5d7d4da68bf54efe0408967c324869a7289" dependencies: