add support for OAuthContactTokens, with tests, upgrade step, new permissions
authorNoah Miller <nm@lemnisc.us>
Tue, 27 Apr 2021 07:08:04 +0000 (00:08 -0700)
committerNoah Miller <nm@lemnisc.us>
Fri, 28 May 2021 20:13:46 +0000 (13:13 -0700)
Per discussion with @totten, re the logic that interprets "tag" and links/creates contacts accordingly: in earlier versions of this code, I had put that logic in OAuthTokenFacade. Moving it to the api4 "create" action means it will happen even when a ContactToken is created outside the OAuth flow. I decided not to put it in BAO writeRecords() because then it would run too late for the permissions checking we do in the api4 Create action.

Note: the permissions change here means that some current users of the OAuth Extension will need to enable an additional permission to allow for the acquisition of new tokens through the auth-code flow.

26 files changed:
ext/oauth-client/CRM/OAuth/BAO/OAuthContactToken.php [new file with mode: 0644]
ext/oauth-client/CRM/OAuth/ContactFromToken.php [new file with mode: 0644]
ext/oauth-client/CRM/OAuth/DAO/OAuthClient.php
ext/oauth-client/CRM/OAuth/DAO/OAuthContactToken.php [new file with mode: 0644]
ext/oauth-client/CRM/OAuth/DAO/OAuthSysToken.php
ext/oauth-client/CRM/OAuth/Upgrader.php
ext/oauth-client/Civi/Api4/Action/OAuthContactToken/Create.php [new file with mode: 0644]
ext/oauth-client/Civi/Api4/Action/OAuthContactToken/Delete.php [new file with mode: 0644]
ext/oauth-client/Civi/Api4/Action/OAuthContactToken/Get.php [new file with mode: 0644]
ext/oauth-client/Civi/Api4/Action/OAuthContactToken/OnlyModifyOwnTokensTrait.php [new file with mode: 0644]
ext/oauth-client/Civi/Api4/Action/OAuthContactToken/Update.php [new file with mode: 0644]
ext/oauth-client/Civi/Api4/OAuthClient.php
ext/oauth-client/Civi/Api4/OAuthContactToken.php [new file with mode: 0644]
ext/oauth-client/Civi/OAuth/OAuthTokenFacade.php
ext/oauth-client/oauth_client.civix.php
ext/oauth-client/oauth_client.php
ext/oauth-client/sql/auto_install.sql
ext/oauth-client/sql/auto_uninstall.sql
ext/oauth-client/sql/upgrade_0001.sql [new file with mode: 0644]
ext/oauth-client/tests/phpunit/Civi/OAuth/AuthCodeFlowTest.php
ext/oauth-client/tests/phpunit/api/v4/OAuthContactTokenTest.php [new file with mode: 0644]
ext/oauth-client/xml/Menu/oauth_client.xml
ext/oauth-client/xml/schema/CRM/OAuth/OAuthClient.xml
ext/oauth-client/xml/schema/CRM/OAuth/OAuthContactToken.entityType.php [new file with mode: 0644]
ext/oauth-client/xml/schema/CRM/OAuth/OAuthContactToken.xml [new file with mode: 0644]
package-lock.json

diff --git a/ext/oauth-client/CRM/OAuth/BAO/OAuthContactToken.php b/ext/oauth-client/CRM/OAuth/BAO/OAuthContactToken.php
new file mode 100644 (file)
index 0000000..920707b
--- /dev/null
@@ -0,0 +1,5 @@
+<?php
+
+class CRM_OAuth_BAO_OAuthContactToken extends CRM_OAuth_DAO_OAuthContactToken {
+
+}
diff --git a/ext/oauth-client/CRM/OAuth/ContactFromToken.php b/ext/oauth-client/CRM/OAuth/ContactFromToken.php
new file mode 100644 (file)
index 0000000..396fbd5
--- /dev/null
@@ -0,0 +1,80 @@
+<?php
+
+class CRM_OAuth_ContactFromToken {
+
+  /**
+   * Given a token, we add a new record to civicrm_contact based on the
+   * provider's template
+   */
+  public static function createContact(array $token): array {
+    $client = \Civi\Api4\OAuthClient::get(FALSE)
+      ->addWhere('id', '=', $token['client_id'])
+      ->execute()
+      ->single();
+    $provider = \Civi\Api4\OAuthProvider::get(FALSE)
+      ->addWhere('name', '=', $client['provider'])
+      ->execute()
+      ->single();
+
+    $vars = ['token' => $token, 'client' => $client, 'provider' => $provider];
+    $template = ['checkPermissions' => FALSE] + $provider['contactTemplate'];
+    $contact = civicrm_api4(
+      'Contact',
+      'create',
+      self::evalArrayTemplate($template, $vars)
+    )->single();
+    return $contact;
+  }
+
+  /**
+   * @param array $template
+   *   Array of key-value expressions. Arrays can be nested.
+   *   Ex: ['name' => '{{person.first}} {{person.last}}']
+   *
+   *   Expressions begin with a variable name; a string followed by a dot
+   *   denotes an array key. Ex: {{person.first}} means
+   *   $vars['person']['first'].
+   *
+   *   Optionally, the value may be piped through other 'filter' functions.
+   *   Ex: {{person.first|lowercase}}
+   *
+   * @param array $vars
+   *   Array tree of data to interpolate.
+   *
+   * @return array
+   *   The template array, with '{{...}}' expressions evaluated.
+   */
+  public static function evalArrayTemplate(array $template, array $vars): array {
+    $filterFunctions = [
+      'lowercase' => function ($s) {
+        return strtolower($s);
+      },
+    ];
+
+    $evaluateLeafNode = function (&$node) use ($filterFunctions, $vars) {
+      if (!(preg_match(';{{([a-zA-Z0-9_\.\|]+)}};', $node, $matches))) {
+        return $node;
+      }
+
+      $parts = explode('|', $matches[1]);
+      $value = (string) CRM_Utils_Array::pathGet($vars, explode('.', $parts[0]));
+      $filterSteps = array_slice($parts, 1);
+
+      foreach ($filterSteps as $f) {
+        if (isset($filterFunctions[$f])) {
+          $value = $filterFunctions[$f]($value);
+        }
+        else {
+          $value = NULL;
+        }
+      }
+
+      $node = $value;
+    };
+
+    $result = $template;
+    array_walk_recursive($result, $evaluateLeafNode);
+    return $result;
+  }
+
+}
index 0307da99b413e707cc908add1f623d51e07ca274..92d2f4378e2f542edd210995227bd88b264a5b8f 100644 (file)
@@ -6,7 +6,7 @@
  *
  * Generated from oauth-client/xml/schema/CRM/OAuth/OAuthClient.xml
  * DO NOT EDIT.  Generated by CRM_Core_CodeGen
- * (GenCodeChecksum:7487cf595064832b3d55188b3e48bffc)
+ * (GenCodeChecksum:16542326ad093d2d6826a11f1576522e)
  */
 use CRM_OAuth_ExtensionUtil as E;
 
@@ -123,6 +123,7 @@ class CRM_OAuth_DAO_OAuthClient extends CRM_Core_DAO {
           'entity' => 'OAuthClient',
           'bao' => 'CRM_OAuth_DAO_OAuthClient',
           'localizable' => 0,
+          'readonly' => TRUE,
           'add' => '5.32',
         ],
         'provider' => [
@@ -164,6 +165,9 @@ class CRM_OAuth_DAO_OAuthClient extends CRM_Core_DAO {
           'title' => E::ts('Client Secret'),
           'description' => E::ts('Client Secret'),
           'where' => 'civicrm_oauth_client.secret',
+          'permission' => [
+            'manage OAuth client',
+          ],
           'table_name' => 'civicrm_oauth_client',
           'entity' => 'OAuthClient',
           'bao' => 'CRM_OAuth_DAO_OAuthClient',
@@ -176,6 +180,9 @@ class CRM_OAuth_DAO_OAuthClient extends CRM_Core_DAO {
           'title' => E::ts('Options'),
           'description' => E::ts('Extra override options for the service (JSON)'),
           'where' => 'civicrm_oauth_client.options',
+          'permission' => [
+            'manage OAuth client',
+          ],
           'table_name' => 'civicrm_oauth_client',
           'entity' => 'OAuthClient',
           'bao' => 'CRM_OAuth_DAO_OAuthClient',
diff --git a/ext/oauth-client/CRM/OAuth/DAO/OAuthContactToken.php b/ext/oauth-client/CRM/OAuth/DAO/OAuthContactToken.php
new file mode 100644 (file)
index 0000000..691a37b
--- /dev/null
@@ -0,0 +1,483 @@
+<?php
+
+/**
+ * @package CRM
+ * @copyright CiviCRM LLC https://civicrm.org/licensing
+ *
+ * Generated from oauth-client/xml/schema/CRM/OAuth/OAuthContactToken.xml
+ * DO NOT EDIT.  Generated by CRM_Core_CodeGen
+ * (GenCodeChecksum:48364c8d563f29a459f0c3ccbe4f1c8e)
+ */
+use CRM_OAuth_ExtensionUtil as E;
+
+/**
+ * Database access object for the OAuthContactToken entity.
+ */
+class CRM_OAuth_DAO_OAuthContactToken extends CRM_Core_DAO {
+  const EXT = E::LONG_NAME;
+  const TABLE_ADDED = '5.35';
+
+  /**
+   * Static instance to hold the table name.
+   *
+   * @var string
+   */
+  public static $_tableName = 'civicrm_oauth_contact_token';
+
+  /**
+   * Should CiviCRM log any modifications to this table in the civicrm_log table.
+   *
+   * @var bool
+   */
+  public static $_log = FALSE;
+
+  /**
+   * Token ID
+   *
+   * @var int
+   */
+  public $id;
+
+  /**
+   * The tag specifies how this token will be used.
+   *
+   * @var string
+   */
+  public $tag;
+
+  /**
+   * Client ID
+   *
+   * @var int
+   */
+  public $client_id;
+
+  /**
+   * Contact ID
+   *
+   * @var int
+   */
+  public $contact_id;
+
+  /**
+   * Ex: authorization_code
+   *
+   * @var string
+   */
+  public $grant_type;
+
+  /**
+   * List of scopes addressed by this token
+   *
+   * @var text
+   */
+  public $scopes;
+
+  /**
+   * Ex: Bearer or MAC
+   *
+   * @var string
+   */
+  public $token_type;
+
+  /**
+   * Token to present when accessing resources
+   *
+   * @var text
+   */
+  public $access_token;
+
+  /**
+   * Expiration time for the access_token (seconds since epoch)
+   *
+   * @var int
+   */
+  public $expires;
+
+  /**
+   * Token to present when refreshing the access_token
+   *
+   * @var text
+   */
+  public $refresh_token;
+
+  /**
+   * Identifier for the resource owner. Structure varies by service.
+   *
+   * @var string
+   */
+  public $resource_owner_name;
+
+  /**
+   * Cached details describing the resource owner
+   *
+   * @var text
+   */
+  public $resource_owner;
+
+  /**
+   * ?? copied from OAuthSysToken
+   *
+   * @var text
+   */
+  public $error;
+
+  /**
+   * The token response data, per AccessToken::jsonSerialize
+   *
+   * @var text
+   */
+  public $raw;
+
+  /**
+   * When the token was created.
+   *
+   * @var timestamp
+   */
+  public $created_date;
+
+  /**
+   * When the token was created or modified.
+   *
+   * @var timestamp
+   */
+  public $modified_date;
+
+  /**
+   * Class constructor.
+   */
+  public function __construct() {
+    $this->__table = 'civicrm_oauth_contact_token';
+    parent::__construct();
+  }
+
+  /**
+   * Returns localized title of this entity.
+   *
+   * @param bool $plural
+   *   Whether to return the plural version of the title.
+   */
+  public static function getEntityTitle($plural = FALSE) {
+    return $plural ? E::ts('OAuth Contact Tokens') : E::ts('OAuth Contact Token');
+  }
+
+  /**
+   * Returns foreign keys and entity references.
+   *
+   * @return array
+   *   [CRM_Core_Reference_Interface]
+   */
+  public static function getReferenceColumns() {
+    if (!isset(Civi::$statics[__CLASS__]['links'])) {
+      Civi::$statics[__CLASS__]['links'] = static::createReferenceColumns(__CLASS__);
+      Civi::$statics[__CLASS__]['links'][] = new CRM_Core_Reference_Basic(self::getTableName(), 'client_id', 'civicrm_oauth_client', 'id');
+      Civi::$statics[__CLASS__]['links'][] = new CRM_Core_Reference_Basic(self::getTableName(), 'contact_id', 'civicrm_contact', 'id');
+      CRM_Core_DAO_AllCoreTables::invoke(__CLASS__, 'links_callback', Civi::$statics[__CLASS__]['links']);
+    }
+    return Civi::$statics[__CLASS__]['links'];
+  }
+
+  /**
+   * Returns all the column names of this table
+   *
+   * @return array
+   */
+  public static function &fields() {
+    if (!isset(Civi::$statics[__CLASS__]['fields'])) {
+      Civi::$statics[__CLASS__]['fields'] = [
+        'id' => [
+          'name' => 'id',
+          'type' => CRM_Utils_Type::T_INT,
+          'title' => E::ts('Token ID'),
+          'description' => E::ts('Token ID'),
+          'required' => TRUE,
+          'where' => 'civicrm_oauth_contact_token.id',
+          'table_name' => 'civicrm_oauth_contact_token',
+          'entity' => 'OAuthContactToken',
+          'bao' => 'CRM_OAuth_DAO_OAuthContactToken',
+          'localizable' => 0,
+          'readonly' => TRUE,
+          'add' => '5.35',
+        ],
+        'tag' => [
+          'name' => 'tag',
+          'type' => CRM_Utils_Type::T_STRING,
+          'title' => E::ts('Tag'),
+          'description' => E::ts('The tag specifies how this token will be used.'),
+          'maxlength' => 128,
+          'size' => CRM_Utils_Type::HUGE,
+          'where' => 'civicrm_oauth_contact_token.tag',
+          'table_name' => 'civicrm_oauth_contact_token',
+          'entity' => 'OAuthContactToken',
+          'bao' => 'CRM_OAuth_DAO_OAuthContactToken',
+          'localizable' => 0,
+          'add' => '5.35',
+        ],
+        'client_id' => [
+          'name' => 'client_id',
+          'type' => CRM_Utils_Type::T_INT,
+          'title' => E::ts('Client ID'),
+          'description' => E::ts('Client ID'),
+          'where' => 'civicrm_oauth_contact_token.client_id',
+          'table_name' => 'civicrm_oauth_contact_token',
+          'entity' => 'OAuthContactToken',
+          'bao' => 'CRM_OAuth_DAO_OAuthContactToken',
+          'localizable' => 0,
+          'FKClassName' => 'CRM_OAuth_DAO_OAuthClient',
+          'add' => '5.35',
+        ],
+        'contact_id' => [
+          'name' => 'contact_id',
+          'type' => CRM_Utils_Type::T_INT,
+          'title' => E::ts('Contact ID'),
+          'description' => E::ts('Contact ID'),
+          'where' => 'civicrm_oauth_contact_token.contact_id',
+          'table_name' => 'civicrm_oauth_contact_token',
+          'entity' => 'OAuthContactToken',
+          'bao' => 'CRM_OAuth_DAO_OAuthContactToken',
+          'localizable' => 0,
+          'FKClassName' => 'CRM_Contact_DAO_Contact',
+          'add' => '5.35',
+        ],
+        'grant_type' => [
+          'name' => 'grant_type',
+          'type' => CRM_Utils_Type::T_STRING,
+          'title' => E::ts('Grant type'),
+          'description' => E::ts('Ex: authorization_code'),
+          'maxlength' => 31,
+          'size' => CRM_Utils_Type::MEDIUM,
+          'where' => 'civicrm_oauth_contact_token.grant_type',
+          'table_name' => 'civicrm_oauth_contact_token',
+          'entity' => 'OAuthContactToken',
+          'bao' => 'CRM_OAuth_DAO_OAuthContactToken',
+          'localizable' => 0,
+          'add' => '5.35',
+        ],
+        'scopes' => [
+          'name' => 'scopes',
+          'type' => CRM_Utils_Type::T_TEXT,
+          'title' => E::ts('Scopes'),
+          'description' => E::ts('List of scopes addressed by this token'),
+          'where' => 'civicrm_oauth_contact_token.scopes',
+          'table_name' => 'civicrm_oauth_contact_token',
+          'entity' => 'OAuthContactToken',
+          'bao' => 'CRM_OAuth_DAO_OAuthContactToken',
+          'localizable' => 0,
+          'serialize' => self::SERIALIZE_SEPARATOR_BOOKEND,
+          'add' => '5.35',
+        ],
+        'token_type' => [
+          'name' => 'token_type',
+          'type' => CRM_Utils_Type::T_STRING,
+          'title' => E::ts('Token Type'),
+          'description' => E::ts('Ex: Bearer or MAC'),
+          'maxlength' => 128,
+          'size' => CRM_Utils_Type::HUGE,
+          'where' => 'civicrm_oauth_contact_token.token_type',
+          'table_name' => 'civicrm_oauth_contact_token',
+          'entity' => 'OAuthContactToken',
+          'bao' => 'CRM_OAuth_DAO_OAuthContactToken',
+          'localizable' => 0,
+          'add' => '5.35',
+        ],
+        'access_token' => [
+          'name' => 'access_token',
+          'type' => CRM_Utils_Type::T_TEXT,
+          'title' => E::ts('Access Token'),
+          'description' => E::ts('Token to present when accessing resources'),
+          'where' => 'civicrm_oauth_contact_token.access_token',
+          'table_name' => 'civicrm_oauth_contact_token',
+          'entity' => 'OAuthContactToken',
+          'bao' => 'CRM_OAuth_DAO_OAuthContactToken',
+          'localizable' => 0,
+          'add' => '5.35',
+        ],
+        'expires' => [
+          'name' => 'expires',
+          'type' => CRM_Utils_Type::T_INT,
+          'title' => E::ts('Expiration time'),
+          'description' => E::ts('Expiration time for the access_token (seconds since epoch)'),
+          'where' => 'civicrm_oauth_contact_token.expires',
+          'default' => '0',
+          'table_name' => 'civicrm_oauth_contact_token',
+          'entity' => 'OAuthContactToken',
+          'bao' => 'CRM_OAuth_DAO_OAuthContactToken',
+          'localizable' => 0,
+          'add' => '5.35',
+        ],
+        'refresh_token' => [
+          'name' => 'refresh_token',
+          'type' => CRM_Utils_Type::T_TEXT,
+          'title' => E::ts('Refresh Token'),
+          'description' => E::ts('Token to present when refreshing the access_token'),
+          'where' => 'civicrm_oauth_contact_token.refresh_token',
+          'table_name' => 'civicrm_oauth_contact_token',
+          'entity' => 'OAuthContactToken',
+          'bao' => 'CRM_OAuth_DAO_OAuthContactToken',
+          'localizable' => 0,
+          'add' => '5.35',
+        ],
+        'resource_owner_name' => [
+          'name' => 'resource_owner_name',
+          'type' => CRM_Utils_Type::T_STRING,
+          'title' => E::ts('Resource Owner Name'),
+          'description' => E::ts('Identifier for the resource owner. Structure varies by service.'),
+          'maxlength' => 128,
+          'size' => CRM_Utils_Type::HUGE,
+          'where' => 'civicrm_oauth_contact_token.resource_owner_name',
+          'table_name' => 'civicrm_oauth_contact_token',
+          'entity' => 'OAuthContactToken',
+          'bao' => 'CRM_OAuth_DAO_OAuthContactToken',
+          'localizable' => 0,
+          'add' => '5.35',
+        ],
+        'resource_owner' => [
+          'name' => 'resource_owner',
+          'type' => CRM_Utils_Type::T_TEXT,
+          'title' => E::ts('Resource Owner'),
+          'description' => E::ts('Cached details describing the resource owner'),
+          'where' => 'civicrm_oauth_contact_token.resource_owner',
+          'table_name' => 'civicrm_oauth_contact_token',
+          'entity' => 'OAuthContactToken',
+          'bao' => 'CRM_OAuth_DAO_OAuthContactToken',
+          'localizable' => 0,
+          'serialize' => self::SERIALIZE_JSON,
+          'add' => '5.35',
+        ],
+        'error' => [
+          'name' => 'error',
+          'type' => CRM_Utils_Type::T_TEXT,
+          'title' => E::ts('Error'),
+          'description' => E::ts('?? copied from OAuthSysToken'),
+          'where' => 'civicrm_oauth_contact_token.error',
+          'table_name' => 'civicrm_oauth_contact_token',
+          'entity' => 'OAuthContactToken',
+          'bao' => 'CRM_OAuth_DAO_OAuthContactToken',
+          'localizable' => 0,
+          'serialize' => self::SERIALIZE_JSON,
+          'add' => '5.35',
+        ],
+        'raw' => [
+          'name' => 'raw',
+          'type' => CRM_Utils_Type::T_TEXT,
+          'title' => E::ts('Raw token'),
+          'description' => E::ts('The token response data, per AccessToken::jsonSerialize'),
+          'where' => 'civicrm_oauth_contact_token.raw',
+          'table_name' => 'civicrm_oauth_contact_token',
+          'entity' => 'OAuthContactToken',
+          'bao' => 'CRM_OAuth_DAO_OAuthContactToken',
+          'localizable' => 0,
+          'serialize' => self::SERIALIZE_JSON,
+          'add' => '5.35',
+        ],
+        'created_date' => [
+          'name' => 'created_date',
+          'type' => CRM_Utils_Type::T_TIMESTAMP,
+          'title' => E::ts('Created Date'),
+          'description' => E::ts('When the token was created.'),
+          'required' => FALSE,
+          'where' => 'civicrm_oauth_contact_token.created_date',
+          'default' => 'CURRENT_TIMESTAMP',
+          'table_name' => 'civicrm_oauth_contact_token',
+          'entity' => 'OAuthContactToken',
+          'bao' => 'CRM_OAuth_DAO_OAuthContactToken',
+          'localizable' => 0,
+          'add' => '5.35',
+        ],
+        'modified_date' => [
+          'name' => 'modified_date',
+          'type' => CRM_Utils_Type::T_TIMESTAMP,
+          'title' => E::ts('Modified Date'),
+          'description' => E::ts('When the token was created or modified.'),
+          'required' => FALSE,
+          'where' => 'civicrm_oauth_contact_token.modified_date',
+          'default' => 'CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
+          'table_name' => 'civicrm_oauth_contact_token',
+          'entity' => 'OAuthContactToken',
+          'bao' => 'CRM_OAuth_DAO_OAuthContactToken',
+          'localizable' => 0,
+          'add' => '5.35',
+        ],
+      ];
+      CRM_Core_DAO_AllCoreTables::invoke(__CLASS__, 'fields_callback', Civi::$statics[__CLASS__]['fields']);
+    }
+    return Civi::$statics[__CLASS__]['fields'];
+  }
+
+  /**
+   * Return a mapping from field-name to the corresponding key (as used in fields()).
+   *
+   * @return array
+   *   Array(string $name => string $uniqueName).
+   */
+  public static function &fieldKeys() {
+    if (!isset(Civi::$statics[__CLASS__]['fieldKeys'])) {
+      Civi::$statics[__CLASS__]['fieldKeys'] = array_flip(CRM_Utils_Array::collect('name', self::fields()));
+    }
+    return Civi::$statics[__CLASS__]['fieldKeys'];
+  }
+
+  /**
+   * Returns the names of this table
+   *
+   * @return string
+   */
+  public static function getTableName() {
+    return self::$_tableName;
+  }
+
+  /**
+   * Returns if this table needs to be logged
+   *
+   * @return bool
+   */
+  public function getLog() {
+    return self::$_log;
+  }
+
+  /**
+   * Returns the list of fields that can be imported
+   *
+   * @param bool $prefix
+   *
+   * @return array
+   */
+  public static function &import($prefix = FALSE) {
+    $r = CRM_Core_DAO_AllCoreTables::getImports(__CLASS__, 'oauth_contact_token', $prefix, []);
+    return $r;
+  }
+
+  /**
+   * Returns the list of fields that can be exported
+   *
+   * @param bool $prefix
+   *
+   * @return array
+   */
+  public static function &export($prefix = FALSE) {
+    $r = CRM_Core_DAO_AllCoreTables::getExports(__CLASS__, 'oauth_contact_token', $prefix, []);
+    return $r;
+  }
+
+  /**
+   * Returns the list of indices
+   *
+   * @param bool $localize
+   *
+   * @return array
+   */
+  public static function indices($localize = TRUE) {
+    $indices = [
+      'UI_tag' => [
+        'name' => 'UI_tag',
+        'field' => [
+          0 => 'tag',
+        ],
+        'localizable' => FALSE,
+        'sig' => 'civicrm_oauth_contact_token::0::tag',
+      ],
+    ];
+    return ($localize && !empty($indices)) ? CRM_Core_DAO_AllCoreTables::multilingualize(__CLASS__, $indices) : $indices;
+  }
+
+}
index c39e59eec1fec56deca84e0d3841869c95c647f5..6e924542e9dd45152e43ce99ae5b74158588a290 100644 (file)
@@ -6,7 +6,7 @@
  *
  * Generated from oauth-client/xml/schema/CRM/OAuth/OAuthSysToken.xml
  * DO NOT EDIT.  Generated by CRM_Core_CodeGen
- * (GenCodeChecksum:071b8b361ebc9d1b4867e4cef2172389)
+ * (GenCodeChecksum:75d2f40faea06bdd175a66124d1ac77d)
  */
 use CRM_OAuth_ExtensionUtil as E;
 
@@ -188,6 +188,7 @@ class CRM_OAuth_DAO_OAuthSysToken extends CRM_Core_DAO {
           'entity' => 'OAuthSysToken',
           'bao' => 'CRM_OAuth_DAO_OAuthSysToken',
           'localizable' => 0,
+          'readonly' => TRUE,
           'add' => '5.32',
         ],
         'tag' => [
index acc937d6d6148103f90700f54173325e0e68a9e9..ce471b7a8f0ac0c215cb9c765e5bf713e0222072 100644 (file)
@@ -27,6 +27,18 @@ class CRM_OAuth_Upgrader extends CRM_OAuth_Upgrader_Base {
     ]);
   }
 
+  /**
+   * Add support for OAuthContactToken
+   *
+   * @return bool TRUE on success
+   * @throws Exception
+   */
+  public function upgrade_0001(): bool {
+    $this->ctx->log->info('Applying update 0001');
+    $this->executeSqlFile('sql/upgrade_0001.sql');
+    return TRUE;
+  }
+
   /**
    * Example: Run an external SQL script when the module is installed.
    *
diff --git a/ext/oauth-client/Civi/Api4/Action/OAuthContactToken/Create.php b/ext/oauth-client/Civi/Api4/Action/OAuthContactToken/Create.php
new file mode 100644 (file)
index 0000000..78055ed
--- /dev/null
@@ -0,0 +1,62 @@
+<?php
+
+
+namespace Civi\Api4\Action\OAuthContactToken;
+
+use Civi\Api4\Generic\Result;
+
+class Create extends \Civi\Api4\Generic\DAOCreateAction {
+
+  public function _run(Result $result) {
+    $this->fillContactIdFromTag();
+    $this->assertPermissionForTokenContact();
+    parent::_run($result);
+  }
+
+  private function fillContactIdFromTag(): void {
+    if (isset($this->values['contact_id'])) {
+      return;
+    }
+
+    $tag = $this->values['tag'] ?? NULL;
+
+    if ('linkContact:' === substr($tag, 0, 12)) {
+      $this->values['contact_id'] = substr($tag, 12);
+    }
+    elseif ('nullContactId' === $tag) {
+      $this->values['contact_id'] = NULL;
+    }
+    elseif ('createContact' === $tag) {
+      $contact = \CRM_OAuth_ContactFromToken::createContact($this->values);
+      $this->values['contact_id'] = $contact['id'];
+    }
+    else {
+      $this->values['contact_id'] = \CRM_Core_Session::singleton()
+        ->getLoggedInContactID();
+    }
+  }
+
+  /**
+   * @throws \Civi\API\Exception\UnauthorizedException
+   */
+  private function assertPermissionForTokenContact(): void {
+    if (!$this->getCheckPermissions()) {
+      return;
+    }
+    if (\CRM_Core_Permission::check('manage all OAuth contact tokens')) {
+      return;
+    }
+    if (\CRM_Core_Permission::check('manage my OAuth contact tokens')) {
+      $loggedInContactID = \CRM_Core_Session::singleton()
+        ->getLoggedInContactID();
+      $tokenContactID = $this->values['contact_id'] ?? NULL;
+      if ($loggedInContactID == $tokenContactID) {
+        return;
+      }
+    }
+    throw new \Civi\API\Exception\UnauthorizedException(ts(
+      "You do not have permission to create OAuth tokens for contact id %1",
+      [1 => $tokenContactID]));
+  }
+
+}
diff --git a/ext/oauth-client/Civi/Api4/Action/OAuthContactToken/Delete.php b/ext/oauth-client/Civi/Api4/Action/OAuthContactToken/Delete.php
new file mode 100644 (file)
index 0000000..d0d02bc
--- /dev/null
@@ -0,0 +1,10 @@
+<?php
+
+
+namespace Civi\Api4\Action\OAuthContactToken;
+
+class Delete extends \Civi\Api4\Generic\DAODeleteAction {
+
+  use OnlyModifyOwnTokensTrait;
+
+}
diff --git a/ext/oauth-client/Civi/Api4/Action/OAuthContactToken/Get.php b/ext/oauth-client/Civi/Api4/Action/OAuthContactToken/Get.php
new file mode 100644 (file)
index 0000000..346dd90
--- /dev/null
@@ -0,0 +1,29 @@
+<?php
+
+
+namespace Civi\Api4\Action\OAuthContactToken;
+
+class Get extends \Civi\Api4\Generic\DAOGetAction {
+
+  protected function setDefaultWhereClause() {
+    $this->applyContactTokenPermissions();
+    parent::setDefaultWhereClause();
+  }
+
+  private function applyContactTokenPermissions() {
+    if (!$this->getCheckPermissions()) {
+      return;
+    }
+    if (\CRM_Core_Permission::check(['manage all OAuth contact tokens'])) {
+      return;
+    }
+    if (\CRM_Core_Permission::check(['manage my OAuth contact tokens'])) {
+      $loggedInContactID = \CRM_Core_Session::singleton()
+        ->getLoggedInContactID();
+      $this->addWhere('contact_id', '=', $loggedInContactID);
+      return;
+    }
+    throw new \Civi\API\Exception\UnauthorizedException(ts('Insufficient permissions to get contact tokens'));
+  }
+
+}
diff --git a/ext/oauth-client/Civi/Api4/Action/OAuthContactToken/OnlyModifyOwnTokensTrait.php b/ext/oauth-client/Civi/Api4/Action/OAuthContactToken/OnlyModifyOwnTokensTrait.php
new file mode 100644 (file)
index 0000000..77817ab
--- /dev/null
@@ -0,0 +1,31 @@
+<?php
+
+namespace Civi\Api4\Action\OAuthContactToken;
+
+trait OnlyModifyOwnTokensTrait {
+
+  public function isAuthorized(): bool {
+    if (\CRM_Core_Permission::check(['manage all OAuth contact tokens'])) {
+      return TRUE;
+    }
+    if (!\CRM_Core_Permission::check(['manage my OAuth contact tokens'])) {
+      return FALSE;
+    }
+    $loggedInContactID = \CRM_Core_Session::singleton()->getLoggedInContactID();
+    foreach ($this->where as $clause) {
+      [$field, $op, $val] = $clause;
+      if ($field !== 'contact_id') {
+        continue;
+      }
+      if (($op === '=' || $op === 'LIKE') && $val != $loggedInContactID) {
+        return FALSE;
+      }
+      if ($op === 'IN' && $val != [$loggedInContactID]) {
+        return FALSE;
+      }
+    }
+    $this->addWhere('contact_id', '=', $loggedInContactID);
+    return TRUE;
+  }
+
+}
diff --git a/ext/oauth-client/Civi/Api4/Action/OAuthContactToken/Update.php b/ext/oauth-client/Civi/Api4/Action/OAuthContactToken/Update.php
new file mode 100644 (file)
index 0000000..954566a
--- /dev/null
@@ -0,0 +1,10 @@
+<?php
+
+
+namespace Civi\Api4\Action\OAuthContactToken;
+
+class Update extends \Civi\Api4\Generic\DAOUpdateAction {
+
+  use OnlyModifyOwnTokensTrait;
+
+}
index decfad9cece784d7b69ba2efc4ee6fadab9a8438..cc70d604c494cd2aa968b367ddc8daf2f4f597f2 100644 (file)
@@ -1,4 +1,5 @@
 <?php
+
 namespace Civi\Api4;
 
 use Civi\Api4\Action\OAuthClient\Create;
@@ -27,9 +28,10 @@ class OAuthClient extends Generic\DAOEntity {
    * Initiate the "Authorization Code" workflow.
    *
    * @param bool $checkPermissions
+   *
    * @return \Civi\Api4\Action\OAuthClient\AuthorizationCode
    */
-  public static function authorizationCode($checkPermissions = TRUE) {
+  public static function authorizationCode($checkPermissions = TRUE): Action\OAuthClient\AuthorizationCode {
     $action = new \Civi\Api4\Action\OAuthClient\AuthorizationCode(static::class, __FUNCTION__);
     return $action->setCheckPermissions($checkPermissions);
   }
@@ -38,9 +40,10 @@ class OAuthClient extends Generic\DAOEntity {
    * Request access with client credentials
    *
    * @param bool $checkPermissions
+   *
    * @return \Civi\Api4\Action\OAuthClient\ClientCredential
    */
-  public static function clientCredential($checkPermissions = TRUE) {
+  public static function clientCredential($checkPermissions = TRUE): Action\OAuthClient\ClientCredential {
     $action = new \Civi\Api4\Action\OAuthClient\ClientCredential(static::class, __FUNCTION__);
     return $action->setCheckPermissions($checkPermissions);
   }
@@ -49,17 +52,25 @@ class OAuthClient extends Generic\DAOEntity {
    * Request access with a username and password.
    *
    * @param bool $checkPermissions
+   *
    * @return \Civi\Api4\Action\OAuthClient\UserPassword
    */
-  public static function userPassword($checkPermissions = TRUE) {
+  public static function userPassword($checkPermissions = TRUE): Action\OAuthClient\UserPassword {
     $action = new \Civi\Api4\Action\OAuthClient\UserPassword(static::class, __FUNCTION__);
     return $action->setCheckPermissions($checkPermissions);
   }
 
-  public static function permissions() {
+  public static function permissions(): array {
     return [
       'meta' => ['access CiviCRM'],
       'default' => ['manage OAuth client'],
+      'get' => [
+        [
+          'manage OAuth client',
+          'manage my OAuth contact tokens',
+          'manage all OAuth contact tokens',
+        ],
+      ],
     ];
   }
 
diff --git a/ext/oauth-client/Civi/Api4/OAuthContactToken.php b/ext/oauth-client/Civi/Api4/OAuthContactToken.php
new file mode 100644 (file)
index 0000000..59cd596
--- /dev/null
@@ -0,0 +1,47 @@
+<?php
+
+
+namespace Civi\Api4;
+
+/**
+ * OAuthContactToken entity.
+ *
+ * Provided by the OAuth Client extension.
+ *
+ * @package Civi\Api4
+ */
+class OAuthContactToken extends Generic\DAOEntity {
+
+  public static function create($checkPermissions = TRUE) {
+    $action = new Action\OAuthContactToken\Create(static::class, __FUNCTION__);
+    return $action->setCheckPermissions($checkPermissions);
+  }
+
+  public static function get($checkPermissions = TRUE) {
+    $action = new Action\OAuthContactToken\Get(static::class, __FUNCTION__);
+    return $action->setCheckPermissions($checkPermissions);
+  }
+
+  public static function update($checkPermissions = TRUE) {
+    $action = new Action\OAuthContactToken\Update(static::class, __FUNCTION__);
+    return $action->setCheckPermissions($checkPermissions);
+  }
+
+  public static function delete($checkPermissions = TRUE) {
+    $action = new Action\OAuthContactToken\Delete(static::class, __FUNCTION__);
+    return $action->setCheckPermissions($checkPermissions);
+  }
+
+  public static function permissions(): array {
+    return [
+      'meta' => ['access CiviCRM'],
+      'default' => [
+        [
+          'manage my OAuth contact tokens',
+          'manage all OAuth contact tokens',
+        ],
+      ],
+    ];
+  }
+
+}
index dcad4770bfa58f50517ad87a791ce2e3a1f13273..58a9c3edd2ef8f0f8f3fff9c2e8380667da460e5 100644 (file)
@@ -6,7 +6,7 @@ use League\OAuth2\Client\Provider\ResourceOwnerInterface;
 
 class OAuthTokenFacade {
 
-  const STORAGE_TYPES = ';^OAuthSysToken$;';
+  const STORAGE_TYPES = ';^OAuth(Sys|Contact)Token$;';
 
   /**
    * Request and store a token.
@@ -14,24 +14,28 @@ class OAuthTokenFacade {
    * @param array $options
    *   With some mix of the following:
    *   - client: array, the OAuthClient record
-   *   - scope: array|string|null, list of scopes to request. if omitted, inherit default from client/provider
+   *   - scope: array|string|null, list of scopes to request. if omitted,
+   *   inherit default from client/provider
    *   - storage: string, default: "OAuthSysToken"
    *   - tag: string|null, a symbolic/freeform identifier for looking-up tokens
-   *   - grant_type: string, ex "authorization_code", "client_credentials", "password"
-   *   - cred: array, extra credentialing options to pass to the "token" URL (via getAccessToken($tokenOptions)),
-   *        eg "username", "password", "code"
+   *   - grant_type: string, ex "authorization_code", "client_credentials",
+   *   "password"
+   *   - cred: array, extra credentialing options to pass to the "token" URL
+   *   (via getAccessToken($tokenOptions)), eg "username", "password", "code"
+   *
    * @return array
    * @throws \API_Exception
    * @see \League\OAuth2\Client\Provider\AbstractProvider::getAccessToken()
    */
-  public function init($options) {
+  public function init($options): array {
     $options['storage'] = $options['storage'] ?? 'OAuthSysToken';
     if (!preg_match(self::STORAGE_TYPES, $options['storage'])) {
       throw new \API_Exception("Invalid token storage ({$options['storage']})");
     }
 
     /** @var \League\OAuth2\Client\Provider\GenericProvider $provider */
-    $provider = \Civi::service('oauth2.league')->createProvider($options['client']);
+    $provider = \Civi::service('oauth2.league')
+      ->createProvider($options['client']);
     $scopeSeparator = $this->callProtected($provider, 'getScopeSeparator');
 
     $sendOptions = $options['cred'] ?? [];
@@ -60,7 +64,9 @@ class OAuthTokenFacade {
       'refresh_token' => $accessToken->getRefreshToken(),
       'expires' => $accessToken->getExpires(),
       'raw' => $accessToken->jsonSerialize(),
+      'storage' => $options['storage'],
     ];
+
     try {
       $owner = $provider->getResourceOwner($accessToken);
       $tokenRecord['resource_owner_name'] = $this->findName($owner);
@@ -82,9 +88,10 @@ class OAuthTokenFacade {
    * @param mixed $obj
    * @param string $method
    * @param array $args
+   *
    * @return mixed
    */
-  protected function callProtected($obj, $method, $args = []) {
+  protected function callProtected($obj, string $method, $args = []) {
     $r = new \ReflectionMethod(get_class($obj), $method);
     $r->setAccessible(TRUE);
     return $r->invokeArgs($obj, $args);
@@ -93,9 +100,10 @@ class OAuthTokenFacade {
   /**
    * @param string $delim
    * @param string|array|null $scopes
+   *
    * @return array|null
    */
-  protected function splitScopes($delim, $scopes) {
+  protected function splitScopes(string $delim, $scopes) {
     if ($scopes === NULL || is_array($scopes)) {
       return $scopes;
     }
@@ -111,7 +119,7 @@ class OAuthTokenFacade {
     return NULL;
   }
 
-  protected function implodeScopes($delim, $scopes) {
+  protected function implodeScopes($delim, $scopes): ?string {
     if ($scopes === NULL || is_string($scopes)) {
       return $scopes;
     }
@@ -125,6 +133,9 @@ class OAuthTokenFacade {
   }
 
   protected function findName(ResourceOwnerInterface $owner) {
+    if (method_exists($owner, 'getName')) {
+      return $owner->getName();
+    }
     $values = $owner->toArray();
     $fields = ['upn', 'userPrincipalName', 'mail', 'email', 'id'];
     foreach ($fields as $field) {
index 64892d344e8d24ac51f565210481c902e5f60f05..0347786a07a057c5ac4584d938cb7bfcd2c72168 100644 (file)
@@ -221,7 +221,8 @@ function _oauth_client_civix_upgrader() {
  * Search directory tree for files which match a glob pattern.
  *
  * Note: Dot-directories (like "..", ".git", or ".svn") will be ignored.
- * Note: In Civi 4.3+, delegate to CRM_Utils_File::findFiles()
+ * Note: Delegate to CRM_Utils_File::findFiles(), this function kept only
+ * for backward compatibility of extension code that uses it.
  *
  * @param string $dir base dir
  * @param string $pattern , glob pattern, eg "*.txt"
@@ -229,32 +230,7 @@ function _oauth_client_civix_upgrader() {
  * @return array
  */
 function _oauth_client_civix_find_files($dir, $pattern) {
-  if (is_callable(['CRM_Utils_File', 'findFiles'])) {
-    return CRM_Utils_File::findFiles($dir, $pattern);
-  }
-
-  $todos = [$dir];
-  $result = [];
-  while (!empty($todos)) {
-    $subdir = array_shift($todos);
-    foreach (_oauth_client_civix_glob("$subdir/$pattern") as $match) {
-      if (!is_dir($match)) {
-        $result[] = $match;
-      }
-    }
-    if ($dh = opendir($subdir)) {
-      while (FALSE !== ($entry = readdir($dh))) {
-        $path = $subdir . DIRECTORY_SEPARATOR . $entry;
-        if ($entry[0] == '.') {
-        }
-        elseif (is_dir($path)) {
-          $todos[] = $path;
-        }
-      }
-      closedir($dh);
-    }
-  }
-  return $result;
+  return CRM_Utils_File::findFiles($dir, $pattern);
 }
 
 /**
@@ -479,6 +455,11 @@ function _oauth_client_civix_civicrm_entityTypes(&$entityTypes) {
       'class' => 'CRM_OAuth_DAO_OAuthClient',
       'table' => 'civicrm_oauth_client',
     ],
+    'CRM_OAuth_DAO_OAuthContactToken' => [
+      'name' => 'OAuthContactToken',
+      'class' => 'CRM_OAuth_DAO_OAuthContactToken',
+      'table' => 'civicrm_oauth_contact_token',
+    ],
     'CRM_OAuth_DAO_OAuthSysToken' => [
       'name' => 'OAuthSysToken',
       'class' => 'CRM_OAuth_DAO_OAuthSysToken',
index 84930ee6c44d82b11cfa63837203e1f1ebf3dfa8..91bd0f85e534f42beb65eb7d2d7d82755b4dc0d3 100644 (file)
@@ -51,6 +51,18 @@ function oauth_client_civicrm_permission(&$permissions) {
     $prefix . ts('manage OAuth client secrets'),
     ts('Access OAuth secrets'),
   ];
+  $permissions['create OAuth tokens via auth code flow'] = [
+    $prefix . ts('create OAuth tokens via auth code flow'),
+    ts('Create OAuth tokens via the authorization code flow'),
+  ];
+  $permissions['manage my OAuth contact tokens'] = [
+    $prefix . ts('manage my OAuth contact tokens'),
+    ts("Manage user's own OAuth tokens"),
+  ];
+  $permissions['manage all OAuth contact tokens'] = [
+    $prefix . ts('manage all OAuth contact tokens'),
+    ts("Manage OAuth tokens for all contacts"),
+  ];
 }
 
 /**
index b5297d59d35341c2eb16e79a11873ec15cc31321..dbb478e5c0890013ea971a2377bc4db302f50a73 100644 (file)
@@ -10,7 +10,6 @@
 -- DO NOT EDIT.  Generated by CRM_Core_CodeGen
 --
 
-
 -- +--------------------------------------------------------------------+
 -- | Copyright CiviCRM LLC. All rights reserved.                        |
 -- |                                                                    |
 --
 -- /*******************************************************
 -- *
--- * Clean up the exisiting tables
+-- * Clean up the existing tables
 -- *
 -- *******************************************************/
 
 SET FOREIGN_KEY_CHECKS=0;
 
 DROP TABLE IF EXISTS `civicrm_oauth_systoken`;
+DROP TABLE IF EXISTS `civicrm_oauth_contact_token`;
 DROP TABLE IF EXISTS `civicrm_oauth_client`;
 
 SET FOREIGN_KEY_CHECKS=1;
@@ -55,19 +55,53 @@ CREATE TABLE `civicrm_oauth_client` (
      `options` text    COMMENT 'Extra override options for the service (JSON)',
      `is_active` tinyint NOT NULL  DEFAULT 1 COMMENT 'Is the client currently enabled?',
      `created_date` timestamp NOT NULL  DEFAULT CURRENT_TIMESTAMP COMMENT 'When the client was created.',
-     `modified_date` timestamp NOT NULL  DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'When the client was created or modified.' 
+     `modified_date` timestamp NOT NULL  DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'When the client was created or modified.'
 ,
         PRIMARY KEY (`id`)
+
     ,     INDEX `UI_provider`(
         provider
   )
   ,     INDEX `UI_guid`(
         guid
   )
-  
-)    ;
+
+
+)  ENGINE=InnoDB  ;
+
+-- /*******************************************************
+-- *
+-- * civicrm_oauth_contact_token
+-- *
+-- *******************************************************/
+CREATE TABLE `civicrm_oauth_contact_token` (
+
+
+     `id` int unsigned NOT NULL AUTO_INCREMENT  COMMENT 'Token ID',
+     `tag` varchar(128)    COMMENT 'The tag specifies how this token will be used.',
+     `client_id` int unsigned    COMMENT 'Client ID',
+     `contact_id` int unsigned    COMMENT 'Contact ID',
+     `grant_type` varchar(31)    COMMENT 'Ex: authorization_code',
+     `scopes` text    COMMENT 'List of scopes addressed by this token',
+     `token_type` varchar(128)    COMMENT 'Ex: Bearer or MAC',
+     `access_token` text    COMMENT 'Token to present when accessing resources',
+     `expires` int unsigned   DEFAULT 0 COMMENT 'Expiration time for the access_token (seconds since epoch)',
+     `refresh_token` text    COMMENT 'Token to present when refreshing the access_token',
+     `resource_owner_name` varchar(128)    COMMENT 'Identifier for the resource owner. Structure varies by service.',
+     `resource_owner` text    COMMENT 'Cached details describing the resource owner',
+     `error` text    COMMENT '?? copied from OAuthSysToken',
+     `raw` text    COMMENT 'The token response data, per AccessToken::jsonSerialize',
+     `created_date` timestamp NULL  DEFAULT CURRENT_TIMESTAMP COMMENT 'When the token was created.',
+     `modified_date` timestamp NULL  DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'When the token was created or modified.'
+,
+        PRIMARY KEY (`id`)
+
+    ,     INDEX `UI_tag`(
+        tag
+  )
+
+,          CONSTRAINT FK_civicrm_oauth_contact_token_client_id FOREIGN KEY (`client_id`) REFERENCES `civicrm_oauth_client`(`id`) ON DELETE CASCADE,          CONSTRAINT FK_civicrm_oauth_contact_token_contact_id FOREIGN KEY (`contact_id`) REFERENCES `civicrm_contact`(`id`) ON DELETE CASCADE
+)  ENGINE=InnoDB  ;
 
 -- /*******************************************************
 -- *
@@ -91,15 +125,14 @@ CREATE TABLE `civicrm_oauth_systoken` (
      `error` text    COMMENT 'List of scopes addressed by this token',
      `raw` text    COMMENT 'The token response data, per AccessToken::jsonSerialize',
      `created_date` timestamp NULL  DEFAULT CURRENT_TIMESTAMP COMMENT 'When the token was created.',
-     `modified_date` timestamp NULL  DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'When the token was created or modified.' 
+     `modified_date` timestamp NULL  DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'When the token was created or modified.'
 ,
         PRIMARY KEY (`id`)
+
     ,     INDEX `UI_tag`(
         tag
   )
-  
-,          CONSTRAINT FK_civicrm_oauth_systoken_client_id FOREIGN KEY (`client_id`) REFERENCES `civicrm_oauth_client`(`id`) ON DELETE CASCADE  
-)    ;
 
\ No newline at end of file
+,          CONSTRAINT FK_civicrm_oauth_systoken_client_id FOREIGN KEY (`client_id`) REFERENCES `civicrm_oauth_client`(`id`) ON DELETE CASCADE
+)  ENGINE=InnoDB  ;
+
index db6fecf209c9a7b6ecd8d7d0edcf421f1831138a..a3c96fd5662da529b7d67e3d5437ea229c4c95c2 100644 (file)
 --
 -- /*******************************************************
 -- *
--- * Clean up the exisiting tables
+-- * Clean up the existing tables
 -- *
 -- *******************************************************/
 
 SET FOREIGN_KEY_CHECKS=0;
 
 DROP TABLE IF EXISTS `civicrm_oauth_systoken`;
+DROP TABLE IF EXISTS `civicrm_oauth_contact_token`;
 DROP TABLE IF EXISTS `civicrm_oauth_client`;
 
 SET FOREIGN_KEY_CHECKS=1;
\ No newline at end of file
diff --git a/ext/oauth-client/sql/upgrade_0001.sql b/ext/oauth-client/sql/upgrade_0001.sql
new file mode 100644 (file)
index 0000000..17fc319
--- /dev/null
@@ -0,0 +1,73 @@
+-- +--------------------------------------------------------------------+
+-- | Copyright CiviCRM LLC. All rights reserved.                        |
+-- |                                                                    |
+-- | This work is published under the GNU AGPLv3 license with some      |
+-- | permitted exceptions and without any warranty. For full license    |
+-- | and copyright information, see https://civicrm.org/licensing       |
+-- +--------------------------------------------------------------------+
+--
+-- Generated from schema.tpl
+-- DO NOT EDIT.  Generated by CRM_Core_CodeGen
+--
+
+-- +--------------------------------------------------------------------+
+-- | Copyright CiviCRM LLC. All rights reserved.                        |
+-- |                                                                    |
+-- | This work is published under the GNU AGPLv3 license with some      |
+-- | permitted exceptions and without any warranty. For full license    |
+-- | and copyright information, see https://civicrm.org/licensing       |
+-- +--------------------------------------------------------------------+
+--
+-- Generated from drop.tpl
+-- DO NOT EDIT.  Generated by CRM_Core_CodeGen
+--
+-- /*******************************************************
+-- *
+-- * Clean up the existing tables
+-- *
+-- *******************************************************/
+
+SET FOREIGN_KEY_CHECKS=0;
+
+DROP TABLE IF EXISTS `civicrm_oauth_contact_token`;
+
+SET FOREIGN_KEY_CHECKS=1;
+-- /*******************************************************
+-- *
+-- * Create new tables
+-- *
+-- *******************************************************/
+
+-- /*******************************************************
+-- *
+-- * civicrm_oauth_contact_token
+-- *
+-- *******************************************************/
+CREATE TABLE `civicrm_oauth_contact_token` (
+
+
+     `id` int unsigned NOT NULL AUTO_INCREMENT  COMMENT 'Token ID',
+     `tag` varchar(128)    COMMENT 'The tag specifies how this token will be used.',
+     `client_id` int unsigned    COMMENT 'Client ID',
+     `contact_id` int unsigned    COMMENT 'Contact ID',
+     `grant_type` varchar(31)    COMMENT 'Ex: authorization_code',
+     `scopes` text    COMMENT 'List of scopes addressed by this token',
+     `token_type` varchar(128)    COMMENT 'Ex: Bearer or MAC',
+     `access_token` text    COMMENT 'Token to present when accessing resources',
+     `expires` int unsigned   DEFAULT 0 COMMENT 'Expiration time for the access_token (seconds since epoch)',
+     `refresh_token` text    COMMENT 'Token to present when refreshing the access_token',
+     `resource_owner_name` varchar(128)    COMMENT 'Identifier for the resource owner. Structure varies by service.',
+     `resource_owner` text    COMMENT 'Cached details describing the resource owner',
+     `error` text    COMMENT '?? copied from OAuthSysToken',
+     `raw` text    COMMENT 'The token response data, per AccessToken::jsonSerialize',
+     `created_date` timestamp NULL  DEFAULT CURRENT_TIMESTAMP COMMENT 'When the token was created.',
+     `modified_date` timestamp NULL  DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'When the token was created or modified.'
+,
+        PRIMARY KEY (`id`)
+
+    ,     INDEX `UI_tag`(
+        tag
+  )
+
+,          CONSTRAINT FK_civicrm_oauth_contact_token_client_id FOREIGN KEY (`client_id`) REFERENCES `civicrm_oauth_client`(`id`) ON DELETE CASCADE,          CONSTRAINT FK_civicrm_oauth_contact_token_contact_id FOREIGN KEY (`contact_id`) REFERENCES `civicrm_contact`(`id`) ON DELETE CASCADE
+)  ENGINE=InnoDB  ;
index 250d5c6ef80bc33e07d0e94b3d3be7eab0772552..94579c9c254f91d39f57ae8d7912e21054340780 100644 (file)
@@ -37,7 +37,7 @@ class AuthCodeFlowTest extends \PHPUnit\Framework\TestCase implements
     $providers = array_merge($providers, $this->providers);
   }
 
-  public function makeDummyProviderThatGetsAToken(): void {
+  public function makeDummyProviderThatGetsAToken(): array {
     $idTokenHeader = ['alg' => 'RS256', 'kid' => '123456789', 'typ' => 'JWT'];
     $idTokenPayload = [
       'iss' => 'https://dummy',
@@ -102,6 +102,8 @@ class AuthCodeFlowTest extends \PHPUnit\Framework\TestCase implements
     ];
 
     require_once 'tests/fixtures/DummyProvider.php';
+
+    return $this->providers;
   }
 
   public function makeDummyProviderClient(): array {
@@ -114,7 +116,7 @@ class AuthCodeFlowTest extends \PHPUnit\Framework\TestCase implements
     )->execute()->single();
   }
 
-  public function testFetchAndStoreSysToken() {
+  public function testSysToken_FetchAndStore() {
     $this->makeDummyProviderThatGetsAToken();
     $client = $this->makeDummyProviderClient();
 
@@ -155,4 +157,235 @@ class AuthCodeFlowTest extends \PHPUnit\Framework\TestCase implements
       $tokenRecord['resource_owner']);
   }
 
+  public function testContactToken_AnonymousUser_LinkExistingContactRecord() {
+    $this->makeDummyProviderThatGetsAToken();
+    $client = $this->makeDummyProviderClient();
+
+    $this->assertNull(\CRM_Core_Session::singleton()->getLoggedInContactID());
+    $notLoggedInContactID = \Civi\Api4\Contact::get(FALSE)
+      ->setSelect(['id'])
+      ->setLimit(1)
+      ->execute()
+      ->single()['id'];
+
+    /** @var OAuthTokenFacade $tokenService */
+    $tokenService = \Civi::service('oauth2.token');
+
+    // Assuming we set the tag to $notLoggedInContactID in the call to
+    // Civi\Api4\OAuthClient::authorizationCode(), this is the call that
+    // \CRM_OAuth_Page_Return::run would make upon receiving an auth code.
+    $tokenRecord = $tokenService->init(
+      [
+        'client' => $client,
+        'scope' => 'foo',
+        'tag' => "linkContact:$notLoggedInContactID",
+        'storage' => 'OAuthContactToken',
+        'grant_type' => 'authorization_code',
+        'cred' => ['code' => 'example-auth-code'],
+      ]
+    );
+    $this->assertTrue(is_numeric($tokenRecord['id']));
+    $this->assertEquals($client['id'], $tokenRecord['client_id']);
+    $this->assertEquals(['foo'], $tokenRecord['scopes']);
+    $this->assertEquals('example-access-token-value', $tokenRecord['access_token']);
+    $this->assertEquals('example-refresh-token-value', $tokenRecord['refresh_token']);
+    $this->assertEquals($notLoggedInContactID, $tokenRecord['contact_id']);
+  }
+
+  public function testContactToken_AnonymousUser_SetNullContactId() {
+    $this->makeDummyProviderThatGetsAToken();
+    $client = $this->makeDummyProviderClient();
+
+    $this->assertNull(\CRM_Core_Session::singleton()->getLoggedInContactID());
+
+    /** @var OAuthTokenFacade $tokenService */
+    $tokenService = \Civi::service('oauth2.token');
+
+    // Assuming we set tag='nullContactId' in the call to Civi\Api4\OAuthClient::authorizationCode(),
+    // this is the call that \CRM_OAuth_Page_Return::run would make upon receiving an auth code.
+    $tokenRecord = $tokenService->init(
+      [
+        'client' => $client,
+        'scope' => 'foo',
+        'tag' => 'nullContactId',
+        'storage' => 'OAuthContactToken',
+        'grant_type' => 'authorization_code',
+        'cred' => ['code' => 'example-auth-code'],
+      ]
+    );
+    $this->assertTrue(is_numeric($tokenRecord['id']));
+    $this->assertEquals($client['id'], $tokenRecord['client_id']);
+    $this->assertEquals(['foo'], $tokenRecord['scopes']);
+    $this->assertEquals('example-access-token-value', $tokenRecord['access_token']);
+    $this->assertEquals('example-refresh-token-value', $tokenRecord['refresh_token']);
+    $this->assertNull($tokenRecord['contact_id']);
+  }
+
+  public function testContactToken_AnonymousUser_CreateContact() {
+    $this->makeDummyProviderThatGetsAToken();
+    $client = $this->makeDummyProviderClient();
+
+    $this->assertNull(\CRM_Core_Session::singleton()->getLoggedInContactID());
+    $notLoggedInContactID = \Civi\Api4\Contact::get(FALSE)
+      ->addSelect('id')
+      ->addOrderBy('id', 'DESC')
+      ->setLimit(1)
+      ->execute()
+      ->single()['id'];
+
+    /** @var OAuthTokenFacade $tokenService */
+    $tokenService = \Civi::service('oauth2.token');
+
+    // Assuming we set tag='createContact' when calling Civi\Api4\OAuthClient::authorizationCode(),
+    // this is the call that \CRM_OAuth_Page_Return::run would make upon receiving an auth code.
+    $tokenRecord = $tokenService->init(
+      [
+        'client' => $client,
+        'scope' => 'foo',
+        'tag' => "createContact",
+        'storage' => 'OAuthContactToken',
+        'grant_type' => 'authorization_code',
+        'cred' => ['code' => 'example-auth-code'],
+      ]
+    );
+    $this->assertTrue(is_numeric($tokenRecord['id']));
+    $this->assertEquals($client['id'], $tokenRecord['client_id']);
+    $this->assertEquals(['foo'], $tokenRecord['scopes']);
+    $this->assertEquals('example-access-token-value', $tokenRecord['access_token']);
+    $this->assertEquals('example-refresh-token-value', $tokenRecord['refresh_token']);
+    $this->assertGreaterThan($notLoggedInContactID, $tokenRecord['contact_id']);
+    $contact = \Civi\Api4\Contact::get(0)
+      ->addWhere('id', '=', $tokenRecord['contact_id'])
+      ->addJoin('Email AS email')
+      ->addSelect('email.email')
+      ->execute()->single();
+    $this->assertEquals('test@baz.biff', $contact['email.email']);
+  }
+
+  public function testContactToken_LoggedInUser_Default() {
+    $this->makeDummyProviderThatGetsAToken();
+    $client = $this->makeDummyProviderClient();
+    $loggedInContactID = $this->createLoggedInUser();
+
+    /** @var OAuthTokenFacade $tokenService */
+    $tokenService = \Civi::service('oauth2.token');
+
+    // This is what \CRM_OAuth_Page_Return::run would call upon receiving an auth code,
+    // assuming we hadn't set any tag earlier in the process.
+    $tokenRecord = $tokenService->init(
+      [
+        'client' => $client,
+        'scope' => 'foo',
+        'tag' => NULL,
+        'storage' => 'OAuthContactToken',
+        'grant_type' => 'authorization_code',
+        'cred' => ['code' => 'example-auth-code'],
+      ]
+    );
+    $this->assertTrue(is_numeric($tokenRecord['id']));
+    $this->assertEquals($client['id'], $tokenRecord['client_id']);
+    $this->assertEquals(['foo'], $tokenRecord['scopes']);
+    $this->assertEquals('example-access-token-value', $tokenRecord['access_token']);
+    $this->assertEquals('example-refresh-token-value', $tokenRecord['refresh_token']);
+    $this->assertEquals($loggedInContactID, $tokenRecord['contact_id']);
+  }
+
+  public function testContactToken_LoggedInUser_LinkOtherContact() {
+    $this->makeDummyProviderThatGetsAToken();
+    $client = $this->makeDummyProviderClient();
+    $loggedInContactID = $this->createLoggedInUser();
+    $notLoggedInContactID = \Civi\Api4\Contact::get(FALSE)
+      ->setSelect(['id'])
+      ->setLimit(1)
+      ->execute()
+      ->single()['id'];
+    $this->assertNotEquals($loggedInContactID, $notLoggedInContactID);
+
+    /** @var OAuthTokenFacade $tokenService */
+    $tokenService = \Civi::service('oauth2.token');
+
+    // Assuming we set tag="linkContact:$notLoggedInContactID" when invoking
+    // Civi\Api4\OAuthClient::authorizationCode(), this is the call that
+    // CRM_OAuth_Page_Return::run would make upon receiving an auth code.
+    $tokenRecord = $tokenService->init(
+      [
+        'client' => $client,
+        'scope' => 'foo',
+        'tag' => "linkContact:$notLoggedInContactID",
+        'storage' => 'OAuthContactToken',
+        'grant_type' => 'authorization_code',
+        'cred' => ['code' => 'example-auth-code'],
+      ]
+    );
+    $this->assertTrue(is_numeric($tokenRecord['id']));
+    $this->assertEquals($client['id'], $tokenRecord['client_id']);
+    $this->assertEquals(['foo'], $tokenRecord['scopes']);
+    $this->assertEquals('example-access-token-value', $tokenRecord['access_token']);
+    $this->assertEquals('example-refresh-token-value', $tokenRecord['refresh_token']);
+    $this->assertEquals($notLoggedInContactID, $tokenRecord['contact_id']);
+  }
+
+  public function testContactToken_LoggedInUser_CreateContact() {
+    $this->makeDummyProviderThatGetsAToken();
+    $client = $this->makeDummyProviderClient();
+    $loggedInContactID = $this->createLoggedInUser();
+
+    /** @var OAuthTokenFacade $tokenService */
+    $tokenService = \Civi::service('oauth2.token');
+
+    // Assuming we set tag='createContact' when calling Civi\Api4\OAuthClient::authorizationCode(),
+    // this is the call that \CRM_OAuth_Page_Return::run would make upon receiving an auth code.
+    $tokenRecord = $tokenService->init(
+      [
+        'client' => $client,
+        'scope' => 'foo',
+        'tag' => "createContact",
+        'storage' => 'OAuthContactToken',
+        'grant_type' => 'authorization_code',
+        'cred' => ['code' => 'example-auth-code'],
+      ]
+    );
+    $this->assertTrue(is_numeric($tokenRecord['id']));
+    $this->assertEquals($client['id'], $tokenRecord['client_id']);
+    $this->assertEquals(['foo'], $tokenRecord['scopes']);
+    $this->assertEquals('example-access-token-value', $tokenRecord['access_token']);
+    $this->assertEquals('example-refresh-token-value', $tokenRecord['refresh_token']);
+    $this->assertGreaterThan($loggedInContactID, $tokenRecord['contact_id']);
+    $contact = \Civi\Api4\Contact::get(0)
+      ->addWhere('id', '=', $tokenRecord['contact_id'])
+      ->addJoin('Email AS email')
+      ->addSelect('email.email')
+      ->execute()->single();
+    $this->assertEquals('test@baz.biff', $contact['email.email']);
+  }
+
+  public function testContactToken_LoggedInUser_SetNullContactId() {
+    $this->makeDummyProviderThatGetsAToken();
+    $client = $this->makeDummyProviderClient();
+    $loggedInContactID = $this->createLoggedInUser();
+
+    /** @var OAuthTokenFacade $tokenService */
+    $tokenService = \Civi::service('oauth2.token');
+
+    // Assuming we set tag="nullContactId" when invoking
+    // Civi\Api4\OAuthClient::authorizationCode(), this is the call that
+    // CRM_OAuth_Page_Return::run would make upon receiving an auth code.
+    $tokenRecord = $tokenService->init(
+      [
+        'client' => $client,
+        'scope' => 'foo',
+        'tag' => "nullContactId",
+        'storage' => 'OAuthContactToken',
+        'grant_type' => 'authorization_code',
+        'cred' => ['code' => 'example-auth-code'],
+      ]
+    );
+    $this->assertTrue(is_numeric($tokenRecord['id']));
+    $this->assertEquals($client['id'], $tokenRecord['client_id']);
+    $this->assertEquals(['foo'], $tokenRecord['scopes']);
+    $this->assertEquals('example-access-token-value', $tokenRecord['access_token']);
+    $this->assertEquals('example-refresh-token-value', $tokenRecord['refresh_token']);
+    $this->assertNull($tokenRecord['contact_id']);
+  }
+
 }
diff --git a/ext/oauth-client/tests/phpunit/api/v4/OAuthContactTokenTest.php b/ext/oauth-client/tests/phpunit/api/v4/OAuthContactTokenTest.php
new file mode 100644 (file)
index 0000000..131bf6d
--- /dev/null
@@ -0,0 +1,345 @@
+<?php
+
+use Civi\Test\HeadlessInterface;
+use Civi\Test\HookInterface;
+use Civi\Test\TransactionalInterface;
+
+/**
+ * Create, read, and destroy contact-specific OAuth tokens
+ *
+ * A logged in user who has permission to "manage my OAuth contact tokens"
+ * can create tokens associated with their own contact id, and can
+ * read/update/delete those tokens if they have at least view access
+ * to their own contact record.
+ *
+ * A user who has permission to "manage all OAuth contact tokens" can create
+ * tokens associated with any contact, and can read/update/delete tokens
+ * associated with any contact for whom they have at least view access.
+ *
+ * Users who have either of the "manage OAuth contact tokens" permissions can
+ * also get basic OAuthClient information, NOT including the client's secret.
+ *
+ * @group headless
+ */
+class api_v4_OAuthContactTokenTest extends \PHPUnit\Framework\TestCase implements
+    HeadlessInterface,
+    HookInterface,
+    TransactionalInterface {
+
+  use Civi\Test\ContactTestTrait;
+  use \Civi\Test\Api3TestTrait;
+
+  private $hookEvents;
+
+  public function setUpHeadless(): \Civi\Test\CiviEnvBuilder {
+    // Civi\Test has many helpers, like install(), uninstall(), sql(), and sqlFile().
+    // See: https://docs.civicrm.org/dev/en/latest/testing/phpunit/#civitest
+    return \Civi\Test::headless()->install('oauth-client')->apply();
+  }
+
+  public function setUp(): void {
+    parent::setUp();
+    $this->assertEquals(0, CRM_Core_DAO::singleValueQuery('SELECT count(*) FROM civicrm_oauth_client'));
+    $this->assertEquals(0, CRM_Core_DAO::singleValueQuery('SELECT count(*) FROM civicrm_oauth_contact_token'));
+  }
+
+  public function tearDown(): void {
+    parent::tearDown();
+  }
+
+  private function createClient(): ?array {
+    $createClient = Civi\Api4\OAuthClient::create(FALSE)->setValues(
+      [
+        'provider' => 'test_example_1',
+        'guid' => "example-client-guid",
+        'secret' => "example-secret",
+      ]
+    )->execute();
+    $client = $createClient->first();
+    $this->assertTrue(is_numeric($client['id']));
+    return $client;
+  }
+
+  private function createTestContactIDs(): array {
+    $notLoggedInContactID = Civi\Api4\Contact::get(FALSE)
+      ->setSelect(['id'])
+      ->setLimit(1)
+      ->execute()
+      ->single()['id'];
+    $loggedInContactID = $this->createLoggedInUser();
+    return [$loggedInContactID, $notLoggedInContactID];
+  }
+
+  private function usePerms(array $permissions) {
+    $base = ['access CiviCRM'];
+    CRM_Core_Config::singleton()->userPermissionClass->permissions = array_merge($base, $permissions);
+    if ($cid = CRM_Core_Session::singleton()->getLoggedInContactID()) {
+      CRM_ACL_BAO_Cache::deleteContactCacheEntry($cid);
+      CRM_Contact_BAO_Contact_Permission::cache($cid, CRM_Core_Permission::VIEW, TRUE);
+    }
+  }
+
+  private function getTestTokenCreateValues($client, $contactId, $prefix) {
+    return [
+      'client_id' => $client['id'],
+      'contact_id' => $contactId,
+      'access_token' => "$prefix-user-access-token",
+      'refresh_token' => "$prefix-user-refresh-token",
+    ];
+  }
+
+  private function makeToken(array $values): ?array {
+    return Civi\Api4\OAuthContactToken::create(FALSE)
+      ->setValues($values)
+      ->execute()
+      ->first();
+  }
+
+  private function createOwnAndStrangerTokens(
+    $client,
+    $loggedInContactID,
+    $notLoggedInContactID
+  ): array {
+    $ownTokenCreationVals = $this->getTestTokenCreateValues(
+      $client, $loggedInContactID, 'own');
+    $strangerTokenCreationVals = $this->getTestTokenCreateValues(
+      $client, $notLoggedInContactID, 'other');
+    return [
+      $this->makeToken($ownTokenCreationVals),
+      $this->makeToken($strangerTokenCreationVals),
+    ];
+  }
+
+  public function hook_civicrm_post($op, $objectName, $objectId, &$objectRef) {
+    if ($objectName === 'OAuthContactToken') {
+      $this->hookEvents['post'][] = func_get_args();
+    }
+  }
+
+  public function testGetClientDetails() {
+    $createClient = $this->createClient();
+
+    $this->usePerms(['manage my OAuth contact tokens']);
+    $getClient = Civi\Api4\OAuthClient::get()
+      ->addWhere('id', '=', $createClient['id'])
+      ->execute()
+      ->single();
+    $this->assertEquals($createClient['guid'], $getClient['guid']);
+    $this->assertEquals($createClient['provider'], $getClient['provider']);
+    $this->assertArrayNotHasKey('secret', $getClient);
+  }
+
+  public function testCreate() {
+    $client = $this->createClient();
+    [$loggedInContactID, $notLoggedInContactID] = $this->createTestContactIDs();
+    $ownTokenCreateVals = $this->getTestTokenCreateValues(
+      $client, $loggedInContactID, 'own');
+    $strangerTokenCreateVals = $this->getTestTokenCreateValues(
+      $client, $notLoggedInContactID, 'other');
+
+    $this->usePerms(['manage all OAuth contact tokens']);
+    $createOtherContactToken = Civi\Api4\OAuthContactToken::create()
+      ->setValues($strangerTokenCreateVals)
+      ->execute();
+    $token = $createOtherContactToken->first();
+    $tokenIDOfDifferentContact = $token['id'];
+    $this->assertTrue(is_numeric($tokenIDOfDifferentContact));
+    $this->assertEquals($client['id'], $token['client_id']);
+    $this->assertEquals($notLoggedInContactID, $token['contact_id']);
+    $this->assertEquals($strangerTokenCreateVals['access_token'], $token['access_token']);
+    $this->assertEquals($strangerTokenCreateVals['refresh_token'], $token['refresh_token']);
+
+    $this->usePerms(['manage my OAuth contact tokens']);
+    $createOwnToken = Civi\Api4\OAuthContactToken::create()
+      ->setValues($ownTokenCreateVals)
+      ->execute();
+    $token = $createOwnToken->first();
+    $tokenIDOfLoggedInContact = $token['id'];
+    $this->assertTrue(is_numeric($tokenIDOfLoggedInContact));
+    $this->assertEquals($client['id'], $token['client_id']);
+    $this->assertEquals($loggedInContactID, $token['contact_id']);
+    $this->assertEquals($ownTokenCreateVals['access_token'], $token['access_token']);
+    $this->assertEquals($ownTokenCreateVals['refresh_token'], $token['refresh_token']);
+
+    $this->usePerms(['manage my OAuth contact tokens']);
+    try {
+      Civi\Api4\OAuthContactToken::create()
+        ->setValues($strangerTokenCreateVals)
+        ->execute();
+      $this->fail('Expected \Civi\API\Exception\UnauthorizedException but none was thrown');
+    }
+    catch (\Civi\API\Exception\UnauthorizedException $e) {
+      // exception successfully thrown
+    }
+  }
+
+  public function testRead() {
+    $client = $this->createClient();
+    [$loggedInContactID, $notLoggedInContactID] = $this->createTestContactIDs();
+    $ownTokenCreationVals = $this->getTestTokenCreateValues(
+      $client, $loggedInContactID, 'own');
+    $this->createOwnAndStrangerTokens($client, $loggedInContactID, $notLoggedInContactID);
+
+    $this->usePerms(['manage all OAuth contact tokens', 'view all contacts']);
+    $getTokensWithFullAccess = Civi\Api4\OAuthContactToken::get()->execute();
+    $this->assertCount(2, $getTokensWithFullAccess);
+
+    $this->usePerms(['manage my OAuth contact tokens', 'view my contact']);
+    $getTokensWithOwnAccess = Civi\Api4\OAuthContactToken::get()->execute();
+    $this->assertCount(1, $getTokensWithOwnAccess);
+    $token = $getTokensWithOwnAccess->first();
+    $this->assertEquals($client['id'], $token['client_id']);
+    $this->assertEquals($loggedInContactID, $token['contact_id']);
+    $this->assertEquals($ownTokenCreationVals['access_token'], $token['access_token']);
+    $this->assertEquals($ownTokenCreationVals['refresh_token'], $token['refresh_token']);
+
+    $this->usePerms(['manage my OAuth contact tokens', 'view my contact']);
+    $getTokensForWrongContact = Civi\Api4\OAuthContactToken::get()
+      ->addWhere('contact_id', '=', $notLoggedInContactID)
+      ->execute();
+    $this->assertCount(0, $getTokensForWrongContact);
+
+    $this->usePerms(['manage all OAuth contact tokens']);
+    $getTokensWithNoContactAccess = Civi\Api4\OAuthContactToken::get()
+      ->execute();
+    $this->assertCount(0, $getTokensWithNoContactAccess);
+  }
+
+  public function testUpdate() {
+    $client = $this->createClient();
+    [$loggedInContactID, $notLoggedInContactID] = $this->createTestContactIDs();
+    [
+      $ownContactToken,
+      $strangerContactToken,
+    ] = $this->createOwnAndStrangerTokens(
+      $client,
+      $loggedInContactID,
+      $notLoggedInContactID
+    );
+
+    $this->usePerms(['manage all OAuth contact tokens', 'view all contacts']);
+    $updateTokensWithFullAccess = Civi\Api4\OAuthContactToken::update()
+      ->addWhere('contact_id', '=', $notLoggedInContactID)
+      ->setValues(['access_token' => 'stranger-token-revised'])
+      ->execute();
+    $this->assertCount(1, $updateTokensWithFullAccess);
+    $token = $updateTokensWithFullAccess->first();
+    $this->assertEquals($strangerContactToken['id'], $token['id']);
+
+    $this->usePerms(['manage my OAuth contact tokens', 'view my contact']);
+    $updateTokensWithLimitedAccess = Civi\Api4\OAuthContactToken::update()
+      ->addWhere('client.guid', '=', $client['guid'])
+      ->setValues(['access_token' => 'own-token-revised'])
+      ->execute();
+    $this->assertCount(1, $updateTokensWithLimitedAccess);
+    $token = $updateTokensWithLimitedAccess->first();
+    $this->assertEquals($ownContactToken['id'], $token['id']);
+
+    $this->usePerms(['manage my OAuth contact tokens', 'view my contact']);
+    $getUpdatedTokensWithLimitedAccess = Civi\Api4\OAuthContactToken::get()
+      ->execute();
+    $this->assertCount(1, $getUpdatedTokensWithLimitedAccess);
+    $token = $getUpdatedTokensWithLimitedAccess->first();
+    $this->assertEquals($loggedInContactID, $token['contact_id']);
+    $this->assertEquals("own-token-revised", $token['access_token']);
+
+    $this->usePerms(['manage my OAuth contact tokens', 'view my contact']);
+    try {
+      Civi\Api4\OAuthContactToken::update()
+        ->addWhere('contact_id', '=', $notLoggedInContactID)
+        ->setValues(['access_token' => "stranger-token-revised"])
+        ->execute();
+      $this->fail('Expected \Civi\API\Exception\UnauthorizedException but none was thrown');
+    }
+    catch (\Civi\API\Exception\UnauthorizedException $e) {
+      // exception successfully thrown
+    }
+
+    $this->usePerms(['manage my OAuth contact tokens', 'view my contact']);
+    $updateTokensForWrongContact = Civi\Api4\OAuthContactToken::update()
+      ->addWhere('contact.id', '=', $notLoggedInContactID)
+      // ^ sneaky way to update a different contact?
+      ->setValues(['access_token' => "stranger-token-revised"])
+      ->execute();
+    $this->assertCount(0, $updateTokensForWrongContact);
+  }
+
+  public function testDelete() {
+    $client = $this->createClient();
+    [$loggedInContactID, $notLoggedInContactID] = $this->createTestContactIDs();
+    $this->createOwnAndStrangerTokens($client, $loggedInContactID, $notLoggedInContactID);
+
+    $this->usePerms(['manage my OAuth contact tokens', 'view all contacts']);
+    $deleteTokensWithLimitedAccess = Civi\Api4\OAuthContactToken::delete()
+      ->setWhere([['client.guid', '=', $client['guid']]])
+      ->execute();
+
+    $this->usePerms(['manage my OAuth contact tokens', 'view all contacts']);
+    $getTokensWithLimitedAccess = Civi\Api4\OAuthContactToken::get()->execute();
+    $this->assertCount(0, $getTokensWithLimitedAccess);
+
+    $this->usePerms(['manage all OAuth contact tokens', 'view all contacts']);
+    $getTokensWithFullAccess = Civi\Api4\OAuthContactToken::get()->execute();
+    $this->assertCount(1, $getTokensWithFullAccess);
+
+    $this->usePerms(['manage my OAuth contact tokens', 'view all contacts']);
+    $this->expectException(\Civi\API\Exception\UnauthorizedException::class);
+    Civi\Api4\OAuthContactToken::delete()
+      ->addWhere('contact_id', '=', $notLoggedInContactID)
+      ->execute();
+  }
+
+  public function testGetByScope() {
+    $client = $this->createClient();
+
+    $this->usePerms(['manage all OAuth contact tokens', 'view all contacts']);
+    $tokenCreationVals = [
+      'client_id' => $client['id'],
+      'contact_id' => 1,
+      'access_token' => "loggedin-user-access-token",
+      'refresh_token' => "loggedin-user-refresh-token",
+      'scopes' => ['foo', 'bar'],
+    ];
+    $createToken = Civi\Api4\OAuthContactToken::create()
+      ->setValues($tokenCreationVals)
+      ->execute();
+    $token = $createToken->first();
+    $this->assertTrue(is_numeric($token['id']));
+    $this->assertEquals(['foo', 'bar'], $token['scopes']);
+
+    $this->usePerms(['manage all OAuth contact tokens', 'view all contacts']);
+    $getTokens = Civi\Api4\OAuthContactToken::get()
+      ->addWhere('client.provider', '=', $client['provider'])
+      ->addWhere('scopes', 'CONTAINS', 'foo')
+      ->execute();
+    $this->assertCount(1, $getTokens);
+    $this->assertEquals($createToken->first()['id'], $getTokens->first()['id']);
+
+    $this->usePerms(['manage all OAuth contact tokens', 'view all contacts']);
+    $getTokens = Civi\Api4\OAuthContactToken::get()
+      ->addWhere('client.provider', '=', $client['provider'])
+      ->addWhere('scopes', 'CONTAINS', 'nada')
+      ->execute();
+    $this->assertCount(0, $getTokens);
+
+    $this->usePerms(['manage all OAuth contact tokens', 'view all contacts']);
+    $getTokens = Civi\Api4\OAuthContactToken::get()
+      ->addWhere('client.provider', '=', 'some-other-provider')
+      ->addWhere('scopes', 'CONTAINS', 'foo')
+      ->execute();
+    $this->assertCount(0, $getTokens);
+  }
+
+  public function testPostHook() {
+    $client = $this->createClient();
+    [$loggedInContactID, $notLoggedInContactID] = $this->createTestContactIDs();
+    $strangerTokenCreationVals = $this->getTestTokenCreateValues(
+      $client, $loggedInContactID, 'other');
+
+    $this->usePerms(['manage all OAuth contact tokens']);
+    $this->makeToken($strangerTokenCreationVals);
+
+    self::assertCount(1, $this->hookEvents['post']);
+  }
+
+}
index 1039478f6344c932d9258b6d134eac2240cd8a31..f4d968720670fd6083de13f7f2c808d45281fc6a 100644 (file)
@@ -4,6 +4,6 @@
     <path>civicrm/oauth-client/return</path>
     <page_callback>CRM_OAuth_Page_Return</page_callback>
     <title>Return</title>
-    <access_arguments>access CiviCRM</access_arguments>
+    <access_arguments>create OAuth tokens via auth code flow;manage OAuth client</access_arguments>
   </item>
 </menu>
index 9b3649a6f9e3e228f75a4227551d7db6e7320658..695c874c33ecc38fe2a29b7ca4285ee4abdd38ff 100644 (file)
@@ -55,9 +55,9 @@
     <comment>Client Secret</comment>
     <add>5.32</add>
     <!-- Would prefer this be write-only for std admin, and read-write with special/elevated perm -->
-    <!--<permission>-->
-      <!--<or>manage OAuth client secrets</or>-->
-    <!--</permission>-->
+    <permission>
+      manage OAuth client
+    </permission>
   </field>
 
   <field>
@@ -67,6 +67,9 @@
     <!-- Ex: urlAuthorize, urlAccessToken, urlResourceOwnerDetails, scopes -->
     <serialize>JSON</serialize>
     <add>5.32</add>
+    <permission>
+      manage OAuth client
+    </permission>
   </field>
 
   <!-- Lifecycle -->
diff --git a/ext/oauth-client/xml/schema/CRM/OAuth/OAuthContactToken.entityType.php b/ext/oauth-client/xml/schema/CRM/OAuth/OAuthContactToken.entityType.php
new file mode 100644 (file)
index 0000000..f0791fd
--- /dev/null
@@ -0,0 +1,10 @@
+<?php
+// This file declares a new entity type. For more details, see "hook_civicrm_entityTypes" at:
+// https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_entityTypes
+return [
+  [
+    'name' => 'OAuthContactToken',
+    'class' => 'CRM_OAuth_DAO_OAuthContactToken',
+    'table' => 'civicrm_oauth_contact_token',
+  ],
+];
diff --git a/ext/oauth-client/xml/schema/CRM/OAuth/OAuthContactToken.xml b/ext/oauth-client/xml/schema/CRM/OAuth/OAuthContactToken.xml
new file mode 100644 (file)
index 0000000..d03931b
--- /dev/null
@@ -0,0 +1,175 @@
+<table>
+  <base>CRM/OAuth</base>
+  <class>OAuthContactToken</class>
+  <name>civicrm_oauth_contact_token</name>
+  <add>5.35</add>
+  <field>
+    <name>id</name>
+    <title>Token ID</title>
+    <type>int unsigned</type>
+    <required>true</required>
+    <comment>Token ID</comment>
+    <add>5.35</add>
+  </field>
+  <primaryKey>
+    <name>id</name>
+    <autoincrement>true</autoincrement>
+  </primaryKey>
+
+  <!-- Details based on how the token was requested -->
+
+  <field>
+    <name>tag</name>
+    <title>Tag</title>
+    <type>varchar</type>
+    <length>128</length>
+    <comment>The tag specifies how this token will be used.</comment>
+    <add>5.35</add>
+  </field>
+  <index>
+    <name>UI_tag</name>
+    <fieldName>tag</fieldName>
+    <add>5.35</add>
+  </index>
+
+  <field>
+    <name>client_id</name>
+    <title>Client ID</title>
+    <type>int unsigned</type>
+    <comment>Client ID</comment>
+    <add>5.35</add>
+  </field>
+  <foreignKey>
+    <name>client_id</name>
+    <table>civicrm_oauth_client</table>
+    <key>id</key>
+    <add>5.35</add>
+    <onDelete>CASCADE</onDelete>
+  </foreignKey>
+
+  <field>
+    <name>contact_id</name>
+    <title>Contact ID</title>
+    <type>int unsigned</type>
+    <comment>Contact ID</comment>
+    <add>5.35</add>
+  </field>
+  <foreignKey>
+    <name>contact_id</name>
+    <table>civicrm_contact</table>
+    <key>id</key>
+    <add>5.35</add>
+    <onDelete>CASCADE</onDelete>
+  </foreignKey>
+
+  <field>
+    <name>grant_type</name>
+    <title>Grant type</title>
+    <type>varchar</type>
+    <length>31</length>
+    <!-- FIXME: Pseudoconstant -->
+    <comment>Ex: authorization_code</comment>
+    <add>5.35</add>
+  </field>
+
+  <field>
+    <name>scopes</name>
+    <type>text</type>
+    <comment>List of scopes addressed by this token</comment>
+    <serialize>SEPARATOR_BOOKEND</serialize>
+    <add>5.35</add>
+  </field>
+
+  <!-- Data provided by the authentication server -->
+
+  <field>
+    <name>token_type</name>
+    <title>Token Type</title>
+    <type>varchar</type>
+    <length>128</length>
+    <comment>Ex: Bearer or MAC</comment>
+    <add>5.35</add>
+  </field>
+
+  <field>
+    <name>access_token</name>
+    <title>Access Token</title>
+    <type>text</type>
+    <!-- text or varchar? In theory, if the auth svc uses JWT, tokens can get long -->
+    <comment>Token to present when accessing resources</comment>
+    <add>5.35</add>
+  </field>
+
+  <field>
+    <name>expires</name>
+    <type>int unsigned</type>
+    <title>Expiration time</title>
+    <default>0</default>
+    <comment>Expiration time for the access_token (seconds since epoch)</comment>
+    <add>5.35</add>
+  </field>
+
+  <field>
+    <name>refresh_token</name>
+    <title>Refresh Token</title>
+    <type>text</type>
+    <!-- text or varchar? In theory, if the auth svc uses JWT, tokens can get long -->
+    <comment>Token to present when refreshing the access_token</comment>
+    <add>5.35</add>
+  </field>
+
+  <field>
+    <name>resource_owner_name</name>
+    <title>Resource Owner Name</title>
+    <type>varchar</type>
+    <length>128</length>
+    <comment>Identifier for the resource owner. Structure varies by service.</comment>
+    <add>5.35</add>
+  </field>
+
+  <field>
+    <name>resource_owner</name>
+    <title>Resource Owner</title>
+    <type>text</type>
+    <comment>Cached details describing the resource owner</comment>
+    <serialize>JSON</serialize>
+    <add>5.35</add>
+  </field>
+
+  <field>
+    <name>error</name>
+    <type>text</type>
+    <comment>?? copied from OAuthSysToken</comment>
+    <serialize>JSON</serialize>
+    <add>5.35</add>
+  </field>
+
+  <field>
+    <name>raw</name>
+    <title>Raw token</title>
+    <type>text</type>
+    <serialize>JSON</serialize>
+    <comment>The token response data, per AccessToken::jsonSerialize</comment>
+    <add>5.35</add>
+  </field>
+
+  <!-- Lifecycle -->
+
+  <field>
+    <name>created_date</name>
+    <type>timestamp</type>
+    <comment>When the token was created.</comment>
+    <required>false</required>
+    <default>CURRENT_TIMESTAMP</default>
+    <add>5.35</add>
+  </field>
+  <field>
+    <name>modified_date</name>
+    <type>timestamp</type>
+    <comment>When the token was created or modified.</comment>
+    <required>false</required>
+    <default>CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP</default>
+    <add>5.35</add>
+  </field>
+
+</table>
index df903d93cd72f5be886c132d7b24071558878fb8..43928e72ef9e74eab24b6303c45f12f89c4f496f 100644 (file)
 {
   "name": "civicrm",
   "version": "4.6.0",
-  "lockfileVersion": 1,
+  "lockfileVersion": 2,
   "requires": true,
+  "packages": {
+    "": {
+      "version": "4.6.0",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "devDependencies": {
+        "bower": "^1.8.8",
+        "civicrm-cv": "^0.1.2",
+        "jasmine-core": "~3.3.0",
+        "karma": "^5.0.9",
+        "karma-jasmine": "~2.0.1",
+        "karma-ng-html2js-preprocessor": "^1.0.0",
+        "karma-phantomjs-launcher": "^1.0.4"
+      }
+    },
+    "node_modules/@types/color-name": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz",
+      "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==",
+      "dev": true
+    },
+    "node_modules/accepts": {
+      "version": "1.3.7",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz",
+      "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==",
+      "dev": true,
+      "dependencies": {
+        "mime-types": "~2.1.24",
+        "negotiator": "0.6.2"
+      }
+    },
+    "node_modules/accepts/node_modules/mime-db": {
+      "version": "1.44.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz",
+      "integrity": "sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg==",
+      "dev": true
+    },
+    "node_modules/accepts/node_modules/mime-types": {
+      "version": "2.1.27",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz",
+      "integrity": "sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==",
+      "dev": true,
+      "dependencies": {
+        "mime-db": "1.44.0"
+      }
+    },
+    "node_modules/after": {
+      "version": "0.8.2",
+      "resolved": "https://registry.npmjs.org/after/-/after-0.8.2.tgz",
+      "integrity": "sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8=",
+      "dev": true
+    },
+    "node_modules/ajv": {
+      "version": "6.6.1",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.6.1.tgz",
+      "integrity": "sha512-ZoJjft5B+EJBjUyu9C9Hc0OZyPZSSlOF+plzouTrg6UlA8f+e/n8NIgBFG/9tppJtpPWfthHakK7juJdNDODww==",
+      "dev": true,
+      "dependencies": {
+        "fast-deep-equal": "^2.0.1",
+        "fast-json-stable-stringify": "^2.0.0",
+        "json-schema-traverse": "^0.4.1",
+        "uri-js": "^4.2.2"
+      }
+    },
+    "node_modules/ansi-regex": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
+      "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
+      "dev": true
+    },
+    "node_modules/ansi-styles": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz",
+      "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==",
+      "dev": true,
+      "dependencies": {
+        "@types/color-name": "^1.1.1",
+        "color-convert": "^2.0.1"
+      }
+    },
+    "node_modules/anymatch": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz",
+      "integrity": "sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg==",
+      "dev": true,
+      "dependencies": {
+        "normalize-path": "^3.0.0",
+        "picomatch": "^2.0.4"
+      }
+    },
+    "node_modules/arraybuffer.slice": {
+      "version": "0.0.7",
+      "resolved": "https://registry.npmjs.org/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz",
+      "integrity": "sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog==",
+      "dev": true
+    },
+    "node_modules/asn1": {
+      "version": "0.2.4",
+      "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz",
+      "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==",
+      "dev": true,
+      "dependencies": {
+        "safer-buffer": "~2.1.0"
+      }
+    },
+    "node_modules/assert-plus": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
+      "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=",
+      "dev": true
+    },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=",
+      "dev": true
+    },
+    "node_modules/aws-sign2": {
+      "version": "0.7.0",
+      "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz",
+      "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=",
+      "dev": true
+    },
+    "node_modules/aws4": {
+      "version": "1.8.0",
+      "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.8.0.tgz",
+      "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==",
+      "dev": true
+    },
+    "node_modules/backo2": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz",
+      "integrity": "sha1-MasayLEpNjRj41s+u2n038+6eUc=",
+      "dev": true
+    },
+    "node_modules/balanced-match": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
+      "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
+      "dev": true
+    },
+    "node_modules/base64-arraybuffer": {
+      "version": "0.1.5",
+      "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz",
+      "integrity": "sha1-c5JncZI7Whl0etZmqlzUv5xunOg=",
+      "dev": true
+    },
+    "node_modules/base64id": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
+      "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
+      "dev": true
+    },
+    "node_modules/bcrypt-pbkdf": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
+      "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=",
+      "dev": true,
+      "dependencies": {
+        "tweetnacl": "^0.14.3"
+      }
+    },
+    "node_modules/binary-extensions": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.0.0.tgz",
+      "integrity": "sha512-Phlt0plgpIIBOGTT/ehfFnbNlfsDEiqmzE2KRXoX1bLIlir4X/MR+zSyBEkL05ffWgnRSf/DXv+WrUAVr93/ow==",
+      "dev": true
+    },
+    "node_modules/blob": {
+      "version": "0.0.5",
+      "resolved": "https://registry.npmjs.org/blob/-/blob-0.0.5.tgz",
+      "integrity": "sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==",
+      "dev": true
+    },
+    "node_modules/body-parser": {
+      "version": "1.19.0",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz",
+      "integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==",
+      "dev": true,
+      "dependencies": {
+        "bytes": "3.1.0",
+        "content-type": "~1.0.4",
+        "debug": "2.6.9",
+        "depd": "~1.1.2",
+        "http-errors": "1.7.2",
+        "iconv-lite": "0.4.24",
+        "on-finished": "~2.3.0",
+        "qs": "6.7.0",
+        "raw-body": "2.4.0",
+        "type-is": "~1.6.17"
+      }
+    },
+    "node_modules/body-parser/node_modules/qs": {
+      "version": "6.7.0",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz",
+      "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==",
+      "dev": true
+    },
+    "node_modules/bower": {
+      "version": "1.8.8",
+      "resolved": "https://registry.npmjs.org/bower/-/bower-1.8.8.tgz",
+      "integrity": "sha512-1SrJnXnkP9soITHptSO+ahx3QKp3cVzn8poI6ujqc5SeOkg5iqM1pK9H+DSc2OQ8SnO0jC/NG4Ur/UIwy7574A==",
+      "dev": true
+    },
+    "node_modules/brace-expansion": {
+      "version": "1.1.11",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+      "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+      "dev": true,
+      "dependencies": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
+      }
+    },
+    "node_modules/braces": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
+      "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
+      "dev": true,
+      "dependencies": {
+        "fill-range": "^7.0.1"
+      }
+    },
+    "node_modules/bytes": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz",
+      "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==",
+      "dev": true
+    },
+    "node_modules/camelcase": {
+      "version": "5.3.1",
+      "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+      "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+      "dev": true
+    },
+    "node_modules/caseless": {
+      "version": "0.12.0",
+      "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
+      "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=",
+      "dev": true
+    },
+    "node_modules/child-process-promise": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/child-process-promise/-/child-process-promise-2.2.1.tgz",
+      "integrity": "sha1-RzChHvYQ+tRQuPIjx50x172tgHQ=",
+      "dev": true,
+      "dependencies": {
+        "cross-spawn": "^4.0.2",
+        "node-version": "^1.0.0",
+        "promise-polyfill": "^6.0.1"
+      }
+    },
+    "node_modules/chokidar": {
+      "version": "3.4.0",
+      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.4.0.tgz",
+      "integrity": "sha512-aXAaho2VJtisB/1fg1+3nlLJqGOuewTzQpd/Tz0yTg2R0e4IGtshYvtjowyEumcBv2z+y4+kc75Mz7j5xJskcQ==",
+      "dev": true,
+      "dependencies": {
+        "anymatch": "~3.1.1",
+        "braces": "~3.0.2",
+        "fsevents": "~2.1.2",
+        "glob-parent": "~5.1.0",
+        "is-binary-path": "~2.1.0",
+        "is-glob": "~4.0.1",
+        "normalize-path": "~3.0.0",
+        "readdirp": "~3.4.0"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.1.2"
+      }
+    },
+    "node_modules/civicrm-cv": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/civicrm-cv/-/civicrm-cv-0.1.2.tgz",
+      "integrity": "sha1-prn+pVahci1Km3ChHGSHVXGmNKg=",
+      "dev": true,
+      "dependencies": {
+        "child-process-promise": "^2.1.3"
+      }
+    },
+    "node_modules/cliui": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+      "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+      "dev": true,
+      "dependencies": {
+        "string-width": "^4.2.0",
+        "strip-ansi": "^6.0.0",
+        "wrap-ansi": "^6.2.0"
+      }
+    },
+    "node_modules/color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "dev": true,
+      "dependencies": {
+        "color-name": "~1.1.4"
+      }
+    },
+    "node_modules/color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+      "dev": true
+    },
+    "node_modules/colors": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz",
+      "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==",
+      "dev": true
+    },
+    "node_modules/combined-stream": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.7.tgz",
+      "integrity": "sha512-brWl9y6vOB1xYPZcpZde3N9zDByXTosAeMDo4p1wzo6UMOX4vumB+TP1RZ76sfE6Md68Q0NJSrE/gbezd4Ul+w==",
+      "dev": true,
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      }
+    },
+    "node_modules/component-bind": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/component-bind/-/component-bind-1.0.0.tgz",
+      "integrity": "sha1-AMYIq33Nk4l8AAllGx06jh5zu9E=",
+      "dev": true
+    },
+    "node_modules/component-emitter": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz",
+      "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=",
+      "dev": true
+    },
+    "node_modules/component-inherit": {
+      "version": "0.0.3",
+      "resolved": "https://registry.npmjs.org/component-inherit/-/component-inherit-0.0.3.tgz",
+      "integrity": "sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM=",
+      "dev": true
+    },
+    "node_modules/concat-map": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+      "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
+      "dev": true
+    },
+    "node_modules/concat-stream": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.0.tgz",
+      "integrity": "sha1-CqxmL9Ur54lk1VMvaUeE5wEQrPc=",
+      "dev": true,
+      "dependencies": {
+        "inherits": "^2.0.3",
+        "readable-stream": "^2.2.2",
+        "typedarray": "^0.0.6"
+      }
+    },
+    "node_modules/connect": {
+      "version": "3.7.0",
+      "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz",
+      "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==",
+      "dev": true,
+      "dependencies": {
+        "debug": "2.6.9",
+        "finalhandler": "1.1.2",
+        "parseurl": "~1.3.3",
+        "utils-merge": "1.0.1"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz",
+      "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==",
+      "dev": true
+    },
+    "node_modules/core-util-is": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
+      "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=",
+      "dev": true
+    },
+    "node_modules/cross-spawn": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-4.0.2.tgz",
+      "integrity": "sha1-e5JHYhwjrf3ThWAEqCPL45dCTUE=",
+      "dev": true,
+      "dependencies": {
+        "lru-cache": "^4.0.1",
+        "which": "^1.2.9"
+      }
+    },
+    "node_modules/custom-event": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz",
+      "integrity": "sha1-XQKkaFCt8bSjF5RqOSj8y1v9BCU=",
+      "dev": true
+    },
+    "node_modules/dashdash": {
+      "version": "1.14.1",
+      "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
+      "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=",
+      "dev": true,
+      "dependencies": {
+        "assert-plus": "^1.0.0"
+      }
+    },
+    "node_modules/date-format": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/date-format/-/date-format-3.0.0.tgz",
+      "integrity": "sha512-eyTcpKOcamdhWJXj56DpQMo1ylSQpcGtGKXcU0Tb97+K56/CF5amAqqqNj0+KvA0iw2ynxtHWFsPDSClCxe48w==",
+      "dev": true
+    },
+    "node_modules/debug": {
+      "version": "2.6.9",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+      "dev": true,
+      "dependencies": {
+        "ms": "2.0.0"
+      }
+    },
+    "node_modules/decamelize": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+      "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=",
+      "dev": true
+    },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=",
+      "dev": true
+    },
+    "node_modules/depd": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
+      "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=",
+      "dev": true
+    },
+    "node_modules/di": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/di/-/di-0.0.1.tgz",
+      "integrity": "sha1-gGZJMmzqp8qjMG112YXqJ0i6kTw=",
+      "dev": true
+    },
+    "node_modules/dom-serialize": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/dom-serialize/-/dom-serialize-2.2.1.tgz",
+      "integrity": "sha1-ViromZ9Evl6jB29UGdzVnrQ6yVs=",
+      "dev": true,
+      "dependencies": {
+        "custom-event": "~1.0.0",
+        "ent": "~2.2.0",
+        "extend": "^3.0.0",
+        "void-elements": "^2.0.0"
+      }
+    },
+    "node_modules/ecc-jsbn": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz",
+      "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=",
+      "dev": true,
+      "dependencies": {
+        "jsbn": "~0.1.0",
+        "safer-buffer": "^2.1.0"
+      }
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=",
+      "dev": true
+    },
+    "node_modules/emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "dev": true
+    },
+    "node_modules/encodeurl": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
+      "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=",
+      "dev": true
+    },
+    "node_modules/engine.io-parser": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-2.2.0.tgz",
+      "integrity": "sha512-6I3qD9iUxotsC5HEMuuGsKA0cXerGz+4uGcXQEkfBidgKf0amsjrrtwcbwK/nzpZBxclXlV7gGl9dgWvu4LF6w==",
+      "dev": true,
+      "dependencies": {
+        "after": "0.8.2",
+        "arraybuffer.slice": "~0.0.7",
+        "base64-arraybuffer": "0.1.5",
+        "blob": "0.0.5",
+        "has-binary2": "~1.0.2"
+      }
+    },
+    "node_modules/ent": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.0.tgz",
+      "integrity": "sha1-6WQhkyWiHQX0RGai9obtbOX13R0=",
+      "dev": true
+    },
+    "node_modules/es6-promise": {
+      "version": "4.2.4",
+      "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.4.tgz",
+      "integrity": "sha512-/NdNZVJg+uZgtm9eS3O6lrOLYmQag2DjdEXuPaHlZ6RuVqgqaVZfgYCepEIKsLqwdQArOPtC3XzRLqGGfT8KQQ==",
+      "dev": true
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=",
+      "dev": true
+    },
+    "node_modules/eventemitter3": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.4.tgz",
+      "integrity": "sha512-rlaVLnVxtxvoyLsQQFBx53YmXHDxRIzzTLbdfxqi4yocpSjAxXwkU0cScM5JgSKMqEhrZpnvQ2D9gjylR0AimQ==",
+      "dev": true
+    },
+    "node_modules/extend": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+      "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+      "dev": true
+    },
+    "node_modules/extract-zip": {
+      "version": "1.6.6",
+      "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.6.6.tgz",
+      "integrity": "sha1-EpDt6NINCHK0Kf0/NRyhKOxe+Fw=",
+      "dev": true,
+      "dependencies": {
+        "concat-stream": "1.6.0",
+        "debug": "2.6.9",
+        "mkdirp": "0.5.0",
+        "yauzl": "2.4.1"
+      }
+    },
+    "node_modules/extract-zip/node_modules/debug": {
+      "version": "2.6.9",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+      "dev": true,
+      "dependencies": {
+        "ms": "2.0.0"
+      }
+    },
+    "node_modules/extract-zip/node_modules/minimist": {
+      "version": "0.0.8",
+      "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz",
+      "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=",
+      "dev": true
+    },
+    "node_modules/extract-zip/node_modules/mkdirp": {
+      "version": "0.5.0",
+      "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.0.tgz",
+      "integrity": "sha1-HXMHam35hs2TROFecfzAWkyavxI=",
+      "dev": true,
+      "dependencies": {
+        "minimist": "0.0.8"
+      }
+    },
+    "node_modules/extract-zip/node_modules/ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
+      "dev": true
+    },
+    "node_modules/extsprintf": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz",
+      "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=",
+      "dev": true
+    },
+    "node_modules/fast-deep-equal": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz",
+      "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=",
+      "dev": true
+    },
+    "node_modules/fast-json-stable-stringify": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz",
+      "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=",
+      "dev": true
+    },
+    "node_modules/fd-slicer": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.0.1.tgz",
+      "integrity": "sha1-i1vL2ewyfFBBv5qwI/1nUPEXfmU=",
+      "dev": true,
+      "dependencies": {
+        "pend": "~1.2.0"
+      }
+    },
+    "node_modules/fill-range": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
+      "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+      "dev": true,
+      "dependencies": {
+        "to-regex-range": "^5.0.1"
+      }
+    },
+    "node_modules/finalhandler": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz",
+      "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==",
+      "dev": true,
+      "dependencies": {
+        "debug": "2.6.9",
+        "encodeurl": "~1.0.2",
+        "escape-html": "~1.0.3",
+        "on-finished": "~2.3.0",
+        "parseurl": "~1.3.3",
+        "statuses": "~1.5.0",
+        "unpipe": "~1.0.0"
+      }
+    },
+    "node_modules/find-up": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+      "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+      "dev": true,
+      "dependencies": {
+        "locate-path": "^5.0.0",
+        "path-exists": "^4.0.0"
+      }
+    },
+    "node_modules/flatted": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/flatted/-/flatted-2.0.2.tgz",
+      "integrity": "sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA==",
+      "dev": true
+    },
+    "node_modules/follow-redirects": {
+      "version": "1.11.0",
+      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.11.0.tgz",
+      "integrity": "sha512-KZm0V+ll8PfBrKwMzdo5D13b1bur9Iq9Zd/RMmAoQQcl2PxxFml8cxXPaaPYVbV0RjNjq1CU7zIzAOqtUPudmA==",
+      "dev": true,
+      "dependencies": {
+        "debug": "^3.0.0"
+      }
+    },
+    "node_modules/follow-redirects/node_modules/debug": {
+      "version": "3.2.6",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz",
+      "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==",
+      "dev": true,
+      "dependencies": {
+        "ms": "^2.1.1"
+      }
+    },
+    "node_modules/follow-redirects/node_modules/ms": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+      "dev": true
+    },
+    "node_modules/forever-agent": {
+      "version": "0.6.1",
+      "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
+      "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=",
+      "dev": true
+    },
+    "node_modules/form-data": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz",
+      "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==",
+      "dev": true,
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.6",
+        "mime-types": "^2.1.12"
+      }
+    },
+    "node_modules/fs-extra": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-1.0.0.tgz",
+      "integrity": "sha1-zTzl9+fLYUWIP8rjGR6Yd/hYeVA=",
+      "dev": true,
+      "dependencies": {
+        "graceful-fs": "^4.1.2",
+        "jsonfile": "^2.1.0",
+        "klaw": "^1.0.0"
+      }
+    },
+    "node_modules/fs-extra/node_modules/graceful-fs": {
+      "version": "4.1.11",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz",
+      "integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg=",
+      "dev": true
+    },
+    "node_modules/fs.realpath": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+      "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
+      "dev": true
+    },
+    "node_modules/fsevents": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.3.tgz",
+      "integrity": "sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==",
+      "dev": true,
+      "optional": true
+    },
+    "node_modules/get-caller-file": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+      "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+      "dev": true
+    },
+    "node_modules/getpass": {
+      "version": "0.1.7",
+      "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz",
+      "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=",
+      "dev": true,
+      "dependencies": {
+        "assert-plus": "^1.0.0"
+      }
+    },
+    "node_modules/glob": {
+      "version": "7.1.6",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
+      "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
+      "dev": true,
+      "dependencies": {
+        "fs.realpath": "^1.0.0",
+        "inflight": "^1.0.4",
+        "inherits": "2",
+        "minimatch": "^3.0.4",
+        "once": "^1.3.0",
+        "path-is-absolute": "^1.0.0"
+      }
+    },
+    "node_modules/glob-parent": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.1.tgz",
+      "integrity": "sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ==",
+      "dev": true,
+      "dependencies": {
+        "is-glob": "^4.0.1"
+      }
+    },
+    "node_modules/graceful-fs": {
+      "version": "4.2.4",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz",
+      "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==",
+      "dev": true
+    },
+    "node_modules/har-schema": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz",
+      "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=",
+      "dev": true
+    },
+    "node_modules/har-validator": {
+      "version": "5.1.3",
+      "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.3.tgz",
+      "integrity": "sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==",
+      "dev": true,
+      "dependencies": {
+        "ajv": "^6.5.5",
+        "har-schema": "^2.0.0"
+      }
+    },
+    "node_modules/has-binary2": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/has-binary2/-/has-binary2-1.0.3.tgz",
+      "integrity": "sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw==",
+      "dev": true,
+      "dependencies": {
+        "isarray": "2.0.1"
+      }
+    },
+    "node_modules/has-binary2/node_modules/isarray": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.1.tgz",
+      "integrity": "sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4=",
+      "dev": true
+    },
+    "node_modules/has-cors": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-cors/-/has-cors-1.1.0.tgz",
+      "integrity": "sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk=",
+      "dev": true
+    },
+    "node_modules/hasha": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/hasha/-/hasha-2.2.0.tgz",
+      "integrity": "sha1-eNfL/B5tZjA/55g3NlmEUXsvbuE=",
+      "dev": true,
+      "dependencies": {
+        "is-stream": "^1.0.1",
+        "pinkie-promise": "^2.0.0"
+      }
+    },
+    "node_modules/http-errors": {
+      "version": "1.7.2",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz",
+      "integrity": "sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==",
+      "dev": true,
+      "dependencies": {
+        "depd": "~1.1.2",
+        "inherits": "2.0.3",
+        "setprototypeof": "1.1.1",
+        "statuses": ">= 1.5.0 < 2",
+        "toidentifier": "1.0.0"
+      }
+    },
+    "node_modules/http-proxy": {
+      "version": "1.18.1",
+      "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
+      "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
+      "dev": true,
+      "dependencies": {
+        "eventemitter3": "^4.0.0",
+        "follow-redirects": "^1.0.0",
+        "requires-port": "^1.0.0"
+      }
+    },
+    "node_modules/http-signature": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz",
+      "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=",
+      "dev": true,
+      "dependencies": {
+        "assert-plus": "^1.0.0",
+        "jsprim": "^1.2.2",
+        "sshpk": "^1.7.0"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "dev": true,
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      }
+    },
+    "node_modules/indexof": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz",
+      "integrity": "sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=",
+      "dev": true
+    },
+    "node_modules/inflight": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+      "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
+      "dev": true,
+      "dependencies": {
+        "once": "^1.3.0",
+        "wrappy": "1"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
+      "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=",
+      "dev": true
+    },
+    "node_modules/is-binary-path": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+      "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+      "dev": true,
+      "dependencies": {
+        "binary-extensions": "^2.0.0"
+      }
+    },
+    "node_modules/is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=",
+      "dev": true
+    },
+    "node_modules/is-fullwidth-code-point": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+      "dev": true
+    },
+    "node_modules/is-glob": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz",
+      "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==",
+      "dev": true,
+      "dependencies": {
+        "is-extglob": "^2.1.1"
+      }
+    },
+    "node_modules/is-number": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+      "dev": true
+    },
+    "node_modules/is-stream": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz",
+      "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=",
+      "dev": true
+    },
+    "node_modules/is-typedarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
+      "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=",
+      "dev": true
+    },
+    "node_modules/isarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+      "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=",
+      "dev": true
+    },
+    "node_modules/isbinaryfile": {
+      "version": "4.0.6",
+      "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.6.tgz",
+      "integrity": "sha512-ORrEy+SNVqUhrCaal4hA4fBzhggQQ+BaLntyPOdoEiwlKZW9BZiJXjg3RMiruE4tPEI3pyVPpySHQF/dKWperg==",
+      "dev": true
+    },
+    "node_modules/isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=",
+      "dev": true
+    },
+    "node_modules/isstream": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
+      "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=",
+      "dev": true
+    },
+    "node_modules/jasmine-core": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-3.3.0.tgz",
+      "integrity": "sha512-3/xSmG/d35hf80BEN66Y6g9Ca5l/Isdeg/j6zvbTYlTzeKinzmaTM4p9am5kYqOmE05D7s1t8FGjzdSnbUbceA==",
+      "dev": true
+    },
+    "node_modules/jsbn": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz",
+      "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=",
+      "dev": true
+    },
+    "node_modules/json-schema": {
+      "version": "0.2.3",
+      "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz",
+      "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=",
+      "dev": true
+    },
+    "node_modules/json-schema-traverse": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+      "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+      "dev": true
+    },
+    "node_modules/json-stringify-safe": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
+      "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=",
+      "dev": true
+    },
+    "node_modules/jsonfile": {
+      "version": "2.4.0",
+      "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-2.4.0.tgz",
+      "integrity": "sha1-NzaitCi4e72gzIO1P6PWM6NcKug=",
+      "dev": true,
+      "dependencies": {
+        "graceful-fs": "^4.1.6"
+      },
+      "optionalDependencies": {
+        "graceful-fs": "^4.1.6"
+      }
+    },
+    "node_modules/jsonfile/node_modules/graceful-fs": {
+      "version": "4.1.11",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz",
+      "integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg=",
+      "dev": true,
+      "optional": true
+    },
+    "node_modules/jsprim": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz",
+      "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=",
+      "dev": true,
+      "dependencies": {
+        "assert-plus": "1.0.0",
+        "extsprintf": "1.3.0",
+        "json-schema": "0.2.3",
+        "verror": "1.10.0"
+      }
+    },
+    "node_modules/karma": {
+      "version": "5.0.9",
+      "resolved": "https://registry.npmjs.org/karma/-/karma-5.0.9.tgz",
+      "integrity": "sha512-dUA5z7Lo7G4FRSe1ZAXqOINEEWxmCjDBbfRBmU/wYlSMwxUQJP/tEEP90yJt3Uqo03s9rCgVnxtlfq+uDhxSPg==",
+      "dev": true,
+      "dependencies": {
+        "body-parser": "^1.19.0",
+        "braces": "^3.0.2",
+        "chokidar": "^3.0.0",
+        "colors": "^1.4.0",
+        "connect": "^3.7.0",
+        "di": "^0.0.1",
+        "dom-serialize": "^2.2.1",
+        "flatted": "^2.0.2",
+        "glob": "^7.1.6",
+        "graceful-fs": "^4.2.4",
+        "http-proxy": "^1.18.1",
+        "isbinaryfile": "^4.0.6",
+        "lodash": "^4.17.15",
+        "log4js": "^6.2.1",
+        "mime": "^2.4.5",
+        "minimatch": "^3.0.4",
+        "qjobs": "^1.2.0",
+        "range-parser": "^1.2.1",
+        "rimraf": "^3.0.2",
+        "socket.io": "^2.3.0",
+        "source-map": "^0.6.1",
+        "tmp": "0.2.1",
+        "ua-parser-js": "0.7.21",
+        "yargs": "^15.3.1"
+      }
+    },
+    "node_modules/karma-jasmine": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-2.0.1.tgz",
+      "integrity": "sha512-iuC0hmr9b+SNn1DaUD2QEYtUxkS1J+bSJSn7ejdEexs7P8EYvA1CWkEdrDQ+8jVH3AgWlCNwjYsT1chjcNW9lA==",
+      "dev": true,
+      "dependencies": {
+        "jasmine-core": "^3.3"
+      }
+    },
+    "node_modules/karma-ng-html2js-preprocessor": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/karma-ng-html2js-preprocessor/-/karma-ng-html2js-preprocessor-1.0.0.tgz",
+      "integrity": "sha1-ENjIz6pBNvHIp22RpMvO7evsSjE=",
+      "dev": true
+    },
+    "node_modules/karma-phantomjs-launcher": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/karma-phantomjs-launcher/-/karma-phantomjs-launcher-1.0.4.tgz",
+      "integrity": "sha1-0jyjSAG9qYY60xjju0vUBisTrNI=",
+      "dev": true,
+      "dependencies": {
+        "lodash": "^4.0.1",
+        "phantomjs-prebuilt": "^2.1.7"
+      }
+    },
+    "node_modules/kew": {
+      "version": "0.7.0",
+      "resolved": "https://registry.npmjs.org/kew/-/kew-0.7.0.tgz",
+      "integrity": "sha1-edk9LTM2PW/dKXCzNdkUGtWR15s=",
+      "dev": true
+    },
+    "node_modules/klaw": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/klaw/-/klaw-1.3.1.tgz",
+      "integrity": "sha1-QIhDO0azsbolnXh4XY6W9zugJDk=",
+      "dev": true,
+      "dependencies": {
+        "graceful-fs": "^4.1.9"
+      },
+      "optionalDependencies": {
+        "graceful-fs": "^4.1.9"
+      }
+    },
+    "node_modules/klaw/node_modules/graceful-fs": {
+      "version": "4.1.11",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz",
+      "integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg=",
+      "dev": true,
+      "optional": true
+    },
+    "node_modules/locate-path": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+      "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+      "dev": true,
+      "dependencies": {
+        "p-locate": "^4.1.0"
+      }
+    },
+    "node_modules/lodash": {
+      "version": "4.17.19",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz",
+      "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==",
+      "dev": true
+    },
+    "node_modules/log4js": {
+      "version": "6.3.0",
+      "resolved": "https://registry.npmjs.org/log4js/-/log4js-6.3.0.tgz",
+      "integrity": "sha512-Mc8jNuSFImQUIateBFwdOQcmC6Q5maU0VVvdC2R6XMb66/VnT+7WS4D/0EeNMZu1YODmJe5NIn2XftCzEocUgw==",
+      "dev": true,
+      "dependencies": {
+        "date-format": "^3.0.0",
+        "debug": "^4.1.1",
+        "flatted": "^2.0.1",
+        "rfdc": "^1.1.4",
+        "streamroller": "^2.2.4"
+      }
+    },
+    "node_modules/log4js/node_modules/debug": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
+      "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+      "dev": true,
+      "dependencies": {
+        "ms": "^2.1.1"
+      }
+    },
+    "node_modules/log4js/node_modules/ms": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+      "dev": true
+    },
+    "node_modules/lru-cache": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.2.tgz",
+      "integrity": "sha512-wgeVXhrDwAWnIF/yZARsFnMBtdFXOg1b8RIrhilp+0iDYN4mdQcNZElDZ0e4B64BhaxeQ5zN7PMyvu7we1kPeQ==",
+      "dev": true,
+      "dependencies": {
+        "pseudomap": "^1.0.2",
+        "yallist": "^2.1.2"
+      }
+    },
+    "node_modules/media-typer": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+      "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=",
+      "dev": true
+    },
+    "node_modules/mime": {
+      "version": "2.4.6",
+      "resolved": "https://registry.npmjs.org/mime/-/mime-2.4.6.tgz",
+      "integrity": "sha512-RZKhC3EmpBchfTGBVb8fb+RL2cWyw/32lshnsETttkBAyAUXSGHxbEJWWRXc751DrIxG1q04b8QwMbAwkRPpUA==",
+      "dev": true
+    },
+    "node_modules/mime-db": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz",
+      "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==",
+      "dev": true
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.18",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz",
+      "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==",
+      "dev": true,
+      "dependencies": {
+        "mime-db": "~1.33.0"
+      }
+    },
+    "node_modules/minimatch": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
+      "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
+      "dev": true,
+      "dependencies": {
+        "brace-expansion": "^1.1.7"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
+      "dev": true
+    },
+    "node_modules/negotiator": {
+      "version": "0.6.2",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz",
+      "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==",
+      "dev": true
+    },
+    "node_modules/node-version": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/node-version/-/node-version-1.1.3.tgz",
+      "integrity": "sha512-rEwE51JWn0yN3Wl5BXeGn5d52OGbSXzWiiXRjAQeuyvcGKyvuSILW2rb3G7Xh+nexzLwhTpek6Ehxd6IjvHePg==",
+      "dev": true
+    },
+    "node_modules/normalize-path": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+      "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+      "dev": true
+    },
+    "node_modules/oauth-sign": {
+      "version": "0.9.0",
+      "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz",
+      "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==",
+      "dev": true
+    },
+    "node_modules/on-finished": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
+      "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=",
+      "dev": true,
+      "dependencies": {
+        "ee-first": "1.1.1"
+      }
+    },
+    "node_modules/once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
+      "dev": true,
+      "dependencies": {
+        "wrappy": "1"
+      }
+    },
+    "node_modules/p-limit": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+      "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+      "dev": true,
+      "dependencies": {
+        "p-try": "^2.0.0"
+      }
+    },
+    "node_modules/p-locate": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+      "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+      "dev": true,
+      "dependencies": {
+        "p-limit": "^2.2.0"
+      }
+    },
+    "node_modules/p-try": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+      "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+      "dev": true
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "dev": true
+    },
+    "node_modules/path-exists": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+      "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+      "dev": true
+    },
+    "node_modules/path-is-absolute": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+      "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
+      "dev": true
+    },
+    "node_modules/pend": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
+      "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=",
+      "dev": true
+    },
+    "node_modules/performance-now": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
+      "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=",
+      "dev": true
+    },
+    "node_modules/phantomjs-prebuilt": {
+      "version": "2.1.16",
+      "resolved": "https://registry.npmjs.org/phantomjs-prebuilt/-/phantomjs-prebuilt-2.1.16.tgz",
+      "integrity": "sha1-79ISpKOWbTZHaE6ouniFSb4q7+8=",
+      "dev": true,
+      "dependencies": {
+        "es6-promise": "^4.0.3",
+        "extract-zip": "^1.6.5",
+        "fs-extra": "^1.0.0",
+        "hasha": "^2.2.0",
+        "kew": "^0.7.0",
+        "progress": "^1.1.8",
+        "request": "^2.81.0",
+        "request-progress": "^2.0.1",
+        "which": "^1.2.10"
+      }
+    },
+    "node_modules/picomatch": {
+      "version": "2.2.2",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz",
+      "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==",
+      "dev": true
+    },
+    "node_modules/pinkie": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz",
+      "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=",
+      "dev": true
+    },
+    "node_modules/pinkie-promise": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz",
+      "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=",
+      "dev": true,
+      "dependencies": {
+        "pinkie": "^2.0.0"
+      }
+    },
+    "node_modules/process-nextick-args": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz",
+      "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==",
+      "dev": true
+    },
+    "node_modules/progress": {
+      "version": "1.1.8",
+      "resolved": "https://registry.npmjs.org/progress/-/progress-1.1.8.tgz",
+      "integrity": "sha1-4mDHj2Fhzdmw5WzD4Khd4Xx6V74=",
+      "dev": true
+    },
+    "node_modules/promise-polyfill": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-6.1.0.tgz",
+      "integrity": "sha1-36lpQ+qcEh/KTem1hoyznTRy4Fc=",
+      "dev": true
+    },
+    "node_modules/pseudomap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",
+      "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=",
+      "dev": true
+    },
+    "node_modules/psl": {
+      "version": "1.1.29",
+      "resolved": "https://registry.npmjs.org/psl/-/psl-1.1.29.tgz",
+      "integrity": "sha512-AeUmQ0oLN02flVHXWh9sSJF7mcdFq0ppid/JkErufc3hGIV/AMa8Fo9VgDo/cT2jFdOWoFvHp90qqBH54W+gjQ==",
+      "dev": true
+    },
+    "node_modules/punycode": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
+      "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
+      "dev": true
+    },
+    "node_modules/qjobs": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/qjobs/-/qjobs-1.2.0.tgz",
+      "integrity": "sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==",
+      "dev": true
+    },
+    "node_modules/qs": {
+      "version": "6.5.2",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz",
+      "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==",
+      "dev": true
+    },
+    "node_modules/range-parser": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+      "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+      "dev": true
+    },
+    "node_modules/raw-body": {
+      "version": "2.4.0",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz",
+      "integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==",
+      "dev": true,
+      "dependencies": {
+        "bytes": "3.1.0",
+        "http-errors": "1.7.2",
+        "iconv-lite": "0.4.24",
+        "unpipe": "1.0.0"
+      }
+    },
+    "node_modules/readable-stream": {
+      "version": "2.3.6",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz",
+      "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==",
+      "dev": true,
+      "dependencies": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
+      }
+    },
+    "node_modules/readdirp": {
+      "version": "3.4.0",
+      "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.4.0.tgz",
+      "integrity": "sha512-0xe001vZBnJEK+uKcj8qOhyAKPzIT+gStxWr3LCB0DwcXR5NZJ3IaC+yGnHCYzB/S7ov3m3EEbZI2zeNvX+hGQ==",
+      "dev": true,
+      "dependencies": {
+        "picomatch": "^2.2.1"
+      }
+    },
+    "node_modules/request": {
+      "version": "2.88.0",
+      "resolved": "https://registry.npmjs.org/request/-/request-2.88.0.tgz",
+      "integrity": "sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==",
+      "dev": true,
+      "dependencies": {
+        "aws-sign2": "~0.7.0",
+        "aws4": "^1.8.0",
+        "caseless": "~0.12.0",
+        "combined-stream": "~1.0.6",
+        "extend": "~3.0.2",
+        "forever-agent": "~0.6.1",
+        "form-data": "~2.3.2",
+        "har-validator": "~5.1.0",
+        "http-signature": "~1.2.0",
+        "is-typedarray": "~1.0.0",
+        "isstream": "~0.1.2",
+        "json-stringify-safe": "~5.0.1",
+        "mime-types": "~2.1.19",
+        "oauth-sign": "~0.9.0",
+        "performance-now": "^2.1.0",
+        "qs": "~6.5.2",
+        "safe-buffer": "^5.1.2",
+        "tough-cookie": "~2.4.3",
+        "tunnel-agent": "^0.6.0",
+        "uuid": "^3.3.2"
+      }
+    },
+    "node_modules/request-progress": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/request-progress/-/request-progress-2.0.1.tgz",
+      "integrity": "sha1-XTa7V5YcZzqlt4jbyBQf3yO0Tgg=",
+      "dev": true,
+      "dependencies": {
+        "throttleit": "^1.0.0"
+      }
+    },
+    "node_modules/request/node_modules/extend": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+      "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+      "dev": true
+    },
+    "node_modules/request/node_modules/mime-db": {
+      "version": "1.37.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.37.0.tgz",
+      "integrity": "sha512-R3C4db6bgQhlIhPU48fUtdVmKnflq+hRdad7IyKhtFj06VPNVdk2RhiYL3UjQIlso8L+YxAtFkobT0VK+S/ybg==",
+      "dev": true
+    },
+    "node_modules/request/node_modules/mime-types": {
+      "version": "2.1.21",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.21.tgz",
+      "integrity": "sha512-3iL6DbwpyLzjR3xHSFNFeb9Nz/M8WDkX33t1GFQnFOllWk8pOrh/LSrB5OXlnlW5P9LH73X6loW/eogc+F5lJg==",
+      "dev": true,
+      "dependencies": {
+        "mime-db": "~1.37.0"
+      }
+    },
+    "node_modules/require-directory": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+      "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=",
+      "dev": true
+    },
+    "node_modules/require-main-filename": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+      "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+      "dev": true
+    },
+    "node_modules/requires-port": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
+      "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=",
+      "dev": true
+    },
+    "node_modules/rfdc": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.1.4.tgz",
+      "integrity": "sha512-5C9HXdzK8EAqN7JDif30jqsBzavB7wLpaubisuQIGHWf2gUXSpzy6ArX/+Da8RjFpagWsCn+pIgxTMAmKw9Zug==",
+      "dev": true
+    },
+    "node_modules/rimraf": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+      "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+      "dev": true,
+      "dependencies": {
+        "glob": "^7.1.3"
+      }
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+      "dev": true
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "dev": true
+    },
+    "node_modules/set-blocking": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+      "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=",
+      "dev": true
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz",
+      "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==",
+      "dev": true
+    },
+    "node_modules/socket.io": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-2.4.1.tgz",
+      "integrity": "sha512-Si18v0mMXGAqLqCVpTxBa8MGqriHGQh8ccEOhmsmNS3thNCGBwO8WGrwMibANsWtQQ5NStdZwHqZR3naJVFc3w==",
+      "dev": true,
+      "dependencies": {
+        "debug": "~4.1.0",
+        "engine.io": "~3.5.0",
+        "has-binary2": "~1.0.2",
+        "socket.io-adapter": "~1.1.0",
+        "socket.io-client": "2.4.0",
+        "socket.io-parser": "~3.4.0"
+      }
+    },
+    "node_modules/socket.io-adapter": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-1.1.2.tgz",
+      "integrity": "sha512-WzZRUj1kUjrTIrUKpZLEzFZ1OLj5FwLlAFQs9kuZJzJi5DKdU7FsWc36SNmA8iDOtwBQyT8FkrriRM8vXLYz8g==",
+      "dev": true
+    },
+    "node_modules/socket.io-parser": {
+      "version": "3.4.1",
+      "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-3.4.1.tgz",
+      "integrity": "sha512-11hMgzL+WCLWf1uFtHSNvliI++tcRUWdoeYuwIl+Axvwy9z2gQM+7nJyN3STj1tLj5JyIUH8/gpDGxzAlDdi0A==",
+      "dev": true,
+      "dependencies": {
+        "component-emitter": "1.2.1",
+        "debug": "~4.1.0",
+        "isarray": "2.0.1"
+      }
+    },
+    "node_modules/socket.io-parser/node_modules/debug": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
+      "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+      "dev": true,
+      "dependencies": {
+        "ms": "^2.1.1"
+      }
+    },
+    "node_modules/socket.io-parser/node_modules/isarray": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.1.tgz",
+      "integrity": "sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4=",
+      "dev": true
+    },
+    "node_modules/socket.io-parser/node_modules/ms": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+      "dev": true
+    },
+    "node_modules/socket.io/node_modules/component-emitter": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz",
+      "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==",
+      "dev": true
+    },
+    "node_modules/socket.io/node_modules/cookie": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz",
+      "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==",
+      "dev": true
+    },
+    "node_modules/socket.io/node_modules/debug": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
+      "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+      "dev": true,
+      "dependencies": {
+        "ms": "^2.1.1"
+      }
+    },
+    "node_modules/socket.io/node_modules/engine.io": {
+      "version": "3.5.0",
+      "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-3.5.0.tgz",
+      "integrity": "sha512-21HlvPUKaitDGE4GXNtQ7PLP0Sz4aWLddMPw2VTyFz1FVZqu/kZsJUO8WNpKuE/OCL7nkfRaOui2ZCJloGznGA==",
+      "dev": true,
+      "dependencies": {
+        "accepts": "~1.3.4",
+        "base64id": "2.0.0",
+        "cookie": "~0.4.1",
+        "debug": "~4.1.0",
+        "engine.io-parser": "~2.2.0",
+        "ws": "~7.4.2"
+      }
+    },
+    "node_modules/socket.io/node_modules/engine.io-client": {
+      "version": "3.5.0",
+      "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-3.5.0.tgz",
+      "integrity": "sha512-12wPRfMrugVw/DNyJk34GQ5vIVArEcVMXWugQGGuw2XxUSztFNmJggZmv8IZlLyEdnpO1QB9LkcjeWewO2vxtA==",
+      "dev": true,
+      "dependencies": {
+        "component-emitter": "~1.3.0",
+        "component-inherit": "0.0.3",
+        "debug": "~3.1.0",
+        "engine.io-parser": "~2.2.0",
+        "has-cors": "1.1.0",
+        "indexof": "0.0.1",
+        "parseqs": "0.0.6",
+        "parseuri": "0.0.6",
+        "ws": "~7.4.2",
+        "xmlhttprequest-ssl": "~1.5.4",
+        "yeast": "0.1.2"
+      }
+    },
+    "node_modules/socket.io/node_modules/engine.io-client/node_modules/debug": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
+      "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
+      "dev": true,
+      "dependencies": {
+        "ms": "2.0.0"
+      }
+    },
+    "node_modules/socket.io/node_modules/engine.io-client/node_modules/ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
+      "dev": true
+    },
+    "node_modules/socket.io/node_modules/isarray": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.1.tgz",
+      "integrity": "sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4=",
+      "dev": true
+    },
+    "node_modules/socket.io/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true
+    },
+    "node_modules/socket.io/node_modules/parseqs": {
+      "version": "0.0.6",
+      "resolved": "https://registry.npmjs.org/parseqs/-/parseqs-0.0.6.tgz",
+      "integrity": "sha512-jeAGzMDbfSHHA091hr0r31eYfTig+29g3GKKE/PPbEQ65X0lmMwlEoqmhzu0iztID5uJpZsFlUPDP8ThPL7M8w==",
+      "dev": true
+    },
+    "node_modules/socket.io/node_modules/parseuri": {
+      "version": "0.0.6",
+      "resolved": "https://registry.npmjs.org/parseuri/-/parseuri-0.0.6.tgz",
+      "integrity": "sha512-AUjen8sAkGgao7UyCX6Ahv0gIK2fABKmYjvP4xmy5JaKvcbTRueIqIPHLAfq30xJddqSE033IOMUSOMCcK3Sow==",
+      "dev": true
+    },
+    "node_modules/socket.io/node_modules/socket.io-client": {
+      "version": "2.4.0",
+      "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-2.4.0.tgz",
+      "integrity": "sha512-M6xhnKQHuuZd4Ba9vltCLT9oa+YvTsP8j9NcEiLElfIg8KeYPyhWOes6x4t+LTAC8enQbE/995AdTem2uNyKKQ==",
+      "dev": true,
+      "dependencies": {
+        "backo2": "1.0.2",
+        "component-bind": "1.0.0",
+        "component-emitter": "~1.3.0",
+        "debug": "~3.1.0",
+        "engine.io-client": "~3.5.0",
+        "has-binary2": "~1.0.2",
+        "indexof": "0.0.1",
+        "parseqs": "0.0.6",
+        "parseuri": "0.0.6",
+        "socket.io-parser": "~3.3.0",
+        "to-array": "0.1.4"
+      }
+    },
+    "node_modules/socket.io/node_modules/socket.io-client/node_modules/debug": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
+      "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
+      "dev": true,
+      "dependencies": {
+        "ms": "2.0.0"
+      }
+    },
+    "node_modules/socket.io/node_modules/socket.io-client/node_modules/ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
+      "dev": true
+    },
+    "node_modules/socket.io/node_modules/socket.io-client/node_modules/socket.io-parser": {
+      "version": "3.3.2",
+      "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-3.3.2.tgz",
+      "integrity": "sha512-FJvDBuOALxdCI9qwRrO/Rfp9yfndRtc1jSgVgV8FDraihmSP/MLGD5PEuJrNfjALvcQ+vMDM/33AWOYP/JSjDg==",
+      "dev": true,
+      "dependencies": {
+        "component-emitter": "~1.3.0",
+        "debug": "~3.1.0",
+        "isarray": "2.0.1"
+      }
+    },
+    "node_modules/socket.io/node_modules/ws": {
+      "version": "7.4.2",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.2.tgz",
+      "integrity": "sha512-T4tewALS3+qsrpGI/8dqNMLIVdq/g/85U98HPMa6F0m6xTbvhXU6RCQLqPH3+SlomNV/LdY6RXEbBpMH6EOJnA==",
+      "dev": true
+    },
+    "node_modules/source-map": {
+      "version": "0.6.1",
+      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+      "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+      "dev": true
+    },
+    "node_modules/sshpk": {
+      "version": "1.15.2",
+      "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.15.2.tgz",
+      "integrity": "sha512-Ra/OXQtuh0/enyl4ETZAfTaeksa6BXks5ZcjpSUNrjBr0DvrJKX+1fsKDPpT9TBXgHAFsa4510aNVgI8g/+SzA==",
+      "dev": true,
+      "dependencies": {
+        "asn1": "~0.2.3",
+        "assert-plus": "^1.0.0",
+        "bcrypt-pbkdf": "^1.0.0",
+        "dashdash": "^1.12.0",
+        "ecc-jsbn": "~0.1.1",
+        "getpass": "^0.1.1",
+        "jsbn": "~0.1.0",
+        "safer-buffer": "^2.0.2",
+        "tweetnacl": "~0.14.0"
+      }
+    },
+    "node_modules/statuses": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
+      "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=",
+      "dev": true
+    },
+    "node_modules/streamroller": {
+      "version": "2.2.4",
+      "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-2.2.4.tgz",
+      "integrity": "sha512-OG79qm3AujAM9ImoqgWEY1xG4HX+Lw+yY6qZj9R1K2mhF5bEmQ849wvrb+4vt4jLMLzwXttJlQbOdPOQVRv7DQ==",
+      "dev": true,
+      "dependencies": {
+        "date-format": "^2.1.0",
+        "debug": "^4.1.1",
+        "fs-extra": "^8.1.0"
+      }
+    },
+    "node_modules/streamroller/node_modules/date-format": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/date-format/-/date-format-2.1.0.tgz",
+      "integrity": "sha512-bYQuGLeFxhkxNOF3rcMtiZxvCBAquGzZm6oWA1oZ0g2THUzivaRhv8uOhdr19LmoobSOLoIAxeUK2RdbM8IFTA==",
+      "dev": true
+    },
+    "node_modules/streamroller/node_modules/debug": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
+      "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+      "dev": true,
+      "dependencies": {
+        "ms": "^2.1.1"
+      }
+    },
+    "node_modules/streamroller/node_modules/fs-extra": {
+      "version": "8.1.0",
+      "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
+      "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
+      "dev": true,
+      "dependencies": {
+        "graceful-fs": "^4.2.0",
+        "jsonfile": "^4.0.0",
+        "universalify": "^0.1.0"
+      }
+    },
+    "node_modules/streamroller/node_modules/jsonfile": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
+      "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=",
+      "dev": true,
+      "dependencies": {
+        "graceful-fs": "^4.1.6"
+      }
+    },
+    "node_modules/streamroller/node_modules/ms": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+      "dev": true
+    },
+    "node_modules/string_decoder": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+      "dev": true,
+      "dependencies": {
+        "safe-buffer": "~5.1.0"
+      }
+    },
+    "node_modules/string-width": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz",
+      "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==",
+      "dev": true,
+      "dependencies": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.0"
+      }
+    },
+    "node_modules/strip-ansi": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
+      "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==",
+      "dev": true,
+      "dependencies": {
+        "ansi-regex": "^5.0.0"
+      }
+    },
+    "node_modules/throttleit": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-1.0.0.tgz",
+      "integrity": "sha1-nnhYNtr0Z0MUWlmEtiaNgoUorGw=",
+      "dev": true
+    },
+    "node_modules/tmp": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz",
+      "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==",
+      "dev": true,
+      "dependencies": {
+        "rimraf": "^3.0.0"
+      }
+    },
+    "node_modules/to-array": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/to-array/-/to-array-0.1.4.tgz",
+      "integrity": "sha1-F+bBH3PdTz10zaek/zI46a2b+JA=",
+      "dev": true
+    },
+    "node_modules/to-regex-range": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+      "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+      "dev": true,
+      "dependencies": {
+        "is-number": "^7.0.0"
+      }
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz",
+      "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==",
+      "dev": true
+    },
+    "node_modules/tough-cookie": {
+      "version": "2.4.3",
+      "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.4.3.tgz",
+      "integrity": "sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ==",
+      "dev": true,
+      "dependencies": {
+        "psl": "^1.1.24",
+        "punycode": "^1.4.1"
+      }
+    },
+    "node_modules/tough-cookie/node_modules/punycode": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
+      "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=",
+      "dev": true
+    },
+    "node_modules/tunnel-agent": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+      "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=",
+      "dev": true,
+      "dependencies": {
+        "safe-buffer": "^5.0.1"
+      }
+    },
+    "node_modules/tweetnacl": {
+      "version": "0.14.5",
+      "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
+      "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=",
+      "dev": true
+    },
+    "node_modules/type-is": {
+      "version": "1.6.18",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+      "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+      "dev": true,
+      "dependencies": {
+        "media-typer": "0.3.0",
+        "mime-types": "~2.1.24"
+      }
+    },
+    "node_modules/type-is/node_modules/mime-db": {
+      "version": "1.44.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz",
+      "integrity": "sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg==",
+      "dev": true
+    },
+    "node_modules/type-is/node_modules/mime-types": {
+      "version": "2.1.27",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz",
+      "integrity": "sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==",
+      "dev": true,
+      "dependencies": {
+        "mime-db": "1.44.0"
+      }
+    },
+    "node_modules/typedarray": {
+      "version": "0.0.6",
+      "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+      "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=",
+      "dev": true
+    },
+    "node_modules/ua-parser-js": {
+      "version": "0.7.21",
+      "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.21.tgz",
+      "integrity": "sha512-+O8/qh/Qj8CgC6eYBVBykMrNtp5Gebn4dlGD/kKXVkJNDwyrAwSIqwz8CDf+tsAIWVycKcku6gIXJ0qwx/ZXaQ==",
+      "dev": true
+    },
+    "node_modules/universalify": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
+      "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
+      "dev": true
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=",
+      "dev": true
+    },
+    "node_modules/uri-js": {
+      "version": "4.2.2",
+      "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz",
+      "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==",
+      "dev": true,
+      "dependencies": {
+        "punycode": "^2.1.0"
+      }
+    },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=",
+      "dev": true
+    },
+    "node_modules/utils-merge": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+      "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=",
+      "dev": true
+    },
+    "node_modules/uuid": {
+      "version": "3.3.2",
+      "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz",
+      "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==",
+      "dev": true
+    },
+    "node_modules/verror": {
+      "version": "1.10.0",
+      "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
+      "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=",
+      "dev": true,
+      "dependencies": {
+        "assert-plus": "^1.0.0",
+        "core-util-is": "1.0.2",
+        "extsprintf": "^1.2.0"
+      }
+    },
+    "node_modules/void-elements": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz",
+      "integrity": "sha1-wGavtYK7HLQSjWDqkjkulNXp2+w=",
+      "dev": true
+    },
+    "node_modules/which": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/which/-/which-1.3.0.tgz",
+      "integrity": "sha512-xcJpopdamTuY5duC/KnTTNBraPK54YwpenP4lzxU8H91GudWpFv38u0CKjclE1Wi2EH2EDz5LRcHcKbCIzqGyg==",
+      "dev": true,
+      "dependencies": {
+        "isexe": "^2.0.0"
+      }
+    },
+    "node_modules/which-module": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz",
+      "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=",
+      "dev": true
+    },
+    "node_modules/wrap-ansi": {
+      "version": "6.2.0",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+      "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+      "dev": true,
+      "dependencies": {
+        "ansi-styles": "^4.0.0",
+        "string-width": "^4.1.0",
+        "strip-ansi": "^6.0.0"
+      }
+    },
+    "node_modules/wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
+      "dev": true
+    },
+    "node_modules/xmlhttprequest-ssl": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz",
+      "integrity": "sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4=",
+      "dev": true
+    },
+    "node_modules/y18n": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.1.tgz",
+      "integrity": "sha512-wNcy4NvjMYL8gogWWYAO7ZFWFfHcbdbE57tZO8e4cbpj8tfUcwrwqSl3ad8HxpYWCdXcJUCeKKZS62Av1affwQ==",
+      "dev": true
+    },
+    "node_modules/yallist": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz",
+      "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=",
+      "dev": true
+    },
+    "node_modules/yargs": {
+      "version": "15.3.1",
+      "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.3.1.tgz",
+      "integrity": "sha512-92O1HWEjw27sBfgmXiixJWT5hRBp2eobqXicLtPBIDBhYB+1HpwZlXmbW2luivBJHBzki+7VyCLRtAkScbTBQA==",
+      "dev": true,
+      "dependencies": {
+        "cliui": "^6.0.0",
+        "decamelize": "^1.2.0",
+        "find-up": "^4.1.0",
+        "get-caller-file": "^2.0.1",
+        "require-directory": "^2.1.1",
+        "require-main-filename": "^2.0.0",
+        "set-blocking": "^2.0.0",
+        "string-width": "^4.2.0",
+        "which-module": "^2.0.0",
+        "y18n": "^4.0.0",
+        "yargs-parser": "^18.1.1"
+      }
+    },
+    "node_modules/yargs-parser": {
+      "version": "18.1.3",
+      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+      "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+      "dev": true,
+      "dependencies": {
+        "camelcase": "^5.0.0",
+        "decamelize": "^1.2.0"
+      }
+    },
+    "node_modules/yauzl": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.4.1.tgz",
+      "integrity": "sha1-lSj0QtqxsihOWLQ3m7GU4i4MQAU=",
+      "dev": true,
+      "dependencies": {
+        "fd-slicer": "~1.0.1"
+      }
+    },
+    "node_modules/yeast": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/yeast/-/yeast-0.1.2.tgz",
+      "integrity": "sha1-AI4G2AlDIMNy28L47XagymyKxBk=",
+      "dev": true
+    }
+  },
   "dependencies": {
     "@types/color-name": {
       "version": "1.1.1",
         }
       }
     },
+    "string_decoder": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+      "dev": true,
+      "requires": {
+        "safe-buffer": "~5.1.0"
+      }
+    },
     "string-width": {
       "version": "4.2.0",
       "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz",
         "strip-ansi": "^6.0.0"
       }
     },
-    "string_decoder": {
-      "version": "1.1.1",
-      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
-      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
-      "dev": true,
-      "requires": {
-        "safe-buffer": "~5.1.0"
-      }
-    },
     "strip-ansi": {
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",